From ea1adf81dbea5d4a97ca1128b9f4c9c4546bf178 Mon Sep 17 00:00:00 2001 From: Keen Wilson <40321520+keenwilson@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:41:05 -0800 Subject: [PATCH 01/11] fix(lambda): add console credential fallback for Lambda URI handler --- .../providers/sharedCredentialsProvider.ts | 2 +- packages/core/src/auth/utils.ts | 57 ++++++++- .../core/src/lambda/commands/editLambda.ts | 62 +++++++++- packages/core/src/lambda/uriHandlers.ts | 18 ++- .../webview/vue/toolkit/backend_toolkit.ts | 14 +-- packages/core/src/test/auth/utils.test.ts | 113 ++++++++++++++++++ .../test/lambda/commands/editLambda.test.ts | 83 +++++++++++++ .../core/src/test/lambda/uriHandlers.test.ts | 38 +++++- .../login/webview/vue/backend_toolkit.test.ts | 27 +++++ ...-e136b0bf-a7f0-45bb-b6b6-5901c7ad04c0.json | 4 + 10 files changed, 395 insertions(+), 23 deletions(-) create mode 100644 packages/core/src/test/auth/utils.test.ts create mode 100644 packages/toolkit/.changes/next-release/Bug Fix-e136b0bf-a7f0-45bb-b6b6-5901c7ad04c0.json diff --git a/packages/core/src/auth/providers/sharedCredentialsProvider.ts b/packages/core/src/auth/providers/sharedCredentialsProvider.ts index c74eca406d8..bb0fb9c106c 100644 --- a/packages/core/src/auth/providers/sharedCredentialsProvider.ts +++ b/packages/core/src/auth/providers/sharedCredentialsProvider.ts @@ -113,7 +113,7 @@ export async function handleInvalidConsoleCredentials( `Reloading window to sync with updated credentials cache using connection for profile: ${profileName}` ) const reloadResponse = await vscode.window.showInformationMessage( - `Credentials for "${profileName}" were updated. A window reload is required to apply them. Save your work before continuing. Reload now?`, + `Credentials for profile "${profileName}" were updated. A window reload is required to apply them. Save your work before continuing. Reload now?`, localizedText.yes, localizedText.no ) diff --git a/packages/core/src/auth/utils.ts b/packages/core/src/auth/utils.ts index 28e2bc1123e..165ff80c67e 100644 --- a/packages/core/src/auth/utils.ts +++ b/packages/core/src/auth/utils.ts @@ -15,7 +15,7 @@ import { createQuickPick, DataQuickPickItem, showQuickPick } from '../shared/ui/ import { isValidResponse } from '../shared/wizards/wizard' import { CancellationError } from '../shared/utilities/timeoutUtils' import { formatError, ToolkitError } from '../shared/errors' -import { asString } from './providers/credentials' +import { CredentialsId, asString } from './providers/credentials' import { TreeNode } from '../shared/treeview/resourceTreeDataProvider' import { createInputBox } from '../shared/ui/inputPrompter' import { CredentialSourceId, telemetry } from '../shared/telemetry/telemetry' @@ -899,3 +899,58 @@ export function isLocalStackConnection(): boolean { globals.globalState.tryGet('aws.toolkit.externalConnection', String, undefined) === localStackConnectionString ) } + +/** + * Creates and activates a connection using console credentials. + * Prompts user to log in via browser, creates a profile-based connection, and sets it as active. + * + * @param name - Profile name (typically Lambda function name) + * @param region - AWS region + * @returns Connection created from console credentials + * @throws ToolkitError if connection creation fails + */ +export async function createAndUseConsoleConnection(profileName: string, region: string): Promise { + await vscode.commands.executeCommand('aws.toolkit.auth.consoleLogin', profileName, region) + + const credentialsId: CredentialsId = { + credentialSource: 'profile', + credentialTypeId: profileName, + } + const connectionId = asString(credentialsId) + const connection = await Auth.instance.getConnection({ id: connectionId }) + + // Console login did not create a connection. + if (!connection) { + throw new ToolkitError('Console login failed to create connection', { + code: 'NoConsoleConnection', + }) + } + + await Auth.instance.useConnection({ id: connectionId }) + return connection +} + +/** + * Gets an active IAM connection or falls back to console credentials. + * + * @param name - Lambda function name (used as profile name for console credentials) + * @param region - AWS region + * @returns Active IAM connection or newly created console connection + * @throws ToolkitError if console login fails + */ +export async function getIAMConnectionOrFallbackToConsole(name: string, region: string): Promise { + const tryActiveConnection = await getIAMConnection({ prompt: false }) + + if (tryActiveConnection && isIamConnection(tryActiveConnection)) { + try { + // Checks if credentials can be retrieved and returns if valid + await tryActiveConnection.getCredentials() + return tryActiveConnection + } catch (error) { + // Fall back to console credentials for any credential retrieval error + } + } + + // Fallback to console + return await createAndUseConsoleConnection(name, region) +} diff --git a/packages/core/src/lambda/commands/editLambda.ts b/packages/core/src/lambda/commands/editLambda.ts index 5ea8169d280..89487906f5a 100644 --- a/packages/core/src/lambda/commands/editLambda.ts +++ b/packages/core/src/lambda/commands/editLambda.ts @@ -24,6 +24,8 @@ import { telemetry } from '../../shared/telemetry/telemetry' import { ToolkitError } from '../../shared/errors' import { getFunctionWithCredentials } from '../../shared/clients/lambdaClient' import { getLogger } from '../../shared/logger/logger' +import { showViewLogsMessage } from '../../shared/utilities/messages' +import { createAndUseConsoleConnection, getIAMConnectionOrFallbackToConsole } from '../../auth/utils' const localize = nls.loadMessageBundle() @@ -118,8 +120,9 @@ export async function deployFromTemp(lambda: LambdaFunction, projectUri: vscode. await vscode.workspace.saveAll() try { await runUploadDirectory(lambda, 'zip', projectUri) - } catch { - throw new ToolkitError('Failed to deploy Lambda function', { code: 'deployFailure' }) + } catch (error) { + // Chain error to preserve root cause for troubleshooting deployment failures + throw ToolkitError.chain(error, 'Failed to deploy Lambda function', { code: 'deployFailure' }) } await setFunctionInfo(lambda, { lastDeployed: globals.clock.Date.now(), @@ -225,14 +228,61 @@ export async function editLambda(lambda: LambdaFunction, source?: 'workspace' | }) } +/** + * Retrieves Lambda function configuration with automatic fallback to console credentials. + * Handles credential mismatches (ResourceNotFoundException, AccessDeniedException). + * + * @param name - Lambda function name + * @param region - AWS region + * @returns Lambda function configuration + */ +export async function getFunctionWithFallback(name: string, region: string) { + // Get active IAM connection or prompt for console credentials. + // Provides connection details for error messages if credential mismatch occurs. + // If this fails, error propagates to URI handler for metrics. + const connection = await getIAMConnectionOrFallbackToConsole(name, region) + + try { + return await getFunctionWithCredentials(region, name) + } catch (error: any) { + // Detect credential mismatches (ResourceNotFoundException, AccessDeniedException) + // so that user gets appropriate error messages (function doesn't exist, permissions issue, etc.) + let message: string | undefined + if (error.name === 'ResourceNotFoundException') { + const credentials = connection.type === 'iam' ? await connection.getCredentials() : undefined + const accountId = credentials ? (credentials as any).accountId : undefined + message = localize( + 'AWS.lambda.open.functionNotFound', + 'Function not found{0}. Retrying with console credentials.', + accountId ? ` in account ${accountId}` : '' + ) + } else if (error.name === 'AccessDeniedException') { + message = localize( + 'AWS.lambda.open.accessDenied', + 'Local credentials lack permission to access function. Retrying with console credentials.' + ) + } + + if (message) { + void showViewLogsMessage(message, 'warn') + getLogger().warn(message) + } + // Retry once with console credentials after credential mismatch. If second attempt fails, throw the error up. + await createAndUseConsoleConnection(name, region) + return await getFunctionWithCredentials(region, name) + } +} + +/** + * Opens a Lambda function for editing in VS Code. + * Retrieves IAM credentials (with console fallback), downloads function code, and opens it in a new workspace. + * Note: IAM credentials are required to interact with AWS resources, even for SSO users. + */ export async function openLambdaFolderForEdit(name: string, region: string) { const downloadLocation = getTempLocation(name, region) - // Do all authentication work before opening workspace to avoid race condition - const getFunctionOutput = await getFunctionWithCredentials(region, name) + const getFunctionOutput = await getFunctionWithFallback(name, region) const configuration = getFunctionOutput.Configuration - - // Download and set up Lambda code before opening workspace await editLambda( { name, diff --git a/packages/core/src/lambda/uriHandlers.ts b/packages/core/src/lambda/uriHandlers.ts index 8ae1d7b8c35..ee80928b703 100644 --- a/packages/core/src/lambda/uriHandlers.ts +++ b/packages/core/src/lambda/uriHandlers.ts @@ -11,10 +11,24 @@ import { openLambdaFolderForEdit } from './commands/editLambda' import { showConfirmationMessage } from '../shared/utilities/messages' import globals from '../shared/extensionGlobals' import { telemetry } from '../shared/telemetry/telemetry' -import { ToolkitError } from '../shared/errors' +import { ToolkitError, isUserCancelledError } from '../shared/errors' const localize = nls.loadMessageBundle() +export function handleLambdaUriError(e: unknown, functionName: string, region: string): never { + // Classify cancellations for telemetry metrics + const message = e instanceof Error ? e.message.toLowerCase() : '' + const isCancellation = isUserCancelledError(e) || message.includes('canceled') || message.includes('cancelled') + + if (isCancellation) { + throw ToolkitError.chain(e, 'User cancelled operation', { cancelled: true }) + } + + // Handle other errors + void vscode.window.showErrorMessage(`Unable to open function ${functionName} in region ${region}: ${e}`) + throw ToolkitError.chain(e, 'Failed to open Lambda function') +} + export function registerLambdaUriHandler() { async function openFunctionHandler(params: ReturnType) { await telemetry.lambda_uriHandler.run(async () => { @@ -34,7 +48,7 @@ export function registerLambdaUriHandler() { } await openLambdaFolderForEdit(params.functionName, params.region) } catch (e) { - throw new ToolkitError(`Unable to get function ${params.functionName} in region ${params.region}: ${e}`) + handleLambdaUriError(e, params.functionName, params.region) } }) } diff --git a/packages/core/src/login/webview/vue/toolkit/backend_toolkit.ts b/packages/core/src/login/webview/vue/toolkit/backend_toolkit.ts index aea498071cb..0452b3505de 100644 --- a/packages/core/src/login/webview/vue/toolkit/backend_toolkit.ts +++ b/packages/core/src/login/webview/vue/toolkit/backend_toolkit.ts @@ -4,7 +4,7 @@ */ import * as vscode from 'vscode' -import { tryAddCredentials } from '../../../../auth/utils' +import { createAndUseConsoleConnection, tryAddCredentials } from '../../../../auth/utils' import { getLogger } from '../../../../shared/logger/logger' import { CommonAuthWebview } from '../backend' import { @@ -21,7 +21,6 @@ import { CodeCatalystAuthenticationProvider } from '../../../../codecatalyst/aut import { AuthError, AuthFlowState } from '../types' import { setContext } from '../../../../shared/vscode/setContext' import { builderIdStartUrl } from '../../../../auth/sso/constants' -import { CredentialsId, asString } from '../../../../auth/providers/credentials' import { RegionProfile } from '../../../../codewhisperer/models/model' import { ProfileSwitchIntent } from '../../../../codewhisperer/region/regionProfileManager' import globals from '../../../../shared/extensionGlobals' @@ -91,16 +90,7 @@ export class ToolkitLoginWebview extends CommonAuthWebview { getLogger().debug(`called startConsoleCredentialSetup()`) const runAuth = async () => { try { - // Execute AWS CLI login command - await vscode.commands.executeCommand('aws.toolkit.auth.consoleLogin', profileName, region) - - // Use profile as active connection - const credentialsId: CredentialsId = { - credentialSource: 'profile', - credentialTypeId: profileName, - } - const connectionId = asString(credentialsId) - await Auth.instance.useConnection({ id: connectionId }) + await createAndUseConsoleConnection(profileName, region) // Hide auth view and show resource explorer await setContext('aws.explorer.showAuthView', false) diff --git a/packages/core/src/test/auth/utils.test.ts b/packages/core/src/test/auth/utils.test.ts new file mode 100644 index 00000000000..8cd3ea234a7 --- /dev/null +++ b/packages/core/src/test/auth/utils.test.ts @@ -0,0 +1,113 @@ +/*! + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +import assert from 'assert' +import * as sinon from 'sinon' +import * as vscode from 'vscode' +import { Auth } from '../../auth/auth' +import { ToolkitError } from '../../shared/errors' +import * as authUtils from '../../auth/utils' + +describe('createAndUseConsoleConnection', function () { + let sandbox: sinon.SinonSandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + }) + + afterEach(function () { + sandbox.restore() + }) + + it('creates connection after successful console login', async function () { + const executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand').resolves() + const mockConnection = { + id: 'profile:test-profile', + type: 'iam', + state: 'valid', + label: 'profile:test-profile', + getCredentials: sandbox.stub().resolves({}), + } + sandbox.stub(Auth.instance, 'getConnection').resolves(mockConnection as any) + const useConnectionStub = sandbox.stub(Auth.instance, 'useConnection').resolves() + + const result = await authUtils.createAndUseConsoleConnection('test-profile', 'us-east-1') + + assert.ok(executeCommandStub.calledWith('aws.toolkit.auth.consoleLogin', 'test-profile', 'us-east-1')) + assert.ok(useConnectionStub.calledWith({ id: 'profile:test-profile' })) + assert.strictEqual(result, mockConnection) + }) + + it('throws ToolkitError when connection not found after console login', async function () { + sandbox.stub(vscode.commands, 'executeCommand').resolves() + sandbox.stub(Auth.instance, 'getConnection').resolves(undefined) + + await assert.rejects( + () => authUtils.createAndUseConsoleConnection('test-profile', 'us-east-1'), + (err: Error) => { + assert.ok(err instanceof ToolkitError) + assert.strictEqual(err.code, 'NoConsoleConnection') + return true + } + ) + }) +}) + +describe('getIAMConnectionOrFallbackToConsole', function () { + let sandbox: sinon.SinonSandbox + + beforeEach(function () { + sandbox = sinon.createSandbox() + }) + + afterEach(function () { + sandbox.restore() + }) + + it('falls back to console credentials when no active connection', async function () { + sandbox.stub(authUtils, 'getIAMConnection').resolves(undefined) + const executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand').resolves() + const mockConnection = { + id: 'profile:test-function', + type: 'iam', + state: 'valid', + label: 'profile:test-function', + getCredentials: sandbox.stub().resolves({}), + } + sandbox.stub(Auth.instance, 'getConnection').resolves(mockConnection as any) + sandbox.stub(Auth.instance, 'useConnection').resolves() + + const result = await authUtils.getIAMConnectionOrFallbackToConsole('test-function', 'us-east-1') + + assert.ok(executeCommandStub.calledWith('aws.toolkit.auth.consoleLogin', 'test-function', 'us-east-1')) + assert.strictEqual(result, mockConnection) + }) + + it('falls back when credentials provider not found', async function () { + const mockConnection = { + id: 'profile:stale', + type: 'iam', + state: 'invalid', + label: 'profile:stale', + getCredentials: sandbox.stub().rejects(new Error('Credentials provider "profile:stale" not found')), + } + sandbox.stub(authUtils, 'getIAMConnection').resolves(mockConnection as any) + // Fall through to console credentials + sandbox.stub(vscode.commands, 'executeCommand').resolves() + const newConnection = { + id: 'profile:test-profile', + type: 'iam', + state: 'valid', + label: 'profile:test-profile', + getCredentials: sandbox.stub().resolves({}), + } + sandbox.stub(Auth.instance, 'getConnection').resolves(newConnection as any) + sandbox.stub(Auth.instance, 'useConnection').resolves() + + const result = await authUtils.getIAMConnectionOrFallbackToConsole('test-function', 'us-east-1') + + assert.strictEqual(result, newConnection) + }) +}) diff --git a/packages/core/src/test/lambda/commands/editLambda.test.ts b/packages/core/src/test/lambda/commands/editLambda.test.ts index 44d874c14fe..00780d0e288 100644 --- a/packages/core/src/test/lambda/commands/editLambda.test.ts +++ b/packages/core/src/test/lambda/commands/editLambda.test.ts @@ -14,6 +14,7 @@ import { getReadme, deleteFilesInFolder, overwriteChangesForEdit, + getFunctionWithFallback, } from '../../../lambda/commands/editLambda' import { LambdaFunction } from '../../../lambda/commands/uploadLambda' import * as downloadLambda from '../../../lambda/commands/downloadLambda' @@ -25,6 +26,8 @@ import { LambdaFunctionNodeDecorationProvider } from '../../../lambda/explorer/l import path from 'path' import globals from '../../../shared/extensionGlobals' import { lambdaTempPath } from '../../../lambda/utils' +import * as lambdaClient from '../../../shared/clients/lambdaClient' +import * as authUtils from '../../../auth/utils' describe('editLambda', function () { let mockLambda: LambdaFunction @@ -305,3 +308,83 @@ describe('editLambda', function () { }) }) }) + +describe('getFunctionWithFallback', function () { + let getFunctionWithCredentialsStub: sinon.SinonStub + let createAndUseConsoleConnectionStub: sinon.SinonStub + let showViewLogsMessageStub: sinon.SinonStub + let getIAMConnectionOrFallbackToConsoleStub: sinon.SinonStub + const mockFunction = { Configuration: { FunctionName: 'test' } } + const mockConnection = { + type: 'iam' as const, + id: 'profile:test', + label: 'profile:test', + endpointUrl: undefined, + getCredentials: sinon.stub().resolves({}), + } + + beforeEach(function () { + getFunctionWithCredentialsStub = sinon.stub(lambdaClient, 'getFunctionWithCredentials') + getIAMConnectionOrFallbackToConsoleStub = sinon.stub(authUtils, 'getIAMConnectionOrFallbackToConsole') + createAndUseConsoleConnectionStub = sinon.stub(authUtils, 'createAndUseConsoleConnection') + showViewLogsMessageStub = sinon.stub(messages, 'showViewLogsMessage') + }) + + afterEach(function () { + sinon.restore() + }) + + it('returns function when credentials are valid', async function () { + getFunctionWithCredentialsStub.resolves(mockFunction) + getIAMConnectionOrFallbackToConsoleStub.resolves(mockConnection) + + const result = await getFunctionWithFallback('test-function', 'us-east-1') + + assert.strictEqual(result, mockFunction) + assert(createAndUseConsoleConnectionStub.notCalled) + }) + + it('retries with console credentials on ResourceNotFoundException', async function () { + const error = new Error('Function not found') + error.name = 'ResourceNotFoundException' + getFunctionWithCredentialsStub.onFirstCall().rejects(error) + getFunctionWithCredentialsStub.onSecondCall().resolves(mockFunction) + const mockConnection = { + type: 'iam' as const, + id: 'profile:test', + label: 'profile:test', + endpointUrl: undefined, + getCredentials: sinon.stub().resolves({ accountId: '123456789' }), + } + getIAMConnectionOrFallbackToConsoleStub.resolves(mockConnection) + + const result = await getFunctionWithFallback('test-function', 'us-east-1') + + assert.strictEqual(result, mockFunction) + assert(createAndUseConsoleConnectionStub.calledWith('test-function', 'us-east-1')) + assert(showViewLogsMessageStub.called) + assert.ok(showViewLogsMessageStub.firstCall.args[0].includes('123456789')) + }) + + it('retries with console credentials on AccessDeniedException', async function () { + const error = new Error('User not authorized to perform: lambda:GetFunction on resource') + error.name = 'AccessDeniedException' + getFunctionWithCredentialsStub.onFirstCall().rejects(error) + getFunctionWithCredentialsStub.onSecondCall().resolves(mockFunction) + const mockConnection = { + type: 'iam' as const, + id: 'profile:test', + label: 'profile:test', + endpointUrl: undefined, + getCredentials: sinon.stub().resolves({ accountId: '987654321' }), + } + getIAMConnectionOrFallbackToConsoleStub.resolves(mockConnection) + + const result = await getFunctionWithFallback('test-function', 'us-east-2') + + assert.strictEqual(result, mockFunction) + assert(createAndUseConsoleConnectionStub.calledWith('test-function', 'us-east-2')) + assert(showViewLogsMessageStub.called) + assert.ok(!showViewLogsMessageStub.firstCall.args[0].includes('987654321')) + }) +}) diff --git a/packages/core/src/test/lambda/uriHandlers.test.ts b/packages/core/src/test/lambda/uriHandlers.test.ts index f3e8ae7c368..2131e3d057c 100644 --- a/packages/core/src/test/lambda/uriHandlers.test.ts +++ b/packages/core/src/test/lambda/uriHandlers.test.ts @@ -5,8 +5,10 @@ import assert from 'assert' import { SearchParams } from '../../shared/vscode/uriHandler' -import { parseOpenParams } from '../../lambda/uriHandlers' +import { handleLambdaUriError, parseOpenParams } from '../../lambda/uriHandlers' import { globals } from '../../shared' +import { ToolkitError } from '../../shared/errors' +import { CancellationError } from '../../shared/utilities/timeoutUtils' describe('Lambda URI Handler', function () { describe('load-function', function () { @@ -33,4 +35,38 @@ describe('Lambda URI Handler', function () { assert.deepEqual(parseOpenParams(query), valid) }) }) + + describe('handleLambdaUriError', function () { + it('throws cancelled error for CancellationError', function () { + const error = new CancellationError('user') + assert.throws( + () => handleLambdaUriError(error, 'test-fn', 'us-east-1'), + (e: ToolkitError) => e.cancelled === true + ) + }) + + it('throws cancelled error for "canceled" message', function () { + const error = new Error('Canceled') // vscode reload window + assert.throws( + () => handleLambdaUriError(error, 'test-fn', 'us-east-1'), + (e: ToolkitError) => e.cancelled === true + ) + }) + + it('throws cancelled error for "cancelled" message', function () { + const error = new Error('Timeout token cancelled') + assert.throws( + () => handleLambdaUriError(error, 'test-fn', 'us-east-1'), + (e: ToolkitError) => e.cancelled === true + ) + }) + + it('throws non-cancelled error for other errors', function () { + const error = new Error('Unable to get function') + assert.throws( + () => handleLambdaUriError(error, 'test-fn', 'us-east-1'), + (e: ToolkitError) => e.cancelled !== true + ) + }) + }) }) diff --git a/packages/core/src/test/login/webview/vue/backend_toolkit.test.ts b/packages/core/src/test/login/webview/vue/backend_toolkit.test.ts index 1294a2d0574..1980f9bd05d 100644 --- a/packages/core/src/test/login/webview/vue/backend_toolkit.test.ts +++ b/packages/core/src/test/login/webview/vue/backend_toolkit.test.ts @@ -120,4 +120,31 @@ describe('Toolkit Login', function () { authEnabledFeatures: 'awsExplorer', }) }) + + it('signs in with console credentials and emits telemetry', async function () { + const stub = sandbox.stub(authUtils, 'createAndUseConsoleConnection').resolves() + await backend.startConsoleCredentialSetup(profileName, region) + + assert.ok(stub.calledOnceWith(profileName, region)) + assertTelemetry('auth_addConnection', { + result: 'Succeeded', + credentialSourceId: 'consoleCredentials', + authEnabledFeatures: 'awsExplorer', + }) + }) + + it('returns error when console credential setup fails', async function () { + const error = new Error('Console login failed') + sandbox.stub(authUtils, 'createAndUseConsoleConnection').rejects(error) + + const result = await backend.startConsoleCredentialSetup(profileName, region) + + assert.strictEqual(result?.id, backend.id) + assert.strictEqual(result?.text, 'Console login failed') + assertTelemetry('auth_addConnection', { + result: 'Failed', + credentialSourceId: 'consoleCredentials', + authEnabledFeatures: 'awsExplorer', + }) + }) }) diff --git a/packages/toolkit/.changes/next-release/Bug Fix-e136b0bf-a7f0-45bb-b6b6-5901c7ad04c0.json b/packages/toolkit/.changes/next-release/Bug Fix-e136b0bf-a7f0-45bb-b6b6-5901c7ad04c0.json new file mode 100644 index 00000000000..e4f4733df2b --- /dev/null +++ b/packages/toolkit/.changes/next-release/Bug Fix-e136b0bf-a7f0-45bb-b6b6-5901c7ad04c0.json @@ -0,0 +1,4 @@ +{ + "type": "Bug Fix", + "description": "Opening Lambda functions from AWS Console now works with missing or mismatched local credentials" +} From 2cb8aecb7f264f954f935e47564fce18969d2180 Mon Sep 17 00:00:00 2001 From: Keen Wilson <40321520+keenwilson@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:23:14 -0800 Subject: [PATCH 02/11] test: stub Auth.instance.activeConnection to fix tests in CI --- packages/core/src/test/auth/utils.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/test/auth/utils.test.ts b/packages/core/src/test/auth/utils.test.ts index 8cd3ea234a7..98257efc299 100644 --- a/packages/core/src/test/auth/utils.test.ts +++ b/packages/core/src/test/auth/utils.test.ts @@ -67,7 +67,7 @@ describe('getIAMConnectionOrFallbackToConsole', function () { }) it('falls back to console credentials when no active connection', async function () { - sandbox.stub(authUtils, 'getIAMConnection').resolves(undefined) + sandbox.stub(Auth.instance, 'activeConnection').value(undefined) const executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand').resolves() const mockConnection = { id: 'profile:test-function', @@ -89,11 +89,11 @@ describe('getIAMConnectionOrFallbackToConsole', function () { const mockConnection = { id: 'profile:stale', type: 'iam', - state: 'invalid', + state: 'valid', label: 'profile:stale', getCredentials: sandbox.stub().rejects(new Error('Credentials provider "profile:stale" not found')), } - sandbox.stub(authUtils, 'getIAMConnection').resolves(mockConnection as any) + sandbox.stub(Auth.instance, 'activeConnection').value(mockConnection as any) // Fall through to console credentials sandbox.stub(vscode.commands, 'executeCommand').resolves() const newConnection = { From 5799e837acd81fe0735d4a0b957417a0a646fddb Mon Sep 17 00:00:00 2001 From: Keen Wilson <40321520+keenwilson@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:39:46 -0800 Subject: [PATCH 03/11] chore: trigger CI From ff3ff7d6a2cb786b2b2bf81245857cbdede23316 Mon Sep 17 00:00:00 2001 From: Keen Wilson <40321520+keenwilson@users.noreply.github.com> Date: Tue, 27 Jan 2026 20:05:52 -0800 Subject: [PATCH 04/11] chore: trigger CI From bdd1c57ee5e2d57974f94bd295902f100c5539e6 Mon Sep 17 00:00:00 2001 From: Keen Wilson <40321520+keenwilson@users.noreply.github.com> Date: Wed, 28 Jan 2026 18:49:23 -0800 Subject: [PATCH 05/11] fix(lambda): inline console creds fallback logic directly into `getFunctionWithFallback` --- packages/core/src/auth/utils.ts | 25 -------- .../core/src/lambda/commands/editLambda.ts | 21 +++---- packages/core/src/test/auth/utils.test.ts | 57 ------------------- .../test/lambda/commands/editLambda.test.ts | 13 ++--- 4 files changed, 16 insertions(+), 100 deletions(-) diff --git a/packages/core/src/auth/utils.ts b/packages/core/src/auth/utils.ts index 165ff80c67e..60c77cfad3a 100644 --- a/packages/core/src/auth/utils.ts +++ b/packages/core/src/auth/utils.ts @@ -929,28 +929,3 @@ export async function createAndUseConsoleConnection(profileName: string, region: await Auth.instance.useConnection({ id: connectionId }) return connection } - -/** - * Gets an active IAM connection or falls back to console credentials. - * - * @param name - Lambda function name (used as profile name for console credentials) - * @param region - AWS region - * @returns Active IAM connection or newly created console connection - * @throws ToolkitError if console login fails - */ -export async function getIAMConnectionOrFallbackToConsole(name: string, region: string): Promise { - const tryActiveConnection = await getIAMConnection({ prompt: false }) - - if (tryActiveConnection && isIamConnection(tryActiveConnection)) { - try { - // Checks if credentials can be retrieved and returns if valid - await tryActiveConnection.getCredentials() - return tryActiveConnection - } catch (error) { - // Fall back to console credentials for any credential retrieval error - } - } - - // Fallback to console - return await createAndUseConsoleConnection(name, region) -} diff --git a/packages/core/src/lambda/commands/editLambda.ts b/packages/core/src/lambda/commands/editLambda.ts index 89487906f5a..740f47e5503 100644 --- a/packages/core/src/lambda/commands/editLambda.ts +++ b/packages/core/src/lambda/commands/editLambda.ts @@ -25,7 +25,7 @@ import { ToolkitError } from '../../shared/errors' import { getFunctionWithCredentials } from '../../shared/clients/lambdaClient' import { getLogger } from '../../shared/logger/logger' import { showViewLogsMessage } from '../../shared/utilities/messages' -import { createAndUseConsoleConnection, getIAMConnectionOrFallbackToConsole } from '../../auth/utils' +import { createAndUseConsoleConnection, getIAMConnection } from '../../auth/utils' const localize = nls.loadMessageBundle() @@ -237,20 +237,21 @@ export async function editLambda(lambda: LambdaFunction, source?: 'workspace' | * @returns Lambda function configuration */ export async function getFunctionWithFallback(name: string, region: string) { - // Get active IAM connection or prompt for console credentials. - // Provides connection details for error messages if credential mismatch occurs. - // If this fails, error propagates to URI handler for metrics. - const connection = await getIAMConnectionOrFallbackToConsole(name, region) + const connection = await getIAMConnection({ prompt: false }) + + // If no connection, create console connection before first attempt + if (!connection) { + await createAndUseConsoleConnection(name, region) + } try { return await getFunctionWithCredentials(region, name) } catch (error: any) { // Detect credential mismatches (ResourceNotFoundException, AccessDeniedException) - // so that user gets appropriate error messages (function doesn't exist, permissions issue, etc.) let message: string | undefined - if (error.name === 'ResourceNotFoundException') { - const credentials = connection.type === 'iam' ? await connection.getCredentials() : undefined - const accountId = credentials ? (credentials as any).accountId : undefined + if (error.name === 'ResourceNotFoundException' && connection?.type === 'iam') { + const credentials = await connection.getCredentials() + const accountId = (credentials as any).accountId message = localize( 'AWS.lambda.open.functionNotFound', 'Function not found{0}. Retrying with console credentials.', @@ -267,7 +268,7 @@ export async function getFunctionWithFallback(name: string, region: string) { void showViewLogsMessage(message, 'warn') getLogger().warn(message) } - // Retry once with console credentials after credential mismatch. If second attempt fails, throw the error up. + // Retry once with console credentials after credential mismatch await createAndUseConsoleConnection(name, region) return await getFunctionWithCredentials(region, name) } diff --git a/packages/core/src/test/auth/utils.test.ts b/packages/core/src/test/auth/utils.test.ts index 98257efc299..397d333f73b 100644 --- a/packages/core/src/test/auth/utils.test.ts +++ b/packages/core/src/test/auth/utils.test.ts @@ -54,60 +54,3 @@ describe('createAndUseConsoleConnection', function () { ) }) }) - -describe('getIAMConnectionOrFallbackToConsole', function () { - let sandbox: sinon.SinonSandbox - - beforeEach(function () { - sandbox = sinon.createSandbox() - }) - - afterEach(function () { - sandbox.restore() - }) - - it('falls back to console credentials when no active connection', async function () { - sandbox.stub(Auth.instance, 'activeConnection').value(undefined) - const executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand').resolves() - const mockConnection = { - id: 'profile:test-function', - type: 'iam', - state: 'valid', - label: 'profile:test-function', - getCredentials: sandbox.stub().resolves({}), - } - sandbox.stub(Auth.instance, 'getConnection').resolves(mockConnection as any) - sandbox.stub(Auth.instance, 'useConnection').resolves() - - const result = await authUtils.getIAMConnectionOrFallbackToConsole('test-function', 'us-east-1') - - assert.ok(executeCommandStub.calledWith('aws.toolkit.auth.consoleLogin', 'test-function', 'us-east-1')) - assert.strictEqual(result, mockConnection) - }) - - it('falls back when credentials provider not found', async function () { - const mockConnection = { - id: 'profile:stale', - type: 'iam', - state: 'valid', - label: 'profile:stale', - getCredentials: sandbox.stub().rejects(new Error('Credentials provider "profile:stale" not found')), - } - sandbox.stub(Auth.instance, 'activeConnection').value(mockConnection as any) - // Fall through to console credentials - sandbox.stub(vscode.commands, 'executeCommand').resolves() - const newConnection = { - id: 'profile:test-profile', - type: 'iam', - state: 'valid', - label: 'profile:test-profile', - getCredentials: sandbox.stub().resolves({}), - } - sandbox.stub(Auth.instance, 'getConnection').resolves(newConnection as any) - sandbox.stub(Auth.instance, 'useConnection').resolves() - - const result = await authUtils.getIAMConnectionOrFallbackToConsole('test-function', 'us-east-1') - - assert.strictEqual(result, newConnection) - }) -}) diff --git a/packages/core/src/test/lambda/commands/editLambda.test.ts b/packages/core/src/test/lambda/commands/editLambda.test.ts index 00780d0e288..c944d950020 100644 --- a/packages/core/src/test/lambda/commands/editLambda.test.ts +++ b/packages/core/src/test/lambda/commands/editLambda.test.ts @@ -313,19 +313,18 @@ describe('getFunctionWithFallback', function () { let getFunctionWithCredentialsStub: sinon.SinonStub let createAndUseConsoleConnectionStub: sinon.SinonStub let showViewLogsMessageStub: sinon.SinonStub - let getIAMConnectionOrFallbackToConsoleStub: sinon.SinonStub + let getIAMConnectionStub: sinon.SinonStub const mockFunction = { Configuration: { FunctionName: 'test' } } const mockConnection = { type: 'iam' as const, id: 'profile:test', label: 'profile:test', - endpointUrl: undefined, getCredentials: sinon.stub().resolves({}), } beforeEach(function () { getFunctionWithCredentialsStub = sinon.stub(lambdaClient, 'getFunctionWithCredentials') - getIAMConnectionOrFallbackToConsoleStub = sinon.stub(authUtils, 'getIAMConnectionOrFallbackToConsole') + getIAMConnectionStub = sinon.stub(authUtils, 'getIAMConnection') createAndUseConsoleConnectionStub = sinon.stub(authUtils, 'createAndUseConsoleConnection') showViewLogsMessageStub = sinon.stub(messages, 'showViewLogsMessage') }) @@ -336,7 +335,7 @@ describe('getFunctionWithFallback', function () { it('returns function when credentials are valid', async function () { getFunctionWithCredentialsStub.resolves(mockFunction) - getIAMConnectionOrFallbackToConsoleStub.resolves(mockConnection) + getIAMConnectionStub.resolves(mockConnection) const result = await getFunctionWithFallback('test-function', 'us-east-1') @@ -353,10 +352,9 @@ describe('getFunctionWithFallback', function () { type: 'iam' as const, id: 'profile:test', label: 'profile:test', - endpointUrl: undefined, getCredentials: sinon.stub().resolves({ accountId: '123456789' }), } - getIAMConnectionOrFallbackToConsoleStub.resolves(mockConnection) + getIAMConnectionStub.resolves(mockConnection) const result = await getFunctionWithFallback('test-function', 'us-east-1') @@ -375,10 +373,9 @@ describe('getFunctionWithFallback', function () { type: 'iam' as const, id: 'profile:test', label: 'profile:test', - endpointUrl: undefined, getCredentials: sinon.stub().resolves({ accountId: '987654321' }), } - getIAMConnectionOrFallbackToConsoleStub.resolves(mockConnection) + getIAMConnectionStub.resolves(mockConnection) const result = await getFunctionWithFallback('test-function', 'us-east-2') From 741ce961030036af3191ac357dc0eb20296b4dfa Mon Sep 17 00:00:00 2001 From: Keen Wilson <40321520+keenwilson@users.noreply.github.com> Date: Wed, 28 Jan 2026 22:01:23 -0800 Subject: [PATCH 06/11] refactor: setupConsoleConnection and improve retry logic --- packages/core/src/auth/consoleSessionUtils.ts | 13 ++-- packages/core/src/auth/utils.ts | 37 ++++++----- .../core/src/lambda/commands/editLambda.ts | 36 +++++++---- .../webview/vue/toolkit/backend_toolkit.ts | 4 +- packages/core/src/test/auth/utils.test.ts | 52 ++++++++------- .../test/lambda/commands/editLambda.test.ts | 63 ++++++++++++++++--- .../login/webview/vue/backend_toolkit.test.ts | 4 +- 7 files changed, 135 insertions(+), 74 deletions(-) diff --git a/packages/core/src/auth/consoleSessionUtils.ts b/packages/core/src/auth/consoleSessionUtils.ts index e05396de6a8..dd9cb62c9a5 100644 --- a/packages/core/src/auth/consoleSessionUtils.ts +++ b/packages/core/src/auth/consoleSessionUtils.ts @@ -16,8 +16,8 @@ import { CancellationError } from '../shared/utilities/timeoutUtils' import { ToolkitError } from '../shared/errors' import { telemetry } from '../shared/telemetry/telemetry' import { Auth } from './auth' -import { CredentialsId, asString } from './providers/credentials' import { createRegionPrompter } from '../shared/ui/common/region' +import { getConnectionIdFromProfile } from './utils' /** * @description Authenticates with AWS using browser-based login via AWS CLI. @@ -289,14 +289,13 @@ export async function authenticateWithConsoleLogin(profileName?: string, region? ) logger.info(`Activating profile: ${profileName}`) - const credentialsId: CredentialsId = { - credentialSource: 'profile', - credentialTypeId: profileName, - } - const connectionId = asString(credentialsId) + const connectionId = getConnectionIdFromProfile(profileName) // Invalidate cached credentials to force fresh fetch getLogger().info(`Invalidated cached credentials for ${connectionId}`) - globals.loginManager.store.invalidateCredentials(credentialsId) + globals.loginManager.store.invalidateCredentials({ + credentialSource: 'profile', + credentialTypeId: profileName, + }) logger.info(`Looking for connection with ID: ${connectionId}`) // Make sure that connection exists before letting other part use connection diff --git a/packages/core/src/auth/utils.ts b/packages/core/src/auth/utils.ts index 60c77cfad3a..82a79283954 100644 --- a/packages/core/src/auth/utils.ts +++ b/packages/core/src/auth/utils.ts @@ -901,31 +901,30 @@ export function isLocalStackConnection(): boolean { } /** - * Creates and activates a connection using console credentials. - * Prompts user to log in via browser, creates a profile-based connection, and sets it as active. + * Constructs a credentials ID from a profile name. * - * @param name - Profile name (typically Lambda function name) - * @param region - AWS region - * @returns Connection created from console credentials - * @throws ToolkitError if connection creation fails + * @param profileName - Profile name + * @returns Credentials ID string */ -export async function createAndUseConsoleConnection(profileName: string, region: string): Promise { - await vscode.commands.executeCommand('aws.toolkit.auth.consoleLogin', profileName, region) - +export function getConnectionIdFromProfile(profileName: string): string { const credentialsId: CredentialsId = { credentialSource: 'profile', credentialTypeId: profileName, } - const connectionId = asString(credentialsId) - const connection = await Auth.instance.getConnection({ id: connectionId }) - - // Console login did not create a connection. - if (!connection) { - throw new ToolkitError('Console login failed to create connection', { - code: 'NoConsoleConnection', - }) - } + return asString(credentialsId) +} +/** + * Sets up and activates a console connection via browser login. + * Prompts user to log in via browser, creates a profile-based connection, and sets it as active. + * + * @param profileName - Profile name (typically Lambda function name) + * @param region - AWS region + * @throws Error if console login fails or user cancels + */ +export async function setupConsoleConnection(profileName: string, region: string): Promise { + getLogger().info('Auth: Sets up a connection via browser login for profile: %s, region: %s', profileName, region) + await vscode.commands.executeCommand('aws.toolkit.auth.consoleLogin', profileName, region) + const connectionId = getConnectionIdFromProfile(profileName) await Auth.instance.useConnection({ id: connectionId }) - return connection } diff --git a/packages/core/src/lambda/commands/editLambda.ts b/packages/core/src/lambda/commands/editLambda.ts index 740f47e5503..cad0c6e29a7 100644 --- a/packages/core/src/lambda/commands/editLambda.ts +++ b/packages/core/src/lambda/commands/editLambda.ts @@ -4,6 +4,7 @@ */ import * as vscode from 'vscode' import * as nls from 'vscode-nls' +import { GetFunctionCommandOutput } from '@aws-sdk/client-lambda' import { LambdaFunctionNode } from '../explorer/lambdaFunctionNode' import { downloadLambdaInLocation, openLambdaFile } from './downloadLambda' import { LambdaFunction, runUploadDirectory } from './uploadLambda' @@ -25,7 +26,7 @@ import { ToolkitError } from '../../shared/errors' import { getFunctionWithCredentials } from '../../shared/clients/lambdaClient' import { getLogger } from '../../shared/logger/logger' import { showViewLogsMessage } from '../../shared/utilities/messages' -import { createAndUseConsoleConnection, getIAMConnection } from '../../auth/utils' +import { setupConsoleConnection, getIAMConnection } from '../../auth/utils' const localize = nls.loadMessageBundle() @@ -232,16 +233,24 @@ export async function editLambda(lambda: LambdaFunction, source?: 'workspace' | * Retrieves Lambda function configuration with automatic fallback to console credentials. * Handles credential mismatches (ResourceNotFoundException, AccessDeniedException). * + * Three scenarios: + * 1. No connection exists → Set up console first, try once, if it fails don't retry (because we already used console) + * 2. Connection exists → Try it first, if it fails with credential error, fall back to console + * 3. Connection exists and fails → Retry with console, if that fails, throw (no second retry) + * * @param name - Lambda function name * @param region - AWS region - * @returns Lambda function configuration + * @returns Lambda function information with a link to download the deployment package */ -export async function getFunctionWithFallback(name: string, region: string) { +export async function getFunctionWithFallback(name: string, region: string): Promise { const connection = await getIAMConnection({ prompt: false }) + // Tracks if we've already attempted console credentials + let calledConsoleLogin = false // If no connection, create console connection before first attempt if (!connection) { - await createAndUseConsoleConnection(name, region) + await setupConsoleConnection(name, region) + calledConsoleLogin = true } try { @@ -254,23 +263,26 @@ export async function getFunctionWithFallback(name: string, region: string) { const accountId = (credentials as any).accountId message = localize( 'AWS.lambda.open.functionNotFound', - 'Function not found{0}. Retrying with console credentials.', + 'Function not found{0}.', accountId ? ` in account ${accountId}` : '' ) } else if (error.name === 'AccessDeniedException') { - message = localize( - 'AWS.lambda.open.accessDenied', - 'Local credentials lack permission to access function. Retrying with console credentials.' - ) + message = localize('AWS.lambda.open.accessDenied', 'Local credentials lack permission to access function.') } if (message) { void showViewLogsMessage(message, 'warn') getLogger().warn(message) } - // Retry once with console credentials after credential mismatch - await createAndUseConsoleConnection(name, region) - return await getFunctionWithCredentials(region, name) + + if (calledConsoleLogin) { + // Skip retry if we just created console connection - error is not due to credential mismatch + throw ToolkitError.chain(error, 'Failed to get Lambda function with console credentials. Retry skipped.') + } else { + // Retry once with console credentials + await setupConsoleConnection(name, region) + return await getFunctionWithCredentials(region, name) + } } } diff --git a/packages/core/src/login/webview/vue/toolkit/backend_toolkit.ts b/packages/core/src/login/webview/vue/toolkit/backend_toolkit.ts index 0452b3505de..01a782ab6a1 100644 --- a/packages/core/src/login/webview/vue/toolkit/backend_toolkit.ts +++ b/packages/core/src/login/webview/vue/toolkit/backend_toolkit.ts @@ -4,7 +4,7 @@ */ import * as vscode from 'vscode' -import { createAndUseConsoleConnection, tryAddCredentials } from '../../../../auth/utils' +import { setupConsoleConnection, tryAddCredentials } from '../../../../auth/utils' import { getLogger } from '../../../../shared/logger/logger' import { CommonAuthWebview } from '../backend' import { @@ -90,7 +90,7 @@ export class ToolkitLoginWebview extends CommonAuthWebview { getLogger().debug(`called startConsoleCredentialSetup()`) const runAuth = async () => { try { - await createAndUseConsoleConnection(profileName, region) + await setupConsoleConnection(profileName, region) // Hide auth view and show resource explorer await setContext('aws.explorer.showAuthView', false) diff --git a/packages/core/src/test/auth/utils.test.ts b/packages/core/src/test/auth/utils.test.ts index 397d333f73b..d1ac5c586d0 100644 --- a/packages/core/src/test/auth/utils.test.ts +++ b/packages/core/src/test/auth/utils.test.ts @@ -10,7 +10,19 @@ import { Auth } from '../../auth/auth' import { ToolkitError } from '../../shared/errors' import * as authUtils from '../../auth/utils' -describe('createAndUseConsoleConnection', function () { +describe('getConnectionIdFromProfile', function () { + it('constructs connection ID from profile name', function () { + const result = authUtils.getConnectionIdFromProfile('my-profile') + assert.strictEqual(result, 'profile:my-profile') + }) + + it('handles profile names with special characters', function () { + const result = authUtils.getConnectionIdFromProfile('my-profile-123') + assert.strictEqual(result, 'profile:my-profile-123') + }) +}) + +describe('setupConsoleConnection', function () { let sandbox: sinon.SinonSandbox beforeEach(function () { @@ -23,34 +35,26 @@ describe('createAndUseConsoleConnection', function () { it('creates connection after successful console login', async function () { const executeCommandStub = sandbox.stub(vscode.commands, 'executeCommand').resolves() - const mockConnection = { - id: 'profile:test-profile', - type: 'iam', - state: 'valid', - label: 'profile:test-profile', - getCredentials: sandbox.stub().resolves({}), - } - sandbox.stub(Auth.instance, 'getConnection').resolves(mockConnection as any) const useConnectionStub = sandbox.stub(Auth.instance, 'useConnection').resolves() - const result = await authUtils.createAndUseConsoleConnection('test-profile', 'us-east-1') + await authUtils.setupConsoleConnection('test-profile', 'us-east-1') - assert.ok(executeCommandStub.calledWith('aws.toolkit.auth.consoleLogin', 'test-profile', 'us-east-1')) - assert.ok(useConnectionStub.calledWith({ id: 'profile:test-profile' })) - assert.strictEqual(result, mockConnection) + assert.ok(executeCommandStub.calledOnceWith('aws.toolkit.auth.consoleLogin', 'test-profile', 'us-east-1')) + assert.ok(useConnectionStub.calledOnceWith({ id: 'profile:test-profile' })) }) - it('throws ToolkitError when connection not found after console login', async function () { + it('throws error when useConnection fails', async function () { sandbox.stub(vscode.commands, 'executeCommand').resolves() - sandbox.stub(Auth.instance, 'getConnection').resolves(undefined) - - await assert.rejects( - () => authUtils.createAndUseConsoleConnection('test-profile', 'us-east-1'), - (err: Error) => { - assert.ok(err instanceof ToolkitError) - assert.strictEqual(err.code, 'NoConsoleConnection') - return true - } - ) + const error = new Error('useConnection failed') + sandbox.stub(Auth.instance, 'useConnection').rejects(error) + + await assert.rejects(() => authUtils.setupConsoleConnection('test-profile', 'us-east-1'), error) + }) + + it('throws error when console login command fails', async function () { + const error = new ToolkitError('Console login failed') + sandbox.stub(vscode.commands, 'executeCommand').rejects(error) + + await assert.rejects(() => authUtils.setupConsoleConnection('test-profile', 'us-east-1'), error) }) }) diff --git a/packages/core/src/test/lambda/commands/editLambda.test.ts b/packages/core/src/test/lambda/commands/editLambda.test.ts index c944d950020..66de6b52229 100644 --- a/packages/core/src/test/lambda/commands/editLambda.test.ts +++ b/packages/core/src/test/lambda/commands/editLambda.test.ts @@ -311,7 +311,7 @@ describe('editLambda', function () { describe('getFunctionWithFallback', function () { let getFunctionWithCredentialsStub: sinon.SinonStub - let createAndUseConsoleConnectionStub: sinon.SinonStub + let setupConsoleConnectionStub: sinon.SinonStub let showViewLogsMessageStub: sinon.SinonStub let getIAMConnectionStub: sinon.SinonStub const mockFunction = { Configuration: { FunctionName: 'test' } } @@ -325,7 +325,7 @@ describe('getFunctionWithFallback', function () { beforeEach(function () { getFunctionWithCredentialsStub = sinon.stub(lambdaClient, 'getFunctionWithCredentials') getIAMConnectionStub = sinon.stub(authUtils, 'getIAMConnection') - createAndUseConsoleConnectionStub = sinon.stub(authUtils, 'createAndUseConsoleConnection') + setupConsoleConnectionStub = sinon.stub(authUtils, 'setupConsoleConnection') showViewLogsMessageStub = sinon.stub(messages, 'showViewLogsMessage') }) @@ -333,17 +333,45 @@ describe('getFunctionWithFallback', function () { sinon.restore() }) - it('returns function when credentials are valid', async function () { + it('returns function when active connection exists and is valid', async function () { getFunctionWithCredentialsStub.resolves(mockFunction) getIAMConnectionStub.resolves(mockConnection) const result = await getFunctionWithFallback('test-function', 'us-east-1') assert.strictEqual(result, mockFunction) - assert(createAndUseConsoleConnectionStub.notCalled) + assert(setupConsoleConnectionStub.notCalled) }) - it('retries with console credentials on ResourceNotFoundException', async function () { + it('creates console connection when no active connection exists', async function () { + getFunctionWithCredentialsStub.resolves(mockFunction) + getIAMConnectionStub.resolves(undefined) + setupConsoleConnectionStub.resolves() + + const result = await getFunctionWithFallback('test-function', 'us-east-1') + + assert.strictEqual(result, mockFunction) + assert(setupConsoleConnectionStub.calledOnce) + assert(setupConsoleConnectionStub.calledWith('test-function', 'us-east-1')) + }) + + it('does not retry when console connection was just created and fails', async function () { + const error = new Error('Function not found') + error.name = 'ResourceNotFoundException' + getFunctionWithCredentialsStub.rejects(error) + getIAMConnectionStub.resolves(undefined) + setupConsoleConnectionStub.resolves() + + await assert.rejects( + () => getFunctionWithFallback('test-function', 'us-east-1'), + (err: Error) => err.message.includes('Failed to get Lambda function with console credentials') + ) + + assert(setupConsoleConnectionStub.calledOnce) + assert(showViewLogsMessageStub.notCalled) + }) + + it('retries with console credentials on ResourceNotFoundException with existing connection', async function () { const error = new Error('Function not found') error.name = 'ResourceNotFoundException' getFunctionWithCredentialsStub.onFirstCall().rejects(error) @@ -359,12 +387,13 @@ describe('getFunctionWithFallback', function () { const result = await getFunctionWithFallback('test-function', 'us-east-1') assert.strictEqual(result, mockFunction) - assert(createAndUseConsoleConnectionStub.calledWith('test-function', 'us-east-1')) + assert(setupConsoleConnectionStub.calledOnce) + assert(setupConsoleConnectionStub.calledWith('test-function', 'us-east-1')) assert(showViewLogsMessageStub.called) assert.ok(showViewLogsMessageStub.firstCall.args[0].includes('123456789')) }) - it('retries with console credentials on AccessDeniedException', async function () { + it('retries with console credentials on AccessDeniedException with existing connection', async function () { const error = new Error('User not authorized to perform: lambda:GetFunction on resource') error.name = 'AccessDeniedException' getFunctionWithCredentialsStub.onFirstCall().rejects(error) @@ -380,8 +409,26 @@ describe('getFunctionWithFallback', function () { const result = await getFunctionWithFallback('test-function', 'us-east-2') assert.strictEqual(result, mockFunction) - assert(createAndUseConsoleConnectionStub.calledWith('test-function', 'us-east-2')) + assert(setupConsoleConnectionStub.calledOnce) + assert(setupConsoleConnectionStub.calledWith('test-function', 'us-east-2')) assert(showViewLogsMessageStub.called) assert.ok(!showViewLogsMessageStub.firstCall.args[0].includes('987654321')) + assert.ok(showViewLogsMessageStub.firstCall.args[0].includes('Local credentials lack permission')) + }) + + it('throws error when setupConsoleConnection fails', async function () { + const setupError = new Error('Console setup failed') + getIAMConnectionStub.resolves(undefined) + setupConsoleConnectionStub.rejects(setupError) + + await assert.rejects( + () => getFunctionWithFallback('test-function', 'us-east-1'), + (err: Error) => err.message.includes('Console setup failed') + ) + + // Verify setupConsoleConnection was called only once (in the initial setup) + assert(setupConsoleConnectionStub.calledOnce) + // Verify getFunctionWithCredentials was never called since setup failed + assert(getFunctionWithCredentialsStub.notCalled) }) }) diff --git a/packages/core/src/test/login/webview/vue/backend_toolkit.test.ts b/packages/core/src/test/login/webview/vue/backend_toolkit.test.ts index 1980f9bd05d..a65448412b2 100644 --- a/packages/core/src/test/login/webview/vue/backend_toolkit.test.ts +++ b/packages/core/src/test/login/webview/vue/backend_toolkit.test.ts @@ -122,7 +122,7 @@ describe('Toolkit Login', function () { }) it('signs in with console credentials and emits telemetry', async function () { - const stub = sandbox.stub(authUtils, 'createAndUseConsoleConnection').resolves() + const stub = sandbox.stub(authUtils, 'setupConsoleConnection').resolves() await backend.startConsoleCredentialSetup(profileName, region) assert.ok(stub.calledOnceWith(profileName, region)) @@ -135,7 +135,7 @@ describe('Toolkit Login', function () { it('returns error when console credential setup fails', async function () { const error = new Error('Console login failed') - sandbox.stub(authUtils, 'createAndUseConsoleConnection').rejects(error) + sandbox.stub(authUtils, 'setupConsoleConnection').rejects(error) const result = await backend.startConsoleCredentialSetup(profileName, region) From 156f63de4067bfb1acb89c63e6060a1c93de0671 Mon Sep 17 00:00:00 2001 From: Keen Wilson <40321520+keenwilson@users.noreply.github.com> Date: Wed, 28 Jan 2026 23:46:28 -0800 Subject: [PATCH 07/11] simplify message --- packages/core/src/lambda/commands/editLambda.ts | 14 ++++---------- packages/core/src/shared/clients/lambdaClient.ts | 1 + .../src/test/lambda/commands/editLambda.test.ts | 10 +++++----- 3 files changed, 10 insertions(+), 15 deletions(-) diff --git a/packages/core/src/lambda/commands/editLambda.ts b/packages/core/src/lambda/commands/editLambda.ts index cad0c6e29a7..83552999a5c 100644 --- a/packages/core/src/lambda/commands/editLambda.ts +++ b/packages/core/src/lambda/commands/editLambda.ts @@ -243,12 +243,12 @@ export async function editLambda(lambda: LambdaFunction, source?: 'workspace' | * @returns Lambda function information with a link to download the deployment package */ export async function getFunctionWithFallback(name: string, region: string): Promise { - const connection = await getIAMConnection({ prompt: false }) + const activeConnection = await getIAMConnection({ prompt: false }) // Tracks if we've already attempted console credentials let calledConsoleLogin = false // If no connection, create console connection before first attempt - if (!connection) { + if (!activeConnection) { await setupConsoleConnection(name, region) calledConsoleLogin = true } @@ -258,14 +258,8 @@ export async function getFunctionWithFallback(name: string, region: string): Pro } catch (error: any) { // Detect credential mismatches (ResourceNotFoundException, AccessDeniedException) let message: string | undefined - if (error.name === 'ResourceNotFoundException' && connection?.type === 'iam') { - const credentials = await connection.getCredentials() - const accountId = (credentials as any).accountId - message = localize( - 'AWS.lambda.open.functionNotFound', - 'Function not found{0}.', - accountId ? ` in account ${accountId}` : '' - ) + if (error.name === 'ResourceNotFoundException') { + message = localize('AWS.lambda.open.functionNotFound', 'Function not found in current account.') } else if (error.name === 'AccessDeniedException') { message = localize('AWS.lambda.open.accessDenied', 'Local credentials lack permission to access function.') } diff --git a/packages/core/src/shared/clients/lambdaClient.ts b/packages/core/src/shared/clients/lambdaClient.ts index 11ce274c448..386d6aa1ef9 100644 --- a/packages/core/src/shared/clients/lambdaClient.ts +++ b/packages/core/src/shared/clients/lambdaClient.ts @@ -349,6 +349,7 @@ export async function getFunctionWithCredentials(region: string, name: string): const credentials = connection.type === 'iam' ? await connection.getCredentials() : fromSSO({ profile: connection.id }) + const client = new LambdaSdkClient({ region, credentials }) const command = new GetFunctionCommand({ FunctionName: name }) diff --git a/packages/core/src/test/lambda/commands/editLambda.test.ts b/packages/core/src/test/lambda/commands/editLambda.test.ts index 66de6b52229..ec9bbffb97a 100644 --- a/packages/core/src/test/lambda/commands/editLambda.test.ts +++ b/packages/core/src/test/lambda/commands/editLambda.test.ts @@ -356,15 +356,16 @@ describe('getFunctionWithFallback', function () { }) it('does not retry when console connection was just created and fails', async function () { - const error = new Error('Function not found') - error.name = 'ResourceNotFoundException' + const error = new Error('Console creds not working') + error.name = 'Error' getFunctionWithCredentialsStub.rejects(error) getIAMConnectionStub.resolves(undefined) setupConsoleConnectionStub.resolves() await assert.rejects( () => getFunctionWithFallback('test-function', 'us-east-1'), - (err: Error) => err.message.includes('Failed to get Lambda function with console credentials') + (err: Error) => + err.message.includes('Failed to get Lambda function with console credentials. Retry skipped.') ) assert(setupConsoleConnectionStub.calledOnce) @@ -390,7 +391,7 @@ describe('getFunctionWithFallback', function () { assert(setupConsoleConnectionStub.calledOnce) assert(setupConsoleConnectionStub.calledWith('test-function', 'us-east-1')) assert(showViewLogsMessageStub.called) - assert.ok(showViewLogsMessageStub.firstCall.args[0].includes('123456789')) + assert.ok(showViewLogsMessageStub.firstCall.args[0].includes('Function not found')) }) it('retries with console credentials on AccessDeniedException with existing connection', async function () { @@ -412,7 +413,6 @@ describe('getFunctionWithFallback', function () { assert(setupConsoleConnectionStub.calledOnce) assert(setupConsoleConnectionStub.calledWith('test-function', 'us-east-2')) assert(showViewLogsMessageStub.called) - assert.ok(!showViewLogsMessageStub.firstCall.args[0].includes('987654321')) assert.ok(showViewLogsMessageStub.firstCall.args[0].includes('Local credentials lack permission')) }) From 754255192e272b377a24794868bc219fbd51c819 Mon Sep 17 00:00:00 2001 From: Keen Wilson <40321520+keenwilson@users.noreply.github.com> Date: Thu, 29 Jan 2026 00:29:16 -0800 Subject: [PATCH 08/11] chore: trigger CI From edf8e3029595c9eae900f566c10c22d96d87f00c Mon Sep 17 00:00:00 2001 From: Keen Wilson <40321520+keenwilson@users.noreply.github.com> Date: Thu, 29 Jan 2026 08:30:05 -0800 Subject: [PATCH 09/11] chore: trigger CI From 2d1de8d1f06d016d434992c646b4248e983d8275 Mon Sep 17 00:00:00 2001 From: Keen Wilson <40321520+keenwilson@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:27:42 -0800 Subject: [PATCH 10/11] chore: trigger CI From d93d3aa18fa0029bc1829a0a74fea6a23b4ace48 Mon Sep 17 00:00:00 2001 From: Keen Wilson <40321520+keenwilson@users.noreply.github.com> Date: Thu, 29 Jan 2026 09:47:43 -0800 Subject: [PATCH 11/11] chore: trigger CI