Skip to content

Commit eb4ea4f

Browse files
committed
fix(exec): derive region from the ARN so --runtime needs no project
`agentcore exec --runtime <arn>` failed with "AWS Targets config file not found" unless --region was also passed. The ARN short-circuit in loadExecContext was gated on `startsWith('arn:') && options.region`, so omitting --region fell through to readAWSDeploymentTargets() / readDeployedState(), which throw before anything else runs. The fall-through branch already existed and was labelled "--runtime <arn> with no --region", but it sat after those reads and used config for one thing: `options.region ?? targetConfig.region`. That region is field 3 of the ARN the caller just supplied, so exec was demanding a project, an aws-targets.json and a completed deploy to recover a value it already had. This blocks exec for anyone deploying runtimes outside the CLI (CDK, pipelines, personal stacks), who have no reason to own an agentcore project at all. Parse the region from the ARN instead and drop the --region requirement from both the --runtime and --harness short-circuits. Config is now read only when a *name* needs resolving, or when the ARN's region field is empty or malformed. An explicit --region still wins. regionFromArn moves from operations/jobs/shared/region to a new cli/aws/arn module so exec does not have to depend on operations/jobs; the jobs path re-exports it. It lives apart from cli/aws/region because that module detects the ambient region via env and shared config files, while this is a pure function over an ARN the caller already holds -- and several jobs tests replace cli/aws/region wholesale with a detectRegion-only factory mock.
1 parent e23bb22 commit eb4ea4f

4 files changed

Lines changed: 103 additions & 24 deletions

File tree

src/cli/aws/arn.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
/**
2+
* ARN parsing helpers.
3+
*
4+
* Kept separate from region.ts: that module *detects* the ambient region from the environment and
5+
* shared config files, while these are pure string functions over an ARN the caller already holds.
6+
*/
7+
8+
/**
9+
* Parse the region out of a service ARN.
10+
* ARN format: arn:{partition}:{service}:{region}:{account}:{resource} → field index 3 is the region.
11+
* Splitting on ':' rather than matching a partition keeps this correct for GovCloud and China ARNs.
12+
* Returns undefined for a malformed or region-less ARN so callers can fall back.
13+
*/
14+
export function regionFromArn(arn: string): string | undefined {
15+
const region = arn.split(':')[3];
16+
return region && region.length > 0 ? region : undefined;
17+
}

src/cli/commands/exec/__tests__/action.test.ts

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -767,13 +767,55 @@ describe('loadExecContext --runtime as ARN or name', () => {
767767
).not.toHaveBeenCalled();
768768
});
769769

770-
it('resolves region from config when --runtime is a full ARN but --region is omitted', async () => {
770+
// A full ARN already carries its region, so exec must not need a project to recover it. Reading
771+
// config here used to fail outright with "AWS Targets config file not found" for anyone deploying
772+
// runtimes outside this CLI (CDK, pipelines, personal stacks) — the ARN was enough all along.
773+
it('takes the region from the ARN when --region is omitted, without reading config', async () => {
774+
// Config reads throw, standing in for "no project / no aws-targets.json in cwd".
775+
const noProject = {
776+
readAWSDeploymentTargets: vi.fn().mockRejectedValue(new Error('AWS Targets config file not found')),
777+
readDeployedState: vi.fn().mockRejectedValue(new Error('State config file not found')),
778+
} as unknown as ConfigIO;
779+
780+
const ctx = await loadExecContext({ runtimeArn: 'arn:aws:bedrock-agentcore:eu-west-2:123:runtime/X' }, noProject);
781+
782+
expect(ctx.region).toBe('eu-west-2'); // from the ARN, not config
783+
expect(ctx.runtimeArn).toBe('arn:aws:bedrock-agentcore:eu-west-2:123:runtime/X');
784+
expect(
785+
(noProject as unknown as { readAWSDeploymentTargets: ReturnType<typeof vi.fn> }).readAWSDeploymentTargets
786+
).not.toHaveBeenCalled();
787+
expect(
788+
(noProject as unknown as { readDeployedState: ReturnType<typeof vi.fn> }).readDeployedState
789+
).not.toHaveBeenCalled();
790+
});
791+
792+
it('lets an explicit --region override the region in the ARN', async () => {
771793
const ctx = await loadExecContext(
772-
{ runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/X' },
794+
{ runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/X', region: 'us-west-2' },
773795
TWO_AGENT_CONFIG
774796
);
797+
expect(ctx.region).toBe('us-west-2');
798+
});
799+
800+
// Region is field 3 regardless of partition, so GovCloud and China ARNs resolve the same way.
801+
it.each([
802+
['arn:aws-us-gov:bedrock-agentcore:us-gov-west-1:123:runtime/X', 'us-gov-west-1'],
803+
['arn:aws-cn:bedrock-agentcore:cn-north-1:123:runtime/X', 'cn-north-1'],
804+
])('parses the region out of a non-commercial partition ARN (%s)', async (arn, expected) => {
805+
const throwing = {
806+
readAWSDeploymentTargets: vi.fn().mockRejectedValue(new Error('should not be read')),
807+
readDeployedState: vi.fn().mockRejectedValue(new Error('should not be read')),
808+
} as unknown as ConfigIO;
809+
810+
const ctx = await loadExecContext({ runtimeArn: arn }, throwing);
811+
expect(ctx.region).toBe(expected);
812+
});
813+
814+
// An ARN with an empty region field carries no region to use, so config remains the fallback.
815+
it('falls back to config when the ARN has no region field', async () => {
816+
const ctx = await loadExecContext({ runtimeArn: 'arn:aws:bedrock-agentcore::123:runtime/X' }, TWO_AGENT_CONFIG);
775817
expect(ctx.region).toBe('us-east-1'); // from config
776-
expect(ctx.runtimeArn).toBe('arn:aws:bedrock-agentcore:us-east-1:123:runtime/X');
818+
expect(ctx.runtimeArn).toBe('arn:aws:bedrock-agentcore::123:runtime/X');
777819
});
778820

779821
it('resolves runtimeArn when --runtime is an agent name', async () => {
@@ -858,10 +900,22 @@ describe('loadExecContext with harnesses', () => {
858900
).not.toHaveBeenCalled();
859901
});
860902

861-
it('resolves region from config for --harness <arn> when --region is omitted', async () => {
862-
const ctx = await loadExecContext({ harnessName: HARNESS_ARN }, HARNESS_ONLY_CONFIG);
863-
expect(ctx.runtimeArn).toBe(HARNESS_ARN);
864-
expect(ctx.region).toBe('us-east-1'); // from config target
903+
it('takes the region from a --harness <arn> when --region is omitted, without reading config', async () => {
904+
const noProject = {
905+
readAWSDeploymentTargets: vi.fn().mockRejectedValue(new Error('AWS Targets config file not found')),
906+
readDeployedState: vi.fn().mockRejectedValue(new Error('State config file not found')),
907+
} as unknown as ConfigIO;
908+
909+
const ctx = await loadExecContext(
910+
{ harnessName: 'arn:aws:bedrock-agentcore:eu-west-2:123:harness/h1-abc' },
911+
noProject
912+
);
913+
914+
expect(ctx.runtimeArn).toBe('arn:aws:bedrock-agentcore:eu-west-2:123:harness/h1-abc');
915+
expect(ctx.region).toBe('eu-west-2'); // from the ARN, not config
916+
expect(
917+
(noProject as unknown as { readAWSDeploymentTargets: ReturnType<typeof vi.fn> }).readAWSDeploymentTargets
918+
).not.toHaveBeenCalled();
865919
});
866920

867921
it('rejects a runtime ARN passed to --harness (with --region)', async () => {

src/cli/commands/exec/action.ts

Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ConfigIO } from '../../../lib';
22
import { executeBashCommand } from '../../aws/agentcore';
3+
import { regionFromArn } from '../../aws/arn';
34
import { connectShell, startKeepalive } from '../../aws/connect-shell';
45
import { ShellChannel, ShellFramer, parseStatusFrame } from '../../aws/shell-framer';
56
import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js';
@@ -38,6 +39,7 @@ function assertInteractiveHarnessUnsupported(options: ExecOptions, ctx: ExecCont
3839

3940
/** Resolve region + runtimeArn from options and/or agentcore.json deployed state.
4041
* --runtime accepts either a full ARN (arn:...) or an agent name from deployed state.
42+
* A full ARN resolves with no project and no config on disk; only a *name* needs deployed state.
4143
*/
4244
export async function loadExecContext(options: ExecOptions, configIO: ConfigIO = new ConfigIO()): Promise<ExecContext> {
4345
// Mutual exclusion: --runtime and --harness cannot both be set. Checked first so it applies to
@@ -46,19 +48,29 @@ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO =
4648
throw new Error('Cannot specify both --runtime and --harness.');
4749
}
4850

49-
// Short-circuit: explicit ARN + region — no need to read deployed state
50-
if (options.runtimeArn?.startsWith('arn:') && options.region) {
51-
return assertInteractiveHarnessUnsupported(options, { region: options.region, runtimeArn: options.runtimeArn });
51+
// Short-circuit: an explicit ARN already carries its region in field 3, so --region is optional.
52+
// Reading config here would demand a project, an aws-targets.json and a completed deploy purely to
53+
// recover a value the caller already supplied — which blocks `exec` for anyone who deploys their
54+
// runtimes outside this CLI (CDK, pipelines, personal stacks).
55+
// A region-less/malformed ARN still falls through so config can supply the region.
56+
if (options.runtimeArn?.startsWith('arn:')) {
57+
const region = options.region ?? regionFromArn(options.runtimeArn);
58+
if (region) {
59+
return assertInteractiveHarnessUnsupported(options, { region, runtimeArn: options.runtimeArn });
60+
}
5261
}
5362

54-
// Same short-circuit for --harness <arn> + region. Validate it's a harness ARN (not a runtime ARN).
55-
if (options.harnessName?.startsWith('arn:') && options.region) {
63+
// Same short-circuit for --harness <arn>. Validate it's a harness ARN (not a runtime ARN).
64+
if (options.harnessName?.startsWith('arn:')) {
5665
if (!isHarnessArn(options.harnessName)) {
5766
throw new Error(
5867
`--harness expects a harness ARN (…:harness/…), got '${options.harnessName}'. Use --runtime for a runtime ARN.`
5968
);
6069
}
61-
return assertInteractiveHarnessUnsupported(options, { region: options.region, runtimeArn: options.harnessName });
70+
const region = options.region ?? regionFromArn(options.harnessName);
71+
if (region) {
72+
return assertInteractiveHarnessUnsupported(options, { region, runtimeArn: options.harnessName });
73+
}
6274
}
6375

6476
const awsTargets = await configIO.readAWSDeploymentTargets();
@@ -85,7 +97,8 @@ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO =
8597
const runtimeKeys = Object.keys(targetState?.resources?.runtimes ?? {});
8698
const harnessKeys = Object.keys(targetState?.resources?.harnesses ?? {});
8799

88-
// --runtime <arn> with no --region: ARN provided but region must come from config
100+
// --runtime <arn> whose region field is empty or malformed: only reachable when the short-circuit
101+
// above could not derive a region, so config is the last resort.
89102
if (options.runtimeArn?.startsWith('arn:')) {
90103
return assertInteractiveHarnessUnsupported(options, {
91104
region: options.region ?? targetConfig.region,

src/cli/operations/jobs/shared/region.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,13 @@
33
* no regression to either legacy path) and baked into the stored ARN; refresh/stop/archive
44
* parse it back out of the ARN rather than storing a separate field.
55
*/
6+
import { regionFromArn } from '../../../aws/arn';
67
import { detectRegion } from '../../../aws/region';
78

9+
// regionFromArn is shared with exec's target resolution, so it lives in cli/aws/arn.
10+
// Re-exported here to keep the jobs-facing import path stable.
11+
export { regionFromArn };
12+
813
/** AWS targets carry a per-target region; we only need that field here. */
914
interface RegionTarget {
1015
region: string;
@@ -24,13 +29,3 @@ export async function resolveJobRegion(optsRegion: string | undefined, awsTarget
2429
const { region } = await detectRegion();
2530
return region;
2631
}
27-
28-
/**
29-
* Parse the region out of a service ARN.
30-
* ARN format: arn:{partition}:{service}:{region}:{account}:{resource} → field index 3 is the region.
31-
* Engine-created ARNs are always well-formed; returns undefined for a malformed/region-less ARN.
32-
*/
33-
export function regionFromArn(arn: string): string | undefined {
34-
const region = arn.split(':')[3];
35-
return region && region.length > 0 ? region : undefined;
36-
}

0 commit comments

Comments
 (0)