Skip to content

Commit abfedb5

Browse files
committed
feat(exec): accept a harness ARN for --harness (name|arn)
--harness now accepts a full harness ARN in addition to a name, matching --runtime <name|arn>. A name is resolved to the harness ARN from deployed state; an `arn:` value is used directly (with an `arn:` + --region short-circuit that skips the config read, mirroring --runtime). A runtime ARN passed to --harness is rejected with guidance to use --runtime, and a full-ARN --harness skips the project requirement like --runtime does. Also corrects the stale ExecOptions.harnessName doc comment (it resolves to the harness ARN, not the underlying agentRuntimeArn). Confidence: high Scope-risk: narrow
1 parent eca9c9e commit abfedb5

4 files changed

Lines changed: 71 additions & 6 deletions

File tree

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -843,6 +843,39 @@ describe('loadExecContext with harnesses', () => {
843843
await expect(loadExecContext({ harnessName: 'nope' }, HARNESS_ONLY_CONFIG)).rejects.toThrow(/nope.*h1/);
844844
});
845845

846+
it('uses --harness <arn> directly with --region (no config read)', async () => {
847+
// ARN + region short-circuits before any deployed-state lookup, mirroring --runtime <arn>.
848+
const cfg = {
849+
readAWSDeploymentTargets: vi.fn(),
850+
readDeployedState: vi.fn(),
851+
} as unknown as ConfigIO;
852+
853+
const ctx = await loadExecContext({ harnessName: HARNESS_ARN, region: 'us-west-2' }, cfg);
854+
expect(ctx.runtimeArn).toBe(HARNESS_ARN);
855+
expect(ctx.region).toBe('us-west-2'); // from the flag
856+
expect(
857+
(cfg as unknown as { readDeployedState: ReturnType<typeof vi.fn> }).readDeployedState
858+
).not.toHaveBeenCalled();
859+
});
860+
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
865+
});
866+
867+
it('rejects a runtime ARN passed to --harness (with --region)', async () => {
868+
await expect(loadExecContext({ harnessName: HARNESS_RUNTIME_ARN, region: 'us-east-1' })).rejects.toThrow(
869+
/--harness expects a harness ARN/
870+
);
871+
});
872+
873+
it('rejects a runtime ARN passed to --harness (no --region)', async () => {
874+
await expect(loadExecContext({ harnessName: HARNESS_RUNTIME_ARN }, HARNESS_ONLY_CONFIG)).rejects.toThrow(
875+
/--harness expects a harness ARN/
876+
);
877+
});
878+
846879
it('throws no-target error when the only harness has no harness ARN in deployed state', async () => {
847880
const config = {
848881
readAWSDeploymentTargets: vi.fn().mockResolvedValue([{ name: 'default', region: 'us-east-1' }]),
@@ -995,6 +1028,12 @@ describe('loadExecContext interactive-harness guard', () => {
9951028
);
9961029
});
9971030

1031+
it('throws for --it with a harness ARN via --harness + --region', async () => {
1032+
await expect(loadExecContext({ interactive: true, harnessName: HARNESS_ARN, region: 'us-east-1' })).rejects.toThrow(
1033+
GUARD_MESSAGE
1034+
);
1035+
});
1036+
9981037
it('does NOT block one-shot exec against a harness (interactive falsy)', async () => {
9991038
const ctx = await loadExecContext({}, HARNESS_ONLY_CONFIG);
10001039
expect(ctx.runtimeArn).toBe(HARNESS_ARN);

src/cli/commands/exec/action.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,16 @@ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO =
5151
return assertInteractiveHarnessUnsupported(options, { region: options.region, runtimeArn: options.runtimeArn });
5252
}
5353

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) {
56+
if (!isHarnessArn(options.harnessName)) {
57+
throw new Error(
58+
`--harness expects a harness ARN (…:harness/…), got '${options.harnessName}'. Use --runtime for a runtime ARN.`
59+
);
60+
}
61+
return assertInteractiveHarnessUnsupported(options, { region: options.region, runtimeArn: options.harnessName });
62+
}
63+
5464
const awsTargets = await configIO.readAWSDeploymentTargets();
5565
const deployedState = await configIO.readDeployedState();
5666

@@ -83,11 +93,24 @@ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO =
8393
});
8494
}
8595

86-
// --harness <name>: resolve to the harness ARN.
96+
// --harness <name|arn>: resolve to the harness ARN.
8797
// exec must target the harness ARN, NOT the underlying agentRuntimeArn: the data plane blocks
8898
// ExecuteCommand / shell against a harness-linked runtime ARN, but routes a harness ARN on the
8999
// /runtimes/{arn}/... path through the harness exec path (delegates to LoopyDP).
90100
if (options.harnessName) {
101+
// A full ARN is used directly (must be a harness ARN, not a runtime ARN); a name is looked up.
102+
if (options.harnessName.startsWith('arn:')) {
103+
if (!isHarnessArn(options.harnessName)) {
104+
throw new Error(
105+
`--harness expects a harness ARN (…:harness/…), got '${options.harnessName}'. Use --runtime for a runtime ARN.`
106+
);
107+
}
108+
return assertInteractiveHarnessUnsupported(options, {
109+
region: options.region ?? targetConfig.region,
110+
runtimeArn: options.harnessName,
111+
});
112+
}
113+
91114
const harnessState = targetState?.resources?.harnesses?.[options.harnessName];
92115
if (!harnessState) {
93116
throw new Error(

src/cli/commands/exec/command.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export const registerExec = (program: Command) => {
3232
.argument('[command...]', 'Command to execute (one-shot mode, non-interactive)')
3333
.option('--it', 'Open an interactive PTY shell session')
3434
.option('--runtime <name|arn>', 'Target agent name or runtime ARN (skips agent picker)')
35-
.option('--harness <name>', 'Target harness name (skips agent picker)')
35+
.option('--harness <name|arn>', 'Target harness name or harness ARN (skips agent picker)')
3636
.option('--session-id <id>', 'Pin to a specific runtime session / VM')
3737
.option('--shell-id <id>', 'Reconnect to an existing shell')
3838
.option('--region <region>', 'AWS region')
@@ -57,10 +57,12 @@ export const registerExec = (program: Command) => {
5757
}
5858
) => {
5959
try {
60-
// Skip project check only when --runtime is a full ARN: the user has all the
60+
// Skip project check only when --runtime or --harness is a full ARN: the user has all the
6161
// information they need without an agentcore.json in the working directory.
62-
// A name-based --runtime still requires the project to resolve the ARN.
63-
if (!cliOptions.runtime?.startsWith('arn:')) {
62+
// A name-based --runtime/--harness still requires the project to resolve the ARN.
63+
const hasArnTarget =
64+
Boolean(cliOptions.runtime?.startsWith('arn:')) || Boolean(cliOptions.harness?.startsWith('arn:'));
65+
if (!hasArnTarget) {
6466
if (cliOptions.json) {
6567
// requireProject() renders Ink and calls process.exit — bypass it in JSON mode
6668
// so we can emit a machine-readable error instead.

src/cli/commands/exec/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import type { Result } from '../../../lib/result';
33
export interface ExecOptions {
44
/** Target runtime ARN (from --runtime). Skips agent picker when provided. */
55
runtimeArn?: string;
6-
/** Target harness name (from --harness). Resolves to the harness's underlying runtime ARN. */
6+
/** Target harness name or harness ARN (from --harness). A name is resolved to the harness ARN
7+
* from deployed state; a full `arn:` value is used directly. Skips agent picker when provided. */
78
harnessName?: string;
89
/** Routes the connection to a specific VM (from --session-id). */
910
sessionId?: string;

0 commit comments

Comments
 (0)