From 2587a6af64c028c13c205ce8bb887cf30b0dd4d9 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Thu, 9 Jul 2026 15:06:42 -0700 Subject: [PATCH 1/5] fix(exec): support harness deployments in exec runtime resolution agentcore exec reported "No deployed runtimes found" for harness deployments because loadExecContext only inspected resources.runtimes, while status and invoke also inspect resources.harnesses. A harness carries its underlying runtime ARN in agentRuntimeArn, which is exactly what exec needs to open a shell. loadExecContext and the ExecScreen picker are now harness-aware: they consider both buckets, add a --harness flag, auto-select a single runtime or (failing that) a single harness, and require an explicit flag when both kinds are deployed. Constraint: ExecContext only carries { region, runtimeArn }; a harness resolves to harnessState.agentRuntimeArn (a full ARN). Rejected: Reuse resolveHarness from operations/resolve-agent.ts | it returns runtimeId, not the full ARN exec needs, and adds a getHarness API call exec does not require. Confidence: high Scope-risk: narrow Directive: Keep the runtimes-only auto-select path unchanged so existing single-agent exec behavior does not regress. Not-tested: Live harness with agentRuntimeArn absent from local state --- .../commands/exec/__tests__/action.test.ts | 108 ++++++++++++++++++ src/cli/commands/exec/action.ts | 56 ++++++++- src/cli/commands/exec/command.tsx | 5 + src/cli/commands/exec/types.ts | 2 + src/cli/telemetry/schemas/command-run.ts | 1 + src/cli/tui/screens/exec/ExecScreen.tsx | 7 +- 6 files changed, 174 insertions(+), 5 deletions(-) diff --git a/src/cli/commands/exec/__tests__/action.test.ts b/src/cli/commands/exec/__tests__/action.test.ts index 0315062bf..4f351e33f 100644 --- a/src/cli/commands/exec/__tests__/action.test.ts +++ b/src/cli/commands/exec/__tests__/action.test.ts @@ -800,6 +800,114 @@ describe('loadExecContext --runtime as ARN or name', () => { }); }); +// --------------------------------------------------------------------------- +// loadExecContext with harnesses (regression for #1724) +// --------------------------------------------------------------------------- + +describe('loadExecContext with harnesses', () => { + const HARNESS_ARN = 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-h1'; + + const HARNESS_ONLY_CONFIG = { + readAWSDeploymentTargets: vi.fn().mockResolvedValue([{ name: 'default', region: 'us-east-1' }]), + readDeployedState: vi.fn().mockResolvedValue({ + targets: { + default: { + resources: { + harnesses: { h1: { agentRuntimeArn: HARNESS_ARN } }, + }, + }, + }, + }), + } as unknown as ConfigIO; + + it('auto-selects the single harness when no flags are given (regression #1724)', async () => { + const ctx = await loadExecContext({}, HARNESS_ONLY_CONFIG); + expect(ctx.runtimeArn).toBe(HARNESS_ARN); + expect(ctx.region).toBe('us-east-1'); + }); + + it('resolves the named harness via --harness', async () => { + const ctx = await loadExecContext({ harnessName: 'h1' }, HARNESS_ONLY_CONFIG); + expect(ctx.runtimeArn).toBe(HARNESS_ARN); + expect(ctx.region).toBe('us-east-1'); + }); + + it('throws listing available harnesses when --harness is not deployed', async () => { + await expect(loadExecContext({ harnessName: 'nope' }, HARNESS_ONLY_CONFIG)).rejects.toThrow(/nope.*h1/); + }); + + it('throws when the harness has no agentRuntimeArn', async () => { + const config = { + readAWSDeploymentTargets: vi.fn().mockResolvedValue([{ name: 'default', region: 'us-east-1' }]), + readDeployedState: vi.fn().mockResolvedValue({ + targets: { default: { resources: { harnesses: { h1: {} } } } }, + }), + } as unknown as ConfigIO; + + await expect(loadExecContext({ harnessName: 'h1' }, config)).rejects.toThrow( + /Re-deploy to populate agentRuntimeArn/ + ); + }); + + it('throws when no runtimes and multiple harnesses and no flag', async () => { + const config = { + readAWSDeploymentTargets: vi.fn().mockResolvedValue([{ name: 'default', region: 'us-east-1' }]), + readDeployedState: vi.fn().mockResolvedValue({ + targets: { + default: { + resources: { + harnesses: { + h1: { agentRuntimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-h1' }, + h2: { agentRuntimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-h2' }, + }, + }, + }, + }, + }), + } as unknown as ConfigIO; + + await expect(loadExecContext({}, config)).rejects.toThrow(/Multiple harnesses/); + }); + + it('throws when both a runtime and a harness are deployed and no flag', async () => { + const config = { + readAWSDeploymentTargets: vi.fn().mockResolvedValue([{ name: 'default', region: 'us-east-1' }]), + readDeployedState: vi.fn().mockResolvedValue({ + targets: { + default: { + resources: { + runtimes: { AgentA: { runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/AgentA' } }, + harnesses: { h1: { agentRuntimeArn: HARNESS_ARN } }, + }, + }, + }, + }), + } as unknown as ConfigIO; + + await expect(loadExecContext({}, config)).rejects.toThrow(/both runtimes and harnesses/); + }); + + it('throws when both --runtime (name) and --harness are set', async () => { + const config = { + readAWSDeploymentTargets: vi.fn().mockResolvedValue([{ name: 'default', region: 'us-east-1' }]), + readDeployedState: vi.fn().mockResolvedValue({ + targets: { + default: { + resources: { + runtimes: { AgentA: { runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/AgentA' } }, + harnesses: { h1: { agentRuntimeArn: HARNESS_ARN } }, + }, + }, + }, + }), + } as unknown as ConfigIO; + + await expect(loadExecContext({ runtimeArn: 'AgentA', harnessName: 'h1' }, config)).rejects.toThrow( + /Cannot specify both --runtime and --harness/ + ); + }); +}); + // --------------------------------------------------------------------------- // handleExecOneShot — --json buffering // --------------------------------------------------------------------------- diff --git a/src/cli/commands/exec/action.ts b/src/cli/commands/exec/action.ts index e68a7baaa..e5aa70e8a 100644 --- a/src/cli/commands/exec/action.ts +++ b/src/cli/commands/exec/action.ts @@ -47,15 +47,34 @@ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO = const targetState = deployedState.targets[targetName]; const runtimeKeys = Object.keys(targetState?.resources?.runtimes ?? {}); - if (runtimeKeys.length === 0) { - throw new Error(`No deployed runtimes found in target '${targetName}'.`); - } + const harnessKeys = Object.keys(targetState?.resources?.harnesses ?? {}); // --runtime with no --region: ARN provided but region must come from config if (options.runtimeArn?.startsWith('arn:')) { return { region: options.region ?? targetConfig.region, runtimeArn: options.runtimeArn }; } + // Mutual exclusion: a name-form --runtime and --harness cannot both be set. + if (options.runtimeArn && options.harnessName) { + throw new Error('Cannot specify both --runtime and --harness.'); + } + + // --harness : resolve to the harness's underlying runtime ARN + if (options.harnessName) { + const harnessState = targetState?.resources?.harnesses?.[options.harnessName]; + if (!harnessState) { + throw new Error( + `Harness '${options.harnessName}' not found in target '${targetName}'. Available harnesses: ${harnessKeys.join(', ')}` + ); + } + if (!harnessState.agentRuntimeArn) { + throw new Error( + `Harness '${options.harnessName}' has no runtime ARN in deployed state. Re-deploy to populate agentRuntimeArn.` + ); + } + return { region: options.region ?? targetConfig.region, runtimeArn: harnessState.agentRuntimeArn }; + } + // --runtime : look up by agent name in deployed state if (options.runtimeArn) { const agentState = targetState?.resources?.runtimes?.[options.runtimeArn]; @@ -67,7 +86,35 @@ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO = return { region: options.region ?? targetConfig.region, runtimeArn: agentState.runtimeArn }; } - // No --runtime: error if ambiguous, auto-select if only one agent deployed + // No --runtime and no --harness: nothing deployed at all + if (runtimeKeys.length === 0 && harnessKeys.length === 0) { + throw new Error(`No deployed runtimes or harnesses found in target '${targetName}'.`); + } + + // Both runtimes and harnesses exist — ambiguous, require an explicit flag. + if (runtimeKeys.length > 0 && harnessKeys.length > 0) { + throw new Error( + `Target '${targetName}' has both runtimes and harnesses. Specify one with --runtime or --harness .` + ); + } + + // Only harnesses deployed: auto-select single, else require --harness. + if (runtimeKeys.length === 0) { + if (harnessKeys.length > 1) { + throw new Error( + `Multiple harnesses deployed in target '${targetName}'. Specify one with --harness : ${harnessKeys.join(', ')}` + ); + } + const harnessState = targetState?.resources?.harnesses?.[harnessKeys[0]!]; + if (!harnessState?.agentRuntimeArn) { + throw new Error( + `Harness '${harnessKeys[0]}' has no runtime ARN in deployed state. Re-deploy to populate agentRuntimeArn.` + ); + } + return { region: options.region ?? targetConfig.region, runtimeArn: harnessState.agentRuntimeArn }; + } + + // Only runtimes deployed: auto-select single, else require --runtime. if (runtimeKeys.length > 1) { throw new Error( `Multiple agents deployed in target '${targetName}'. Specify one with --runtime : ${runtimeKeys.join(', ')}` @@ -409,6 +456,7 @@ export async function runInteractiveShell(options: ExecOptions): Promise { { interactive: true, has_runtime: Boolean(options.runtimeArn), + has_harness: Boolean(options.harnessName), has_shell_id: Boolean(options.shellId), has_session_id: Boolean(options.sessionId), is_one_shot: false, diff --git a/src/cli/commands/exec/command.tsx b/src/cli/commands/exec/command.tsx index fc54c3116..a75967a8e 100644 --- a/src/cli/commands/exec/command.tsx +++ b/src/cli/commands/exec/command.tsx @@ -32,6 +32,7 @@ export const registerExec = (program: Command) => { .argument('[command...]', 'Command to execute (one-shot mode, non-interactive)') .option('--it', 'Open an interactive PTY shell session') .option('--runtime ', 'Target agent name or runtime ARN (skips agent picker)') + .option('--harness ', 'Target harness name (resolves to its underlying runtime)') .option('--session-id ', 'Pin to a specific runtime session / VM') .option('--shell-id ', 'Reconnect to an existing shell') .option('--region ', 'AWS region') @@ -45,6 +46,7 @@ export const registerExec = (program: Command) => { cliOptions: { it?: boolean; runtime?: string; + harness?: string; sessionId?: string; shellId?: string; region?: string; @@ -103,6 +105,7 @@ export const registerExec = (program: Command) => { const options: ExecOptions = { runtimeArn: cliOptions.runtime, + harnessName: cliOptions.harness, sessionId: cliOptions.sessionId, shellId: cliOptions.shellId, interactive: cliOptions.it, @@ -128,6 +131,7 @@ export const registerExec = (program: Command) => { { interactive: false, has_runtime: Boolean(options.runtimeArn), + has_harness: Boolean(options.harnessName), has_shell_id: Boolean(options.shellId), has_session_id: Boolean(options.sessionId), is_one_shot: true, @@ -190,6 +194,7 @@ export async function runExecLoop(options: ExecOptions = {}): Promise { { interactive: true, has_runtime: Boolean(shellOptions.runtimeArn), + has_harness: Boolean(shellOptions.harnessName), has_shell_id: Boolean(shellOptions.shellId), has_session_id: Boolean(shellOptions.sessionId), is_one_shot: false, diff --git a/src/cli/commands/exec/types.ts b/src/cli/commands/exec/types.ts index ff67fdcbc..118a383a5 100644 --- a/src/cli/commands/exec/types.ts +++ b/src/cli/commands/exec/types.ts @@ -3,6 +3,8 @@ import type { Result } from '../../../lib/result'; export interface ExecOptions { /** Target runtime ARN (from --runtime). Skips agent picker when provided. */ runtimeArn?: string; + /** Target harness name (from --harness). Resolves to the harness's underlying runtime ARN. */ + harnessName?: string; /** Routes the connection to a specific VM (from --session-id). */ sessionId?: string; /** Reconnect to an existing shell (from --shell-id). Maps to `shellId` query param at the wire boundary. */ diff --git a/src/cli/telemetry/schemas/command-run.ts b/src/cli/telemetry/schemas/command-run.ts index 4c35c3a0f..f92b15afc 100644 --- a/src/cli/telemetry/schemas/command-run.ts +++ b/src/cli/telemetry/schemas/command-run.ts @@ -144,6 +144,7 @@ const InvokeAttrs = safeSchema({ const ExecAttrs = safeSchema({ interactive: z.boolean(), has_runtime: z.boolean(), + has_harness: z.boolean().optional(), has_shell_id: z.boolean(), has_session_id: z.boolean(), is_one_shot: z.boolean(), diff --git a/src/cli/tui/screens/exec/ExecScreen.tsx b/src/cli/tui/screens/exec/ExecScreen.tsx index 68b1edb27..4f52fec2b 100644 --- a/src/cli/tui/screens/exec/ExecScreen.tsx +++ b/src/cli/tui/screens/exec/ExecScreen.tsx @@ -45,9 +45,14 @@ export function ExecScreen({ onSelect, onExit }: ExecScreenProps) { if (!state?.runtimeArn) continue; loaded.push({ name: agent.name, runtimeArn: state.runtimeArn }); } + for (const harness of project.harnesses ?? []) { + const state = targetState?.resources?.harnesses?.[harness.name]; + if (!state?.agentRuntimeArn) continue; + loaded.push({ name: harness.name, runtimeArn: state.agentRuntimeArn, description: 'harness' }); + } if (loaded.length === 0) { - setError('No deployed agents found. Run `agentcore deploy` first.'); + setError('No deployed agents or harnesses found. Run `agentcore deploy` first.'); setLoading(false); return; } From 2dd6a6f88d6a2757b0c17af5e42c61f842411821 Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Thu, 9 Jul 2026 15:22:33 -0700 Subject: [PATCH 2/5] fix(exec): target harness ARN, not runtime ARN, for harness exec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2E testing against a real harness deployment revealed the initial fix resolved a harness to its underlying agentRuntimeArn, which the data plane rejects: HarnessLinkedRuntimeValidator blocks ExecuteCommand and the WebSocket shell against harness-linked runtimes ("managed by a harness and cannot be invoked directly"). The service instead routes a harness ARN on the /runtimes/{arn}/... path through the harness exec path (ExecuteCommandActivity.invokeHarnessExecuteCommand delegates to LoopyDP). loadExecContext and ExecScreen now resolve harnesses to harnessState.harnessArn. Verified E2E in us-east-1: `agentcore exec` (auto-select), `exec --harness `, and `exec --json` all run inside the harness container; passing agentRuntimeArn is rejected by the service. Constraint: Data plane only accepts a harness ARN (not its linked runtime ARN) on the exec/shell path. Rejected: Resolve harness -> agentRuntimeArn | service returns ValidationException, exec never connects. Confidence: high Scope-risk: narrow Directive: Do not "simplify" harness exec to use agentRuntimeArn — the service will reject it. The harness ARN is required. --- npm-shrinkwrap.json | 4 +-- .../commands/exec/__tests__/action.test.ts | 33 +++++++++++-------- src/cli/commands/exec/action.ts | 21 +++++------- src/cli/tui/screens/exec/ExecScreen.tsx | 6 ++-- 4 files changed, 35 insertions(+), 29 deletions(-) diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index bbca23fd8..9d26c978c 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -1,12 +1,12 @@ { "name": "@aws/agentcore", - "version": "0.20.2", + "version": "0.23.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@aws/agentcore", - "version": "0.20.2", + "version": "0.23.0", "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { diff --git a/src/cli/commands/exec/__tests__/action.test.ts b/src/cli/commands/exec/__tests__/action.test.ts index 4f351e33f..7982ee144 100644 --- a/src/cli/commands/exec/__tests__/action.test.ts +++ b/src/cli/commands/exec/__tests__/action.test.ts @@ -805,7 +805,11 @@ describe('loadExecContext --runtime as ARN or name', () => { // --------------------------------------------------------------------------- describe('loadExecContext with harnesses', () => { - const HARNESS_ARN = 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-h1'; + // exec must resolve a harness to its harness ARN (not the underlying agentRuntimeArn): the data + // plane blocks exec against a harness-linked runtime ARN but routes a harness ARN through the + // harness exec path. Deployed state carries both; only harnessArn is a valid exec target. + const HARNESS_ARN = 'arn:aws:bedrock-agentcore:us-east-1:123:harness/h1-abc123'; + const HARNESS_RUNTIME_ARN = 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/harness_h1-xyz789'; const HARNESS_ONLY_CONFIG = { readAWSDeploymentTargets: vi.fn().mockResolvedValue([{ name: 'default', region: 'us-east-1' }]), @@ -813,22 +817,25 @@ describe('loadExecContext with harnesses', () => { targets: { default: { resources: { - harnesses: { h1: { agentRuntimeArn: HARNESS_ARN } }, + harnesses: { h1: { harnessArn: HARNESS_ARN, agentRuntimeArn: HARNESS_RUNTIME_ARN } }, }, }, }, }), } as unknown as ConfigIO; - it('auto-selects the single harness when no flags are given (regression #1724)', async () => { + it('auto-selects the single harness (by harness ARN) when no flags are given (regression #1724)', async () => { const ctx = await loadExecContext({}, HARNESS_ONLY_CONFIG); + // Must be the harness ARN, NOT the agentRuntimeArn — the service rejects the runtime ARN. expect(ctx.runtimeArn).toBe(HARNESS_ARN); + expect(ctx.runtimeArn).not.toBe(HARNESS_RUNTIME_ARN); expect(ctx.region).toBe('us-east-1'); }); - it('resolves the named harness via --harness', async () => { + it('resolves the named harness (by harness ARN) via --harness', async () => { const ctx = await loadExecContext({ harnessName: 'h1' }, HARNESS_ONLY_CONFIG); expect(ctx.runtimeArn).toBe(HARNESS_ARN); + expect(ctx.runtimeArn).not.toBe(HARNESS_RUNTIME_ARN); expect(ctx.region).toBe('us-east-1'); }); @@ -836,17 +843,17 @@ describe('loadExecContext with harnesses', () => { await expect(loadExecContext({ harnessName: 'nope' }, HARNESS_ONLY_CONFIG)).rejects.toThrow(/nope.*h1/); }); - it('throws when the harness has no agentRuntimeArn', async () => { + it('throws when the harness has no harness ARN in deployed state', async () => { const config = { readAWSDeploymentTargets: vi.fn().mockResolvedValue([{ name: 'default', region: 'us-east-1' }]), readDeployedState: vi.fn().mockResolvedValue({ - targets: { default: { resources: { harnesses: { h1: {} } } } }, + targets: { default: { resources: { harnesses: { h1: { agentRuntimeArn: HARNESS_RUNTIME_ARN } } } } }, }), } as unknown as ConfigIO; - await expect(loadExecContext({ harnessName: 'h1' }, config)).rejects.toThrow( - /Re-deploy to populate agentRuntimeArn/ - ); + // Auto-select path surfaces a "Could not determine harness ARN" error rather than silently + // falling back to the (unusable) runtime ARN. + await expect(loadExecContext({}, config)).rejects.toThrow(/Could not determine harness ARN/); }); it('throws when no runtimes and multiple harnesses and no flag', async () => { @@ -857,8 +864,8 @@ describe('loadExecContext with harnesses', () => { default: { resources: { harnesses: { - h1: { agentRuntimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-h1' }, - h2: { agentRuntimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/rt-h2' }, + h1: { harnessArn: 'arn:aws:bedrock-agentcore:us-east-1:123:harness/h1-abc' }, + h2: { harnessArn: 'arn:aws:bedrock-agentcore:us-east-1:123:harness/h2-def' }, }, }, }, @@ -877,7 +884,7 @@ describe('loadExecContext with harnesses', () => { default: { resources: { runtimes: { AgentA: { runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/AgentA' } }, - harnesses: { h1: { agentRuntimeArn: HARNESS_ARN } }, + harnesses: { h1: { harnessArn: HARNESS_ARN, agentRuntimeArn: HARNESS_RUNTIME_ARN } }, }, }, }, @@ -895,7 +902,7 @@ describe('loadExecContext with harnesses', () => { default: { resources: { runtimes: { AgentA: { runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/AgentA' } }, - harnesses: { h1: { agentRuntimeArn: HARNESS_ARN } }, + harnesses: { h1: { harnessArn: HARNESS_ARN, agentRuntimeArn: HARNESS_RUNTIME_ARN } }, }, }, }, diff --git a/src/cli/commands/exec/action.ts b/src/cli/commands/exec/action.ts index e5aa70e8a..2d461830b 100644 --- a/src/cli/commands/exec/action.ts +++ b/src/cli/commands/exec/action.ts @@ -59,7 +59,10 @@ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO = throw new Error('Cannot specify both --runtime and --harness.'); } - // --harness : resolve to the harness's underlying runtime ARN + // --harness : resolve to the harness ARN. + // exec must target the harness ARN, NOT the underlying agentRuntimeArn: the data plane blocks + // ExecuteCommand / shell against a harness-linked runtime ARN, but routes a harness ARN on the + // /runtimes/{arn}/... path through the harness exec path (delegates to LoopyDP). if (options.harnessName) { const harnessState = targetState?.resources?.harnesses?.[options.harnessName]; if (!harnessState) { @@ -67,12 +70,7 @@ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO = `Harness '${options.harnessName}' not found in target '${targetName}'. Available harnesses: ${harnessKeys.join(', ')}` ); } - if (!harnessState.agentRuntimeArn) { - throw new Error( - `Harness '${options.harnessName}' has no runtime ARN in deployed state. Re-deploy to populate agentRuntimeArn.` - ); - } - return { region: options.region ?? targetConfig.region, runtimeArn: harnessState.agentRuntimeArn }; + return { region: options.region ?? targetConfig.region, runtimeArn: harnessState.harnessArn }; } // --runtime : look up by agent name in deployed state @@ -105,13 +103,12 @@ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO = `Multiple harnesses deployed in target '${targetName}'. Specify one with --harness : ${harnessKeys.join(', ')}` ); } + // Target the harness ARN (see the --harness branch above for why not agentRuntimeArn). const harnessState = targetState?.resources?.harnesses?.[harnessKeys[0]!]; - if (!harnessState?.agentRuntimeArn) { - throw new Error( - `Harness '${harnessKeys[0]}' has no runtime ARN in deployed state. Re-deploy to populate agentRuntimeArn.` - ); + if (!harnessState?.harnessArn) { + throw new Error('Could not determine harness ARN from deployed state.'); } - return { region: options.region ?? targetConfig.region, runtimeArn: harnessState.agentRuntimeArn }; + return { region: options.region ?? targetConfig.region, runtimeArn: harnessState.harnessArn }; } // Only runtimes deployed: auto-select single, else require --runtime. diff --git a/src/cli/tui/screens/exec/ExecScreen.tsx b/src/cli/tui/screens/exec/ExecScreen.tsx index 4f52fec2b..838618fdc 100644 --- a/src/cli/tui/screens/exec/ExecScreen.tsx +++ b/src/cli/tui/screens/exec/ExecScreen.tsx @@ -45,10 +45,12 @@ export function ExecScreen({ onSelect, onExit }: ExecScreenProps) { if (!state?.runtimeArn) continue; loaded.push({ name: agent.name, runtimeArn: state.runtimeArn }); } + // exec targets the harness ARN, not agentRuntimeArn: the data plane blocks exec against a + // harness-linked runtime ARN but routes a harness ARN through the harness exec path. for (const harness of project.harnesses ?? []) { const state = targetState?.resources?.harnesses?.[harness.name]; - if (!state?.agentRuntimeArn) continue; - loaded.push({ name: harness.name, runtimeArn: state.agentRuntimeArn, description: 'harness' }); + if (!state?.harnessArn) continue; + loaded.push({ name: harness.name, runtimeArn: state.harnessArn, description: 'harness' }); } if (loaded.length === 0) { From e584b36d38fdf5e56f97f63ac2937a2a19fe16cb Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Fri, 10 Jul 2026 08:29:59 -0700 Subject: [PATCH 3/5] fix(exec): honor --harness in interactive mode and enforce mutex on ARN paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address PR review feedback (#1728): - Interactive mode: `exec --it --harness ` fell through to the agent picker because the direct-PTY guard only checked `runtimeArn`. It now takes the direct path for either `runtimeArn` or `harnessName`. - Mutual exclusion: `--runtime --harness ` silently ignored `--harness` because both ARN short-circuits returned before the mutex check. Hoisted the mutex to the top of loadExecContext so it covers every path. - Simplification: collapsed the no-flag tail from four kind-specific branches to a single candidate-count check; the merged ambiguity message lists all names and both flags, so it is correct regardless of the runtime/harness mix. Tests: added regressions for `--it --harness` skipping the picker, ARN-form `--runtime` + `--harness` mutex (with and without --region), and updated the ambiguity-wording assertions for the merged message. Constraint: data plane only accepts a harness ARN (not its linked runtime ARN) on the exec path Rejected: enforce mutex at the CLI layer in command.tsx | loadExecContext is the single chokepoint every caller (incl. TUI exit path) routes through Confidence: high Scope-risk: narrow Directive: the no-flag ambiguity message intentionally merges runtime+harness kinds — do not re-split without restoring the kind-specific wording tests --- .../commands/exec/__tests__/action.test.ts | 43 ++++++++++--- .../commands/exec/__tests__/command.test.ts | 57 ++++++++++++++++- src/cli/commands/exec/action.ts | 61 ++++++------------- src/cli/commands/exec/command.tsx | 9 +-- 4 files changed, 116 insertions(+), 54 deletions(-) diff --git a/src/cli/commands/exec/__tests__/action.test.ts b/src/cli/commands/exec/__tests__/action.test.ts index 7982ee144..a3338a412 100644 --- a/src/cli/commands/exec/__tests__/action.test.ts +++ b/src/cli/commands/exec/__tests__/action.test.ts @@ -790,7 +790,7 @@ describe('loadExecContext --runtime as ARN or name', () => { it('throws when no --runtime and multiple agents are deployed', async () => { await expect(loadExecContext({}, TWO_AGENT_CONFIG)).rejects.toThrow( - /Multiple agents.*AgentA.*AgentB|Multiple agents.*AgentB.*AgentA/ + /multiple deploy targets.*AgentA.*AgentB|multiple deploy targets.*AgentB.*AgentA/ ); }); @@ -843,7 +843,7 @@ describe('loadExecContext with harnesses', () => { await expect(loadExecContext({ harnessName: 'nope' }, HARNESS_ONLY_CONFIG)).rejects.toThrow(/nope.*h1/); }); - it('throws when the harness has no harness ARN in deployed state', async () => { + it('throws no-target error when the only harness has no harness ARN in deployed state', async () => { const config = { readAWSDeploymentTargets: vi.fn().mockResolvedValue([{ name: 'default', region: 'us-east-1' }]), readDeployedState: vi.fn().mockResolvedValue({ @@ -851,9 +851,9 @@ describe('loadExecContext with harnesses', () => { }), } as unknown as ConfigIO; - // Auto-select path surfaces a "Could not determine harness ARN" error rather than silently - // falling back to the (unusable) runtime ARN. - await expect(loadExecContext({}, config)).rejects.toThrow(/Could not determine harness ARN/); + // A harness with no harnessArn is not a usable exec target, so it drops out of the candidate + // list — leaving zero candidates rather than silently falling back to the (unusable) runtime ARN. + await expect(loadExecContext({}, config)).rejects.toThrow(/No deployed runtimes or harnesses/); }); it('throws when no runtimes and multiple harnesses and no flag', async () => { @@ -873,7 +873,8 @@ describe('loadExecContext with harnesses', () => { }), } as unknown as ConfigIO; - await expect(loadExecContext({}, config)).rejects.toThrow(/Multiple harnesses/); + // Merged ambiguity message lists all candidate names and both flags. + await expect(loadExecContext({}, config)).rejects.toThrow(/multiple deploy targets.*h1.*h2/s); }); it('throws when both a runtime and a harness are deployed and no flag', async () => { @@ -891,7 +892,8 @@ describe('loadExecContext with harnesses', () => { }), } as unknown as ConfigIO; - await expect(loadExecContext({}, config)).rejects.toThrow(/both runtimes and harnesses/); + // Merged ambiguity message lists both the runtime and harness names. + await expect(loadExecContext({}, config)).rejects.toThrow(/multiple deploy targets.*AgentA.*h1/s); }); it('throws when both --runtime (name) and --harness are set', async () => { @@ -913,6 +915,33 @@ describe('loadExecContext with harnesses', () => { /Cannot specify both --runtime and --harness/ ); }); + + it('throws when --runtime is an ARN + --region and --harness are both set (mutex before ARN short-circuit)', async () => { + // The ARN + region short-circuit returns before touching deployed state, so the mutex must be + // enforced ahead of it — otherwise --harness is silently ignored. No config read is needed. + await expect( + loadExecContext({ + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/r', + region: 'us-east-1', + harnessName: 'h1', + }) + ).rejects.toThrow(/Cannot specify both --runtime and --harness/); + }); + + it('throws when --runtime is an ARN (no region) and --harness are both set', async () => { + // Even without --region (which would defer to the config-read ARN short-circuit), the mutex + // fires first. Config read is stubbed but should never be consulted. + const config = { + readAWSDeploymentTargets: vi.fn().mockResolvedValue([{ name: 'default', region: 'us-east-1' }]), + readDeployedState: vi.fn().mockResolvedValue({ + targets: { default: { resources: { harnesses: { h1: { harnessArn: HARNESS_ARN } } } } }, + }), + } as unknown as ConfigIO; + + await expect( + loadExecContext({ runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/r', harnessName: 'h1' }, config) + ).rejects.toThrow(/Cannot specify both --runtime and --harness/); + }); }); // --------------------------------------------------------------------------- diff --git a/src/cli/commands/exec/__tests__/command.test.ts b/src/cli/commands/exec/__tests__/command.test.ts index c0c68ff77..97932831f 100644 --- a/src/cli/commands/exec/__tests__/command.test.ts +++ b/src/cli/commands/exec/__tests__/command.test.ts @@ -3,9 +3,10 @@ // --------------------------------------------------------------------------- import { ANSI } from '../../../constants.js'; import { withCommandRunTelemetry } from '../../../telemetry/cli-command-run.js'; -import { handleExecOneShot, handleShellSession } from '../action.js'; +import { handleExecOneShot, handleShellSession, runInteractiveShell } from '../action.js'; import { registerExec } from '../command.js'; import { Command } from '@commander-js/extra-typings'; +import { render } from 'ink'; import React from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; @@ -421,6 +422,60 @@ describe('exec telemetry attributes', () => { }); }); +// --------------------------------------------------------------------------- +// Interactive mode: --harness / --runtime skip the picker +// --------------------------------------------------------------------------- + +describe('exec --it flag-directed target skips the agent picker', () => { + let mockExit: ReturnType; + + beforeEach(() => { + mockExit = vi.spyOn(process, 'exit').mockImplementation((_code?: string | number | null) => { + throw new Error(`process.exit(${_code})`); + }); + vi.spyOn(console, 'error').mockImplementation(() => undefined); + }); + + afterEach(() => { + mockExit.mockRestore(); + vi.clearAllMocks(); + }); + + it('goes straight to runInteractiveShell (no picker) when --it --harness is passed', async () => { + // Ensure a clean success from the underlying shell session (prior tests may have left a + // success:false implementation on the shared mock). + vi.mocked(handleShellSession).mockResolvedValue({ success: true, exitCode: 0 }); + + const program = new Command(); + program.exitOverride(); + registerExec(program); + + // The action reaches process.exit() (mocked to throw). Whether that resolves to exit(0) or is + // re-caught into exit(1) is immaterial here — the mock-call assertions below prove the path. + await expect(program.parseAsync(['exec', '--it', '--harness', 'myharness'], { from: 'user' })).rejects.toThrow(); + + // Direct PTY path taken with the harness name forwarded... + expect(vi.mocked(runInteractiveShell)).toHaveBeenCalledTimes(1); + expect(vi.mocked(runInteractiveShell).mock.calls[0]![0]).toMatchObject({ harnessName: 'myharness' }); + // ...and the ExecScreen picker (rendered via ink) was never mounted. + expect(vi.mocked(render)).not.toHaveBeenCalled(); + }); + + it('mounts the ExecScreen picker when --it is passed with neither --runtime nor --harness', async () => { + // Control: without a flag-directed target, the picker path must still run. render() never + // resolves the picker promise here, so we assert on the synchronous mount rather than exit. + const program = new Command(); + program.exitOverride(); + registerExec(program); + + void program.parseAsync(['exec', '--it'], { from: 'user' }); + await Promise.resolve(); + + expect(vi.mocked(render)).toHaveBeenCalledTimes(1); + expect(vi.mocked(runInteractiveShell)).not.toHaveBeenCalled(); + }); +}); + // --------------------------------------------------------------------------- // one-shot error message printed before exit // --------------------------------------------------------------------------- diff --git a/src/cli/commands/exec/action.ts b/src/cli/commands/exec/action.ts index 2d461830b..227069780 100644 --- a/src/cli/commands/exec/action.ts +++ b/src/cli/commands/exec/action.ts @@ -20,6 +20,12 @@ export interface ExecContext { * --runtime accepts either a full ARN (arn:...) or an agent name from deployed state. */ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO = new ConfigIO()): Promise { + // Mutual exclusion: --runtime and --harness cannot both be set. Checked first so it applies to + // every path below, including the ARN short-circuits (where --runtime is a full ARN, not a name). + if (options.runtimeArn && options.harnessName) { + throw new Error('Cannot specify both --runtime and --harness.'); + } + // Short-circuit: explicit ARN + region — no need to read deployed state if (options.runtimeArn?.startsWith('arn:') && options.region) { return { region: options.region, runtimeArn: options.runtimeArn }; @@ -54,11 +60,6 @@ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO = return { region: options.region ?? targetConfig.region, runtimeArn: options.runtimeArn }; } - // Mutual exclusion: a name-form --runtime and --harness cannot both be set. - if (options.runtimeArn && options.harnessName) { - throw new Error('Cannot specify both --runtime and --harness.'); - } - // --harness : resolve to the harness ARN. // exec must target the harness ARN, NOT the underlying agentRuntimeArn: the data plane blocks // ExecuteCommand / shell against a harness-linked runtime ARN, but routes a harness ARN on the @@ -84,49 +85,25 @@ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO = return { region: options.region ?? targetConfig.region, runtimeArn: agentState.runtimeArn }; } - // No --runtime and no --harness: nothing deployed at all - if (runtimeKeys.length === 0 && harnessKeys.length === 0) { - throw new Error(`No deployed runtimes or harnesses found in target '${targetName}'.`); - } + // No flag: exec needs exactly one target. Both buckets collapse to a single candidate list — + // exec only ever wants one ARN, so the count is what matters, not the kind. Harnesses resolve to + // their harness ARN (not agentRuntimeArn — see the --harness branch above for why). + const candidates = [ + ...Object.values(targetState?.resources?.runtimes ?? {}).map(r => r.runtimeArn), + ...Object.values(targetState?.resources?.harnesses ?? {}).map(h => h.harnessArn), + ].filter(Boolean); - // Both runtimes and harnesses exist — ambiguous, require an explicit flag. - if (runtimeKeys.length > 0 && harnessKeys.length > 0) { - throw new Error( - `Target '${targetName}' has both runtimes and harnesses. Specify one with --runtime or --harness .` - ); - } - - // Only harnesses deployed: auto-select single, else require --harness. - if (runtimeKeys.length === 0) { - if (harnessKeys.length > 1) { - throw new Error( - `Multiple harnesses deployed in target '${targetName}'. Specify one with --harness : ${harnessKeys.join(', ')}` - ); - } - // Target the harness ARN (see the --harness branch above for why not agentRuntimeArn). - const harnessState = targetState?.resources?.harnesses?.[harnessKeys[0]!]; - if (!harnessState?.harnessArn) { - throw new Error('Could not determine harness ARN from deployed state.'); - } - return { region: options.region ?? targetConfig.region, runtimeArn: harnessState.harnessArn }; + if (candidates.length === 0) { + throw new Error(`No deployed runtimes or harnesses found in target '${targetName}'.`); } - - // Only runtimes deployed: auto-select single, else require --runtime. - if (runtimeKeys.length > 1) { + if (candidates.length > 1) { throw new Error( - `Multiple agents deployed in target '${targetName}'. Specify one with --runtime : ${runtimeKeys.join(', ')}` + `Target '${targetName}' has multiple deploy targets. Specify one with --runtime or --harness : ` + + [...runtimeKeys, ...harnessKeys].join(', ') ); } - const agentState = targetState?.resources?.runtimes?.[runtimeKeys[0]!]; - if (!agentState?.runtimeArn) { - throw new Error('Could not determine runtime ARN from deployed state.'); - } - - return { - region: options.region ?? targetConfig.region, - runtimeArn: agentState.runtimeArn, - }; + return { region: options.region ?? targetConfig.region, runtimeArn: candidates[0]! }; } // --------------------------------------------------------------------------- diff --git a/src/cli/commands/exec/command.tsx b/src/cli/commands/exec/command.tsx index a75967a8e..eb7573023 100644 --- a/src/cli/commands/exec/command.tsx +++ b/src/cli/commands/exec/command.tsx @@ -32,7 +32,7 @@ export const registerExec = (program: Command) => { .argument('[command...]', 'Command to execute (one-shot mode, non-interactive)') .option('--it', 'Open an interactive PTY shell session') .option('--runtime ', 'Target agent name or runtime ARN (skips agent picker)') - .option('--harness ', 'Target harness name (resolves to its underlying runtime)') + .option('--harness ', 'Target harness name (skips agent picker)') .option('--session-id ', 'Pin to a specific runtime session / VM') .option('--shell-id ', 'Reconnect to an existing shell') .option('--region ', 'AWS region') @@ -153,14 +153,15 @@ export const registerExec = (program: Command) => { } // ── Interactive mode ─────────────────────────────────────────────── - // With --runtime: skip agent picker, go straight to PTY - if (options.runtimeArn) { + // With --runtime or --harness: skip agent picker, go straight to PTY. + // loadExecContext resolves either flag to a concrete ARN, so the picker is unnecessary. + if (options.runtimeArn || options.harnessName) { requireTTY(); await runInteractiveShell(options); process.exit(0); } - // Without --runtime: mount ExecScreen to let user pick agent, then PTY, then loop + // Without --runtime/--harness: mount ExecScreen to let user pick agent, then PTY, then loop requireTTY(); await runExecLoop(options); process.exit(0); From eca9c9e41b3c922c270bba573d788ad0853c0cce Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Fri, 10 Jul 2026 08:40:07 -0700 Subject: [PATCH 4/5] fix(exec): fail fast when --it targets a harness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The AgentCore data plane (InvokeAgentRuntimeCommandShell) explicitly rejects harness-linked runtimes: interactive shell is not supported for harness deployments yet. Previously `agentcore exec --it` against a harness surfaced an opaque 403 reconnect loop with a misleading "check IAM permission" message. loadExecContext now detects an interactive session resolving to a harness ARN and throws up front with actionable guidance to use one-shot exec, which IS supported. The guard lives in loadExecContext (the single resolution chokepoint) so it covers --harness, a harness ARN via --runtime, and the auto-select / picker paths uniformly. It keys off the resolved ARN (…:harness/…), and only fires when options.interactive is set — one-shot exec against a harness is unaffected. Verified E2E against a real us-east-1 harness under a PTY: `exec --it` now prints the guidance and makes no network call; one-shot exec still runs in the harness container. Constraint: Service blocks InvokeAgentRuntimeCommandShell for harnesses (YggDP HarnessLinkedRuntimeValidator / CommandShellActivity guard). Rejected: Duplicate the check in command.tsx before requireTTY() | would not cover auto-select/picker paths; loadExecContext is the one chokepoint that sees the resolved ARN in every case. Confidence: high Scope-risk: narrow Directive: Remove this guard only once the service supports the shell API for harness-linked runtimes (the error message says "yet"). --- .../commands/exec/__tests__/action.test.ts | 62 +++++++++++++++++++ src/cli/commands/exec/action.ts | 42 +++++++++++-- src/cli/commands/exec/command.tsx | 10 ++- 3 files changed, 108 insertions(+), 6 deletions(-) diff --git a/src/cli/commands/exec/__tests__/action.test.ts b/src/cli/commands/exec/__tests__/action.test.ts index a3338a412..d07f29c06 100644 --- a/src/cli/commands/exec/__tests__/action.test.ts +++ b/src/cli/commands/exec/__tests__/action.test.ts @@ -944,6 +944,68 @@ describe('loadExecContext with harnesses', () => { }); }); +// --------------------------------------------------------------------------- +// loadExecContext — interactive (--it) is not supported for harnesses +// --------------------------------------------------------------------------- + +describe('loadExecContext interactive-harness guard', () => { + // The data plane (InvokeAgentRuntimeCommandShell) explicitly rejects harness-linked runtimes: + // interactive shell is not supported for harnesses yet. loadExecContext must fail fast with + // actionable guidance rather than let the WebSocket connection surface an opaque service error. + // One-shot exec (options.interactive falsy) IS supported and must NOT be blocked. + const HARNESS_ARN = 'arn:aws:bedrock-agentcore:us-east-1:123:harness/h1-abc123'; + const RUNTIME_ARN = 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/AgentA'; + const GUARD_MESSAGE = /Interactive shell \(--it\) is not supported for harness deployments/; + + const HARNESS_ONLY_CONFIG = { + readAWSDeploymentTargets: vi.fn().mockResolvedValue([{ name: 'default', region: 'us-east-1' }]), + readDeployedState: vi.fn().mockResolvedValue({ + targets: { default: { resources: { harnesses: { h1: { harnessArn: HARNESS_ARN } } } } }, + }), + } as unknown as ConfigIO; + + const RUNTIME_ONLY_CONFIG = { + readAWSDeploymentTargets: vi.fn().mockResolvedValue([{ name: 'default', region: 'us-east-1' }]), + readDeployedState: vi.fn().mockResolvedValue({ + targets: { default: { resources: { runtimes: { AgentA: { runtimeArn: RUNTIME_ARN } } } } }, + }), + } as unknown as ConfigIO; + + it('throws for --it with an auto-selected single harness', async () => { + await expect(loadExecContext({ interactive: true }, HARNESS_ONLY_CONFIG)).rejects.toThrow(GUARD_MESSAGE); + }); + + it('throws for --it with an explicit --harness', async () => { + await expect(loadExecContext({ interactive: true, harnessName: 'h1' }, HARNESS_ONLY_CONFIG)).rejects.toThrow( + GUARD_MESSAGE + ); + }); + + it('throws for --it with a harness ARN passed via --runtime + --region (ARN short-circuit)', async () => { + // Covers the case where the user hands a harness ARN directly — the guard is ARN-based, so it + // fires even on the no-config short-circuit path. + await expect(loadExecContext({ interactive: true, runtimeArn: HARNESS_ARN, region: 'us-east-1' })).rejects.toThrow( + GUARD_MESSAGE + ); + }); + + it('throws for --it with a harness ARN via --runtime (no region, config short-circuit)', async () => { + await expect(loadExecContext({ interactive: true, runtimeArn: HARNESS_ARN }, HARNESS_ONLY_CONFIG)).rejects.toThrow( + GUARD_MESSAGE + ); + }); + + it('does NOT block one-shot exec against a harness (interactive falsy)', async () => { + const ctx = await loadExecContext({}, HARNESS_ONLY_CONFIG); + expect(ctx.runtimeArn).toBe(HARNESS_ARN); + }); + + it('does NOT block --it against a regular runtime', async () => { + const ctx = await loadExecContext({ interactive: true }, RUNTIME_ONLY_CONFIG); + expect(ctx.runtimeArn).toBe(RUNTIME_ARN); + }); +}); + // --------------------------------------------------------------------------- // handleExecOneShot — --json buffering // --------------------------------------------------------------------------- diff --git a/src/cli/commands/exec/action.ts b/src/cli/commands/exec/action.ts index 227069780..e329db2fd 100644 --- a/src/cli/commands/exec/action.ts +++ b/src/cli/commands/exec/action.ts @@ -16,6 +16,26 @@ export interface ExecContext { runtimeArn: string; } +/** True when `arn` is a harness ARN (…:harness/…) rather than a runtime ARN (…:runtime/…). */ +function isHarnessArn(arn: string): boolean { + return arn.includes(':harness/'); +} + +/** Guard interactive (`--it`) exec against harness targets. + * The data plane (InvokeAgentRuntimeCommandShell) explicitly rejects harness-linked runtimes: + * interactive shell is not supported for harnesses yet. One-shot exec IS supported (routes through + * the harness ExecuteCommand path), so fail fast here with actionable guidance instead of letting + * the WebSocket connection surface an opaque service error. */ +function assertInteractiveHarnessUnsupported(options: ExecOptions, ctx: ExecContext): ExecContext { + if (options.interactive && isHarnessArn(ctx.runtimeArn)) { + throw new Error( + 'Interactive shell (--it) is not supported for harness deployments yet. ' + + 'Use one-shot exec instead, e.g. `agentcore exec ""`.' + ); + } + return ctx; +} + /** Resolve region + runtimeArn from options and/or agentcore.json deployed state. * --runtime accepts either a full ARN (arn:...) or an agent name from deployed state. */ @@ -28,7 +48,7 @@ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO = // Short-circuit: explicit ARN + region — no need to read deployed state if (options.runtimeArn?.startsWith('arn:') && options.region) { - return { region: options.region, runtimeArn: options.runtimeArn }; + return assertInteractiveHarnessUnsupported(options, { region: options.region, runtimeArn: options.runtimeArn }); } const awsTargets = await configIO.readAWSDeploymentTargets(); @@ -57,7 +77,10 @@ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO = // --runtime with no --region: ARN provided but region must come from config if (options.runtimeArn?.startsWith('arn:')) { - return { region: options.region ?? targetConfig.region, runtimeArn: options.runtimeArn }; + return assertInteractiveHarnessUnsupported(options, { + region: options.region ?? targetConfig.region, + runtimeArn: options.runtimeArn, + }); } // --harness : resolve to the harness ARN. @@ -71,7 +94,10 @@ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO = `Harness '${options.harnessName}' not found in target '${targetName}'. Available harnesses: ${harnessKeys.join(', ')}` ); } - return { region: options.region ?? targetConfig.region, runtimeArn: harnessState.harnessArn }; + return assertInteractiveHarnessUnsupported(options, { + region: options.region ?? targetConfig.region, + runtimeArn: harnessState.harnessArn, + }); } // --runtime : look up by agent name in deployed state @@ -82,7 +108,10 @@ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO = `Agent '${options.runtimeArn}' not found in target '${targetName}'. Available agents: ${runtimeKeys.join(', ')}` ); } - return { region: options.region ?? targetConfig.region, runtimeArn: agentState.runtimeArn }; + return assertInteractiveHarnessUnsupported(options, { + region: options.region ?? targetConfig.region, + runtimeArn: agentState.runtimeArn, + }); } // No flag: exec needs exactly one target. Both buckets collapse to a single candidate list — @@ -103,7 +132,10 @@ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO = ); } - return { region: options.region ?? targetConfig.region, runtimeArn: candidates[0]! }; + return assertInteractiveHarnessUnsupported(options, { + region: options.region ?? targetConfig.region, + runtimeArn: candidates[0]!, + }); } // --------------------------------------------------------------------------- diff --git a/src/cli/commands/exec/command.tsx b/src/cli/commands/exec/command.tsx index eb7573023..bedce3045 100644 --- a/src/cli/commands/exec/command.tsx +++ b/src/cli/commands/exec/command.tsx @@ -186,7 +186,15 @@ export async function runExecLoop(options: ExecOptions = {}): Promise { const picked = await pickAgent(); if (!picked) break; // user pressed Esc - const shellOptions: ExecOptions = { ...options, runtimeArn: picked.runtimeArn, sessionId: picked.sessionId }; + // This loop always opens a PTY (handleShellSession), so it is inherently interactive — set the + // flag explicitly so loadExecContext's interactive-harness guard fires even when a caller (e.g. + // the TUI exit-action path) invoked runExecLoop() without options.interactive set. + const shellOptions: ExecOptions = { + ...options, + interactive: true, + runtimeArn: picked.runtimeArn, + sessionId: picked.sessionId, + }; let sessionError: unknown; try { From abfedb56cae67f26277dd92e659fcdb0d3f6f1cd Mon Sep 17 00:00:00 2001 From: Jesse Turner Date: Fri, 10 Jul 2026 08:50:43 -0700 Subject: [PATCH 5/5] feat(exec): accept a harness ARN for --harness (name|arn) --harness now accepts a full harness ARN in addition to a name, matching --runtime . 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 --- .../commands/exec/__tests__/action.test.ts | 39 +++++++++++++++++++ src/cli/commands/exec/action.ts | 25 +++++++++++- src/cli/commands/exec/command.tsx | 10 +++-- src/cli/commands/exec/types.ts | 3 +- 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/src/cli/commands/exec/__tests__/action.test.ts b/src/cli/commands/exec/__tests__/action.test.ts index d07f29c06..a33bfbf59 100644 --- a/src/cli/commands/exec/__tests__/action.test.ts +++ b/src/cli/commands/exec/__tests__/action.test.ts @@ -843,6 +843,39 @@ describe('loadExecContext with harnesses', () => { await expect(loadExecContext({ harnessName: 'nope' }, HARNESS_ONLY_CONFIG)).rejects.toThrow(/nope.*h1/); }); + it('uses --harness directly with --region (no config read)', async () => { + // ARN + region short-circuits before any deployed-state lookup, mirroring --runtime . + const cfg = { + readAWSDeploymentTargets: vi.fn(), + readDeployedState: vi.fn(), + } as unknown as ConfigIO; + + const ctx = await loadExecContext({ harnessName: HARNESS_ARN, region: 'us-west-2' }, cfg); + expect(ctx.runtimeArn).toBe(HARNESS_ARN); + expect(ctx.region).toBe('us-west-2'); // from the flag + expect( + (cfg as unknown as { readDeployedState: ReturnType }).readDeployedState + ).not.toHaveBeenCalled(); + }); + + it('resolves region from config for --harness when --region is omitted', async () => { + const ctx = await loadExecContext({ harnessName: HARNESS_ARN }, HARNESS_ONLY_CONFIG); + expect(ctx.runtimeArn).toBe(HARNESS_ARN); + expect(ctx.region).toBe('us-east-1'); // from config target + }); + + it('rejects a runtime ARN passed to --harness (with --region)', async () => { + await expect(loadExecContext({ harnessName: HARNESS_RUNTIME_ARN, region: 'us-east-1' })).rejects.toThrow( + /--harness expects a harness ARN/ + ); + }); + + it('rejects a runtime ARN passed to --harness (no --region)', async () => { + await expect(loadExecContext({ harnessName: HARNESS_RUNTIME_ARN }, HARNESS_ONLY_CONFIG)).rejects.toThrow( + /--harness expects a harness ARN/ + ); + }); + it('throws no-target error when the only harness has no harness ARN in deployed state', async () => { const config = { readAWSDeploymentTargets: vi.fn().mockResolvedValue([{ name: 'default', region: 'us-east-1' }]), @@ -995,6 +1028,12 @@ describe('loadExecContext interactive-harness guard', () => { ); }); + it('throws for --it with a harness ARN via --harness + --region', async () => { + await expect(loadExecContext({ interactive: true, harnessName: HARNESS_ARN, region: 'us-east-1' })).rejects.toThrow( + GUARD_MESSAGE + ); + }); + it('does NOT block one-shot exec against a harness (interactive falsy)', async () => { const ctx = await loadExecContext({}, HARNESS_ONLY_CONFIG); expect(ctx.runtimeArn).toBe(HARNESS_ARN); diff --git a/src/cli/commands/exec/action.ts b/src/cli/commands/exec/action.ts index e329db2fd..8fb5c1fef 100644 --- a/src/cli/commands/exec/action.ts +++ b/src/cli/commands/exec/action.ts @@ -51,6 +51,16 @@ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO = return assertInteractiveHarnessUnsupported(options, { region: options.region, runtimeArn: options.runtimeArn }); } + // Same short-circuit for --harness + region. Validate it's a harness ARN (not a runtime ARN). + if (options.harnessName?.startsWith('arn:') && options.region) { + if (!isHarnessArn(options.harnessName)) { + throw new Error( + `--harness expects a harness ARN (…:harness/…), got '${options.harnessName}'. Use --runtime for a runtime ARN.` + ); + } + return assertInteractiveHarnessUnsupported(options, { region: options.region, runtimeArn: options.harnessName }); + } + const awsTargets = await configIO.readAWSDeploymentTargets(); const deployedState = await configIO.readDeployedState(); @@ -83,11 +93,24 @@ export async function loadExecContext(options: ExecOptions, configIO: ConfigIO = }); } - // --harness : resolve to the harness ARN. + // --harness : resolve to the harness ARN. // exec must target the harness ARN, NOT the underlying agentRuntimeArn: the data plane blocks // ExecuteCommand / shell against a harness-linked runtime ARN, but routes a harness ARN on the // /runtimes/{arn}/... path through the harness exec path (delegates to LoopyDP). if (options.harnessName) { + // A full ARN is used directly (must be a harness ARN, not a runtime ARN); a name is looked up. + if (options.harnessName.startsWith('arn:')) { + if (!isHarnessArn(options.harnessName)) { + throw new Error( + `--harness expects a harness ARN (…:harness/…), got '${options.harnessName}'. Use --runtime for a runtime ARN.` + ); + } + return assertInteractiveHarnessUnsupported(options, { + region: options.region ?? targetConfig.region, + runtimeArn: options.harnessName, + }); + } + const harnessState = targetState?.resources?.harnesses?.[options.harnessName]; if (!harnessState) { throw new Error( diff --git a/src/cli/commands/exec/command.tsx b/src/cli/commands/exec/command.tsx index bedce3045..a474f72b4 100644 --- a/src/cli/commands/exec/command.tsx +++ b/src/cli/commands/exec/command.tsx @@ -32,7 +32,7 @@ export const registerExec = (program: Command) => { .argument('[command...]', 'Command to execute (one-shot mode, non-interactive)') .option('--it', 'Open an interactive PTY shell session') .option('--runtime ', 'Target agent name or runtime ARN (skips agent picker)') - .option('--harness ', 'Target harness name (skips agent picker)') + .option('--harness ', 'Target harness name or harness ARN (skips agent picker)') .option('--session-id ', 'Pin to a specific runtime session / VM') .option('--shell-id ', 'Reconnect to an existing shell') .option('--region ', 'AWS region') @@ -57,10 +57,12 @@ export const registerExec = (program: Command) => { } ) => { try { - // Skip project check only when --runtime is a full ARN: the user has all the + // Skip project check only when --runtime or --harness is a full ARN: the user has all the // information they need without an agentcore.json in the working directory. - // A name-based --runtime still requires the project to resolve the ARN. - if (!cliOptions.runtime?.startsWith('arn:')) { + // A name-based --runtime/--harness still requires the project to resolve the ARN. + const hasArnTarget = + Boolean(cliOptions.runtime?.startsWith('arn:')) || Boolean(cliOptions.harness?.startsWith('arn:')); + if (!hasArnTarget) { if (cliOptions.json) { // requireProject() renders Ink and calls process.exit — bypass it in JSON mode // so we can emit a machine-readable error instead. diff --git a/src/cli/commands/exec/types.ts b/src/cli/commands/exec/types.ts index 118a383a5..e96da106d 100644 --- a/src/cli/commands/exec/types.ts +++ b/src/cli/commands/exec/types.ts @@ -3,7 +3,8 @@ import type { Result } from '../../../lib/result'; export interface ExecOptions { /** Target runtime ARN (from --runtime). Skips agent picker when provided. */ runtimeArn?: string; - /** Target harness name (from --harness). Resolves to the harness's underlying runtime ARN. */ + /** Target harness name or harness ARN (from --harness). A name is resolved to the harness ARN + * from deployed state; a full `arn:` value is used directly. Skips agent picker when provided. */ harnessName?: string; /** Routes the connection to a specific VM (from --session-id). */ sessionId?: string;