From 1bfb0e1dbcc1d1441bd43cb819e4f420a614412e Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 25 Jun 2026 06:13:53 +0000 Subject: [PATCH] fix(cli): populate invoke URL for AWS_IAM agents in fetch access (#1593) The fetch access TUI listed IAM-authenticated agents in the resource picker but dead-ended on selection: the runtime path hard-coded url:'' so neither the invoke URL nor the example command rendered. Add fetchRuntimeAccess, which reads the deployed runtimeArn + target region and builds the invocation URL via buildRuntimeInvocationUrl, bringing the AWS_IAM agent path to parity with the AWS_IAM gateway path. Route runtime selection in useFetchAccessFlow through the new function and add unit coverage. --- .../__tests__/fetch-runtime-access.test.ts | 49 ++++++++++++ .../fetch-access/fetch-runtime-access.ts | 78 +++++++++++++++++++ src/cli/operations/fetch-access/index.ts | 1 + .../fetch-access/useFetchAccessFlow.ts | 8 +- 4 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 src/cli/operations/fetch-access/__tests__/fetch-runtime-access.test.ts create mode 100644 src/cli/operations/fetch-access/fetch-runtime-access.ts diff --git a/src/cli/operations/fetch-access/__tests__/fetch-runtime-access.test.ts b/src/cli/operations/fetch-access/__tests__/fetch-runtime-access.test.ts new file mode 100644 index 000000000..37fd05b0b --- /dev/null +++ b/src/cli/operations/fetch-access/__tests__/fetch-runtime-access.test.ts @@ -0,0 +1,49 @@ +import { fetchRuntimeAccess } from '../fetch-runtime-access'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../../lib', () => ({ + ConfigIO: vi.fn(), +})); + +const RUNTIME_ARN = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/my-agent-abc123'; +const EXPECTED_URL = `https://bedrock-agentcore.us-east-1.amazonaws.com/runtimes/${encodeURIComponent(RUNTIME_ARN)}/invocations`; + +const deployedState = { + targets: { + default: { + resources: { + runtimes: { + 'my-agent': { runtimeId: 'rt-1', runtimeArn: RUNTIME_ARN, roleArn: 'arn:aws:iam::123:role/r' }, + }, + }, + }, + }, +}; + +const projectSpec = { + runtimes: [{ name: 'my-agent', authorizerType: 'AWS_IAM' }], + credentials: [], +}; + +const awsTargets = [{ name: 'default', region: 'us-east-1', account: '123456789012' }]; + +function createMockConfigIO() { + return { + readDeployedState: vi.fn().mockResolvedValue(deployedState), + readProjectSpec: vi.fn().mockResolvedValue(projectSpec), + readAWSDeploymentTargets: vi.fn().mockResolvedValue(awsTargets), + } as any; +} + +describe('fetchRuntimeAccess', () => { + afterEach(() => vi.clearAllMocks()); + + it('returns the runtime invocation URL and SigV4 message for an AWS_IAM agent, with no token', async () => { + const result = await fetchRuntimeAccess('my-agent', { configIO: createMockConfigIO() }); + + expect(result.url).toBe(EXPECTED_URL); + expect(result.authType).toBe('AWS_IAM'); + expect(result.token).toBeUndefined(); + expect(result.message).toContain('SigV4'); + }); +}); diff --git a/src/cli/operations/fetch-access/fetch-runtime-access.ts b/src/cli/operations/fetch-access/fetch-runtime-access.ts new file mode 100644 index 000000000..44a0675fe --- /dev/null +++ b/src/cli/operations/fetch-access/fetch-runtime-access.ts @@ -0,0 +1,78 @@ +import { ConfigIO } from '../../../lib'; +import { buildRuntimeInvocationUrl } from '../../commands/status/constants'; +import { fetchOAuthToken } from './oauth-token'; +import type { TokenFetchResult } from './types'; + +/** + * Resolve invoke access for a deployed agent runtime. + * + * AWS_IAM agents have no token to fetch (SigV4 signing is used instead) but DO have an + * invoke URL, so we surface the runtime invocation URL plus a SigV4 message — parity with + * the AWS_IAM gateway path. CUSTOM_JWT agents additionally fetch an OAuth access token. + */ +export async function fetchRuntimeAccess( + agentName: string, + options: { configIO?: ConfigIO; deployTarget?: string; identityName?: string } = {} +): Promise { + const configIO = options.configIO ?? new ConfigIO(); + + const deployedState = await configIO.readDeployedState(); + const projectSpec = await configIO.readProjectSpec(); + const awsTargets = await configIO.readAWSDeploymentTargets(); + + const targetNames = Object.keys(deployedState.targets); + if (targetNames.length === 0) { + throw new Error('No deployed targets found. Run `agentcore deploy` first.'); + } + + const targetName = options.deployTarget ?? targetNames[0]!; + const target = deployedState.targets[targetName]; + if (!target) { + throw new Error(`Deployment target '${targetName}' not found. Available targets: ${targetNames.join(', ')}`); + } + + const agentSpec = projectSpec.runtimes.find(a => a.name === agentName); + if (!agentSpec) { + const available = projectSpec.runtimes.map(a => a.name); + throw new Error(`Agent '${agentName}' not found in project. Available agents: ${available.join(', ') || 'none'}`); + } + + const deployedRuntime = target.resources?.runtimes?.[agentName]; + if (!deployedRuntime?.runtimeArn) { + throw new Error(`Agent '${agentName}' does not have a deployed runtime. Run \`agentcore deploy\` first.`); + } + + const region = awsTargets.find(t => t.name === targetName)?.region; + const url = region ? buildRuntimeInvocationUrl(region, deployedRuntime.runtimeArn) : ''; + + const authType = agentSpec.authorizerType ?? 'AWS_IAM'; + + if (authType === 'AWS_IAM') { + return { + url, + authType: 'AWS_IAM', + message: 'This agent uses AWS_IAM authentication. Use AWS SigV4 signing to invoke.', + }; + } + + const jwtConfig = agentSpec.authorizerConfiguration?.customJwtAuthorizer; + if (!jwtConfig) { + throw new Error(`Agent '${agentName}' is configured as CUSTOM_JWT but has no customJwtAuthorizer configuration.`); + } + + const result = await fetchOAuthToken({ + resourceName: agentName, + jwtConfig, + deployedState, + targetName, + credentials: projectSpec.credentials, + credentialName: options.identityName, + }); + + return { + url, + authType: 'CUSTOM_JWT', + token: result.token, + expiresIn: result.expiresIn, + }; +} diff --git a/src/cli/operations/fetch-access/index.ts b/src/cli/operations/fetch-access/index.ts index fa7c64ea6..92b54ad0a 100644 --- a/src/cli/operations/fetch-access/index.ts +++ b/src/cli/operations/fetch-access/index.ts @@ -1,5 +1,6 @@ export { fetchGatewayToken } from './fetch-gateway-token'; export { canFetchHarnessToken, fetchHarnessToken } from './fetch-harness-token'; +export { fetchRuntimeAccess } from './fetch-runtime-access'; export { canFetchRuntimeToken, fetchRuntimeToken } from './fetch-runtime-token'; export { fetchOAuthToken } from './oauth-token'; export type { OAuthTokenResult } from './oauth-token'; diff --git a/src/cli/tui/screens/fetch-access/useFetchAccessFlow.ts b/src/cli/tui/screens/fetch-access/useFetchAccessFlow.ts index bec80d2b8..b05609746 100644 --- a/src/cli/tui/screens/fetch-access/useFetchAccessFlow.ts +++ b/src/cli/tui/screens/fetch-access/useFetchAccessFlow.ts @@ -4,7 +4,7 @@ import type { OAuthTokenResult, ResourceInfo, TokenFetchResult } from '../../../ import { fetchGatewayToken, fetchHarnessToken, - fetchRuntimeToken, + fetchRuntimeAccess, listAgents, listGateways, listHarnesses, @@ -13,8 +13,8 @@ import { spawn } from 'node:child_process'; import { useCallback, useEffect, useRef, useState } from 'react'; /** - * Resolve token-bearing access for an agent runtime or harness. AWS_IAM resources have no token - * to fetch (SigV4 signing is used instead); CUSTOM_JWT resources fetch an OAuth token directly, + * Resolve token-bearing access for a harness. AWS_IAM harnesses have no token to fetch + * (SigV4 signing is used instead); CUSTOM_JWT harnesses fetch an OAuth token directly, * with any error (missing credential, bad config) surfacing in the error phase. */ async function fetchTokenAccess( @@ -127,7 +127,7 @@ export function useFetchAccessFlow() { ? fetchGatewayToken(resource.name) : resource.resourceType === 'harness' ? fetchTokenAccess(resource, fetchHarnessToken) - : fetchTokenAccess(resource, fetchRuntimeToken); + : fetchRuntimeAccess(resource.name); fetchToken .then(result => {