From 9b327a01932bf663fc556c3d45f7bd6c564bae00 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 25 Jun 2026 06:10:55 +0000 Subject: [PATCH 1/4] fix(cli): add --endpoint flag to invoke and set SigV4 qualifier agentcore invoke had no --endpoint flag and the SigV4 InvokeAgentRuntimeCommand was built without a qualifier, so named runtime endpoints (version aliases like prod/staging) could not be targeted and every SigV4 invoke silently hit DEFAULT. Register --endpoint on invoke, thread it through InvokeOptions -> resolve.ts (validated against project.runtimes[].endpoints and deployed runtimeEndpoints) -> action.ts, and set qualifier on both the streaming and non-streaming SigV4 commands in agentcore.ts. Fixes #986 Fixes #1554 --- .../agentcore-invoke-qualifier.test.ts | 99 +++++++++++++++++++ src/cli/aws/agentcore.ts | 2 + src/cli/aws/index.ts | 9 +- .../commands/invoke/__tests__/resolve.test.ts | 90 +++++++++++++++++ src/cli/commands/invoke/action.ts | 5 +- src/cli/commands/invoke/command.tsx | 16 ++- src/cli/commands/invoke/resolve.ts | 38 +++++++ src/cli/commands/invoke/types.ts | 2 + 8 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 src/cli/aws/__tests__/agentcore-invoke-qualifier.test.ts diff --git a/src/cli/aws/__tests__/agentcore-invoke-qualifier.test.ts b/src/cli/aws/__tests__/agentcore-invoke-qualifier.test.ts new file mode 100644 index 000000000..8b87e601c --- /dev/null +++ b/src/cli/aws/__tests__/agentcore-invoke-qualifier.test.ts @@ -0,0 +1,99 @@ +import { invokeAgentRuntime, invokeAgentRuntimeStreaming } from '../agentcore.js'; +import type { InvokeAgentRuntimeOptions } from '../agentcore.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const commandArgs: Record[] = []; +const mockSend = vi.fn(); + +vi.mock('@aws-sdk/client-bedrock-agentcore', () => { + class MockBedrockAgentCoreClient { + send = mockSend; + middlewareStack = { add: vi.fn() }; + // eslint-disable-next-line @typescript-eslint/no-empty-function + constructor(_config: unknown) {} + } + class MockInvokeAgentRuntimeCommand { + input: Record; + constructor(args: Record) { + this.input = args; + commandArgs.push(args); + } + } + return { + BedrockAgentCoreClient: MockBedrockAgentCoreClient, + InvokeAgentRuntimeCommand: MockInvokeAgentRuntimeCommand, + }; +}); + +vi.mock('../account.js', () => ({ + getCredentialProvider: vi.fn().mockReturnValue(() => + Promise.resolve({ accessKeyId: 'test', secretAccessKey: 'test' }) + ), +})); + +function makeByteResponse(body: string) { + return { + runtimeSessionId: 'sess-1', + response: { + transformToByteArray: () => Promise.resolve(new TextEncoder().encode(body)), + }, + }; +} + +function makeStreamResponse(body: string) { + return { + runtimeSessionId: 'sess-1', + response: { + transformToWebStream: () => + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(body)); + controller.close(); + }, + }), + }, + }; +} + +const BASE: InvokeAgentRuntimeOptions = { + region: 'us-east-1', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime/r', + payload: 'hi', +}; + +describe('SigV4 invoke qualifier', () => { + beforeEach(() => { + commandArgs.length = 0; + mockSend.mockReset(); + }); + + it('sets qualifier on the non-streaming command when endpoint is provided', async () => { + mockSend.mockResolvedValue(makeByteResponse('{"result":"ok"}')); + await invokeAgentRuntime({ ...BASE, endpoint: 'prod' }); + expect(commandArgs[0]?.qualifier).toBe('prod'); + }); + + it('omits qualifier on the non-streaming command when no endpoint is provided', async () => { + mockSend.mockResolvedValue(makeByteResponse('{"result":"ok"}')); + await invokeAgentRuntime({ ...BASE }); + expect(commandArgs[0]).not.toHaveProperty('qualifier'); + }); + + it('sets qualifier on the streaming command when endpoint is provided', async () => { + mockSend.mockResolvedValue(makeStreamResponse('data: "hi"\n')); + const { stream } = await invokeAgentRuntimeStreaming({ ...BASE, endpoint: 'staging' }); + for await (const _ of stream) { + // drain + } + expect(commandArgs[0]?.qualifier).toBe('staging'); + }); + + it('omits qualifier on the streaming command when no endpoint is provided', async () => { + mockSend.mockResolvedValue(makeStreamResponse('data: "hi"\n')); + const { stream } = await invokeAgentRuntimeStreaming({ ...BASE }); + for await (const _ of stream) { + // drain + } + expect(commandArgs[0]).not.toHaveProperty('qualifier'); + }); +}); diff --git a/src/cli/aws/agentcore.ts b/src/cli/aws/agentcore.ts index b91df356d..3b2a8b303 100644 --- a/src/cli/aws/agentcore.ts +++ b/src/cli/aws/agentcore.ts @@ -367,6 +367,7 @@ export async function invokeAgentRuntimeStreaming(options: InvokeAgentRuntimeOpt runtimeSessionId: options.sessionId, runtimeUserId: options.userId ?? DEFAULT_RUNTIME_USER_ID, ...(options.baggage && { baggage: options.baggage }), + ...(options.endpoint && { qualifier: options.endpoint }), }); const response = await client.send(command); @@ -463,6 +464,7 @@ export async function invokeAgentRuntime(options: InvokeAgentRuntimeOptions): Pr runtimeSessionId: options.sessionId, runtimeUserId: options.userId ?? DEFAULT_RUNTIME_USER_ID, ...(options.baggage && { baggage: options.baggage }), + ...(options.endpoint && { qualifier: options.endpoint }), }); const response = await client.send(command); diff --git a/src/cli/aws/index.ts b/src/cli/aws/index.ts index 09851e678..6cdbbf1df 100644 --- a/src/cli/aws/index.ts +++ b/src/cli/aws/index.ts @@ -16,7 +16,14 @@ export { type AgentRuntimeStatusResult, type GetAgentRuntimeStatusOptions, } from './agentcore-control'; -export { streamLogs, searchLogs, type LogEvent, type StreamLogsOptions, type SearchLogsOptions } from './cloudwatch'; +export { + streamLogs, + searchLogs, + resolveEndpointName, + type LogEvent, + type StreamLogsOptions, + type SearchLogsOptions, +} from './cloudwatch'; export { enableTransactionSearch } from './transaction-search'; export { startPolicyGeneration, diff --git a/src/cli/commands/invoke/__tests__/resolve.test.ts b/src/cli/commands/invoke/__tests__/resolve.test.ts index 02c2f5359..e49a6aa0b 100644 --- a/src/cli/commands/invoke/__tests__/resolve.test.ts +++ b/src/cli/commands/invoke/__tests__/resolve.test.ts @@ -257,6 +257,96 @@ describe('resolveInvokeTarget', () => { expect(result.runtimeArn).toContain('rt-b'); }); + describe('named runtime endpoint resolution', () => { + it('resolves an endpoint configured on the runtime to a qualifier', async () => { + const project = makeProject({ + runtimes: [ + { + name: 'my-agent', + build: 'CodeZip', + codeLocation: './agents/my-agent', + entrypoint: 'main.py', + runtimeVersion: '1.0', + networkMode: 'PUBLIC', + endpoints: { prod: { version: 2 } }, + }, + ] as unknown as AgentCoreProjectSpec['runtimes'], + }); + + const result = await resolveInvokeTarget({ + project, + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + endpointName: 'prod', + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.endpoint).toBe('prod'); + }); + + it('resolves an endpoint present only in deployed runtimeEndpoints', async () => { + const deployedState = { + targets: { + default: { + resources: { + runtimes: { + 'my-agent': { + runtimeId: 'rt-123', + runtimeArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789:runtime/rt-123', + roleArn: 'arn:aws:iam::123456789:role/test-role', + }, + }, + runtimeEndpoints: { + 'my-agent/staging': { + endpointId: 'ep-1', + endpointArn: 'arn:aws:bedrock-agentcore:us-east-1:123:runtime-endpoint/ep-1', + }, + }, + }, + }, + }, + } as unknown as DeployedState; + + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState, + awsTargets: makeAwsTargets(), + endpointName: 'staging', + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.endpoint).toBe('staging'); + }); + + it('returns ResourceNotFoundError for an unknown endpoint name', async () => { + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + endpointName: 'nope', + }); + + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error).toBeInstanceOf(ResourceNotFoundError); + expect(result.error.message).toContain("Endpoint 'nope' not found"); + }); + + it('leaves endpoint undefined when no endpointName is provided', async () => { + const result = await resolveInvokeTarget({ + project: makeProject(), + deployedState: makeDeployedState(), + awsTargets: makeAwsTargets(), + }); + + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.endpoint).toBeUndefined(); + }); + }); + describe('CUSTOM_JWT token resolution', () => { function makeJwtProject(): AgentCoreProjectSpec { return makeProject({ diff --git a/src/cli/commands/invoke/action.ts b/src/cli/commands/invoke/action.ts index 7840b50b0..7ae7d67ab 100644 --- a/src/cli/commands/invoke/action.ts +++ b/src/cli/commands/invoke/action.ts @@ -289,6 +289,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption awsTargets, agentName: options.agentName, targetName: options.targetName, + endpointName: options.endpoint, bearerToken: options.bearerToken, sessionId: options.sessionId, }); @@ -297,7 +298,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption return { success: false, error: resolved.error }; } - const { agentSpec, targetName: selectedTargetName, targetConfig, runtimeArn, baggage } = resolved; + const { agentSpec, targetName: selectedTargetName, targetConfig, runtimeArn, endpoint, baggage } = resolved; options = { ...options, bearerToken: resolved.bearerToken ?? options.bearerToken, @@ -714,6 +715,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption headers: options.headers, bearerToken: options.bearerToken, baggage, + endpoint, paymentInstrumentId: options.paymentInstrumentId, paymentSessionId: options.paymentSessionId, paymentUserId: options.paymentUserId, @@ -751,6 +753,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption headers: options.headers, bearerToken: options.bearerToken, baggage, + endpoint, paymentInstrumentId: options.paymentInstrumentId, paymentSessionId: options.paymentSessionId, paymentUserId: options.paymentUserId, diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index 574aa1357..ddf4ea7cd 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -1,5 +1,6 @@ import { ValidationError, serializeResult } from '../../../lib'; -import { COMMAND_DESCRIPTIONS } from '../../constants'; +import { resolveEndpointName } from '../../aws'; +import { COMMAND_DESCRIPTIONS, DEFAULT_ENDPOINT_NAME } from '../../constants'; import { getErrorMessage } from '../../errors'; import { ADDITIONAL_PARAMS_JSON_ERROR } from '../../primitives/constants'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; @@ -138,6 +139,10 @@ export const registerInvoke = (program: Command) => { 'Read the prompt from a file (for long or structured payloads that exceed shell arg limits) [non-interactive]' ) .option('--runtime ', 'Select specific runtime [non-interactive]') + .option( + '--endpoint ', + 'Target a named runtime endpoint (version alias, e.g. prod/staging). Defaults to AGENTCORE_RUNTIME_ENDPOINT env var, then DEFAULT [non-interactive]' + ) .option('--gateway ', 'Invoke through a gateway [non-interactive]') .option('--gateway-target-name ', 'HTTP runtime target on the gateway [non-interactive]') .option('--target ', 'Select deployment target [non-interactive]') @@ -292,6 +297,7 @@ Model & Runtime Overrides (harness only) [non-interactive] prompt?: string; promptFile?: string; runtime?: string; + endpoint?: string; gateway?: string; gatewayTargetName?: string; target?: string; @@ -365,6 +371,7 @@ Model & Runtime Overrides (harness only) [non-interactive] cliOptions.gatewayTargetName || cliOptions.stream || cliOptions.runtime || + cliOptions.endpoint || cliOptions.gateway || cliOptions.tool || cliOptions.exec || @@ -415,9 +422,16 @@ Model & Runtime Overrides (harness only) [non-interactive] } } + // Resolve the named endpoint: --endpoint flag → AGENTCORE_RUNTIME_ENDPOINT + // env var → DEFAULT. Leave it undefined for DEFAULT so the SigV4 qualifier + // is omitted (preserving the DEFAULT endpoint) rather than sent explicitly. + const resolvedEndpoint = resolveEndpointName(cliOptions.endpoint); + const endpoint = resolvedEndpoint === DEFAULT_ENDPOINT_NAME ? undefined : resolvedEndpoint; + const options: InvokeOptions = { prompt: resolved.prompt, agentName: cliOptions.runtime, + endpoint, gateway: cliOptions.gateway, gatewayTarget: cliOptions.gatewayTargetName, targetName: cliOptions.target ?? 'default', diff --git a/src/cli/commands/invoke/resolve.ts b/src/cli/commands/invoke/resolve.ts index 4b76c4a6d..2eb540634 100644 --- a/src/cli/commands/invoke/resolve.ts +++ b/src/cli/commands/invoke/resolve.ts @@ -16,6 +16,8 @@ export interface ResolveInvokeInput { awsTargets: AwsDeploymentTargets; agentName?: string; targetName?: string; + /** Named runtime endpoint (version alias) to target. When omitted, the DEFAULT endpoint is used. */ + endpointName?: string; bearerToken?: string; sessionId?: string; configIO?: ConfigIO; @@ -27,6 +29,8 @@ export interface ResolvedInvokeTarget { targetConfig: AwsDeploymentTarget; region: string; runtimeArn: string; + /** Resolved runtime endpoint qualifier (the endpoint NAME). Undefined targets the DEFAULT endpoint. */ + endpoint?: string; bearerToken?: string; sessionId?: string; baggage?: string; @@ -99,6 +103,39 @@ export async function resolveInvokeTarget(input: ResolveInvokeInput): Promise k.startsWith(`${agentSpec.name}/`)) + .map(k => k.slice(`${agentSpec.name}/`.length)), + ]) + ); + return { + success: false, + error: new ResourceNotFoundError( + `Endpoint '${input.endpointName}' not found for agent '${agentSpec.name}'.` + + (available.length > 0 ? ` Available: ${available.join(', ')}` : '') + ), + }; + } + endpoint = input.endpointName; + } + // Build config bundle baggage if a bundle is associated with this agent const deployedBundles = targetState?.resources?.configBundles ?? {}; let baggage: string | undefined; @@ -157,6 +194,7 @@ export async function resolveInvokeTarget(input: ResolveInvokeInput): Promise Date: Fri, 26 Jun 2026 19:45:58 +0000 Subject: [PATCH 2/4] fix(cli): honor endpoint env-var in tui mode and add telemetry Resolve the invoke endpoint (--endpoint flag -> AGENTCORE_RUNTIME_ENDPOINT env var -> DEFAULT) before the CLI-mode gate so a non-DEFAULT endpoint forces CLI mode instead of silently dropping into the TUI and hitting DEFAULT. Thread the resolved endpoint through the TUI flow (App route -> InvokeScreen -> useInvokeFlow) into invokeAgentRuntimeStreaming so the SigV4 qualifier reaches the SDK in TUI mode too. Add endpoint_source ('flag' | 'env' | 'default') telemetry to InvokeAttrs via a new EndpointSource enum and computeEndpointSource helper, threaded through computeInvokeAttrs at both invoke entry points. --- .../agentcore-invoke-qualifier.test.ts | 6 +- .../commands/invoke/__tests__/invoke.test.ts | 44 +++++++++++++ .../commands/invoke/__tests__/utils.test.ts | 64 ++++++++++++++++++- src/cli/commands/invoke/command.tsx | 23 +++++-- src/cli/commands/invoke/utils.ts | 21 +++++- src/cli/telemetry/schemas/command-run.ts | 2 + src/cli/telemetry/schemas/common-shapes.ts | 2 + src/cli/tui/App.tsx | 6 ++ src/cli/tui/screens/invoke/InvokeScreen.tsx | 8 +++ src/cli/tui/screens/invoke/useInvokeFlow.ts | 9 +++ 10 files changed, 172 insertions(+), 13 deletions(-) diff --git a/src/cli/aws/__tests__/agentcore-invoke-qualifier.test.ts b/src/cli/aws/__tests__/agentcore-invoke-qualifier.test.ts index 8b87e601c..15278c897 100644 --- a/src/cli/aws/__tests__/agentcore-invoke-qualifier.test.ts +++ b/src/cli/aws/__tests__/agentcore-invoke-qualifier.test.ts @@ -26,9 +26,9 @@ vi.mock('@aws-sdk/client-bedrock-agentcore', () => { }); vi.mock('../account.js', () => ({ - getCredentialProvider: vi.fn().mockReturnValue(() => - Promise.resolve({ accessKeyId: 'test', secretAccessKey: 'test' }) - ), + getCredentialProvider: vi + .fn() + .mockReturnValue(() => Promise.resolve({ accessKeyId: 'test', secretAccessKey: 'test' })), })); function makeByteResponse(body: string) { diff --git a/src/cli/commands/invoke/__tests__/invoke.test.ts b/src/cli/commands/invoke/__tests__/invoke.test.ts index 49ac97335..04545535c 100644 --- a/src/cli/commands/invoke/__tests__/invoke.test.ts +++ b/src/cli/commands/invoke/__tests__/invoke.test.ts @@ -270,4 +270,48 @@ describe('invoke command', () => { expect(result.stderr).not.toContain('requires an interactive terminal'); }); }); + + // -------------------------------------------------------------------------- + // Endpoint mode routing. + // + // The same no-TTY signature lets us prove the env-var fallback no longer + // silently drops into the TUI (which would invoke the DEFAULT endpoint). A + // non-DEFAULT resolved endpoint — whether from --endpoint or the + // AGENTCORE_RUNTIME_ENDPOINT env var — forces CLI mode. + // -------------------------------------------------------------------------- + describe('endpoint mode routing', () => { + it('AGENTCORE_RUNTIME_ENDPOINT (no prompt/flag) forces CLI mode instead of silently routing to the TUI', async () => { + // Regression for #986/#1554: previously the env var alone never forced CLI + // mode, so the user dropped into the TUI and silently hit DEFAULT. Now the + // resolved non-DEFAULT endpoint forces CLI mode -> the action layer reports + // "No prompt provided" (NOT the TUI requireTTY guard). + const result = await runCLI(['invoke', '--json'], projectDir, { + env: { ...telemetry.env, AGENTCORE_RUNTIME_ENDPOINT: 'staging' }, + }); + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(false); + expect(result.stderr).not.toContain('requires an interactive terminal'); + telemetry.assertMetricEmitted({ command: 'invoke', endpoint_source: 'env' }); + }); + + it('records endpoint_source=flag when --endpoint is passed', async () => { + // --endpoint forces CLI mode; the agent has no named endpoint configured so + // the action layer rejects it — but the telemetry attr is emitted regardless. + const result = await runCLI(['invoke', 'hi', '--endpoint', 'prod', '--json'], projectDir, { + env: telemetry.env, + }); + expect(result.exitCode).toBe(1); + const json = JSON.parse(result.stdout); + expect(json.success).toBe(false); + expect(result.stderr).not.toContain('requires an interactive terminal'); + telemetry.assertMetricEmitted({ command: 'invoke', endpoint_source: 'flag' }); + }); + + it('records endpoint_source=default when neither --endpoint nor the env var is set', async () => { + const result = await runCLI(['invoke', 'hi', '--json'], projectDir, { env: telemetry.env }); + expect(result.exitCode).toBe(1); + telemetry.assertMetricEmitted({ command: 'invoke', endpoint_source: 'default' }); + }); + }); }); diff --git a/src/cli/commands/invoke/__tests__/utils.test.ts b/src/cli/commands/invoke/__tests__/utils.test.ts index b0f87bb9f..297cc7ebb 100644 --- a/src/cli/commands/invoke/__tests__/utils.test.ts +++ b/src/cli/commands/invoke/__tests__/utils.test.ts @@ -1,5 +1,35 @@ -import { computeInvokeAttrs } from '../utils'; -import { describe, expect, it } from 'vitest'; +import { computeEndpointSource, computeInvokeAttrs } from '../utils'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +describe('computeEndpointSource', () => { + const original = process.env.AGENTCORE_RUNTIME_ENDPOINT; + + beforeEach(() => { + delete process.env.AGENTCORE_RUNTIME_ENDPOINT; + }); + + afterEach(() => { + if (original === undefined) { + delete process.env.AGENTCORE_RUNTIME_ENDPOINT; + } else { + process.env.AGENTCORE_RUNTIME_ENDPOINT = original; + } + }); + + it("returns 'flag' when the --endpoint flag is set (even if the env var is also set)", () => { + process.env.AGENTCORE_RUNTIME_ENDPOINT = 'staging'; + expect(computeEndpointSource('prod')).toBe('flag'); + }); + + it("returns 'env' when only the env var is set", () => { + process.env.AGENTCORE_RUNTIME_ENDPOINT = 'staging'; + expect(computeEndpointSource(undefined)).toBe('env'); + }); + + it("returns 'default' when neither flag nor env var is set", () => { + expect(computeEndpointSource(undefined)).toBe('default'); + }); +}); describe('computeInvokeAttrs', () => { it('returns harness when harnessName is set', () => { @@ -77,4 +107,34 @@ describe('computeInvokeAttrs', () => { }); expect(attrs.agent_protocol).toBe('mcp'); }); + + it("defaults endpoint_source to 'default' when omitted", () => { + const attrs = computeInvokeAttrs({ + harnessCount: 0, + runtimeCount: 1, + stream: false, + hasSessionId: false, + }); + expect(attrs.endpoint_source).toBe('default'); + }); + + it('passes through the provided endpoint_source', () => { + const flag = computeInvokeAttrs({ + harnessCount: 0, + runtimeCount: 1, + stream: false, + hasSessionId: false, + endpointSource: 'flag', + }); + expect(flag.endpoint_source).toBe('flag'); + + const env = computeInvokeAttrs({ + harnessCount: 0, + runtimeCount: 1, + stream: false, + hasSessionId: false, + endpointSource: 'env', + }); + expect(env.endpoint_source).toBe('env'); + }); }); diff --git a/src/cli/commands/invoke/command.tsx b/src/cli/commands/invoke/command.tsx index ddf4ea7cd..b8171be31 100644 --- a/src/cli/commands/invoke/command.tsx +++ b/src/cli/commands/invoke/command.tsx @@ -10,7 +10,7 @@ import { parseHeaderFlags } from '../shared/header-utils'; import { type InvokeContext, handleHarnessInvokeByArn, handleInvoke, loadInvokeConfig } from './action'; import { resolvePrompt } from './resolve-prompt'; import type { InvokeOptions, InvokeResult } from './types'; -import { computeInvokeAttrs } from './utils'; +import { computeEndpointSource, computeInvokeAttrs } from './utils'; import { validateInvokeOptions } from './validate'; import type { Command } from '@commander-js/extra-typings'; import { Text, render } from 'ink'; @@ -361,6 +361,17 @@ Model & Runtime Overrides (harness only) [non-interactive] stdinPiped: !process.stdin.isTTY, }); + // Resolve the named endpoint once, before the CLI-mode gate, so the + // AGENTCORE_RUNTIME_ENDPOINT env-var fallback is honored in BOTH flows: + // --endpoint flag → env var → DEFAULT. A non-DEFAULT resolution forces + // CLI mode (so `AGENTCORE_RUNTIME_ENDPOINT=staging agentcore invoke` does + // not silently drop into the TUI and hit DEFAULT) and is also threaded + // into the TUI route below for interactive parity. Leave it undefined for + // DEFAULT so the SigV4 qualifier is omitted rather than sent explicitly. + const resolvedEndpoint = resolveEndpointName(cliOptions.endpoint); + const endpoint = resolvedEndpoint === DEFAULT_ENDPOINT_NAME ? undefined : resolvedEndpoint; + const endpointSource = computeEndpointSource(cliOptions.endpoint); + // CLI mode if any CLI-specific options provided, prompt resolved, or prompt resolution failed // (follows deploy command pattern) if ( @@ -372,6 +383,7 @@ Model & Runtime Overrides (harness only) [non-interactive] cliOptions.stream || cliOptions.runtime || cliOptions.endpoint || + endpoint !== undefined || cliOptions.gateway || cliOptions.tool || cliOptions.exec || @@ -396,6 +408,7 @@ Model & Runtime Overrides (harness only) [non-interactive] hasSessionId: !!cliOptions.sessionId, bearerToken: cliOptions.bearerToken, agentProtocol: agentProtocol ?? (cliOptions.tool ? 'mcp' : undefined), + endpointSource, }), async (): Promise => { if (!resolved.success) { @@ -422,12 +435,6 @@ Model & Runtime Overrides (harness only) [non-interactive] } } - // Resolve the named endpoint: --endpoint flag → AGENTCORE_RUNTIME_ENDPOINT - // env var → DEFAULT. Leave it undefined for DEFAULT so the SigV4 qualifier - // is omitted (preserving the DEFAULT endpoint) rather than sent explicitly. - const resolvedEndpoint = resolveEndpointName(cliOptions.endpoint); - const endpoint = resolvedEndpoint === DEFAULT_ENDPOINT_NAME ? undefined : resolvedEndpoint; - const options: InvokeOptions = { prompt: resolved.prompt, agentName: cliOptions.runtime, @@ -506,6 +513,8 @@ Model & Runtime Overrides (harness only) [non-interactive] name: 'invoke', sessionId: cliOptions.sessionId, userId: cliOptions.userId, + endpoint, + endpointSource, headers, bearerToken: cliOptions.bearerToken, paymentInstrumentId: cliOptions.paymentInstrumentId, diff --git a/src/cli/commands/invoke/utils.ts b/src/cli/commands/invoke/utils.ts index 240681885..791a001a2 100644 --- a/src/cli/commands/invoke/utils.ts +++ b/src/cli/commands/invoke/utils.ts @@ -1,4 +1,10 @@ -import { AgentEnvironment, AgentProtocol, AuthType, standardize } from '../../telemetry/schemas/common-shapes.js'; +import { + AgentEnvironment, + AgentProtocol, + AuthType, + EndpointSource, + standardize, +} from '../../telemetry/schemas/common-shapes.js'; function isHarnessInvoke(options: { harnessName?: string; @@ -11,6 +17,17 @@ function isHarnessInvoke(options: { return false; } +/** + * Classify where the resolved invoke endpoint qualifier came from for telemetry: + * the explicit --endpoint flag, the AGENTCORE_RUNTIME_ENDPOINT env var, or the + * implicit DEFAULT endpoint when neither is set. + */ +export function computeEndpointSource(flagEndpoint?: string): 'flag' | 'env' | 'default' { + if (flagEndpoint) return 'flag'; + if (process.env.AGENTCORE_RUNTIME_ENDPOINT) return 'env'; + return 'default'; +} + export function computeInvokeAttrs(options: { harnessName?: string; harnessArn?: string; @@ -20,6 +37,7 @@ export function computeInvokeAttrs(options: { hasSessionId: boolean; bearerToken?: string; agentProtocol?: string; + endpointSource?: 'flag' | 'env' | 'default'; }) { const isHarness = isHarnessInvoke(options); return { @@ -28,5 +46,6 @@ export function computeInvokeAttrs(options: { has_session_id: options.hasSessionId, auth_type: standardize(AuthType, options.bearerToken ? 'bearer_token' : 'sigv4'), agent_protocol: isHarness ? undefined : standardize(AgentProtocol, options.agentProtocol ?? 'http'), + endpoint_source: standardize(EndpointSource, options.endpointSource ?? 'default'), }; } diff --git a/src/cli/telemetry/schemas/command-run.ts b/src/cli/telemetry/schemas/command-run.ts index 564709f2d..c89738eb5 100644 --- a/src/cli/telemetry/schemas/command-run.ts +++ b/src/cli/telemetry/schemas/command-run.ts @@ -12,6 +12,7 @@ import { CredentialType, DeployModeSchema, DevAction, + EndpointSource, EvaluatorLevel, EvaluatorType, FilterState, @@ -137,6 +138,7 @@ const InvokeAttrs = safeSchema({ has_session_id: z.boolean(), auth_type: AuthType, agent_protocol: AgentProtocol.optional(), + endpoint_source: EndpointSource, }); const ExecAttrs = safeSchema({ diff --git a/src/cli/telemetry/schemas/common-shapes.ts b/src/cli/telemetry/schemas/common-shapes.ts index bf7679d80..5f8fdc7ef 100644 --- a/src/cli/telemetry/schemas/common-shapes.ts +++ b/src/cli/telemetry/schemas/common-shapes.ts @@ -32,6 +32,8 @@ export const UiMode = z.enum(['browser', 'terminal']); export const AgentSource = z.enum(['create', 'byo', 'import']); export const AttachMode = z.enum(['log_only', 'enforce']); export const AuthType = z.enum(['sigv4', 'bearer_token']); +/** Where the invoke --endpoint qualifier came from: the --endpoint flag, the AGENTCORE_RUNTIME_ENDPOINT env var, or the implicit DEFAULT endpoint. */ +export const EndpointSource = z.enum(['flag', 'env', 'default']); export const AuthorizerType = z.enum(['aws_iam', 'custom_jwt', 'none']); export const BuildType = z.enum(['codezip', 'container']); export const CredentialType = z.enum(['api-key', 'oauth']); diff --git a/src/cli/tui/App.tsx b/src/cli/tui/App.tsx index 81795d16d..a3b265c2f 100644 --- a/src/cli/tui/App.tsx +++ b/src/cli/tui/App.tsx @@ -43,6 +43,10 @@ type Route = name: 'invoke'; sessionId?: string; userId?: string; + /** Resolved named runtime endpoint (version alias) to target. Undefined targets DEFAULT. */ + endpoint?: string; + /** Where the resolved endpoint came from, for telemetry. */ + endpointSource?: 'flag' | 'env' | 'default'; headers?: Record; bearerToken?: string; isResume?: boolean; @@ -248,6 +252,8 @@ function AppContent({ initialSessionId={route.sessionId} isResume={route.isResume} initialUserId={route.userId} + initialEndpoint={route.endpoint} + initialEndpointSource={route.endpointSource} initialHeaders={route.headers} initialBearerToken={route.bearerToken} onExec={result => { diff --git a/src/cli/tui/screens/invoke/InvokeScreen.tsx b/src/cli/tui/screens/invoke/InvokeScreen.tsx index 092076a1e..016638b43 100644 --- a/src/cli/tui/screens/invoke/InvokeScreen.tsx +++ b/src/cli/tui/screens/invoke/InvokeScreen.tsx @@ -14,6 +14,10 @@ interface InvokeScreenProps { initialPrompt?: string; initialSessionId?: string; initialUserId?: string; + /** Resolved named runtime endpoint (version alias) to target on every invocation. Undefined targets DEFAULT. */ + initialEndpoint?: string; + /** Where the resolved endpoint came from, for telemetry. */ + initialEndpointSource?: 'flag' | 'env' | 'default'; /** Custom headers to forward to the agent runtime on every invocation */ initialHeaders?: Record; initialBearerToken?: string; @@ -155,6 +159,8 @@ export function InvokeScreen({ initialPrompt, initialSessionId, initialUserId, + initialEndpoint, + initialEndpointSource, initialHeaders, initialBearerToken, onExec, @@ -189,6 +195,8 @@ export function InvokeScreen({ } = useInvokeFlow({ initialSessionId, initialUserId, + initialEndpoint, + initialEndpointSource, headers: initialHeaders, initialBearerToken, isResume, diff --git a/src/cli/tui/screens/invoke/useInvokeFlow.ts b/src/cli/tui/screens/invoke/useInvokeFlow.ts index fd01237b6..d81f37244 100644 --- a/src/cli/tui/screens/invoke/useInvokeFlow.ts +++ b/src/cli/tui/screens/invoke/useInvokeFlow.ts @@ -69,6 +69,10 @@ export interface InvokeConfig { export interface InvokeFlowOptions { initialSessionId?: string; initialUserId?: string; + /** Resolved named runtime endpoint (version alias) to target on every invocation. Undefined targets DEFAULT. */ + initialEndpoint?: string; + /** Where the resolved endpoint came from, for telemetry. */ + initialEndpointSource?: 'flag' | 'env' | 'default'; /** Custom headers to forward to the agent runtime on every invocation */ headers?: Record; initialBearerToken?: string; @@ -121,6 +125,8 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState const { initialSessionId, initialUserId, + initialEndpoint, + initialEndpointSource, headers, initialBearerToken, isResume, @@ -183,6 +189,7 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState hasSessionId: !!initialSessionId, bearerToken: initialBearerToken, agentProtocol: firstProtocol, + endpointSource: initialEndpointSource, }), async () => { if (!project) { @@ -790,6 +797,7 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState region: config.target.region, runtimeArn: agent.state.runtimeArn, payload: prompt, + endpoint: initialEndpoint, sessionId: sessionId ?? undefined, userId, logger, @@ -850,6 +858,7 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState fetchMcpTools, getMcpInvokeOptions, streamHarnessInvoke, + initialEndpoint, initialPaymentInstrumentId, initialPaymentUserId, ] From 8c153bc39929578ecbaa9f497442e82ba0b2a630 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 26 Jun 2026 20:00:08 +0000 Subject: [PATCH 3/4] fix(cli): set sigv4 qualifier on agui invoke endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit invokeAguiRuntime built InvokeAgentRuntimeCommand without a qualifier, so --endpoint was silently ignored for AGUI-protocol agents (routing to DEFAULT) — the same bug this PR fixes for HTTP/streaming. Add an endpoint field to AguiInvokeOptions, set the qualifier on the command, and thread the resolved endpoint through the CLI (action.ts) and TUI (useInvokeFlow.ts) AGUI call sites. Add qualifier tests for the AGUI path. --- .../agentcore-invoke-qualifier.test.ts | 20 ++++++++++++++++++- src/cli/aws/agentcore.ts | 3 +++ src/cli/commands/invoke/action.ts | 1 + src/cli/tui/screens/invoke/useInvokeFlow.ts | 1 + 4 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/cli/aws/__tests__/agentcore-invoke-qualifier.test.ts b/src/cli/aws/__tests__/agentcore-invoke-qualifier.test.ts index 15278c897..f1f7ca5d8 100644 --- a/src/cli/aws/__tests__/agentcore-invoke-qualifier.test.ts +++ b/src/cli/aws/__tests__/agentcore-invoke-qualifier.test.ts @@ -1,4 +1,4 @@ -import { invokeAgentRuntime, invokeAgentRuntimeStreaming } from '../agentcore.js'; +import { invokeAgentRuntime, invokeAgentRuntimeStreaming, invokeAguiRuntime } from '../agentcore.js'; import type { InvokeAgentRuntimeOptions } from '../agentcore.js'; import { beforeEach, describe, expect, it, vi } from 'vitest'; @@ -96,4 +96,22 @@ describe('SigV4 invoke qualifier', () => { } expect(commandArgs[0]).not.toHaveProperty('qualifier'); }); + + it('sets qualifier on the AGUI command when endpoint is provided', async () => { + mockSend.mockResolvedValue(makeStreamResponse('data: {}\n\n')); + await invokeAguiRuntime( + { region: BASE.region, runtimeArn: BASE.runtimeArn, endpoint: 'prod' }, + { threadId: 't', runId: 'r', messages: [], tools: [], context: [], state: {}, forwardedProps: {} } + ); + expect(commandArgs[0]?.qualifier).toBe('prod'); + }); + + it('omits qualifier on the AGUI command when no endpoint is provided', async () => { + mockSend.mockResolvedValue(makeStreamResponse('data: {}\n\n')); + await invokeAguiRuntime( + { region: BASE.region, runtimeArn: BASE.runtimeArn }, + { threadId: 't', runId: 'r', messages: [], tools: [], context: [], state: {}, forwardedProps: {} } + ); + expect(commandArgs[0]).not.toHaveProperty('qualifier'); + }); }); diff --git a/src/cli/aws/agentcore.ts b/src/cli/aws/agentcore.ts index 3b2a8b303..fde71540a 100644 --- a/src/cli/aws/agentcore.ts +++ b/src/cli/aws/agentcore.ts @@ -1061,6 +1061,8 @@ export interface AguiInvokeOptions { headers?: Record; /** Bearer token for CUSTOM_JWT auth — not yet supported for AGUI, will throw if provided */ bearerToken?: string; + /** Runtime endpoint qualifier (the endpoint NAME, e.g. prod/staging). Defaults to DEFAULT when omitted. */ + endpoint?: string; } export interface AguiStreamingInvokeResult { @@ -1092,6 +1094,7 @@ export async function invokeAguiRuntime( accept: 'text/event-stream', runtimeSessionId: options.sessionId, runtimeUserId: options.userId ?? DEFAULT_RUNTIME_USER_ID, + ...(options.endpoint && { qualifier: options.endpoint }), }); const response = await client.send(command); diff --git a/src/cli/commands/invoke/action.ts b/src/cli/commands/invoke/action.ts index 7ae7d67ab..8e0439274 100644 --- a/src/cli/commands/invoke/action.ts +++ b/src/cli/commands/invoke/action.ts @@ -642,6 +642,7 @@ export async function handleInvoke(context: InvokeContext, options: InvokeOption logger, headers: options.headers, bearerToken: options.bearerToken, + endpoint, }, aguiInput ); diff --git a/src/cli/tui/screens/invoke/useInvokeFlow.ts b/src/cli/tui/screens/invoke/useInvokeFlow.ts index d81f37244..8fbbe9785 100644 --- a/src/cli/tui/screens/invoke/useInvokeFlow.ts +++ b/src/cli/tui/screens/invoke/useInvokeFlow.ts @@ -675,6 +675,7 @@ export function useInvokeFlow(options: InvokeFlowOptions = {}): InvokeFlowState logger, headers, bearerToken: bearerToken || undefined, + endpoint: initialEndpoint, }, aguiInput ); From e0bb1335b1f425099208486af279985e3d8e3477 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 26 Jun 2026 20:10:29 +0000 Subject: [PATCH 4/4] test(cli): exercise env-var cli-mode gate without --json The endpoint regression test passed --json, which already forces CLI mode on its own, so it never exercised the new 'endpoint !== undefined' gate clause. Drop --json (and every other CLI flag) so the env-var-resolved endpoint is the ONLY trigger, and assert the action-layer 'No deployed targets found' error appears (not the TUI requireTTY guard). Verified the test fails when the gate clause is removed (routes to TUI) and passes with it. --- .../commands/invoke/__tests__/invoke.test.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/cli/commands/invoke/__tests__/invoke.test.ts b/src/cli/commands/invoke/__tests__/invoke.test.ts index 04545535c..721a4c936 100644 --- a/src/cli/commands/invoke/__tests__/invoke.test.ts +++ b/src/cli/commands/invoke/__tests__/invoke.test.ts @@ -280,17 +280,21 @@ describe('invoke command', () => { // AGENTCORE_RUNTIME_ENDPOINT env var — forces CLI mode. // -------------------------------------------------------------------------- describe('endpoint mode routing', () => { - it('AGENTCORE_RUNTIME_ENDPOINT (no prompt/flag) forces CLI mode instead of silently routing to the TUI', async () => { + it('AGENTCORE_RUNTIME_ENDPOINT (no prompt/flag/json) forces CLI mode instead of silently routing to the TUI', async () => { // Regression for #986/#1554: previously the env var alone never forced CLI - // mode, so the user dropped into the TUI and silently hit DEFAULT. Now the - // resolved non-DEFAULT endpoint forces CLI mode -> the action layer reports - // "No prompt provided" (NOT the TUI requireTTY guard). - const result = await runCLI(['invoke', '--json'], projectDir, { + // mode, so the user dropped into the TUI and silently hit DEFAULT. NO --json + // and NO other CLI flag here on purpose — the resolved non-DEFAULT endpoint + // (`endpoint !== undefined` in the gate) is the ONLY thing forcing CLI mode, + // so this exercises the new clause directly. CLI mode reaches the action + // layer, which resolves the invoke target and fails with "No deployed + // targets found" (this project has an agent but no deployed state). If the + // `endpoint !== undefined` clause were removed, this would instead route to + // the TUI and hit the requireTTY guard ("requires an interactive terminal"). + const result = await runCLI(['invoke'], projectDir, { env: { ...telemetry.env, AGENTCORE_RUNTIME_ENDPOINT: 'staging' }, }); expect(result.exitCode).toBe(1); - const json = JSON.parse(result.stdout); - expect(json.success).toBe(false); + expect(result.stderr).toContain('No deployed targets found'); expect(result.stderr).not.toContain('requires an interactive terminal'); telemetry.assertMetricEmitted({ command: 'invoke', endpoint_source: 'env' }); });