Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` | Look up a specific runtime by ID |
Expand Down
62 changes: 59 additions & 3 deletions src/cli/aws/__tests__/account-extended.test.ts
Original file line number Diff line number Diff line change
@@ -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) {}
Expand Down Expand Up @@ -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();
Expand All @@ -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<string, unknown>;
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 });

Expand Down
25 changes: 20 additions & 5 deletions src/cli/aws/account.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<string | null> {
const region = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? 'us-east-1';
export async function detectAccount(options: { region?: string; timeoutMs?: number } = {}): Promise<string | null> {
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(
Expand Down
2 changes: 1 addition & 1 deletion src/cli/aws/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
43 changes: 43 additions & 0 deletions src/cli/commands/status/AwsIdentityInfo.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Text color="yellow">
AWS account: unavailable{source} - {identity.error}
</Text>
);
}

return (
<Box flexDirection="column">
<Text dimColor>
AWS account: {identity.account}
{source}
</Text>
{isAccountMismatch(identity, targetAccount) && (
<Text color="yellow">
Warning: your AWS credentials are for account {identity.account}, but target &apos;{targetName}&apos; is
configured for account {targetAccount}.
</Text>
)}
</Box>
);
}
63 changes: 63 additions & 0 deletions src/cli/commands/status/__tests__/AwsIdentityInfo.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<AwsIdentityInfo />);
expect(lastFrame()).toBe('');
});

it('shows the account and profile', () => {
const { lastFrame } = render(
<AwsIdentityInfo
identity={{ success: true, account: '111111111111', profile: 'dev', fromEnvironment: false }}
targetName="default"
targetAccount="111111111111"
/>
);
expect(lastFrame()).toContain('AWS account: 111111111111 (profile: dev)');
expect(lastFrame()).not.toContain('Warning');
});

it('omits the profile when none is set', () => {
const { lastFrame } = render(
<AwsIdentityInfo identity={{ success: true, account: '111111111111', fromEnvironment: false }} />
);
expect(lastFrame()).toContain('AWS account: 111111111111');
expect(lastFrame()).not.toContain('profile');
});

it('says when credentials come from environment variables', () => {
const { lastFrame } = render(
<AwsIdentityInfo identity={{ success: true, account: '111111111111', fromEnvironment: true }} />
);
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(
<AwsIdentityInfo
identity={{ success: true, account: '111111111111', fromEnvironment: false }}
targetName="prod"
targetAccount="222222222222"
/>
);
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(
<AwsIdentityInfo
identity={{ success: false, profile: 'expired', fromEnvironment: false, error: 'AWS credentials expired.' }}
targetAccount="222222222222"
/>
);
expect(lastFrame()).toContain('AWS account: unavailable (profile: expired) - AWS credentials expired.');
expect(lastFrame()).not.toContain('Warning');
});
});
69 changes: 69 additions & 0 deletions src/cli/commands/status/__tests__/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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', () => ({
Expand Down Expand Up @@ -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();
});
});
Loading
Loading