From 12076ebe40d22bb4f7bf6d77c6cb891a3d0d9ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Fern=C3=A1ndez?= <71078418+dfernaro@users.noreply.github.com> Date: Sat, 3 Oct 2026 10:55:11 +0200 Subject: [PATCH 1/3] feat(status): show the active AWS account and profile `agentcore status` gave no indication of which AWS account the CLI is talking to, even though `dev`, `invoke` and `deploy` all call AWS with the active credentials. With several accounts and profiles this makes it easy to hit the wrong account without noticing. `status` now resolves the active credentials with STS GetCallerIdentity (reusing detectAccount) and shows the account and the AWS_PROFILE in use under the header. If the credentials belong to a different account than the selected deployment target, it shows a warning. If credentials are missing, expired or invalid, it says so instead of failing the command. The same information is returned in `--json` output as `awsIdentity` (`account`, `profile`, `error`) and `targetAccount`, and the status telemetry records `has_aws_credentials` and `aws_account_mismatch`. Closes #2009 --- docs/commands.md | 5 + .../commands/status/__tests__/action.test.ts | 99 ++++++++++++++++++- .../status/__tests__/aws-identity.test.tsx | 60 +++++++++++ .../commands/status/__tests__/command.test.ts | 32 ++++++ src/cli/commands/status/action.ts | 41 +++++++- src/cli/commands/status/aws-identity.tsx | 43 ++++++++ src/cli/commands/status/command.tsx | 22 ++++- src/cli/commands/status/constants.ts | 9 ++ src/cli/telemetry/schemas/command-run.ts | 7 +- 9 files changed, 311 insertions(+), 7 deletions(-) create mode 100644 src/cli/commands/status/__tests__/aws-identity.test.tsx create mode 100644 src/cli/commands/status/aws-identity.tsx diff --git a/docs/commands.md b/docs/commands.md index d804481c37..f72a347d21 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -144,6 +144,11 @@ agentcore status --runtime-id abc123 agentcore status --json ``` +The output starts with the AWS account of your active credentials and the `AWS_PROFILE` in use, so you can see which +account `dev`, `invoke`, and `deploy` will call. If those credentials belong to a different account than the selected +deployment target, `status` shows a warning. If no credentials can be resolved, it says so instead of failing. With +`--json`, this information is returned as `awsIdentity` (`account`, `profile`, `error`) and `targetAccount`. + | Flag | Description | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `--runtime-id ` | Look up a specific runtime by ID | diff --git a/src/cli/commands/status/__tests__/action.test.ts b/src/cli/commands/status/__tests__/action.test.ts index 99bf2954a9..a5f3dc82da 100644 --- a/src/cli/commands/status/__tests__/action.test.ts +++ b/src/cli/commands/status/__tests__/action.test.ts @@ -1,11 +1,13 @@ +import { AwsCredentialsError } from '../../../../lib/errors/types.js'; import type { AgentCoreProjectSpec, DeployedResourceState } from '../../../../schema/index.js'; -import { computeResourceStatuses, handleProjectStatus } from '../action.js'; +import { computeResourceStatuses, handleProjectStatus, resolveAwsIdentity } from '../action.js'; import type { ResourceStatusEntry, StatusContext } from '../action.js'; import { buildRuntimeInvocationUrl } from '../constants.js'; import assert from 'node:assert'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mockGetAgentRuntimeStatus = vi.fn(); +const mockDetectAccount = vi.fn(); const mockGetEvaluator = vi.fn(); const mockGetOnlineEvaluationConfig = vi.fn(); const mockGetKnowledgeBase = vi.fn(); @@ -15,6 +17,7 @@ const mockGetPaymentConnector = vi.fn(); vi.mock('../../../aws', () => ({ getAgentRuntimeStatus: (...args: unknown[]) => mockGetAgentRuntimeStatus(...args), + detectAccount: (...args: unknown[]) => mockDetectAccount(...args), })); vi.mock('../../../aws/agentcore-control', () => ({ @@ -1496,3 +1499,97 @@ describe('handleProjectStatus — invocation URL enrichment', () => { expect(pendingEntry!.deploymentState).toBe('pending-removal'); }); }); + +describe('resolveAwsIdentity', () => { + let originalProfile: string | undefined; + + beforeEach(() => { + originalProfile = process.env.AWS_PROFILE; + delete process.env.AWS_PROFILE; + mockDetectAccount.mockReset(); + }); + + afterEach(() => { + if (originalProfile === undefined) delete process.env.AWS_PROFILE; + else process.env.AWS_PROFILE = originalProfile; + }); + + it('returns the account and the AWS_PROFILE in use', async () => { + process.env.AWS_PROFILE = 'dev'; + mockDetectAccount.mockResolvedValue('123456789012'); + + expect(await resolveAwsIdentity()).toEqual({ account: '123456789012', profile: 'dev' }); + }); + + it('leaves profile undefined when AWS_PROFILE is not set', async () => { + mockDetectAccount.mockResolvedValue('123456789012'); + + expect(await resolveAwsIdentity()).toEqual({ account: '123456789012', profile: undefined }); + }); + + it('treats an empty AWS_PROFILE as unset', async () => { + process.env.AWS_PROFILE = ''; + mockDetectAccount.mockResolvedValue('123456789012'); + + expect((await resolveAwsIdentity()).profile).toBeUndefined(); + }); + + it('reports missing credentials without throwing', async () => { + mockDetectAccount.mockResolvedValue(null); + + expect(await resolveAwsIdentity()).toEqual({ profile: undefined, error: 'No AWS credentials found.' }); + }); + + it('uses the short message of an AwsCredentialsError', async () => { + process.env.AWS_PROFILE = 'expired'; + mockDetectAccount.mockRejectedValue( + new AwsCredentialsError('AWS credentials expired.', 'AWS credentials expired.\n\nTo fix this: ...') + ); + + expect(await resolveAwsIdentity()).toEqual({ profile: 'expired', error: 'AWS credentials expired.' }); + }); + + it('reports unexpected errors without throwing', async () => { + mockDetectAccount.mockRejectedValue(new Error('network down')); + + expect(await resolveAwsIdentity()).toEqual({ profile: undefined, error: 'network down' }); + }); +}); + +describe('handleProjectStatus — AWS identity', () => { + beforeEach(() => mockDetectAccount.mockReset()); + afterEach(() => vi.clearAllMocks()); + + it('returns the active AWS identity and the target account', async () => { + mockDetectAccount.mockResolvedValue('111111111111'); + const ctx = { + project: baseProject, + awsTargets: [{ name: 'dev', region: 'us-east-1', account: '222222222222' }], + deployedState: { targets: {} }, + } as unknown as StatusContext; + + const result = await handleProjectStatus(ctx); + + assert(result.success); + expect(result.awsIdentity?.account).toBe('111111111111'); + expect(result.targetAccount).toBe('222222222222'); + }); + + it('still succeeds when AWS credentials are unavailable', async () => { + mockDetectAccount.mockResolvedValue(null); + const ctx = { + project: baseProject, + awsTargets: [], + deployedState: { targets: {} }, + } as unknown as StatusContext; + + const result = await handleProjectStatus(ctx); + + assert(result.success); + expect(result.awsIdentity).toEqual({ + profile: process.env.AWS_PROFILE || undefined, + error: 'No AWS credentials found.', + }); + expect(result.targetAccount).toBeUndefined(); + }); +}); diff --git a/src/cli/commands/status/__tests__/aws-identity.test.tsx b/src/cli/commands/status/__tests__/aws-identity.test.tsx new file mode 100644 index 0000000000..c336732dc5 --- /dev/null +++ b/src/cli/commands/status/__tests__/aws-identity.test.tsx @@ -0,0 +1,60 @@ +import { AwsIdentityInfo } from '../aws-identity.js'; +import { isAccountMismatch } from '../constants.js'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +describe('isAccountMismatch', () => { + it('is true only when both accounts are known and differ', () => { + expect(isAccountMismatch({ account: '111111111111' }, '222222222222')).toBe(true); + expect(isAccountMismatch({ account: '111111111111' }, '111111111111')).toBe(false); + expect(isAccountMismatch({ account: '111111111111' }, undefined)).toBe(false); + expect(isAccountMismatch({ error: 'No AWS credentials found.' }, '222222222222')).toBe(false); + }); +}); + +describe('AwsIdentityInfo', () => { + it('renders nothing without an identity', () => { + const { lastFrame } = render(); + expect(lastFrame()).toBe(''); + }); + + it('shows the account and profile', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('AWS account: 111111111111 (profile: dev)'); + expect(lastFrame()).not.toContain('Warning'); + }); + + it('omits the profile when none is set', () => { + const { lastFrame } = render(); + expect(lastFrame()).toContain('AWS account: 111111111111'); + expect(lastFrame()).not.toContain('profile'); + }); + + it('warns when the credentials belong to a different account than the target', () => { + const { lastFrame } = render( + + ); + const frame = lastFrame()!.replace(/\s+/g, ' '); + expect(frame).toContain( + "Warning: your AWS credentials are for account 111111111111, but target 'prod' is configured for account 222222222222." + ); + }); + + it('explains why the account is unavailable', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('AWS account: unavailable (profile: expired) - AWS credentials expired.'); + expect(lastFrame()).not.toContain('Warning'); + }); +}); diff --git a/src/cli/commands/status/__tests__/command.test.ts b/src/cli/commands/status/__tests__/command.test.ts index 55521cc56e..d41ff2fe98 100644 --- a/src/cli/commands/status/__tests__/command.test.ts +++ b/src/cli/commands/status/__tests__/command.test.ts @@ -1,3 +1,4 @@ +import { withCommandRunTelemetry } from '../../../telemetry/cli-command-run.js'; import { handleProjectStatus, loadStatusConfig } from '../action.js'; import { registerStatus } from '../command.js'; import { Command } from '@commander-js/extra-typings'; @@ -74,4 +75,35 @@ describe('status command validation', () => { expect(process.exitCode).toBeUndefined(); }); + + it('records AWS identity telemetry when the credentials do not match the target account', async () => { + const recorder = { set: vi.fn(), get: vi.fn() }; + vi.mocked(withCommandRunTelemetry).mockImplementationOnce(async (_command, _attrs, run) => run(recorder as never)); + vi.mocked(loadStatusConfig).mockResolvedValue({} as never); + vi.mocked(handleProjectStatus).mockResolvedValue({ + success: true, + resources: [], + targetAccount: '222222222222', + awsIdentity: { account: '111111111111' }, + } as never); + + await program.parseAsync(['status'], { from: 'user' }); + + expect(recorder.set).toHaveBeenCalledWith({ has_aws_credentials: true, aws_account_mismatch: true }); + }); + + it('records missing AWS credentials in telemetry', async () => { + const recorder = { set: vi.fn(), get: vi.fn() }; + vi.mocked(withCommandRunTelemetry).mockImplementationOnce(async (_command, _attrs, run) => run(recorder as never)); + vi.mocked(loadStatusConfig).mockResolvedValue({} as never); + vi.mocked(handleProjectStatus).mockResolvedValue({ + success: true, + resources: [], + awsIdentity: { error: 'No AWS credentials found.' }, + } as never); + + await program.parseAsync(['status'], { from: 'user' }); + + expect(recorder.set).toHaveBeenCalledWith({ has_aws_credentials: false, aws_account_mismatch: false }); + }); }); diff --git a/src/cli/commands/status/action.ts b/src/cli/commands/status/action.ts index 3471f99414..afaea27491 100644 --- a/src/cli/commands/status/action.ts +++ b/src/cli/commands/status/action.ts @@ -1,7 +1,7 @@ -import { ConfigIO, ResourceNotFoundError, toError } from '../../../lib'; +import { AwsCredentialsError, ConfigIO, ResourceNotFoundError, toError } from '../../../lib'; import type { Result } from '../../../lib/result'; import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedResourceState, DeployedState } from '../../../schema'; -import { getAgentRuntimeStatus } from '../../aws'; +import { detectAccount, getAgentRuntimeStatus } from '../../aws'; import { getEvaluator, getOnlineEvaluationConfig } from '../../aws/agentcore-control'; import { getPaymentConnector, getPaymentManager } from '../../aws/agentcore-payments'; import { getKnowledgeBase, getLatestIngestionJob } from '../../aws/bedrock-agent'; @@ -54,8 +54,19 @@ export interface ResourceStatusEntry { paymentConnectors?: PaymentConnectorStatusEntry[]; } +export interface AwsIdentityStatus { + /** Account the active AWS credentials belong to, when they resolve. */ + account?: string; + /** Named profile from AWS_PROFILE, when set. */ + profile?: string; + /** Why the active credentials could not be resolved. */ + error?: string; +} + export type ProjectStatusResult = Result<{ targetRegion?: string; + targetAccount?: string; + awsIdentity?: AwsIdentityStatus; resources: ResourceStatusEntry[]; deployedState: DeployedState; }> & { projectName?: string; targetName?: string; logPath?: string; resources?: ResourceStatusEntry[] }; @@ -88,6 +99,21 @@ export async function loadStatusConfig(configIO: ConfigIO = new ConfigIO()): Pro return { project, deployedState, awsTargets }; } +/** + * Resolves the account behind the active AWS credentials, so status can show which + * account `dev`, `invoke` and `deploy` will call. Never throws: a credential problem is + * reported in `error` instead of failing the status command. + */ +export async function resolveAwsIdentity(): Promise { + const profile = process.env.AWS_PROFILE?.length ? process.env.AWS_PROFILE : undefined; + try { + const account = await detectAccount(); + return account ? { account, profile } : { profile, error: 'No AWS credentials found.' }; + } catch (error) { + return { profile, error: error instanceof AwsCredentialsError ? error.shortMessage : getErrorMessage(error) }; + } +} + /** * Diffs a set of local resources against deployed resources, producing status entries. * Shared logic for all resource types (agents, credentials, memories, gateways). @@ -398,6 +424,9 @@ export async function handleProjectStatus( const logger = new ExecLogger({ command: 'status' }); const { project, deployedState, awsTargets } = context; + // Resolve the active AWS identity in parallel with the local diff; it never rejects. + const awsIdentityPromise = resolveAwsIdentity(); + logger.startStep('Resolve target'); const deployedTargetNames = Object.keys(deployedState.targets); const targetNames = deployedTargetNames.length > 0 ? deployedTargetNames : awsTargets.map(t => t.name); @@ -795,6 +824,12 @@ export async function handleProjectStatus( } } + logger.startStep('Resolve AWS identity'); + const awsIdentity = await awsIdentityPromise; + logger.log(`Profile: ${awsIdentity.profile ?? '(default)'}`); + logger.log(`Account: ${awsIdentity.account ?? `(unavailable: ${awsIdentity.error})`}`); + logger.endStep(awsIdentity.account ? 'success' : 'warn'); + logger.finalize(true); return { success: true, @@ -803,6 +838,8 @@ export async function handleProjectStatus( targetRegion: targetConfig?.region, resources, deployedState, + targetAccount: targetConfig?.account, + awsIdentity, logPath: logger.getRelativeLogPath(), }; } diff --git a/src/cli/commands/status/aws-identity.tsx b/src/cli/commands/status/aws-identity.tsx new file mode 100644 index 0000000000..028a18f9e4 --- /dev/null +++ b/src/cli/commands/status/aws-identity.tsx @@ -0,0 +1,43 @@ +import type { AwsIdentityStatus } from './action'; +import { isAccountMismatch } from './constants'; +import { Box, Text } from 'ink'; + +/** + * Shows which AWS account (and profile) the active credentials resolve to, and warns + * when it differs from the selected deployment target's account. + */ +export function AwsIdentityInfo({ + identity, + targetName, + targetAccount, +}: { + identity?: AwsIdentityStatus; + targetName?: string; + targetAccount?: string; +}) { + if (!identity) return null; + const profile = identity.profile ? ` (profile: ${identity.profile})` : ''; + + if (!identity.account) { + return ( + + AWS account: unavailable{profile} - {identity.error} + + ); + } + + return ( + + + AWS account: {identity.account} + {profile} + + {isAccountMismatch(identity, targetAccount) && ( + + Warning: your AWS credentials are for account {identity.account}, but target '{targetName}' is + configured for account {targetAccount}. + + )} + + ); +} diff --git a/src/cli/commands/status/command.tsx b/src/cli/commands/status/command.tsx index a5a3928559..6fe2a9f61a 100644 --- a/src/cli/commands/status/command.tsx +++ b/src/cli/commands/status/command.tsx @@ -8,7 +8,8 @@ import { FilterState, FilterType, standardize } from '../../telemetry/schemas/co import { requireProject } from '../../tui/guards'; import type { ResourceStatusEntry } from './action'; import { handleProjectStatus, handleRuntimeLookup, loadStatusConfig } from './action'; -import { DEPLOYMENT_STATE_COLORS, DEPLOYMENT_STATE_LABELS } from './constants'; +import { AwsIdentityInfo } from './aws-identity'; +import { DEPLOYMENT_STATE_COLORS, DEPLOYMENT_STATE_LABELS, isAccountMismatch } from './constants'; import type { Command } from '@commander-js/extra-typings'; import { Box, Text, render } from 'ink'; @@ -148,14 +149,24 @@ export const registerStatus = (program: Command) => { } // Default path: show all resource types with deployment state - const result = await withCommandRunTelemetry('status', telemetryAttrs, async () => { + const result = await withCommandRunTelemetry('status', telemetryAttrs, async recorder => { const context = await loadStatusConfig(); // --name drives the KB drill-down (full block) vs the default summary // line. Scope it to KB filtering: only thread it when the user hasn't // narrowed to a different resource type. const knowledgeBaseName = cliOptions.name && (!cliOptions.type || cliOptions.type === 'knowledge-base') ? cliOptions.name : undefined; - return handleProjectStatus(context, { targetName: cliOptions.target, knowledgeBaseName }); + const statusResult = await handleProjectStatus(context, { + targetName: cliOptions.target, + knowledgeBaseName, + }); + if (statusResult.success && statusResult.awsIdentity) { + recorder.set({ + has_aws_credentials: !!statusResult.awsIdentity.account, + aws_account_mismatch: isAccountMismatch(statusResult.awsIdentity, statusResult.targetAccount), + }); + } + return statusResult; }); if (!result.success) { @@ -215,6 +226,11 @@ export const registerStatus = (program: Command) => { AgentCore Status (target: {result.targetName ?? 'No target configured'} {result.targetRegion ? `, ${result.targetRegion}` : ''}) + {agents.length > 0 && ( diff --git a/src/cli/commands/status/constants.ts b/src/cli/commands/status/constants.ts index f43522b439..d4cb844925 100644 --- a/src/cli/commands/status/constants.ts +++ b/src/cli/commands/status/constants.ts @@ -1,5 +1,6 @@ import { serviceEndpoint } from '../../aws/partition'; import { STATUS_COLORS } from '../../tui/theme'; +import type { AwsIdentityStatus } from './action'; export type ResourceDeploymentState = 'deployed' | 'local-only' | 'pending-removal'; @@ -19,3 +20,11 @@ export function buildRuntimeInvocationUrl(region: string, runtimeArn: string): s const encodedArn = encodeURIComponent(runtimeArn); return `https://${serviceEndpoint('bedrock-agentcore', region)}/runtimes/${encodedArn}/invocations`; } + +/** + * True when the active credentials resolve to a different account than the one the + * deployment target is configured for. + */ +export function isAccountMismatch(identity: AwsIdentityStatus, targetAccount?: string): boolean { + return !!identity.account && !!targetAccount && identity.account !== targetAccount; +} diff --git a/src/cli/telemetry/schemas/command-run.ts b/src/cli/telemetry/schemas/command-run.ts index bca7a0e9aa..d01302fcb7 100644 --- a/src/cli/telemetry/schemas/command-run.ts +++ b/src/cli/telemetry/schemas/command-run.ts @@ -188,7 +188,12 @@ const ExecAttrs = safeSchema({ was_kicked: z.boolean(), }); -const StatusAttrs = safeSchema({ filter_type: FilterType, filter_state: FilterState }); +const StatusAttrs = safeSchema({ + filter_type: FilterType, + filter_state: FilterState, + has_aws_credentials: z.boolean().optional(), + aws_account_mismatch: z.boolean().optional(), +}); const LogsAttrs = safeSchema({ has_query: z.boolean(), has_level_filter: z.boolean() }); From b5f10b1054bf37bbcf7008276d9c1231aebc4ccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Fern=C3=A1ndez?= <71078418+dfernaro@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:08:17 +0200 Subject: [PATCH 2/3] refactor(status): resolve AWS identity in the target region - Call STS in the deployment target's region, so credentials from other partitions (GovCloud, China) are not reported as invalid. detectAccount takes an optional region; existing callers are unchanged. - Move the identity logic to status/aws-identity.ts and the component to status/AwsIdentityInfo.tsx. This removes the type import cycle between constants.ts and action.ts and keeps constants.ts as on main. - Return AwsIdentityStatus as { success, error? } plus account/profile. - Trim comments to match the surrounding code. - Make the "credentials unavailable" test independent of AWS_PROFILE. --- docs/commands.md | 2 +- .../aws/__tests__/account-extended.test.ts | 13 ++- src/cli/aws/account.ts | 5 +- .../{aws-identity.tsx => AwsIdentityInfo.tsx} | 9 +- ...tity.test.tsx => AwsIdentityInfo.test.tsx} | 24 ++---- .../commands/status/__tests__/action.test.ts | 65 ++------------- .../status/__tests__/aws-identity.test.ts | 83 +++++++++++++++++++ .../commands/status/__tests__/command.test.ts | 4 +- src/cli/commands/status/action.ts | 35 ++------ src/cli/commands/status/aws-identity.ts | 33 ++++++++ src/cli/commands/status/command.tsx | 7 +- src/cli/commands/status/constants.ts | 9 -- 12 files changed, 163 insertions(+), 126 deletions(-) rename src/cli/commands/status/{aws-identity.tsx => AwsIdentityInfo.tsx} (75%) rename src/cli/commands/status/__tests__/{aws-identity.test.tsx => AwsIdentityInfo.test.tsx} (61%) create mode 100644 src/cli/commands/status/__tests__/aws-identity.test.ts create mode 100644 src/cli/commands/status/aws-identity.ts diff --git a/docs/commands.md b/docs/commands.md index f72a347d21..48618ea41f 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -147,7 +147,7 @@ agentcore status --json The output starts with the AWS account of your active credentials and the `AWS_PROFILE` in use, so you can see which account `dev`, `invoke`, and `deploy` will call. If those credentials belong to a different account than the selected deployment target, `status` shows a warning. If no credentials can be resolved, it says so instead of failing. With -`--json`, this information is returned as `awsIdentity` (`account`, `profile`, `error`) and `targetAccount`. +`--json`, this information is returned as `awsIdentity` (`success`, `account`, `profile`, `error`) and `targetAccount`. | Flag | Description | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/src/cli/aws/__tests__/account-extended.test.ts b/src/cli/aws/__tests__/account-extended.test.ts index 5f9a7376a2..2be60501d1 100644 --- a/src/cli/aws/__tests__/account-extended.test.ts +++ b/src/cli/aws/__tests__/account-extended.test.ts @@ -2,13 +2,17 @@ import { AwsCredentialsError, ValidationError } from '../../../lib/errors/types. import { detectAccount, getCredentialProvider, validateAwsCredentials } from '../account.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { mockSend } = vi.hoisted(() => ({ +const { mockSend, mockStsConfig } = vi.hoisted(() => ({ mockSend: vi.fn(), + mockStsConfig: vi.fn(), })); vi.mock('@aws-sdk/client-sts', () => ({ STSClient: class { send = mockSend; + constructor(config: unknown) { + mockStsConfig(config); + } }, GetCallerIdentityCommand: class { constructor(public input: unknown) {} @@ -51,6 +55,13 @@ describe('detectAccount', () => { expect(account).toBe('123456789012'); }); + it('calls STS in the given region instead of the environment region', async () => { + mockSend.mockResolvedValue({ Account: '123456789012' }); + + await detectAccount('us-gov-west-1'); + expect(mockStsConfig).toHaveBeenCalledWith(expect.objectContaining({ region: 'us-gov-west-1' })); + }); + it('returns null when Account is undefined', async () => { mockSend.mockResolvedValue({ Account: undefined }); diff --git a/src/cli/aws/account.ts b/src/cli/aws/account.ts index 5641b3c490..fbe3935a67 100644 --- a/src/cli/aws/account.ts +++ b/src/cli/aws/account.ts @@ -19,9 +19,10 @@ export function getCredentialProvider(): AwsCredentialIdentityProvider { * Get AWS account ID using STS GetCallerIdentity with detailed error handling. * Throws AwsCredentialsError with helpful messages for common credential issues. * Returns null only for unexpected errors (triggers generic "no credentials" message). + * Pass the deployment target's region so STS is called in the right partition (e.g. GovCloud). */ -export async function detectAccount(): Promise { - const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? 'us-east-1'; +export async function detectAccount(regionOverride?: string): Promise { + const region = regionOverride ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? 'us-east-1'; try { const client = new STSClient({ diff --git a/src/cli/commands/status/aws-identity.tsx b/src/cli/commands/status/AwsIdentityInfo.tsx similarity index 75% rename from src/cli/commands/status/aws-identity.tsx rename to src/cli/commands/status/AwsIdentityInfo.tsx index 028a18f9e4..83f209f44e 100644 --- a/src/cli/commands/status/aws-identity.tsx +++ b/src/cli/commands/status/AwsIdentityInfo.tsx @@ -1,11 +1,6 @@ -import type { AwsIdentityStatus } from './action'; -import { isAccountMismatch } from './constants'; +import { type AwsIdentityStatus, isAccountMismatch } from './aws-identity'; import { Box, Text } from 'ink'; -/** - * Shows which AWS account (and profile) the active credentials resolve to, and warns - * when it differs from the selected deployment target's account. - */ export function AwsIdentityInfo({ identity, targetName, @@ -18,7 +13,7 @@ export function AwsIdentityInfo({ if (!identity) return null; const profile = identity.profile ? ` (profile: ${identity.profile})` : ''; - if (!identity.account) { + if (!identity.success) { return ( AWS account: unavailable{profile} - {identity.error} diff --git a/src/cli/commands/status/__tests__/aws-identity.test.tsx b/src/cli/commands/status/__tests__/AwsIdentityInfo.test.tsx similarity index 61% rename from src/cli/commands/status/__tests__/aws-identity.test.tsx rename to src/cli/commands/status/__tests__/AwsIdentityInfo.test.tsx index c336732dc5..d282296202 100644 --- a/src/cli/commands/status/__tests__/aws-identity.test.tsx +++ b/src/cli/commands/status/__tests__/AwsIdentityInfo.test.tsx @@ -1,18 +1,8 @@ -import { AwsIdentityInfo } from '../aws-identity.js'; -import { isAccountMismatch } from '../constants.js'; +import { AwsIdentityInfo } from '../AwsIdentityInfo.js'; import { render } from 'ink-testing-library'; import React from 'react'; import { describe, expect, it } from 'vitest'; -describe('isAccountMismatch', () => { - it('is true only when both accounts are known and differ', () => { - expect(isAccountMismatch({ account: '111111111111' }, '222222222222')).toBe(true); - expect(isAccountMismatch({ account: '111111111111' }, '111111111111')).toBe(false); - expect(isAccountMismatch({ account: '111111111111' }, undefined)).toBe(false); - expect(isAccountMismatch({ error: 'No AWS credentials found.' }, '222222222222')).toBe(false); - }); -}); - describe('AwsIdentityInfo', () => { it('renders nothing without an identity', () => { const { lastFrame } = render(); @@ -22,7 +12,7 @@ describe('AwsIdentityInfo', () => { it('shows the account and profile', () => { const { lastFrame } = render( @@ -32,14 +22,18 @@ describe('AwsIdentityInfo', () => { }); it('omits the profile when none is set', () => { - const { lastFrame } = render(); + const { lastFrame } = render(); expect(lastFrame()).toContain('AWS account: 111111111111'); expect(lastFrame()).not.toContain('profile'); }); it('warns when the credentials belong to a different account than the target', () => { const { lastFrame } = render( - + ); const frame = lastFrame()!.replace(/\s+/g, ' '); expect(frame).toContain( @@ -50,7 +44,7 @@ describe('AwsIdentityInfo', () => { it('explains why the account is unavailable', () => { const { lastFrame } = render( ); diff --git a/src/cli/commands/status/__tests__/action.test.ts b/src/cli/commands/status/__tests__/action.test.ts index a5f3dc82da..9e1aeed543 100644 --- a/src/cli/commands/status/__tests__/action.test.ts +++ b/src/cli/commands/status/__tests__/action.test.ts @@ -1,6 +1,5 @@ -import { AwsCredentialsError } from '../../../../lib/errors/types.js'; import type { AgentCoreProjectSpec, DeployedResourceState } from '../../../../schema/index.js'; -import { computeResourceStatuses, handleProjectStatus, resolveAwsIdentity } from '../action.js'; +import { computeResourceStatuses, handleProjectStatus } from '../action.js'; import type { ResourceStatusEntry, StatusContext } from '../action.js'; import { buildRuntimeInvocationUrl } from '../constants.js'; import assert from 'node:assert'; @@ -1500,7 +1499,7 @@ describe('handleProjectStatus — invocation URL enrichment', () => { }); }); -describe('resolveAwsIdentity', () => { +describe('handleProjectStatus — AWS identity', () => { let originalProfile: string | undefined; beforeEach(() => { @@ -1512,66 +1511,22 @@ describe('resolveAwsIdentity', () => { afterEach(() => { if (originalProfile === undefined) delete process.env.AWS_PROFILE; else process.env.AWS_PROFILE = originalProfile; + vi.clearAllMocks(); }); - it('returns the account and the AWS_PROFILE in use', async () => { - process.env.AWS_PROFILE = 'dev'; - mockDetectAccount.mockResolvedValue('123456789012'); - - expect(await resolveAwsIdentity()).toEqual({ account: '123456789012', profile: 'dev' }); - }); - - it('leaves profile undefined when AWS_PROFILE is not set', async () => { - mockDetectAccount.mockResolvedValue('123456789012'); - - expect(await resolveAwsIdentity()).toEqual({ account: '123456789012', profile: undefined }); - }); - - it('treats an empty AWS_PROFILE as unset', async () => { - process.env.AWS_PROFILE = ''; - mockDetectAccount.mockResolvedValue('123456789012'); - - expect((await resolveAwsIdentity()).profile).toBeUndefined(); - }); - - it('reports missing credentials without throwing', async () => { - mockDetectAccount.mockResolvedValue(null); - - expect(await resolveAwsIdentity()).toEqual({ profile: undefined, error: 'No AWS credentials found.' }); - }); - - it('uses the short message of an AwsCredentialsError', async () => { - process.env.AWS_PROFILE = 'expired'; - mockDetectAccount.mockRejectedValue( - new AwsCredentialsError('AWS credentials expired.', 'AWS credentials expired.\n\nTo fix this: ...') - ); - - expect(await resolveAwsIdentity()).toEqual({ profile: 'expired', error: 'AWS credentials expired.' }); - }); - - it('reports unexpected errors without throwing', async () => { - mockDetectAccount.mockRejectedValue(new Error('network down')); - - expect(await resolveAwsIdentity()).toEqual({ profile: undefined, error: 'network down' }); - }); -}); - -describe('handleProjectStatus — AWS identity', () => { - beforeEach(() => mockDetectAccount.mockReset()); - afterEach(() => vi.clearAllMocks()); - - it('returns the active AWS identity and the target account', async () => { + it('returns the active AWS identity and the target account, resolved in the target region', async () => { mockDetectAccount.mockResolvedValue('111111111111'); const ctx = { project: baseProject, - awsTargets: [{ name: 'dev', region: 'us-east-1', account: '222222222222' }], + awsTargets: [{ name: 'gov', region: 'us-gov-west-1', account: '222222222222' }], deployedState: { targets: {} }, } as unknown as StatusContext; const result = await handleProjectStatus(ctx); assert(result.success); - expect(result.awsIdentity?.account).toBe('111111111111'); + expect(mockDetectAccount).toHaveBeenCalledWith('us-gov-west-1'); + expect(result.awsIdentity).toEqual({ success: true, account: '111111111111', profile: undefined }); expect(result.targetAccount).toBe('222222222222'); }); @@ -1586,10 +1541,8 @@ describe('handleProjectStatus — AWS identity', () => { const result = await handleProjectStatus(ctx); assert(result.success); - expect(result.awsIdentity).toEqual({ - profile: process.env.AWS_PROFILE || undefined, - error: 'No AWS credentials found.', - }); + expect(mockDetectAccount).toHaveBeenCalledWith(undefined); + expect(result.awsIdentity).toEqual({ success: false, profile: undefined, error: 'No AWS credentials found.' }); expect(result.targetAccount).toBeUndefined(); }); }); diff --git a/src/cli/commands/status/__tests__/aws-identity.test.ts b/src/cli/commands/status/__tests__/aws-identity.test.ts new file mode 100644 index 0000000000..fe1d2710d2 --- /dev/null +++ b/src/cli/commands/status/__tests__/aws-identity.test.ts @@ -0,0 +1,83 @@ +import { AwsCredentialsError } from '../../../../lib/errors/types.js'; +import { isAccountMismatch, resolveAwsIdentity } from '../aws-identity.js'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockDetectAccount = vi.fn(); + +vi.mock('../../../aws', () => ({ + detectAccount: (...args: unknown[]) => mockDetectAccount(...args), +})); + +describe('resolveAwsIdentity', () => { + let originalProfile: string | undefined; + + beforeEach(() => { + originalProfile = process.env.AWS_PROFILE; + delete process.env.AWS_PROFILE; + mockDetectAccount.mockReset(); + }); + + afterEach(() => { + if (originalProfile === undefined) delete process.env.AWS_PROFILE; + else process.env.AWS_PROFILE = originalProfile; + }); + + it('returns the account and the AWS_PROFILE in use', async () => { + process.env.AWS_PROFILE = 'dev'; + mockDetectAccount.mockResolvedValue('123456789012'); + + expect(await resolveAwsIdentity()).toEqual({ success: true, account: '123456789012', profile: 'dev' }); + }); + + it('calls STS in the given region', async () => { + mockDetectAccount.mockResolvedValue('123456789012'); + + await resolveAwsIdentity('cn-north-1'); + expect(mockDetectAccount).toHaveBeenCalledWith('cn-north-1'); + }); + + it('treats an empty AWS_PROFILE as unset', async () => { + process.env.AWS_PROFILE = ''; + mockDetectAccount.mockResolvedValue('123456789012'); + + expect((await resolveAwsIdentity()).profile).toBeUndefined(); + }); + + it('reports missing credentials without throwing', async () => { + mockDetectAccount.mockResolvedValue(null); + + expect(await resolveAwsIdentity()).toEqual({ + success: false, + profile: undefined, + error: 'No AWS credentials found.', + }); + }); + + it('uses the short message of an AwsCredentialsError', async () => { + process.env.AWS_PROFILE = 'expired'; + mockDetectAccount.mockRejectedValue( + new AwsCredentialsError('AWS credentials expired.', 'AWS credentials expired.\n\nTo fix this: ...') + ); + + expect(await resolveAwsIdentity()).toEqual({ + success: false, + profile: 'expired', + error: 'AWS credentials expired.', + }); + }); + + it('reports unexpected errors without throwing', async () => { + mockDetectAccount.mockRejectedValue(new Error('network down')); + + expect(await resolveAwsIdentity()).toEqual({ success: false, profile: undefined, error: 'network down' }); + }); +}); + +describe('isAccountMismatch', () => { + it('is true only when both accounts are known and differ', () => { + expect(isAccountMismatch({ success: true, account: '111111111111' }, '222222222222')).toBe(true); + expect(isAccountMismatch({ success: true, account: '111111111111' }, '111111111111')).toBe(false); + expect(isAccountMismatch({ success: true, account: '111111111111' }, undefined)).toBe(false); + expect(isAccountMismatch({ success: false, error: 'No AWS credentials found.' }, '222222222222')).toBe(false); + }); +}); diff --git a/src/cli/commands/status/__tests__/command.test.ts b/src/cli/commands/status/__tests__/command.test.ts index d41ff2fe98..ecbb6f0c22 100644 --- a/src/cli/commands/status/__tests__/command.test.ts +++ b/src/cli/commands/status/__tests__/command.test.ts @@ -84,7 +84,7 @@ describe('status command validation', () => { success: true, resources: [], targetAccount: '222222222222', - awsIdentity: { account: '111111111111' }, + awsIdentity: { success: true, account: '111111111111' }, } as never); await program.parseAsync(['status'], { from: 'user' }); @@ -99,7 +99,7 @@ describe('status command validation', () => { vi.mocked(handleProjectStatus).mockResolvedValue({ success: true, resources: [], - awsIdentity: { error: 'No AWS credentials found.' }, + awsIdentity: { success: false, error: 'No AWS credentials found.' }, } as never); await program.parseAsync(['status'], { from: 'user' }); diff --git a/src/cli/commands/status/action.ts b/src/cli/commands/status/action.ts index afaea27491..e415d061d3 100644 --- a/src/cli/commands/status/action.ts +++ b/src/cli/commands/status/action.ts @@ -1,12 +1,13 @@ -import { AwsCredentialsError, ConfigIO, ResourceNotFoundError, toError } from '../../../lib'; +import { ConfigIO, ResourceNotFoundError, toError } from '../../../lib'; import type { Result } from '../../../lib/result'; import type { AgentCoreProjectSpec, AwsDeploymentTargets, DeployedResourceState, DeployedState } from '../../../schema'; -import { detectAccount, getAgentRuntimeStatus } from '../../aws'; +import { getAgentRuntimeStatus } from '../../aws'; import { getEvaluator, getOnlineEvaluationConfig } from '../../aws/agentcore-control'; import { getPaymentConnector, getPaymentManager } from '../../aws/agentcore-payments'; import { getKnowledgeBase, getLatestIngestionJob } from '../../aws/bedrock-agent'; import { getErrorMessage } from '../../errors'; import { ExecLogger } from '../../logging'; +import { type AwsIdentityStatus, resolveAwsIdentity } from './aws-identity'; import type { ResourceDeploymentState } from './constants'; import { buildRuntimeInvocationUrl } from './constants'; import { @@ -54,15 +55,6 @@ export interface ResourceStatusEntry { paymentConnectors?: PaymentConnectorStatusEntry[]; } -export interface AwsIdentityStatus { - /** Account the active AWS credentials belong to, when they resolve. */ - account?: string; - /** Named profile from AWS_PROFILE, when set. */ - profile?: string; - /** Why the active credentials could not be resolved. */ - error?: string; -} - export type ProjectStatusResult = Result<{ targetRegion?: string; targetAccount?: string; @@ -99,21 +91,6 @@ export async function loadStatusConfig(configIO: ConfigIO = new ConfigIO()): Pro return { project, deployedState, awsTargets }; } -/** - * Resolves the account behind the active AWS credentials, so status can show which - * account `dev`, `invoke` and `deploy` will call. Never throws: a credential problem is - * reported in `error` instead of failing the status command. - */ -export async function resolveAwsIdentity(): Promise { - const profile = process.env.AWS_PROFILE?.length ? process.env.AWS_PROFILE : undefined; - try { - const account = await detectAccount(); - return account ? { account, profile } : { profile, error: 'No AWS credentials found.' }; - } catch (error) { - return { profile, error: error instanceof AwsCredentialsError ? error.shortMessage : getErrorMessage(error) }; - } -} - /** * Diffs a set of local resources against deployed resources, producing status entries. * Shared logic for all resource types (agents, credentials, memories, gateways). @@ -424,9 +401,6 @@ export async function handleProjectStatus( const logger = new ExecLogger({ command: 'status' }); const { project, deployedState, awsTargets } = context; - // Resolve the active AWS identity in parallel with the local diff; it never rejects. - const awsIdentityPromise = resolveAwsIdentity(); - logger.startStep('Resolve target'); const deployedTargetNames = Object.keys(deployedState.targets); const targetNames = deployedTargetNames.length > 0 ? deployedTargetNames : awsTargets.map(t => t.name); @@ -456,6 +430,7 @@ export async function handleProjectStatus( logger.startStep('Compute resource statuses'); const targetConfig = selectedTargetName ? awsTargets.find(t => t.name === selectedTargetName) : undefined; + const awsIdentityPromise = resolveAwsIdentity(targetConfig?.region); const targetResources = selectedTargetName ? deployedState.targets[selectedTargetName]?.resources : undefined; const resources = computeResourceStatuses(project, targetResources); @@ -828,7 +803,7 @@ export async function handleProjectStatus( const awsIdentity = await awsIdentityPromise; logger.log(`Profile: ${awsIdentity.profile ?? '(default)'}`); logger.log(`Account: ${awsIdentity.account ?? `(unavailable: ${awsIdentity.error})`}`); - logger.endStep(awsIdentity.account ? 'success' : 'warn'); + logger.endStep(awsIdentity.success ? 'success' : 'warn'); logger.finalize(true); return { diff --git a/src/cli/commands/status/aws-identity.ts b/src/cli/commands/status/aws-identity.ts new file mode 100644 index 0000000000..e196b3de81 --- /dev/null +++ b/src/cli/commands/status/aws-identity.ts @@ -0,0 +1,33 @@ +import { AwsCredentialsError } from '../../../lib'; +import { detectAccount } from '../../aws'; +import { getErrorMessage } from '../../errors'; + +export interface AwsIdentityStatus { + success: boolean; + account?: string; + profile?: string; + error?: string; +} + +/** + * Resolves the account of the active AWS credentials. Never throws: credential problems are returned in `error`. + */ +export async function resolveAwsIdentity(region?: string): Promise { + const profile = process.env.AWS_PROFILE?.length ? process.env.AWS_PROFILE : undefined; + try { + const account = await detectAccount(region); + return account + ? { success: true, account, profile } + : { success: false, profile, error: 'No AWS credentials found.' }; + } catch (error) { + return { + success: false, + profile, + error: error instanceof AwsCredentialsError ? error.shortMessage : getErrorMessage(error), + }; + } +} + +export function isAccountMismatch(identity: AwsIdentityStatus, targetAccount?: string): boolean { + return !!identity.account && !!targetAccount && identity.account !== targetAccount; +} diff --git a/src/cli/commands/status/command.tsx b/src/cli/commands/status/command.tsx index 6fe2a9f61a..8eb2f7a061 100644 --- a/src/cli/commands/status/command.tsx +++ b/src/cli/commands/status/command.tsx @@ -6,10 +6,11 @@ import type { DatasetStatusResult } from '../../operations/dataset'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; import { FilterState, FilterType, standardize } from '../../telemetry/schemas/common-shapes.js'; import { requireProject } from '../../tui/guards'; +import { AwsIdentityInfo } from './AwsIdentityInfo'; import type { ResourceStatusEntry } from './action'; import { handleProjectStatus, handleRuntimeLookup, loadStatusConfig } from './action'; -import { AwsIdentityInfo } from './aws-identity'; -import { DEPLOYMENT_STATE_COLORS, DEPLOYMENT_STATE_LABELS, isAccountMismatch } from './constants'; +import { isAccountMismatch } from './aws-identity'; +import { DEPLOYMENT_STATE_COLORS, DEPLOYMENT_STATE_LABELS } from './constants'; import type { Command } from '@commander-js/extra-typings'; import { Box, Text, render } from 'ink'; @@ -162,7 +163,7 @@ export const registerStatus = (program: Command) => { }); if (statusResult.success && statusResult.awsIdentity) { recorder.set({ - has_aws_credentials: !!statusResult.awsIdentity.account, + has_aws_credentials: statusResult.awsIdentity.success, aws_account_mismatch: isAccountMismatch(statusResult.awsIdentity, statusResult.targetAccount), }); } diff --git a/src/cli/commands/status/constants.ts b/src/cli/commands/status/constants.ts index d4cb844925..f43522b439 100644 --- a/src/cli/commands/status/constants.ts +++ b/src/cli/commands/status/constants.ts @@ -1,6 +1,5 @@ import { serviceEndpoint } from '../../aws/partition'; import { STATUS_COLORS } from '../../tui/theme'; -import type { AwsIdentityStatus } from './action'; export type ResourceDeploymentState = 'deployed' | 'local-only' | 'pending-removal'; @@ -20,11 +19,3 @@ export function buildRuntimeInvocationUrl(region: string, runtimeArn: string): s const encodedArn = encodeURIComponent(runtimeArn); return `https://${serviceEndpoint('bedrock-agentcore', region)}/runtimes/${encodedArn}/invocations`; } - -/** - * True when the active credentials resolve to a different account than the one the - * deployment target is configured for. - */ -export function isAccountMismatch(identity: AwsIdentityStatus, targetAccount?: string): boolean { - return !!identity.account && !!targetAccount && identity.account !== targetAccount; -} From 808e5077ee9b39c3607902e259d973aa1a752a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?David=20Fern=C3=A1ndez?= <71078418+dfernaro@users.noreply.github.com> Date: Sat, 3 Oct 2026 11:32:18 +0200 Subject: [PATCH 3/3] fix(status): bound the AWS identity lookup and report its source - Give the STS lookup in `status` a single 3s attempt, so an unreachable network no longer leaves the command hanging. A timeout is reported as "Timed out resolving the AWS account after 3s." detectAccount now takes { region, timeoutMs }; callers without options keep the SDK defaults. - Show "credentials from environment variables" when access keys in the environment take precedence, instead of an AWS_PROFILE that is not used. - Resolve the identity only when handleProjectStatus is asked to (includeAwsIdentity), so the TUI status screen behaves as on main. - Rename the telemetry attribute has_aws_credentials to aws_identity_resolved, since it is also false for invalid credentials. --- docs/commands.md | 10 ++-- .../aws/__tests__/account-extended.test.ts | 51 ++++++++++++++++- src/cli/aws/account.ts | 26 +++++++-- src/cli/aws/index.ts | 2 +- src/cli/commands/status/AwsIdentityInfo.tsx | 11 +++- .../status/__tests__/AwsIdentityInfo.test.tsx | 17 ++++-- .../commands/status/__tests__/action.test.ts | 31 ++++++++-- .../status/__tests__/aws-identity.test.ts | 56 +++++++++++++++---- .../commands/status/__tests__/command.test.ts | 12 ++-- src/cli/commands/status/action.ts | 17 +++--- src/cli/commands/status/aws-identity.ts | 15 +++-- src/cli/commands/status/command.tsx | 3 +- src/cli/commands/status/constants.ts | 3 + src/cli/telemetry/schemas/command-run.ts | 2 +- 14 files changed, 201 insertions(+), 55 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 48618ea41f..3205bb2cfe 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -144,10 +144,12 @@ agentcore status --runtime-id abc123 agentcore status --json ``` -The output starts with the AWS account of your active credentials and the `AWS_PROFILE` in use, so you can see which -account `dev`, `invoke`, and `deploy` will call. If those credentials belong to a different account than the selected -deployment target, `status` shows a warning. If no credentials can be resolved, it says so instead of failing. With -`--json`, this information is returned as `awsIdentity` (`success`, `account`, `profile`, `error`) and `targetAccount`. +The output starts with the AWS account of your active credentials and where they come from (`AWS_PROFILE` or environment +variables), so you can see which account `dev`, `invoke`, and `deploy` will call. If those credentials belong to a +different account than the selected deployment target, `status` shows a warning. If the account cannot be resolved (no +credentials, invalid credentials, or no response from AWS within a few seconds), it says so instead of failing. With +`--json`, this information is returned as `awsIdentity` (`success`, `account`, `profile`, `fromEnvironment`, `error`) +and `targetAccount`. | Flag | Description | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/src/cli/aws/__tests__/account-extended.test.ts b/src/cli/aws/__tests__/account-extended.test.ts index 2be60501d1..4dc03061ea 100644 --- a/src/cli/aws/__tests__/account-extended.test.ts +++ b/src/cli/aws/__tests__/account-extended.test.ts @@ -1,5 +1,5 @@ -import { AwsCredentialsError, ValidationError } from '../../../lib/errors/types.js'; -import { detectAccount, getCredentialProvider, validateAwsCredentials } from '../account.js'; +import { AwsCredentialsError, TimeoutError, ValidationError } from '../../../lib/errors/types.js'; +import { detectAccount, getCredentialProvider, hasEnvCredentials, validateAwsCredentials } from '../account.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const { mockSend, mockStsConfig } = vi.hoisted(() => ({ @@ -43,6 +43,23 @@ describe('getCredentialProvider', () => { }); }); +describe('hasEnvCredentials', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + it('is true only when both access key variables are set', () => { + process.env.AWS_ACCESS_KEY_ID = 'AKIAIOSFODNN7EXAMPLE'; + process.env.AWS_SECRET_ACCESS_KEY = 'secret'; + expect(hasEnvCredentials()).toBe(true); + + delete process.env.AWS_SECRET_ACCESS_KEY; + expect(hasEnvCredentials()).toBe(false); + }); +}); + describe('detectAccount', () => { beforeEach(() => { vi.clearAllMocks(); @@ -58,10 +75,38 @@ describe('detectAccount', () => { it('calls STS in the given region instead of the environment region', async () => { mockSend.mockResolvedValue({ Account: '123456789012' }); - await detectAccount('us-gov-west-1'); + await detectAccount({ region: 'us-gov-west-1' }); expect(mockStsConfig).toHaveBeenCalledWith(expect.objectContaining({ region: 'us-gov-west-1' })); }); + it('keeps the SDK retry and timeout defaults when no timeout is given', async () => { + mockSend.mockResolvedValue({ Account: '123456789012' }); + + await detectAccount(); + const config = mockStsConfig.mock.calls[0]![0] as Record; + expect(config).not.toHaveProperty('maxAttempts'); + expect(config).not.toHaveProperty('requestHandler'); + }); + + it('makes a single bounded attempt when a timeout is given', async () => { + mockSend.mockResolvedValue({ Account: '123456789012' }); + + await detectAccount({ timeoutMs: 3000 }); + expect(mockStsConfig).toHaveBeenCalledWith( + expect.objectContaining({ + maxAttempts: 1, + requestHandler: { connectionTimeout: 3000, requestTimeout: 3000, throwOnRequestTimeout: true }, + }) + ); + }); + + it('throws TimeoutError when the bounded attempt times out', async () => { + mockSend.mockRejectedValue(makeNamedError('socket timed out', 'TimeoutError')); + + await expect(detectAccount({ timeoutMs: 3000 })).rejects.toThrow(TimeoutError); + await expect(detectAccount({ timeoutMs: 3000 })).rejects.toThrow('Timed out resolving the AWS account after 3s.'); + }); + it('returns null when Account is undefined', async () => { mockSend.mockResolvedValue({ Account: undefined }); diff --git a/src/cli/aws/account.ts b/src/cli/aws/account.ts index fbe3935a67..b6c6a095b9 100644 --- a/src/cli/aws/account.ts +++ b/src/cli/aws/account.ts @@ -1,4 +1,4 @@ -import { AwsCredentialsError, ValidationError } from '../../lib/errors/types.js'; +import { AwsCredentialsError, TimeoutError, ValidationError } from '../../lib/errors/types.js'; import type { AwsDeploymentTarget } from '../../schema'; import { getAwsLoginGuidance } from '../external-requirements/checks'; import { GetCallerIdentityCommand, STSClient } from '@aws-sdk/client-sts'; @@ -11,29 +11,43 @@ import type { AwsCredentialIdentityProvider } from '@smithy/types'; * This ensures proper credential resolution without requiring ~/.aws directory. */ export function getCredentialProvider(): AwsCredentialIdentityProvider { - const hasEnvCreds = process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY; - return hasEnvCreds ? fromEnv() : fromNodeProviderChain(); + return hasEnvCredentials() ? fromEnv() : fromNodeProviderChain(); +} + +/** True when static access keys in the environment take precedence over AWS_PROFILE. */ +export function hasEnvCredentials(): boolean { + return !!(process.env.AWS_ACCESS_KEY_ID && process.env.AWS_SECRET_ACCESS_KEY); } /** * Get AWS account ID using STS GetCallerIdentity with detailed error handling. * Throws AwsCredentialsError with helpful messages for common credential issues. * Returns null only for unexpected errors (triggers generic "no credentials" message). - * Pass the deployment target's region so STS is called in the right partition (e.g. GovCloud). + * Pass the deployment target's region so STS is called in the right partition (e.g. GovCloud), and + * `timeoutMs` to make a single bounded attempt that throws TimeoutError instead of waiting on the network. */ -export async function detectAccount(regionOverride?: string): Promise { - const region = regionOverride ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? 'us-east-1'; +export async function detectAccount(options: { region?: string; timeoutMs?: number } = {}): Promise { + const { timeoutMs } = options; + const region = options.region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? 'us-east-1'; try { const client = new STSClient({ credentials: getCredentialProvider(), region, + ...(timeoutMs !== undefined && { + maxAttempts: 1, + requestHandler: { connectionTimeout: timeoutMs, requestTimeout: timeoutMs, throwOnRequestTimeout: true }, + }), }); const response = await client.send(new GetCallerIdentityCommand({})); return response.Account ?? null; } catch (err) { const code = (err as { name?: string })?.name ?? (err as { Code?: string })?.Code; + if (code === 'TimeoutError' && timeoutMs !== undefined) { + throw new TimeoutError(`Timed out resolving the AWS account after ${timeoutMs / 1000}s.`, { cause: err }); + } + if (code === 'ExpiredTokenException' || code === 'ExpiredToken') { const guidance = await getAwsLoginGuidance(); throw new AwsCredentialsError( diff --git a/src/cli/aws/index.ts b/src/cli/aws/index.ts index 09851e6782..b0ec3cf958 100644 --- a/src/cli/aws/index.ts +++ b/src/cli/aws/index.ts @@ -1,5 +1,5 @@ export { detectAwsContext, type AwsContext } from './aws-context'; -export { detectAccount, getCredentialProvider } from './account'; +export { detectAccount, getCredentialProvider, hasEnvCredentials } from './account'; export { getPartition, arnPrefix, dnsSuffix, serviceEndpoint, consoleDomain } from './partition'; export { detectRegion, type RegionDetectionResult } from './region'; export { applyTargetRegionToEnv, withTargetRegion } from './target-region'; diff --git a/src/cli/commands/status/AwsIdentityInfo.tsx b/src/cli/commands/status/AwsIdentityInfo.tsx index 83f209f44e..e8a4fa93ee 100644 --- a/src/cli/commands/status/AwsIdentityInfo.tsx +++ b/src/cli/commands/status/AwsIdentityInfo.tsx @@ -1,6 +1,11 @@ import { type AwsIdentityStatus, isAccountMismatch } from './aws-identity'; import { Box, Text } from 'ink'; +function describeCredentialSource(identity: AwsIdentityStatus): string { + if (identity.fromEnvironment) return ' (credentials from environment variables)'; + return identity.profile ? ` (profile: ${identity.profile})` : ''; +} + export function AwsIdentityInfo({ identity, targetName, @@ -11,12 +16,12 @@ export function AwsIdentityInfo({ targetAccount?: string; }) { if (!identity) return null; - const profile = identity.profile ? ` (profile: ${identity.profile})` : ''; + const source = describeCredentialSource(identity); if (!identity.success) { return ( - AWS account: unavailable{profile} - {identity.error} + AWS account: unavailable{source} - {identity.error} ); } @@ -25,7 +30,7 @@ export function AwsIdentityInfo({ AWS account: {identity.account} - {profile} + {source} {isAccountMismatch(identity, targetAccount) && ( diff --git a/src/cli/commands/status/__tests__/AwsIdentityInfo.test.tsx b/src/cli/commands/status/__tests__/AwsIdentityInfo.test.tsx index d282296202..f40a27834b 100644 --- a/src/cli/commands/status/__tests__/AwsIdentityInfo.test.tsx +++ b/src/cli/commands/status/__tests__/AwsIdentityInfo.test.tsx @@ -12,7 +12,7 @@ describe('AwsIdentityInfo', () => { it('shows the account and profile', () => { const { lastFrame } = render( @@ -22,15 +22,24 @@ describe('AwsIdentityInfo', () => { }); it('omits the profile when none is set', () => { - const { lastFrame } = render(); + const { lastFrame } = render( + + ); expect(lastFrame()).toContain('AWS account: 111111111111'); expect(lastFrame()).not.toContain('profile'); }); + it('says when credentials come from environment variables', () => { + const { lastFrame } = render( + + ); + expect(lastFrame()).toContain('AWS account: 111111111111 (credentials from environment variables)'); + }); + it('warns when the credentials belong to a different account than the target', () => { const { lastFrame } = render( @@ -44,7 +53,7 @@ describe('AwsIdentityInfo', () => { it('explains why the account is unavailable', () => { const { lastFrame } = render( ); diff --git a/src/cli/commands/status/__tests__/action.test.ts b/src/cli/commands/status/__tests__/action.test.ts index 9e1aeed543..b8aa7fc64d 100644 --- a/src/cli/commands/status/__tests__/action.test.ts +++ b/src/cli/commands/status/__tests__/action.test.ts @@ -17,6 +17,7 @@ const mockGetPaymentConnector = vi.fn(); vi.mock('../../../aws', () => ({ getAgentRuntimeStatus: (...args: unknown[]) => mockGetAgentRuntimeStatus(...args), detectAccount: (...args: unknown[]) => mockDetectAccount(...args), + hasEnvCredentials: () => false, })); vi.mock('../../../aws/agentcore-control', () => ({ @@ -1522,11 +1523,16 @@ describe('handleProjectStatus — AWS identity', () => { deployedState: { targets: {} }, } as unknown as StatusContext; - const result = await handleProjectStatus(ctx); + const result = await handleProjectStatus(ctx, { includeAwsIdentity: true }); assert(result.success); - expect(mockDetectAccount).toHaveBeenCalledWith('us-gov-west-1'); - expect(result.awsIdentity).toEqual({ success: true, account: '111111111111', profile: undefined }); + expect(mockDetectAccount).toHaveBeenCalledWith(expect.objectContaining({ region: 'us-gov-west-1' })); + expect(result.awsIdentity).toEqual({ + success: true, + account: '111111111111', + profile: undefined, + fromEnvironment: false, + }); expect(result.targetAccount).toBe('222222222222'); }); @@ -1538,11 +1544,24 @@ describe('handleProjectStatus — AWS identity', () => { deployedState: { targets: {} }, } as unknown as StatusContext; - const result = await handleProjectStatus(ctx); + const result = await handleProjectStatus(ctx, { includeAwsIdentity: true }); assert(result.success); - expect(mockDetectAccount).toHaveBeenCalledWith(undefined); - expect(result.awsIdentity).toEqual({ success: false, profile: undefined, error: 'No AWS credentials found.' }); + expect(result.awsIdentity).toMatchObject({ success: false, error: 'No AWS credentials found.' }); expect(result.targetAccount).toBeUndefined(); }); + + it('does not call AWS for the identity unless asked to', async () => { + const ctx = { + project: baseProject, + awsTargets: [{ name: 'dev', region: 'us-east-1', account: '222222222222' }], + deployedState: { targets: {} }, + } as unknown as StatusContext; + + const result = await handleProjectStatus(ctx); + + assert(result.success); + expect(mockDetectAccount).not.toHaveBeenCalled(); + expect(result.awsIdentity).toBeUndefined(); + }); }); diff --git a/src/cli/commands/status/__tests__/aws-identity.test.ts b/src/cli/commands/status/__tests__/aws-identity.test.ts index fe1d2710d2..9308450940 100644 --- a/src/cli/commands/status/__tests__/aws-identity.test.ts +++ b/src/cli/commands/status/__tests__/aws-identity.test.ts @@ -1,11 +1,14 @@ -import { AwsCredentialsError } from '../../../../lib/errors/types.js'; +import { AwsCredentialsError, TimeoutError } from '../../../../lib/errors/types.js'; import { isAccountMismatch, resolveAwsIdentity } from '../aws-identity.js'; +import { AWS_IDENTITY_TIMEOUT_MS } from '../constants.js'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mockDetectAccount = vi.fn(); +const mockHasEnvCredentials = vi.fn(); vi.mock('../../../aws', () => ({ detectAccount: (...args: unknown[]) => mockDetectAccount(...args), + hasEnvCredentials: () => mockHasEnvCredentials(), })); describe('resolveAwsIdentity', () => { @@ -15,6 +18,7 @@ describe('resolveAwsIdentity', () => { originalProfile = process.env.AWS_PROFILE; delete process.env.AWS_PROFILE; mockDetectAccount.mockReset(); + mockHasEnvCredentials.mockReset().mockReturnValue(false); }); afterEach(() => { @@ -26,14 +30,32 @@ describe('resolveAwsIdentity', () => { process.env.AWS_PROFILE = 'dev'; mockDetectAccount.mockResolvedValue('123456789012'); - expect(await resolveAwsIdentity()).toEqual({ success: true, account: '123456789012', profile: 'dev' }); + expect(await resolveAwsIdentity()).toEqual({ + success: true, + account: '123456789012', + profile: 'dev', + fromEnvironment: false, + }); }); - it('calls STS in the given region', async () => { + it('calls STS in the given region with a bounded timeout', async () => { mockDetectAccount.mockResolvedValue('123456789012'); await resolveAwsIdentity('cn-north-1'); - expect(mockDetectAccount).toHaveBeenCalledWith('cn-north-1'); + expect(mockDetectAccount).toHaveBeenCalledWith({ region: 'cn-north-1', timeoutMs: AWS_IDENTITY_TIMEOUT_MS }); + }); + + it('reports environment credentials instead of an AWS_PROFILE they override', async () => { + process.env.AWS_PROFILE = 'dev'; + mockHasEnvCredentials.mockReturnValue(true); + mockDetectAccount.mockResolvedValue('123456789012'); + + expect(await resolveAwsIdentity()).toEqual({ + success: true, + account: '123456789012', + profile: undefined, + fromEnvironment: true, + }); }); it('treats an empty AWS_PROFILE as unset', async () => { @@ -49,6 +71,7 @@ describe('resolveAwsIdentity', () => { expect(await resolveAwsIdentity()).toEqual({ success: false, profile: undefined, + fromEnvironment: false, error: 'No AWS credentials found.', }); }); @@ -59,25 +82,38 @@ describe('resolveAwsIdentity', () => { new AwsCredentialsError('AWS credentials expired.', 'AWS credentials expired.\n\nTo fix this: ...') ); - expect(await resolveAwsIdentity()).toEqual({ + expect(await resolveAwsIdentity()).toMatchObject({ success: false, profile: 'expired', error: 'AWS credentials expired.', }); }); + it('reports a timeout without throwing', async () => { + mockDetectAccount.mockRejectedValue(new TimeoutError('Timed out resolving the AWS account after 3s.')); + + expect(await resolveAwsIdentity()).toMatchObject({ + success: false, + error: 'Timed out resolving the AWS account after 3s.', + }); + }); + it('reports unexpected errors without throwing', async () => { mockDetectAccount.mockRejectedValue(new Error('network down')); - expect(await resolveAwsIdentity()).toEqual({ success: false, profile: undefined, error: 'network down' }); + expect(await resolveAwsIdentity()).toMatchObject({ success: false, error: 'network down' }); }); }); describe('isAccountMismatch', () => { + const resolved = { success: true, account: '111111111111', fromEnvironment: false }; + it('is true only when both accounts are known and differ', () => { - expect(isAccountMismatch({ success: true, account: '111111111111' }, '222222222222')).toBe(true); - expect(isAccountMismatch({ success: true, account: '111111111111' }, '111111111111')).toBe(false); - expect(isAccountMismatch({ success: true, account: '111111111111' }, undefined)).toBe(false); - expect(isAccountMismatch({ success: false, error: 'No AWS credentials found.' }, '222222222222')).toBe(false); + expect(isAccountMismatch(resolved, '222222222222')).toBe(true); + expect(isAccountMismatch(resolved, '111111111111')).toBe(false); + expect(isAccountMismatch(resolved, undefined)).toBe(false); + expect( + isAccountMismatch({ success: false, fromEnvironment: false, error: 'No AWS credentials found.' }, '222222222222') + ).toBe(false); }); }); diff --git a/src/cli/commands/status/__tests__/command.test.ts b/src/cli/commands/status/__tests__/command.test.ts index ecbb6f0c22..07089f0db6 100644 --- a/src/cli/commands/status/__tests__/command.test.ts +++ b/src/cli/commands/status/__tests__/command.test.ts @@ -84,12 +84,16 @@ describe('status command validation', () => { success: true, resources: [], targetAccount: '222222222222', - awsIdentity: { success: true, account: '111111111111' }, + awsIdentity: { success: true, account: '111111111111', fromEnvironment: false }, } as never); await program.parseAsync(['status'], { from: 'user' }); - expect(recorder.set).toHaveBeenCalledWith({ has_aws_credentials: true, aws_account_mismatch: true }); + expect(handleProjectStatus).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ includeAwsIdentity: true }) + ); + expect(recorder.set).toHaveBeenCalledWith({ aws_identity_resolved: true, aws_account_mismatch: true }); }); it('records missing AWS credentials in telemetry', async () => { @@ -99,11 +103,11 @@ describe('status command validation', () => { vi.mocked(handleProjectStatus).mockResolvedValue({ success: true, resources: [], - awsIdentity: { success: false, error: 'No AWS credentials found.' }, + awsIdentity: { success: false, fromEnvironment: false, error: 'No AWS credentials found.' }, } as never); await program.parseAsync(['status'], { from: 'user' }); - expect(recorder.set).toHaveBeenCalledWith({ has_aws_credentials: false, aws_account_mismatch: false }); + expect(recorder.set).toHaveBeenCalledWith({ aws_identity_resolved: false, aws_account_mismatch: false }); }); }); diff --git a/src/cli/commands/status/action.ts b/src/cli/commands/status/action.ts index e415d061d3..2f27f10466 100644 --- a/src/cli/commands/status/action.ts +++ b/src/cli/commands/status/action.ts @@ -396,7 +396,7 @@ export function computeResourceStatuses( export async function handleProjectStatus( context: StatusContext, - options: { targetName?: string; knowledgeBaseName?: string } = {} + options: { targetName?: string; knowledgeBaseName?: string; includeAwsIdentity?: boolean } = {} ): Promise { const logger = new ExecLogger({ command: 'status' }); const { project, deployedState, awsTargets } = context; @@ -430,7 +430,7 @@ export async function handleProjectStatus( logger.startStep('Compute resource statuses'); const targetConfig = selectedTargetName ? awsTargets.find(t => t.name === selectedTargetName) : undefined; - const awsIdentityPromise = resolveAwsIdentity(targetConfig?.region); + const awsIdentityPromise = options.includeAwsIdentity ? resolveAwsIdentity(targetConfig?.region) : undefined; const targetResources = selectedTargetName ? deployedState.targets[selectedTargetName]?.resources : undefined; const resources = computeResourceStatuses(project, targetResources); @@ -799,11 +799,14 @@ export async function handleProjectStatus( } } - logger.startStep('Resolve AWS identity'); - const awsIdentity = await awsIdentityPromise; - logger.log(`Profile: ${awsIdentity.profile ?? '(default)'}`); - logger.log(`Account: ${awsIdentity.account ?? `(unavailable: ${awsIdentity.error})`}`); - logger.endStep(awsIdentity.success ? 'success' : 'warn'); + let awsIdentity: AwsIdentityStatus | undefined; + if (awsIdentityPromise) { + logger.startStep('Resolve AWS identity'); + awsIdentity = await awsIdentityPromise; + logger.log(`Credentials: ${awsIdentity.fromEnvironment ? 'environment' : (awsIdentity.profile ?? '(default)')}`); + logger.log(`Account: ${awsIdentity.account ?? `(unavailable: ${awsIdentity.error})`}`); + logger.endStep(awsIdentity.success ? 'success' : 'warn'); + } logger.finalize(true); return { diff --git a/src/cli/commands/status/aws-identity.ts b/src/cli/commands/status/aws-identity.ts index e196b3de81..54d2049e56 100644 --- a/src/cli/commands/status/aws-identity.ts +++ b/src/cli/commands/status/aws-identity.ts @@ -1,11 +1,14 @@ import { AwsCredentialsError } from '../../../lib'; -import { detectAccount } from '../../aws'; +import { detectAccount, hasEnvCredentials } from '../../aws'; import { getErrorMessage } from '../../errors'; +import { AWS_IDENTITY_TIMEOUT_MS } from './constants'; export interface AwsIdentityStatus { success: boolean; account?: string; + /** Set only when AWS_PROFILE is the credential source; access keys in the environment take precedence. */ profile?: string; + fromEnvironment: boolean; error?: string; } @@ -13,16 +16,18 @@ export interface AwsIdentityStatus { * Resolves the account of the active AWS credentials. Never throws: credential problems are returned in `error`. */ export async function resolveAwsIdentity(region?: string): Promise { - const profile = process.env.AWS_PROFILE?.length ? process.env.AWS_PROFILE : undefined; + const fromEnvironment = hasEnvCredentials(); + const profile = !fromEnvironment && process.env.AWS_PROFILE?.length ? process.env.AWS_PROFILE : undefined; try { - const account = await detectAccount(region); + const account = await detectAccount({ region, timeoutMs: AWS_IDENTITY_TIMEOUT_MS }); return account - ? { success: true, account, profile } - : { success: false, profile, error: 'No AWS credentials found.' }; + ? { success: true, account, profile, fromEnvironment } + : { success: false, profile, fromEnvironment, error: 'No AWS credentials found.' }; } catch (error) { return { success: false, profile, + fromEnvironment, error: error instanceof AwsCredentialsError ? error.shortMessage : getErrorMessage(error), }; } diff --git a/src/cli/commands/status/command.tsx b/src/cli/commands/status/command.tsx index 8eb2f7a061..49097f4cee 100644 --- a/src/cli/commands/status/command.tsx +++ b/src/cli/commands/status/command.tsx @@ -160,10 +160,11 @@ export const registerStatus = (program: Command) => { const statusResult = await handleProjectStatus(context, { targetName: cliOptions.target, knowledgeBaseName, + includeAwsIdentity: true, }); if (statusResult.success && statusResult.awsIdentity) { recorder.set({ - has_aws_credentials: statusResult.awsIdentity.success, + aws_identity_resolved: statusResult.awsIdentity.success, aws_account_mismatch: isAccountMismatch(statusResult.awsIdentity, statusResult.targetAccount), }); } diff --git a/src/cli/commands/status/constants.ts b/src/cli/commands/status/constants.ts index f43522b439..058fec95b4 100644 --- a/src/cli/commands/status/constants.ts +++ b/src/cli/commands/status/constants.ts @@ -19,3 +19,6 @@ export function buildRuntimeInvocationUrl(region: string, runtimeArn: string): s const encodedArn = encodeURIComponent(runtimeArn); return `https://${serviceEndpoint('bedrock-agentcore', region)}/runtimes/${encodedArn}/invocations`; } + +/** Upper bound for the STS identity lookup, so `status` never waits on an unreachable network. */ +export const AWS_IDENTITY_TIMEOUT_MS = 3000; diff --git a/src/cli/telemetry/schemas/command-run.ts b/src/cli/telemetry/schemas/command-run.ts index d01302fcb7..29de5116fe 100644 --- a/src/cli/telemetry/schemas/command-run.ts +++ b/src/cli/telemetry/schemas/command-run.ts @@ -191,7 +191,7 @@ const ExecAttrs = safeSchema({ const StatusAttrs = safeSchema({ filter_type: FilterType, filter_state: FilterState, - has_aws_credentials: z.boolean().optional(), + aws_identity_resolved: z.boolean().optional(), aws_account_mismatch: z.boolean().optional(), });