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 0315062bf..a33bfbf59 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/ ); }); @@ -800,6 +800,251 @@ describe('loadExecContext --runtime as ARN or name', () => { }); }); +// --------------------------------------------------------------------------- +// loadExecContext with harnesses (regression for #1724) +// --------------------------------------------------------------------------- + +describe('loadExecContext with harnesses', () => { + // 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' }]), + readDeployedState: vi.fn().mockResolvedValue({ + targets: { + default: { + resources: { + harnesses: { h1: { harnessArn: HARNESS_ARN, agentRuntimeArn: HARNESS_RUNTIME_ARN } }, + }, + }, + }, + }), + } as unknown as ConfigIO; + + 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 (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'); + }); + + it('throws listing available harnesses when --harness is not deployed', async () => { + 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' }]), + readDeployedState: vi.fn().mockResolvedValue({ + targets: { default: { resources: { harnesses: { h1: { agentRuntimeArn: HARNESS_RUNTIME_ARN } } } } }, + }), + } as unknown as ConfigIO; + + // 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 () => { + const config = { + readAWSDeploymentTargets: vi.fn().mockResolvedValue([{ name: 'default', region: 'us-east-1' }]), + readDeployedState: vi.fn().mockResolvedValue({ + targets: { + default: { + resources: { + harnesses: { + 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' }, + }, + }, + }, + }, + }), + } as unknown as ConfigIO; + + // 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 () => { + 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: { harnessArn: HARNESS_ARN, agentRuntimeArn: HARNESS_RUNTIME_ARN } }, + }, + }, + }, + }), + } as unknown as ConfigIO; + + // 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 () => { + 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: { harnessArn: HARNESS_ARN, agentRuntimeArn: HARNESS_RUNTIME_ARN } }, + }, + }, + }, + }), + } as unknown as ConfigIO; + + await expect(loadExecContext({ runtimeArn: 'AgentA', harnessName: 'h1' }, config)).rejects.toThrow( + /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/); + }); +}); + +// --------------------------------------------------------------------------- +// 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('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); + }); + + 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/__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 e68a7baaa..8fb5c1fef 100644 --- a/src/cli/commands/exec/action.ts +++ b/src/cli/commands/exec/action.ts @@ -16,13 +16,49 @@ 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. */ 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 }; + 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(); @@ -47,13 +83,44 @@ 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 }; + return assertInteractiveHarnessUnsupported(options, { + region: options.region ?? targetConfig.region, + runtimeArn: options.runtimeArn, + }); + } + + // --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( + `Harness '${options.harnessName}' not found in target '${targetName}'. Available harnesses: ${harnessKeys.join(', ')}` + ); + } + return assertInteractiveHarnessUnsupported(options, { + region: options.region ?? targetConfig.region, + runtimeArn: harnessState.harnessArn, + }); } // --runtime : look up by agent name in deployed state @@ -64,25 +131,34 @@ 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 --runtime: error if ambiguous, auto-select if only one agent deployed - if (runtimeKeys.length > 1) { + // 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); + + if (candidates.length === 0) { + throw new Error(`No deployed runtimes or harnesses found in target '${targetName}'.`); + } + 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 { + return assertInteractiveHarnessUnsupported(options, { region: options.region ?? targetConfig.region, - runtimeArn: agentState.runtimeArn, - }; + runtimeArn: candidates[0]!, + }); } // --------------------------------------------------------------------------- @@ -409,6 +485,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..a474f72b4 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 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') @@ -45,6 +46,7 @@ export const registerExec = (program: Command) => { cliOptions: { it?: boolean; runtime?: string; + harness?: string; sessionId?: string; shellId?: string; region?: string; @@ -55,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. @@ -103,6 +107,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 +133,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, @@ -149,14 +155,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); @@ -181,7 +188,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 { @@ -190,6 +205,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..e96da106d 100644 --- a/src/cli/commands/exec/types.ts +++ b/src/cli/commands/exec/types.ts @@ -3,6 +3,9 @@ import type { Result } from '../../../lib/result'; export interface ExecOptions { /** Target runtime ARN (from --runtime). Skips agent picker when provided. */ runtimeArn?: string; + /** 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; /** 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..838618fdc 100644 --- a/src/cli/tui/screens/exec/ExecScreen.tsx +++ b/src/cli/tui/screens/exec/ExecScreen.tsx @@ -45,9 +45,16 @@ 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?.harnessArn) continue; + loaded.push({ name: harness.name, runtimeArn: state.harnessArn, 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; }