diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 4ccae5cdaa..5472f86d88 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,8 +1,8 @@ { - "chat-client": "0.1.28", + "chat-client": "0.1.29", "core/aws-lsp-core": "0.0.12", "server/aws-lsp-antlr4": "0.1.16", - "server/aws-lsp-codewhisperer": "0.0.69", + "server/aws-lsp-codewhisperer": "0.0.70", "server/aws-lsp-json": "0.1.16", "server/aws-lsp-partiql": "0.0.15", "server/aws-lsp-yaml": "0.1.16" diff --git a/chat-client/CHANGELOG.md b/chat-client/CHANGELOG.md index f0e560db9d..97042b92be 100644 --- a/chat-client/CHANGELOG.md +++ b/chat-client/CHANGELOG.md @@ -1,5 +1,13 @@ # Changelog +## [0.1.29](https://github.com/aws/language-servers/compare/chat-client/v0.1.28...chat-client/v0.1.29) (2025-07-29) + + +### Features + +* **amazonq:** redirect /review, rename CodeReview tool, emit metrics, modify prompts ([#1964](https://github.com/aws/language-servers/issues/1964)) ([ad8e2db](https://github.com/aws/language-servers/commit/ad8e2db77e34f369fef9af71cdda2d3522f555c6)) +* **amazonq:** revert auto-approve ([#2002](https://github.com/aws/language-servers/issues/2002)) ([c8181f7](https://github.com/aws/language-servers/commit/c8181f7a1de224dfcc7a77cd0bfc905fa1018372)) + ## [0.1.28](https://github.com/aws/language-servers/compare/chat-client/v0.1.27...chat-client/v0.1.28) (2025-07-23) diff --git a/chat-client/package.json b/chat-client/package.json index 931091b705..b467e237c7 100644 --- a/chat-client/package.json +++ b/chat-client/package.json @@ -1,6 +1,6 @@ { "name": "@aws/chat-client", - "version": "0.1.28", + "version": "0.1.29", "description": "AWS Chat Client", "main": "out/index.js", "repository": { @@ -27,7 +27,7 @@ "@aws/chat-client-ui-types": "^0.1.56", "@aws/language-server-runtimes": "^0.2.114", "@aws/language-server-runtimes-types": "^0.1.50", - "@aws/mynah-ui": "^4.36.0" + "@aws/mynah-ui": "^4.36.2" }, "devDependencies": { "@types/jsdom": "^21.1.6", diff --git a/chat-client/src/client/mcpMynahUi.ts b/chat-client/src/client/mcpMynahUi.ts index 114f5e742c..3e64336876 100644 --- a/chat-client/src/client/mcpMynahUi.ts +++ b/chat-client/src/client/mcpMynahUi.ts @@ -29,6 +29,7 @@ export const MCP_IDS = { EDIT: 'edit-mcp', SAVE: 'save-mcp', CANCEL: 'cancel-mcp', + CHANGE_TRANSPORT: 'change-transport', // Permission actions PERMISSION_CHANGE: 'mcp-permission-change', @@ -448,12 +449,26 @@ export class McpMynahUi { const typedParams = params as McpServerParams if (params.id === MCP_IDS.ADD_NEW || params.id === MCP_IDS.EDIT || params.id === MCP_IDS.FIX_SERVER) { this.mynahUi.toggleSplashLoader(false) + + const uiFilters = (typedParams.filterOptions ?? []) as McpFilterOption[] + const initial = uiFilters.find(f => f.id === 'transport') + let _lastTransport = initial?.value as unknown as string + const detailedList = this.createAddMcpServerDetailedList(typedParams) const events = { onBackClick: () => { this.messager.onListMcpServers() }, + onFilterValueChange: (filterValues: Record) => { + const newTransport = filterValues?.transport + if (!newTransport || newTransport === _lastTransport) { + return + } + + _lastTransport = newTransport + this.messager.onMcpServerClick(MCP_IDS.CHANGE_TRANSPORT, filterValues.name, filterValues) + }, onFilterActionClick: ( actionParams: McpServerClickResult, filterValues?: Record, diff --git a/chat-client/src/client/tabs/tabFactory.ts b/chat-client/src/client/tabs/tabFactory.ts index eea8536d25..1757cbdf90 100644 --- a/chat-client/src/client/tabs/tabFactory.ts +++ b/chat-client/src/client/tabs/tabFactory.ts @@ -187,7 +187,7 @@ export class TabFactory { tabBarButtons.push({ id: McpServerTabButtonId, icon: MynahIcons.TOOLS, - description: 'Configure MCP servers and Built-in tools', + description: 'Configure MCP servers', }) } diff --git a/package-lock.json b/package-lock.json index f40f7ede48..003f4f5902 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,8 @@ "typescript": "^5.8.2" }, "devDependencies": { + "@aws-sdk/client-iam": "^3.840.0", + "@aws-sdk/client-sts": "^3.840.0", "@commitlint/cli": "^19.8.0", "@commitlint/config-conventional": "^19.8.0", "@types/ignore-walk": "^4.0.3", @@ -249,13 +251,13 @@ }, "chat-client": { "name": "@aws/chat-client", - "version": "0.1.28", + "version": "0.1.29", "license": "Apache-2.0", "dependencies": { "@aws/chat-client-ui-types": "^0.1.56", "@aws/language-server-runtimes": "^0.2.114", "@aws/language-server-runtimes-types": "^0.1.50", - "@aws/mynah-ui": "^4.36.0" + "@aws/mynah-ui": "^4.36.2" }, "devDependencies": { "@types/jsdom": "^21.1.6", @@ -1273,44 +1275,1092 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/client-iam": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-iam/-/client-iam-3.840.0.tgz", + "integrity": "sha512-+HWqpTwXQYhFzgwfjGFHfo+a0mRQwYq29BEYlgfcydo8UOApc1oxsVmEmnYh2nbukaefUkOaMDb1xORybsE6Lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.840.0", + "@aws-sdk/credential-provider-node": "3.840.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.840.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.840.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.840.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.6.0", + "@smithy/fetch-http-handler": "^5.0.4", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.13", + "@smithy/middleware-retry": "^4.1.14", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.0.6", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.5", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.21", + "@smithy/util-defaults-mode-node": "^4.0.21", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "@smithy/util-waiter": "^4.0.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/client-sso": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.840.0.tgz", + "integrity": "sha512-3Zp+FWN2hhmKdpS0Ragi5V2ZPsZNScE3jlbgoJjzjI/roHZqO+e3/+XFN4TlM0DsPKYJNp+1TAjmhxN6rOnfYA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.840.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.840.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.840.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.840.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.6.0", + "@smithy/fetch-http-handler": "^5.0.4", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.13", + "@smithy/middleware-retry": "^4.1.14", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.0.6", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.5", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.21", + "@smithy/util-defaults-mode-node": "^4.0.21", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/core": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.840.0.tgz", + "integrity": "sha512-x3Zgb39tF1h2XpU+yA4OAAQlW6LVEfXNlSedSYJ7HGKXqA/E9h3rWQVpYfhXXVVsLdYXdNw5KBUkoAoruoZSZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@aws-sdk/xml-builder": "3.821.0", + "@smithy/core": "^3.6.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/signature-v4": "^5.1.2", + "@smithy/smithy-client": "^4.4.5", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-utf8": "^4.0.0", + "fast-xml-parser": "4.4.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.840.0.tgz", + "integrity": "sha512-EzF6VcJK7XvQ/G15AVEfJzN2mNXU8fcVpXo4bRyr1S6t2q5zx6UPH/XjDbn18xyUmOq01t+r8gG+TmHEVo18fA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.840.0.tgz", + "integrity": "sha512-wbnUiPGLVea6mXbUh04fu+VJmGkQvmToPeTYdHE8eRZq3NRDi3t3WltT+jArLBKD/4NppRpMjf2ju4coMCz91g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@smithy/fetch-http-handler": "^5.0.4", + "@smithy/node-http-handler": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.5", + "@smithy/types": "^4.3.1", + "@smithy/util-stream": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.840.0.tgz", + "integrity": "sha512-7F290BsWydShHb+7InXd+IjJc3mlEIm9I0R57F/Pjl1xZB69MdkhVGCnuETWoBt4g53ktJd6NEjzm/iAhFXFmw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.840.0", + "@aws-sdk/credential-provider-env": "3.840.0", + "@aws-sdk/credential-provider-http": "3.840.0", + "@aws-sdk/credential-provider-process": "3.840.0", + "@aws-sdk/credential-provider-sso": "3.840.0", + "@aws-sdk/credential-provider-web-identity": "3.840.0", + "@aws-sdk/nested-clients": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.840.0.tgz", + "integrity": "sha512-KufP8JnxA31wxklLm63evUPSFApGcH8X86z3mv9SRbpCm5ycgWIGVCTXpTOdgq6rPZrwT9pftzv2/b4mV/9clg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.840.0", + "@aws-sdk/credential-provider-http": "3.840.0", + "@aws-sdk/credential-provider-ini": "3.840.0", + "@aws-sdk/credential-provider-process": "3.840.0", + "@aws-sdk/credential-provider-sso": "3.840.0", + "@aws-sdk/credential-provider-web-identity": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.840.0.tgz", + "integrity": "sha512-HkDQWHy8tCI4A0Ps2NVtuVYMv9cB4y/IuD/TdOsqeRIAT12h8jDb98BwQPNLAImAOwOWzZJ8Cu0xtSpX7CQhMw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.840.0.tgz", + "integrity": "sha512-2qgdtdd6R0Z1y0KL8gzzwFUGmhBHSUx4zy85L2XV1CXhpRNwV71SVWJqLDVV5RVWVf9mg50Pm3AWrUC0xb0pcA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-sso": "3.840.0", + "@aws-sdk/core": "3.840.0", + "@aws-sdk/token-providers": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.840.0.tgz", + "integrity": "sha512-dpEeVXG8uNZSmVXReE4WP0lwoioX2gstk4RnUgrdUE3YaPq8A+hJiVAyc3h+cjDeIqfbsQbZm9qFetKC2LF9dQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.840.0", + "@aws-sdk/nested-clients": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.840.0.tgz", + "integrity": "sha512-ub+hXJAbAje94+Ya6c6eL7sYujoE8D4Bumu1NUI8TXjUhVVn0HzVWQjpRLshdLsUp1AW7XyeJaxyajRaJQ8+Xg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-logger": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.840.0.tgz", + "integrity": "sha512-lSV8FvjpdllpGaRspywss4CtXV8M7NNNH+2/j86vMH+YCOZ6fu2T/TyFd/tHwZ92vDfHctWkRbQxg0bagqwovA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.840.0.tgz", + "integrity": "sha512-Gu7lGDyfddyhIkj1Z1JtrY5NHb5+x/CRiB87GjaSrKxkDaydtX2CU977JIABtt69l9wLbcGDIQ+W0uJ5xPof7g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.840.0.tgz", + "integrity": "sha512-hiiMf7BP5ZkAFAvWRcK67Mw/g55ar7OCrvrynC92hunx/xhMkrgSLM0EXIZ1oTn3uql9kH/qqGF0nqsK6K555A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.840.0", + "@smithy/core": "^3.6.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/nested-clients": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.840.0.tgz", + "integrity": "sha512-LXYYo9+n4hRqnRSIMXLBb+BLz+cEmjMtTudwK1BF6Bn2RfdDv29KuyeDRrPCS3TwKl7ZKmXUmE9n5UuHAPfBpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.840.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.840.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.840.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.840.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.6.0", + "@smithy/fetch-http-handler": "^5.0.4", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.13", + "@smithy/middleware-retry": "^4.1.14", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.0.6", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.5", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.21", + "@smithy/util-defaults-mode-node": "^4.0.21", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.840.0.tgz", + "integrity": "sha512-Qjnxd/yDv9KpIMWr90ZDPtRj0v75AqGC92Lm9+oHXZ8p1MjG5JE2CW0HL8JRgK9iKzgKBL7pPQRXI8FkvEVfrA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/token-providers": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.840.0.tgz", + "integrity": "sha512-6BuTOLTXvmgwjK7ve7aTg9JaWFdM5UoMolLVPMyh3wTv9Ufalh8oklxYHUBIxsKkBGO2WiHXytveuxH6tAgTYg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "3.840.0", + "@aws-sdk/nested-clients": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/types": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.840.0.tgz", + "integrity": "sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/util-endpoints": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.840.0.tgz", + "integrity": "sha512-eqE9ROdg/Kk0rj3poutyRCFauPDXIf/WSvCqFiRDDVi6QOnCv/M0g2XW8/jSvkJlOyaXkNCptapIp6BeeFFGYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "@smithy/util-endpoints": "^3.0.6", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.840.0.tgz", + "integrity": "sha512-JdyZM3EhhL4PqwFpttZu1afDpPJCCc3eyZOLi+srpX11LsGj6sThf47TYQN75HT1CarZ7cCdQHGzP2uy3/xHfQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.840.0.tgz", + "integrity": "sha512-Fy5JUEDQU1tPm2Yw/YqRYYc27W5+QD/J4mYvQvdWjUGZLB5q3eLFMGD35Uc28ZFoGMufPr4OCxK/bRfWROBRHQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@smithy/abort-controller": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.4.tgz", + "integrity": "sha512-gJnEjZMvigPDQWHrW3oPrFhQtkrgqBkyjj3pCIdF3A5M6vsZODG93KNlfJprv6bp4245bdT32fsHK4kkH3KYDA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@smithy/node-http-handler": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.0.6.tgz", + "integrity": "sha512-NqbmSz7AW2rvw4kXhKGrYTiJVDHnMsFnX4i+/FzcZAfbOBauPYs2ekuECkSbtqaxETLLTu9Rl/ex6+I2BKErPA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@smithy/abort-controller": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/querystring-builder": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-iam/node_modules/@smithy/types": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.1.tgz", + "integrity": "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/client-s3": { "version": "3.839.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.839.0.tgz", - "integrity": "sha512-7zDInY+qltKxeG+9d/97nbs+FWINcAi5bChBrleUQkuQ/dA9pSP1URo/6JlVzD2Ejvksm+hVK6z3VUWZaIAVOw==", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-s3/-/client-s3-3.839.0.tgz", + "integrity": "sha512-7zDInY+qltKxeG+9d/97nbs+FWINcAi5bChBrleUQkuQ/dA9pSP1URo/6JlVzD2Ejvksm+hVK6z3VUWZaIAVOw==", + "dependencies": { + "@aws-crypto/sha1-browser": "5.2.0", + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.839.0", + "@aws-sdk/credential-provider-node": "3.839.0", + "@aws-sdk/middleware-bucket-endpoint": "3.830.0", + "@aws-sdk/middleware-expect-continue": "3.821.0", + "@aws-sdk/middleware-flexible-checksums": "3.839.0", + "@aws-sdk/middleware-host-header": "3.821.0", + "@aws-sdk/middleware-location-constraint": "3.821.0", + "@aws-sdk/middleware-logger": "3.821.0", + "@aws-sdk/middleware-recursion-detection": "3.821.0", + "@aws-sdk/middleware-sdk-s3": "3.839.0", + "@aws-sdk/middleware-ssec": "3.821.0", + "@aws-sdk/middleware-user-agent": "3.839.0", + "@aws-sdk/region-config-resolver": "3.821.0", + "@aws-sdk/signature-v4-multi-region": "3.839.0", + "@aws-sdk/types": "3.821.0", + "@aws-sdk/util-endpoints": "3.828.0", + "@aws-sdk/util-user-agent-browser": "3.821.0", + "@aws-sdk/util-user-agent-node": "3.839.0", + "@aws-sdk/xml-builder": "3.821.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.6.0", + "@smithy/eventstream-serde-browser": "^4.0.4", + "@smithy/eventstream-serde-config-resolver": "^4.1.2", + "@smithy/eventstream-serde-node": "^4.0.4", + "@smithy/fetch-http-handler": "^5.0.4", + "@smithy/hash-blob-browser": "^4.0.4", + "@smithy/hash-node": "^4.0.4", + "@smithy/hash-stream-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/md5-js": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.13", + "@smithy/middleware-retry": "^4.1.14", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.0.6", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.5", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.21", + "@smithy/util-defaults-mode-node": "^4.0.21", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-stream": "^4.2.2", + "@smithy/util-utf8": "^4.0.0", + "@smithy/util-waiter": "^4.0.6", + "@types/uuid": "^9.0.1", + "tslib": "^2.6.2", + "uuid": "^9.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/client-sso": { + "version": "3.839.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.839.0.tgz", + "integrity": "sha512-AZABysUhbfcwXVlMo97/vwHgsfJNF81wypCAowpqAJkSjP2KrqsqHpb71/RoR2w8JGmEnBBXRD4wIxDhnmifWg==", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.839.0", + "@aws-sdk/middleware-host-header": "3.821.0", + "@aws-sdk/middleware-logger": "3.821.0", + "@aws-sdk/middleware-recursion-detection": "3.821.0", + "@aws-sdk/middleware-user-agent": "3.839.0", + "@aws-sdk/region-config-resolver": "3.821.0", + "@aws-sdk/types": "3.821.0", + "@aws-sdk/util-endpoints": "3.828.0", + "@aws-sdk/util-user-agent-browser": "3.821.0", + "@aws-sdk/util-user-agent-node": "3.839.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.6.0", + "@smithy/fetch-http-handler": "^5.0.4", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.13", + "@smithy/middleware-retry": "^4.1.14", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.0.6", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.5", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.21", + "@smithy/util-defaults-mode-node": "^4.0.21", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/core": { + "version": "3.839.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.839.0.tgz", + "integrity": "sha512-KdwL5RaK7eUIlOpdOoZ5u+2t4X1rdX/MTZgz3IV/aBzjVUoGsp+uUnbyqXomLQSUitPHp72EE/NHDsvWW/IHvQ==", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@aws-sdk/xml-builder": "3.821.0", + "@smithy/core": "^3.6.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/signature-v4": "^5.1.2", + "@smithy/smithy-client": "^4.4.5", + "@smithy/types": "^4.3.1", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-utf8": "^4.0.0", + "fast-xml-parser": "4.4.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.839.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.839.0.tgz", + "integrity": "sha512-cWTadewPPz1OvObZJB+olrgh8VwcgIVcT293ZUT9V0CMF0UU7QaPwJP7uNXcNxltTh+sk1yhjH4UlcnJigZZbA==", + "dependencies": { + "@aws-sdk/core": "3.839.0", + "@aws-sdk/types": "3.821.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.839.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.839.0.tgz", + "integrity": "sha512-fv0BZwrDhWDju4D1MCLT4I2aPjr0dVQ6P+MpqvcGNOA41Oa9UdRhYTV5iuy5NLXzIzoCmnS+XfSq5Kbsf6//xw==", + "dependencies": { + "@aws-sdk/core": "3.839.0", + "@aws-sdk/types": "3.821.0", + "@smithy/fetch-http-handler": "^5.0.4", + "@smithy/node-http-handler": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.5", + "@smithy/types": "^4.3.1", + "@smithy/util-stream": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.839.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.839.0.tgz", + "integrity": "sha512-GHm0hF4CiDxIDR7TauMaA6iI55uuSqRxMBcqTAHaTPm6+h1A+MS+ysQMxZ+Jvwtoy8WmfTIGrJVxSCw0sK2hvA==", + "dependencies": { + "@aws-sdk/core": "3.839.0", + "@aws-sdk/credential-provider-env": "3.839.0", + "@aws-sdk/credential-provider-http": "3.839.0", + "@aws-sdk/credential-provider-process": "3.839.0", + "@aws-sdk/credential-provider-sso": "3.839.0", + "@aws-sdk/credential-provider-web-identity": "3.839.0", + "@aws-sdk/nested-clients": "3.839.0", + "@aws-sdk/types": "3.821.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.839.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.839.0.tgz", + "integrity": "sha512-7bR+U2h+ft0V8chyeu9Bh/pvau4ZkQMeRt5f0dAULoepZQ77QQVRP4H04yJPTg9DCtqbVULQ3uf5YOp1/08vQw==", + "dependencies": { + "@aws-sdk/credential-provider-env": "3.839.0", + "@aws-sdk/credential-provider-http": "3.839.0", + "@aws-sdk/credential-provider-ini": "3.839.0", + "@aws-sdk/credential-provider-process": "3.839.0", + "@aws-sdk/credential-provider-sso": "3.839.0", + "@aws-sdk/credential-provider-web-identity": "3.839.0", + "@aws-sdk/types": "3.821.0", + "@smithy/credential-provider-imds": "^4.0.6", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.839.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.839.0.tgz", + "integrity": "sha512-qShpekjociUZ+isyQNa0P7jo+0q3N2+0eJDg8SGyP6K6hHTcGfiqxTDps+IKl6NreCPhZCBzyI9mWkP0xSDR6g==", + "dependencies": { + "@aws-sdk/core": "3.839.0", + "@aws-sdk/types": "3.821.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.839.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.839.0.tgz", + "integrity": "sha512-w10zBLHhU8SBQcdrSPMI02haLoRGZg+gP7mH/Er8VhIXfHefbr7o4NirmB0hwdw/YAH8MLlC9jj7c2SJlsNhYA==", + "dependencies": { + "@aws-sdk/client-sso": "3.839.0", + "@aws-sdk/core": "3.839.0", + "@aws-sdk/token-providers": "3.839.0", + "@aws-sdk/types": "3.821.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.839.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.839.0.tgz", + "integrity": "sha512-EvqTc7J1kgmiuxknpCp1S60hyMQvmKxsI5uXzQtcogl/N55rxiXEqnCLI5q6p33q91PJegrcMCM5Q17Afhm5qA==", + "dependencies": { + "@aws-sdk/core": "3.839.0", + "@aws-sdk/nested-clients": "3.839.0", + "@aws-sdk/types": "3.821.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.821.0.tgz", + "integrity": "sha512-xSMR+sopSeWGx5/4pAGhhfMvGBHioVBbqGvDs6pG64xfNwM5vq5s5v6D04e2i+uSTj4qGa71dLUs5I0UzAK3sw==", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-logger": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.821.0.tgz", + "integrity": "sha512-0cvI0ipf2tGx7fXYEEN5fBeZDz2RnHyb9xftSgUsEq7NBxjV0yTZfLJw6Za5rjE6snC80dRN8+bTNR1tuG89zA==", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.821.0.tgz", + "integrity": "sha512-efmaifbhBoqKG3bAoEfDdcM8hn1psF+4qa7ykWuYmfmah59JBeqHLfz5W9m9JoTwoKPkFcVLWZxnyZzAnVBOIg==", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.839.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.839.0.tgz", + "integrity": "sha512-2u74uRM1JWq6Sf7+3YpjejPM9YkomGt4kWhrmooIBEq1k5r2GTbkH7pNCxBQwBueXM21jAGVDxxeClpTx+5hig==", + "dependencies": { + "@aws-sdk/core": "3.839.0", + "@aws-sdk/types": "3.821.0", + "@aws-sdk/util-endpoints": "3.828.0", + "@smithy/core": "^3.6.0", + "@smithy/protocol-http": "^5.1.2", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/nested-clients": { + "version": "3.839.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.839.0.tgz", + "integrity": "sha512-Glic0pg2THYP3aRhJORwJJBe1JLtJoEdWV/MFZNyzCklfMwEzpWtZAyxy+tQyFmMeW50uBAnh2R0jhMMcf257w==", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.839.0", + "@aws-sdk/middleware-host-header": "3.821.0", + "@aws-sdk/middleware-logger": "3.821.0", + "@aws-sdk/middleware-recursion-detection": "3.821.0", + "@aws-sdk/middleware-user-agent": "3.839.0", + "@aws-sdk/region-config-resolver": "3.821.0", + "@aws-sdk/types": "3.821.0", + "@aws-sdk/util-endpoints": "3.828.0", + "@aws-sdk/util-user-agent-browser": "3.821.0", + "@aws-sdk/util-user-agent-node": "3.839.0", + "@smithy/config-resolver": "^4.1.4", + "@smithy/core": "^3.6.0", + "@smithy/fetch-http-handler": "^5.0.4", + "@smithy/hash-node": "^4.0.4", + "@smithy/invalid-dependency": "^4.0.4", + "@smithy/middleware-content-length": "^4.0.4", + "@smithy/middleware-endpoint": "^4.1.13", + "@smithy/middleware-retry": "^4.1.14", + "@smithy/middleware-serde": "^4.0.8", + "@smithy/middleware-stack": "^4.0.4", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/node-http-handler": "^4.0.6", + "@smithy/protocol-http": "^5.1.2", + "@smithy/smithy-client": "^4.4.5", + "@smithy/types": "^4.3.1", + "@smithy/url-parser": "^4.0.4", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.21", + "@smithy/util-defaults-mode-node": "^4.0.21", + "@smithy/util-endpoints": "^3.0.6", + "@smithy/util-middleware": "^4.0.4", + "@smithy/util-retry": "^4.0.6", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.821.0.tgz", + "integrity": "sha512-t8og+lRCIIy5nlId0bScNpCkif8sc0LhmtaKsbm0ZPm3sCa/WhCbSZibjbZ28FNjVCV+p0D9RYZx0VDDbtWyjw==", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "@smithy/util-config-provider": "^4.0.0", + "@smithy/util-middleware": "^4.0.4", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.821.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.821.0.tgz", + "integrity": "sha512-irWZHyM0Jr1xhC+38OuZ7JB6OXMLPZlj48thElpsO1ZSLRkLZx5+I7VV6k3sp2yZ7BYbKz/G2ojSv4wdm7XTLw==", + "dependencies": { + "@aws-sdk/types": "3.821.0", + "@smithy/types": "^4.3.1", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.839.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.839.0.tgz", + "integrity": "sha512-MuunkIG1bJVMtTH7MbjXOrhHleU5wjHz5eCAUc6vj7M9rwol71nqjj9b8RLnkO5gsJcKc29Qk8iV6xQuzKWNMw==", + "dependencies": { + "@aws-sdk/middleware-user-agent": "3.839.0", + "@aws-sdk/types": "3.821.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@smithy/abort-controller": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.4.tgz", + "integrity": "sha512-gJnEjZMvigPDQWHrW3oPrFhQtkrgqBkyjj3pCIdF3A5M6vsZODG93KNlfJprv6bp4245bdT32fsHK4kkH3KYDA==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@smithy/node-http-handler": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.0.6.tgz", + "integrity": "sha512-NqbmSz7AW2rvw4kXhKGrYTiJVDHnMsFnX4i+/FzcZAfbOBauPYs2ekuECkSbtqaxETLLTu9Rl/ex6+I2BKErPA==", + "dependencies": { + "@smithy/abort-controller": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/querystring-builder": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/@smithy/types": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.1.tgz", + "integrity": "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-s3/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/@aws-sdk/client-sso": { + "version": "3.731.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.731.0.tgz", + "integrity": "sha512-O4C/UYGgqMsBg21MMApFdgyh8BX568hQhbdoNFmRVTBoSnCZ3w+H4a1wBPX4Gyl0NX+ab6Xxo9rId8HiyPXJ0A==", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "3.731.0", + "@aws-sdk/middleware-host-header": "3.731.0", + "@aws-sdk/middleware-logger": "3.731.0", + "@aws-sdk/middleware-recursion-detection": "3.731.0", + "@aws-sdk/middleware-user-agent": "3.731.0", + "@aws-sdk/region-config-resolver": "3.731.0", + "@aws-sdk/types": "3.731.0", + "@aws-sdk/util-endpoints": "3.731.0", + "@aws-sdk/util-user-agent-browser": "3.731.0", + "@aws-sdk/util-user-agent-node": "3.731.0", + "@smithy/config-resolver": "^4.0.0", + "@smithy/core": "^3.0.0", + "@smithy/fetch-http-handler": "^5.0.0", + "@smithy/hash-node": "^4.0.0", + "@smithy/invalid-dependency": "^4.0.0", + "@smithy/middleware-content-length": "^4.0.0", + "@smithy/middleware-endpoint": "^4.0.0", + "@smithy/middleware-retry": "^4.0.0", + "@smithy/middleware-serde": "^4.0.0", + "@smithy/middleware-stack": "^4.0.0", + "@smithy/node-config-provider": "^4.0.0", + "@smithy/node-http-handler": "^4.0.0", + "@smithy/protocol-http": "^5.0.0", + "@smithy/smithy-client": "^4.0.0", + "@smithy/types": "^4.0.0", + "@smithy/url-parser": "^4.0.0", + "@smithy/util-base64": "^4.0.0", + "@smithy/util-body-length-browser": "^4.0.0", + "@smithy/util-body-length-node": "^4.0.0", + "@smithy/util-defaults-mode-browser": "^4.0.0", + "@smithy/util-defaults-mode-node": "^4.0.0", + "@smithy/util-endpoints": "^3.0.0", + "@smithy/util-middleware": "^4.0.0", + "@smithy/util-retry": "^4.0.0", + "@smithy/util-utf8": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sso-oidc": { + "version": "3.839.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.839.0.tgz", + "integrity": "sha512-7QnMApYfQBT441YkxObxt1hZ8TdqZH7h0NdYsvbLdEqGROXBDDT+Wq7ZVfsnKjuVUGQ/t75bIqFn7M8cdyESfA==", "dependencies": { - "@aws-crypto/sha1-browser": "5.2.0", "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "3.839.0", "@aws-sdk/credential-provider-node": "3.839.0", - "@aws-sdk/middleware-bucket-endpoint": "3.830.0", - "@aws-sdk/middleware-expect-continue": "3.821.0", - "@aws-sdk/middleware-flexible-checksums": "3.839.0", "@aws-sdk/middleware-host-header": "3.821.0", - "@aws-sdk/middleware-location-constraint": "3.821.0", "@aws-sdk/middleware-logger": "3.821.0", "@aws-sdk/middleware-recursion-detection": "3.821.0", - "@aws-sdk/middleware-sdk-s3": "3.839.0", - "@aws-sdk/middleware-ssec": "3.821.0", "@aws-sdk/middleware-user-agent": "3.839.0", "@aws-sdk/region-config-resolver": "3.821.0", - "@aws-sdk/signature-v4-multi-region": "3.839.0", "@aws-sdk/types": "3.821.0", "@aws-sdk/util-endpoints": "3.828.0", "@aws-sdk/util-user-agent-browser": "3.821.0", "@aws-sdk/util-user-agent-node": "3.839.0", - "@aws-sdk/xml-builder": "3.821.0", "@smithy/config-resolver": "^4.1.4", "@smithy/core": "^3.6.0", - "@smithy/eventstream-serde-browser": "^4.0.4", - "@smithy/eventstream-serde-config-resolver": "^4.1.2", - "@smithy/eventstream-serde-node": "^4.0.4", "@smithy/fetch-http-handler": "^5.0.4", - "@smithy/hash-blob-browser": "^4.0.4", "@smithy/hash-node": "^4.0.4", - "@smithy/hash-stream-node": "^4.0.4", "@smithy/invalid-dependency": "^4.0.4", - "@smithy/md5-js": "^4.0.4", "@smithy/middleware-content-length": "^4.0.4", "@smithy/middleware-endpoint": "^4.1.13", "@smithy/middleware-retry": "^4.1.14", @@ -1330,18 +2380,14 @@ "@smithy/util-endpoints": "^3.0.6", "@smithy/util-middleware": "^4.0.4", "@smithy/util-retry": "^4.0.6", - "@smithy/util-stream": "^4.2.2", "@smithy/util-utf8": "^4.0.0", - "@smithy/util-waiter": "^4.0.6", - "@types/uuid": "^9.0.1", - "tslib": "^2.6.2", - "uuid": "^9.0.1" + "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/client-sso": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/client-sso": { "version": "3.839.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.839.0.tgz", "integrity": "sha512-AZABysUhbfcwXVlMo97/vwHgsfJNF81wypCAowpqAJkSjP2KrqsqHpb71/RoR2w8JGmEnBBXRD4wIxDhnmifWg==", @@ -1389,7 +2435,7 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/core": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/core": { "version": "3.839.0", "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.839.0.tgz", "integrity": "sha512-KdwL5RaK7eUIlOpdOoZ5u+2t4X1rdX/MTZgz3IV/aBzjVUoGsp+uUnbyqXomLQSUitPHp72EE/NHDsvWW/IHvQ==", @@ -1414,7 +2460,7 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-env": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/credential-provider-env": { "version": "3.839.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.839.0.tgz", "integrity": "sha512-cWTadewPPz1OvObZJB+olrgh8VwcgIVcT293ZUT9V0CMF0UU7QaPwJP7uNXcNxltTh+sk1yhjH4UlcnJigZZbA==", @@ -1429,7 +2475,7 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-http": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/credential-provider-http": { "version": "3.839.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.839.0.tgz", "integrity": "sha512-fv0BZwrDhWDju4D1MCLT4I2aPjr0dVQ6P+MpqvcGNOA41Oa9UdRhYTV5iuy5NLXzIzoCmnS+XfSq5Kbsf6//xw==", @@ -1449,7 +2495,7 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-ini": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/credential-provider-ini": { "version": "3.839.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.839.0.tgz", "integrity": "sha512-GHm0hF4CiDxIDR7TauMaA6iI55uuSqRxMBcqTAHaTPm6+h1A+MS+ysQMxZ+Jvwtoy8WmfTIGrJVxSCw0sK2hvA==", @@ -1472,7 +2518,7 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-node": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/credential-provider-node": { "version": "3.839.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.839.0.tgz", "integrity": "sha512-7bR+U2h+ft0V8chyeu9Bh/pvau4ZkQMeRt5f0dAULoepZQ77QQVRP4H04yJPTg9DCtqbVULQ3uf5YOp1/08vQw==", @@ -1494,7 +2540,7 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-process": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/credential-provider-process": { "version": "3.839.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.839.0.tgz", "integrity": "sha512-qShpekjociUZ+isyQNa0P7jo+0q3N2+0eJDg8SGyP6K6hHTcGfiqxTDps+IKl6NreCPhZCBzyI9mWkP0xSDR6g==", @@ -1510,7 +2556,7 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-sso": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/credential-provider-sso": { "version": "3.839.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.839.0.tgz", "integrity": "sha512-w10zBLHhU8SBQcdrSPMI02haLoRGZg+gP7mH/Er8VhIXfHefbr7o4NirmB0hwdw/YAH8MLlC9jj7c2SJlsNhYA==", @@ -1528,7 +2574,7 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/credential-provider-web-identity": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/credential-provider-web-identity": { "version": "3.839.0", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.839.0.tgz", "integrity": "sha512-EvqTc7J1kgmiuxknpCp1S60hyMQvmKxsI5uXzQtcogl/N55rxiXEqnCLI5q6p33q91PJegrcMCM5Q17Afhm5qA==", @@ -1544,7 +2590,7 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-host-header": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/middleware-host-header": { "version": "3.821.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.821.0.tgz", "integrity": "sha512-xSMR+sopSeWGx5/4pAGhhfMvGBHioVBbqGvDs6pG64xfNwM5vq5s5v6D04e2i+uSTj4qGa71dLUs5I0UzAK3sw==", @@ -1558,7 +2604,7 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-logger": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/middleware-logger": { "version": "3.821.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.821.0.tgz", "integrity": "sha512-0cvI0ipf2tGx7fXYEEN5fBeZDz2RnHyb9xftSgUsEq7NBxjV0yTZfLJw6Za5rjE6snC80dRN8+bTNR1tuG89zA==", @@ -1571,7 +2617,7 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-recursion-detection": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/middleware-recursion-detection": { "version": "3.821.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.821.0.tgz", "integrity": "sha512-efmaifbhBoqKG3bAoEfDdcM8hn1psF+4qa7ykWuYmfmah59JBeqHLfz5W9m9JoTwoKPkFcVLWZxnyZzAnVBOIg==", @@ -1585,7 +2631,7 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/middleware-user-agent": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/middleware-user-agent": { "version": "3.839.0", "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.839.0.tgz", "integrity": "sha512-2u74uRM1JWq6Sf7+3YpjejPM9YkomGt4kWhrmooIBEq1k5r2GTbkH7pNCxBQwBueXM21jAGVDxxeClpTx+5hig==", @@ -1602,7 +2648,7 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/nested-clients": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/nested-clients": { "version": "3.839.0", "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.839.0.tgz", "integrity": "sha512-Glic0pg2THYP3aRhJORwJJBe1JLtJoEdWV/MFZNyzCklfMwEzpWtZAyxy+tQyFmMeW50uBAnh2R0jhMMcf257w==", @@ -1650,7 +2696,7 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/region-config-resolver": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/region-config-resolver": { "version": "3.821.0", "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.821.0.tgz", "integrity": "sha512-t8og+lRCIIy5nlId0bScNpCkif8sc0LhmtaKsbm0ZPm3sCa/WhCbSZibjbZ28FNjVCV+p0D9RYZx0VDDbtWyjw==", @@ -1666,7 +2712,7 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-user-agent-browser": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/util-user-agent-browser": { "version": "3.821.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.821.0.tgz", "integrity": "sha512-irWZHyM0Jr1xhC+38OuZ7JB6OXMLPZlj48thElpsO1ZSLRkLZx5+I7VV6k3sp2yZ7BYbKz/G2ojSv4wdm7XTLw==", @@ -1677,7 +2723,7 @@ "tslib": "^2.6.2" } }, - "node_modules/@aws-sdk/client-s3/node_modules/@aws-sdk/util-user-agent-node": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/util-user-agent-node": { "version": "3.839.0", "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.839.0.tgz", "integrity": "sha512-MuunkIG1bJVMtTH7MbjXOrhHleU5wjHz5eCAUc6vj7M9rwol71nqjj9b8RLnkO5gsJcKc29Qk8iV6xQuzKWNMw==", @@ -1700,7 +2746,7 @@ } } }, - "node_modules/@aws-sdk/client-s3/node_modules/@smithy/abort-controller": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@smithy/abort-controller": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.4.tgz", "integrity": "sha512-gJnEjZMvigPDQWHrW3oPrFhQtkrgqBkyjj3pCIdF3A5M6vsZODG93KNlfJprv6bp4245bdT32fsHK4kkH3KYDA==", @@ -1712,7 +2758,7 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/@smithy/node-http-handler": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@smithy/node-http-handler": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.0.6.tgz", "integrity": "sha512-NqbmSz7AW2rvw4kXhKGrYTiJVDHnMsFnX4i+/FzcZAfbOBauPYs2ekuECkSbtqaxETLLTu9Rl/ex6+I2BKErPA==", @@ -1727,7 +2773,7 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/@smithy/types": { + "node_modules/@aws-sdk/client-sso-oidc/node_modules/@smithy/types": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.1.tgz", "integrity": "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==", @@ -1738,84 +2784,90 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-s3/node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "bin": { - "uuid": "dist/bin/uuid" + "node_modules/@aws-sdk/client-sso/node_modules/@aws-sdk/types": { + "version": "3.731.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.731.0.tgz", + "integrity": "sha512-NrdkJg6oOUbXR2r9WvHP408CLyvST8cJfp1/jP9pemtjvjPoh6NukbCtiSFdOOb1eryP02CnqQWItfJC1p2Y/Q==", + "dependencies": { + "@smithy/types": "^4.0.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso": { + "node_modules/@aws-sdk/client-sso/node_modules/@aws-sdk/util-endpoints": { "version": "3.731.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.731.0.tgz", - "integrity": "sha512-O4C/UYGgqMsBg21MMApFdgyh8BX568hQhbdoNFmRVTBoSnCZ3w+H4a1wBPX4Gyl0NX+ab6Xxo9rId8HiyPXJ0A==", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.731.0.tgz", + "integrity": "sha512-riztxTAfncFS9yQWcBJffGgOgLoKSa63ph+rxWJxKl6BHAmWEvHICj1qDcVmnWfIcvJ5cClclY75l9qKaUH7rQ==", "dependencies": { - "@aws-crypto/sha256-browser": "5.2.0", - "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.731.0", - "@aws-sdk/middleware-host-header": "3.731.0", - "@aws-sdk/middleware-logger": "3.731.0", - "@aws-sdk/middleware-recursion-detection": "3.731.0", - "@aws-sdk/middleware-user-agent": "3.731.0", - "@aws-sdk/region-config-resolver": "3.731.0", "@aws-sdk/types": "3.731.0", - "@aws-sdk/util-endpoints": "3.731.0", - "@aws-sdk/util-user-agent-browser": "3.731.0", - "@aws-sdk/util-user-agent-node": "3.731.0", - "@smithy/config-resolver": "^4.0.0", - "@smithy/core": "^3.0.0", - "@smithy/fetch-http-handler": "^5.0.0", - "@smithy/hash-node": "^4.0.0", - "@smithy/invalid-dependency": "^4.0.0", - "@smithy/middleware-content-length": "^4.0.0", - "@smithy/middleware-endpoint": "^4.0.0", - "@smithy/middleware-retry": "^4.0.0", - "@smithy/middleware-serde": "^4.0.0", - "@smithy/middleware-stack": "^4.0.0", - "@smithy/node-config-provider": "^4.0.0", - "@smithy/node-http-handler": "^4.0.0", - "@smithy/protocol-http": "^5.0.0", - "@smithy/smithy-client": "^4.0.0", "@smithy/types": "^4.0.0", - "@smithy/url-parser": "^4.0.0", - "@smithy/util-base64": "^4.0.0", - "@smithy/util-body-length-browser": "^4.0.0", - "@smithy/util-body-length-node": "^4.0.0", - "@smithy/util-defaults-mode-browser": "^4.0.0", - "@smithy/util-defaults-mode-node": "^4.0.0", "@smithy/util-endpoints": "^3.0.0", - "@smithy/util-middleware": "^4.0.0", - "@smithy/util-retry": "^4.0.0", - "@smithy/util-utf8": "^4.0.0", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso-oidc": { - "version": "3.839.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso-oidc/-/client-sso-oidc-3.839.0.tgz", - "integrity": "sha512-7QnMApYfQBT441YkxObxt1hZ8TdqZH7h0NdYsvbLdEqGROXBDDT+Wq7ZVfsnKjuVUGQ/t75bIqFn7M8cdyESfA==", + "node_modules/@aws-sdk/client-sso/node_modules/@smithy/abort-controller": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.4.tgz", + "integrity": "sha512-gJnEjZMvigPDQWHrW3oPrFhQtkrgqBkyjj3pCIdF3A5M6vsZODG93KNlfJprv6bp4245bdT32fsHK4kkH3KYDA==", + "dependencies": { + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sso/node_modules/@smithy/node-http-handler": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.0.6.tgz", + "integrity": "sha512-NqbmSz7AW2rvw4kXhKGrYTiJVDHnMsFnX4i+/FzcZAfbOBauPYs2ekuECkSbtqaxETLLTu9Rl/ex6+I2BKErPA==", + "dependencies": { + "@smithy/abort-controller": "^4.0.4", + "@smithy/protocol-http": "^5.1.2", + "@smithy/querystring-builder": "^4.0.4", + "@smithy/types": "^4.3.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sso/node_modules/@smithy/types": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.1.tgz", + "integrity": "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/client-sts": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sts/-/client-sts-3.840.0.tgz", + "integrity": "sha512-h+mu89Wk81Ne+B624GT/pBM5VjuAZueSeQNixhgtQ1QHi6bZzrpz8+lvMSibKO+kXFyQsTLzkyibbxnhLpWQZA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.839.0", - "@aws-sdk/credential-provider-node": "3.839.0", - "@aws-sdk/middleware-host-header": "3.821.0", - "@aws-sdk/middleware-logger": "3.821.0", - "@aws-sdk/middleware-recursion-detection": "3.821.0", - "@aws-sdk/middleware-user-agent": "3.839.0", - "@aws-sdk/region-config-resolver": "3.821.0", - "@aws-sdk/types": "3.821.0", - "@aws-sdk/util-endpoints": "3.828.0", - "@aws-sdk/util-user-agent-browser": "3.821.0", - "@aws-sdk/util-user-agent-node": "3.839.0", + "@aws-sdk/core": "3.840.0", + "@aws-sdk/credential-provider-node": "3.840.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.840.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.840.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.840.0", "@smithy/config-resolver": "^4.1.4", "@smithy/core": "^3.6.0", "@smithy/fetch-http-handler": "^5.0.4", @@ -1847,23 +2899,25 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/client-sso": { - "version": "3.839.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.839.0.tgz", - "integrity": "sha512-AZABysUhbfcwXVlMo97/vwHgsfJNF81wypCAowpqAJkSjP2KrqsqHpb71/RoR2w8JGmEnBBXRD4wIxDhnmifWg==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/client-sso": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-sso/-/client-sso-3.840.0.tgz", + "integrity": "sha512-3Zp+FWN2hhmKdpS0Ragi5V2ZPsZNScE3jlbgoJjzjI/roHZqO+e3/+XFN4TlM0DsPKYJNp+1TAjmhxN6rOnfYA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.839.0", - "@aws-sdk/middleware-host-header": "3.821.0", - "@aws-sdk/middleware-logger": "3.821.0", - "@aws-sdk/middleware-recursion-detection": "3.821.0", - "@aws-sdk/middleware-user-agent": "3.839.0", - "@aws-sdk/region-config-resolver": "3.821.0", - "@aws-sdk/types": "3.821.0", - "@aws-sdk/util-endpoints": "3.828.0", - "@aws-sdk/util-user-agent-browser": "3.821.0", - "@aws-sdk/util-user-agent-node": "3.839.0", + "@aws-sdk/core": "3.840.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.840.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.840.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.840.0", "@smithy/config-resolver": "^4.1.4", "@smithy/core": "^3.6.0", "@smithy/fetch-http-handler": "^5.0.4", @@ -1895,12 +2949,14 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/core": { - "version": "3.839.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.839.0.tgz", - "integrity": "sha512-KdwL5RaK7eUIlOpdOoZ5u+2t4X1rdX/MTZgz3IV/aBzjVUoGsp+uUnbyqXomLQSUitPHp72EE/NHDsvWW/IHvQ==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/core": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.840.0.tgz", + "integrity": "sha512-x3Zgb39tF1h2XpU+yA4OAAQlW6LVEfXNlSedSYJ7HGKXqA/E9h3rWQVpYfhXXVVsLdYXdNw5KBUkoAoruoZSZA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.821.0", + "@aws-sdk/types": "3.840.0", "@aws-sdk/xml-builder": "3.821.0", "@smithy/core": "^3.6.0", "@smithy/node-config-provider": "^4.1.3", @@ -1920,13 +2976,15 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/credential-provider-env": { - "version": "3.839.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.839.0.tgz", - "integrity": "sha512-cWTadewPPz1OvObZJB+olrgh8VwcgIVcT293ZUT9V0CMF0UU7QaPwJP7uNXcNxltTh+sk1yhjH4UlcnJigZZbA==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/credential-provider-env": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.840.0.tgz", + "integrity": "sha512-EzF6VcJK7XvQ/G15AVEfJzN2mNXU8fcVpXo4bRyr1S6t2q5zx6UPH/XjDbn18xyUmOq01t+r8gG+TmHEVo18fA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.839.0", - "@aws-sdk/types": "3.821.0", + "@aws-sdk/core": "3.840.0", + "@aws-sdk/types": "3.840.0", "@smithy/property-provider": "^4.0.4", "@smithy/types": "^4.3.1", "tslib": "^2.6.2" @@ -1935,13 +2993,15 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/credential-provider-http": { - "version": "3.839.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.839.0.tgz", - "integrity": "sha512-fv0BZwrDhWDju4D1MCLT4I2aPjr0dVQ6P+MpqvcGNOA41Oa9UdRhYTV5iuy5NLXzIzoCmnS+XfSq5Kbsf6//xw==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/credential-provider-http": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.840.0.tgz", + "integrity": "sha512-wbnUiPGLVea6mXbUh04fu+VJmGkQvmToPeTYdHE8eRZq3NRDi3t3WltT+jArLBKD/4NppRpMjf2ju4coMCz91g==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.839.0", - "@aws-sdk/types": "3.821.0", + "@aws-sdk/core": "3.840.0", + "@aws-sdk/types": "3.840.0", "@smithy/fetch-http-handler": "^5.0.4", "@smithy/node-http-handler": "^4.0.6", "@smithy/property-provider": "^4.0.4", @@ -1955,19 +3015,21 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.839.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.839.0.tgz", - "integrity": "sha512-GHm0hF4CiDxIDR7TauMaA6iI55uuSqRxMBcqTAHaTPm6+h1A+MS+ysQMxZ+Jvwtoy8WmfTIGrJVxSCw0sK2hvA==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.840.0.tgz", + "integrity": "sha512-7F290BsWydShHb+7InXd+IjJc3mlEIm9I0R57F/Pjl1xZB69MdkhVGCnuETWoBt4g53ktJd6NEjzm/iAhFXFmw==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.839.0", - "@aws-sdk/credential-provider-env": "3.839.0", - "@aws-sdk/credential-provider-http": "3.839.0", - "@aws-sdk/credential-provider-process": "3.839.0", - "@aws-sdk/credential-provider-sso": "3.839.0", - "@aws-sdk/credential-provider-web-identity": "3.839.0", - "@aws-sdk/nested-clients": "3.839.0", - "@aws-sdk/types": "3.821.0", + "@aws-sdk/core": "3.840.0", + "@aws-sdk/credential-provider-env": "3.840.0", + "@aws-sdk/credential-provider-http": "3.840.0", + "@aws-sdk/credential-provider-process": "3.840.0", + "@aws-sdk/credential-provider-sso": "3.840.0", + "@aws-sdk/credential-provider-web-identity": "3.840.0", + "@aws-sdk/nested-clients": "3.840.0", + "@aws-sdk/types": "3.840.0", "@smithy/credential-provider-imds": "^4.0.6", "@smithy/property-provider": "^4.0.4", "@smithy/shared-ini-file-loader": "^4.0.4", @@ -1978,18 +3040,20 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/credential-provider-node": { - "version": "3.839.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.839.0.tgz", - "integrity": "sha512-7bR+U2h+ft0V8chyeu9Bh/pvau4ZkQMeRt5f0dAULoepZQ77QQVRP4H04yJPTg9DCtqbVULQ3uf5YOp1/08vQw==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/credential-provider-node": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.840.0.tgz", + "integrity": "sha512-KufP8JnxA31wxklLm63evUPSFApGcH8X86z3mv9SRbpCm5ycgWIGVCTXpTOdgq6rPZrwT9pftzv2/b4mV/9clg==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "3.839.0", - "@aws-sdk/credential-provider-http": "3.839.0", - "@aws-sdk/credential-provider-ini": "3.839.0", - "@aws-sdk/credential-provider-process": "3.839.0", - "@aws-sdk/credential-provider-sso": "3.839.0", - "@aws-sdk/credential-provider-web-identity": "3.839.0", - "@aws-sdk/types": "3.821.0", + "@aws-sdk/credential-provider-env": "3.840.0", + "@aws-sdk/credential-provider-http": "3.840.0", + "@aws-sdk/credential-provider-ini": "3.840.0", + "@aws-sdk/credential-provider-process": "3.840.0", + "@aws-sdk/credential-provider-sso": "3.840.0", + "@aws-sdk/credential-provider-web-identity": "3.840.0", + "@aws-sdk/types": "3.840.0", "@smithy/credential-provider-imds": "^4.0.6", "@smithy/property-provider": "^4.0.4", "@smithy/shared-ini-file-loader": "^4.0.4", @@ -2000,13 +3064,15 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/credential-provider-process": { - "version": "3.839.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.839.0.tgz", - "integrity": "sha512-qShpekjociUZ+isyQNa0P7jo+0q3N2+0eJDg8SGyP6K6hHTcGfiqxTDps+IKl6NreCPhZCBzyI9mWkP0xSDR6g==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/credential-provider-process": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.840.0.tgz", + "integrity": "sha512-HkDQWHy8tCI4A0Ps2NVtuVYMv9cB4y/IuD/TdOsqeRIAT12h8jDb98BwQPNLAImAOwOWzZJ8Cu0xtSpX7CQhMw==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.839.0", - "@aws-sdk/types": "3.821.0", + "@aws-sdk/core": "3.840.0", + "@aws-sdk/types": "3.840.0", "@smithy/property-provider": "^4.0.4", "@smithy/shared-ini-file-loader": "^4.0.4", "@smithy/types": "^4.3.1", @@ -2016,15 +3082,17 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.839.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.839.0.tgz", - "integrity": "sha512-w10zBLHhU8SBQcdrSPMI02haLoRGZg+gP7mH/Er8VhIXfHefbr7o4NirmB0hwdw/YAH8MLlC9jj7c2SJlsNhYA==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.840.0.tgz", + "integrity": "sha512-2qgdtdd6R0Z1y0KL8gzzwFUGmhBHSUx4zy85L2XV1CXhpRNwV71SVWJqLDVV5RVWVf9mg50Pm3AWrUC0xb0pcA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@aws-sdk/client-sso": "3.839.0", - "@aws-sdk/core": "3.839.0", - "@aws-sdk/token-providers": "3.839.0", - "@aws-sdk/types": "3.821.0", + "@aws-sdk/client-sso": "3.840.0", + "@aws-sdk/core": "3.840.0", + "@aws-sdk/token-providers": "3.840.0", + "@aws-sdk/types": "3.840.0", "@smithy/property-provider": "^4.0.4", "@smithy/shared-ini-file-loader": "^4.0.4", "@smithy/types": "^4.3.1", @@ -2034,14 +3102,16 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.839.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.839.0.tgz", - "integrity": "sha512-EvqTc7J1kgmiuxknpCp1S60hyMQvmKxsI5uXzQtcogl/N55rxiXEqnCLI5q6p33q91PJegrcMCM5Q17Afhm5qA==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.840.0.tgz", + "integrity": "sha512-dpEeVXG8uNZSmVXReE4WP0lwoioX2gstk4RnUgrdUE3YaPq8A+hJiVAyc3h+cjDeIqfbsQbZm9qFetKC2LF9dQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.839.0", - "@aws-sdk/nested-clients": "3.839.0", - "@aws-sdk/types": "3.821.0", + "@aws-sdk/core": "3.840.0", + "@aws-sdk/nested-clients": "3.840.0", + "@aws-sdk/types": "3.840.0", "@smithy/property-provider": "^4.0.4", "@smithy/types": "^4.3.1", "tslib": "^2.6.2" @@ -2050,12 +3120,14 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/middleware-host-header": { - "version": "3.821.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.821.0.tgz", - "integrity": "sha512-xSMR+sopSeWGx5/4pAGhhfMvGBHioVBbqGvDs6pG64xfNwM5vq5s5v6D04e2i+uSTj4qGa71dLUs5I0UzAK3sw==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/middleware-host-header": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.840.0.tgz", + "integrity": "sha512-ub+hXJAbAje94+Ya6c6eL7sYujoE8D4Bumu1NUI8TXjUhVVn0HzVWQjpRLshdLsUp1AW7XyeJaxyajRaJQ8+Xg==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.821.0", + "@aws-sdk/types": "3.840.0", "@smithy/protocol-http": "^5.1.2", "@smithy/types": "^4.3.1", "tslib": "^2.6.2" @@ -2064,12 +3136,14 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/middleware-logger": { - "version": "3.821.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.821.0.tgz", - "integrity": "sha512-0cvI0ipf2tGx7fXYEEN5fBeZDz2RnHyb9xftSgUsEq7NBxjV0yTZfLJw6Za5rjE6snC80dRN8+bTNR1tuG89zA==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/middleware-logger": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.840.0.tgz", + "integrity": "sha512-lSV8FvjpdllpGaRspywss4CtXV8M7NNNH+2/j86vMH+YCOZ6fu2T/TyFd/tHwZ92vDfHctWkRbQxg0bagqwovA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.821.0", + "@aws-sdk/types": "3.840.0", "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, @@ -2077,12 +3151,14 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.821.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.821.0.tgz", - "integrity": "sha512-efmaifbhBoqKG3bAoEfDdcM8hn1psF+4qa7ykWuYmfmah59JBeqHLfz5W9m9JoTwoKPkFcVLWZxnyZzAnVBOIg==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/middleware-recursion-detection": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.840.0.tgz", + "integrity": "sha512-Gu7lGDyfddyhIkj1Z1JtrY5NHb5+x/CRiB87GjaSrKxkDaydtX2CU977JIABtt69l9wLbcGDIQ+W0uJ5xPof7g==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.821.0", + "@aws-sdk/types": "3.840.0", "@smithy/protocol-http": "^5.1.2", "@smithy/types": "^4.3.1", "tslib": "^2.6.2" @@ -2091,14 +3167,16 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.839.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.839.0.tgz", - "integrity": "sha512-2u74uRM1JWq6Sf7+3YpjejPM9YkomGt4kWhrmooIBEq1k5r2GTbkH7pNCxBQwBueXM21jAGVDxxeClpTx+5hig==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/middleware-user-agent": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.840.0.tgz", + "integrity": "sha512-hiiMf7BP5ZkAFAvWRcK67Mw/g55ar7OCrvrynC92hunx/xhMkrgSLM0EXIZ1oTn3uql9kH/qqGF0nqsK6K555A==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "3.839.0", - "@aws-sdk/types": "3.821.0", - "@aws-sdk/util-endpoints": "3.828.0", + "@aws-sdk/core": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.840.0", "@smithy/core": "^3.6.0", "@smithy/protocol-http": "^5.1.2", "@smithy/types": "^4.3.1", @@ -2108,23 +3186,25 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/nested-clients": { - "version": "3.839.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.839.0.tgz", - "integrity": "sha512-Glic0pg2THYP3aRhJORwJJBe1JLtJoEdWV/MFZNyzCklfMwEzpWtZAyxy+tQyFmMeW50uBAnh2R0jhMMcf257w==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/nested-clients": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.840.0.tgz", + "integrity": "sha512-LXYYo9+n4hRqnRSIMXLBb+BLz+cEmjMtTudwK1BF6Bn2RfdDv29KuyeDRrPCS3TwKl7ZKmXUmE9n5UuHAPfBpA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "3.839.0", - "@aws-sdk/middleware-host-header": "3.821.0", - "@aws-sdk/middleware-logger": "3.821.0", - "@aws-sdk/middleware-recursion-detection": "3.821.0", - "@aws-sdk/middleware-user-agent": "3.839.0", - "@aws-sdk/region-config-resolver": "3.821.0", - "@aws-sdk/types": "3.821.0", - "@aws-sdk/util-endpoints": "3.828.0", - "@aws-sdk/util-user-agent-browser": "3.821.0", - "@aws-sdk/util-user-agent-node": "3.839.0", + "@aws-sdk/core": "3.840.0", + "@aws-sdk/middleware-host-header": "3.840.0", + "@aws-sdk/middleware-logger": "3.840.0", + "@aws-sdk/middleware-recursion-detection": "3.840.0", + "@aws-sdk/middleware-user-agent": "3.840.0", + "@aws-sdk/region-config-resolver": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@aws-sdk/util-endpoints": "3.840.0", + "@aws-sdk/util-user-agent-browser": "3.840.0", + "@aws-sdk/util-user-agent-node": "3.840.0", "@smithy/config-resolver": "^4.1.4", "@smithy/core": "^3.6.0", "@smithy/fetch-http-handler": "^5.0.4", @@ -2156,12 +3236,14 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/region-config-resolver": { - "version": "3.821.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.821.0.tgz", - "integrity": "sha512-t8og+lRCIIy5nlId0bScNpCkif8sc0LhmtaKsbm0ZPm3sCa/WhCbSZibjbZ28FNjVCV+p0D9RYZx0VDDbtWyjw==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/region-config-resolver": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.840.0.tgz", + "integrity": "sha512-Qjnxd/yDv9KpIMWr90ZDPtRj0v75AqGC92Lm9+oHXZ8p1MjG5JE2CW0HL8JRgK9iKzgKBL7pPQRXI8FkvEVfrA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "3.821.0", + "@aws-sdk/types": "3.840.0", "@smithy/node-config-provider": "^4.1.3", "@smithy/types": "^4.3.1", "@smithy/util-config-provider": "^4.0.0", @@ -2172,44 +3254,31 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.821.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.821.0.tgz", - "integrity": "sha512-irWZHyM0Jr1xhC+38OuZ7JB6OXMLPZlj48thElpsO1ZSLRkLZx5+I7VV6k3sp2yZ7BYbKz/G2ojSv4wdm7XTLw==", - "dependencies": { - "@aws-sdk/types": "3.821.0", - "@smithy/types": "^4.3.1", - "bowser": "^2.11.0", - "tslib": "^2.6.2" - } - }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.839.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.839.0.tgz", - "integrity": "sha512-MuunkIG1bJVMtTH7MbjXOrhHleU5wjHz5eCAUc6vj7M9rwol71nqjj9b8RLnkO5gsJcKc29Qk8iV6xQuzKWNMw==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/token-providers": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.840.0.tgz", + "integrity": "sha512-6BuTOLTXvmgwjK7ve7aTg9JaWFdM5UoMolLVPMyh3wTv9Ufalh8oklxYHUBIxsKkBGO2WiHXytveuxH6tAgTYg==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "3.839.0", - "@aws-sdk/types": "3.821.0", - "@smithy/node-config-provider": "^4.1.3", + "@aws-sdk/core": "3.840.0", + "@aws-sdk/nested-clients": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@smithy/property-provider": "^4.0.4", + "@smithy/shared-ini-file-loader": "^4.0.4", "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" - }, - "peerDependencies": { - "aws-crt": ">=1.0.0" - }, - "peerDependenciesMeta": { - "aws-crt": { - "optional": true - } } }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@smithy/abort-controller": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.4.tgz", - "integrity": "sha512-gJnEjZMvigPDQWHrW3oPrFhQtkrgqBkyjj3pCIdF3A5M6vsZODG93KNlfJprv6bp4245bdT32fsHK4kkH3KYDA==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/types": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.840.0.tgz", + "integrity": "sha512-xliuHaUFZxEx1NSXeLLZ9Dyu6+EJVQKEoD+yM+zqUo3YDZ7medKJWY6fIOKiPX/N7XbLdBYwajb15Q7IL8KkeA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.3.1", "tslib": "^2.6.2" @@ -2218,62 +3287,66 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@smithy/node-http-handler": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.0.6.tgz", - "integrity": "sha512-NqbmSz7AW2rvw4kXhKGrYTiJVDHnMsFnX4i+/FzcZAfbOBauPYs2ekuECkSbtqaxETLLTu9Rl/ex6+I2BKErPA==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/util-endpoints": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.840.0.tgz", + "integrity": "sha512-eqE9ROdg/Kk0rj3poutyRCFauPDXIf/WSvCqFiRDDVi6QOnCv/M0g2XW8/jSvkJlOyaXkNCptapIp6BeeFFGYw==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@smithy/abort-controller": "^4.0.4", - "@smithy/protocol-http": "^5.1.2", - "@smithy/querystring-builder": "^4.0.4", + "@aws-sdk/types": "3.840.0", "@smithy/types": "^4.3.1", + "@smithy/util-endpoints": "^3.0.6", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso-oidc/node_modules/@smithy/types": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.1.tgz", - "integrity": "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/util-user-agent-browser": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.840.0.tgz", + "integrity": "sha512-JdyZM3EhhL4PqwFpttZu1afDpPJCCc3eyZOLi+srpX11LsGj6sThf47TYQN75HT1CarZ7cCdQHGzP2uy3/xHfQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { + "@aws-sdk/types": "3.840.0", + "@smithy/types": "^4.3.1", + "bowser": "^2.11.0", "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso/node_modules/@aws-sdk/types": { - "version": "3.731.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.731.0.tgz", - "integrity": "sha512-NrdkJg6oOUbXR2r9WvHP408CLyvST8cJfp1/jP9pemtjvjPoh6NukbCtiSFdOOb1eryP02CnqQWItfJC1p2Y/Q==", + "node_modules/@aws-sdk/client-sts/node_modules/@aws-sdk/util-user-agent-node": { + "version": "3.840.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.840.0.tgz", + "integrity": "sha512-Fy5JUEDQU1tPm2Yw/YqRYYc27W5+QD/J4mYvQvdWjUGZLB5q3eLFMGD35Uc28ZFoGMufPr4OCxK/bRfWROBRHQ==", + "dev": true, + "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.0.0", + "@aws-sdk/middleware-user-agent": "3.840.0", + "@aws-sdk/types": "3.840.0", + "@smithy/node-config-provider": "^4.1.3", + "@smithy/types": "^4.3.1", "tslib": "^2.6.2" }, "engines": { "node": ">=18.0.0" - } - }, - "node_modules/@aws-sdk/client-sso/node_modules/@aws-sdk/util-endpoints": { - "version": "3.731.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.731.0.tgz", - "integrity": "sha512-riztxTAfncFS9yQWcBJffGgOgLoKSa63ph+rxWJxKl6BHAmWEvHICj1qDcVmnWfIcvJ5cClclY75l9qKaUH7rQ==", - "dependencies": { - "@aws-sdk/types": "3.731.0", - "@smithy/types": "^4.0.0", - "@smithy/util-endpoints": "^3.0.0", - "tslib": "^2.6.2" }, - "engines": { - "node": ">=18.0.0" + "peerDependencies": { + "aws-crt": ">=1.0.0" + }, + "peerDependenciesMeta": { + "aws-crt": { + "optional": true + } } }, - "node_modules/@aws-sdk/client-sso/node_modules/@smithy/abort-controller": { + "node_modules/@aws-sdk/client-sts/node_modules/@smithy/abort-controller": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/@smithy/abort-controller/-/abort-controller-4.0.4.tgz", "integrity": "sha512-gJnEjZMvigPDQWHrW3oPrFhQtkrgqBkyjj3pCIdF3A5M6vsZODG93KNlfJprv6bp4245bdT32fsHK4kkH3KYDA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.3.1", "tslib": "^2.6.2" @@ -2282,10 +3355,12 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso/node_modules/@smithy/node-http-handler": { + "node_modules/@aws-sdk/client-sts/node_modules/@smithy/node-http-handler": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.0.6.tgz", "integrity": "sha512-NqbmSz7AW2rvw4kXhKGrYTiJVDHnMsFnX4i+/FzcZAfbOBauPYs2ekuECkSbtqaxETLLTu9Rl/ex6+I2BKErPA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { "@smithy/abort-controller": "^4.0.4", "@smithy/protocol-http": "^5.1.2", @@ -2297,10 +3372,12 @@ "node": ">=18.0.0" } }, - "node_modules/@aws-sdk/client-sso/node_modules/@smithy/types": { + "node_modules/@aws-sdk/client-sts/node_modules/@smithy/types": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.3.1.tgz", "integrity": "sha512-UqKOQBL2x6+HWl3P+3QqFD4ncKq0I8Nuz9QItGv5WuKuMHuuwlhvqcZCoXGfc+P1QmfJE7VieykoYYmrOoFJxA==", + "dev": true, + "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" }, @@ -4201,9 +5278,9 @@ "link": true }, "node_modules/@aws/mynah-ui": { - "version": "4.36.0", - "resolved": "https://registry.npmjs.org/@aws/mynah-ui/-/mynah-ui-4.36.0.tgz", - "integrity": "sha512-M2lT3F0u1WWAJ2l1+GxccGcXLs/Wu69n8rZllU8jGr3ox3r0WwyEe52E47ut3eZU0pKKilOqXzawK5mr9fsNiQ==", + "version": "4.36.2", + "resolved": "https://registry.npmjs.org/@aws/mynah-ui/-/mynah-ui-4.36.2.tgz", + "integrity": "sha512-3ibfK2CTj7dlFFdgTIE1DdEyDpy+P3hdP/Fmlx76T9GGSYiGHqwunDSi59L1P61Kj46WADBrQ52mLUQ6FR8Rzg==", "hasInstallScript": true, "license": "Apache License 2.0", "dependencies": { @@ -6786,15 +7863,17 @@ "integrity": "sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw==" }, "node_modules/@modelcontextprotocol/sdk": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.13.2.tgz", - "integrity": "sha512-Vx7qOcmoKkR3qhaQ9qf3GxiVKCEu+zfJddHv6x3dY/9P6+uIwJnmuAur5aB+4FDXf41rRrDnOEGkviX5oYZ67w==", + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.15.0.tgz", + "integrity": "sha512-67hnl/ROKdb03Vuu0YOr+baKTvf1/5YBHBm9KnZdjdAh8hjt4FRCPD5ucwxGB237sBpzlqQsLy1PFu7z/ekZ9Q==", + "license": "MIT", "dependencies": { "ajv": "^6.12.6", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "pkce-challenge": "^5.0.0", @@ -27168,7 +28247,7 @@ }, "server/aws-lsp-codewhisperer": { "name": "@aws/lsp-codewhisperer", - "version": "0.0.69", + "version": "0.0.70", "bundleDependencies": [ "@amzn/codewhisperer-streaming", "@amzn/amazon-q-developer-streaming-client" @@ -27183,7 +28262,7 @@ "@aws/chat-client-ui-types": "^0.1.56", "@aws/language-server-runtimes": "^0.2.116", "@aws/lsp-core": "^0.0.12", - "@modelcontextprotocol/sdk": "^1.9.0", + "@modelcontextprotocol/sdk": "^1.15.0", "@smithy/node-http-handler": "^2.5.0", "adm-zip": "^0.5.10", "archiver": "^7.0.1", diff --git a/package.json b/package.json index 4327ab29e3..55e8d4f3e3 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,8 @@ "typescript": "^5.8.2" }, "devDependencies": { + "@aws-sdk/client-iam": "^3.840.0", + "@aws-sdk/client-sts": "^3.840.0", "@commitlint/cli": "^19.8.0", "@commitlint/config-conventional": "^19.8.0", "@types/ignore-walk": "^4.0.3", diff --git a/server/aws-lsp-codewhisperer/CHANGELOG.md b/server/aws-lsp-codewhisperer/CHANGELOG.md index 809fe81dc7..762411550a 100644 --- a/server/aws-lsp-codewhisperer/CHANGELOG.md +++ b/server/aws-lsp-codewhisperer/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## [0.0.70](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.69...lsp-codewhisperer/v0.0.70) (2025-07-29) + + +### Features + +* **amazonq:** add new model error handling code ([#1972](https://github.com/aws/language-servers/issues/1972)) ([905f0fc](https://github.com/aws/language-servers/commit/905f0fcbb274926d22bcf30600ad4bd419ac8ee4)) +* **amazonq:** enable compaction, minor UI changes ([#1979](https://github.com/aws/language-servers/issues/1979)) ([2b56ca8](https://github.com/aws/language-servers/commit/2b56ca87f442a06b554043fee86edd79f96c638d)) +* **amazonq:** enhance workspaceContext classpath generation ([#1955](https://github.com/aws/language-servers/issues/1955)) ([f7ed20b](https://github.com/aws/language-servers/commit/f7ed20bc4010996c508f6ea8ca87950e117e43c1)) +* **amazonq:** redirect /review, rename CodeReview tool, emit metrics, modify prompts ([#1964](https://github.com/aws/language-servers/issues/1964)) ([ad8e2db](https://github.com/aws/language-servers/commit/ad8e2db77e34f369fef9af71cdda2d3522f555c6)) +* **amazonq:** revert auto-approve ([#2002](https://github.com/aws/language-servers/issues/2002)) ([c8181f7](https://github.com/aws/language-servers/commit/c8181f7a1de224dfcc7a77cd0bfc905fa1018372)) +* enhance profile fetching logs to diagnose developerProfiles errors ([#1969](https://github.com/aws/language-servers/issues/1969)) ([eb688c2](https://github.com/aws/language-servers/commit/eb688c272df1251cd5c14ada7894bcaf625b6453)) + + +### Bug Fixes + +* **amazonq:** wrong path in the logs for the function ([#1978](https://github.com/aws/language-servers/issues/1978)) ([ed8b4f6](https://github.com/aws/language-servers/commit/ed8b4f6755accb624e7dc8c645ecd5cd9370a0f2)) +* emit metric for tool error ([#1954](https://github.com/aws/language-servers/issues/1954)) ([c3bbcea](https://github.com/aws/language-servers/commit/c3bbceabcea3d7aea2e414abc632c3a744b0e02b)) +* enable repomap for all users ([#1967](https://github.com/aws/language-servers/issues/1967)) ([6954085](https://github.com/aws/language-servers/commit/69540851e54b65729b2affbe3ae7d98629bdb5f4)) +* move network commands out of ro category ([#1985](https://github.com/aws/language-servers/issues/1985)) ([3cc9fd9](https://github.com/aws/language-servers/commit/3cc9fd91ae2f78ee28e224d5390ba78509de3615)) +* remove malicious characters from MCP tool description ([#1977](https://github.com/aws/language-servers/issues/1977)) ([64d4e3e](https://github.com/aws/language-servers/commit/64d4e3ebade706b01d256682cafe8d4ff8b85f41)) + ## [0.0.69](https://github.com/aws/language-servers/compare/lsp-codewhisperer/v0.0.68...lsp-codewhisperer/v0.0.69) (2025-07-23) diff --git a/server/aws-lsp-codewhisperer/package.json b/server/aws-lsp-codewhisperer/package.json index 0712b8292f..2616a67b26 100644 --- a/server/aws-lsp-codewhisperer/package.json +++ b/server/aws-lsp-codewhisperer/package.json @@ -1,6 +1,6 @@ { "name": "@aws/lsp-codewhisperer", - "version": "0.0.69", + "version": "0.0.70", "description": "CodeWhisperer Language Server", "main": "out/index.js", "repository": { @@ -38,7 +38,7 @@ "@aws/chat-client-ui-types": "^0.1.56", "@aws/language-server-runtimes": "^0.2.116", "@aws/lsp-core": "^0.0.12", - "@modelcontextprotocol/sdk": "^1.9.0", + "@modelcontextprotocol/sdk": "^1.15.0", "@smithy/node-http-handler": "^2.5.0", "adm-zip": "^0.5.10", "archiver": "^7.0.1", diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts index f0b61dd64a..c343c4f100 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/agenticChatController.ts @@ -186,9 +186,6 @@ import { DEFAULT_WINDOW_REJECT_SHORTCUT, DEFAULT_MACOS_STOP_SHORTCUT, DEFAULT_WINDOW_STOP_SHORTCUT, - OUT_OF_WORKSPACE_WARNING_MSG, - CREDENTIAL_FILE_WARNING_MSG, - BINARY_FILE_WARNING_MSG, } from './constants/constants' import { AgenticChatError, @@ -225,7 +222,7 @@ import { sanitize } from '@aws/lsp-core/out/util/path' import { getLatestAvailableModel } from './utils/agenticChatControllerHelper' import { ActiveUserTracker } from '../../shared/activeUserTracker' import { UserContext } from '../../client/token/codewhispererbearertokenclient' -import { CodeWhispererServiceToken } from '../../shared/codeWhispererService' +import { CodeWhispererServiceToken } from '../../shared/codeWhispererService/codeWhispererServiceToken' type ChatHandlers = Omit< LspHandlers, @@ -1702,9 +1699,9 @@ export class AgenticChatController implements ChatHandlers { const tool = new Tool(this.#features) // For MCP tools, get the permission from McpManager - const permission = McpManager.instance.getToolPerm('Built-in', toolUse.name) + // const permission = McpManager.instance.getToolPerm('Built-in', toolUse.name) // If permission is 'alwaysAllow', we don't need to ask for acceptance - const builtInPermission = permission !== 'alwaysAllow' + // const builtInPermission = permission !== 'alwaysAllow' // Get the approved paths from the session const approvedPaths = session.approvedPaths @@ -1715,40 +1712,19 @@ export class AgenticChatController implements ChatHandlers { approvedPaths ) - const isExecuteBash = toolUse.name === EXECUTE_BASH - - // check if tool execution's path is out of workspace - const isOutOfWorkSpace = warning === OUT_OF_WORKSPACE_WARNING_MSG - // check if tool involved secured files - const isSecuredFilesInvoled = - warning === BINARY_FILE_WARNING_MSG || warning === CREDENTIAL_FILE_WARNING_MSG - // Honor built-in permission if available, otherwise use tool's requiresAcceptance - let toolRequiresAcceptance = - (builtInPermission || isOutOfWorkSpace || isSecuredFilesInvoled) ?? requiresAcceptance - - // if the command is read-only and in-workspace --> flip back to no approval needed - if ( - isExecuteBash && - commandCategory === CommandCategory.ReadOnly && - !isOutOfWorkSpace && - !requiresAcceptance - ) { - toolRequiresAcceptance = false - } + // const requiresAcceptance = builtInPermission || toolRequiresAcceptance - if (toolRequiresAcceptance || isExecuteBash) { + if (requiresAcceptance || toolUse.name === EXECUTE_BASH) { // for executeBash, we till send the confirmation message without action buttons const confirmationResult = this.#processToolConfirmation( toolUse, - toolRequiresAcceptance, + requiresAcceptance, warning, - commandCategory, - toolUse.name, - builtInPermission + commandCategory ) cachedButtonBlockId = await chatResultStream.writeResultBlock(confirmationResult) - + const isExecuteBash = toolUse.name === EXECUTE_BASH if (isExecuteBash) { this.#telemetryController.emitInteractWithAgenticChat( 'GeneratedCommand', @@ -1759,7 +1735,7 @@ export class AgenticChatController implements ChatHandlers { this.#abTestingAllocation?.userVariation ) } - if (toolRequiresAcceptance) { + if (requiresAcceptance) { await this.waitForToolApproval( toolUse, chatResultStream, @@ -2743,7 +2719,7 @@ export class AgenticChatController implements ChatHandlers { body = builtInPermission ? `I need permission to read files.\n${formattedPaths.join('\n')}` : `I need permission to read files outside the workspace.\n${formattedPaths.join('\n')}` - } else if (toolName === 'listDirectory') { + } else { const readFilePath = (toolUse.input as unknown as ListDirectoryParams).path // Validate the path using our synchronous utility @@ -2753,11 +2729,6 @@ export class AgenticChatController implements ChatHandlers { body = builtInPermission ? `I need permission to list directories.\n\`${readFilePath}\`` : `I need permission to list directories outside the workspace.\n\`${readFilePath}\`` - } else { - const readFilePath = (toolUse.input as unknown as ListDirectoryParams).path - body = builtInPermission - ? `I need permission to search files.\n\`${readFilePath}\`` - : `I need permission to search files outside the workspace.\n\`${readFilePath}\`` } break } diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/constants.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/constants.ts index 66a0a00221..09fbb20436 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/constants.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/constants/constants.ts @@ -88,11 +88,3 @@ export const DEFAULT_LINUX_STOP_SHORTCUT = 'Meta + ⇧ + ⌫' export const DEFAULT_MACOS_REJECT_SHORTCUT = '⇧ ⌘ R' export const DEFAULT_WINDOW_REJECT_SHORTCUT = 'Ctrl + ⇧ + R' export const DEFAULT_LINUX_REJECT_SHORTCUT = 'Meta + ⇧ + R' - -// Warning Message Constants -export const DESTRUCTIVE_COMMAND_WARNING_MSG = 'WARNING: Potentially destructive command detected:\n\n' -export const MUTATE_COMMAND_WARNING_MSG = 'Mutation command:\n\n' -export const OUT_OF_WORKSPACE_WARNING_MSG = 'Execution out of workspace scope:\n\n' -export const CREDENTIAL_FILE_WARNING_MSG = - 'WARNING: Command involves credential files that require secure permissions:\n\n' -export const BINARY_FILE_WARNING_MSG = 'WARNING: Command involves binary files that require secure permissions:\n\n' diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/executeBash.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/executeBash.test.ts index b792652f1a..bd3c6bf6c8 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/executeBash.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/executeBash.test.ts @@ -158,7 +158,7 @@ describe('ExecuteBash Tool', () => { assert.equal((execBash as any).isLikelyCredentialFile('/path/to/data.csv'), false) }) - it('should equire acceptance for network commands like ping', async () => { + it('should require acceptance for network commands like ping', async () => { const execBash = new ExecuteBash(features) const validation = await execBash.requiresAcceptance({ command: 'ping example.com' }) assert.equal(validation.requiresAcceptance, true, 'Ping should not require acceptance') diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/executeBash.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/executeBash.ts index 3ebbc332e4..552bc49682 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/executeBash.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/executeBash.ts @@ -13,15 +13,6 @@ import { getWorkspaceFolderPaths } from '@aws/lsp-core/out/util/workspaceUtils' // eslint-disable-next-line import/no-nodejs-modules import { existsSync, statSync } from 'fs' -// Warning message -import { - BINARY_FILE_WARNING_MSG, - CREDENTIAL_FILE_WARNING_MSG, - DESTRUCTIVE_COMMAND_WARNING_MSG, - MUTATE_COMMAND_WARNING_MSG, - OUT_OF_WORKSPACE_WARNING_MSG, -} from '../constants/constants' - export enum CommandCategory { ReadOnly, Mutate, @@ -117,6 +108,12 @@ export const commandCategories = new Map([ ]) export const maxToolResponseSize: number = 1024 * 1024 // 1MB export const lineCount: number = 1024 +export const destructiveCommandWarningMessage = 'WARNING: Potentially destructive command detected:\n\n' +export const mutateCommandWarningMessage = 'Mutation command:\n\n' +export const outOfWorkspaceWarningmessage = 'Execution out of workspace scope:\n\n' +export const credentialFileWarningMessage = + 'WARNING: Command involves credential files that require secure permissions:\n\n' +export const binaryFileWarningMessage = 'WARNING: Command involves binary files that require secure permissions:\n\n' /** * Parameters for executing a command on the system shell. @@ -235,7 +232,7 @@ export class ExecuteBash { // Treat tilde paths as absolute paths (they will be expanded by the shell) return { requiresAcceptance: true, - warning: DESTRUCTIVE_COMMAND_WARNING_MSG, + warning: destructiveCommandWarningMessage, commandCategory: CommandCategory.Destructive, } } else if (!isAbsolute(arg) && params.cwd) { @@ -258,7 +255,7 @@ export class ExecuteBash { this.logging.info(`Detected credential file in command: ${fullPath}`) return { requiresAcceptance: true, - warning: CREDENTIAL_FILE_WARNING_MSG, + warning: credentialFileWarningMessage, commandCategory: CommandCategory.Mutate, } } @@ -268,7 +265,7 @@ export class ExecuteBash { this.logging.info(`Detected binary file in command: ${fullPath}`) return { requiresAcceptance: true, - warning: BINARY_FILE_WARNING_MSG, + warning: binaryFileWarningMessage, commandCategory: CommandCategory.Mutate, } } @@ -285,7 +282,7 @@ export class ExecuteBash { if (!isInWorkspace) { return { requiresAcceptance: true, - warning: OUT_OF_WORKSPACE_WARNING_MSG, + warning: outOfWorkspaceWarningmessage, commandCategory: highestCommandCategory, } } @@ -309,13 +306,13 @@ export class ExecuteBash { case CommandCategory.Destructive: return { requiresAcceptance: true, - warning: DESTRUCTIVE_COMMAND_WARNING_MSG, + warning: destructiveCommandWarningMessage, commandCategory: CommandCategory.Destructive, } case CommandCategory.Mutate: return { requiresAcceptance: true, - warning: MUTATE_COMMAND_WARNING_MSG, + warning: mutateCommandWarningMessage, commandCategory: CommandCategory.Mutate, } case CommandCategory.ReadOnly: @@ -337,7 +334,7 @@ export class ExecuteBash { if (!workspaceFolders || workspaceFolders.length === 0) { return { requiresAcceptance: true, - warning: OUT_OF_WORKSPACE_WARNING_MSG, + warning: outOfWorkspaceWarningmessage, commandCategory: highestCommandCategory, } } @@ -354,7 +351,7 @@ export class ExecuteBash { if (!isInWorkspace) { return { requiresAcceptance: true, - warning: OUT_OF_WORKSPACE_WARNING_MSG, + warning: outOfWorkspaceWarningmessage, commandCategory: highestCommandCategory, } } diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/grepSearch.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/grepSearch.ts index f1d52e01ee..9ae48ccae3 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/grepSearch.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/grepSearch.ts @@ -7,7 +7,6 @@ import { ChildProcess, ChildProcessOptions } from '@aws/lsp-core/out/util/proces import path = require('path') import { dirname } from 'path' import { pathToFileURL } from 'url' -import { OUT_OF_WORKSPACE_WARNING_MSG } from '../constants/constants' export interface GrepSearchParams { path?: string @@ -82,8 +81,7 @@ export class GrepSearch { public async requiresAcceptance(params: GrepSearchParams): Promise { const path = this.getSearchDirectory(params.path) - const isInWorkspace = workspaceUtils.isInWorkspace(getWorkspaceFolderPaths(this.workspace), path) - return { requiresAcceptance: !isInWorkspace, warning: !isInWorkspace ? OUT_OF_WORKSPACE_WARNING_MSG : '' } + return { requiresAcceptance: !workspaceUtils.isInWorkspace(getWorkspaceFolderPaths(this.workspace), path) } } public async invoke(params: GrepSearchParams, token?: CancellationToken): Promise { diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.test.ts index 3bb07ac5bd..d8511d955d 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.test.ts @@ -49,7 +49,6 @@ describe('McpEventHandler error handling', () => { }, agent: { getTools: sinon.stub().returns([]), - getBuiltInToolNames: sinon.stub().returns([]), }, lsp: {}, telemetry: { diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.ts index 2272ba8402..95a9245233 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpEventHandler.ts @@ -34,7 +34,6 @@ import { URI } from 'vscode-uri' interface PermissionOption { label: string value: string - description?: string } export class McpEventHandler { @@ -49,6 +48,7 @@ export class McpEventHandler { #isProgrammaticChange: boolean = false #debounceTimer: NodeJS.Timeout | null = null #lastProgrammaticState: boolean = false + #serverNameBeforeUpdate: string | undefined constructor(features: Features, telemetryService: TelemetryService) { this.#features = features @@ -128,42 +128,38 @@ export class McpEventHandler { // Transform server configs into DetailedListItem objects const activeItems: DetailedListItem[] = [] const disabledItems: DetailedListItem[] = [] + const builtInItems: DetailedListItem[] = [] // Get built-in tools programmatically const allTools = this.#features.agent.getTools({ format: 'bedrock' }) - const builtInToolNames = new Set(this.#features.agent.getBuiltInToolNames()) + const mcpToolNames = new Set(mcpManager.getAllTools().map(tool => tool.toolName)) const builtInTools = allTools - .filter(tool => { - return builtInToolNames.has(tool.toolSpecification.name) && tool.toolSpecification.name !== 'fsReplace' - }) + .filter(tool => !mcpToolNames.has(tool.toolSpecification.name)) .map(tool => ({ name: tool.toolSpecification.name, - description: - this.#getBuiltInToolDescription(tool.toolSpecification.name) || - tool.toolSpecification.description || - `${tool.toolSpecification.name} tool`, + description: tool.toolSpecification.description || `${tool.toolSpecification.name} tool`, })) // Add built-in tools as a server in the active items - activeItems.push({ - title: 'Built-in', - description: `${builtInTools.length} tools`, - children: [ - { - groupName: 'serverInformation', - children: [ - { - title: 'status', - description: 'ENABLED', - }, - { - title: 'toolcount', - description: `${builtInTools.length}`, - }, - ], - }, - ], - }) + // activeItems.push({ + // title: 'Built-in', + // description: `${builtInTools.length} tools`, + // children: [ + // { + // groupName: 'serverInformation', + // children: [ + // { + // title: 'status', + // description: 'ENABLED', + // }, + // { + // title: 'toolcount', + // description: `${builtInTools.length}`, + // }, + // ], + // }, + // ], + // }) Array.from(mcpManagerServerConfigs.entries()).forEach(([serverName, config]) => { const toolsWithPermissions = mcpManager.getAllToolsWithPermissions(serverName) @@ -226,7 +222,7 @@ export class McpEventHandler { // Return the result in the expected format const header = { - title: 'MCP Servers and Built-in Tools', + title: 'MCP Servers', description: "Add MCP servers to extend Q's capabilities.", // only show error on list mcp server page if unable to read mcp.json file status: configLoadErrors @@ -246,8 +242,12 @@ export class McpEventHandler { // Use a map of handlers for different action types const handlers: Record Promise> = { - 'add-new-mcp': () => this.#handleAddNewMcp(params), + 'add-new-mcp': () => { + this.#currentEditingServerName = undefined + return this.#handleAddNewMcp(params) + }, 'save-mcp': () => this.#handleSaveMcp(params), + 'change-transport': () => this.#handleChangeTransport(params), 'open-mcp-server': () => this.#handleOpenMcpServer(params), 'edit-mcp': () => this.#handleEditMcpServer(params), 'mcp-permission-change': () => this.#handleMcpPermissionChange(params), @@ -275,7 +275,7 @@ export class McpEventHandler { return { id, header: { - title: 'MCP Servers and Built-in Tools', + title: 'MCP Servers', status: {}, description: `Add MCP servers to extend Q's capabilities.`, actions: [], @@ -286,28 +286,21 @@ export class McpEventHandler { async #handleAddNewMcp(params: McpServerClickParams, error?: string) { const existingValues = params.optionsValues || {} - let argsValue = [ - { - persistent: true, - value: { arg_key: '' }, - }, - ] + + // Arguments (stdio) + let argsValue = [{ persistent: true, value: { arg_key: '' } }] if (existingValues.args && Array.isArray(existingValues.args)) { argsValue = existingValues.args.map((arg, index) => ({ persistent: index === 0, - value: { - arg_key: arg.arg_key || '', - }, + value: { arg_key: arg.arg_key || '' }, })) } + // Environment variables (stdio) let envVarsValue = [ { persistent: true, - value: { - env_var_name: '', - env_var_value: '', - }, + value: { env_var_name: '', env_var_value: '' }, }, ] if (existingValues.env_variables && Array.isArray(existingValues.env_variables)) { @@ -320,6 +313,18 @@ export class McpEventHandler { })) } + // Headers (http) + let headersValue: any[] = [] + if (existingValues.headers && Array.isArray(existingValues.headers)) { + headersValue = existingValues.headers.map(hdr => ({ + persistent: false, // allow every row to be deleted + value: { + key: hdr.key || '', + value: hdr.value || '', + }, + })) + } + if (existingValues.name) { const serverName = existingValues.name const sanitizedServerName = sanitizeName(serverName) @@ -338,116 +343,121 @@ export class McpEventHandler { } const serverStatusError = this.#getServerStatusError(existingValues.name) || {} + + // Determine which transport is selected (default to stdio) + const selectedTransport = existingValues.transport || 'stdio' + return { id: params.id, header: { title: 'Add MCP Server', - status: error - ? { - title: error, - icon: 'cancel-circle', - status: 'error' as Status, - } - : serverStatusError, + status: error ? { title: error, icon: 'cancel-circle', status: 'error' as Status } : serverStatusError, actions: [], }, list: [], filterActions: [ - { - id: 'cancel-mcp', - text: 'Cancel', - }, - { - id: 'save-mcp', - text: 'Save', - status: error ? ('error' as Status) : 'primary', - }, + { id: 'cancel-mcp', text: 'Cancel' }, + { id: 'save-mcp', text: 'Save', status: error ? ('error' as Status) : 'primary' }, ], - filterOptions: [ - { - type: 'radiogroup', - id: 'scope', - title: 'Scope', - options: [ + filterOptions: (() => { + const common = [ + { + type: 'radiogroup', + id: 'scope', + title: 'Scope', + options: [ + { label: 'Global - Used globally.', value: 'global' }, + { label: 'This workspace - Only used in this workspace.', value: 'workspace' }, + ], + value: existingValues.scope || 'global', + }, + { + type: 'textinput', + id: 'name', + title: 'Name', + value: existingValues.name || '', + mandatory: true, + }, + { + type: 'select', + id: 'transport', + title: 'Transport', + mandatory: true, + options: [ + { label: 'stdio', value: 'stdio' }, + { label: 'http', value: 'http' }, + ], + value: selectedTransport, + }, + ] + + if (selectedTransport === 'http') { + return [ + ...common, { - label: `Global - Used globally.`, - value: 'global', + type: 'textinput', + id: 'url', + title: 'URL', + value: existingValues.url || '', + mandatory: true, }, { - label: `This workspace - Only used in this workspace.`, - value: 'workspace', + type: 'list', + id: 'headers', + title: 'Headers - optional', + items: [ + { id: 'key', title: 'Key', type: 'textinput' }, + { id: 'value', title: 'Value', type: 'textinput' }, + ], + ...(headersValue.length > 0 ? { value: headersValue } : {}), }, - ], - value: existingValues.scope || 'global', - }, - { - type: 'textinput', - id: 'name', - title: 'Name', - value: existingValues.name || '', - mandatory: true, - }, - { - type: 'select', - id: 'transport', - title: 'Transport', - mandatory: true, - options: [ { - label: 'stdio', - value: 'yes', + type: 'numericinput', + id: 'timeout', + title: 'Timeout - use 0 to disable', + value: existingValues.timeout || 60, }, - ], - }, - { - type: 'textinput', - id: 'command', - title: 'Command', - value: existingValues.command || '', - mandatory: true, - }, - { - type: 'list', - id: 'args', - title: 'Arguments - optional', - mandatory: false, - items: [ + ] + } else { + // stdio transport + return [ + ...common, { - id: 'arg_key', type: 'textinput', + id: 'command', + title: 'Command', + value: existingValues.command || '', + mandatory: true, }, - ], - value: argsValue, - }, - { - type: 'list', - id: 'env_variables', - title: 'Environment variables - optional', - mandatory: false, - items: [ { - id: 'env_var_name', - title: 'Name', - type: 'textinput', + type: 'list', + id: 'args', + title: 'Arguments - optional', + items: [{ id: 'arg_key', type: 'textinput' }], + value: argsValue, }, { - id: 'env_var_value', - title: 'Value', - type: 'textinput', + type: 'list', + id: 'env_variables', + title: 'Environment variables - optional', + items: [ + { id: 'env_var_name', title: 'Name', type: 'textinput' }, + { id: 'env_var_value', title: 'Value', type: 'textinput' }, + ], + value: envVarsValue, }, - ], - value: envVarsValue, - }, - { - type: 'numericinput', - id: 'timeout', - title: 'Timeout - use 0 to disable', - value: existingValues.timeout || 60, // Default to 60 seconds in UI - mandatory: false, - }, - ], + { + type: 'numericinput', + id: 'timeout', + title: 'Timeout - use 0 to disable', + value: existingValues.timeout || 60, + }, + ] + } + })(), } } + /** * Validates all MCP server configurations and returns combined error messages * @param serverConfigs Map of server configurations to validate @@ -469,6 +479,8 @@ export class McpEventHandler { timeout: config.timeout?.toString() || '', env: config.env, args: config.args, + url: config.url, + headers: config.headers, } const validation = this.#validateMcpServerForm(values, false) @@ -534,8 +546,17 @@ export class McpEventHandler { } } - if (!values.command || values.command.trim() === '') { - errors.push('Command is required for stdio transport') + const transport = values.transport + const command = values.command?.trim() || '' + const url = values.url?.trim() || '' + + // Basic validation for command/url presence and exclusivity + if (!command && !url) { + errors.push('Either command or url is required') + } else if (command && url) { + errors.push('Provide either command OR url, not both') + } else if (transport && ((transport === 'stdio' && !command) || (transport !== 'stdio' && !url))) { + errors.push(`${transport === 'stdio' ? 'Command' : 'URL'} is required for ${transport} transport`) } if (values.timeout && values.timeout.trim() !== '') { @@ -568,6 +589,24 @@ export class McpEventHandler { } } + if (Array.isArray(values.headers)) { + const hdrs = values.headers as Array<{ key: string; value: string }> + const invalidHeaders = hdrs.find(h => { + const key = h.key?.trim() || '' + const value = h.value?.trim() || '' + return (key === '' && value !== '') || (key !== '' && value === '') + }) + + if (invalidHeaders) { + const hasKey = invalidHeaders.key?.trim() + errors.push( + hasKey + ? 'Header value cannot be empty when key is provided' + : 'Header key cannot be empty when value is provided' + ) + } + } + return { isValid: errors.length === 0, errors, @@ -582,10 +621,12 @@ export class McpEventHandler { return this.#getDefaultMcpResponse(params.id) } + const selectedTransport = params.optionsValues.transport const serverName = params.optionsValues.name const sanitizedServerName = sanitizeName(serverName) const originalServerName = this.#currentEditingServerName const isEditMode = !!(originalServerName && McpManager.instance.getAllServerConfigs().has(originalServerName)) + // Validate form values const validation = this.#validateMcpServerForm( params.optionsValues, @@ -594,56 +635,79 @@ export class McpEventHandler { ) if (!validation.isValid) { const error = validation.errors[0] - if (isEditMode) { - params.id = 'edit-mcp' - params.title = originalServerName! - return this.#handleEditMcpServer(params, error) - } else { - params.id = 'add-new-mcp' - return this.#handleAddNewMcp(params, error) - } + params.id = isEditMode ? 'edit-mcp' : 'add-new-mcp' + return isEditMode + ? this.#handleEditMcpServer({ ...params, title: originalServerName! }, error) + : this.#handleAddNewMcp(params, error) } - // Process args to string[] + // stdio‑specific parsing let args: string[] = [] - const argsValue = params.optionsValues.args + let env: Record = {} + if (selectedTransport === 'stdio') { + try { + args = (Array.isArray(params.optionsValues.args) ? params.optionsValues.args : []) + .map((item: any) => + item && typeof item === 'object' && 'arg_key' in item ? String(item.arg_key) : '' + ) + .filter(Boolean) + } catch (e) { + this.#features.logging.warn(`MCP: Failed to process args: ${e}`) + } - // Handle the case where argsValue might be a direct array or another type - try { - // Try to safely access and process the value - const argsArray = Array.isArray(argsValue) ? argsValue : [] - args = argsArray - .map((item: any) => { - return typeof item === 'object' && item !== null && 'arg_key' in item ? String(item.arg_key) : '' - }) - .filter(Boolean) - } catch (e) { - this.#features.logging.warn(`Failed to process args: ${e}`) + try { + env = ( + Array.isArray(params.optionsValues.env_variables) ? params.optionsValues.env_variables : [] + ).reduce((acc: Record, item: any) => { + if (item && 'env_var_name' in item && 'env_var_value' in item) { + acc[String(item.env_var_name)] = String(item.env_var_value) + } + return acc + }, {}) + } catch (e) { + this.#features.logging.warn(`MCP: Failed to process env variables: ${e}`) + } } - // Process env_variables to Record - let env: Record = {} - const envValue = params.optionsValues.env_variables - - try { - const envArray = Array.isArray(envValue) ? envValue : [] - env = envArray.reduce((acc: Record, item: any) => { - if (item && typeof item === 'object' && 'env_var_name' in item && 'env_var_value' in item) { - acc[String(item.env_var_name)] = String(item.env_var_value) - } - return acc - }, {}) - } catch (e) { - this.#features.logging.warn(`Failed to process env variables: ${e}`) + // http‑specific parsing + let headers: Record = {} + if (selectedTransport === 'http') { + try { + const raw = Array.isArray(params.optionsValues.headers) ? params.optionsValues.headers : [] + headers = raw.reduce((acc: Record, item: any) => { + const k = item.key?.toString().trim() ?? '' + const v = item.value?.toString().trim() ?? '' + // both empty → skip + if (k === '' && v === '') { + return acc + } + // otherwise keep (validation layer handles partial-empty cases) + acc[k] = item.value ?? '' + return acc + }, {}) + } catch (e) { + this.#features.logging.warn(`MCP: Failed to process headers: ${e}`) + } } // Config file requires timeout in milliseconds const timeoutInMs = (parseInt(params.optionsValues.timeout) ?? 60) * 1000 - const config: MCPServerConfig = { - command: params.optionsValues.command, - args, - env, - timeout: timeoutInMs, + + // build final config (no transport field persisted) + let config: MCPServerConfig + if (selectedTransport === 'http') { + config = { + url: params.optionsValues.url, + headers, + timeout: timeoutInMs, + } + } else { + config = { + command: params.optionsValues.command, + args, + env, + timeout: timeoutInMs, + } } // Get agent path based on scope @@ -661,7 +725,8 @@ export class McpEventHandler { try { if (isEditMode && originalServerName) { - await McpManager.instance.removeServer(originalServerName) + const serverToRemove = this.#serverNameBeforeUpdate || originalServerName + await McpManager.instance.removeServer(serverToRemove) await McpManager.instance.addServer(serverName, config, agentPath) } else { // Create new server @@ -677,14 +742,14 @@ export class McpEventHandler { // need to check server state now, as there is possibility of error during server initialization const serverStatusError = this.#getServerStatusError(serverName) - // Emit telemetry event regardless of success/failure this.#telemetryController?.emitMCPServerInitializeEvent({ source: isEditMode ? 'updateServer' : 'addServer', - command: config.command, + command: selectedTransport === 'stdio' ? params.optionsValues.command : undefined, + url: selectedTransport === 'http' ? params.optionsValues.url : undefined, enabled: true, numTools: McpManager.instance.getAllToolsWithPermissions(serverName).length, scope: params.optionsValues['scope'] === 'global' ? 'global' : 'workspace', - transportType: 'stdio', + transportType: selectedTransport, languageServerVersion: this.#features.runtime.serverInfo.version, }) @@ -732,23 +797,17 @@ export class McpEventHandler { if (serverName === 'Built-in') { // Handle Built-in server specially const allTools = this.#features.agent.getTools({ format: 'bedrock' }) - const builtInToolNames = new Set(this.#features.agent.getBuiltInToolNames()) - // combine fsWrite and fsReplace into fsWrite + const mcpToolNames = new Set(McpManager.instance.getAllTools().map(tool => tool.toolName)) const builtInTools = allTools - .filter(tool => { - return ( - builtInToolNames.has(tool.toolSpecification.name) && tool.toolSpecification.name !== 'fsReplace' - ) - }) + .filter(tool => !mcpToolNames.has(tool.toolSpecification.name)) .map(tool => { - const permission = McpManager.instance.getToolPerm(serverName, tool.toolSpecification.name) + // Set default permission based on tool name + const permission = 'alwaysAllow' + return { tool: { toolName: tool.toolSpecification.name, - description: - this.#getBuiltInToolDescription(tool.toolSpecification.name) || - tool.toolSpecification.description || - `${tool.toolSpecification.name} tool`, + description: tool.toolSpecification.description || `${tool.toolSpecification.name} tool`, }, permission, } @@ -761,7 +820,6 @@ export class McpEventHandler { header: { title: serverName, status: serverStatusError || {}, - description: 'TOOLS', actions: [], }, list: [], @@ -891,6 +949,7 @@ export class McpEventHandler { // Set programmatic change flag to true to prevent file watcher triggers this.#isProgrammaticChange = true await this.#handleSavePermissionChange({ id: 'save-mcp-permission' }) + const serverName = params.title if (!serverName) { this.#isProgrammaticChange = false @@ -914,20 +973,32 @@ export class McpEventHandler { } } + // Respect a user flip first; otherwise fall back to what the stored configuration implies. + const transport = params.optionsValues?.transport ?? (config.url ? 'http' : 'stdio') + + // Convert stored structures to UI‑friendly lists + const argsList = (config.args ?? []).map(a => ({ arg_key: a })) // for stdio + const envList = Object.entries(config.env ?? {}).map(([k, v]) => ({ + env_var_name: k, + env_var_value: v, + })) // for stdio + const headersList = Object.entries(config.headers ?? {}).map(([k, v]) => ({ + key: k, + value: v, + })) // for http + // UI must display timeout to user in seconds const timeoutInSeconds = params.optionsValues?.timeout || Math.floor((config.timeout ?? 60000) / 1000).toString() + const existingValues: Record = { name: params.optionsValues?.name || serverName, - transport: 'stdio', + transport, command: params.optionsValues?.command || config.command, - args: params.optionsValues?.args || (config.args ?? []).map(a => ({ arg_key: a })), - env_variables: - params.optionsValues?.env_variables || - Object.entries(config.env ?? {}).map(([k, v]) => ({ - env_var_name: k, - env_var_value: v, - })), + args: params.optionsValues?.args || argsList, + env_variables: params.optionsValues?.env_variables || envList, + url: params.optionsValues?.url || config.url, + headers: params.optionsValues?.headers || headersList, timeout: timeoutInSeconds, scope: params.optionsValues?.scope, } @@ -957,29 +1028,53 @@ export class McpEventHandler { // Add tool select options toolsWithPermissions.forEach(item => { const toolName = item.tool.toolName + const currentPermission = this.#getCurrentPermission(item.permission) // For Built-in server, use a special function that doesn't include the 'Deny' option - let permissionOptions = this.#buildPermissionOptions(item.permission) - - if (serverName === 'Built-in') { - permissionOptions = this.#buildBuiltInPermissionOptions(item.permission) - } + const permissionOptions = this.#buildPermissionOptions(item.permission) filterOptions.push({ type: 'select', id: `${toolName}`, title: toolName, description: item.tool.description, + placeholder: currentPermission, options: permissionOptions, - ...(toolName === 'fsWrite' - ? { disabled: true, selectTooltip: 'Permission for this tool is not configurable yet' } - : {}), - ...{ value: item.permission, boldTitle: true, mandatory: true, hideMandatoryIcon: true }, }) }) return filterOptions } + async #handleChangeTransport(params: McpServerClickParams) { + const { optionsValues, title } = params + const editingServerName = this.#currentEditingServerName + + // Clean up transport-specific fields + if (optionsValues) { + const transport = optionsValues.transport ?? 'stdio' // Maintain default to 'stdio' + const fieldsToDelete = transport === 'http' ? ['command', 'args', 'env_variables'] : ['url', 'headers'] + + fieldsToDelete.forEach(field => delete optionsValues[field]) + } + + // Handle server name change in edit mode + if (editingServerName && title && editingServerName !== title) { + const servers = McpManager.instance.getAllServerConfigs() + const existingConfig = servers.get(editingServerName) + + if (existingConfig) { + const updatedServers = new Map(servers) + updatedServers.delete(editingServerName) + updatedServers.set(title, existingConfig) + await McpManager.instance.updateServerMap(updatedServers) + } + this.#serverNameBeforeUpdate = editingServerName + } + + params.id = editingServerName ? 'edit-mcp' : 'add-new-mcp' + return editingServerName ? this.#handleEditMcpServer(params) : this.#handleAddNewMcp(params) + } + /** * Gets the current permission setting for a tool */ @@ -999,19 +1094,17 @@ export class McpEventHandler { #buildPermissionOptions(currentPermission: string) { const permissionOptions: PermissionOption[] = [] - permissionOptions.push({ - label: 'Ask', - value: McpPermissionType.ask, - description: 'Ask for your approval each time this tool is run', - }) + if (currentPermission !== McpPermissionType.alwaysAllow) { + permissionOptions.push({ label: 'Always allow', value: McpPermissionType.alwaysAllow }) + } - permissionOptions.push({ - label: 'Always allow', - value: McpPermissionType.alwaysAllow, - description: 'Always allow this tool to run without asking for approval', - }) + if (currentPermission !== McpPermissionType.ask) { + permissionOptions.push({ label: 'Ask', value: McpPermissionType.ask }) + } - permissionOptions.push({ label: 'Deny', value: McpPermissionType.deny, description: 'Never run this tool' }) + if (currentPermission !== McpPermissionType.deny) { + permissionOptions.push({ label: 'Deny', value: McpPermissionType.deny }) + } return permissionOptions } @@ -1019,57 +1112,33 @@ export class McpEventHandler { /** * Builds permission options for Built-in tools (no 'Disable' option) */ - #buildBuiltInPermissionOptions(currentPermission: string) { - const permissionOptions: PermissionOption[] = [] - - permissionOptions.push({ - label: 'Ask', - value: 'ask', - description: 'Ask for your approval each time this tool is run', - }) - - permissionOptions.push({ - label: 'Always Allow', - value: 'alwaysAllow', - description: 'Always allow this tool to run without asking for approval', - }) - - return permissionOptions - } - - #getBuiltInToolDescription(toolName: string) { - switch (toolName) { - case 'fsRead': - return 'Read the content of files.' - case 'listDirectory': - return 'List the structure of a directory and its subdirectories.' - case 'fileSearch': - return 'Search for files and directories using fuzzy name matching.' - case 'executeBash': - return 'Run shell or powershell commands.\n\nNote: read-only commands are auto-run' - case 'fsWrite': - case 'fsReplace': - return 'Create or edit files.' - case 'qCodeReview': - return 'Review tool analyzes code for security vulnerabilities, quality issues, and best practices across multiple programming languages.' - default: - return '' - } - } + // #buildBuiltInPermissionOptions(currentPermission: string) { + // const permissionOptions: PermissionOption[] = [] + + // if (currentPermission !== 'alwaysAllow') { + // permissionOptions.push({ + // label: 'Always run', + // value: 'alwaysAllow', + // }) + // } + + // if (currentPermission !== 'ask') { + // permissionOptions.push({ + // label: 'Ask to run', + // value: 'ask', + // }) + // } + + // return permissionOptions + // } /** * Handles MCP permission change events to update the pending permission config without applying changes */ async #handleMcpPermissionChange(params: McpServerClickParams) { const serverName = params.title - - // combine fsWrite and fsReplace into fsWrite - if (serverName === 'Built-in' && params.optionsValues?.fsWrite) { - // add fsReplace along - params.optionsValues.fsReplace = params.optionsValues.fsWrite - } - const updatedPermissionConfig = params.optionsValues + if (!serverName || !updatedPermissionConfig) { return { id: params.id } } @@ -1105,6 +1174,11 @@ export class McpEventHandler { * Applies the stored permission changes */ async #handleSavePermissionChange(params: McpServerClickParams) { + if (!params.optionsValues) { + return this.#getDefaultMcpResponse(params.id) + } + const selectedTransport = params.optionsValues.transport + if (!this.#pendingPermissionConfig) { this.#features.logging.warn('No pending permission changes to save') return { id: params.id } @@ -1126,7 +1200,8 @@ export class McpEventHandler { // Emit server initialize event after permission change this.#telemetryController?.emitMCPServerInitializeEvent({ source: 'updatePermission', - command: serverConfig.command, + command: selectedTransport === 'stdio' ? params.optionsValues.command : undefined, + url: selectedTransport === 'http' ? params.optionsValues.url : undefined, enabled: true, numTools: McpManager.instance.getAllToolsWithPermissions(serverName).length, scope: @@ -1134,7 +1209,7 @@ export class McpEventHandler { getGlobalAgentConfigPath(this.#features.workspace.fs.getUserHomeDir()) ? 'global' : 'workspace', - transportType: 'stdio', + transportType: selectedTransport, languageServerVersion: this.#features.runtime.serverInfo.version, }) } @@ -1192,10 +1267,12 @@ export class McpEventHandler { // Emit server initialize events for all active servers for (const [serverName, config] of serverConfigs.entries()) { + const transportType = config.command ? 'stdio' : 'http' // const enabled = !mcpManager.isServerDisabled(serverName) this.#telemetryController?.emitMCPServerInitializeEvent({ source: 'reload', - command: config.command, + command: transportType === 'stdio' ? config.command : undefined, + url: transportType === 'http' ? config.url : undefined, enabled: true, numTools: mcpManager.getAllToolsWithPermissions(serverName).length, scope: config.__configPath__ === globalAgentPath ? 'global' : 'workspace', diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.test.ts index 6f82a0064d..0f9e33bf26 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.test.ts @@ -219,6 +219,7 @@ describe('addServer()', () => { it('persists config and initializes', async () => { const mgr = await McpManager.init([], features) + const newCfg: MCPServerConfig = { command: 'c2', args: ['a'], @@ -226,9 +227,43 @@ describe('addServer()', () => { timeout: 0, __configPath__: 'path.json', } + await mgr.addServer('newS', newCfg, 'path.json') + + expect(saveAgentConfigStub.calledOnce).to.be.true + expect(initOneStub.calledOnceWith('newS', sinon.match(newCfg))).to.be.true + }) + + it('persists and initializes an HTTP server', async () => { + loadStub.resolves({ + servers: new Map(), + serverNameMapping: new Map(), + errors: new Map(), + agentConfig: { + name: 'test-agent', + version: '1.0.0', + description: 'Test agent', + mcpServers: {}, + tools: [], + allowedTools: [], + toolsSettings: {}, + includedFiles: [], + resources: [], + }, + }) + const mgr = await McpManager.init([], features) + + const httpCfg: MCPServerConfig = { + url: 'https://api.example.com/mcp', + headers: { Authorization: 'Bearer 123' }, + timeout: 0, + __configPath__: 'http.json', + } + + await mgr.addServer('httpSrv', httpCfg, 'http.json') + expect(saveAgentConfigStub.calledOnce).to.be.true - expect(initOneStub.calledWith('newS', newCfg)).to.be.true + expect(initOneStub.calledOnceWith('httpSrv', sinon.match(httpCfg))).to.be.true }) }) @@ -411,7 +446,7 @@ describe('updateServer()', () => { } catch {} }) - it('re-initializes when changing timeout', async () => { + it('re‑initializes when changing timeout', async () => { const oldCfg: MCPServerConfig = { command: 'cmd', args: [], @@ -419,6 +454,7 @@ describe('updateServer()', () => { timeout: 1, __configPath__: 'u.json', } + loadStub = sinon.stub(mcpUtils, 'loadAgentConfig').resolves({ servers: new Map([['u1', oldCfg]]), serverNameMapping: new Map([['u1', 'u1']]), @@ -435,6 +471,7 @@ describe('updateServer()', () => { resources: [], }, }) + await McpManager.init([], features) const mgr = McpManager.instance const fakeClient = new Client({ name: 'c', version: 'v' }) @@ -444,10 +481,49 @@ describe('updateServer()', () => { initOneStub.resetHistory() saveAgentConfigStub.resetHistory() - await mgr.updateServer('u1', { timeout: 999 }, 'fakepath') + await mgr.updateServer('u1', { timeout: 999 }, 'u.json') + expect(saveAgentConfigStub.calledOnce).to.be.true expect(closeStub.calledOnce).to.be.true - expect(initOneStub.calledOnce).to.be.true + expect(initOneStub.calledOnceWith('u1', sinon.match.has('timeout', 999))).to.be.true + }) + + it('switches from stdio to http by clearing command and setting url', async () => { + const oldCfg: MCPServerConfig = { + command: 'cmd', + args: [], + env: {}, + timeout: 0, + __configPath__: 'z.json', + } + + loadStub = sinon.stub(mcpUtils, 'loadAgentConfig').resolves({ + servers: new Map([['srv', oldCfg]]), + serverNameMapping: new Map([['srv', 'srv']]), + errors: new Map(), + agentConfig: { + name: 'test-agent', + version: '1.0.0', + description: 'Test agent', + mcpServers: { srv: oldCfg }, + tools: ['@srv'], + allowedTools: [], + toolsSettings: {}, + includedFiles: [], + resources: [], + }, + }) + + await McpManager.init([], features) + const mgr = McpManager.instance + + initOneStub.resetHistory() + saveAgentConfigStub.resetHistory() + + await mgr.updateServer('srv', { command: undefined, url: 'https://new.host/mcp' }, 'z.json') + + expect(saveAgentConfigStub.calledOnce).to.be.true + expect(initOneStub.calledOnceWith('srv', sinon.match({ url: 'https://new.host/mcp' }))).to.be.true }) }) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts index a8ad242d7c..d9204839ec 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpManager.ts @@ -7,6 +7,11 @@ import type { Features } from '@aws/language-server-runtimes/server-interface/se import { ChatTelemetryEventName } from '../../../../shared/telemetry/types' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { + StreamableHTTPClientTransport, + StreamableHTTPClientTransportOptions, +} from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import { SSEClientTransport, SSEClientTransportOptions } from '@modelcontextprotocol/sdk/client/sse.js' import { MCPServerConfig, McpToolDefinition, @@ -266,73 +271,106 @@ export class McpManager { try { this.features.logging.debug(`MCP: initializing server [${serverName}]`) - const mergedEnv = { - ...(process.env as Record), - // Make sure we do not have empty key and value in mergedEnv, or adding server through UI will fail on Windows - ...(cfg.env && !isEmptyEnv(cfg.env) - ? Object.fromEntries(Object.entries(cfg.env).filter(([key]) => key && key.trim() !== '')) - : {}), - } - const transportConfig: any = { - command: cfg.command, - args: cfg.args ?? [], - env: mergedEnv, - } - - try { - const workspaceFolders = this.features.workspace.getAllWorkspaceFolders() - if (workspaceFolders.length > 0) { - transportConfig.cwd = URI.parse(workspaceFolders[0].uri).fsPath - } - } catch { - this.features.logging.debug( - `MCP: No workspace folder available for server [${serverName}], continuing without cwd` - ) - } - - const transport = new StdioClientTransport(transportConfig) const client = new Client({ name: `mcp-client-${serverName}`, version: '1.0.0', }) - const connectPromise = client.connect(transport).catch(err => { - let errorMessage = err.message - - // Provide specific guidance for common command not found errors - if (err.code === 'ENOENT') { - errorMessage = `Command '${cfg.command}' not found. Please ensure it's installed and available in your PATH.` - } else if (err.code === 'EINVAL') { - errorMessage = `Invalid arguments. Please check the command and arguments.` - } else if (err.code === -32000) { - errorMessage = `MCP protocol error. The server may not be properly configured.` + let transport: any + const isStdio = !!cfg.command + const doConnect = async () => { + if (isStdio) { + const mergedEnv = { + ...(process.env as Record), + // Make sure we do not have empty key and value in mergedEnv, or adding server through UI will fail on Windows + ...(cfg.env && !isEmptyEnv(cfg.env) + ? Object.fromEntries(Object.entries(cfg.env).filter(([k, v]) => k.trim() && v.trim())) + : {}), + } + let cwd: string | undefined + try { + const folders = this.features.workspace.getAllWorkspaceFolders() + if (folders.length > 0) cwd = URI.parse(folders[0].uri).fsPath + } catch { + this.features.logging.debug( + `MCP: no workspace folder for [${serverName}], continuing without cwd` + ) + } + transport = new StdioClientTransport({ + command: cfg.command!, + args: cfg.args ?? [], + env: mergedEnv, + cwd, + }) + this.features.logging.info(`MCP: Connecting MCP server using StdioClientTransport`) + try { + await client.connect(transport) + } catch (err: any) { + let errorMessage = err?.message ?? String(err) + if (err?.code === 'ENOENT') { + errorMessage = `Command '${cfg.command}' not found. Please ensure it's installed and on your PATH.` + } else if (err?.code === 'EINVAL') { + errorMessage = `Invalid arguments for command '${cfg.command}'.` + } else if (err?.code === -32000) { + errorMessage = `MCP protocol error. The server may not be properly configured.` + } + throw new AgenticChatError( + `MCP: server '${serverName}' failed to connect: ${errorMessage}`, + 'MCPServerConnectionFailed' + ) + } + } else { + const base = new URL(cfg.url!) + try { + try { + // try streamable http first + transport = new StreamableHTTPClientTransport(base, this.buildHttpOpts(cfg.headers)) + this.features.logging.info(`MCP: Connecting MCP server using StreamableHTTPClientTransport`) + await client.connect(transport) + } catch (err) { + // fallback to SSE + this.features.logging.info( + `MCP: streamable http connect failed for [${serverName}], fallback to SSEClientTransport: ${String(err)}` + ) + transport = new SSEClientTransport(new URL(cfg.url!), this.buildSseOpts(cfg.headers)) + await client.connect(transport) + } + } catch (err: any) { + let errorMessage = err?.message ?? String(err) + throw new AgenticChatError( + `MCP: server '${serverName}' failed to connect: ${errorMessage}`, + 'MCPServerConnectionFailed' + ) + } } + } - throw new AgenticChatError( - `MCP: server '${serverName}' failed to connect: ${errorMessage}`, - 'MCPServerConnectionFailed' - ) - }) - - // 0 or undefined -> no timeout - if (cfg.initializationTimeout === 0 || cfg.initializationTimeout === undefined) { - await connectPromise + const connectPromise = doConnect() + + const timeoutMs = + cfg.initializationTimeout === 0 || cfg.initializationTimeout === undefined + ? 0 + : (cfg.initializationTimeout ?? DEFAULT_SERVER_INIT_TIMEOUT_MS) + + if (timeoutMs > 0) { + await Promise.race([ + connectPromise, + new Promise((_, reject) => { + const t = setTimeout( + () => + reject( + new AgenticChatError( + `MCP: server '${serverName}' initialization timed out after ${timeoutMs} ms`, + 'MCPServerInitTimeout' + ) + ), + timeoutMs + ) + t.unref() + }), + ]) } else { - const timeoutMs = cfg.initializationTimeout ?? DEFAULT_SERVER_INIT_TIMEOUT_MS - const timeoutPromise = new Promise((_, reject) => { - const timer = setTimeout( - () => - reject( - new AgenticChatError( - `MCP: server '${serverName}' initialization timed out after ${timeoutMs} ms`, - 'MCPServerInitTimeout' - ) - ), - timeoutMs - ) - timer.unref() - }) - await Promise.race([connectPromise, timeoutPromise]) + await connectPromise } this.clients.set(serverName, client) @@ -367,6 +405,13 @@ export class McpManager { } } + /** + * Update server map + */ + public updateServerMap(newMap: Map): void { + this.mcpServers = new Map(newMap) + } + /** * Return a list of all discovered tools. */ @@ -443,7 +488,7 @@ export class McpManager { */ public getToolPerm(server: string, tool: string): McpPermissionType { // For built-in tools, check directly without prefix - if (server === 'Built-in') { + if (server === 'builtIn') { return this.agentConfig.allowedTools.includes(tool) ? McpPermissionType.alwaysAllow : McpPermissionType.ask } @@ -566,9 +611,9 @@ export class McpManager { // Add server to agent config const serverConfig: MCPServerConfig = { command: cfg.command, + url: cfg.url, initializationTimeout: cfg.initializationTimeout, } - // Only add timeout to agent config if it's not 0 if (cfg.timeout !== 0) { serverConfig.timeout = cfg.timeout @@ -579,6 +624,9 @@ export class McpManager { if (cfg.env && !isEmptyEnv(cfg.env)) { serverConfig.env = cfg.env } + if (cfg.headers && !isEmptyEnv(cfg.headers)) { + serverConfig.headers = cfg.headers + } // Add to agent config this.agentConfig.mcpServers[serverName] = serverConfig @@ -716,6 +764,14 @@ export class McpManager { // Update agent config if (this.agentConfig && unsanitizedServerName) { const updatedConfig = { ...(this.agentConfig.mcpServers[unsanitizedServerName] || {}) } + if (configUpdates.url !== undefined) updatedConfig.url = configUpdates.url + if (configUpdates.headers !== undefined) { + if (configUpdates.headers && Object.keys(configUpdates.headers).length) { + updatedConfig.headers = configUpdates.headers + } else { + delete updatedConfig.headers // allow user to clear headers + } + } if (configUpdates.command !== undefined) updatedConfig.command = configUpdates.command if (configUpdates.initializationTimeout !== undefined) updatedConfig.initializationTimeout = configUpdates.initializationTimeout @@ -734,7 +790,6 @@ export class McpManager { delete updatedConfig.env } } - this.agentConfig.mcpServers[unsanitizedServerName] = updatedConfig // Save agent config @@ -853,7 +908,7 @@ export class McpManager { // Process each tool permission for (const [toolName, permission] of Object.entries(perm.toolPerms || {})) { - const toolId = (unsanitizedServerName !== 'Built-in' ? `${serverPrefix}/` : '') + `${toolName}` + const toolId = `${serverPrefix}/${toolName}` if (permission === McpPermissionType.deny) { // For deny: if server is enabled as a whole, we need to switch to individual tools @@ -963,49 +1018,10 @@ export class McpManager { */ public requiresApproval(server: string, tool: string): boolean { // For built-in tools, check directly without prefix - const toolId = server === 'Built-in' ? tool : `@${server}/${tool}` + const toolId = server === 'builtIn' ? tool : `@${server}/${tool}` return !this.agentConfig.allowedTools.includes(toolId) } - /** - * Updates the runtime state for a given server, including status, tool count, and optional error message. - * This is used by the UI to reflect real-time server status. - * @private - */ - private setState(server: string, status: McpServerStatus, toolsCount: number, lastError?: string) { - const st: McpServerRuntimeState = { status, toolsCount, lastError } - this.mcpServerStates.set(server, st) - this.events.emit(MCP_SERVER_STATUS_CHANGED, server, { ...st }) - } - - /** - * Emits an event when the tools associated with a server change. - * Used to refresh the Agent's tool list. - * @private - */ - private emitToolsChanged(server: string) { - const enabled = this.getEnabledTools() - .filter(t => t.serverName === server) - .map(t => ({ ...t })) - this.features.logging.debug(`ToolsChanged | server=${server} | toolCount=${enabled.length}`) - this.events.emit(AGENT_TOOLS_CHANGED, server, enabled) - } - - /** - * Centralized error handling: logs the error, updates the status, and emits an event. - * Exceptions are no longer thrown to ensure the remaining workflow continues uninterrupted. - */ - private handleError(server: string | undefined, err: unknown) { - const msg = err instanceof Error ? err.message : String(err) - - this.features.logging.error(`MCP ERROR${server ? ` [${server}]` : ''}: ${msg}`) - - if (server) { - this.setState(server, McpServerStatus.FAILED, 0, msg) - this.emitToolsChanged(server) - } - } - /** * Returns any errors that occurred during loading of MCP configuration files */ @@ -1147,4 +1163,105 @@ export class McpManager { public setToolNameMapping(mapping: Map): void { this.toolNameMapping = new Map(mapping) } + + /** + * Updates the runtime state for a given server, including status, tool count, and optional error message. + * This is used by the UI to reflect real-time server status. + * @private + */ + private setState(server: string, status: McpServerStatus, toolsCount: number, lastError?: string) { + const st: McpServerRuntimeState = { status, toolsCount, lastError } + this.mcpServerStates.set(server, st) + this.events.emit(MCP_SERVER_STATUS_CHANGED, server, { ...st }) + } + + /** + * Emits an event when the tools associated with a server change. + * Used to refresh the Agent's tool list. + * @private + */ + private emitToolsChanged(server: string) { + const enabled = this.getEnabledTools() + .filter(t => t.serverName === server) + .map(t => ({ ...t })) + this.features.logging.debug(`ToolsChanged | server=${server} | toolCount=${enabled.length}`) + this.events.emit(AGENT_TOOLS_CHANGED, server, enabled) + } + + /** + * Centralized error handling: logs the error, updates the status, and emits an event. + * Exceptions are no longer thrown to ensure the remaining workflow continues uninterrupted. + */ + private handleError(server: string | undefined, err: unknown) { + const msg = err instanceof Error ? err.message : String(err) + + this.features.logging.error(`MCP ERROR${server ? ` [${server}]` : ''}: ${msg}`) + + if (server) { + this.setState(server, McpServerStatus.FAILED, 0, msg) + this.emitToolsChanged(server) + } + } + + /** + * Ensure the server-specific config is internally consistent. + * Mutates `cfg` in-place, trimming fields that don't belong to the selected transport. + * @private + */ + private validateServerCfg(cfg: MCPServerConfig): void { + const hasCmd = !!cfg.command?.trim() + const hasUrl = !!cfg.url?.trim() + + if (hasCmd && hasUrl) throw new Error('Specify either command or url, not both') + if (!hasCmd && !hasUrl) throw new Error('Either command or url is required') + + if (hasCmd) { + if (!cfg.command!.trim()) throw new Error('Stdio transport requires "command"') + delete cfg.url + delete cfg.headers + } else { + if (!cfg.url!.trim()) throw new Error('HTTP transport requires "url"') + delete cfg.command + delete cfg.args + delete cfg.env + } + } + + /** + * Creates the option bag for SSEClientTransport + * @private + */ + private buildSseOpts(headers?: Record): SSEClientTransportOptions | undefined { + if (!headers || Object.keys(headers).length === 0) { + return + } + const requestInit: RequestInit = { headers } + + // override only the SSE‐GET: + const eventSourceInit = { + fetch: (input: RequestInfo | URL | string, init: RequestInit = {}) => { + const merged = new Headers(init.headers || {}) + for (const [k, v] of Object.entries(headers)) { + merged.set(k, v) + } + return fetch(input, { + ...init, + headers: merged, + }) + }, + } as any + + return { requestInit, eventSourceInit } + } + + /** + * Creates the option bag for StreamableHTTPClientTransport + * @private + */ + private buildHttpOpts(headers?: Record): StreamableHTTPClientTransportOptions | undefined { + if (!headers || Object.keys(headers).length === 0) { + return + } + return { requestInit: { headers } } + } } diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpTypes.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpTypes.ts index 3543fce9be..df6a39cc86 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpTypes.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpTypes.ts @@ -28,11 +28,13 @@ export interface McpToolDefinition { } export interface MCPServerConfig { - command: string + command?: string args?: string[] env?: Record initializationTimeout?: number timeout?: number + url?: string + headers?: Record __configPath__?: string } export interface MCPServerPermission { diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.test.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.test.ts index a95720b81f..9130451855 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.test.ts @@ -109,6 +109,38 @@ describe('loadMcpServerConfigs', () => { const out2 = await loadMcpServerConfigs(workspace, logger, [overridePath, globalPath]) expect(out2.servers.get('S')!.command).to.equal('workspaceCmd') }) + + it('loads config that uses url only', async () => { + const cfg = { mcpServers: { WebSrv: { url: 'https://example.com/mcp' } } } + const p = path.join(tmpDir, 'http.json') + fs.writeFileSync(p, JSON.stringify(cfg)) + + const out = await loadMcpServerConfigs(workspace, logger, [p]) + expect(out.servers.has('WebSrv')).to.be.true + const c = out.servers.get('WebSrv')! + expect(c.url).to.equal('https://example.com/mcp') + expect(c.command).to.be.undefined + }) + + it('skips server that specifies both command and url', async () => { + const cfg = { mcpServers: { BadSrv: { command: 'foo', url: 'https://example.com' } } } + const p = path.join(tmpDir, 'bad.json') + fs.writeFileSync(p, JSON.stringify(cfg)) + + const out = await loadMcpServerConfigs(workspace, logger, [p]) + expect(out.servers.size).to.equal(0) + expect(out.errors.get('BadSrv')).to.match(/either.*command.*url/i) + }) + + it('skips server that has neither command nor url', async () => { + const cfg = { mcpServers: { EmptySrv: { args: [] } } } + const p = path.join(tmpDir, 'empty.json') + fs.writeFileSync(p, JSON.stringify(cfg)) + + const out = await loadMcpServerConfigs(workspace, logger, [p]) + expect(out.servers.size).to.equal(0) + expect(out.errors.get('EmptySrv')).to.match(/either.*command.*url/i) + }) }) describe('loadPersonaPermissions', () => { @@ -386,17 +418,6 @@ describe('loadMcpServerConfigs error handling', () => { expect(result.errors.get(missingFieldPath)).to.include("missing or invalid 'mcpServers' field") }) - it('captures missing command errors', async () => { - const missingCommandPath = path.join(tmpDir, 'missing-command.json') - fs.writeFileSync(missingCommandPath, '{"mcpServers": {"serverA": {"args": []}}}') - - const result = await loadMcpServerConfigs(workspace, logger, [missingCommandPath]) - - expect(result.servers.size).to.equal(0) - expect(result.errors.size).to.equal(1) - expect(result.errors.get('serverA')).to.include("missing required 'command'") - }) - it('captures invalid timeout errors', async () => { const invalidTimeoutPath = path.join(tmpDir, 'invalid-timeout.json') fs.writeFileSync( diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts index 190d9db74b..2f7e4e7b3a 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/mcp/mcpUtils.ts @@ -10,7 +10,6 @@ import path = require('path') import { QClientCapabilities } from '../../../configuration/qConfigurationServer' import crypto = require('crypto') import { Features } from '@aws/language-server-runtimes/server-interface/server' -import { EXECUTE_BASH } from '../../constants/toolConstants' /** * Load, validate, and parse MCP server configurations from JSON files. @@ -90,27 +89,33 @@ export async function loadMcpServerConfigs( } // 4) dedupe and validate - for (const [name, entry] of Object.entries(json.mcpServers)) { - if (!entry || typeof (entry as any).command !== 'string') { - const errorMsg = `MCP server '${name}' in ${fsPath} missing required 'command', skipping.` + for (const [name, entryRaw] of Object.entries(json.mcpServers)) { + const entry = entryRaw as any + + const hasCmd = typeof entry.command === 'string' && entry.command.trim() !== '' + const hasUrl = typeof entry.url === 'string' && entry.url.trim() !== '' + + if ((hasCmd && hasUrl) || (!hasCmd && !hasUrl)) { + const errorMsg = `MCP server '${name}' must specify *either* command or url (not both) – skipping` logging.warn(errorMsg) configErrors.set(`${name}`, errorMsg) continue } + if ((entry as any).timeout !== undefined && typeof (entry as any).timeout !== 'number') { const errorMsg = `Invalid timeout value on '${name}', ignoring.` logging.warn(errorMsg) configErrors.set(`${name}_timeout`, errorMsg) } const cfg: MCPServerConfig = { - command: (entry as any).command, - args: Array.isArray((entry as any).args) ? (entry as any).args.map(String) : [], - env: typeof (entry as any).env === 'object' && (entry as any).env !== null ? (entry as any).env : {}, + url: entry.url, + headers: typeof entry.headers === 'object' && entry.headers !== null ? entry.headers : undefined, + command: entry.command, + args: Array.isArray(entry.args) ? entry.args.map(String) : [], + env: typeof entry.env === 'object' && entry.env !== null ? entry.env : {}, initializationTimeout: - typeof (entry as any).initializationTimeout === 'number' - ? (entry as any).initializationTimeout - : undefined, - timeout: typeof (entry as any).timeout === 'number' ? (entry as any).timeout : undefined, + typeof entry.initializationTimeout === 'number' ? entry.initializationTimeout : undefined, + timeout: typeof entry.timeout === 'number' ? entry.timeout : undefined, __configPath__: fsPath, } @@ -346,9 +351,13 @@ export async function loadAgentConfig( // 6) Process MCP servers (similar to loadMcpServerConfigs) if (json.mcpServers && typeof json.mcpServers === 'object') { - for (const [name, entry] of Object.entries(json.mcpServers)) { - if (!entry || typeof (entry as any).command !== 'string') { - const errorMsg = `MCP server '${name}' in ${fsPath} missing required 'command', skipping.` + for (const [name, entryRaw] of Object.entries(json.mcpServers)) { + const entry = entryRaw as any + const hasCmd = typeof entry.command === 'string' && entry.command.trim() !== '' + const hasUrl = typeof entry.url === 'string' && entry.url.trim() !== '' + + if ((hasCmd && hasUrl) || (!hasCmd && !hasUrl)) { + const errorMsg = `MCP server '${name}' must specify *either* command or url (not both) – skipping` logging.warn(errorMsg) configErrors.set(`${name}`, errorMsg) continue @@ -357,9 +366,14 @@ export async function loadAgentConfig( // Create server config const cfg: MCPServerConfig = { command: (entry as any).command, + url: (entry as any).url, args: Array.isArray((entry as any).args) ? (entry as any).args.map(String) : [], env: typeof (entry as any).env === 'object' && (entry as any).env !== null ? (entry as any).env : {}, + headers: + typeof (entry as any).headers === 'object' && (entry as any).headers !== null + ? (entry as any).headers + : undefined, initializationTimeout: typeof (entry as any).initializationTimeout === 'number' ? (entry as any).initializationTimeout @@ -393,13 +407,17 @@ export async function loadAgentConfig( serverNameMapping.set(sanitizedName, name) // Add to agent config - agentConfig.mcpServers[name] = { - command: cfg.command, - args: cfg.args, - env: cfg.env, - initializationTimeout: cfg.initializationTimeout, - timeout: cfg.timeout, + const agentEntry: any = {} + if (cfg.command) agentEntry.command = cfg.command + if (cfg.url) agentEntry.url = cfg.url + if (cfg.args && cfg.args.length) agentEntry.args = cfg.args + if (cfg.env && Object.keys(cfg.env).length) agentEntry.env = cfg.env + if (cfg.headers && Object.keys(cfg.headers).length) agentEntry.headers = cfg.headers + if (typeof cfg.initializationTimeout === 'number') { + agentEntry.initializationTimeout = cfg.initializationTimeout } + if (typeof cfg.timeout === 'number') agentEntry.timeout = cfg.timeout + agentConfig.mcpServers[name] = agentEntry logging.info( `Loaded MCP server with sanitizedName: '${sanitizedName}' and originalName: '${name}' from ${fsPath}` @@ -645,7 +663,7 @@ export function convertPersonaToAgent( // Add default allowed tools const writeToolNames = new Set(featureAgent.getBuiltInWriteToolNames()) - const defaultAllowedTools = featureAgent.getBuiltInToolNames().filter(toolName => toolName !== EXECUTE_BASH) + const defaultAllowedTools = featureAgent.getBuiltInToolNames().filter(toolName => !writeToolNames.has(toolName)) for (const toolName of defaultAllowedTools) { if (!agent.allowedTools.includes(toolName)) { agent.allowedTools.push(toolName) diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReview.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReview.ts index 3a6a8eee7a..6741db106e 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReview.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/qCodeAnalysis/codeReview.ts @@ -1,6 +1,6 @@ /* eslint-disable import/no-nodejs-modules */ -import { CodeWhispererServiceToken } from '../../../../shared/codeWhispererService' +import { CodeWhispererServiceToken } from '../../../../shared/codeWhispererService/codeWhispererServiceToken' import { Features } from '@aws/language-server-runtimes/server-interface/server' import { CODE_REVIEW_TOOL_NAME, diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolServer.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolServer.ts index c49927eefd..ff9c78987f 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolServer.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolServer.ts @@ -11,7 +11,7 @@ import { McpTool } from './mcp/mcpTool' import { FileSearch, FileSearchParams } from './fileSearch' import { GrepSearch } from './grepSearch' import { CodeReview } from './qCodeAnalysis/codeReview' -import { CodeWhispererServiceToken } from '../../../shared/codeWhispererService' +import { CodeWhispererServiceToken } from '../../../shared/codeWhispererService/codeWhispererServiceToken' import { McpToolDefinition } from './mcp/mcpTypes' import { getGlobalAgentConfigPath, diff --git a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolShared.ts b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolShared.ts index 0314bdd754..0303b7fc81 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolShared.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/agenticChat/tools/toolShared.ts @@ -3,7 +3,6 @@ import { workspaceUtils } from '@aws/lsp-core' import { getWorkspaceFolderPaths } from '@aws/lsp-core/out/util/workspaceUtils' import * as path from 'path' import { CommandCategory } from './executeBash' -import { OUT_OF_WORKSPACE_WARNING_MSG } from '../constants/constants' interface Output { kind: Kind @@ -128,13 +127,10 @@ export async function requiresPathAcceptance( if (logging) { logging.debug('No workspace folders found when checking file acceptance') } - return { requiresAcceptance: true, warning: OUT_OF_WORKSPACE_WARNING_MSG } + return { requiresAcceptance: true } } const isInWorkspace = workspaceUtils.isInWorkspace(workspaceFolders, path) - return { - requiresAcceptance: !isInWorkspace, - warning: !isInWorkspace ? OUT_OF_WORKSPACE_WARNING_MSG : undefined, - } + return { requiresAcceptance: !isInWorkspace } } catch (error) { if (logging) { logging.error(`Error checking file acceptance: ${error}`) diff --git a/server/aws-lsp-codewhisperer/src/language-server/chat/chatController.test.ts b/server/aws-lsp-codewhisperer/src/language-server/chat/chatController.test.ts index 216c0cf6cf..8b78a09e78 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/chat/chatController.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/chat/chatController.test.ts @@ -639,6 +639,31 @@ describe('ChatController', () => { assert.deepStrictEqual(chatResult, new ResponseError(LSPErrorCodes.RequestFailed, 'invalid state')) }) + it('emits telemetry on successful inline chat response', async () => { + await chatController.onInlineChatPrompt({ prompt: { prompt: 'Hello' } }, mockCancellationToken) + + sinon.assert.calledWith( + testFeatures.telemetry.emitMetric, + sinon.match.has('name', 'codewhisperer_inlineChatServiceInvocation') + ) + }) + + it('emits failure telemetry when inline chat service invocation fails', async () => { + sendMessageStub.callsFake(() => { + throw new Error('Service Error') + }) + + await chatController.onInlineChatPrompt({ prompt: { prompt: 'Hello' } }, mockCancellationToken) + + sinon.assert.calledWith( + testFeatures.telemetry.emitMetric, + sinon.match({ + name: 'codewhisperer_inlineChatServiceInvocation', + result: 'Failed', + }) + ) + }) + describe('#extractDocumentContext', () => { const typescriptDocument = TextDocument.create('file:///test.ts', 'typescript', 1, 'test') let extractDocumentContextStub: sinon.SinonStub diff --git a/server/aws-lsp-codewhisperer/src/language-server/chat/chatController.ts b/server/aws-lsp-codewhisperer/src/language-server/chat/chatController.ts index 265b4489cd..30235a0af9 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/chat/chatController.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/chat/chatController.ts @@ -9,15 +9,8 @@ import { TextEdit, chatRequestType, InlineChatResultParams, - NotificationHandler, - PromptInputOptionChangeParams, ButtonClickParams, ButtonClickResult, - ListMcpServersParams, - ListMcpServersResult, - McpServerClickParams, - McpServerClickResult, - RequestHandler, OpenFileDialogParams, OpenFileDialogResult, } from '@aws/language-server-runtimes/protocol' @@ -98,6 +91,16 @@ export class ChatController implements ChatHandlers { #telemetryService: TelemetryService #serviceManager: AmazonQBaseServiceManager + #inlineChatRequestStartTime: number = 0 + #inlineChatResponseLatency: number = 0 + #inlineChatRequestId?: string + #inlineChatLanguage?: string + #inlineChatTriggerType: string = 'OnDemand' + #inlineChatCredentialStartUrl?: string + #inlineChatCustomizationArn?: string + #inlineChatResponseLength: number = 0 + #inlineChatRequestPromptLength: number = 0 + constructor( chatSessionManagementService: ChatSessionManagementService, features: Features, @@ -265,10 +268,45 @@ export class ChatController implements ChatHandlers { this.#customizationArn ) + this.#inlineChatRequestStartTime = Date.now() + this.#inlineChatRequestId = undefined + this.#inlineChatLanguage = triggerContext.programmingLanguage?.languageName + this.#inlineChatTriggerType = ChatTriggerType.MANUAL + this.#inlineChatCustomizationArn = this.#customizationArn + this.#inlineChatRequestPromptLength = params.prompt?.prompt?.length ?? 0 + const client = this.#serviceManager.getStreamingClient() response = await client.sendMessage(requestInput) + + this.#inlineChatRequestId = response.$metadata.requestId + this.#log('Response for inline chat', JSON.stringify(response.$metadata), JSON.stringify(response)) } catch (err) { + this.#log(`Inline Chat Service Invocation Failed: ${err instanceof Error ? err.message : 'unknown'}`) + + this.#inlineChatResponseLatency = Date.now() - this.#inlineChatRequestStartTime + this.#features.telemetry.emitMetric({ + name: 'codewhisperer_inlineChatServiceInvocation', + result: 'Failed', + data: { + codewhispererRequestId: isAwsError(err) ? err.requestId : undefined, + codewhispererTriggerType: this.#inlineChatTriggerType, + duration: this.#inlineChatResponseLatency, + codewhispererLanguage: this.#inlineChatLanguage, + credentialStartUrl: this.#inlineChatCredentialStartUrl, + codewhispererCustomizationArn: this.#inlineChatCustomizationArn, + result: 'Failed', + requestLength: this.#inlineChatRequestPromptLength, + responseLength: 0, + reason: `Inline Chat Invocation Exception: ${err instanceof Error ? err.name : 'UnknownError'}`, + }, + errorData: { + reason: err instanceof Error ? err.name : 'UnknownError', + errorCode: isAwsError(err) ? err.code : undefined, + httpStatusCode: isAwsError(err) ? err.statusCode : undefined, + }, + }) + if (err instanceof AmazonQServicePendingSigninError || err instanceof AmazonQServicePendingProfileError) { this.#log(`Q Inline Chat SSO Connection error: ${getErrorMessage(err)}`) return new ResponseError(LSPErrorCodes.RequestFailed, err.message) @@ -286,6 +324,22 @@ export class ChatController implements ChatHandlers { metric, params.partialResultToken ) + this.#inlineChatResponseLatency = Date.now() - this.#inlineChatRequestStartTime + this.#inlineChatResponseLength = result.data?.chatResult.body?.length ?? 0 + this.#features.telemetry.emitMetric({ + name: 'codewhisperer_inlineChatServiceInvocation', + data: { + codewhispererRequestId: this.#inlineChatRequestId, + codewhispererTriggerType: this.#inlineChatTriggerType, + duration: this.#inlineChatResponseLatency, + codewhispererLanguage: this.#inlineChatLanguage, + credentialStartUrl: this.#inlineChatCredentialStartUrl, + codewhispererCustomizationArn: this.#inlineChatCustomizationArn, + result: 'Succeeded', + requestLength: this.#inlineChatRequestPromptLength, + responseLength: this.#inlineChatResponseLength, + }, + }) return result.success ? { @@ -298,6 +352,29 @@ export class ChatController implements ChatHandlers { 'Error encountered during inline chat response streaming:', err instanceof Error ? err.message : 'unknown' ) + this.#inlineChatResponseLatency = Date.now() - this.#inlineChatRequestStartTime + this.#features.telemetry.emitMetric({ + name: 'codewhisperer_inlineChatServiceInvocation', + result: 'Failed', + data: { + codewhispererRequestId: this.#inlineChatRequestId, + codewhispererTriggerType: this.#inlineChatTriggerType, + duration: this.#inlineChatResponseLatency, + codewhispererLanguage: this.#inlineChatLanguage, + credentialStartUrl: this.#inlineChatCredentialStartUrl, + codewhispererCustomizationArn: this.#inlineChatCustomizationArn, + result: 'Failed', + requestLength: this.#inlineChatRequestPromptLength, + responseLength: 0, + reason: `Inline Chat Response Streaming Exception: ${err instanceof Error ? err.name : 'UnknownError'}`, + }, + errorData: { + reason: err instanceof Error ? err.name : 'UnknownError', + errorCode: isAwsError(err) ? err.code : undefined, + httpStatusCode: isAwsError(err) ? err.statusCode : undefined, + }, + }) + return new ResponseError( LSPErrorCodes.RequestFailed, err instanceof Error ? err.message : 'Unknown error occurred during inline chat response stream' diff --git a/server/aws-lsp-codewhisperer/src/language-server/chat/chatSessionService.test.ts b/server/aws-lsp-codewhisperer/src/language-server/chat/chatSessionService.test.ts index f8cd1a866c..bc776c2f85 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/chat/chatSessionService.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/chat/chatSessionService.test.ts @@ -8,6 +8,7 @@ import { AmazonQBaseServiceManager } from '../../shared/amazonQServiceManager/Ba import { AmazonQIAMServiceManager } from '../../shared/amazonQServiceManager/AmazonQIAMServiceManager' import * as sharedUtils from '../../shared/utils' import { Utils } from 'vscode-uri' +import { wrapErrorWithCode } from '../agenticChat/errors' describe('Chat Session Service', () => { let abortStub: sinon.SinonStub @@ -18,7 +19,7 @@ describe('Chat Session Service', () => { const mockRequestParams: SendMessageCommandInput = { conversationState: { - chatTriggerType: 'MANUAL', + chatTriggerType: ChatTriggerType.MANUAL, currentMessage: { userInputMessage: { content: 'hello', @@ -41,7 +42,11 @@ describe('Chat Session Service', () => { abortStub = sinon.stub(AbortController.prototype, 'abort') - chatSessionService = new ChatSessionService(amazonQServiceManager) + const mockLsp = { + getClientInitializeParams: () => ({}), + } + + chatSessionService = new ChatSessionService(amazonQServiceManager, mockLsp as any) // needed to identify the stubs as the actual class when checking 'instanceof' in generateAssistantResponse Object.setPrototypeOf(amazonQServiceManager, AmazonQTokenServiceManager.prototype) @@ -396,4 +401,224 @@ describe('Chat Session Service', () => { getOriginFromClientInfoStub.restore() }) }) + + describe('Error handling for model capacity issues', () => { + let enabledModelSelectionStub: sinon.SinonStub + + beforeEach(() => { + enabledModelSelectionStub = sinon.stub(sharedUtils, 'enabledModelSelection') + }) + + afterEach(() => { + enabledModelSelectionStub.restore() + }) + + describe('getChatResponse error handling', () => { + it('should handle HTTP 500 error with specific message when model selection is enabled', async () => { + enabledModelSelectionStub.returns(true) + + const error = new Error( + 'Encountered unexpectedly high load when processing the request, please try again.' + ) as any + error.$metadata = { httpStatusCode: 500 } + + codeWhispererStreamingClient.generateAssistantResponse.rejects(error) + + const requestWithModelId = { + conversationState: { + chatTriggerType: ChatTriggerType.MANUAL, + currentMessage: { + userInputMessage: { + content: 'hello', + modelId: 'test-model-id', + }, + }, + }, + } + + try { + await chatSessionService.getChatResponse(requestWithModelId) + assert.fail('Expected error to be thrown') + } catch (e: any) { + assert.strictEqual( + e.message, + 'The model you selected is temporarily unavailable. Please switch to a different model and try again.' + ) + } + }) + + it('should handle HTTP 500 error with specific message when model selection is disabled', async () => { + enabledModelSelectionStub.returns(false) + + const error = new Error( + 'Encountered unexpectedly high load when processing the request, please try again.' + ) as any + error.$metadata = { httpStatusCode: 500 } + + codeWhispererStreamingClient.generateAssistantResponse.rejects(error) + + const requestWithModelId = { + conversationState: { + chatTriggerType: ChatTriggerType.MANUAL, + currentMessage: { + userInputMessage: { + content: 'hello', + modelId: 'test-model-id', + }, + }, + }, + } + + try { + await chatSessionService.getChatResponse(requestWithModelId) + assert.fail('Expected error to be thrown') + } catch (e: any) { + assert.strictEqual(e.message, 'I am experiencing high traffic, please try again shortly.') + } + }) + + it('should handle HTTP 429 error with INSUFFICIENT_MODEL_CAPACITY when model selection is enabled', async () => { + enabledModelSelectionStub.returns(true) + + const error = new Error('Some error message') as any + error.$metadata = { httpStatusCode: 429 } + error.reason = 'INSUFFICIENT_MODEL_CAPACITY' + + codeWhispererStreamingClient.generateAssistantResponse.rejects(error) + + const requestWithModelId = { + conversationState: { + chatTriggerType: ChatTriggerType.MANUAL, + currentMessage: { + userInputMessage: { + content: 'hello', + modelId: 'test-model-id', + }, + }, + }, + } + + try { + await chatSessionService.getChatResponse(requestWithModelId) + assert.fail('Expected error to be thrown') + } catch (e: any) { + assert.strictEqual( + e.message, + 'The model you selected is temporarily unavailable. Please switch to a different model and try again.' + ) + } + }) + + it('should handle HTTP 429 error with INSUFFICIENT_MODEL_CAPACITY when model selection is disabled', async () => { + enabledModelSelectionStub.returns(false) + + const error = new Error('Some error message') as any + error.$metadata = { httpStatusCode: 429 } + error.reason = 'INSUFFICIENT_MODEL_CAPACITY' + + codeWhispererStreamingClient.generateAssistantResponse.rejects(error) + + const requestWithModelId = { + conversationState: { + chatTriggerType: ChatTriggerType.MANUAL, + currentMessage: { + userInputMessage: { + content: 'hello', + modelId: 'test-model-id', + }, + }, + }, + } + + try { + await chatSessionService.getChatResponse(requestWithModelId) + assert.fail('Expected error to be thrown') + } catch (e: any) { + assert.strictEqual(e.message, 'I am experiencing high traffic, please try again shortly.') + } + }) + }) + + describe('IAM client error handling', () => { + let codeWhispererStreamingClientIAM: StubbedInstance + let amazonQServiceManagerIAM: StubbedInstance + let chatSessionServiceIAM: ChatSessionService + + beforeEach(() => { + codeWhispererStreamingClientIAM = stubInterface() + amazonQServiceManagerIAM = stubInterface() + amazonQServiceManagerIAM.getStreamingClient.returns(codeWhispererStreamingClientIAM) + + Object.setPrototypeOf(codeWhispererStreamingClientIAM, StreamingClientServiceIAM.prototype) + Object.setPrototypeOf(amazonQServiceManagerIAM, AmazonQIAMServiceManager.prototype) + + const mockLsp = { + getClientInitializeParams: () => ({}), + } + chatSessionServiceIAM = new ChatSessionService(amazonQServiceManagerIAM, mockLsp as any) + }) + + it('should handle HTTP 500 error with specific message when model selection is enabled', async () => { + enabledModelSelectionStub.returns(true) + + const error = new Error( + 'Encountered unexpectedly high load when processing the request, please try again.' + ) as any + error.$metadata = { httpStatusCode: 500 } + + codeWhispererStreamingClientIAM.sendMessage.rejects(error) + + const requestWithModelId = { + conversationState: { + chatTriggerType: ChatTriggerType.MANUAL, + currentMessage: { + userInputMessage: { + content: 'hello', + modelId: 'test-model-id', + }, + }, + }, + } + + try { + await chatSessionServiceIAM.getChatResponse(requestWithModelId) + assert.fail('Expected error to be thrown') + } catch (e: any) { + assert.strictEqual( + e.message, + 'The model you selected is temporarily unavailable. Please switch to a different model and try again.' + ) + } + }) + + it('should handle HTTP 429 error with INSUFFICIENT_MODEL_CAPACITY when model selection is disabled', async () => { + enabledModelSelectionStub.returns(false) + + const error = new Error('Some error message') as any + error.$metadata = { httpStatusCode: 429 } + error.reason = 'INSUFFICIENT_MODEL_CAPACITY' + + codeWhispererStreamingClientIAM.sendMessage.rejects(error) + + const requestWithModelId = { + conversationState: { + chatTriggerType: ChatTriggerType.MANUAL, + currentMessage: { + userInputMessage: { + content: 'hello', + modelId: 'test-model-id', + }, + }, + }, + } + + try { + await chatSessionServiceIAM.getChatResponse(requestWithModelId) + assert.fail('Expected error to be thrown') + } catch (e: any) { + assert.strictEqual(e.message, 'I am experiencing high traffic, please try again shortly.') + } + }) + }) + }) }) diff --git a/server/aws-lsp-codewhisperer/src/language-server/chat/chatSessionService.ts b/server/aws-lsp-codewhisperer/src/language-server/chat/chatSessionService.ts index d553866b99..0282a04fcc 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/chat/chatSessionService.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/chat/chatSessionService.ts @@ -220,10 +220,16 @@ export class ChatSessionService { } let error = wrapErrorWithCode(e, 'QModelResponse') if ( - request.conversationState?.currentMessage?.userInputMessage?.modelId !== undefined && - (error.cause as any)?.$metadata?.httpStatusCode === 500 && - error.message === - 'Encountered unexpectedly high load when processing the request, please try again.' + (request.conversationState?.currentMessage?.userInputMessage?.modelId !== undefined && + (error.cause as any)?.$metadata?.httpStatusCode === 500 && + error.message === + 'Encountered unexpectedly high load when processing the request, please try again.') || + (error.cause && + typeof error.cause === 'object' && + '$metadata' in error.cause && + (error.cause as any).$metadata?.httpStatusCode === 429 && + 'reason' in error.cause && + error.cause.reason === 'INSUFFICIENT_MODEL_CAPACITY') ) { error.message = this.isModelSelectionEnabled() ? `The model you selected is temporarily unavailable. Please switch to a different model and try again.` @@ -272,12 +278,20 @@ export class ChatSessionService { } let error = wrapErrorWithCode(e, 'QModelResponse') if ( - request.conversationState?.currentMessage?.userInputMessage?.modelId !== undefined && - (error.cause as any)?.$metadata?.httpStatusCode === 500 && - error.message === - 'Encountered unexpectedly high load when processing the request, please try again.' + (request.conversationState?.currentMessage?.userInputMessage?.modelId !== undefined && + (error.cause as any)?.$metadata?.httpStatusCode === 500 && + error.message === + 'Encountered unexpectedly high load when processing the request, please try again.') || + (error.cause && + typeof error.cause === 'object' && + '$metadata' in error.cause && + (error.cause as any).$metadata?.httpStatusCode === 429 && + 'reason' in error.cause && + error.cause.reason === 'INSUFFICIENT_MODEL_CAPACITY') ) { - error.message = `The model you selected is temporarily unavailable. Please switch to a different model and try again.` + error.message = this.isModelSelectionEnabled() + ? `The model you selected is temporarily unavailable. Please switch to a different model and try again.` + : `I am experiencing high traffic, please try again shortly.` } throw error } diff --git a/server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts b/server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts index 034e3ec7d0..76b9b1f14e 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/chat/telemetry/chatTelemetryController.ts @@ -365,6 +365,7 @@ export class ChatTelemetryController { public emitMCPServerInitializeEvent(data?: { command?: string + url?: string enabled?: boolean initializeTime?: number numTools?: number @@ -378,6 +379,7 @@ export class ChatTelemetryController { data: { credentialStartUrl: this.#credentialsProvider.getConnectionMetadata()?.sso?.startUrl, command: data?.command, + url: data?.url, enabled: data?.enabled, initializeTime: data?.initializeTime, languageServerVersion: data?.languageServerVersion, diff --git a/server/aws-lsp-codewhisperer/src/language-server/configuration/qConfigurationServer.test.ts b/server/aws-lsp-codewhisperer/src/language-server/configuration/qConfigurationServer.test.ts index 97d3a084d6..72decf69c1 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/configuration/qConfigurationServer.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/configuration/qConfigurationServer.test.ts @@ -7,7 +7,7 @@ import { ServerConfigurationProvider, } from './qConfigurationServer' import { TestFeatures } from '@aws/language-server-runtimes/testing' -import { CodeWhispererServiceToken } from '../../shared/codeWhispererService' +import { CodeWhispererServiceToken } from '../../shared/codeWhispererService/codeWhispererServiceToken' import { CancellationToken, CancellationTokenSource, diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/autoTrigger.test.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/autoTrigger.test.ts index 9397c709d9..5874065963 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/autoTrigger.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/autoTrigger.test.ts @@ -1,5 +1,5 @@ import assert = require('assert') -import { FileContext } from '../../../shared/codeWhispererService' +import { FileContext } from '../../../shared/codeWhispererService/codeWhispererServiceBase' import { autoTrigger, getAutoTriggerType, triggerType } from './autoTrigger' describe('Auto Trigger', async () => { @@ -119,6 +119,8 @@ describe('Auto Trigger', async () => { assert.strictEqual(getAutoTriggerType(createContentChange('\n')), 'Enter') assert.strictEqual(getAutoTriggerType(createContentChange('\r\n')), 'Enter') assert.strictEqual(getAutoTriggerType(createContentChange('\n ')), 'Enter') + const changes = [{ text: '\n ' }, { text: '' }] + assert.strictEqual(getAutoTriggerType(changes), 'Enter') }) it('should return undefined for tab changes', () => { diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/autoTrigger.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/autoTrigger.ts index a6f87e8d12..2e1492cb4a 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/autoTrigger.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/autoTrigger.ts @@ -1,6 +1,6 @@ import * as os from 'os' import { Logging } from '@aws/language-server-runtimes/server-interface' -import { FileContext } from '../../../shared/codeWhispererService' +import { FileContext } from '../../../shared/codeWhispererService/codeWhispererServiceBase' import typedCoefficients = require('./coefficients.json') import { TextDocumentContentChangeEvent } from 'vscode-languageserver-textdocument' @@ -110,9 +110,10 @@ function isTabKey(str: string): boolean { export const getAutoTriggerType = ( contentChanges: TextDocumentContentChangeEvent[] ): CodewhispererAutomatedTriggerType | undefined => { - if (contentChanges.length !== 1) { + if (contentChanges.length < 1 || contentChanges.length > 2) { // Won't trigger cwspr on multi-line changes // event.contentChanges.length will be 2 when user press Enter key multiple times + // in certain cases, first contentChange item is valid, 2nd is empty string return undefined } const changedText = contentChanges[0].text diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/editPredictionAutoTrigger.test.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/editPredictionAutoTrigger.test.ts index 261748468f..d3b471e3ff 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/editPredictionAutoTrigger.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/editPredictionAutoTrigger.test.ts @@ -7,7 +7,7 @@ import * as assert from 'assert' import * as sinon from 'sinon' import { editPredictionAutoTrigger } from './editPredictionAutoTrigger' import { EditPredictionConfigManager } from './editPredictionConfig' -import { FileContext } from '../../../shared/codeWhispererService' +import { FileContext } from '../../../shared/codeWhispererService/codeWhispererServiceBase' import { Position } from '@aws/language-server-runtimes/server-interface' import { CursorTracker } from '../tracker/cursorTracker' import { RecentEditTracker } from '../tracker/codeEditTracker' diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/editPredictionAutoTrigger.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/editPredictionAutoTrigger.ts index eee9edbdcb..8a64697d27 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/editPredictionAutoTrigger.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/auto-trigger/editPredictionAutoTrigger.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { FileContext } from '../../../shared/codeWhispererService' +import { FileContext } from '../../../shared/codeWhispererService/codeWhispererServiceBase' import { Position } from '@aws/language-server-runtimes/server-interface' import { CursorTracker } from '../tracker/cursorTracker' import { RecentEditTracker } from '../tracker/codeEditTracker' diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.test.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.test.ts index 2db0cfaba9..3b74cc9b91 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.test.ts @@ -15,11 +15,11 @@ import sinon, { StubbedInstance } from 'ts-sinon' import { CONTEXT_CHARACTERS_LIMIT, CodewhispererServerFactory } from './codeWhispererServer' import { CodeWhispererServiceBase, - CodeWhispererServiceToken, ResponseContext, Suggestion, SuggestionType, -} from '../../shared/codeWhispererService' +} from '../../shared/codeWhispererService/codeWhispererServiceBase' +import { CodeWhispererServiceToken } from '../../shared/codeWhispererService/codeWhispererServiceToken' import { CodeWhispererSession, SessionData, SessionManager } from './session/sessionManager' import { EMPTY_RESULT, diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.ts index 15792b0f71..cbd739e9ec 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/codeWhispererServer.ts @@ -16,12 +16,12 @@ import { } from '@aws/language-server-runtimes/server-interface' import { autoTrigger, getAutoTriggerType, getNormalizeOsName, triggerType } from './auto-trigger/autoTrigger' import { - CodeWhispererServiceToken, GenerateSuggestionsRequest, GenerateSuggestionsResponse, Suggestion, SuggestionType, -} from '../../shared/codeWhispererService' +} from '../../shared/codeWhispererService/codeWhispererServiceBase' +import { CodeWhispererServiceToken } from '../../shared/codeWhispererService/codeWhispererServiceToken' import { CodewhispererLanguage, getRuntimeLanguage, getSupportedLanguageId } from '../../shared/languageDetection' import { mergeEditSuggestionsWithFileContext, truncateOverlapWithRightContext } from './mergeRightUtils' import { CodeWhispererSession, SessionManager } from './session/sessionManager' @@ -890,9 +890,9 @@ export const CodewhispererServerFactory = } logging.debug(`CodePercentageTracker customizationArn updated to ${customizationArn}`) /* - The flag enableTelemetryEventsToDestination is set to true temporarily. It's value will be determined through destination - configuration post all events migration to STE. It'll be replaced by qConfig['enableTelemetryEventsToDestination'] === true - */ + The flag enableTelemetryEventsToDestination is set to true temporarily. It's value will be determined through destination + configuration post all events migration to STE. It'll be replaced by qConfig['enableTelemetryEventsToDestination'] === true + */ // const enableTelemetryEventsToDestination = true // telemetryService.updateEnableTelemetryEventsToDestination(enableTelemetryEventsToDestination) telemetryService.updateOptOutPreference(optOutTelemetryPreference) diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/mergeRightUtils.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/mergeRightUtils.ts index 9091139e7e..f8cb037786 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/mergeRightUtils.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/mergeRightUtils.ts @@ -1,7 +1,7 @@ import { InlineCompletionItemWithReferences, TextDocument } from '@aws/language-server-runtimes/server-interface' import { CodeWhispererSession } from './session/sessionManager' import { applyUnifiedDiff, generateUnifiedDiffWithTimestamps } from './diffUtils' -import { FileContext } from '../../shared/codeWhispererService' +import { FileContext } from '../../shared/codeWhispererService/codeWhispererServiceBase' /** * Returns the longest overlap between the Suffix of firstString and Prefix of second string diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.test.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.test.ts index db34f885ff..5b633fbe8f 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.test.ts @@ -1,6 +1,6 @@ import * as assert from 'assert' import sinon from 'ts-sinon' -import { Suggestion } from '../../../shared/codeWhispererService' +import { Suggestion } from '../../../shared/codeWhispererService/codeWhispererServiceBase' import { CodeWhispererSession, SessionData, SessionManager } from './sessionManager' import { TextDocument } from '@aws/language-server-runtimes/server-interface' import { HELLO_WORLD_IN_CSHARP } from '../../../shared/testUtils' diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.ts index 35568fc6e2..5dbf0557ad 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/session/sessionManager.ts @@ -6,7 +6,11 @@ import { } from '@aws/language-server-runtimes/server-interface' import { v4 as uuidv4 } from 'uuid' import { CodewhispererAutomatedTriggerType, CodewhispererTriggerType } from '../auto-trigger/autoTrigger' -import { GenerateSuggestionsRequest, ResponseContext, Suggestion } from '../../../shared/codeWhispererService' +import { + GenerateSuggestionsRequest, + ResponseContext, + Suggestion, +} from '../../../shared/codeWhispererService/codeWhispererServiceBase' import { CodewhispererLanguage } from '../../../shared/languageDetection' import { CodeWhispererSupplementalContext } from '../../../shared/models/model' import { Logging } from '@aws/language-server-runtimes/server-interface' diff --git a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/userTriggerDecision.test.ts b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/userTriggerDecision.test.ts index 81f11d8fad..397e7223a4 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/inline-completion/userTriggerDecision.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/inline-completion/userTriggerDecision.test.ts @@ -15,7 +15,7 @@ import { ResponseContext, Suggestion, SuggestionType, -} from '../../shared/codeWhispererService' +} from '../../shared/codeWhispererService/codeWhispererServiceBase' import { CodeWhispererSession, SessionManager } from './session/sessionManager' import { TelemetryService } from '../../shared/telemetry/telemetryService' import { initBaseTestServiceManager, TestAmazonQServiceManager } from '../../shared/amazonQServiceManager/testUtils' diff --git a/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/transformHandler.test.ts b/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/transformHandler.test.ts index efaa69e3cf..f42f351278 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/transformHandler.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/netTransform/tests/transformHandler.test.ts @@ -14,7 +14,7 @@ import * as fs from 'fs' import got from 'got' import { StubbedInstance, default as simon, stubInterface } from 'ts-sinon' import { StreamingClient, createStreamingClient } from '../../../client/streamingClient/codewhispererStreamingClient' -import { CodeWhispererServiceToken } from '../../../shared/codeWhispererService' +import { CodeWhispererServiceToken } from '../../../shared/codeWhispererService/codeWhispererServiceToken' import { CancelTransformRequest, CancellationJobStatus, diff --git a/server/aws-lsp-codewhisperer/src/language-server/securityScan/securityScanHandler.test.ts b/server/aws-lsp-codewhisperer/src/language-server/securityScan/securityScanHandler.test.ts index 6b6d045c3d..7715541476 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/securityScan/securityScanHandler.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/securityScan/securityScanHandler.test.ts @@ -5,7 +5,7 @@ import got from 'got' import * as Sinon from 'sinon' import { StubbedInstance, default as simon, stubInterface } from 'ts-sinon' import { StartCodeAnalysisRequest } from '../../client/token/codewhispererbearertokenclient' -import { CodeWhispererServiceToken } from '../../shared/codeWhispererService' +import { CodeWhispererServiceToken } from '../../shared/codeWhispererService/codeWhispererServiceToken' import { SecurityScanHandler } from './securityScanHandler' import { RawCodeScanIssue } from './types' import * as ScanConstants from './constants' diff --git a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceFolderManager.test.ts b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceFolderManager.test.ts index 0b4ec53f25..9764734254 100644 --- a/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceFolderManager.test.ts +++ b/server/aws-lsp-codewhisperer/src/language-server/workspaceContext/workspaceFolderManager.test.ts @@ -5,7 +5,7 @@ import { CredentialsProvider, Logging } from '@aws/language-server-runtimes/serv import { DependencyDiscoverer } from './dependency/dependencyDiscoverer' import { WorkspaceFolder } from 'vscode-languageserver-protocol' import { ArtifactManager } from './artifactManager' -import { CodeWhispererServiceToken } from '../../shared/codeWhispererService' +import { CodeWhispererServiceToken } from '../../shared/codeWhispererService/codeWhispererServiceToken' import { CreateWorkspaceResponse } from '../../client/token/codewhispererbearertokenclient' import { AWSError } from 'aws-sdk' diff --git a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQIAMServiceManager.ts b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQIAMServiceManager.ts index 774102a987..a7298bb1b5 100644 --- a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQIAMServiceManager.ts +++ b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQIAMServiceManager.ts @@ -1,4 +1,4 @@ -import { CodeWhispererServiceIAM } from '../codeWhispererService' +import { CodeWhispererServiceIAM } from '../codeWhispererService/codeWhispererServiceIAM' import { AmazonQBaseServiceManager, BaseAmazonQServiceManager, diff --git a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.test.ts b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.test.ts index 73af1c14fc..9cf10fc623 100644 --- a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.test.ts @@ -2,7 +2,8 @@ import * as assert from 'assert' import sinon, { StubbedInstance, stubInterface } from 'ts-sinon' import { AmazonQTokenServiceManager } from './AmazonQTokenServiceManager' import { TestFeatures } from '@aws/language-server-runtimes/testing' -import { CodeWhispererServiceToken, GenerateSuggestionsRequest } from '../codeWhispererService' +import { GenerateSuggestionsRequest } from '../codeWhispererService/codeWhispererServiceBase' +import { CodeWhispererServiceToken } from '../codeWhispererService/codeWhispererServiceToken' import { AmazonQServiceInitializationError, AmazonQServicePendingProfileError, diff --git a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.ts b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.ts index 8db60a69ab..7b182f85dd 100644 --- a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.ts +++ b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/AmazonQTokenServiceManager.ts @@ -8,7 +8,7 @@ import { InitializeParams, CancellationTokenSource, } from '@aws/language-server-runtimes/server-interface' -import { CodeWhispererServiceToken } from '../codeWhispererService' +import { CodeWhispererServiceToken } from '../codeWhispererService/codeWhispererServiceToken' import { AmazonQError, AmazonQServiceAlreadyInitializedError, diff --git a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/BaseAmazonQServiceManager.test.ts b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/BaseAmazonQServiceManager.test.ts index acc55d75ed..dda7cbf52c 100644 --- a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/BaseAmazonQServiceManager.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/BaseAmazonQServiceManager.test.ts @@ -1,7 +1,7 @@ import { TestFeatures } from '@aws/language-server-runtimes/testing' import sinon, { StubbedInstance } from 'ts-sinon' import { expect } from 'chai' -import { CodeWhispererServiceBase } from '../codeWhispererService' +import { CodeWhispererServiceBase } from '../codeWhispererService/codeWhispererServiceBase' import { stubCodeWhispererService } from '../testUtils' import { initBaseTestServiceManager, TestAmazonQServiceManager } from './testUtils' import { diff --git a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/BaseAmazonQServiceManager.ts b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/BaseAmazonQServiceManager.ts index 9c241809a7..0413e8f9bf 100644 --- a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/BaseAmazonQServiceManager.ts +++ b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/BaseAmazonQServiceManager.ts @@ -9,7 +9,7 @@ import { UpdateConfigurationParams, Workspace, } from '@aws/language-server-runtimes/server-interface' -import { CodeWhispererServiceBase } from '../codeWhispererService' +import { CodeWhispererServiceBase } from '../codeWhispererService/codeWhispererServiceBase' import { AmazonQConfigurationCache, AmazonQWorkspaceConfig, diff --git a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/qDeveloperProfiles.test.ts b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/qDeveloperProfiles.test.ts index a04b823305..e42bfed80b 100644 --- a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/qDeveloperProfiles.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/qDeveloperProfiles.test.ts @@ -1,6 +1,6 @@ import * as assert from 'assert' import sinon, { StubbedInstance, stubInterface } from 'ts-sinon' -import { CodeWhispererServiceToken } from '../codeWhispererService' +import { CodeWhispererServiceToken } from '../codeWhispererService/codeWhispererServiceToken' import { SsoConnectionType } from '../utils' import { AWSInitializationOptions, @@ -110,6 +110,32 @@ describe('ListAllAvailableProfiles Handler', () => { }) }) + describe('Enhanced Logging for Debugging', () => { + it('should log complete error object when profile fetching fails', async () => { + const testError = new Error('Test error') as any + testError.code = 'TestErrorCode' + testError.statusCode = 500 + + codeWhispererService.listAvailableProfiles.rejects(testError) + + try { + await handler({ + connectionType: 'identityCenter', + logging, + endpoints: SOME_AWS_Q_ENDPOINT, + token: tokenSource.token, + }) + assert.fail('Expected method to throw') + } catch (error) { + // Verify that debug logging was called for complete error object + sinon.assert.called(logging.debug) + const debugCalls = logging.debug.getCalls() + const hasCompleteErrorLogging = debugCalls.some(call => call.args[0].includes('Complete error object')) + assert.ok(hasCompleteErrorLogging, 'Should log complete error object in debug logs') + } + }) + }) + describe('Pagination', () => { const MAX_EXPECTED_PAGES = 10 const SOME_NEXT_TOKEN = 'some-random-next-token' diff --git a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/qDeveloperProfiles.ts b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/qDeveloperProfiles.ts index aa0cba1539..e7048afe52 100644 --- a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/qDeveloperProfiles.ts +++ b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/qDeveloperProfiles.ts @@ -7,7 +7,7 @@ import { } from '@aws/language-server-runtimes/server-interface' import { isBool, isObject, SsoConnectionType } from '../utils' import { AWS_Q_ENDPOINTS } from '../../shared/constants' -import { CodeWhispererServiceToken } from '../codeWhispererService' +import { CodeWhispererServiceToken } from '../codeWhispererService/codeWhispererServiceToken' import { AmazonQServiceProfileThrottlingError } from './errors' export interface AmazonQDeveloperProfile { @@ -45,12 +45,18 @@ export const getListAllAvailableProfilesHandler = let allProfiles: AmazonQDeveloperProfile[] = [] const qEndpoints = endpoints ?? AWS_Q_ENDPOINTS + // Log all regions we're going to try + logging.log( + `Attempting to fetch profiles from ${qEndpoints.size} regions: ${Array.from(qEndpoints.keys()).join(', ')}` + ) + if (token.isCancellationRequested) { return [] } const result = await Promise.allSettled( Array.from(qEndpoints.entries(), ([region, endpoint]) => { + logging.log(`Creating service client for region: ${region}`) const codeWhispererService = service(region, endpoint) return fetchProfilesFromRegion(codeWhispererService, region, logging, token) }) @@ -60,6 +66,21 @@ export const getListAllAvailableProfilesHandler = return [] } + // Log detailed results from each region + try { + result.forEach((settledResult, index) => { + const [region, endpoint] = Array.from(qEndpoints.entries())[index] + if (settledResult.status === 'fulfilled') { + const profiles = settledResult.value + logging.log(`Successfully fetched ${profiles.length} profiles from region: ${region}`) + } else { + logging.error( + `Failed to fetch profiles from region: ${region}, error: ${settledResult.reason?.name || 'unknown'}, message: ${settledResult.reason?.message || 'No message'}` + ) + } + }) + } catch (loggingError) {} + const fulfilledResults = result.filter(settledResult => settledResult.status === 'fulfilled') const hasThrottlingError = result.some( re => re.status === `rejected` && re.reason?.name == `ThrottlingException` @@ -77,6 +98,15 @@ export const getListAllAvailableProfilesHandler = fulfilledResults.forEach(fulfilledResult => allProfiles.push(...fulfilledResult.value)) + // Log summary of all profiles fetched + try { + logging.log(`Total profiles fetched: ${allProfiles.length}`) + if (allProfiles.length > 0) { + logging.log(`Profile names: ${allProfiles.map(p => p.name).join(', ')}`) + logging.log(`Profile regions: ${allProfiles.map(p => p.identityDetails?.region).join(', ')}`) + } + } catch (loggingError) {} + // Check for partial throttling if (hasThrottlingError && allProfiles.length == 0) { logging.error(throttlingErrorMessage) @@ -97,17 +127,25 @@ async function fetchProfilesFromRegion( let numberOfPages = 0 try { + logging.log(`Starting profile fetch from region: ${region}`) + do { - logging.debug(`Fetching profiles from region: ${region} (iteration: ${numberOfPages})`) + logging.debug(`Fetching profiles from region: ${region} (page: ${numberOfPages + 1})`) if (token.isCancellationRequested) { + logging.debug(`Cancellation requested during profile fetch from region: ${region}`) return allRegionalProfiles } - const response = await service.listAvailableProfiles({ + const requestParams = { maxResults: MAX_Q_DEVELOPER_PROFILES_PER_PAGE, nextToken: nextToken, - }) + } + logging.debug(`Request params for region ${region}: ${JSON.stringify(requestParams)}`) + + const response = await service.listAvailableProfiles(requestParams) + + logging.debug(`Raw response from ${region}: ${JSON.stringify(response)}`) const profiles = response.profiles.map(profile => ({ arn: profile.arn, @@ -117,16 +155,29 @@ async function fetchProfilesFromRegion( }, })) + logging.log(`Fetched ${profiles.length} profiles from ${region} (page: ${numberOfPages + 1})`) + if (profiles.length > 0) { + logging.log(`Profile names from ${region}: ${profiles.map(p => p.name).join(', ')}`) + } + allRegionalProfiles.push(...profiles) - logging.debug(`Fetched profiles from ${region}: ${JSON.stringify(response)} (iteration: ${numberOfPages})`) nextToken = response.nextToken + if (nextToken) { + logging.debug(`Next token received from ${region}: ${nextToken.substring(0, 10)}...`) + } else { + logging.debug(`No next token received from ${region}, pagination complete`) + } + numberOfPages++ } while (nextToken !== undefined && numberOfPages < MAX_Q_DEVELOPER_PROFILE_PAGES) + logging.log(`Completed fetching profiles from ${region}, total profiles: ${allRegionalProfiles.length}`) return allRegionalProfiles } catch (error) { - logging.error(`Error fetching profiles from ${region}: ${error}`) + // Enhanced error logging with complete error object + logging.error(`Error fetching profiles from region: ${region}`) + logging.log(`Complete error object: ${JSON.stringify(error, Object.getOwnPropertyNames(error), 2)}`) throw error } diff --git a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/testUtils.ts b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/testUtils.ts index 78870b62e8..622e325780 100644 --- a/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/testUtils.ts +++ b/server/aws-lsp-codewhisperer/src/shared/amazonQServiceManager/testUtils.ts @@ -1,5 +1,5 @@ import { TestFeatures } from '@aws/language-server-runtimes/testing' -import { CodeWhispererServiceBase } from '../codeWhispererService' +import { CodeWhispererServiceBase } from '../codeWhispererService/codeWhispererServiceBase' import { BaseAmazonQServiceManager, QServiceManagerFeatures } from './BaseAmazonQServiceManager' import { StreamingClientServiceBase } from '../streamingClientService' import { diff --git a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.test.ts b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.test.ts deleted file mode 100644 index 63ad6556e7..0000000000 --- a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.test.ts +++ /dev/null @@ -1,398 +0,0 @@ -/*! - * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. - * SPDX-License-Identifier: Apache-2.0 - */ - -import { - CredentialsProvider, - CredentialsType, - Workspace, - Logging, - SDKInitializator, -} from '@aws/language-server-runtimes/server-interface' -import { ConfigurationOptions } from 'aws-sdk' -import * as sinon from 'sinon' -import * as assert from 'assert' -import { - CodeWhispererServiceBase, - CodeWhispererServiceToken, - CodeWhispererServiceIAM, - GenerateSuggestionsRequest, - GenerateSuggestionsResponse, -} from './codeWhispererService' - -describe('CodeWhispererService', function () { - let sandbox: sinon.SinonSandbox - let mockCredentialsProvider: sinon.SinonStubbedInstance - let mockWorkspace: sinon.SinonStubbedInstance - let mockLogging: sinon.SinonStubbedInstance - let mockSDKInitializator: sinon.SinonStubbedInstance - - beforeEach(function () { - sandbox = sinon.createSandbox() - - mockCredentialsProvider = { - getCredentials: sandbox.stub(), - hasCredentials: sandbox.stub(), - refresh: sandbox.stub(), - } as any - - mockWorkspace = { - getWorkspaceFolder: sandbox.stub(), - getWorkspaceFolders: sandbox.stub(), - } as any - - mockLogging = { - debug: sandbox.stub(), - error: sandbox.stub(), - info: sandbox.stub(), - warn: sandbox.stub(), - log: sandbox.stub(), - } - - mockSDKInitializator = { - initialize: sandbox.stub(), - } as any - }) - - afterEach(function () { - sandbox.restore() - }) - - describe('CodeWhispererServiceBase', function () { - let service: CodeWhispererServiceBase - - beforeEach(function () { - // Create a concrete implementation for testing abstract class - class TestCodeWhispererService extends CodeWhispererServiceBase { - client: any = {} - - getCredentialsType(): CredentialsType { - return 'iam' - } - - // Add public getters for protected properties - get testCodeWhispererRegion() { - return this.codeWhispererRegion - } - - get testCodeWhispererEndpoint() { - return this.codeWhispererEndpoint - } - - async generateCompletionsAndEdits(): Promise { - return { - suggestions: [], - responseContext: { requestId: 'test', codewhispererSessionId: 'test' }, - } - } - - async generateSuggestions(): Promise { - return { - suggestions: [], - responseContext: { requestId: 'test', codewhispererSessionId: 'test' }, - } - } - - clearCachedSuggestions(): void {} - } - - service = new TestCodeWhispererService('us-east-1', 'https://codewhisperer.us-east-1.amazonaws.com') - }) - - describe('constructor', function () { - it('should initialize with region and endpoint', function () { - assert.strictEqual((service as any).testCodeWhispererRegion, 'us-east-1') - assert.strictEqual( - (service as any).testCodeWhispererEndpoint, - 'https://codewhisperer.us-east-1.amazonaws.com' - ) - }) - }) - - describe('request tracking', function () { - it('should track inflight requests', function () { - const mockRequest = { - abort: sandbox.stub(), - } as any - - service.trackRequest(mockRequest) - assert.strictEqual(service.inflightRequests.size, 1) - assert.strictEqual(service.inflightRequests.has(mockRequest), true) - }) - - it('should complete and remove tracked requests', function () { - const mockRequest = { - abort: sandbox.stub(), - } as any - - service.trackRequest(mockRequest) - service.completeRequest(mockRequest) - - assert.strictEqual(service.inflightRequests.size, 0) - assert.strictEqual(service.inflightRequests.has(mockRequest), false) - }) - - it('should abort all inflight requests', function () { - const mockRequest1 = { abort: sandbox.stub() } as any - const mockRequest2 = { abort: sandbox.stub() } as any - - service.trackRequest(mockRequest1) - service.trackRequest(mockRequest2) - - service.abortInflightRequests() - - assert.strictEqual(mockRequest1.abort.calledOnce, true) - assert.strictEqual(mockRequest2.abort.calledOnce, true) - assert.strictEqual(service.inflightRequests.size, 0) - }) - }) - - describe('updateClientConfig', function () { - it('should update client configuration', function () { - const mockClient = { - config: { - update: sandbox.stub(), - }, - // Add minimal required properties to satisfy the interface - createCodeScan: sandbox.stub(), - createCodeScanUploadUrl: sandbox.stub(), - createProfile: sandbox.stub(), - deleteProfile: sandbox.stub(), - generateCompletions: sandbox.stub(), - generateSuggestions: sandbox.stub(), - getCodeAnalysis: sandbox.stub(), - getCodeScan: sandbox.stub(), - listCodeAnalysisFindings: sandbox.stub(), - listCodeScans: sandbox.stub(), - listFeatureEvaluations: sandbox.stub(), - listProfiles: sandbox.stub(), - sendTelemetryEvent: sandbox.stub(), - startCodeAnalysis: sandbox.stub(), - stopCodeAnalysis: sandbox.stub(), - updateProfile: sandbox.stub(), - } as any - service.client = mockClient - - const options: ConfigurationOptions = { region: 'us-west-2' } - service.updateClientConfig(options) - - assert.strictEqual(mockClient.config.update.calledOnceWith(options), true) - }) - }) - - describe('generateItemId', function () { - it('should generate unique item IDs', function () { - const id1 = service.generateItemId() - const id2 = service.generateItemId() - - assert.strictEqual(typeof id1, 'string') - assert.strictEqual(typeof id2, 'string') - assert.notStrictEqual(id1, id2) - }) - }) - }) - - describe('CodeWhispererServiceIAM', function () { - let service: CodeWhispererServiceIAM - - beforeEach(function () { - // Mock the createCodeWhispererSigv4Client function to avoid real client creation - const mockClient = { - generateRecommendations: sandbox.stub().returns({ - promise: sandbox.stub().resolves({ - recommendations: [], - $response: { - requestId: 'test-request-id', - httpResponse: { - headers: { 'x-amzn-sessionid': 'test-session-id' }, - }, - }, - }), - }), - setupRequestListeners: sandbox.stub(), - config: { - update: sandbox.stub(), - }, - } - - // Mock the client creation - const createClientStub = sandbox.stub( - require('../client/sigv4/codewhisperer'), - 'createCodeWhispererSigv4Client' - ) - createClientStub.returns(mockClient) - - service = new CodeWhispererServiceIAM( - mockCredentialsProvider as any, - {} as any, // workspace parameter - mockLogging as any, - 'us-east-1', - 'https://codewhisperer.us-east-1.amazonaws.com', - mockSDKInitializator as any - ) - }) - - describe('getCredentialsType', function () { - it('should return iam credentials type', function () { - assert.strictEqual(service.getCredentialsType(), 'iam') - }) - }) - - describe('generateSuggestions', function () { - it('should call client.generateRecommendations and process response', async function () { - const mockRequest: GenerateSuggestionsRequest = { - fileContext: { - filename: 'test.js', - programmingLanguage: { languageName: 'javascript' }, - leftFileContent: 'const x = ', - rightFileContent: '', - }, - maxResults: 5, - } - - const result = await service.generateSuggestions(mockRequest) - - assert.strictEqual(Array.isArray(result.suggestions), true) - assert.strictEqual(typeof result.responseContext.requestId, 'string') - assert.strictEqual(typeof result.responseContext.codewhispererSessionId, 'string') - }) - - it('should add customizationArn to request if set', async function () { - service.customizationArn = 'test-arn' - - const mockRequest: GenerateSuggestionsRequest = { - fileContext: { - filename: 'test.js', - programmingLanguage: { languageName: 'javascript' }, - leftFileContent: 'const x = ', - rightFileContent: '', - }, - maxResults: 5, - } - - await service.generateSuggestions(mockRequest) - - // Verify that the client was called with the customizationArn - const clientCall = (service.client.generateRecommendations as sinon.SinonStub).getCall(0) - assert.strictEqual(clientCall.args[0].customizationArn, 'test-arn') - }) - }) - }) - - describe('CodeWhispererServiceToken', function () { - let service: CodeWhispererServiceToken - let mockClient: any - - beforeEach(function () { - // Mock the token client - mockClient = { - generateCompletions: sandbox.stub().returns({ - promise: sandbox.stub().resolves({ - completions: [ - { - content: 'console.log("hello");', - references: [], - }, - ], - $response: { - requestId: 'test-request-id', - httpResponse: { - headers: { 'x-amzn-sessionid': 'test-session-id' }, - }, - }, - }), - }), - config: { - update: sandbox.stub(), - }, - } - - // Mock the client creation - const createTokenClientStub = sandbox.stub( - require('../client/token/codewhisperer'), - 'createCodeWhispererTokenClient' - ) - createTokenClientStub.returns(mockClient) - - // Mock bearer credentials - mockCredentialsProvider.getCredentials.returns({ - token: 'mock-bearer-token', - }) - - service = new CodeWhispererServiceToken( - mockCredentialsProvider as any, - mockWorkspace as any, - mockLogging as any, - 'us-east-1', - 'https://codewhisperer.us-east-1.amazonaws.com', - mockSDKInitializator as any - ) - }) - - describe('getCredentialsType', function () { - it('should return bearer credentials type', function () { - assert.strictEqual(service.getCredentialsType(), 'bearer') - }) - }) - - describe('generateSuggestions', function () { - it('should call client.generateCompletions and process response', async function () { - const mockRequest: GenerateSuggestionsRequest = { - fileContext: { - filename: 'test.js', - programmingLanguage: { languageName: 'javascript' }, - leftFileContent: 'const x = ', - rightFileContent: '', - }, - maxResults: 5, - } - - const result = await service.generateSuggestions(mockRequest) - - assert.strictEqual(mockClient.generateCompletions.calledOnce, true) - assert.strictEqual(Array.isArray(result.suggestions), true) - assert.strictEqual(typeof result.responseContext.requestId, 'string') - assert.strictEqual(typeof result.responseContext.codewhispererSessionId, 'string') - }) - - it('should add customizationArn to request if set', async function () { - service.customizationArn = 'test-arn' - - const mockRequest: GenerateSuggestionsRequest = { - fileContext: { - filename: 'test.js', - programmingLanguage: { languageName: 'javascript' }, - leftFileContent: 'const x = ', - rightFileContent: '', - }, - maxResults: 5, - } - - await service.generateSuggestions(mockRequest) - - const clientCall = mockClient.generateCompletions.getCall(0) - assert.strictEqual(clientCall.args[0].customizationArn, 'test-arn') - }) - - it('should process profile ARN with withProfileArn method', async function () { - const mockRequest: GenerateSuggestionsRequest = { - fileContext: { - filename: 'test.js', - programmingLanguage: { languageName: 'javascript' }, - leftFileContent: 'const x = ', - rightFileContent: '', - }, - maxResults: 5, - } - - const withProfileArnStub = sandbox.stub(service, 'withProfileArn' as any) - withProfileArnStub.returns(mockRequest) - - await service.generateSuggestions(mockRequest) - - assert.strictEqual(withProfileArnStub.calledOnceWith(mockRequest), true) - }) - }) - }) -}) diff --git a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService/codeWhispererServiceBase.test.ts b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService/codeWhispererServiceBase.test.ts new file mode 100644 index 0000000000..a90f0e7112 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService/codeWhispererServiceBase.test.ts @@ -0,0 +1,272 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CredentialsType } from '@aws/language-server-runtimes/server-interface' +import { AWSError, ConfigurationOptions } from 'aws-sdk' +import * as sinon from 'sinon' +import * as assert from 'assert' +import { PromiseResult } from 'aws-sdk/lib/request' +import { + CodeWhispererServiceBase, + GenerateSuggestionsResponse, + CreateUploadUrlRequest, + CreateUploadUrlResponse, + StartTransformationRequest, + StartTransformationResponse, + StopTransformationRequest, + StopTransformationResponse, + GetTransformationRequest, + GetTransformationResponse, + GetTransformationPlanRequest, + GetTransformationPlanResponse, + StartCodeAnalysisRequest, + StartCodeAnalysisResponse, + GetCodeAnalysisRequest, + GetCodeAnalysisResponse, + ListCodeAnalysisFindingsRequest, + ListCodeAnalysisFindingsResponse, + ListAvailableCustomizationsRequest, + ListAvailableCustomizationsResponse, + ListAvailableProfilesRequest, + SendTelemetryEventRequest, + SendTelemetryEventResponse, + CreateWorkspaceRequest, + CreateWorkspaceResponse, + ListWorkspaceMetadataRequest, + ListWorkspaceMetadataResponse, + DeleteWorkspaceRequest, + DeleteWorkspaceResponse, + ListFeatureEvaluationsRequest, + ListFeatureEvaluationsResponse, + CreateSubscriptionTokenRequest, + CreateSubscriptionTokenResponse, + ListAvailableProfilesResponse, +} from './codeWhispererServiceBase' + +describe('CodeWhispererServiceBase', function () { + // Create the environment + let sandbox: sinon.SinonSandbox + let service: CodeWhispererServiceBase + + beforeEach(function () { + sandbox = sinon.createSandbox() + + // Create a concrete implementation for testing abstract class + class TestCodeWhispererService extends CodeWhispererServiceBase { + client: any = {} + + getCredentialsType(): CredentialsType { + return 'iam' + } + + // Add public getters for protected properties + get testCodeWhispererRegion() { + return this.codeWhispererRegion + } + + get testCodeWhispererEndpoint() { + return this.codeWhispererEndpoint + } + + async generateCompletionsAndEdits(): Promise { + return { + suggestions: [], + responseContext: { requestId: 'test', codewhispererSessionId: 'test' }, + } + } + + async generateSuggestions(): Promise { + return { + suggestions: [], + responseContext: { requestId: 'test', codewhispererSessionId: 'test' }, + } + } + + clearCachedSuggestions(): void {} + + override codeModernizerCreateUploadUrl(request: CreateUploadUrlRequest): Promise { + throw new Error('Method not implemented.') + } + override codeModernizerStartCodeTransformation( + request: StartTransformationRequest + ): Promise> { + throw new Error('Method not implemented.') + } + override codeModernizerStopCodeTransformation( + request: StopTransformationRequest + ): Promise> { + throw new Error('Method not implemented.') + } + override codeModernizerGetCodeTransformation( + request: GetTransformationRequest + ): Promise> { + throw new Error('Method not implemented.') + } + override codeModernizerGetCodeTransformationPlan( + request: GetTransformationPlanRequest + ): Promise> { + throw new Error('Method not implemented.') + } + override createUploadUrl( + request: CreateUploadUrlRequest + ): Promise> { + throw new Error('Method not implemented.') + } + override startCodeAnalysis( + request: StartCodeAnalysisRequest + ): Promise> { + throw new Error('Method not implemented.') + } + override getCodeAnalysis( + request: GetCodeAnalysisRequest + ): Promise> { + throw new Error('Method not implemented.') + } + override listCodeAnalysisFindings( + request: ListCodeAnalysisFindingsRequest + ): Promise> { + throw new Error('Method not implemented.') + } + override listAvailableCustomizations( + request: ListAvailableCustomizationsRequest + ): Promise> { + throw new Error('Method not implemented.') + } + override listAvailableProfiles( + request: ListAvailableProfilesRequest + ): Promise> { + throw new Error('Method not implemented.') + } + override sendTelemetryEvent( + request: SendTelemetryEventRequest + ): Promise> { + throw new Error('Method not implemented.') + } + override createWorkspace( + request: CreateWorkspaceRequest + ): Promise> { + throw new Error('Method not implemented.') + } + override listWorkspaceMetadata( + request: ListWorkspaceMetadataRequest + ): Promise> { + throw new Error('Method not implemented.') + } + override deleteWorkspace( + request: DeleteWorkspaceRequest + ): Promise> { + throw new Error('Method not implemented.') + } + override listFeatureEvaluations( + request: ListFeatureEvaluationsRequest + ): Promise> { + throw new Error('Method not implemented.') + } + override createSubscriptionToken( + request: CreateSubscriptionTokenRequest + ): Promise> { + throw new Error('Method not implemented.') + } + } + + service = new TestCodeWhispererService('us-east-1', 'https://codewhisperer.us-east-1.amazonaws.com') + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('constructor', function () { + it('should initialize with region and endpoint', function () { + assert.strictEqual((service as any).testCodeWhispererRegion, 'us-east-1') + assert.strictEqual( + (service as any).testCodeWhispererEndpoint, + 'https://codewhisperer.us-east-1.amazonaws.com' + ) + }) + }) + + describe('request tracking', function () { + it('should track inflight requests', function () { + const mockRequest = { + abort: sandbox.stub(), + } as any + + service.trackRequest(mockRequest) + assert.strictEqual(service.inflightRequests.size, 1) + assert.strictEqual(service.inflightRequests.has(mockRequest), true) + }) + + it('should complete and remove tracked requests', function () { + const mockRequest = { + abort: sandbox.stub(), + } as any + + service.trackRequest(mockRequest) + service.completeRequest(mockRequest) + + assert.strictEqual(service.inflightRequests.size, 0) + assert.strictEqual(service.inflightRequests.has(mockRequest), false) + }) + + it('should abort all inflight requests', function () { + const mockRequest1 = { abort: sandbox.stub() } as any + const mockRequest2 = { abort: sandbox.stub() } as any + + service.trackRequest(mockRequest1) + service.trackRequest(mockRequest2) + + service.abortInflightRequests() + + assert.strictEqual(mockRequest1.abort.calledOnce, true) + assert.strictEqual(mockRequest2.abort.calledOnce, true) + assert.strictEqual(service.inflightRequests.size, 0) + }) + }) + + describe('updateClientConfig', function () { + it('should update client configuration', function () { + const mockClient = { + config: { + update: sandbox.stub(), + }, + // Add minimal required properties to satisfy the interface + createCodeScan: sandbox.stub(), + createCodeScanUploadUrl: sandbox.stub(), + createProfile: sandbox.stub(), + deleteProfile: sandbox.stub(), + generateCompletions: sandbox.stub(), + generateSuggestions: sandbox.stub(), + getCodeAnalysis: sandbox.stub(), + getCodeScan: sandbox.stub(), + listCodeAnalysisFindings: sandbox.stub(), + listCodeScans: sandbox.stub(), + listFeatureEvaluations: sandbox.stub(), + listProfiles: sandbox.stub(), + sendTelemetryEvent: sandbox.stub(), + startCodeAnalysis: sandbox.stub(), + stopCodeAnalysis: sandbox.stub(), + updateProfile: sandbox.stub(), + } as any + service.client = mockClient + + const options: ConfigurationOptions = { region: 'us-west-2' } + service.updateClientConfig(options) + + assert.strictEqual(mockClient.config.update.calledOnceWith(options), true) + }) + }) + + describe('generateItemId', function () { + it('should generate unique item IDs', function () { + const id1 = service.generateItemId() + const id2 = service.generateItemId() + + assert.strictEqual(typeof id1, 'string') + assert.strictEqual(typeof id2, 'string') + assert.notStrictEqual(id1, id2) + }) + }) +}) diff --git a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService/codeWhispererServiceBase.ts b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService/codeWhispererServiceBase.ts new file mode 100644 index 0000000000..63803cd825 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService/codeWhispererServiceBase.ts @@ -0,0 +1,205 @@ +import { CredentialsType, CancellationToken } from '@aws/language-server-runtimes/server-interface' +import { AWSError, ConfigurationOptions } from 'aws-sdk' +import { PromiseResult } from 'aws-sdk/lib/request' +import { v4 as uuidv4 } from 'uuid' +import { RequestExtras } from '../../client/token/codewhisperer' +import CodeWhispererSigv4Client = require('../../client/sigv4/codewhisperersigv4client') +import CodeWhispererTokenClient = require('../../client/token/codewhispererbearertokenclient') + +export interface Suggestion extends CodeWhispererTokenClient.Completion, CodeWhispererSigv4Client.Recommendation { + itemId: string +} + +export interface GenerateSuggestionsRequest extends CodeWhispererTokenClient.GenerateCompletionsRequest { + // TODO : This is broken due to Interface 'GenerateSuggestionsRequest' cannot simultaneously extend types 'GenerateCompletionsRequest' and 'GenerateRecommendationsRequest'. + //CodeWhispererSigv4Client.GenerateRecommendationsRequest { + maxResults: number +} + +export type FileContext = GenerateSuggestionsRequest['fileContext'] + +export interface ResponseContext { + requestId: string + codewhispererSessionId: string + nextToken?: string +} + +export enum SuggestionType { + EDIT = 'EDIT', + COMPLETION = 'COMPLETION', +} + +export interface GenerateSuggestionsResponse { + suggestions: Suggestion[] + suggestionType?: SuggestionType + responseContext: ResponseContext +} + +// TODO: CodeWhispererSigv4Client requests and responses do not exist yet and should be added in the future +export interface CreateUploadUrlRequest extends CodeWhispererTokenClient.CreateUploadUrlRequest {} +export interface CreateUploadUrlResponse extends CodeWhispererTokenClient.CreateUploadUrlResponse {} + +export interface StartTransformationRequest extends CodeWhispererTokenClient.StartTransformationRequest {} +export interface StartTransformationResponse extends CodeWhispererTokenClient.StartTransformationResponse {} + +export interface StopTransformationRequest extends CodeWhispererTokenClient.StopTransformationRequest {} +export interface StopTransformationResponse extends CodeWhispererTokenClient.StopTransformationResponse {} + +export interface GetTransformationRequest extends CodeWhispererTokenClient.GetTransformationRequest {} +export interface GetTransformationResponse extends CodeWhispererTokenClient.GetTransformationResponse {} + +export interface GetTransformationPlanRequest extends CodeWhispererTokenClient.GetTransformationPlanRequest {} +export interface GetTransformationPlanResponse extends CodeWhispererTokenClient.GetTransformationPlanResponse {} + +export interface StartCodeAnalysisRequest extends CodeWhispererTokenClient.StartCodeAnalysisRequest {} +export interface StartCodeAnalysisResponse extends CodeWhispererTokenClient.StartCodeAnalysisResponse {} + +export interface GetCodeAnalysisRequest extends CodeWhispererTokenClient.GetCodeAnalysisRequest {} +export interface GetCodeAnalysisResponse extends CodeWhispererTokenClient.GetCodeAnalysisResponse {} + +export interface ListCodeAnalysisFindingsRequest extends CodeWhispererTokenClient.ListCodeAnalysisFindingsRequest {} +export interface ListCodeAnalysisFindingsResponse extends CodeWhispererTokenClient.ListCodeAnalysisFindingsResponse {} + +export interface ListAvailableCustomizationsRequest + extends CodeWhispererTokenClient.ListAvailableCustomizationsRequest {} +export interface ListAvailableCustomizationsResponse + extends CodeWhispererTokenClient.ListAvailableCustomizationsResponse {} + +export interface ListAvailableProfilesRequest extends CodeWhispererTokenClient.ListAvailableProfilesRequest {} +export interface ListAvailableProfilesResponse extends CodeWhispererTokenClient.ListAvailableProfilesResponse {} + +export interface SendTelemetryEventRequest extends CodeWhispererTokenClient.SendTelemetryEventRequest {} +export interface SendTelemetryEventResponse extends CodeWhispererTokenClient.SendTelemetryEventResponse {} + +export interface CreateWorkspaceRequest extends CodeWhispererTokenClient.CreateWorkspaceRequest {} +export interface CreateWorkspaceResponse extends CodeWhispererTokenClient.CreateWorkspaceResponse {} + +export interface ListWorkspaceMetadataRequest extends CodeWhispererTokenClient.ListWorkspaceMetadataRequest {} +export interface ListWorkspaceMetadataResponse extends CodeWhispererTokenClient.ListWorkspaceMetadataResponse {} + +export interface DeleteWorkspaceRequest extends CodeWhispererTokenClient.DeleteWorkspaceRequest {} +export interface DeleteWorkspaceResponse extends CodeWhispererTokenClient.DeleteWorkspaceResponse {} + +export interface ListFeatureEvaluationsRequest extends CodeWhispererTokenClient.ListFeatureEvaluationsRequest {} +export interface ListFeatureEvaluationsResponse extends CodeWhispererTokenClient.ListFeatureEvaluationsResponse {} + +export interface CreateSubscriptionTokenRequest extends CodeWhispererTokenClient.CreateSubscriptionTokenRequest {} +export interface CreateSubscriptionTokenResponse extends CodeWhispererTokenClient.CreateSubscriptionTokenResponse {} + +type CodeWhispererClient = CodeWhispererSigv4Client | CodeWhispererTokenClient + +// Right now the only difference between the token client and the IAM client for codewhsiperer is the difference in function name +// This abstract class can grow in the future to account for any additional changes across the clients +export abstract class CodeWhispererServiceBase { + protected readonly codeWhispererRegion + protected readonly codeWhispererEndpoint + public shareCodeWhispererContentWithAWS = false + public customizationArn?: string + public profileArn?: string + abstract client: CodeWhispererClient + + inflightRequests: Set & RequestExtras> = new Set() + + abortInflightRequests() { + this.inflightRequests.forEach(request => { + request.abort() + }) + this.inflightRequests.clear() + } + + trackRequest(request: AWS.Request & RequestExtras) { + this.inflightRequests.add(request) + } + + completeRequest(request: AWS.Request & RequestExtras) { + this.inflightRequests.delete(request) + } + + constructor(codeWhispererRegion: string, codeWhispererEndpoint: string) { + this.codeWhispererRegion = codeWhispererRegion + this.codeWhispererEndpoint = codeWhispererEndpoint + } + + /** + * Updates Service Client options after client was instantiated. + */ + public updateClientConfig(options: ConfigurationOptions) { + this.client.config.update(options) + } + + generateItemId = () => uuidv4() + + async getSubscriptionStatus( + statusOnly?: boolean + ): Promise<{ status: 'active' | 'active-expiring' | 'none'; encodedVerificationUrl?: string }> { + // No-op/default implementation: assume no subscription + return { + status: 'none', + } + } + + async waitUntilSubscriptionActive(_cancelToken?: CancellationToken): Promise { + // No-op: base class doesn't support subscription polling + return false + } + + abstract getCredentialsType(): CredentialsType + + abstract generateSuggestions(request: GenerateSuggestionsRequest): Promise + + abstract codeModernizerCreateUploadUrl(request: CreateUploadUrlRequest): Promise + + abstract codeModernizerStartCodeTransformation( + request: StartTransformationRequest + ): Promise> + + abstract codeModernizerStopCodeTransformation( + request: StopTransformationRequest + ): Promise> + + abstract codeModernizerGetCodeTransformation( + request: GetTransformationRequest + ): Promise> + + abstract codeModernizerGetCodeTransformationPlan( + request: GetTransformationPlanRequest + ): Promise> + + abstract createUploadUrl(request: CreateUploadUrlRequest): Promise> + + abstract startCodeAnalysis( + request: StartCodeAnalysisRequest + ): Promise> + + abstract getCodeAnalysis(request: GetCodeAnalysisRequest): Promise> + + abstract listCodeAnalysisFindings( + request: ListCodeAnalysisFindingsRequest + ): Promise> + + abstract listAvailableCustomizations( + request: ListAvailableCustomizationsRequest + ): Promise> + + abstract listAvailableProfiles( + request: ListAvailableProfilesRequest + ): Promise> + + abstract sendTelemetryEvent( + request: SendTelemetryEventRequest + ): Promise> + + abstract createWorkspace(request: CreateWorkspaceRequest): Promise> + + abstract listWorkspaceMetadata( + request: ListWorkspaceMetadataRequest + ): Promise> + + abstract deleteWorkspace(request: DeleteWorkspaceRequest): Promise> + + abstract listFeatureEvaluations( + request: ListFeatureEvaluationsRequest + ): Promise> + + abstract createSubscriptionToken(request: CreateSubscriptionTokenRequest): Promise +} diff --git a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService/codeWhispererServiceIAM.test.ts b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService/codeWhispererServiceIAM.test.ts new file mode 100644 index 0000000000..594d5dea64 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService/codeWhispererServiceIAM.test.ts @@ -0,0 +1,137 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CredentialsProvider, + Workspace, + Logging, + SDKInitializator, +} from '@aws/language-server-runtimes/server-interface' +import * as sinon from 'sinon' +import * as assert from 'assert' +import { GenerateSuggestionsRequest } from './codeWhispererServiceBase' +import { CodeWhispererServiceIAM } from './codeWhispererServiceIAM' + +describe('CodeWhispererServiceIAM', function () { + let sandbox: sinon.SinonSandbox + let mockCredentialsProvider: sinon.SinonStubbedInstance + let mockWorkspace: sinon.SinonStubbedInstance + let mockLogging: sinon.SinonStubbedInstance + let mockSDKInitializator: sinon.SinonStubbedInstance + let service: CodeWhispererServiceIAM + + beforeEach(function () { + // Create the environment + sandbox = sinon.createSandbox() + + mockCredentialsProvider = { + getCredentials: sandbox.stub(), + hasCredentials: sandbox.stub(), + refresh: sandbox.stub(), + } as any + + mockWorkspace = { + getWorkspaceFolder: sandbox.stub(), + getWorkspaceFolders: sandbox.stub(), + } as any + + mockLogging = { + debug: sandbox.stub(), + error: sandbox.stub(), + info: sandbox.stub(), + warn: sandbox.stub(), + log: sandbox.stub(), + } + + mockSDKInitializator = { + initialize: sandbox.stub(), + } as any + + // Mock the createCodeWhispererSigv4Client function to avoid real client creation + const mockClient = { + generateRecommendations: sandbox.stub().returns({ + promise: sandbox.stub().resolves({ + recommendations: [], + $response: { + requestId: 'test-request-id', + httpResponse: { + headers: { 'x-amzn-sessionid': 'test-session-id' }, + }, + }, + }), + }), + setupRequestListeners: sandbox.stub(), + config: { + update: sandbox.stub(), + }, + } + + // Mock the client creation + const createClientStub = sandbox.stub( + require('../../client/sigv4/codewhisperer'), + 'createCodeWhispererSigv4Client' + ) + createClientStub.returns(mockClient) + + service = new CodeWhispererServiceIAM( + mockCredentialsProvider as any, + {} as any, // workspace parameter + mockLogging as any, + 'us-east-1', + 'https://codewhisperer.us-east-1.amazonaws.com', + mockSDKInitializator as any + ) + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('getCredentialsType', function () { + it('should return iam credentials type', function () { + assert.strictEqual(service.getCredentialsType(), 'iam') + }) + }) + + describe('generateSuggestions', function () { + it('should call client.generateRecommendations and process response', async function () { + const mockRequest: GenerateSuggestionsRequest = { + fileContext: { + filename: 'test.js', + programmingLanguage: { languageName: 'javascript' }, + leftFileContent: 'const x = ', + rightFileContent: '', + }, + maxResults: 5, + } + + const result = await service.generateSuggestions(mockRequest) + + assert.strictEqual(Array.isArray(result.suggestions), true) + assert.strictEqual(typeof result.responseContext.requestId, 'string') + assert.strictEqual(typeof result.responseContext.codewhispererSessionId, 'string') + }) + + it('should add customizationArn to request if set', async function () { + service.customizationArn = 'test-arn' + + const mockRequest: GenerateSuggestionsRequest = { + fileContext: { + filename: 'test.js', + programmingLanguage: { languageName: 'javascript' }, + leftFileContent: 'const x = ', + rightFileContent: '', + }, + maxResults: 5, + } + + await service.generateSuggestions(mockRequest) + + // Verify that the client was called with the customizationArn + const clientCall = (service.client.generateRecommendations as sinon.SinonStub).getCall(0) + assert.strictEqual(clientCall.args[0].customizationArn, 'test-arn') + }) + }) +}) diff --git a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService/codeWhispererServiceIAM.ts b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService/codeWhispererServiceIAM.ts new file mode 100644 index 0000000000..1c3b9188bf --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService/codeWhispererServiceIAM.ts @@ -0,0 +1,201 @@ +import { + CredentialsProvider, + CredentialsType, + Workspace, + Logging, + SDKInitializator, +} from '@aws/language-server-runtimes/server-interface' +import { AWSError, CredentialProviderChain, Credentials } from 'aws-sdk' +import { Request } from 'aws-sdk/lib/core' +import { + CodeWhispererSigv4ClientConfigurationOptions, + createCodeWhispererSigv4Client, +} from '../../client/sigv4/codewhisperer' +import CodeWhispererSigv4Client = require('../../client/sigv4/codewhisperersigv4client') +import CodeWhispererTokenClient = require('../../client/token/codewhispererbearertokenclient') +import { + CodeWhispererServiceBase, + CreateSubscriptionTokenRequest, + CreateSubscriptionTokenResponse, + CreateUploadUrlRequest, + CreateUploadUrlResponse, + CreateWorkspaceRequest, + CreateWorkspaceResponse, + DeleteWorkspaceRequest, + DeleteWorkspaceResponse, + GenerateSuggestionsRequest, + GenerateSuggestionsResponse, + GetCodeAnalysisRequest, + GetCodeAnalysisResponse, + GetTransformationPlanRequest, + GetTransformationPlanResponse, + GetTransformationRequest, + GetTransformationResponse, + ListAvailableCustomizationsRequest, + ListAvailableCustomizationsResponse, + ListAvailableProfilesRequest, + ListAvailableProfilesResponse, + ListCodeAnalysisFindingsRequest, + ListCodeAnalysisFindingsResponse, + ListFeatureEvaluationsRequest, + ListFeatureEvaluationsResponse, + ListWorkspaceMetadataRequest, + ListWorkspaceMetadataResponse, + SendTelemetryEventRequest, + SendTelemetryEventResponse, + StartCodeAnalysisRequest, + StartCodeAnalysisResponse, + StartTransformationRequest, + StartTransformationResponse, + StopTransformationRequest, + StopTransformationResponse, + Suggestion, + SuggestionType, +} from './codeWhispererServiceBase' +import { PromiseResult } from 'aws-sdk/lib/request' + +export class CodeWhispererServiceIAM extends CodeWhispererServiceBase { + client: CodeWhispererSigv4Client + constructor( + credentialsProvider: CredentialsProvider, + workspace: Workspace, + logging: Logging, + codeWhispererRegion: string, + codeWhispererEndpoint: string, + sdkInitializator: SDKInitializator + ) { + super(codeWhispererRegion, codeWhispererEndpoint) + const options: CodeWhispererSigv4ClientConfigurationOptions = { + region: this.codeWhispererRegion, + endpoint: this.codeWhispererEndpoint, + credentialProvider: new CredentialProviderChain([ + () => credentialsProvider.getCredentials('iam') as Credentials, + ]), + } + this.client = createCodeWhispererSigv4Client(options, sdkInitializator, logging) + // Avoid overwriting any existing client listeners + const clientRequestListeners = this.client.setupRequestListeners + this.client.setupRequestListeners = (request: Request) => { + if (clientRequestListeners) { + clientRequestListeners.call(this.client, request) + } + request.httpRequest.headers['x-amzn-codewhisperer-optout'] = `${!this.shareCodeWhispererContentWithAWS}` + } + } + + getCredentialsType(): CredentialsType { + return 'iam' + } + + async generateSuggestions(request: GenerateSuggestionsRequest): Promise { + // add cancellation check + // add error check + if (this.customizationArn) request = { ...request, customizationArn: this.customizationArn } + const response = await this.client.generateRecommendations(request).promise() + const responseContext = { + requestId: response?.$response?.requestId, + codewhispererSessionId: response?.$response?.httpResponse?.headers['x-amzn-sessionid'], + nextToken: response.nextToken, + } + + for (const recommendation of response?.recommendations ?? []) { + Object.assign(recommendation, { itemId: this.generateItemId() }) + } + + return { + suggestions: response.recommendations as Suggestion[], + suggestionType: SuggestionType.COMPLETION, + responseContext, + } + } + + async codeModernizerCreateUploadUrl(request: CreateUploadUrlRequest): Promise { + throw new Error('Method not implemented.') + } + + async codeModernizerStartCodeTransformation( + request: StartTransformationRequest + ): Promise> { + throw new Error('Method not implemented.') + } + + async codeModernizerStopCodeTransformation( + request: StopTransformationRequest + ): Promise> { + throw new Error('Method not implemented.') + } + + async codeModernizerGetCodeTransformation( + request: GetTransformationRequest + ): Promise> { + throw new Error('Method not implemented.') + } + + async codeModernizerGetCodeTransformationPlan( + request: GetTransformationPlanRequest + ): Promise> { + throw new Error('Method not implemented.') + } + + async createUploadUrl(request: CreateUploadUrlRequest): Promise> { + throw new Error('Method not implemented.') + } + + async startCodeAnalysis( + request: StartCodeAnalysisRequest + ): Promise> { + throw new Error('Method not implemented.') + } + + async getCodeAnalysis(request: GetCodeAnalysisRequest): Promise> { + throw new Error('Method not implemented.') + } + + async listCodeAnalysisFindings( + request: ListCodeAnalysisFindingsRequest + ): Promise> { + throw new Error('Method not implemented.') + } + + async listAvailableCustomizations( + request: ListAvailableCustomizationsRequest + ): Promise> { + throw new Error('Method not implemented.') + } + + async listAvailableProfiles( + request: ListAvailableProfilesRequest + ): Promise> { + throw new Error('Method not implemented.') + } + + async sendTelemetryEvent( + request: SendTelemetryEventRequest + ): Promise> { + throw new Error('Method not implemented.') + } + + async createWorkspace(request: CreateWorkspaceRequest): Promise> { + throw new Error('Method not implemented.') + } + + async listWorkspaceMetadata( + request: ListWorkspaceMetadataRequest + ): Promise> { + throw new Error('Method not implemented.') + } + + async deleteWorkspace(request: DeleteWorkspaceRequest): Promise> { + throw new Error('Method not implemented.') + } + + async listFeatureEvaluations( + request: ListFeatureEvaluationsRequest + ): Promise> { + throw new Error('Method not implemented.') + } + + async createSubscriptionToken(request: CreateSubscriptionTokenRequest): Promise { + throw new Error('Method not implemented.') + } +} diff --git a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService/codeWhispererServiceToken.test.ts b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService/codeWhispererServiceToken.test.ts new file mode 100644 index 0000000000..a1edac04e2 --- /dev/null +++ b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService/codeWhispererServiceToken.test.ts @@ -0,0 +1,166 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + CredentialsProvider, + Workspace, + Logging, + SDKInitializator, +} from '@aws/language-server-runtimes/server-interface' +import * as sinon from 'sinon' +import * as assert from 'assert' +import { CodeWhispererServiceToken } from './codeWhispererServiceToken' +import { GenerateSuggestionsRequest } from './codeWhispererServiceBase' + +describe('CodeWhispererServiceToken', function () { + let sandbox: sinon.SinonSandbox + let mockCredentialsProvider: sinon.SinonStubbedInstance + let mockWorkspace: sinon.SinonStubbedInstance + let mockLogging: sinon.SinonStubbedInstance + let mockSDKInitializator: sinon.SinonStubbedInstance + let service: CodeWhispererServiceToken + let mockClient: any + + beforeEach(function () { + // Create the environment + sandbox = sinon.createSandbox() + + mockCredentialsProvider = { + getCredentials: sandbox.stub(), + hasCredentials: sandbox.stub(), + refresh: sandbox.stub(), + } as any + + mockWorkspace = { + getWorkspaceFolder: sandbox.stub(), + getWorkspaceFolders: sandbox.stub(), + } as any + + mockLogging = { + debug: sandbox.stub(), + error: sandbox.stub(), + info: sandbox.stub(), + warn: sandbox.stub(), + log: sandbox.stub(), + } + + mockSDKInitializator = { + initialize: sandbox.stub(), + } as any + + // Mock the token client + mockClient = { + generateCompletions: sandbox.stub().returns({ + promise: sandbox.stub().resolves({ + completions: [ + { + content: 'console.log("hello");', + references: [], + }, + ], + $response: { + requestId: 'test-request-id', + httpResponse: { + headers: { 'x-amzn-sessionid': 'test-session-id' }, + }, + }, + }), + }), + config: { + update: sandbox.stub(), + }, + } + + // Mock the client creation + const createTokenClientStub = sandbox.stub( + require('../../client/token/codewhisperer'), + 'createCodeWhispererTokenClient' + ) + createTokenClientStub.returns(mockClient) + + // Mock bearer credentials + mockCredentialsProvider.getCredentials.returns({ + token: 'mock-bearer-token', + }) + + service = new CodeWhispererServiceToken( + mockCredentialsProvider as any, + mockWorkspace as any, + mockLogging as any, + 'us-east-1', + 'https://codewhisperer.us-east-1.amazonaws.com', + mockSDKInitializator as any + ) + }) + + afterEach(function () { + sandbox.restore() + }) + + describe('getCredentialsType', function () { + it('should return bearer credentials type', function () { + assert.strictEqual(service.getCredentialsType(), 'bearer') + }) + }) + + describe('generateSuggestions', function () { + it('should call client.generateCompletions and process response', async function () { + const mockRequest: GenerateSuggestionsRequest = { + fileContext: { + filename: 'test.js', + programmingLanguage: { languageName: 'javascript' }, + leftFileContent: 'const x = ', + rightFileContent: '', + }, + maxResults: 5, + } + + const result = await service.generateSuggestions(mockRequest) + + assert.strictEqual(mockClient.generateCompletions.calledOnce, true) + assert.strictEqual(Array.isArray(result.suggestions), true) + assert.strictEqual(typeof result.responseContext.requestId, 'string') + assert.strictEqual(typeof result.responseContext.codewhispererSessionId, 'string') + }) + + it('should add customizationArn to request if set', async function () { + service.customizationArn = 'test-arn' + + const mockRequest: GenerateSuggestionsRequest = { + fileContext: { + filename: 'test.js', + programmingLanguage: { languageName: 'javascript' }, + leftFileContent: 'const x = ', + rightFileContent: '', + }, + maxResults: 5, + } + + await service.generateSuggestions(mockRequest) + + const clientCall = mockClient.generateCompletions.getCall(0) + assert.strictEqual(clientCall.args[0].customizationArn, 'test-arn') + }) + + it('should process profile ARN with withProfileArn method', async function () { + const mockRequest: GenerateSuggestionsRequest = { + fileContext: { + filename: 'test.js', + programmingLanguage: { languageName: 'javascript' }, + leftFileContent: 'const x = ', + rightFileContent: '', + }, + maxResults: 5, + } + + const withProfileArnStub = sandbox.stub(service, 'withProfileArn' as any) + withProfileArnStub.returns(mockRequest) + + await service.generateSuggestions(mockRequest) + + assert.strictEqual(withProfileArnStub.calledOnceWith(mockRequest), true) + }) + }) +}) diff --git a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService/codeWhispererServiceToken.ts similarity index 64% rename from server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts rename to server/aws-lsp-codewhisperer/src/shared/codeWhispererService/codeWhispererServiceToken.ts index 6ad476db05..648ac26b58 100644 --- a/server/aws-lsp-codewhisperer/src/shared/codeWhispererService.ts +++ b/server/aws-lsp-codewhisperer/src/shared/codeWhispererService/codeWhispererServiceToken.ts @@ -9,167 +9,48 @@ import { CancellationTokenSource, } from '@aws/language-server-runtimes/server-interface' import { waitUntil } from '@aws/lsp-core/out/util/timeoutUtils' -import { AWSError, ConfigurationOptions, CredentialProviderChain, Credentials } from 'aws-sdk' +import { AWSError } from 'aws-sdk' import { PromiseResult } from 'aws-sdk/lib/request' -import { Request } from 'aws-sdk/lib/core' -import { v4 as uuidv4 } from 'uuid' -import { - CodeWhispererSigv4ClientConfigurationOptions, - createCodeWhispererSigv4Client, -} from '../client/sigv4/codewhisperer' import { CodeWhispererTokenClientConfigurationOptions, createCodeWhispererTokenClient, - RequestExtras, -} from '../client/token/codewhisperer' -import CodeWhispererSigv4Client = require('../client/sigv4/codewhisperersigv4client') -import CodeWhispererTokenClient = require('../client/token/codewhispererbearertokenclient') -import { getErrorId } from './utils' -import { GenerateCompletionsResponse } from '../client/token/codewhispererbearertokenclient' - -export interface Suggestion extends CodeWhispererTokenClient.Completion, CodeWhispererSigv4Client.Recommendation { - itemId: string -} - -export interface GenerateSuggestionsRequest extends CodeWhispererTokenClient.GenerateCompletionsRequest { - // TODO : This is broken due to Interface 'GenerateSuggestionsRequest' cannot simultaneously extend types 'GenerateCompletionsRequest' and 'GenerateRecommendationsRequest'. - //CodeWhispererSigv4Client.GenerateRecommendationsRequest { - maxResults: number -} - -export type FileContext = GenerateSuggestionsRequest['fileContext'] - -export interface ResponseContext { - requestId: string - codewhispererSessionId: string - nextToken?: string -} - -export enum SuggestionType { - EDIT = 'EDIT', - COMPLETION = 'COMPLETION', -} - -export interface GenerateSuggestionsResponse { - suggestions: Suggestion[] - suggestionType?: SuggestionType - responseContext: ResponseContext -} - -// This abstract class can grow in the future to account for any additional changes across the clients -export abstract class CodeWhispererServiceBase { - protected readonly codeWhispererRegion - protected readonly codeWhispererEndpoint - public shareCodeWhispererContentWithAWS = false - public customizationArn?: string - public profileArn?: string - abstract client: CodeWhispererSigv4Client | CodeWhispererTokenClient - - inflightRequests: Set & RequestExtras> = new Set() - - abortInflightRequests() { - this.inflightRequests.forEach(request => { - request.abort() - }) - this.inflightRequests.clear() - } - - trackRequest(request: AWS.Request & RequestExtras) { - this.inflightRequests.add(request) - } - - completeRequest(request: AWS.Request & RequestExtras) { - this.inflightRequests.delete(request) - } - - abstract getCredentialsType(): CredentialsType - - abstract generateSuggestions(request: GenerateSuggestionsRequest): Promise - - constructor(codeWhispererRegion: string, codeWhispererEndpoint: string) { - this.codeWhispererRegion = codeWhispererRegion - this.codeWhispererEndpoint = codeWhispererEndpoint - } - - /** - * Updates Service Client options after client was instantiated. - */ - public updateClientConfig(options: ConfigurationOptions) { - this.client.config.update(options) - } - - generateItemId = () => uuidv4() - - async getSubscriptionStatus( - statusOnly?: boolean - ): Promise<{ status: 'active' | 'active-expiring' | 'none'; encodedVerificationUrl?: string }> { - // No-op/default implementation: assume no subscription - return { - status: 'none', - } - } - - async waitUntilSubscriptionActive(_cancelToken?: CancellationToken): Promise { - // No-op: base class doesn't support subscription polling - return false - } -} - -export class CodeWhispererServiceIAM extends CodeWhispererServiceBase { - client: CodeWhispererSigv4Client - constructor( - credentialsProvider: CredentialsProvider, - workspace: Workspace, - logging: Logging, - codeWhispererRegion: string, - codeWhispererEndpoint: string, - sdkInitializator: SDKInitializator - ) { - super(codeWhispererRegion, codeWhispererEndpoint) - const options: CodeWhispererSigv4ClientConfigurationOptions = { - region: this.codeWhispererRegion, - endpoint: this.codeWhispererEndpoint, - credentialProvider: new CredentialProviderChain([ - () => credentialsProvider.getCredentials('iam') as Credentials, - ]), - } - this.client = createCodeWhispererSigv4Client(options, sdkInitializator, logging) - // Avoid overwriting any existing client listeners - const clientRequestListeners = this.client.setupRequestListeners - this.client.setupRequestListeners = (request: Request) => { - if (clientRequestListeners) { - clientRequestListeners.call(this.client, request) - } - request.httpRequest.headers['x-amzn-codewhisperer-optout'] = `${!this.shareCodeWhispererContentWithAWS}` - } - } - - getCredentialsType(): CredentialsType { - return 'iam' - } - - async generateSuggestions(request: GenerateSuggestionsRequest): Promise { - // add cancellation check - // add error check - if (this.customizationArn) request = { ...request, customizationArn: this.customizationArn } - const response = await this.client.generateRecommendations(request).promise() - const responseContext = { - requestId: response?.$response?.requestId, - codewhispererSessionId: response?.$response?.httpResponse?.headers['x-amzn-sessionid'], - nextToken: response.nextToken, - } - - for (const recommendation of response?.recommendations ?? []) { - Object.assign(recommendation, { itemId: this.generateItemId() }) - } - - return { - suggestions: response.recommendations as Suggestion[], - suggestionType: SuggestionType.COMPLETION, - responseContext, - } - } -} +} from '../../client/token/codewhisperer' +import CodeWhispererTokenClient = require('../../client/token/codewhispererbearertokenclient') +import { getErrorId } from '../utils' +import { GenerateCompletionsResponse } from '../../client/token/codewhispererbearertokenclient' +import { + CodeWhispererServiceBase, + CreateSubscriptionTokenRequest, + CreateSubscriptionTokenResponse, + CreateUploadUrlRequest, + CreateUploadUrlResponse, + CreateWorkspaceRequest, + DeleteWorkspaceRequest, + GenerateSuggestionsRequest, + GenerateSuggestionsResponse, + GetCodeAnalysisRequest, + GetCodeAnalysisResponse, + GetTransformationPlanRequest, + GetTransformationPlanResponse, + GetTransformationRequest, + GetTransformationResponse, + ListAvailableCustomizationsRequest, + ListAvailableProfilesRequest, + ListCodeAnalysisFindingsRequest, + ListCodeAnalysisFindingsResponse, + ListFeatureEvaluationsRequest, + ListWorkspaceMetadataRequest, + ResponseContext, + SendTelemetryEventRequest, + StartCodeAnalysisRequest, + StartCodeAnalysisResponse, + StartTransformationRequest, + StartTransformationResponse, + StopTransformationRequest, + StopTransformationResponse, + Suggestion, + SuggestionType, +} from './codeWhispererServiceBase' /** * Hint: to get an instance of this: `AmazonQTokenServiceManager.getInstance().getCodewhispererService()` @@ -177,7 +58,7 @@ export class CodeWhispererServiceIAM extends CodeWhispererServiceBase { export class CodeWhispererServiceToken extends CodeWhispererServiceBase { client: CodeWhispererTokenClient /** Debounce createSubscriptionToken by storing the current, pending promise (if any). */ - #createSubscriptionTokenPromise?: Promise + #createSubscriptionTokenPromise?: Promise /** If user clicks "Upgrade" multiple times, cancel the previous wait-promise. */ #waitUntilSubscriptionCancelSource?: CancellationTokenSource @@ -307,9 +188,7 @@ export class CodeWhispererServiceToken extends CodeWhispererServiceBase { } } - public async codeModernizerCreateUploadUrl( - request: CodeWhispererTokenClient.CreateUploadUrlRequest - ): Promise { + public async codeModernizerCreateUploadUrl(request: CreateUploadUrlRequest): Promise { return this.client.createUploadUrl(this.withProfileArn(request)).promise() } /** @@ -319,8 +198,8 @@ export class CodeWhispererServiceToken extends CodeWhispererServiceBase { */ public async codeModernizerStartCodeTransformation( - request: CodeWhispererTokenClient.StartTransformationRequest - ): Promise> { + request: StartTransformationRequest + ): Promise> { return await this.client.startTransformation(this.withProfileArn(request)).promise() } @@ -330,8 +209,8 @@ export class CodeWhispererServiceToken extends CodeWhispererServiceBase { * @returns transformationJobId - String id for the Job */ public async codeModernizerStopCodeTransformation( - request: CodeWhispererTokenClient.StopTransformationRequest - ): Promise> { + request: StopTransformationRequest + ): Promise> { return await this.client.stopTransformation(this.withProfileArn(request)).promise() } @@ -341,8 +220,8 @@ export class CodeWhispererServiceToken extends CodeWhispererServiceBase { * returns COMPLETED we know the transformation is done. */ public async codeModernizerGetCodeTransformation( - request: CodeWhispererTokenClient.GetTransformationRequest - ): Promise> { + request: GetTransformationRequest + ): Promise> { return await this.client.getTransformation(this.withProfileArn(request)).promise() } @@ -352,17 +231,15 @@ export class CodeWhispererServiceToken extends CodeWhispererServiceBase { * @params tranformationJobId - String id returned from StartCodeTransformationResponse */ public async codeModernizerGetCodeTransformationPlan( - request: CodeWhispererTokenClient.GetTransformationPlanRequest - ): Promise> { + request: GetTransformationPlanRequest + ): Promise> { return this.client.getTransformationPlan(this.withProfileArn(request)).promise() } /** * @description get a pre-signed url to upload source code into S3 bucket */ - async createUploadUrl( - request: CodeWhispererTokenClient.CreateUploadUrlRequest - ): Promise> { + async createUploadUrl(request: CreateUploadUrlRequest): Promise> { return this.client.createUploadUrl(this.withProfileArn(request)).promise() } @@ -370,17 +247,15 @@ export class CodeWhispererServiceToken extends CodeWhispererServiceBase { * @description Once source code uploaded to S3, send a request to run security scan on uploaded source code. */ async startCodeAnalysis( - request: CodeWhispererTokenClient.StartCodeAnalysisRequest - ): Promise> { + request: StartCodeAnalysisRequest + ): Promise> { return this.client.startCodeAnalysis(this.withProfileArn(request)).promise() } /** * @description Send a request to get the code scan status detail. */ - async getCodeAnalysis( - request: CodeWhispererTokenClient.GetCodeAnalysisRequest - ): Promise> { + async getCodeAnalysis(request: GetCodeAnalysisRequest): Promise> { return this.client.getCodeAnalysis(this.withProfileArn(request)).promise() } @@ -388,57 +263,57 @@ export class CodeWhispererServiceToken extends CodeWhispererServiceBase { * @description Once scan completed successfully, send a request to get list of all the findings for the given scan. */ async listCodeAnalysisFindings( - request: CodeWhispererTokenClient.ListCodeAnalysisFindingsRequest - ): Promise> { + request: ListCodeAnalysisFindingsRequest + ): Promise> { return this.client.listCodeAnalysisFindings(this.withProfileArn(request)).promise() } /** * @description Get list of available customizations */ - async listAvailableCustomizations(request: CodeWhispererTokenClient.ListAvailableCustomizationsRequest) { + async listAvailableCustomizations(request: ListAvailableCustomizationsRequest) { return this.client.listAvailableCustomizations(this.withProfileArn(request)).promise() } /** * @description Get list of available profiles */ - async listAvailableProfiles(request: CodeWhispererTokenClient.ListAvailableProfilesRequest) { + async listAvailableProfiles(request: ListAvailableProfilesRequest) { return this.client.listAvailableProfiles(request).promise() } /** * @description send telemetry event to code whisperer data warehouse */ - async sendTelemetryEvent(request: CodeWhispererTokenClient.SendTelemetryEventRequest) { + async sendTelemetryEvent(request: SendTelemetryEventRequest) { return this.client.sendTelemetryEvent(this.withProfileArn(request)).promise() } /** * @description create a remote workspace */ - async createWorkspace(request: CodeWhispererTokenClient.CreateWorkspaceRequest) { + async createWorkspace(request: CreateWorkspaceRequest) { return this.client.createWorkspace(this.withProfileArn(request)).promise() } /** * @description get list of workspace metadata */ - async listWorkspaceMetadata(request: CodeWhispererTokenClient.ListWorkspaceMetadataRequest) { + async listWorkspaceMetadata(request: ListWorkspaceMetadataRequest) { return this.client.listWorkspaceMetadata(this.withProfileArn(request)).promise() } /** * @description delete the remote workspace */ - async deleteWorkspace(request: CodeWhispererTokenClient.DeleteWorkspaceRequest) { + async deleteWorkspace(request: DeleteWorkspaceRequest) { return this.client.deleteWorkspace(this.withProfileArn(request)).promise() } /* * @description get the list of feature evaluations */ - async listFeatureEvaluations(request: CodeWhispererTokenClient.ListFeatureEvaluationsRequest) { + async listFeatureEvaluations(request: ListFeatureEvaluationsRequest) { return this.client.listFeatureEvaluations(this.withProfileArn(request)).promise() } @@ -447,7 +322,7 @@ export class CodeWhispererServiceToken extends CodeWhispererServiceBase { * * cool api you have there 🥹 */ - async createSubscriptionToken(request: CodeWhispererTokenClient.CreateSubscriptionTokenRequest) { + async createSubscriptionToken(request: CreateSubscriptionTokenRequest) { // Debounce. if (this.#createSubscriptionTokenPromise) { return this.#createSubscriptionTokenPromise diff --git a/server/aws-lsp-codewhisperer/src/shared/streamingClientService.ts b/server/aws-lsp-codewhisperer/src/shared/streamingClientService.ts index cc0ec57070..7105e192fa 100644 --- a/server/aws-lsp-codewhisperer/src/shared/streamingClientService.ts +++ b/server/aws-lsp-codewhisperer/src/shared/streamingClientService.ts @@ -34,6 +34,7 @@ export type ChatCommandOutput = SendMessageCommandOutput | GenerateAssistantResp export abstract class StreamingClientServiceBase { protected readonly region protected readonly endpoint + public profileArn?: string inflightRequests: Set = new Set() @@ -49,6 +50,16 @@ export abstract class StreamingClientServiceBase { abortController?: AbortController ): Promise + abstract generateAssistantResponse( + request: GenerateAssistantResponseCommandInputCodeWhispererStreaming, + abortController?: AbortController + ): Promise + + abstract exportResultArchive( + request: ExportResultArchiveCommandInputCodeWhispererStreaming, + abortController?: AbortController + ): Promise + public abortInflightRequests() { this.inflightRequests.forEach(abortController => { abortController.abort() @@ -59,7 +70,6 @@ export abstract class StreamingClientServiceBase { export class StreamingClientServiceToken extends StreamingClientServiceBase { client: CodeWhispererStreaming - public profileArn?: string constructor( credentialsProvider: CredentialsProvider, sdkInitializator: SDKInitializator, @@ -209,4 +219,17 @@ export class StreamingClientServiceIAM extends StreamingClientServiceBase { return response } + + public async generateAssistantResponse( + request: GenerateAssistantResponseCommandInputCodeWhispererStreaming, + abortController?: AbortController + ): Promise { + throw new Error('Method not implemented.') + } + public async exportResultArchive( + request: ExportResultArchiveCommandInputCodeWhispererStreaming, + abortController?: AbortController + ): Promise { + throw new Error('Method not implemented.') + } } diff --git a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetry.test.ts b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetry.test.ts index 86b9e4ec9d..e4d513caea 100644 --- a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetry.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetry.test.ts @@ -9,7 +9,12 @@ import { TestFeatures } from '@aws/language-server-runtimes/testing' import sinon, { StubbedInstance, stubInterface } from 'ts-sinon' import { TextDocument } from 'vscode-languageserver-textdocument' import { CodewhispererServerFactory } from '../../language-server/inline-completion/codeWhispererServer' -import { CodeWhispererServiceBase, ResponseContext, Suggestion, SuggestionType } from '../codeWhispererService' +import { + CodeWhispererServiceBase, + ResponseContext, + Suggestion, + SuggestionType, +} from '../codeWhispererService/codeWhispererServiceBase' import { TelemetryService } from './telemetryService' import { initBaseTestServiceManager, TestAmazonQServiceManager } from '../amazonQServiceManager/testUtils' diff --git a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.test.ts b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.test.ts index 14df60adb9..97f917156c 100644 --- a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.test.ts +++ b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.test.ts @@ -15,7 +15,7 @@ import { CodeWhispererSession } from '../../language-server/inline-completion/se import sinon, { StubbedInstance, stubInterface } from 'ts-sinon' import { BUILDER_ID_START_URL } from '../constants' import { ChatInteractionType } from './types' -import { CodeWhispererServiceToken } from '../codeWhispererService' +import { CodeWhispererServiceToken } from '../codeWhispererService/codeWhispererServiceToken' import { initBaseTestServiceManager, TestAmazonQServiceManager } from '../amazonQServiceManager/testUtils' import { TestFeatures } from '@aws/language-server-runtimes/testing' diff --git a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts index 6cc0b4fdfe..48d16d8dcb 100644 --- a/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts +++ b/server/aws-lsp-codewhisperer/src/shared/telemetry/telemetryService.ts @@ -1,4 +1,4 @@ -import { CodeWhispererServiceToken } from '../codeWhispererService' +import { CodeWhispererServiceToken } from '../codeWhispererService/codeWhispererServiceToken' import { CredentialsProvider, CredentialsType, diff --git a/server/aws-lsp-codewhisperer/src/shared/testUtils.ts b/server/aws-lsp-codewhisperer/src/shared/testUtils.ts index 38d6e960cb..79596d8cc6 100644 --- a/server/aws-lsp-codewhisperer/src/shared/testUtils.ts +++ b/server/aws-lsp-codewhisperer/src/shared/testUtils.ts @@ -1,5 +1,5 @@ import { TextDocument } from 'vscode-languageserver-textdocument' -import { CodeWhispererServiceBase, ResponseContext, Suggestion } from './codeWhispererService' +import { CodeWhispererServiceBase, ResponseContext, Suggestion } from './codeWhispererService/codeWhispererServiceBase' import { TestFeatures } from '@aws/language-server-runtimes/testing' import { SsoConnectionType } from './utils' import { stubInterface } from 'ts-sinon' diff --git a/server/aws-lsp-codewhisperer/src/shared/utils.ts b/server/aws-lsp-codewhisperer/src/shared/utils.ts index 3499696d45..fd5363577f 100644 --- a/server/aws-lsp-codewhisperer/src/shared/utils.ts +++ b/server/aws-lsp-codewhisperer/src/shared/utils.ts @@ -6,7 +6,7 @@ import { } from '@aws/language-server-runtimes/server-interface' import { AWSError, Credentials } from 'aws-sdk' import { distance } from 'fastest-levenshtein' -import { Suggestion } from './codeWhispererService' +import { Suggestion } from './codeWhispererService/codeWhispererServiceBase' import { CodewhispererCompletionType } from './telemetry/types' import { BUILDER_ID_START_URL, diff --git a/server/aws-lsp-identity/src/iam/iamProvider.test.ts b/server/aws-lsp-identity/src/iam/iamProvider.test.ts new file mode 100644 index 0000000000..66415caf4e --- /dev/null +++ b/server/aws-lsp-identity/src/iam/iamProvider.test.ts @@ -0,0 +1,297 @@ +import { expect, use } from 'chai' +import { StubbedInstance, stubInterface } from 'ts-sinon' +import { ProfileData, ProfileStore } from '../language-server/profiles/profileService' +import { createStubInstance, restore, SinonStub, stub } from 'sinon' +import { CancellationToken, Profile, ProfileKind } from '@aws/language-server-runtimes/protocol' +import { Logging, Telemetry } from '@aws/language-server-runtimes/server-interface' +import { IamCredentials, Observability } from '@aws/lsp-core' +import { StsCache } from '../sts/cache/stsCache' +import { StsAutoRefresher } from '../sts/stsAutoRefresher' +import { IamProvider } from '../iam/iamProvider' +import { IamFlowParams } from './utils' +import * as iamUtils from '../iam/utils' +import { STSClient } from '@aws-sdk/client-sts' + +// eslint-disable-next-line +use(require('chai-as-promised')) + +let sut: IamProvider +let defaultParams: IamFlowParams +let defaultProfile: Profile +let profileStore: StubbedInstance +let stsCache: StubbedInstance +let stsAutoRefresher: StubbedInstance +let handlers: StubbedInstance +let observability: StubbedInstance +let token: StubbedInstance +let checkMfaRequiredStub: SinonStub< + [credentials: IamCredentials, permissions: string[], region?: string | undefined], + Promise +> + +describe('IamProvider', () => { + beforeEach(() => { + defaultProfile = { + kinds: [ProfileKind.Unknown], + name: 'default-profile', + } + + profileStore = stubInterface({ + load: Promise.resolve({ + profiles: [ + { + kinds: [ProfileKind.IamSourceProfileProfile], + name: 'cyclic-profile-1', + settings: { + role_arn: 'my-role-arn', + source_profile: 'cyclic-profile-1', + }, + }, + { + kinds: [ProfileKind.IamSourceProfileProfile], + name: 'cyclic-profile-2', + settings: { + role_arn: 'my-role-arn', + source_profile: 'cyclic-profile-3', + }, + }, + { + kinds: [ProfileKind.IamSourceProfileProfile], + name: 'cyclic-profile-3', + settings: { + role_arn: 'my-role-arn', + source_profile: 'cyclic-profile-2', + }, + }, + { + kinds: [ProfileKind.IamSourceProfileProfile], + name: 'base-profile', + settings: { + role_arn: 'my-role-arn', + source_profile: 'intermediate-profile', + }, + }, + { + kinds: [ProfileKind.IamSourceProfileProfile], + name: 'intermediate-profile', + settings: { + role_arn: 'my-role-arn', + source_profile: 'my-iam-profile', + }, + }, + { + kinds: [ProfileKind.IamCredentialsProfile], + name: 'my-iam-profile', + settings: { + aws_access_key_id: 'my-access-key', + aws_secret_access_key: 'my-secret-key', + }, + }, + ], + ssoSessions: [], + } satisfies ProfileData), + }) + + stsCache = stubInterface({ + getStsCredential: Promise.resolve(undefined), + setStsCredential: Promise.resolve(), + removeStsCredential: Promise.resolve(), + }) + + stsAutoRefresher = createStubInstance(StsAutoRefresher, { + watch: Promise.resolve(), + unwatch: undefined, + }) as StubbedInstance + + observability = stubInterface() + observability.logging = stubInterface() + observability.telemetry = stubInterface() + + handlers = stubInterface({ + sendGetMfaCode: Promise.resolve({ code: 'mfa-code', mfaSerial: 'mfa-serial' }), + }) + + token = stubInterface() + + defaultParams = { + profile: defaultProfile, + callStsOnInvalidIamCredential: true, + recursionCount: 0, + profileStore: profileStore, + stsCache: stsCache, + stsAutoRefresher: stsAutoRefresher, + handlers: handlers, + token: token, + observability: observability, + } + + sut = new IamProvider() + + checkMfaRequiredStub = stub(iamUtils, 'checkMfaRequired') + checkMfaRequiredStub.resolves(false) + + stub(STSClient.prototype, 'send').resolves({ + Credentials: { + AccessKeyId: 'role-access-key', + SecretAccessKey: 'role-secret-key', + SessionToken: 'role-session-token', + Expiration: new Date('2024-09-25T18:09:20.455Z'), + }, + AssumedRoleUser: { + Arn: 'role-arn', + AssumedRoleId: 'role-id', + }, + }) + }) + + afterEach(() => { + restore() + }) + + describe('getCredential', () => { + it('Can get credentials from profile', async () => { + const profile: Profile = { + kinds: [ProfileKind.IamCredentialsProfile], + name: 'iam-profile', + settings: { + aws_access_key_id: 'access-key', + aws_secret_access_key: 'secret-key', + aws_session_token: 'session-token', + }, + } + const actual = await sut.getCredential({ ...defaultParams, profile: profile }) + + expect(actual.credentials.accessKeyId).to.equal('access-key') + expect(actual.credentials.secretAccessKey).to.equal('secret-key') + expect(actual.credentials.sessionToken).to.equal('session-token') + }) + + it('Can generate credentials by assuming role.', async () => { + const profile: Profile = { + kinds: [ProfileKind.IamSourceProfileProfile], + name: 'my-role-profile', + settings: { + role_arn: 'my-role-arn', + source_profile: 'my-iam-profile', + }, + } + const actual = await sut.getCredential({ ...defaultParams, profile: profile }) + + expect(actual.credentials.accessKeyId).to.equal('role-access-key') + expect(actual.credentials.secretAccessKey).to.equal('role-secret-key') + expect(actual.credentials.sessionToken).to.equal('role-session-token') + expect(actual.credentials.expiration?.toISOString()).to.equal('2024-09-25T18:09:20.455Z') + expect(stsAutoRefresher.watch.calledOnce).to.be.true + }) + + it('Can generate credentials with MFA.', async () => { + checkMfaRequiredStub.resolves(true) + const profile: Profile = { + kinds: [ProfileKind.IamSourceProfileProfile], + name: 'my-mfa-profile', + settings: { + role_arn: 'my-role-arn', + source_profile: 'my-iam-profile', + mfa_serial: 'my-device-arn', + }, + } + const actual = await sut.getCredential({ ...defaultParams, profile: profile }) + + expect(actual.credentials.accessKeyId).to.equal('role-access-key') + expect(actual.credentials.secretAccessKey).to.equal('role-secret-key') + expect(actual.credentials.sessionToken).to.equal('role-session-token') + expect(actual.credentials.expiration?.toISOString()).to.equal('2024-09-25T18:09:20.455Z') + expect(handlers.sendGetMfaCode.calledOnce).to.be.true + }) + + it('Returns existing STS credential.', async () => { + const profile: Profile = { + kinds: [ProfileKind.IamSourceProfileProfile], + name: 'my-role-profile', + settings: { + role_arn: 'my-role-arn', + source_profile: 'my-iam-profile', + }, + } + stsCache.getStsCredential = (() => + Promise.resolve({ + accessKeyId: 'other-access-key', + secretAccessKey: 'other-secret-key', + sessionToken: 'other-session-token', + expiration: new Date('2024-10-25T18:09:20.455Z'), + })) as any + const actual = await sut.getCredential({ ...defaultParams, profile: profile }) + + expect(actual.credentials.accessKeyId).to.equal('other-access-key') + expect(actual.credentials.secretAccessKey).to.equal('other-secret-key') + expect(actual.credentials.sessionToken).to.equal('other-session-token') + expect(actual.credentials.expiration?.toISOString()).to.equal('2024-10-25T18:09:20.455Z') + expect(stsAutoRefresher.watch.calledOnce).to.be.true + }) + + it('Throws when no STS credential cached and callStsOnInvalidIamCredential is false.', async () => { + const profile: Profile = { + kinds: [ProfileKind.IamSourceProfileProfile], + name: 'my-role-profile', + settings: { + role_arn: 'my-role-arn', + source_profile: 'my-iam-profile', + }, + } + const error = await expect( + sut.getCredential({ ...defaultParams, profile: profile, callStsOnInvalidIamCredential: false }) + ).rejectedWith(Error) + + expect(error.message).to.equal('STS credential not found.') + expect(stsAutoRefresher.watch.calledOnce).to.be.false + }) + + it('Can login with chained IamSourceProfileProfiles.', async () => { + const profile: Profile = { + kinds: [ProfileKind.IamSourceProfileProfile], + name: 'base-profile', + settings: { + role_arn: 'my-role-arn', + source_profile: 'intermediate-profile', + }, + } + const actual = await sut.getCredential({ ...defaultParams, profile: profile }) + + expect(actual.credentials.accessKeyId).to.equal('role-access-key') + expect(actual.credentials.secretAccessKey).to.equal('role-secret-key') + expect(actual.credentials.sessionToken).to.equal('role-session-token') + expect(actual.credentials.expiration?.toISOString()).to.equal('2024-09-25T18:09:20.455Z') + expect(stsAutoRefresher.watch.called).to.be.true + }) + + it('Throws when IamSourceProfileProfile points to itself.', async () => { + const profile: Profile = { + kinds: [ProfileKind.IamSourceProfileProfile], + name: 'cyclic-profile-1', + settings: { + role_arn: 'my-role-arn', + source_profile: 'cyclic-profile-1', + }, + } + const error = await expect(sut.getCredential({ ...defaultParams, profile: profile })).rejectedWith(Error) + + expect(error.message).to.equal('Source profile chain exceeded max length.') + expect(stsAutoRefresher.watch.calledOnce).to.be.false + }) + + it('Throws when IamSourceProfileProfile form cycle.', async () => { + const profile: Profile = { + kinds: [ProfileKind.IamSourceProfileProfile], + name: 'cyclic-profile-2', + settings: { + role_arn: 'my-role-arn', + source_profile: 'cyclic-profile-3', + }, + } + const error = await expect(sut.getCredential({ ...defaultParams, profile: profile })).rejectedWith(Error) + + expect(error.message).to.equal('Source profile chain exceeded max length.') + expect(stsAutoRefresher.watch.calledOnce).to.be.false + }) + }) +}) diff --git a/server/aws-lsp-identity/src/iam/iamProvider.ts b/server/aws-lsp-identity/src/iam/iamProvider.ts new file mode 100644 index 0000000000..dd93fc118b --- /dev/null +++ b/server/aws-lsp-identity/src/iam/iamProvider.ts @@ -0,0 +1,187 @@ +import { + AwsErrorCodes, + GetMfaCodeResult, + IamCredential, + IamCredentials, + ProfileKind, +} from '@aws/language-server-runtimes/server-interface' +import { AwsError } from '@aws/lsp-core' +import { AssumeRoleCommand, AssumeRoleCommandInput, STSClient } from '@aws-sdk/client-sts' +import { checkMfaRequired, IamFlowParams } from './utils' +import { convertProfileToId } from '../sts/cache/fileSystemStsCache' + +const sourceProfileRecursionMax = 5 +const mfaTimeout = 2 * 60 * 1000 // 2 minutes + +export class IamProvider { + readonly defaultRegion = 'us-east-1' + + async getCredential(params: IamFlowParams): Promise { + let credentials: IamCredentials + + // Get the credentials directly from the profile + if (params.profile.kinds.includes(ProfileKind.IamCredentialsProfile)) { + credentials = { + accessKeyId: params.profile.settings!.aws_access_key_id!, + secretAccessKey: params.profile.settings!.aws_secret_access_key!, + sessionToken: params.profile.settings?.aws_session_token, + } + } + // Assume the role matching the found ARN + else if (params.profile.kinds.includes(ProfileKind.IamSourceProfileProfile)) { + credentials = await this.getAssumedRoleCredential(params) + } else { + throw new AwsError( + 'Credentials could not be found for provided profile kind', + AwsErrorCodes.E_INVALID_PROFILE + ) + } + + return { id: convertProfileToId(params.profile), kinds: params.profile.kinds, credentials: credentials } + } + + private async getAssumedRoleCredential(params: IamFlowParams): Promise { + if (!params.profile.settings) { + throw new AwsError('Profile settings not found when assuming role.', AwsErrorCodes.E_INVALID_PROFILE) + } + + // Try to get the STS credentials from cache + let result: IamCredentials + const credential = await params.stsCache + .getStsCredential(convertProfileToId(params.profile)) + .catch(_ => undefined) + + if (credential) { + result = credential + } else if (params.callStsOnInvalidIamCredential) { + // Generate STS credentials + result = await this.generateStsCredential(params) + // Cache STS credentials + await params.stsCache.setStsCredential(convertProfileToId(params.profile), result) + } else { + // If we could not get the cached STS credential and cannot generate a new credential, give up + params.observability.logging.log( + 'STS credential not found an callStsOnInvalidIamCredential = false, returning no credential.' + ) + throw new AwsError('STS credential not found.', AwsErrorCodes.E_INVALID_STS_CREDENTIAL) + } + + // Set up auto-refresh if MFA is disabled + if (!params.profile.settings.mfa_serial) { + await params.stsAutoRefresher + .watch(convertProfileToId(params.profile), () => this.generateStsCredential(params)) + .catch(reason => { + params.observability.logging.log(`Unable to auto-refresh STS credentials. ${reason}`) + }) + } + + return result + } + + private async getParentCredential(params: IamFlowParams): Promise { + let parentCredentials: IamCredentials + if (params.profile.kinds.includes(ProfileKind.IamSourceProfileProfile)) { + // Get the source profile + const profileData = await params.profileStore.load() + const sourceName = params.profile.settings!.source_profile! + const sourceProfile = profileData.profiles.find(p => p.name === sourceName) + if (!sourceProfile) { + params.observability.logging.log(`Source profile ${sourceName} not found.`) + throw new AwsError(`Source profile ${sourceName} not found.`, AwsErrorCodes.E_PROFILE_NOT_FOUND) + } + // Obtain parent profile credentials if IamRoleSourceProfile chain isn't too long + if (params.recursionCount <= sourceProfileRecursionMax) { + const response = await this.getCredential({ + ...params, + profile: sourceProfile, + recursionCount: params.recursionCount + 1, + }) + parentCredentials = response.credentials + } else { + throw new AwsError('Source profile chain exceeded max length.', AwsErrorCodes.E_INVALID_PROFILE) + } + } else { + throw new AwsError('Source credentials not found', AwsErrorCodes.E_INVALID_PROFILE) + } + return parentCredentials + } + + private async generateStsCredential(params: IamFlowParams): Promise { + try { + // Set up AssumeRole input + const parentCredentials = await this.getParentCredential(params) + const stsClient = new STSClient({ + region: params.profile.settings?.region || this.defaultRegion, + credentials: parentCredentials, + }) + const assumeRoleInput: AssumeRoleCommandInput = { + RoleArn: params.profile.settings?.role_arn, + RoleSessionName: params.profile.settings?.role_session_name || `session-${Date.now()}`, + DurationSeconds: 3600, + } + + // Add MFA fields to assume role request if MultiFactorAuthPresent is required + const mfaRequired = await checkMfaRequired( + parentCredentials, + ['sts:AssumeRole'], + params.profile.settings?.region + ) + if (mfaRequired) { + const response = await this.requestMfa(params) + assumeRoleInput.SerialNumber = response.mfaSerial + assumeRoleInput.TokenCode = response.code + + // Add the MFA serial number to the profile + const updatedProfile = { + ...params.profile, + settings: { ...params.profile.settings, mfa_serial: response.mfaSerial }, + } + params.profileStore.save({ profiles: [updatedProfile], ssoSessions: [] }) + // Update params.profile to ensure STS cache key is generated with updated MFA serial number + params.profile = updatedProfile + } + + // Call AssumeRole API + const command = new AssumeRoleCommand(assumeRoleInput) + const { Credentials } = await stsClient.send(command) + if (!Credentials?.AccessKeyId || !Credentials.SecretAccessKey) { + throw new AwsError( + 'Failed to generate credentials for assumed role', + AwsErrorCodes.E_CANNOT_CREATE_STS_CREDENTIAL + ) + } + return { + accessKeyId: Credentials.AccessKeyId, + secretAccessKey: Credentials.SecretAccessKey, + sessionToken: Credentials.SessionToken, + expiration: Credentials.Expiration, + } + } catch (e) { + params.observability.logging.log(`Error generating STS credentials.`) + throw e + } + } + + // Request an MFA code from the language client + private async requestMfa(params: IamFlowParams): Promise { + const response = await params.handlers.sendGetMfaCode({ + profileName: params.profile.name, + mfaSerial: params.profile.settings?.mfa_serial, + }) + + if (!response.code) { + throw new AwsError( + 'MFA code required when assuming role with MultiFactorAuthPresent permission condition', + AwsErrorCodes.E_MFA_REQUIRED + ) + } + if (!response.mfaSerial) { + throw new AwsError( + 'MFA serial required when assuming role with MultiFactorAuthPresent permission condition', + AwsErrorCodes.E_MFA_REQUIRED + ) + } + + return response + } +} diff --git a/server/aws-lsp-identity/src/iam/utils.ts b/server/aws-lsp-identity/src/iam/utils.ts new file mode 100644 index 0000000000..a5ef2bf1af --- /dev/null +++ b/server/aws-lsp-identity/src/iam/utils.ts @@ -0,0 +1,104 @@ +import { IAMClient, SimulatePrincipalPolicyCommand, SimulatePrincipalPolicyCommandOutput } from '@aws-sdk/client-iam' +import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts' +import { + AwsErrorCodes, + CancellationToken, + GetMfaCodeParams, + GetMfaCodeResult, + IamCredentials, + Profile, + StsCredentialChangedParams, +} from '@aws/language-server-runtimes/server-interface' +import { AwsError, Observability } from '@aws/lsp-core' +import { StsCache } from '../sts/cache/stsCache' +import { StsAutoRefresher } from '../sts/stsAutoRefresher' +import { ProfileStore } from '../language-server/profiles/profileService' + +const defaultRegion = 'us-east-1' + +export async function validatePermissions( + credentials: IamCredentials, + permissions: string[], + region?: string +): Promise { + const response = await simulatePermissions(credentials, permissions, region) + // If evaluation results are missing, assume caller does not have sufficient permissions + if (!response.EvaluationResults) { + return false + } + return response.EvaluationResults.every(result => result.EvalDecision === 'allowed') +} + +export async function checkMfaRequired( + credentials: IamCredentials, + permissions: string[], + region?: string +): Promise { + const response = await simulatePermissions(credentials, permissions, region) + // If evaluation results are missing, assume caller does not need MFA + if (!response.EvaluationResults) { + return false + } + return response.EvaluationResults?.some(result => + result?.MissingContextValues?.includes('aws:MultiFactorAuthPresent') + ) +} + +export function throwOnInvalidCredentialId(iamCredentialId?: string): asserts iamCredentialId is string { + if (typeof iamCredentialId?.trim !== 'function' || !iamCredentialId?.trim()) { + throw new AwsError('IAM credential id is invalid.', AwsErrorCodes.E_INVALID_STS_CREDENTIAL) + } +} + +// Simulate permissions on the identity associated with the credentials +async function simulatePermissions( + credentials: IamCredentials, + permissions: string[], + region?: string +): Promise { + // Convert the credentials into an identity + const stsClient = new STSClient({ region: region || defaultRegion, credentials: credentials }) + const identity = await stsClient.send(new GetCallerIdentityCommand({})) + if (!identity.Arn) { + throw new AwsError('Caller identity ARN not found.', AwsErrorCodes.E_CALLER_IDENTITY_NOT_FOUND) + } + + // Simulate permissions on the identity + const iamClient = new IAMClient({ region: region || defaultRegion, credentials: credentials }) + return await iamClient.send( + new SimulatePrincipalPolicyCommand({ + PolicySourceArn: convertToIamArn(identity.Arn), + ActionNames: permissions, + }) + ) +} + +// Converts an assumed role ARN into an IAM role ARN +function convertToIamArn(arn: string) { + if (arn.includes(':assumed-role/')) { + const parts = arn.split(':') + const roleName = parts[5].split('/')[1] + return `arn:aws:iam::${parts[4]}:role/${roleName}` + } else { + return arn + } +} + +export type SendGetMfaCode = (params: GetMfaCodeParams) => Promise +export type SendStsCredentialChanged = (params: StsCredentialChangedParams) => void + +export type IamHandlers = { + sendGetMfaCode: SendGetMfaCode +} + +export type IamFlowParams = { + profile: Profile + callStsOnInvalidIamCredential: boolean + recursionCount: number + profileStore: ProfileStore + stsCache: StsCache + stsAutoRefresher: StsAutoRefresher + handlers: IamHandlers + token: CancellationToken + observability: Observability +} diff --git a/server/aws-lsp-identity/src/language-server/autoRefresher.ts b/server/aws-lsp-identity/src/language-server/autoRefresher.ts new file mode 100644 index 0000000000..7f7f72f3f1 --- /dev/null +++ b/server/aws-lsp-identity/src/language-server/autoRefresher.ts @@ -0,0 +1,46 @@ +import { Observability } from '@aws/lsp-core' + +export const invalidDelay: number = -1 +export const refreshWindowMillis: number = 5 * 60 * 1000 +export const retryCooldownWindowMillis: number = 30000 +const bufferedRefreshWindowMillis = refreshWindowMillis * 0.95 +const bufferedRetryCooldownWindowMillis = retryCooldownWindowMillis * 1.05 +const maxRefreshJitterMillis = 10000 +const maxRetryCooldownJitterMillis = 3000 + +export abstract class AutoRefresher implements Disposable { + protected readonly timeouts: Record = {} + + constructor(protected readonly observability: Observability) {} + + [Symbol.dispose](): void { + for (const key of Object.keys(this.timeouts)) { + this.unwatch(key) + } + } + + protected abstract unwatch(key: string): void + + getDelay(expiration: string): number { + const nowMillis = Date.now() + const expiresAtMillis = Date.parse(expiration) + let delayMillis: number + + if (nowMillis < expiresAtMillis - refreshWindowMillis) { + // Before refresh window, schedule to run in refresh window with jitter + delayMillis = expiresAtMillis - bufferedRefreshWindowMillis - nowMillis + delayMillis += Math.random() * maxRefreshJitterMillis // Jitter to mitigate race conditions + } else if (expiresAtMillis - refreshWindowMillis < nowMillis && nowMillis < expiresAtMillis) { + // In refresh window with time for a retry + delayMillis = bufferedRetryCooldownWindowMillis + delayMillis += Math.random() * maxRetryCooldownJitterMillis // Jitter to mitigate race conditions + } else { + // Otherwise, expired + this.observability.logging.log('SSO token has expired and will not be auto-refreshed.') + return invalidDelay + } + + this.observability.logging.log(`Auto-refreshing SSO token in ${delayMillis} milliseconds.`) + return delayMillis + } +} diff --git a/server/aws-lsp-identity/src/language-server/identityServer.ts b/server/aws-lsp-identity/src/language-server/identityServer.ts index be5d357ead..1ca20798ef 100644 --- a/server/aws-lsp-identity/src/language-server/identityServer.ts +++ b/server/aws-lsp-identity/src/language-server/identityServer.ts @@ -7,17 +7,26 @@ import { AwsErrorCodes, GetSsoTokenParams, InvalidateSsoTokenParams, + InvalidateStsCredentialParams, InitializeParams, PartialInitializeResult, ShowMessageRequestParams, + GetIamCredentialParams, + GetMfaCodeParams, + StsCredentialChangedParams, + SsoTokenChangedParams, } from '@aws/language-server-runtimes/server-interface' import { SharedConfigProfileStore } from './profiles/sharedConfigProfileStore' import { IdentityService } from './identityService' import { FileSystemSsoCache, RefreshingSsoCache } from '../sso/cache' -import { SsoTokenAutoRefresher } from './ssoTokenAutoRefresher' +import { SsoTokenAutoRefresher } from '../sso/ssoTokenAutoRefresher' +import { FileSystemStsCache } from '../sts/cache/fileSystemStsCache' +import { StsAutoRefresher } from '../sts/stsAutoRefresher' import { AwsError, ServerBase } from '@aws/lsp-core' import { Features } from '@aws/language-server-runtimes/server-interface/server' -import { ShowUrl, ShowMessageRequest, ShowProgress } from '../sso/utils' +import { ShowUrl, ShowMessageRequest, ShowProgress, SendSsoTokenChanged } from '../sso/utils' +import { SendGetMfaCode, SendStsCredentialChanged } from '../iam/utils' +import { IamProvider } from '../iam/iamProvider' export class IdentityServer extends ServerBase { constructor(features: Features) { @@ -38,23 +47,37 @@ export class IdentityServer extends ServerBase { const showMessageRequest: ShowMessageRequest = (params: ShowMessageRequestParams) => this.features.lsp.window.showMessageRequest(params) const showProgress: ShowProgress = this.features.lsp.sendProgress + const sendGetMfaCode: SendGetMfaCode = (params: GetMfaCodeParams) => + this.features.identityManagement.sendGetMfaCode(params) + + // Callbacks for client->server JSON-RPC calls + const sendSsoTokenChanged: SendSsoTokenChanged = (params: SsoTokenChangedParams) => + this.features.identityManagement.sendSsoTokenChanged(params) + const sendStsCredentialChanged: SendStsCredentialChanged = (params: StsCredentialChangedParams) => + this.features.identityManagement.sendStsCredentialChanged(params) // Initialize dependencies const profileStore = new SharedConfigProfileStore(this.observability) const ssoCache = new RefreshingSsoCache( new FileSystemSsoCache(this.observability), - this.features.identityManagement.sendSsoTokenChanged, + sendSsoTokenChanged, this.observability ) const autoRefresher = new SsoTokenAutoRefresher(ssoCache, this.observability) + const stsCache = new FileSystemStsCache(this.observability) + const stsAutoRefresher = new StsAutoRefresher(stsCache, sendStsCredentialChanged, this.observability) + const iamProvider = new IamProvider() const identityService = new IdentityService( profileStore, ssoCache, autoRefresher, - { showUrl, showMessageRequest, showProgress }, + stsCache, + stsAutoRefresher, + iamProvider, + { showUrl, showMessageRequest, showProgress, sendGetMfaCode }, this.getClientName(params), this.observability ) @@ -70,6 +93,14 @@ export class IdentityServer extends ServerBase { }) ) + this.features.identityManagement.onGetIamCredential( + async (params: GetIamCredentialParams, token: CancellationToken) => + await identityService.getIamCredential(params, token).catch(reason => { + this.observability.logging.log(`GetIamCredential failed. ${reason}`) + throw awsResponseErrorWrap(reason) + }) + ) + this.features.identityManagement.onInvalidateSsoToken( async (params: InvalidateSsoTokenParams, token: CancellationToken) => await identityService.invalidateSsoToken(params, token).catch(reason => { @@ -78,6 +109,14 @@ export class IdentityServer extends ServerBase { }) ) + this.features.identityManagement.onInvalidateStsCredential( + async (params: InvalidateStsCredentialParams, token: CancellationToken) => + await identityService.invalidateStsCredential(params, token).catch(reason => { + this.observability.logging.log(`InvalidateIamCredentials failed. ${reason}`) + throw awsResponseErrorWrap(reason) + }) + ) + this.features.identityManagement.onListProfiles( async (params: ListProfilesParams, token: CancellationToken) => await profileService.listProfiles(params, token).catch(reason => { @@ -95,6 +134,7 @@ export class IdentityServer extends ServerBase { ) this.disposables.push(autoRefresher) + this.disposables.push(stsAutoRefresher) return { ...result, diff --git a/server/aws-lsp-identity/src/language-server/identityService.test.ts b/server/aws-lsp-identity/src/language-server/identityService.test.ts index 5bf9ff1432..51966100ee 100644 --- a/server/aws-lsp-identity/src/language-server/identityService.test.ts +++ b/server/aws-lsp-identity/src/language-server/identityService.test.ts @@ -3,17 +3,23 @@ import { StubbedInstance, stubInterface } from 'ts-sinon' import { awsBuilderIdReservedName, SsoCache, SsoClientRegistration } from '../sso' import { IdentityService } from './identityService' import { ProfileData, ProfileStore } from './profiles/profileService' -import { SsoTokenAutoRefresher } from './ssoTokenAutoRefresher' -import { createStubInstance, restore, spy, SinonSpy } from 'sinon' +import { SsoTokenAutoRefresher } from '../sso/ssoTokenAutoRefresher' +import { createStubInstance, restore, spy, SinonSpy, stub, SinonStub } from 'sinon' import { AuthorizationFlowKind, CancellationToken, + IamCredential, + IamCredentials, ProfileKind, SsoTokenSourceKind, } from '@aws/language-server-runtimes/protocol' import { SSOToken } from '@smithy/shared-ini-file-loader' import { Logging, Telemetry } from '@aws/language-server-runtimes/server-interface' import { Observability } from '@aws/lsp-core' +import { StsCache } from '../sts/cache/stsCache' +import { StsAutoRefresher } from '../sts/stsAutoRefresher' +import { IamProvider } from '../iam/iamProvider' +import * as iamUtils from '../iam/utils' // eslint-disable-next-line use(require('chai-as-promised')) @@ -22,9 +28,16 @@ let sut: IdentityService let profileStore: StubbedInstance let ssoCache: StubbedInstance +let stsCache: StubbedInstance let autoRefresher: StubbedInstance +let stsAutoRefresher: StubbedInstance +let iamProvider: StubbedInstance let observability: StubbedInstance let authFlowFn: SinonSpy +let validatePermissionsStub: SinonStub< + [credentials: IamCredentials, permissions: string[], region?: string | undefined], + Promise +> describe('IdentityService', () => { beforeEach(() => { @@ -33,11 +46,19 @@ describe('IdentityService', () => { profiles: [ { kinds: [ProfileKind.SsoTokenProfile], - name: 'my-profile', + name: 'my-sso-profile', settings: { sso_session: 'my-sso-session', }, }, + { + kinds: [ProfileKind.IamCredentialsProfile], + name: 'my-iam-profile', + settings: { + aws_access_key_id: 'my-access-key', + aws_secret_access_key: 'my-secret-key', + }, + }, ], ssoSessions: [ { @@ -65,11 +86,33 @@ describe('IdentityService', () => { setSsoToken: Promise.resolve(), }) + stsCache = stubInterface({ + getStsCredential: Promise.resolve(undefined), + setStsCredential: Promise.resolve(), + removeStsCredential: Promise.resolve(), + }) + autoRefresher = createStubInstance(SsoTokenAutoRefresher, { watch: Promise.resolve(), unwatch: undefined, }) as StubbedInstance + stsAutoRefresher = createStubInstance(StsAutoRefresher, { + watch: Promise.resolve(), + unwatch: undefined, + }) as StubbedInstance + + iamProvider = createStubInstance(IamProvider, { + getCredential: Promise.resolve({ + id: 'id', + kinds: [], + credentials: { + accessKeyId: 'access-key', + secretAccessKey: 'secret-key', + }, + } as IamCredential), + }) as StubbedInstance + authFlowFn = spy(() => Promise.resolve({ accessToken: 'my-access-token', @@ -85,10 +128,14 @@ describe('IdentityService', () => { profileStore, ssoCache, autoRefresher, + stsCache, + stsAutoRefresher, + iamProvider, { showUrl: _ => {}, showMessageRequest: _ => Promise.resolve({ title: 'client-response' }), showProgress: _ => Promise.resolve(), + sendGetMfaCode: () => Promise.resolve({ code: 'mfa-code', mfaSerial: 'mfa-serial' }), }, 'My Client', observability, @@ -97,6 +144,9 @@ describe('IdentityService', () => { [AuthorizationFlowKind.DeviceCode]: authFlowFn, } ) + + validatePermissionsStub = stub(iamUtils, 'validatePermissions') + validatePermissionsStub.resolves(true) }) afterEach(() => { @@ -122,7 +172,7 @@ describe('IdentityService', () => { const actual = await sut.getSsoToken( { clientName: 'my-client', - source: { kind: SsoTokenSourceKind.IamIdentityCenter, profileName: 'my-profile' }, + source: { kind: SsoTokenSourceKind.IamIdentityCenter, profileName: 'my-sso-profile' }, }, CancellationToken.None ) @@ -136,7 +186,7 @@ describe('IdentityService', () => { await sut.getSsoToken( { clientName: 'my-client', - source: { kind: SsoTokenSourceKind.IamIdentityCenter, profileName: 'my-profile' }, + source: { kind: SsoTokenSourceKind.IamIdentityCenter, profileName: 'my-sso-profile' }, options: { authorizationFlow: 'DeviceCode', }, @@ -148,7 +198,7 @@ describe('IdentityService', () => { await sut.getSsoToken( { clientName: 'my-client', - source: { kind: SsoTokenSourceKind.IamIdentityCenter, profileName: 'my-profile' }, + source: { kind: SsoTokenSourceKind.IamIdentityCenter, profileName: 'my-sso-profile' }, options: { authorizationFlow: 'Pkce', }, @@ -185,7 +235,7 @@ describe('IdentityService', () => { const actual = await sut.getSsoToken( { clientName: 'my-client', - source: { kind: SsoTokenSourceKind.IamIdentityCenter, profileName: 'my-profile' }, + source: { kind: SsoTokenSourceKind.IamIdentityCenter, profileName: 'my-sso-profile' }, }, CancellationToken.None ) @@ -203,7 +253,7 @@ describe('IdentityService', () => { const actual = await sut.getSsoToken( { clientName: 'my-client', - source: { kind: SsoTokenSourceKind.IamIdentityCenter, profileName: 'my-profile' }, + source: { kind: SsoTokenSourceKind.IamIdentityCenter, profileName: 'my-sso-profile' }, }, CancellationToken.None ) @@ -255,6 +305,24 @@ describe('IdentityService', () => { }) }) + describe('getIamCredential', () => { + it('Can login with IAM credentials.', async () => { + const actual = await sut.getIamCredential({ profileName: 'my-iam-profile' }, CancellationToken.None) + + expect(actual.credential.credentials.accessKeyId).to.equal('access-key') + expect(actual.credential.credentials.secretAccessKey).to.equal('secret-key') + }) + + it('Throws when permissions are insufficient', async () => { + validatePermissionsStub.resolves(false) + const error = await expect( + sut.getIamCredential({ profileName: 'my-iam-profile' }, CancellationToken.None) + ).rejectedWith(Error) + + expect(error.message).to.equal('Credentials have insufficient permissions.') + }) + }) + describe('invalidateSsoToken', () => { it('removeToken removes on valid SSO session name', async () => { await sut.invalidateSsoToken({ ssoTokenId: 'my-sso-session' }, CancellationToken.None) @@ -268,4 +336,20 @@ describe('IdentityService', () => { expect(ssoCache.removeSsoToken.notCalled).is.true }) }) + + describe('invalidateStsCredential', () => { + it('Removes on valid name', async () => { + await sut.invalidateStsCredential({ iamCredentialId: 'my-role-profile' }, CancellationToken.None) + + expect(stsCache.removeStsCredential.called).is.true + }) + + it('Throws on invalid name', async () => { + await expect( + sut.invalidateStsCredential({ iamCredentialId: ' ' }, CancellationToken.None) + ).to.be.rejectedWith() + + expect(stsCache.removeStsCredential.notCalled).is.true + }) + }) }) diff --git a/server/aws-lsp-identity/src/language-server/identityService.ts b/server/aws-lsp-identity/src/language-server/identityService.ts index 6e0d4a3817..7f75df1645 100644 --- a/server/aws-lsp-identity/src/language-server/identityService.ts +++ b/server/aws-lsp-identity/src/language-server/identityService.ts @@ -3,12 +3,17 @@ import { AwsBuilderIdSsoTokenSource, AwsErrorCodes, CancellationToken, + GetIamCredentialParams, + GetIamCredentialResult, getSsoTokenOptionsDefaults, + getIamCredentialOptionsDefaults, GetSsoTokenParams, GetSsoTokenResult, IamIdentityCenterSsoTokenSource, InvalidateSsoTokenParams, InvalidateSsoTokenResult, + InvalidateStsCredentialParams, + InvalidateStsCredentialResult, MetricEvent, SsoSession, SsoTokenSourceKind, @@ -16,20 +21,26 @@ import { import { normalizeSettingList, ProfileStore } from './profiles/profileService' import { authorizationCodePkceFlow, awsBuilderIdReservedName, awsBuilderIdSsoRegion } from '../sso' import { SsoCache, SsoClientRegistration } from '../sso/cache' -import { SsoTokenAutoRefresher } from './ssoTokenAutoRefresher' +import { SsoTokenAutoRefresher } from '../sso/ssoTokenAutoRefresher' +import { StsCache } from '../sts/cache/stsCache' +import { StsAutoRefresher } from '../sts/stsAutoRefresher' import { throwOnInvalidClientRegistration, throwOnInvalidSsoSession, throwOnInvalidSsoSessionName, SsoFlowParams, + SsoHandlers, } from '../sso/utils' +import { IamFlowParams, IamHandlers, throwOnInvalidCredentialId, validatePermissions } from '../iam/utils' import { AwsError, Observability } from '@aws/lsp-core' import { __ServiceException } from '@aws-sdk/client-sso-oidc/dist-types/models/SSOOIDCServiceException' import { deviceCodeFlow } from '../sso/deviceCode/deviceCodeFlow' import { SSOToken } from '@smithy/shared-ini-file-loader' +import { IamProvider } from '../iam/iamProvider' type SsoTokenSource = IamIdentityCenterSsoTokenSource | AwsBuilderIdSsoTokenSource type AuthFlows = Record Promise> +type Handlers = SsoHandlers & IamHandlers const flows: AuthFlows = { [AuthorizationFlowKind.DeviceCode]: deviceCodeFlow, @@ -40,8 +51,11 @@ export class IdentityService { constructor( private readonly profileStore: ProfileStore, private readonly ssoCache: SsoCache, - private readonly autoRefresher: SsoTokenAutoRefresher, - private readonly handlers: SsoFlowParams['handlers'], + private readonly ssoAutoRefresher: SsoTokenAutoRefresher, + private readonly stsCache: StsCache, + private readonly stsAutoRefresher: StsAutoRefresher, + private readonly iamProvider: IamProvider, + private readonly handlers: Handlers, private readonly clientName: string, private readonly observability: Observability, private readonly authFlows: AuthFlows = flows @@ -94,7 +108,7 @@ export class IdentityService { clientName: this.clientName, clientRegistration, ssoSession, - handlers: this.handlers, + handlers: this.handlers as SsoHandlers, token, observability: this.observability, } @@ -120,7 +134,7 @@ export class IdentityService { } // Auto refresh is best effort - await this.autoRefresher.watch(this.clientName, ssoSession).catch(reason => { + await this.ssoAutoRefresher.watch(this.clientName, ssoSession).catch(reason => { this.observability.logging.log(`Unable to auto-refresh token. ${reason}`) }) @@ -136,6 +150,66 @@ export class IdentityService { } } + async getIamCredential(params: GetIamCredentialParams, token: CancellationToken): Promise { + const emitMetric = this.emitMetric.bind( + this, + 'flareIdentity_getIamCredential', + this.getIamCredential.name, + Date.now() + ) + + try { + const options = { ...getIamCredentialOptionsDefaults, ...params.options } + + token.onCancellationRequested(_ => { + emitMetric('Cancelled', null) + }) + + // Get the profile with provided name + const profileData = await this.profileStore.load() + const profile = profileData.profiles.find(p => p.name === params.profileName) + if (!profile) { + this.observability.logging.log('Profile not found.') + throw new AwsError('Profile not found.', AwsErrorCodes.E_PROFILE_NOT_FOUND) + } + + const flowOpts: IamFlowParams = { + profile: profile, + callStsOnInvalidIamCredential: options.callStsOnInvalidIamCredential, + recursionCount: 0, + profileStore: this.profileStore, + stsCache: this.stsCache, + stsAutoRefresher: this.stsAutoRefresher, + handlers: this.handlers as IamHandlers, + token: token, + observability: this.observability, + } + const credential = await this.iamProvider.getCredential(flowOpts) + + // Validate permissions + if (options.permissionSet.length > 0) { + const hasPermissions = await validatePermissions( + credential.credentials, + options.permissionSet, + profile.settings?.region + ) + if (!hasPermissions) { + throw new AwsError(`Credentials have insufficient permissions.`, AwsErrorCodes.E_INVALID_PROFILE) + } + } + + emitMetric('Succeeded') + + return { + credential: credential, + updateCredentialsParams: { data: credential.credentials, encrypted: false }, + } + } catch (e) { + emitMetric('Failed', e) + throw e + } + } + async invalidateSsoToken( params: InvalidateSsoTokenParams, token: CancellationToken @@ -154,7 +228,7 @@ export class IdentityService { try { throwOnInvalidSsoSessionName(params?.ssoTokenId) - this.autoRefresher.unwatch(params.ssoTokenId) + this.ssoAutoRefresher.unwatch(params.ssoTokenId) await this.ssoCache.removeSsoToken(params.ssoTokenId) @@ -168,6 +242,37 @@ export class IdentityService { } } + async invalidateStsCredential( + params: InvalidateStsCredentialParams, + token: CancellationToken + ): Promise { + const emitMetric = this.emitMetric.bind( + this, + 'flareIdentity_invalidateStsCredential', + this.invalidateStsCredential.name, + Date.now() + ) + + token.onCancellationRequested(_ => { + emitMetric('Cancelled') + }) + + try { + throwOnInvalidCredentialId(params.iamCredentialId) + + this.stsAutoRefresher.unwatch(params.iamCredentialId) + + await this.stsCache.removeStsCredential(params.iamCredentialId) + + emitMetric('Succeeded') + this.observability.logging.log('Successfully invalidated STS credentials.') + return {} + } catch (e) { + emitMetric('Failed', e) + throw e + } + } + private emitMetric( name: string, source: string, diff --git a/server/aws-lsp-identity/src/language-server/profiles/profileService.test.ts b/server/aws-lsp-identity/src/language-server/profiles/profileService.test.ts index f2da8c62e9..216f3010af 100644 --- a/server/aws-lsp-identity/src/language-server/profiles/profileService.test.ts +++ b/server/aws-lsp-identity/src/language-server/profiles/profileService.test.ts @@ -23,6 +23,7 @@ let observability: StubbedInstance let profile1: Profile let profile2: Profile let profile3: Profile +let profile4: Profile let ssoSession1: SsoSession let ssoSession2: SsoSession @@ -52,6 +53,16 @@ describe('ProfileService', async () => { }, } + profile4 = { + kinds: [ProfileKind.IamCredentialsProfile], + name: 'profile4', + settings: { + aws_access_key_id: 'access-key', + aws_secret_access_key: 'secret-key', + aws_session_token: 'session-token', + }, + } + ssoSession1 = { name: 'ssoSession1', settings: { @@ -71,7 +82,7 @@ describe('ProfileService', async () => { store = stubInterface({ load: Promise.resolve({ - profiles: [profile1, profile2, profile3], + profiles: [profile1, profile2, profile3, profile4], ssoSessions: [ssoSession1, ssoSession2], } satisfies ProfileData), save: Promise.resolve(), @@ -87,7 +98,7 @@ describe('ProfileService', async () => { it('listProfiles return profiles and sso-sessions', async () => { const actual = await sut.listProfiles({}) - expect(actual.profiles).to.be.an('array').that.has.deep.members([profile1, profile2, profile3]) + expect(actual.profiles).to.be.an('array').that.has.deep.members([profile1, profile2, profile3, profile4]) expect(actual.ssoSessions).to.be.an('array').that.has.deep.members([ssoSession1, ssoSession2]) }) @@ -198,23 +209,6 @@ describe('ProfileService', async () => { expectAwsError(sut, { profile: undefined! }, AwsErrorCodes.E_INVALID_PROFILE, 'Profile required.') }) - it('updateProfile throws on non-SSO token profile', async () => { - const profile = { - kinds: [ProfileKind.Unknown], - name: 'profile-name', - settings: { - sso_session: 'sso-session-name', - }, - } - - await expectAwsError( - sut, - { profile }, - AwsErrorCodes.E_INVALID_PROFILE, - 'Profile must be non-legacy sso-session type.' - ) - }) - it('updateProfile throws on no profile name', async () => { const profile = { kinds: [ProfileKind.SsoTokenProfile], @@ -253,7 +247,7 @@ describe('ProfileService', async () => { await expectAwsError(sut, { profile }, AwsErrorCodes.E_INVALID_PROFILE, 'Sso-session name required on profile.') }) - it('updateProfile throws on no sso-session on profile', async () => { + it('updateProfile throws on no sso-session on SSO token profile', async () => { const profile = { kinds: [ProfileKind.SsoTokenProfile], name: 'profile-name', @@ -265,6 +259,100 @@ describe('ProfileService', async () => { await expectAwsError(sut, { profile }, AwsErrorCodes.E_INVALID_PROFILE, 'Sso-session name required on profile.') }) + it('updateProfile throws on missing access key for IamCredentialsProfile', async () => { + const profile = { + kinds: [ProfileKind.IamCredentialsProfile], + name: 'profile-name', + settings: { + aws_secret_access_key: 'secret-key', + }, + } + + await expectAwsError(sut, { profile }, AwsErrorCodes.E_INVALID_PROFILE, 'Access key required on profile.') + }) + + it('updateProfile throws on missing secret key for IamCredentialsProfile', async () => { + const profile = { + kinds: [ProfileKind.IamCredentialsProfile], + name: 'profile-name', + settings: { + aws_access_key_id: 'access-key', + }, + } + + await expectAwsError(sut, { profile }, AwsErrorCodes.E_INVALID_PROFILE, 'Secret key required on profile.') + }) + + it('updateProfile throws on missing role ARN for IamSourceProfileProfile', async () => { + const profile = { + kinds: [ProfileKind.IamSourceProfileProfile], + name: 'profile-name', + settings: { + source_profile: 'source', + }, + } + + await expectAwsError(sut, { profile }, AwsErrorCodes.E_INVALID_PROFILE, 'Role ARN required on profile.') + }) + + it('updateProfile throws on missing source profile for IamSourceProfileProfile', async () => { + const profile = { + kinds: [ProfileKind.IamSourceProfileProfile], + name: 'profile-name', + settings: { + role_arn: 'role-arn', + }, + } + + await expectAwsError(sut, { profile }, AwsErrorCodes.E_INVALID_PROFILE, 'Source profile required on profile.') + }) + + it('updateProfile throws on missing role ARN for IamCredentialSourceProfile', async () => { + const profile = { + kinds: [ProfileKind.IamCredentialSourceProfile], + name: 'profile-name', + settings: { + credential_source: 'Ec2InstanceMetadata', + region: 'region', + }, + } + + await expectAwsError(sut, { profile }, AwsErrorCodes.E_INVALID_PROFILE, 'Role ARN required on profile.') + }) + + it('updateProfile throws on missing credential source for IamCredentialSourceProfile', async () => { + const profile = { + kinds: [ProfileKind.IamCredentialSourceProfile], + name: 'profile-name', + settings: { + role_arn: 'role-arn', + region: 'region', + }, + } + + await expectAwsError( + sut, + { profile }, + AwsErrorCodes.E_INVALID_PROFILE, + 'Credential source required on profile.' + ) + }) + + it('updateProfile throws on missing credential process for process profile', async () => { + const profile = { + kinds: [ProfileKind.IamCredentialProcessProfile], + name: 'profile-name', + settings: {}, + } + + await expectAwsError( + sut, + { profile }, + AwsErrorCodes.E_INVALID_PROFILE, + 'Credential process required on profile.' + ) + }) + it('updateProfile throws when profile cannot be created', async () => { const profile = { kinds: [ProfileKind.SsoTokenProfile], @@ -411,7 +499,7 @@ describe('ProfileService', async () => { }) describe('profileService.DuckTypers', () => { - it('profileDuckTypers.eval returns true on valid profiles', () => { + it('profileDuckTypers.SsoTokenProfile.eval returns true on valid profiles', () => { const profiles = [ { sso_session: 'my-sso-session', @@ -428,7 +516,7 @@ describe('profileService.DuckTypers', () => { } }) - it('profileDuckTypers returns false on invalid profiles', () => { + it('profileDuckTypers.SsoTokenProfile.eval returns false on invalid profiles', () => { const profiles = [ { SSO_session: 'my-sso-session', @@ -484,6 +572,102 @@ describe('profileService.DuckTypers', () => { expect(actual).to.be.false } }) + + it('profileDuckTypers.IamCredentialsProfile.eval returns true on valid profiles', () => { + const profiles = [ + { + aws_access_key_id: 'access-key', + aws_secret_access_key: 'secret-key', + }, + { + aws_access_key_id: 'access-key', + aws_secret_access_key: 'secret-key', + aws_session_token: 'session-token', + }, + ] + + for (const profile of profiles) { + const actual = profileDuckTypers.IamCredentialsProfile.eval(profile) + expect(actual).to.be.true + } + }) + + it('profileDuckTypers.IamCredentialsProfile.eval returns false on invalid profiles', () => { + const profiles = [ + { + sso_session: 'my-sso-session', + }, + null, + { + sso_account_id: '123', + }, + ] + + for (const profile of profiles) { + const actual = profileDuckTypers.IamCredentialsProfile.eval(profile as object) + expect(actual).to.be.false + } + }) + + it('profileDuckTypers.IamSourceProfileProfile.eval returns true on valid profiles', () => { + const profiles = [ + { + role_arn: 'role-arn', + source_profile: 'source-profile', + }, + { + role_arn: 'role-arn', + source_profile: 'source-profile', + role_session_name: 'role-session-name', + mfa_serial: 'mfa-serial', + }, + ] + + for (const profile of profiles) { + const actual = profileDuckTypers.IamSourceProfileProfile.eval(profile) + expect(actual).to.be.true + } + }) + + it('profileDuckTypers.IamCredentialSourceProfile.eval returns true on valid profiles', () => { + const profiles = [ + { + role_arn: 'role-arn', + credential_source: 'credential-source', + region: 'region', + }, + { + role_arn: 'role-arn', + credential_source: 'credential-source', + region: 'region', + role_session_name: 'role-session-name', + }, + ] + + for (const profile of profiles) { + const actual = profileDuckTypers.IamCredentialSourceProfile.eval(profile) + expect(actual).to.be.true + } + }) + + it('profileDuckTypers.IamCredentialProcessProfile.eval returns true on valid profiles', () => { + const profiles = [ + { + credential_process: 'credential-process', + }, + { + aws_access_key_id: 'access-key', + aws_secret_access_key: 'secret-key', + aws_session_token: 'session-token', + credential_process: 'credential-process', + }, + ] + + for (const profile of profiles) { + const actual = profileDuckTypers.IamCredentialProcessProfile.eval(profile) + expect(actual).to.be.true + } + }) }) describe('profileService.functions', () => { diff --git a/server/aws-lsp-identity/src/language-server/profiles/profileService.ts b/server/aws-lsp-identity/src/language-server/profiles/profileService.ts index 247ccd1833..a37e27e30e 100644 --- a/server/aws-lsp-identity/src/language-server/profiles/profileService.ts +++ b/server/aws-lsp-identity/src/language-server/profiles/profileService.ts @@ -30,6 +30,18 @@ export const ProfileFields = { sso_account_id: 'sso_account_id', sso_role_name: 'sso_role_name', sso_session: 'sso_session', + aws_access_key_id: 'aws_access_key_id', + aws_secret_access_key: 'aws_secret_access_key', + aws_session_token: 'aws_session_token', + role_arn: 'role_arn', + role_session_name: 'role_session_name', + credential_process: 'credential_process', + credential_source: 'credential_source', + source_profile: 'source_profile', + mfa_serial: 'mfa_serial', + external_id: 'external_id', + credential_cache: 'credential_cache', + credential_cache_location: 'credential_cache_location', } as const export const SsoSessionFields = { @@ -38,12 +50,70 @@ export const SsoSessionFields = { sso_start_url: 'sso_start_url', } as const -export const profileDuckTypers = { - SsoTokenProfile: new DuckTyper() - .requireProperty(ProfileFields.sso_session) - .disallowProperty(ProfileFields.sso_account_id) - .disallowProperty(ProfileFields.sso_role_name), -} +export const profileTypes = { + SsoTokenProfile: { + kind: ProfileKind.SsoTokenProfile, + required: [ProfileFields.sso_session], + optional: [ProfileFields.region], + disallowed: [ProfileFields.sso_account_id, ProfileFields.sso_role_name], + }, + IamCredentialsProfile: { + kind: ProfileKind.IamCredentialsProfile, + required: [ProfileFields.aws_access_key_id, ProfileFields.aws_secret_access_key], + optional: [ProfileFields.aws_session_token], + disallowed: [], + }, + IamSourceProfileProfile: { + kind: ProfileKind.IamSourceProfileProfile, + required: [ProfileFields.role_arn, ProfileFields.source_profile], + optional: [ + ProfileFields.external_id, + ProfileFields.role_session_name, + ProfileFields.region, + ProfileFields.mfa_serial, + ProfileFields.credential_cache, + ProfileFields.credential_cache_location, + ], + disallowed: [ProfileFields.credential_source], + }, + IamCredentialSourceProfile: { + kind: ProfileKind.IamCredentialSourceProfile, + required: [ProfileFields.role_arn, ProfileFields.credential_source], + optional: [ + ProfileFields.external_id, + ProfileFields.role_session_name, + ProfileFields.region, + ProfileFields.credential_cache, + ProfileFields.credential_cache_location, + ], + disallowed: [ProfileFields.source_profile], + }, + IamCredentialProcessProfile: { + kind: ProfileKind.IamCredentialProcessProfile, + required: [ProfileFields.credential_process], + optional: [], + disallowed: [], + }, +} as const + +export const profileDuckTypers = Object.fromEntries( + Object.entries(profileTypes).map(([key, def]) => [ + key, + (() => { + const typer = new DuckTyper() + for (const field of def.required) { + typer.requireProperty(field) + } + for (const field of def.optional) { + typer.optionalProperty(field) + } + for (const field of def.disallowed) { + typer.disallowProperty(field) + } + return typer + })(), + ]) +) export const ssoSessionDuckTyper = new DuckTyper() .requireProperty(SsoSessionFields.sso_start_url) @@ -94,53 +164,78 @@ export class ProfileService { const profile = params.profile! this.throwOnInvalidProfile( - !profile.kinds.includes(ProfileKind.SsoTokenProfile), - 'Profile must be non-legacy sso-session type.' + !profile.kinds.some(kind => Object.values(ProfileKind).includes(kind)), + 'Profile must be non-legacy sso-session or iam-credentials type.' ) this.throwOnInvalidProfile(!profile.name, 'Profile name required.') this.throwOnInvalidProfile(!profile.settings, 'Settings required on profile.') const profileSettings = profile.settings! - this.throwOnInvalidProfile(!profileSettings.sso_session, 'Sso-session name required on profile.') - - // Validate sso-session - this.throwOnInvalidSsoSession(!params.ssoSession, 'Sso-session required.') - const ssoSession: SsoSession = params.ssoSession! - - this.throwOnInvalidSsoSession(!ssoSession.name, 'Sso-session name required.') - this.throwOnInvalidSsoSession(!ssoSession.settings, 'Settings required on sso-session.') - const ssoSessionSettings = ssoSession.settings! - - this.throwOnInvalidSsoSession(!ssoSessionSettings.sso_region, 'Sso-session region required.') - this.throwOnInvalidSsoSession(!ssoSessionSettings.sso_start_url, 'Sso-session start URL required.') - - this.throwOnInvalidProfile( - profileSettings.sso_session !== ssoSession.name, - 'Profile sso-session name must be the same as provided sso-session.' - ) - + // Get profiles and SSO sessions to check whether a duplicate will be created const { profiles, ssoSessions } = await this.profileStore.load().catch(reason => { throw AwsError.wrap(reason, AwsErrorCodes.E_CANNOT_READ_SHARED_CONFIG) }) - // Enforce options + // Check if the profile can be created if (!options.createNonexistentProfile && !profiles.some(p => p.name === profile.name)) { this.observability.logging.log(`Cannot create profile. options: ${JSON.stringify(options)}`) throw new AwsError('Cannot create profile.', AwsErrorCodes.E_CANNOT_CREATE_PROFILE) } - if (!options.createNonexistentSsoSession && !ssoSessions.some(s => s.name === ssoSession.name)) { - this.observability.logging.log(`Cannot create sso-session. options: ${JSON.stringify(options)}`) - throw new AwsError('Cannot create sso-session.', AwsErrorCodes.E_CANNOT_CREATE_SSO_SESSION) + // TODO: can this be refactored and simplified using the existing DuckTypers? + // Validate SSO profile + if (profile.kinds.includes(ProfileKind.SsoTokenProfile)) { + this.throwOnInvalidProfile(!profileSettings.sso_session, 'Sso-session name required on profile.') + this.throwOnInvalidSsoSession(!params.ssoSession, 'Sso-session required.') + const ssoSession: SsoSession = params.ssoSession! + + this.throwOnInvalidSsoSession(!ssoSession.name, 'Sso-session name required.') + this.throwOnInvalidSsoSession(!ssoSession.settings, 'Settings required on sso-session.') + const ssoSessionSettings = ssoSession.settings! + + this.throwOnInvalidSsoSession(!ssoSessionSettings.sso_region, 'Sso-session region required.') + this.throwOnInvalidSsoSession(!ssoSessionSettings.sso_start_url, 'Sso-session start URL required.') + + this.throwOnInvalidProfile( + profileSettings.sso_session !== ssoSession.name, + 'Profile sso-session name must be the same as provided sso-session.' + ) + + // Check if the SSO session can be created + if (!options.createNonexistentSsoSession && !ssoSessions.some(s => s.name === ssoSession.name)) { + this.observability.logging.log(`Cannot create sso-session. options: ${JSON.stringify(options)}`) + throw new AwsError('Cannot create sso-session.', AwsErrorCodes.E_CANNOT_CREATE_SSO_SESSION) + } + + // Check if the SSO session can be updated + if ( + !options.updateSharedSsoSession && + this.isSharedSsoSession(ssoSession.name, profiles, profile.name) && + this.willUpdateExistingSsoSession(ssoSession, ssoSessions) + ) { + this.observability.logging.log(`Cannot update shared sso-session. options: ${JSON.stringify(options)}`) + throw new AwsError('Cannot update shared sso-session.', AwsErrorCodes.E_CANNOT_OVERWRITE_SSO_SESSION) + } + } + + // Validate IAM profiles + if (profile.kinds.includes(ProfileKind.IamCredentialsProfile)) { + this.throwOnInvalidProfile(!profileSettings.aws_access_key_id, 'Access key required on profile.') + this.throwOnInvalidProfile(!profileSettings.aws_secret_access_key, 'Secret key required on profile.') + } + + if (profile.kinds.includes(ProfileKind.IamCredentialSourceProfile)) { + this.throwOnInvalidProfile(!profileSettings.role_arn, 'Role ARN required on profile.') + this.throwOnInvalidProfile(!profileSettings.credential_source, 'Credential source required on profile.') + } + + if (profile.kinds.includes(ProfileKind.IamSourceProfileProfile)) { + this.throwOnInvalidProfile(!profileSettings.role_arn, 'Role ARN required on profile.') + this.throwOnInvalidProfile(!profileSettings.source_profile, 'Source profile required on profile.') } - if ( - !options.updateSharedSsoSession && - this.isSharedSsoSession(ssoSession.name, profiles, profile.name) && - this.willUpdateExistingSsoSession(ssoSession, ssoSessions) - ) { - this.observability.logging.log(`Cannot update shared sso-session. options: ${JSON.stringify(options)}`) - throw new AwsError('Cannot update shared sso-session.', AwsErrorCodes.E_CANNOT_OVERWRITE_SSO_SESSION) + if (profile.kinds.includes(ProfileKind.IamCredentialProcessProfile)) { + this.throwOnInvalidProfile(!profileSettings.credential_process, 'Credential process required on profile.') } await this.profileStore diff --git a/server/aws-lsp-identity/src/language-server/profiles/sharedConfigProfileStore.test.ts b/server/aws-lsp-identity/src/language-server/profiles/sharedConfigProfileStore.test.ts index c6d938e53c..fa0e85bb72 100644 --- a/server/aws-lsp-identity/src/language-server/profiles/sharedConfigProfileStore.test.ts +++ b/server/aws-lsp-identity/src/language-server/profiles/sharedConfigProfileStore.test.ts @@ -81,7 +81,7 @@ describe('SharedConfigProfileStore', async () => { mock.restore() }) - it('loads SSO token profiles and sso-sessions, but not services', async () => { + it('loads profiles and sso-sessions', async () => { setupTest(config, credentials) const actual = await sut.load() @@ -89,20 +89,17 @@ describe('SharedConfigProfileStore', async () => { expect(actual).to.deep.equal({ profiles: [ { - kinds: [ProfileKind.Unknown], + kinds: [ProfileKind.IamCredentialsProfile], name: 'default', settings: { - region: 'us-west-2', - sso_session: undefined, + aws_access_key_id: 'AAAAAAAA', + aws_secret_access_key: 'BBBBBBBB', }, }, { kinds: [ProfileKind.Unknown], name: 'subsettings', - settings: { - region: undefined, - sso_session: undefined, - }, + settings: {}, }, { kinds: [ProfileKind.SsoTokenProfile], @@ -183,26 +180,22 @@ describe('SharedConfigProfileStore', async () => { expect(after).to.deep.equal({ profiles: [ { - kinds: ['Unknown'], + kinds: [ProfileKind.IamCredentialsProfile], name: 'default', settings: { - region: 'us-west-2', - sso_session: undefined, + aws_access_key_id: 'AAAAAAAA', + aws_secret_access_key: 'BBBBBBBB', }, }, { - kinds: ['Unknown'], + kinds: [ProfileKind.Unknown], name: 'subsettings', - settings: { - region: undefined, - sso_session: undefined, - }, + settings: {}, }, { kinds: [ProfileKind.SsoTokenProfile], name: 'config-only.profile', settings: { - region: undefined, sso_session: 'test-sso-session', }, }, @@ -283,20 +276,17 @@ describe('SharedConfigProfileStore', async () => { expect(after).to.deep.equal({ profiles: [ { - kinds: ['Unknown'], + kinds: [ProfileKind.IamCredentialsProfile], name: 'default', settings: { - region: 'us-west-2', - sso_session: undefined, + aws_access_key_id: 'AAAAAAAA', + aws_secret_access_key: 'BBBBBBBB', }, }, { - kinds: ['Unknown'], + kinds: [ProfileKind.Unknown], name: 'subsettings', - settings: { - region: undefined, - sso_session: undefined, - }, + settings: {}, }, { kinds: ['SsoTokenProfile'], @@ -325,6 +315,38 @@ describe('SharedConfigProfileStore', async () => { sso_session: 'new-sso-session', }, }, + { + kinds: [ProfileKind.IamCredentialsProfile], + name: 'iam-user.profile', + settings: { + aws_access_key_id: 'new-access-key', + aws_secret_access_key: 'new-secret-key', + aws_session_token: 'new-session-token', + }, + }, + { + kinds: [ProfileKind.IamSourceProfileProfile], + name: 'role-source.profile', + settings: { + role_arn: 'new-role-arn', + source_profile: 'new-source-profile', + }, + }, + { + kinds: [ProfileKind.IamCredentialSourceProfile], + name: 'role-instance.profile', + settings: { + role_arn: 'new-role-arn', + credential_source: 'new-source', + }, + }, + { + kinds: [ProfileKind.IamCredentialProcessProfile], + name: 'process.profile', + settings: { + credential_process: 'new-credential-process', + }, + }, ], ssoSessions: [ { @@ -348,51 +370,74 @@ describe('SharedConfigProfileStore', async () => { await sut.save(data) const after = await sut.load() + after.profiles.sort((a, b) => a.name.localeCompare(b.name)) + after.ssoSessions.sort((a, b) => a.name.localeCompare(b.name)) expect(after).to.deep.equal({ profiles: [ { - kinds: ['Unknown'], + kinds: [ProfileKind.SsoTokenProfile], + name: 'config-only.profile', + settings: { + region: 'us-west-1', + sso_session: 'new-sso-session', + }, + }, + { + kinds: [ProfileKind.SsoTokenProfile], + name: 'credentials-only.profile', + settings: { + region: 'us-east-1', + sso_session: 'test-sso-session', + }, + }, + { + kinds: [ProfileKind.IamCredentialsProfile], name: 'default', settings: { - region: 'us-west-2', - sso_session: undefined, + aws_access_key_id: 'AAAAAAAA', + aws_secret_access_key: 'BBBBBBBB', }, }, { - kinds: ['Unknown'], - name: 'subsettings', + kinds: [ProfileKind.IamCredentialsProfile], + name: 'iam-user.profile', settings: { - region: undefined, - sso_session: undefined, + aws_access_key_id: 'new-access-key', + aws_secret_access_key: 'new-secret-key', + aws_session_token: 'new-session-token', }, }, { - kinds: [ProfileKind.SsoTokenProfile], - name: 'config-only.profile', + kinds: [ProfileKind.IamCredentialProcessProfile], + name: 'process.profile', settings: { - region: 'us-west-1', - sso_session: 'new-sso-session', + credential_process: 'new-credential-process', }, }, { - kinds: [ProfileKind.SsoTokenProfile], - name: 'credentials-only.profile', + kinds: [ProfileKind.IamCredentialSourceProfile], + name: 'role-instance.profile', settings: { - region: 'us-east-1', - sso_session: 'test-sso-session', + role_arn: 'new-role-arn', + credential_source: 'new-source', }, }, - ], - ssoSessions: [ { - name: 'test-sso-session', + kinds: [ProfileKind.IamSourceProfileProfile], + name: 'role-source.profile', settings: { - sso_region: 'us-east-1', - sso_registration_scopes: ['my-scope'], - sso_start_url: 'http://newnowhere', + role_arn: 'new-role-arn', + source_profile: 'new-source-profile', }, }, + { + kinds: [ProfileKind.Unknown], + name: 'subsettings', + settings: {}, + }, + ], + ssoSessions: [ { name: 'new-sso-session', settings: { @@ -400,6 +445,14 @@ describe('SharedConfigProfileStore', async () => { sso_start_url: 'http://somewhere', }, }, + { + name: 'test-sso-session', + settings: { + sso_region: 'us-east-1', + sso_registration_scopes: ['my-scope'], + sso_start_url: 'http://newnowhere', + }, + }, ], }) }) diff --git a/server/aws-lsp-identity/src/language-server/profiles/sharedConfigProfileStore.ts b/server/aws-lsp-identity/src/language-server/profiles/sharedConfigProfileStore.ts index 2edbe2bb0f..0b612a46a0 100644 --- a/server/aws-lsp-identity/src/language-server/profiles/sharedConfigProfileStore.ts +++ b/server/aws-lsp-identity/src/language-server/profiles/sharedConfigProfileStore.ts @@ -3,11 +3,12 @@ import { ProfileData, profileDuckTypers, ProfileStore, + profileTypes, ssoSessionDuckTyper, } from './profileService' import { parseKnownFiles, SharedConfigInit } from '@smithy/shared-ini-file-loader' import { IniSection, IniSectionType, ParsedIniData } from '@smithy/types' -import { AwsErrorCodes, ProfileKind, SsoSession } from '@aws/language-server-runtimes/server-interface' +import { AwsErrorCodes, Profile, ProfileKind, SsoSession } from '@aws/language-server-runtimes/server-interface' import { SectionHeader } from '../../sharedConfig/types' import { saveKnownFiles } from '../../sharedConfig' import { normalizeParsedIniData } from '../../sharedConfig/saveKnownFiles' @@ -44,22 +45,33 @@ export class SharedConfigProfileStore implements ProfileStore { for (const [parsedSectionName, settings] of Object.entries(parsedIni)) { const sectionHeader = SectionHeader.fromParsedSectionName(parsedSectionName) switch (sectionHeader.type) { - case IniSectionType.PROFILE: - result.profiles.push({ - kinds: [ - // As more profile kinds are added this will get more complex and need refactored - profileDuckTypers.SsoTokenProfile.eval(settings) - ? ProfileKind.SsoTokenProfile - : ProfileKind.Unknown, - ], + // Convert config file profile into profile object + case IniSectionType.PROFILE: { + const profile: Profile = { + kinds: [], name: sectionHeader.name, - settings: { - // Only apply settings expected on Profile - region: settings.region, - sso_session: settings.sso_session, - }, - }) + settings: {}, + } + // Add the kinds and settings for each matched profile type + for (const [profileType, fields] of Object.entries(profileTypes)) { + if (profileDuckTypers[profileType].eval(settings)) { + profile.kinds.push(fields.kind) + const relevantFields = [...fields.required, ...fields.optional] + for (const field of relevantFields) { + if (settings[field] !== undefined) { + profile.settings![field] = settings[field] + } + } + } + } + // If the profile does not match any profile type, mark it as an unknown profile + if (profile.kinds.length === 0) { + profile.kinds.push(ProfileKind.Unknown) + } + result.profiles.push(profile) break + } + // Convert config file SSO session into SSO session object case IniSectionType.SSO_SESSION: { if (!ssoSessionDuckTyper.eval(settings)) { continue @@ -91,9 +103,9 @@ export class SharedConfigProfileStore implements ProfileStore { return result } - // If a setting is set to undefined or null, it will be removed from shared config files - // If the settings property is set to undefined or null, the entire section will be removed - // from the shared config files. This is equivalent to deleting a section. + // If a setting is set to undefined, null, or an empty string, it will be removed from shared + // config files. If the settings property is set to undefined or null, the entire section will + // be removed from the shared config files. This is equivalent to deleting a section. // Any settings or sections in the shared config files that are not passed into data will // be preserved as-is. async save(data: ProfileData, init?: SharedConfigInit): Promise { @@ -114,9 +126,12 @@ export class SharedConfigProfileStore implements ProfileStore { IniSectionType.PROFILE, data.profiles, parsedKnownFiles, - (section, parsedSection) => - !section.kinds.includes(ProfileKind.SsoTokenProfile) || - profileDuckTypers.SsoTokenProfile.eval(parsedSection) + (section, parsedSection) => { + return section.kinds.every(kind => { + const duckTyper = profileDuckTypers[kind] + return duckTyper ? duckTyper.eval(parsedSection) : true + }) + } ) } @@ -192,7 +207,9 @@ export class SharedConfigProfileStore implements ProfileStore { // If setting not passed then preserve setting in file as-is value = value?.toString().trim() if (value === undefined || value === null || value === '') { - Object.hasOwn(parsedSection, name) && delete parsedSection[name] + if (Object.hasOwn(parsedSection, name)) { + delete parsedSection[name] + } } else { if (controlCharsRegex.test(value)) { throwAwsError(`Setting [${name}] cannot contain control characters.`) diff --git a/server/aws-lsp-identity/src/sso/cache/refreshingSsoCache.ts b/server/aws-lsp-identity/src/sso/cache/refreshingSsoCache.ts index d642f0a36b..197fd1680b 100644 --- a/server/aws-lsp-identity/src/sso/cache/refreshingSsoCache.ts +++ b/server/aws-lsp-identity/src/sso/cache/refreshingSsoCache.ts @@ -7,13 +7,11 @@ import { throwOnInvalidClientName, UpdateSsoTokenFromCreateToken, throwOnInvalidSsoSessionName, + SendSsoTokenChanged, } from '../utils' -import { RaiseSsoTokenChanged } from '../../language-server/ssoTokenAutoRefresher' import { CreateTokenCommandOutput, InvalidGrantException } from '@aws-sdk/client-sso-oidc' import { AwsError, Observability } from '@aws/lsp-core' - -export const refreshWindowMillis: number = 5 * 60 * 1000 -export const retryCooldownWindowMillis: number = 30000 +import { refreshWindowMillis, retryCooldownWindowMillis } from '../../language-server/autoRefresher' interface SsoTokenDetail { lastRefreshMillis: number @@ -24,7 +22,7 @@ export class RefreshingSsoCache implements SsoCache { constructor( private readonly next: SsoCache, - private readonly raiseSsoTokenChanged: RaiseSsoTokenChanged, + private readonly raiseSsoTokenChanged: SendSsoTokenChanged, private readonly observability: Observability ) {} @@ -128,14 +126,18 @@ export class RefreshingSsoCache implements SsoCache { // Current time is before start of refresh window? Just return it const refreshAfterMillis = accessTokenExpiresAtMillis - refreshWindowMillis if (nowMillis < refreshAfterMillis) { - this.observability.logging.log('SSO token before refresh window. Returning current SSO token.') + this.observability.logging.log( + 'SSO token expiration is before refresh window. Returning current SSO token.' + ) return ssoToken } // Last refresh attempt was less than the retry window? Just return it const retryAfterMillis = ssoTokenDetail.lastRefreshMillis + retryCooldownWindowMillis if (nowMillis < retryAfterMillis) { - this.observability.logging.log('SSO token in retry cooldown window. Returning current SSO token.') + this.observability.logging.log( + 'SSO token expiration is in retry cooldown window. Returning current SSO token.' + ) return ssoToken } } diff --git a/server/aws-lsp-identity/src/language-server/ssoTokenAutoRefresher.test.ts b/server/aws-lsp-identity/src/sso/ssoTokenAutoRefresher.test.ts similarity index 100% rename from server/aws-lsp-identity/src/language-server/ssoTokenAutoRefresher.test.ts rename to server/aws-lsp-identity/src/sso/ssoTokenAutoRefresher.test.ts diff --git a/server/aws-lsp-identity/src/language-server/ssoTokenAutoRefresher.ts b/server/aws-lsp-identity/src/sso/ssoTokenAutoRefresher.ts similarity index 54% rename from server/aws-lsp-identity/src/language-server/ssoTokenAutoRefresher.ts rename to server/aws-lsp-identity/src/sso/ssoTokenAutoRefresher.ts index 477d5c901f..4a3863129e 100644 --- a/server/aws-lsp-identity/src/language-server/ssoTokenAutoRefresher.ts +++ b/server/aws-lsp-identity/src/sso/ssoTokenAutoRefresher.ts @@ -1,30 +1,18 @@ -import { SsoSession, SsoTokenChangedParams } from '@aws/language-server-runtimes/protocol' -import { RefreshingSsoCache, refreshWindowMillis, retryCooldownWindowMillis } from '../sso/cache/refreshingSsoCache' -import { throwOnInvalidClientName, throwOnInvalidSsoSession, throwOnInvalidSsoSessionName } from '../sso/utils' +import { SsoSession } from '@aws/language-server-runtimes/protocol' +import { RefreshingSsoCache } from './cache/refreshingSsoCache' +import { throwOnInvalidClientName, throwOnInvalidSsoSession, throwOnInvalidSsoSessionName } from './utils' import { MetricEvent } from '@aws/language-server-runtimes/server-interface' -import { normalizeSettingList } from './profiles/profileService' +import { normalizeSettingList } from '../language-server/profiles/profileService' import { __ServiceException } from '@aws-sdk/client-sso-oidc/dist-types/models/SSOOIDCServiceException' import { AwsError, Observability } from '@aws/lsp-core' +import { AutoRefresher, invalidDelay } from '../language-server/autoRefresher' -const bufferedRefreshWindowMillis = refreshWindowMillis * 0.95 -const bufferedRetryCooldownWindowMillis = retryCooldownWindowMillis * 1.05 -const maxRefreshJitterMillis = 10000 -const maxRetryCooldownJitterMillis = 3000 - -export type RaiseSsoTokenChanged = (params: SsoTokenChangedParams) => void - -export class SsoTokenAutoRefresher implements Disposable { - private readonly timeouts: Record = {} - +export class SsoTokenAutoRefresher extends AutoRefresher { constructor( private readonly ssoCache: RefreshingSsoCache, - private readonly observability: Observability - ) {} - - [Symbol.dispose](): void { - for (const ssoSessionName of Object.keys(this.timeouts)) { - this.unwatch(ssoSessionName) - } + observability: Observability + ) { + super(observability) } async watch(clientName: string, ssoSession: SsoSession): Promise { @@ -46,29 +34,12 @@ export class SsoTokenAutoRefresher implements Disposable { return } - const nowMillis = Date.now() - const accessTokenExpiresAtMillis = Date.parse(ssoToken.expiresAt) - let delayMillis: number - - if (nowMillis < accessTokenExpiresAtMillis - refreshWindowMillis) { - // Before refresh window, schedule to run in refresh window with jitter - delayMillis = accessTokenExpiresAtMillis - bufferedRefreshWindowMillis - nowMillis - delayMillis += Math.random() * maxRefreshJitterMillis // Jitter to mitigate race conditions - } else if ( - accessTokenExpiresAtMillis - refreshWindowMillis < nowMillis && - nowMillis < accessTokenExpiresAtMillis - ) { - // In refresh window with time for a retry - delayMillis = bufferedRetryCooldownWindowMillis - delayMillis += Math.random() * maxRetryCooldownJitterMillis // Jitter to mitigate race conditions - } else { - // Otherwise, expired - this.observability.logging.log('SSO token has expired and will not be auto-refreshed.') - return + // Refresh timeout if delay is valid + const delayMillis = this.getDelay(ssoToken.expiresAt) + if (delayMillis !== invalidDelay) { + this.observability.logging.log(`Auto-refreshing SSO token in ${delayMillis} milliseconds.`) + this.timeouts[ssoSession.name] = setTimeout(this.watch.bind(this, clientName, ssoSession), delayMillis) } - - this.observability.logging.log(`Auto-refreshing SSO token in ${delayMillis} milliseconds.`) - this.timeouts[ssoSession.name] = setTimeout(this.watch.bind(this, clientName, ssoSession), delayMillis) } catch (e) { emitMetric(e, ssoSession) diff --git a/server/aws-lsp-identity/src/sso/utils.ts b/server/aws-lsp-identity/src/sso/utils.ts index fe068dc796..38c6846b17 100644 --- a/server/aws-lsp-identity/src/sso/utils.ts +++ b/server/aws-lsp-identity/src/sso/utils.ts @@ -5,6 +5,7 @@ import { ShowMessageRequestParams, SsoSession, Lsp, + SsoTokenChangedParams, } from '@aws/language-server-runtimes/server-interface' import { CreateTokenCommandOutput, SSOOIDC, SSOOIDCClientConfig } from '@aws-sdk/client-sso-oidc' import { SsoClientRegistration } from './cache' @@ -36,7 +37,7 @@ export function getSsoOidc(ssoRegion: string): SSOOIDC & Disposable { } export function throwOnInvalidClientName(clientName?: string): asserts clientName is string { - if (!clientName?.trim().length) { + if (typeof clientName?.trim !== 'function' || !clientName?.trim().length) { throw new AwsError(`Client name [${clientName}] is invalid.`, AwsErrorCodes.E_INVALID_SSO_CLIENT) } } @@ -57,7 +58,7 @@ export function throwOnInvalidClientRegistration( } export function throwOnInvalidSsoSessionName(ssoSessionName?: string): asserts ssoSessionName is string { - if (!ssoSessionName?.trim()) { + if (typeof ssoSessionName?.trim !== 'function' || !ssoSessionName?.trim()) { throw new AwsError('SSO session name is invalid.', AwsErrorCodes.E_INVALID_SSO_SESSION) } } @@ -111,17 +112,19 @@ export function UpdateSsoTokenFromCreateToken( export type ShowUrl = (url: URL) => void export type ShowMessageRequest = (params: ShowMessageRequestParams) => Promise export type ShowProgress = Lsp['sendProgress'] +export type SendSsoTokenChanged = (params: SsoTokenChangedParams) => void +export type SsoHandlers = { + showUrl: ShowUrl + showMessageRequest: ShowMessageRequest + showProgress: ShowProgress + // Add `showMsg: ShowMessage` if needed. +} export type SsoFlowParams = { clientName: string clientRegistration: SsoClientRegistration ssoSession: SsoSession - handlers: { - showUrl: ShowUrl - showMessageRequest: ShowMessageRequest - showProgress: ShowProgress - // Add `showMsg: ShowMessage` if needed. - } + handlers: SsoHandlers token: CancellationToken observability: Observability } diff --git a/server/aws-lsp-identity/src/sts/cache/fileSystemStsCache.test.ts b/server/aws-lsp-identity/src/sts/cache/fileSystemStsCache.test.ts new file mode 100644 index 0000000000..c686ad8cd1 --- /dev/null +++ b/server/aws-lsp-identity/src/sts/cache/fileSystemStsCache.test.ts @@ -0,0 +1,197 @@ +// eslint-disable-next-line @typescript-eslint/no-require-imports +import mock = require('mock-fs') +import { FileSystemStsCache, getStsCredentialFilepath } from './fileSystemStsCache' +import { expect, use } from 'chai' +import { DirectoryItems } from 'mock-fs/lib/filesystem' +import { Logging, IamCredentials, Telemetry } from '@aws/language-server-runtimes/server-interface' +import { access } from 'fs/promises' +import * as fs from 'fs' +import { StubbedInstance, stubInterface } from 'ts-sinon' +import { Observability } from '@aws/lsp-core' + +// eslint-disable-next-line +use(require('chai-as-promised')) + +let sut: FileSystemStsCache + +let observability: StubbedInstance + +const id: string = 'someid' + +const credential: IamCredentials = { + accessKeyId: 'someaccesskeyid', + secretAccessKey: 'somesecretaccesskey', + sessionToken: 'somesessiontoken', + expiration: new Date(Date.now() + 60 * 60 * 1000), +} + +function setupTest(args?: { id?: string; credential?: IamCredentials }): void { + // Just for sanity, safe to call restore if mock not currently active + mock.restore() + + args = { ...{ id, credential }, ...args } + + const mockConfig: DirectoryItems = {} + mockConfig[getStsCredentialFilepath(args.id!)] = JSON.stringify({ + Credentials: { + AccessKeyId: credential.accessKeyId, + SecretAccessKey: credential.secretAccessKey, + SessionToken: credential.sessionToken, + Expiration: credential.expiration, + }, + }) + + mock(mockConfig) +} + +function expectFileExists(filename: string): Chai.Assertion { + return expect(access(filename, fs.constants.F_OK)) +} + +describe('FileSystemStsCache', () => { + beforeEach(() => { + observability = stubInterface() + observability.logging = stubInterface() + observability.telemetry = stubInterface() + + sut = new FileSystemStsCache(observability) + }) + + afterEach(() => { + mock.restore() + }) + + it('removeStsCredential deletes a valid credential', async () => { + const filename = getStsCredentialFilepath(id) + setupTest() + + await expectFileExists(filename).to.not.be.rejectedWith() + + await sut.removeStsCredential(id) + + await expectFileExists(filename).to.be.rejectedWith() + }) + + it('removeStsCredential does nothing on invalid/non-existent credential', async () => { + const filename = getStsCredentialFilepath(id) + setupTest() + + await expectFileExists(filename).to.not.be.rejectedWith() + + await sut.removeStsCredential('non-existent credential') + + await expectFileExists(filename).to.not.be.rejectedWith() + }) + + it('removeStsCredential throws on invalid id', async () => { + await expect(sut.removeStsCredential(' ')).to.be.rejectedWith() + }) + + it('getStsCredential returns valid credential', async () => { + setupTest() + + const actual = await sut.getStsCredential(id) + + expect(actual).to.not.be.null.and.not.undefined + expect(actual?.accessKeyId).to.equal(credential.accessKeyId) + expect(actual?.secretAccessKey).to.equal(credential.secretAccessKey) + expect(actual?.sessionToken).to.equal(credential.sessionToken) + expect(actual?.expiration?.toISOString()).to.equal(credential.expiration?.toISOString()) + }) + + it('getStsCredential returns undefined when file does not exist', async () => { + setupTest() + + const actual = await sut.getStsCredential('does not exist') + + expect(actual).to.be.undefined + }) + + it('getStsCredential returns undefined on invalid credential', async () => { + setupTest({ id: 'invalid-id', credential: {} as IamCredentials }) + + const actual = await sut.getStsCredential(id) + + expect(actual).to.be.undefined + }) + + it('getStsCredential returns undefined on expired credential', async () => { + setupTest({ + id: 'invalid-id', + credential: { + accessKeyId: 'newaccesskeyid', + secretAccessKey: 'newsecretaccesskey', + sessionToken: 'newsessiontoken', + expiration: new Date(Date.now() - 60 * 60 * 1000), + } as IamCredentials, + }) + + const actual = await sut.getStsCredential(id) + + expect(actual).to.be.undefined + }) + + it('setStsCredential writes new valid credential', async () => { + setupTest() + await sut.setStsCredential(id, credential) + + const actual = await sut.getStsCredential(id) + + expect(actual).to.not.be.null.and.not.undefined + expect(actual?.accessKeyId).to.equal(credential.accessKeyId) + expect(actual?.secretAccessKey).to.equal(credential.secretAccessKey) + expect(actual?.sessionToken).to.equal(credential.sessionToken) + expect(actual?.expiration?.toISOString()).to.equal(credential.expiration?.toISOString()) + }) + + it('setStsCredential writes new valid credential when ~/.aws does not exist', async () => { + mock.restore() + mock({}) + + await sut.setStsCredential(id, credential) + + const actual = await sut.getStsCredential(id) + + expect(actual).to.not.be.null.and.not.undefined + expect(actual?.accessKeyId).to.equal(credential.accessKeyId) + expect(actual?.secretAccessKey).to.equal(credential.secretAccessKey) + expect(actual?.sessionToken).to.equal(credential.sessionToken) + expect(actual?.expiration?.toISOString()).to.equal(credential.expiration?.toISOString()) + }) + + it('setStsCredential writes updated existing credential', async () => { + setupTest() + + const newCredential = { + accessKeyId: 'newaccesskeyid', + secretAccessKey: 'newsecretaccesskey', + sessionToken: 'newsessiontoken', + expiration: new Date(Date.now() + 60 * 60 * 1000), + } + await sut.setStsCredential(id, newCredential) + const actual = await sut.getStsCredential(id) + + expect(actual).to.not.be.null.and.not.undefined + expect(actual?.accessKeyId).to.equal(newCredential.accessKeyId) + expect(actual?.secretAccessKey).to.equal(newCredential.secretAccessKey) + expect(actual?.sessionToken).to.equal(newCredential.sessionToken) + expect(actual?.expiration?.toISOString()).to.equal(newCredential.expiration.toISOString()) + }) + + it('setStsCredential returns without error on invalid credential', async () => { + setupTest() + + await sut.setStsCredential(id, {} as IamCredentials) // no throw + }) + + it('setStsCredential returns without error on expired credential', async () => { + setupTest() + + await sut.setStsCredential(id, { + accessKeyId: 'newaccesskeyid', + secretAccessKey: 'newsecretaccesskey', + sessionToken: 'newsessiontoken', + expiration: new Date(Date.now() - 60 * 60 * 1000), + } as IamCredentials) // no throw + }) +}) diff --git a/server/aws-lsp-identity/src/sts/cache/fileSystemStsCache.ts b/server/aws-lsp-identity/src/sts/cache/fileSystemStsCache.ts new file mode 100644 index 0000000000..a11120f232 --- /dev/null +++ b/server/aws-lsp-identity/src/sts/cache/fileSystemStsCache.ts @@ -0,0 +1,136 @@ +import { StsCache } from './stsCache' +import { AwsError, Observability } from '@aws/lsp-core' +import { AwsErrorCodes, IamCredentials, Profile } from '@aws/language-server-runtimes/protocol' +import path, { join } from 'path' +import { mkdir, readFile, unlink, writeFile } from 'fs/promises' +import { getHomeDir } from '@smithy/shared-ini-file-loader' +import { throwOnInvalidCredentialId } from '../../iam/utils' +import { createHash } from 'crypto' + +export class FileSystemStsCache implements StsCache { + constructor(private readonly observability: Observability) {} + + async removeStsCredential(name: string): Promise { + throwOnInvalidCredentialId(name) + + await unlink(getStsCredentialFilepath(name)).catch(reason => this.ignoreDoesNotExistOrThrow(reason)) + } + + async getStsCredential(name: string): Promise { + try { + let credential = await getStsCredentialFromFile(name) + if (!this.isValid(credential)) { + this.observability.logging.log(`Cannot get credential from ${name}: missing fields.`) + return undefined + } + // Ensure expiration is a Date object + if (typeof credential.expiration === 'string') { + credential = { ...credential, expiration: new Date(credential.expiration) } + } + if (this.isExpired(credential)) { + this.observability.logging.log(`Credential from ${name} is expired`) + return undefined + } + return credential + } catch (e) { + this.ignoreDoesNotExistOrThrow(e) + } + } + + async setStsCredential(name: string, credential: IamCredentials): Promise { + if (!this.isValid(credential)) { + this.observability.logging.log('Cannot set credential: missing fields.') + return + } + if (this.isExpired(credential)) { + this.observability.logging.log(`Cannot set credential: expired`) + return undefined + } + + await writeStsObjectToFile(name, credential).catch(reason => { + throw AwsError.wrap(reason, AwsErrorCodes.E_CANNOT_WRITE_SSO_CACHE) + }) + } + + private ignoreDoesNotExistOrThrow(error: unknown): Promise { + // Error codes are consistent across OSes (Windows is converted to libuv error codes) + // https://nodejs.org/api/errors.html#errorerrno + if ((error as SystemError)?.code === 'ENOENT') { + return Promise.resolve(undefined) + } + + this.observability.logging.log('Cannot read STS cache.') + throw AwsError.wrap(error as Error, AwsErrorCodes.E_CANNOT_READ_SSO_CACHE) + } + + private isValid(credential: IamCredentials): boolean { + return ( + credential.accessKeyId !== undefined && + credential.secretAccessKey !== undefined && + credential.sessionToken !== undefined && + credential.expiration !== undefined + ) + } + + private isExpired(credential: IamCredentials): boolean { + if (credential.expiration === undefined) { + return false + } + return Date.now() >= credential.expiration.getTime() + } +} + +export function convertProfileToId(profile: Profile) { + const key = JSON.stringify({ + RoleArn: profile.settings?.role_arn, + RoleSessionName: profile.settings?.role_session_name, + SerialNumber: profile.settings?.mfa_serial, + }) + return createHash('sha1').update(key).digest('hex') +} + +// Based on: +// https://github.com/smithy-lang/smithy-typescript/blob/main/packages/shared-ini-file-loader/src/getSSOTokenFilepath.ts +export function getStsCredentialFilepath(id: string): string { + return join(getHomeDir(), '.aws', 'flare', 'cache', `${id}.json`) +} + +// Based on: +// https://github.com/smithy-lang/smithy-typescript/blob/main/packages/shared-ini-file-loader/src/getSSOTokenFromFile.ts +async function getStsCredentialFromFile(id: string): Promise { + const stsCredentialFilepath = getStsCredentialFilepath(id) + const text = await readFile(stsCredentialFilepath, 'utf8') + const json = JSON.parse(text) + return { + accessKeyId: json.Credentials.AccessKeyId, + secretAccessKey: json.Credentials.SecretAccessKey, + sessionToken: json.Credentials.SessionToken, + expiration: json.Credentials.Expiration, + } as IamCredentials +} + +// Based on: +// https://github.com/aws/aws-sdk-js-v3/blob/6e61f0e78ff7a9e3b1f2cd651bde5fc656d85ba9/packages/token-providers/src/writeSSOTokenToFile.ts +async function writeStsObjectToFile(id: string, credentials: IamCredentials): Promise { + const filepath = getStsCredentialFilepath(id) + await mkdir(path.dirname(filepath), { mode: 0o755, recursive: true }) + const json = JSON.stringify( + { + Credentials: { + AccessKeyId: credentials.accessKeyId, + SecretAccessKey: credentials.secretAccessKey, + SessionToken: credentials.sessionToken, + Expiration: credentials.expiration, + }, + }, + null, + 2 + ) + return await writeFile(filepath, json, { encoding: 'utf-8', flush: true, mode: 0o600 }) +} + +// Minimal declaration of SystemError (no node type declaration for it) to access code property +// https://nodejs.org/api/errors.html#class-systemerror +interface SystemError { + code: string +} diff --git a/server/aws-lsp-identity/src/sts/cache/stsCache.ts b/server/aws-lsp-identity/src/sts/cache/stsCache.ts new file mode 100644 index 0000000000..d89e8dbc4e --- /dev/null +++ b/server/aws-lsp-identity/src/sts/cache/stsCache.ts @@ -0,0 +1,7 @@ +import { IamCredentials } from '@aws/language-server-runtimes/protocol' + +export interface StsCache { + getStsCredential(name: string): Promise + setStsCredential(name: string, credentials: IamCredentials): Promise + removeStsCredential(name: string): Promise +} diff --git a/server/aws-lsp-identity/src/sts/stsAutoRefresher.test.ts b/server/aws-lsp-identity/src/sts/stsAutoRefresher.test.ts new file mode 100644 index 0000000000..ec080b6ba1 --- /dev/null +++ b/server/aws-lsp-identity/src/sts/stsAutoRefresher.test.ts @@ -0,0 +1,124 @@ +import { expect, use } from 'chai' +import { StsAutoRefresher } from './stsAutoRefresher' +import { StubbedInstance, stubInterface } from 'ts-sinon' +import { restore, spy } from 'sinon' +import { AwsErrorCodes, IamCredentials, Logging, Telemetry } from '@aws/language-server-runtimes/server-interface' +import { AwsError, Observability } from '@aws/lsp-core' +import { FileSystemStsCache } from './cache/fileSystemStsCache' + +// eslint-disable-next-line +use(require('chai-as-promised')) + +let observability: StubbedInstance + +const profileName = 'someprofile' +const now = Date.now() + +function createStsCredential(expiresAsOffsetMillis: number): IamCredentials { + return { + accessKeyId: 'someaccesskeyid', + secretAccessKey: 'somesecretaccesskey', + sessionToken: 'somesessiontoken', + expiration: new Date(now + expiresAsOffsetMillis), + } satisfies IamCredentials +} + +function refreshStsCredential(): Promise { + return Promise.resolve({ + accessKeyId: 'newaccesskeyid', + secretAccessKey: 'newsecretaccesskey', + sessionToken: 'newsessiontoken', + expiration: new Date(now + 60 * 60 * 1000 /* 1 hour in relative seconds */), + } satisfies IamCredentials) +} + +function stubStsCache(credential?: IamCredentials): FileSystemStsCache { + return stubInterface({ + getStsCredential: credential + ? Promise.resolve(credential) + : Promise.reject(new AwsError('Test: No STS credential', AwsErrorCodes.E_INVALID_STS_CREDENTIAL)), + }) +} + +describe('StsAutoRefresher', () => { + beforeEach(() => { + observability = stubInterface() + observability.logging = stubInterface() + observability.telemetry = stubInterface() + }) + + afterEach(() => { + restore() + }) + + it('watch does nothing if STS credential is not loaded from cache.', async () => { + const stsCache = stubStsCache() + using sut = new StsAutoRefresher(stsCache, () => {}, observability) + + expect(Object.keys(sut['timeouts']).length).to.equal(0) + + await sut.watch(profileName, refreshStsCredential) + + expect(Object.keys(sut['timeouts']).length).to.equal(0) + }) + + it('watch does nothing if STS credential is expired.', async () => { + const stsCache = stubStsCache(createStsCredential(-10000)) + using sut = new StsAutoRefresher(stsCache, () => {}, observability) + + expect(Object.keys(sut['timeouts']).length).to.equal(0) + + await sut.watch(profileName, refreshStsCredential) + + expect(Object.keys(sut['timeouts']).length).to.equal(0) + }) + + it('watch schedules refresh in refresh window prior to expiration.', async () => { + const setTimeoutSpy = spy(global, 'setTimeout') + + // Before the refresh window + const stsCache = stubStsCache(createStsCredential(60 * 60 * 1000)) + using sut = new StsAutoRefresher(stsCache, () => {}, observability) + + expect(Object.keys(sut['timeouts']).length).to.equal(0) + + await sut.watch(profileName, refreshStsCredential) + + expect(Object.keys(sut['timeouts']).length).to.equal(1) + + expect(setTimeoutSpy.calledOnce).to.be.true + expect(setTimeoutSpy.lastCall.args[1]) + .to.be.greaterThan(55 * 60 * 1000) + .and.lessThan(60 * 60 * 1000) + }) + + it('watch schedules refresh retry in retry window after last attempt.', async () => { + const setTimeoutSpy = spy(global, 'setTimeout') + + // In the refresh window + const stsCache = stubStsCache(createStsCredential(4 * 60 * 1000)) + using sut = new StsAutoRefresher(stsCache, () => {}, observability) + + expect(Object.keys(sut['timeouts']).length).to.equal(0) + + await sut.watch(profileName, refreshStsCredential) + + expect(Object.keys(sut['timeouts']).length).to.equal(1) + + expect(setTimeoutSpy.calledOnce).to.be.true + expect(setTimeoutSpy.lastCall.args[1]) + .to.be.greaterThan(30 * 1000) + .and.lessThan(40 * 1000) + }) + + it('unwatch does nothing if profileName is not watched.', () => { + const stsCache = stubStsCache() + using sut = new StsAutoRefresher(stsCache, () => {}, observability) + + expect(Object.keys(sut['timeouts']).length).to.equal(0) + + sut.unwatch(refreshStsCredential.name) + + expect(Object.keys(sut['timeouts']).length).to.equal(0) + }) +}) diff --git a/server/aws-lsp-identity/src/sts/stsAutoRefresher.ts b/server/aws-lsp-identity/src/sts/stsAutoRefresher.ts new file mode 100644 index 0000000000..e5610a8c7e --- /dev/null +++ b/server/aws-lsp-identity/src/sts/stsAutoRefresher.ts @@ -0,0 +1,84 @@ +import { StsCache } from './cache/stsCache' +import { Observability } from '@aws/lsp-core' +import { AutoRefresher, invalidDelay } from '../language-server/autoRefresher' +import { IamCredentials, StsCredentialChangedKind } from '@aws/language-server-runtimes/protocol' +import { SendStsCredentialChanged } from '../iam/utils' + +interface StsCredentialDetail { + lastRefreshMillis: number +} + +export class StsAutoRefresher extends AutoRefresher { + private readonly stsCredentialDetails: Record = {} + + constructor( + private readonly stsCache: StsCache, + private readonly raiseStsCredentialChanged: SendStsCredentialChanged, + observability: Observability + ) { + super(observability) + } + + async watch(iamCredentialId: string, refreshCallback: () => Promise): Promise { + try { + this.unwatch(iamCredentialId) + + const credential = await this.stsCache.getStsCredential(iamCredentialId).catch(_ => undefined) + + if (!credential?.expiration) { + this.observability.logging.log( + 'STS credentials do not exist or have no expiration, will not be auto-refreshed.' + ) + return + } + + // Get or create StsCredentialDetail + const stsCredentialDetail = + this.stsCredentialDetails[iamCredentialId] ?? + (this.stsCredentialDetails[iamCredentialId] = { lastRefreshMillis: 0 }) + + const delayMillis = this.getDelay(credential.expiration.toISOString()) + if (delayMillis !== invalidDelay) { + this.observability.logging.info(`Auto-refreshing STS credentials in ${delayMillis} milliseconds.`) + this.timeouts[iamCredentialId] = setTimeout(async () => { + try { + // Update last refresh attempt time (matching SSO pattern) + stsCredentialDetail.lastRefreshMillis = Date.now() + + // Passing refresh function into here is easier than refreshing from STS cache + const newCredentials = await refreshCallback() + this.observability.logging.log(`Generated new STS credentials`) + await this.stsCache.setStsCredential(iamCredentialId, newCredentials) + + // Continue watching with the new credentials (allows multiple refreshes) + this.watch(iamCredentialId, refreshCallback) + + this.raiseStsCredentialChanged({ + kind: StsCredentialChangedKind.Refreshed, + stsCredentialId: iamCredentialId, + }) + } catch (error) { + this.observability.logging.log(`Failed to refresh STS credentials: ${error}`) + + // On error, continue watching to retry later (matching SSO pattern) + this.watch(iamCredentialId, refreshCallback) + } + }, delayMillis) + } + } catch (e) { + this.observability.logging.log(`Error setting up STS auto-refresh: ${e}`) + throw e + } + } + + unwatch(stsSessionName: string): void { + const timeout = this.timeouts[stsSessionName] + if (timeout) { + clearTimeout(timeout) + delete this.timeouts[stsSessionName] + // Also clean up the credential detail + delete this.stsCredentialDetails[stsSessionName] + this.observability.logging.log('STS credentials unwatched and will not be auto-refreshed.') + } + } +}