diff --git a/docs/commands.md b/docs/commands.md index d804481c37..3205bb2cfe 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -144,6 +144,13 @@ agentcore status --runtime-id abc123 agentcore status --json ``` +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 | | ------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | | `--runtime-id ` | Look up a specific runtime by ID | diff --git a/src/cli/aws/__tests__/account-extended.test.ts b/src/cli/aws/__tests__/account-extended.test.ts index 5f9a7376a2..4dc03061ea 100644 --- a/src/cli/aws/__tests__/account-extended.test.ts +++ b/src/cli/aws/__tests__/account-extended.test.ts @@ -1,14 +1,18 @@ -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 } = 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) {} @@ -39,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(); @@ -51,6 +72,41 @@ 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({ 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 5641b3c490..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,28 +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), and + * `timeoutMs` to make a single bounded attempt that throws TimeoutError instead of waiting on the network. */ -export async function detectAccount(): Promise { - const region = 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 new file mode 100644 index 0000000000..e8a4fa93ee --- /dev/null +++ b/src/cli/commands/status/AwsIdentityInfo.tsx @@ -0,0 +1,43 @@ +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, + targetAccount, +}: { + identity?: AwsIdentityStatus; + targetName?: string; + targetAccount?: string; +}) { + if (!identity) return null; + const source = describeCredentialSource(identity); + + if (!identity.success) { + return ( + + AWS account: unavailable{source} - {identity.error} + + ); + } + + return ( + + + AWS account: {identity.account} + {source} + + {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/__tests__/AwsIdentityInfo.test.tsx b/src/cli/commands/status/__tests__/AwsIdentityInfo.test.tsx new file mode 100644 index 0000000000..f40a27834b --- /dev/null +++ b/src/cli/commands/status/__tests__/AwsIdentityInfo.test.tsx @@ -0,0 +1,63 @@ +import { AwsIdentityInfo } from '../AwsIdentityInfo.js'; +import { render } from 'ink-testing-library'; +import React from 'react'; +import { describe, expect, it } from 'vitest'; + +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('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( + + ); + 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__/action.test.ts b/src/cli/commands/status/__tests__/action.test.ts index 99bf2954a9..b8aa7fc64d 100644 --- a/src/cli/commands/status/__tests__/action.test.ts +++ b/src/cli/commands/status/__tests__/action.test.ts @@ -6,6 +6,7 @@ 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 +16,8 @@ const mockGetPaymentConnector = vi.fn(); vi.mock('../../../aws', () => ({ getAgentRuntimeStatus: (...args: unknown[]) => mockGetAgentRuntimeStatus(...args), + detectAccount: (...args: unknown[]) => mockDetectAccount(...args), + hasEnvCredentials: () => false, })); vi.mock('../../../aws/agentcore-control', () => ({ @@ -1496,3 +1499,69 @@ describe('handleProjectStatus — invocation URL enrichment', () => { expect(pendingEntry!.deploymentState).toBe('pending-removal'); }); }); + +describe('handleProjectStatus — AWS identity', () => { + 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; + vi.clearAllMocks(); + }); + + 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: 'gov', region: 'us-gov-west-1', account: '222222222222' }], + deployedState: { targets: {} }, + } as unknown as StatusContext; + + const result = await handleProjectStatus(ctx, { includeAwsIdentity: true }); + + assert(result.success); + 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'); + }); + + 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, { includeAwsIdentity: true }); + + assert(result.success); + 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 new file mode 100644 index 0000000000..9308450940 --- /dev/null +++ b/src/cli/commands/status/__tests__/aws-identity.test.ts @@ -0,0 +1,119 @@ +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', () => { + let originalProfile: string | undefined; + + beforeEach(() => { + originalProfile = process.env.AWS_PROFILE; + delete process.env.AWS_PROFILE; + mockDetectAccount.mockReset(); + mockHasEnvCredentials.mockReset().mockReturnValue(false); + }); + + 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', + fromEnvironment: false, + }); + }); + + it('calls STS in the given region with a bounded timeout', async () => { + mockDetectAccount.mockResolvedValue('123456789012'); + + await resolveAwsIdentity('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 () => { + 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, + fromEnvironment: false, + 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()).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()).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(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 55521cc56e..07089f0db6 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,39 @@ 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: { success: true, account: '111111111111', fromEnvironment: false }, + } as never); + + await program.parseAsync(['status'], { from: 'user' }); + + 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 () => { + 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: { success: false, fromEnvironment: false, error: 'No AWS credentials found.' }, + } as never); + + await program.parseAsync(['status'], { from: 'user' }); + + 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 3471f99414..2f27f10466 100644 --- a/src/cli/commands/status/action.ts +++ b/src/cli/commands/status/action.ts @@ -7,6 +7,7 @@ import { getPaymentConnector, getPaymentManager } from '../../aws/agentcore-paym 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 { @@ -56,6 +57,8 @@ export interface ResourceStatusEntry { export type ProjectStatusResult = Result<{ targetRegion?: string; + targetAccount?: string; + awsIdentity?: AwsIdentityStatus; resources: ResourceStatusEntry[]; deployedState: DeployedState; }> & { projectName?: string; targetName?: string; logPath?: string; resources?: ResourceStatusEntry[] }; @@ -393,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; @@ -427,6 +430,7 @@ export async function handleProjectStatus( logger.startStep('Compute resource statuses'); const targetConfig = selectedTargetName ? awsTargets.find(t => t.name === selectedTargetName) : undefined; + const awsIdentityPromise = options.includeAwsIdentity ? resolveAwsIdentity(targetConfig?.region) : undefined; const targetResources = selectedTargetName ? deployedState.targets[selectedTargetName]?.resources : undefined; const resources = computeResourceStatuses(project, targetResources); @@ -795,6 +799,15 @@ export async function handleProjectStatus( } } + 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 { success: true, @@ -803,6 +816,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.ts b/src/cli/commands/status/aws-identity.ts new file mode 100644 index 0000000000..54d2049e56 --- /dev/null +++ b/src/cli/commands/status/aws-identity.ts @@ -0,0 +1,38 @@ +import { AwsCredentialsError } from '../../../lib'; +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; +} + +/** + * Resolves the account of the active AWS credentials. Never throws: credential problems are returned in `error`. + */ +export async function resolveAwsIdentity(region?: string): Promise { + const fromEnvironment = hasEnvCredentials(); + const profile = !fromEnvironment && process.env.AWS_PROFILE?.length ? process.env.AWS_PROFILE : undefined; + try { + const account = await detectAccount({ region, timeoutMs: AWS_IDENTITY_TIMEOUT_MS }); + return account + ? { 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), + }; + } +} + +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 a5a3928559..49097f4cee 100644 --- a/src/cli/commands/status/command.tsx +++ b/src/cli/commands/status/command.tsx @@ -6,8 +6,10 @@ 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 { 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'; @@ -148,14 +150,25 @@ 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, + includeAwsIdentity: true, + }); + if (statusResult.success && statusResult.awsIdentity) { + recorder.set({ + aws_identity_resolved: statusResult.awsIdentity.success, + aws_account_mismatch: isAccountMismatch(statusResult.awsIdentity, statusResult.targetAccount), + }); + } + return statusResult; }); if (!result.success) { @@ -215,6 +228,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..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 bca7a0e9aa..29de5116fe 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, + aws_identity_resolved: z.boolean().optional(), + aws_account_mismatch: z.boolean().optional(), +}); const LogsAttrs = safeSchema({ has_query: z.boolean(), has_level_filter: z.boolean() });