From 98b8f6611e17fc6126dce76b34c166fc9fede165 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 22 Jun 2026 17:24:01 -0400 Subject: [PATCH 1/4] feat(fetch): support harness in fetch access (CLI + TUI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 'harness' as a resource type for fetch access, fetching a CUSTOM_JWT bearer token for a deployed harness via the existing fetchHarnessToken operation. CLI: - types: FetchResourceType += 'harness' - action: handleFetchHarnessAccess dispatch; agent + harness share one fetchTokenAccess helper - command: help text covers harness TUI: - new listHarnesses operation (project registry ∩ deployed-state, reads each harness.json for authorizerType) - useFetchAccessFlow loads harnesses alongside gateways/agents and routes the harness fetch through fetchHarnessToken - FetchAccessScreen labels the harness resource type Verified end-to-end against real AWS (us-west-2): deployed a CUSTOM_JWT harness backed by Cognito with the OAuth credential stored in AgentCore Identity, then fetched a valid bearer token via both 'fetch access --type harness' and the TUI picker. Unit tests: 48 passing across fetch-access (incl. 6 new harness cases). --- .../fetch/__tests__/fetch-access.test.ts | 50 +++++++++++++++ src/cli/commands/fetch/action.ts | 26 +++++++- src/cli/commands/fetch/command.tsx | 6 +- src/cli/commands/fetch/types.ts | 2 +- src/cli/operations/fetch-access/index.ts | 3 +- .../operations/fetch-access/list-harnesses.ts | 44 +++++++++++++ src/cli/operations/fetch-access/types.ts | 7 ++- .../fetch-access/FetchAccessScreen.tsx | 4 +- .../__tests__/useFetchAccessFlow.test.tsx | 63 ++++++++++++++++++- .../fetch-access/useFetchAccessFlow.ts | 42 +++++++++---- 10 files changed, 223 insertions(+), 24 deletions(-) create mode 100644 src/cli/operations/fetch-access/list-harnesses.ts diff --git a/src/cli/commands/fetch/__tests__/fetch-access.test.ts b/src/cli/commands/fetch/__tests__/fetch-access.test.ts index 76f5e5557..d0b12cd1d 100644 --- a/src/cli/commands/fetch/__tests__/fetch-access.test.ts +++ b/src/cli/commands/fetch/__tests__/fetch-access.test.ts @@ -3,12 +3,16 @@ import { Command } from '@commander-js/extra-typings'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const mockFetchGatewayToken = vi.fn(); +const mockFetchRuntimeToken = vi.fn(); +const mockFetchHarnessToken = vi.fn(); const mockListGateways = vi.fn(); const mockRequireProject = vi.fn(); const mockRender = vi.fn(); vi.mock('../../../operations/fetch-access', () => ({ fetchGatewayToken: (...args: unknown[]) => mockFetchGatewayToken(...args), + fetchRuntimeToken: (...args: unknown[]) => mockFetchRuntimeToken(...args), + fetchHarnessToken: (...args: unknown[]) => mockFetchHarnessToken(...args), listGateways: (...args: unknown[]) => mockListGateways(...args), })); @@ -193,4 +197,50 @@ describe('registerFetch', () => { const output = JSON.parse(mockLog.mock.calls[0][0]); expect(output.success).toBe(true); }); + + describe('--type harness', () => { + const harnessTokenResult = { token: 'harness-token', expiresIn: 3600 }; + + it('outputs a CUSTOM_JWT token for a harness when --json flag is used', async () => { + mockFetchHarnessToken.mockResolvedValue(harnessTokenResult); + + await program.parseAsync(['fetch', 'access', '--type', 'harness', '--name', 'MyHarness', '--json'], { + from: 'user', + }); + + expect(mockFetchHarnessToken).toHaveBeenCalledWith('MyHarness', expect.objectContaining({})); + expect(mockFetchGatewayToken).not.toHaveBeenCalled(); + expect(mockLog).toHaveBeenCalledTimes(1); + const output = JSON.parse(mockLog.mock.calls[0][0]); + expect(output.success).toBe(true); + expect(output.authType).toBe('CUSTOM_JWT'); + expect(output.token).toBe('harness-token'); + expect(output.expiresIn).toBe(3600); + }); + + it('errors with a harness-specific message when --name is missing and --json flag is used', async () => { + await expect( + program.parseAsync(['fetch', 'access', '--type', 'harness', '--json'], { from: 'user' }) + ).rejects.toThrow('process.exit'); + + expect(mockFetchHarnessToken).not.toHaveBeenCalled(); + expect(mockLog).toHaveBeenCalledTimes(1); + const output = JSON.parse(mockLog.mock.calls[0][0]); + expect(output.success).toBe(false); + expect(output.error).toBe('Missing required option: --name '); + }); + + it('outputs JSON error when fetchHarnessToken throws (e.g. non-CUSTOM_JWT harness)', async () => { + mockFetchHarnessToken.mockRejectedValue(new Error("Harness 'MyHarness' uses AWS_IAM auth, not CUSTOM_JWT.")); + + await expect( + program.parseAsync(['fetch', 'access', '--type', 'harness', '--name', 'MyHarness', '--json'], { from: 'user' }) + ).rejects.toThrow('process.exit'); + + expect(mockLog).toHaveBeenCalledTimes(1); + const output = JSON.parse(mockLog.mock.calls[0][0]); + expect(output.success).toBe(false); + expect(output.error).toBe("Harness 'MyHarness' uses AWS_IAM auth, not CUSTOM_JWT."); + }); + }); }); diff --git a/src/cli/commands/fetch/action.ts b/src/cli/commands/fetch/action.ts index c8bd44091..75b3512b4 100644 --- a/src/cli/commands/fetch/action.ts +++ b/src/cli/commands/fetch/action.ts @@ -1,4 +1,4 @@ -import { fetchGatewayToken, fetchRuntimeToken, listGateways } from '../../operations/fetch-access'; +import { fetchGatewayToken, fetchHarnessToken, fetchRuntimeToken, listGateways } from '../../operations/fetch-access'; import type { OAuthTokenResult, TokenFetchResult } from '../../operations/fetch-access'; import type { FetchAccessOptions } from './types'; @@ -16,6 +16,10 @@ export async function handleFetchAccess(options: FetchAccessOptions): Promise { + return fetchTokenAccess(options, 'agent', fetchRuntimeToken); +} + +async function handleFetchHarnessAccess(options: FetchAccessOptions): Promise { + return fetchTokenAccess(options, 'harness', fetchHarnessToken); +} + +/** + * Shared flow for the CUSTOM_JWT token-bearing resources (agent runtime, harness): both + * resolve an OAuth token by name and surface it in the same result shape (no invoke URL). + */ +async function fetchTokenAccess( + options: FetchAccessOptions, + label: 'agent' | 'harness', + fetchToken: (name: string, opts: { deployTarget?: string; identityName?: string }) => Promise +): Promise { if (!options.name) { - return { success: false, error: 'Missing required option: --name ' }; + return { success: false, error: `Missing required option: --name <${label}>` }; } let tokenResult: OAuthTokenResult; try { - tokenResult = await fetchRuntimeToken(options.name, { + tokenResult = await fetchToken(options.name, { deployTarget: options.target, identityName: options.identityName, }); diff --git a/src/cli/commands/fetch/command.tsx b/src/cli/commands/fetch/command.tsx index e2e43cefb..f3da8801f 100644 --- a/src/cli/commands/fetch/command.tsx +++ b/src/cli/commands/fetch/command.tsx @@ -12,9 +12,9 @@ export const registerFetch = (program: Command) => { fetchCmd .command('access') - .description('Fetch access info (URL, token, auth guidance) for a deployed gateway or agent.') - .option('--name ', 'Gateway or agent name [non-interactive]') - .option('--type ', 'Resource type: gateway (default) or agent [non-interactive]', 'gateway') + .description('Fetch access info (URL, token, auth guidance) for a deployed gateway, agent, or harness.') + .option('--name ', 'Gateway, agent, or harness name [non-interactive]') + .option('--type ', 'Resource type: gateway (default), agent, or harness [non-interactive]', 'gateway') .option('--target ', 'Deployment target [non-interactive]') .option('--identity-name ', 'Identity credential name for token fetch [non-interactive]') .option('--json', 'Output as JSON [non-interactive]') diff --git a/src/cli/commands/fetch/types.ts b/src/cli/commands/fetch/types.ts index 43b12d9cc..c507a05a7 100644 --- a/src/cli/commands/fetch/types.ts +++ b/src/cli/commands/fetch/types.ts @@ -1,4 +1,4 @@ -export type FetchResourceType = 'gateway' | 'agent'; +export type FetchResourceType = 'gateway' | 'agent' | 'harness'; export interface FetchAccessOptions { name?: string; diff --git a/src/cli/operations/fetch-access/index.ts b/src/cli/operations/fetch-access/index.ts index cbccf9c45..fa7c64ea6 100644 --- a/src/cli/operations/fetch-access/index.ts +++ b/src/cli/operations/fetch-access/index.ts @@ -5,4 +5,5 @@ export { fetchOAuthToken } from './oauth-token'; export type { OAuthTokenResult } from './oauth-token'; export { listAgents } from './list-agents'; export { listGateways } from './list-gateways'; -export type { TokenFetchResult, GatewayInfo, AgentInfo, ResourceInfo } from './types'; +export { listHarnesses } from './list-harnesses'; +export type { TokenFetchResult, GatewayInfo, AgentInfo, HarnessInfo, ResourceInfo } from './types'; diff --git a/src/cli/operations/fetch-access/list-harnesses.ts b/src/cli/operations/fetch-access/list-harnesses.ts new file mode 100644 index 000000000..cd7347dcd --- /dev/null +++ b/src/cli/operations/fetch-access/list-harnesses.ts @@ -0,0 +1,44 @@ +import { ConfigIO } from '../../../lib'; +import type { HarnessInfo } from './types'; + +/** + * List deployed harnesses with their inbound authorizer type. A harness is included only when + * it is registered in the project AND present in deployed-state (mirrors listAgents/listGateways). + * The authorizerType lives in each harness.json, so the per-harness spec is read to surface it. + */ +export async function listHarnesses( + options: { configIO?: ConfigIO; deployTarget?: string } = {} +): Promise { + const configIO = options.configIO ?? new ConfigIO(); + + const deployedState = await configIO.readDeployedState(); + const projectSpec = await configIO.readProjectSpec(); + + const targetNames = Object.keys(deployedState.targets); + if (targetNames.length === 0) return []; + + const targetName = options.deployTarget ?? targetNames[0]!; + const target = deployedState.targets[targetName]; + if (!target) return []; + + const deployedHarnesses = target.resources?.harnesses ?? {}; + + const harnesses: HarnessInfo[] = []; + + for (const harness of projectSpec.harnesses) { + const deployed = deployedHarnesses[harness.name]; + if (!deployed?.harnessArn) continue; + + let authType = 'AWS_IAM'; + try { + const spec = await configIO.readHarnessSpec(harness.name); + authType = spec.authorizerType ?? 'AWS_IAM'; + } catch { + // Spec unreadable — fall back to the AWS_IAM default rather than dropping the harness. + } + + harnesses.push({ name: harness.name, authType }); + } + + return harnesses; +} diff --git a/src/cli/operations/fetch-access/types.ts b/src/cli/operations/fetch-access/types.ts index 8f0f72472..51ff1f593 100644 --- a/src/cli/operations/fetch-access/types.ts +++ b/src/cli/operations/fetch-access/types.ts @@ -1,6 +1,6 @@ import type { GatewayAuthorizerType, RuntimeAuthorizerType } from '../../../schema'; -export type FetchResourceType = 'gateway' | 'agent'; +export type FetchResourceType = 'gateway' | 'agent' | 'harness'; export interface TokenFetchResult { url: string; @@ -20,6 +20,11 @@ export interface AgentInfo { authType: RuntimeAuthorizerType; } +export interface HarnessInfo { + name: string; + authType: string; +} + export interface ResourceInfo { name: string; resourceType: FetchResourceType; diff --git a/src/cli/tui/screens/fetch-access/FetchAccessScreen.tsx b/src/cli/tui/screens/fetch-access/FetchAccessScreen.tsx index d21d0ad40..1d1e22425 100644 --- a/src/cli/tui/screens/fetch-access/FetchAccessScreen.tsx +++ b/src/cli/tui/screens/fetch-access/FetchAccessScreen.tsx @@ -21,7 +21,9 @@ function authColor(authType: string): string { } function resourceLabel(resourceType: string): string { - return resourceType === 'agent' ? 'Agent' : 'Gateway'; + if (resourceType === 'agent') return 'Agent'; + if (resourceType === 'harness') return 'Harness'; + return 'Gateway'; } export function FetchAccessScreen({ isInteractive: _isInteractive, onExit }: FetchAccessScreenProps) { diff --git a/src/cli/tui/screens/fetch-access/__tests__/useFetchAccessFlow.test.tsx b/src/cli/tui/screens/fetch-access/__tests__/useFetchAccessFlow.test.tsx index 4fc6a9562..0649b52af 100644 --- a/src/cli/tui/screens/fetch-access/__tests__/useFetchAccessFlow.test.tsx +++ b/src/cli/tui/screens/fetch-access/__tests__/useFetchAccessFlow.test.tsx @@ -1,9 +1,15 @@ -import { fetchGatewayToken, listAgents, listGateways } from '../../../../operations/fetch-access'; +import { + fetchGatewayToken, + fetchHarnessToken, + listAgents, + listGateways, + listHarnesses, +} from '../../../../operations/fetch-access'; import { useFetchAccessFlow } from '../useFetchAccessFlow'; import { Text } from 'ink'; import { render } from 'ink-testing-library'; import React, { act, useImperativeHandle } from 'react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; // --------------------------------------------------------------------------- // Mocks @@ -12,13 +18,17 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; vi.mock('../../../../operations/fetch-access', () => ({ listGateways: vi.fn(), listAgents: vi.fn(), + listHarnesses: vi.fn(), fetchGatewayToken: vi.fn(), fetchRuntimeToken: vi.fn(), + fetchHarnessToken: vi.fn(), })); const mockListGateways = vi.mocked(listGateways); const mockListAgents = vi.mocked(listAgents); +const mockListHarnesses = vi.mocked(listHarnesses); const mockFetchGatewayToken = vi.mocked(fetchGatewayToken); +const mockFetchHarnessToken = vi.mocked(fetchHarnessToken); // --------------------------------------------------------------------------- // Fixtures @@ -107,6 +117,10 @@ ImperativeHarness.displayName = 'ImperativeHarness'; afterEach(() => vi.clearAllMocks()); +// Default the harness lister to empty so existing gateway/agent cases are unaffected; +// the harness-specific tests override it. +beforeEach(() => mockListHarnesses.mockResolvedValue([])); + describe('useFetchAccessFlow', () => { describe('initial loading state', () => { it('starts in loading phase on mount', () => { @@ -270,4 +284,49 @@ describe('useFetchAccessFlow', () => { expect(lastFrame()).toContain('phase:error'); }); }); + + describe('harness resources', () => { + it('fetches a CUSTOM_JWT token for a single deployed harness', async () => { + mockListGateways.mockResolvedValue([]); + mockListAgents.mockResolvedValue([]); + mockListHarnesses.mockResolvedValue([{ name: 'MyHarness', authType: 'CUSTOM_JWT' }]); + mockFetchHarnessToken.mockResolvedValue({ token: 'harness-token', expiresIn: 3600 }); + const ref = React.createRef(); + const { lastFrame } = render(); + + await delay(); + + expect(mockFetchHarnessToken).toHaveBeenCalledWith('MyHarness'); + expect(lastFrame()).toContain('phase:result'); + expect(ref.current!.getResult()?.authType).toBe('CUSTOM_JWT'); + expect(ref.current!.getResult()?.token).toBe('harness-token'); + }); + + it('shows the AWS_IAM guidance message for an AWS_IAM harness without fetching a token', async () => { + mockListGateways.mockResolvedValue([]); + mockListAgents.mockResolvedValue([]); + mockListHarnesses.mockResolvedValue([{ name: 'IamHarness', authType: 'AWS_IAM' }]); + const ref = React.createRef(); + const { lastFrame } = render(); + + await delay(); + + expect(mockFetchHarnessToken).not.toHaveBeenCalled(); + expect(lastFrame()).toContain('phase:result'); + expect(ref.current!.getResult()?.authType).toBe('AWS_IAM'); + expect(ref.current!.getResult()?.message).toContain('harness'); + }); + + it('includes harnesses alongside gateways and agents in the picker', async () => { + mockListGateways.mockResolvedValue([{ name: 'gw-jwt', authType: 'CUSTOM_JWT' as const }]); + mockListAgents.mockResolvedValue([{ name: 'agent-one', authType: 'AWS_IAM' as const }]); + mockListHarnesses.mockResolvedValue([{ name: 'MyHarness', authType: 'CUSTOM_JWT' }]); + const { lastFrame } = render(); + + await delay(); + + expect(lastFrame()).toContain('phase:picking'); + expect(lastFrame()).toContain('resourceCount:3'); + }); + }); }); diff --git a/src/cli/tui/screens/fetch-access/useFetchAccessFlow.ts b/src/cli/tui/screens/fetch-access/useFetchAccessFlow.ts index e0d387735..bec80d2b8 100644 --- a/src/cli/tui/screens/fetch-access/useFetchAccessFlow.ts +++ b/src/cli/tui/screens/fetch-access/useFetchAccessFlow.ts @@ -1,22 +1,35 @@ import { isMacOS, isWindows } from '../../../../lib/utils/platform'; import { getErrorMessage } from '../../../errors'; -import type { ResourceInfo, TokenFetchResult } from '../../../operations/fetch-access'; -import { fetchGatewayToken, fetchRuntimeToken, listAgents, listGateways } from '../../../operations/fetch-access'; +import type { OAuthTokenResult, ResourceInfo, TokenFetchResult } from '../../../operations/fetch-access'; +import { + fetchGatewayToken, + fetchHarnessToken, + fetchRuntimeToken, + listAgents, + listGateways, + listHarnesses, +} from '../../../operations/fetch-access'; import { spawn } from 'node:child_process'; import { useCallback, useEffect, useRef, useState } from 'react'; -async function fetchAgentAccess(resource: ResourceInfo): Promise { +/** + * 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, + * with any error (missing credential, bad config) surfacing in the error phase. + */ +async function fetchTokenAccess( + resource: ResourceInfo, + fetchToken: (name: string) => Promise +): Promise { if (resource.authType === 'AWS_IAM') { return { url: '', authType: 'AWS_IAM', - message: 'This agent uses AWS_IAM authentication. Use AWS SigV4 signing to invoke.', + message: `This ${resource.resourceType} uses AWS_IAM authentication. Use AWS SigV4 signing to invoke.`, }; } - // For CUSTOM_JWT agents, attempt token fetch directly. - // Errors (missing credential, bad config) surface in the error phase. - const tokenResult = await fetchRuntimeToken(resource.name); + const tokenResult = await fetchToken(resource.name); return { url: '', authType: 'CUSTOM_JWT', @@ -55,22 +68,23 @@ export function useFetchAccessFlow() { }; }, []); - // Load gateways and agents on mount + // Load gateways, agents, and harnesses on mount useEffect(() => { - Promise.all([listGateways(), listAgents()]) - .then(([gateways, agents]) => { + Promise.all([listGateways(), listAgents(), listHarnesses()]) + .then(([gateways, agents, harnesses]) => { if (!mountedRef.current) return; const resources: ResourceInfo[] = [ ...gateways.map(gw => ({ name: gw.name, resourceType: 'gateway' as const, authType: gw.authType })), ...agents.map(ag => ({ name: ag.name, resourceType: 'agent' as const, authType: ag.authType })), + ...harnesses.map(hn => ({ name: hn.name, resourceType: 'harness' as const, authType: hn.authType })), ]; if (resources.length === 0) { setState(prev => ({ ...prev, phase: 'error', - error: 'No deployed gateways or agents found. Run `agentcore deploy` first.', + error: 'No deployed gateways, agents, or harnesses found. Run `agentcore deploy` first.', })); return; } @@ -109,7 +123,11 @@ export function useFetchAccessFlow() { const resource = state.selectedResource; const fetchToken: Promise = - resource.resourceType === 'gateway' ? fetchGatewayToken(resource.name) : fetchAgentAccess(resource); + resource.resourceType === 'gateway' + ? fetchGatewayToken(resource.name) + : resource.resourceType === 'harness' + ? fetchTokenAccess(resource, fetchHarnessToken) + : fetchTokenAccess(resource, fetchRuntimeToken); fetchToken .then(result => { From e3be6851c5812cfe8732fb6cf88a12e9fe2f4874 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 22 Jun 2026 19:13:24 -0400 Subject: [PATCH 2/4] fix(fetch): address review on harness fetch access - fetchHarnessToken: accept identityName and forward as credentialName, so 'fetch access --type harness --identity-name X' is honored instead of silently using the default -oauth (mirrors fetchRuntimeToken). - listHarnesses: drop the silent AWS_IAM fallback on readHarnessSpec failure; let the error propagate so a corrupt/missing harness.json for a deployed harness surfaces instead of masking a CUSTOM_JWT harness as AWS_IAM. - HarnessInfo.authType: type as RuntimeAuthorizerType (was string) to match AgentInfo and get the TUI auth-type branches type-checked. - tests: replace the objectContaining({}) matcher with exact-options assertions and add a case proving --identity-name/--target are forwarded. --- .../fetch/__tests__/fetch-access.test.ts | 31 ++++++++++++++++++- .../fetch-access/fetch-harness-token.ts | 3 +- .../operations/fetch-access/list-harnesses.ts | 15 ++++----- src/cli/operations/fetch-access/types.ts | 2 +- 4 files changed, 39 insertions(+), 12 deletions(-) diff --git a/src/cli/commands/fetch/__tests__/fetch-access.test.ts b/src/cli/commands/fetch/__tests__/fetch-access.test.ts index d0b12cd1d..a768778e2 100644 --- a/src/cli/commands/fetch/__tests__/fetch-access.test.ts +++ b/src/cli/commands/fetch/__tests__/fetch-access.test.ts @@ -208,7 +208,10 @@ describe('registerFetch', () => { from: 'user', }); - expect(mockFetchHarnessToken).toHaveBeenCalledWith('MyHarness', expect.objectContaining({})); + expect(mockFetchHarnessToken).toHaveBeenCalledWith('MyHarness', { + deployTarget: undefined, + identityName: undefined, + }); expect(mockFetchGatewayToken).not.toHaveBeenCalled(); expect(mockLog).toHaveBeenCalledTimes(1); const output = JSON.parse(mockLog.mock.calls[0][0]); @@ -218,6 +221,32 @@ describe('registerFetch', () => { expect(output.expiresIn).toBe(3600); }); + it('forwards --identity-name and --target to fetchHarnessToken', async () => { + mockFetchHarnessToken.mockResolvedValue(harnessTokenResult); + + await program.parseAsync( + [ + 'fetch', + 'access', + '--type', + 'harness', + '--name', + 'MyHarness', + '--identity-name', + 'my-custom-cred', + '--target', + 'prod', + '--json', + ], + { from: 'user' } + ); + + expect(mockFetchHarnessToken).toHaveBeenCalledWith('MyHarness', { + deployTarget: 'prod', + identityName: 'my-custom-cred', + }); + }); + it('errors with a harness-specific message when --name is missing and --json flag is used', async () => { await expect( program.parseAsync(['fetch', 'access', '--type', 'harness', '--json'], { from: 'user' }) diff --git a/src/cli/operations/fetch-access/fetch-harness-token.ts b/src/cli/operations/fetch-access/fetch-harness-token.ts index 654bc5c36..f5f74828f 100644 --- a/src/cli/operations/fetch-access/fetch-harness-token.ts +++ b/src/cli/operations/fetch-access/fetch-harness-token.ts @@ -47,7 +47,7 @@ export async function canFetchHarnessToken( */ export async function fetchHarnessToken( harnessName: string, - options: { configIO?: ConfigIO; deployTarget?: string } = {} + options: { configIO?: ConfigIO; deployTarget?: string; identityName?: string } = {} ): Promise { const configIO = options.configIO ?? new ConfigIO(); @@ -79,5 +79,6 @@ export async function fetchHarnessToken( deployedState, targetName, credentials: projectSpec.credentials, + credentialName: options.identityName, }); } diff --git a/src/cli/operations/fetch-access/list-harnesses.ts b/src/cli/operations/fetch-access/list-harnesses.ts index cd7347dcd..7d107663b 100644 --- a/src/cli/operations/fetch-access/list-harnesses.ts +++ b/src/cli/operations/fetch-access/list-harnesses.ts @@ -29,15 +29,12 @@ export async function listHarnesses( const deployed = deployedHarnesses[harness.name]; if (!deployed?.harnessArn) continue; - let authType = 'AWS_IAM'; - try { - const spec = await configIO.readHarnessSpec(harness.name); - authType = spec.authorizerType ?? 'AWS_IAM'; - } catch { - // Spec unreadable — fall back to the AWS_IAM default rather than dropping the harness. - } - - harnesses.push({ name: harness.name, authType }); + // Read the spec for its authorizerType. A read failure (corrupt/missing harness.json, + // post-upgrade schema mismatch) for a deployed harness is a real config problem — let it + // propagate so the caller surfaces it, rather than masking it as AWS_IAM and silently + // steering a CUSTOM_JWT harness to the "use SigV4" path with no token. + const spec = await configIO.readHarnessSpec(harness.name); + harnesses.push({ name: harness.name, authType: spec.authorizerType ?? 'AWS_IAM' }); } return harnesses; diff --git a/src/cli/operations/fetch-access/types.ts b/src/cli/operations/fetch-access/types.ts index 51ff1f593..5f35e8cf2 100644 --- a/src/cli/operations/fetch-access/types.ts +++ b/src/cli/operations/fetch-access/types.ts @@ -22,7 +22,7 @@ export interface AgentInfo { export interface HarnessInfo { name: string; - authType: string; + authType: RuntimeAuthorizerType; } export interface ResourceInfo { From 2ad723648f1ebeec3a7019f481f87655bd1a1ca0 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 22 Jun 2026 19:38:01 -0400 Subject: [PATCH 3/4] feat(fetch): add harness to telemetry ResourceType and emit fetch.access command_run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the re-review's telemetry notes: - ResourceType enum += 'harness', so a fetch.access emission with resource_type=harness validates (the enum backs that attribute). - Wire withCommandRunTelemetry into 'fetch access' (was unwrapped — no cli.command_run was emitted at all), recording resource_type. handleFetchAccess runs once inside the wrapper; its string-error shape is adapted to the Result the telemetry layer expects while the original result drives output. Tests: 131 passing across telemetry + fetch + tui-fetch suites; ResourceType accepts 'harness' and rejects unknown values. --- src/cli/commands/fetch/command.tsx | 19 ++++++++++++++++++- src/cli/telemetry/schemas/common-shapes.ts | 2 +- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/fetch/command.tsx b/src/cli/commands/fetch/command.tsx index f3da8801f..dce9b47ed 100644 --- a/src/cli/commands/fetch/command.tsx +++ b/src/cli/commands/fetch/command.tsx @@ -1,5 +1,7 @@ import { COMMAND_DESCRIPTIONS } from '../../constants'; import { getErrorMessage } from '../../errors'; +import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; +import { ResourceType, standardize } from '../../telemetry/schemas/common-shapes.js'; import { requireProject } from '../../tui/guards'; import { handleFetchAccess } from './action'; import type { FetchAccessResult } from './action'; @@ -24,7 +26,22 @@ export const registerFetch = (program: Command) => { let result: FetchAccessResult; try { - result = await handleFetchAccess(options); + // Record cli.command_run for fetch.access. handleFetchAccess runs exactly once inside + // the telemetry wrapper; its string-error shape is adapted to the Result {success, + // error: Error} the telemetry layer expects (used only for exit_reason/error_name), + // while the original result is captured via closure to drive output below. + let captured: FetchAccessResult; + await withCommandRunTelemetry( + 'fetch.access', + { resource_type: standardize(ResourceType, options.type ?? 'gateway') }, + async () => { + captured = await handleFetchAccess(options); + return captured.success + ? { success: true as const } + : { success: false as const, error: new Error(captured.error) }; + } + ); + result = captured!; } catch (error) { if (options.json) { console.log(JSON.stringify({ success: false, error: getErrorMessage(error) })); diff --git a/src/cli/telemetry/schemas/common-shapes.ts b/src/cli/telemetry/schemas/common-shapes.ts index 3a11954e4..bf7679d80 100644 --- a/src/cli/telemetry/schemas/common-shapes.ts +++ b/src/cli/telemetry/schemas/common-shapes.ts @@ -90,7 +90,7 @@ export const OutboundAuthType = z.enum(['oauth', 'api-key', 'none']); export const PolicyEngineMode = z.enum(['log_only', 'enforce']); export const AgentProtocol = z.enum(['http', 'mcp', 'a2a', 'agui']); export const RefType = z.enum(['arn', 'name']); -export const ResourceType = z.enum(['gateway', 'agent']); +export const ResourceType = z.enum(['gateway', 'agent', 'harness']); export const JobType = z.enum(['recommendation', 'batch-evaluation', 'ab-test', 'insights']); export const RecommendationKind = z.enum(['system-prompt', 'tool-description']); export const RecommendationInputSource = z.enum(['config-bundle', 'inline', 'file']); From d0c3f75f1d93ecc3b7db4b5e97513da02d73c692 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Tue, 23 Jun 2026 11:15:42 -0400 Subject: [PATCH 4/4] docs(fetch): document harness in fetch access command reference Update docs/commands.md 'fetch access' section: --type now lists harness, add a harness usage example, and note the CUSTOM_JWT token-fetch behavior (managed OAuth credential, --identity-name override, AWS_IAM has no token). --- docs/commands.md | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 84f8856ac..23ed07b91 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1385,21 +1385,26 @@ agentcore dataset remove-version 2 --name MyDataset ### fetch access -Fetch access info (URL, token, auth guidance) for a deployed gateway or agent. +Fetch access info (URL, token, auth guidance) for a deployed gateway, agent, or harness. ```bash agentcore fetch access agentcore fetch access --name MyGateway --type gateway --json agentcore fetch access --name MyAgent --type agent --target staging +agentcore fetch access --name MyHarness --type harness --json ``` -| Flag | Description | -| ------------------------ | --------------------------------------------- | -| `--name ` | Gateway or agent name | -| `--type ` | Resource type: `gateway` (default) or `agent` | -| `--target ` | Deployment target | -| `--identity-name ` | Identity credential name for token fetch | -| `--json` | JSON output | +| Flag | Description | +| ------------------------ | --------------------------------------------------------- | +| `--name ` | Gateway, agent, or harness name | +| `--type ` | Resource type: `gateway` (default), `agent`, or `harness` | +| `--target ` | Deployment target | +| `--identity-name ` | Identity credential name for token fetch | +| `--json` | JSON output | + +For an `agent` or `harness` configured with a `CUSTOM_JWT` authorizer, this mints an OAuth bearer token via the +resource's managed OAuth credential (override the credential with `--identity-name`). A resource using `AWS_IAM` auth +has no token to fetch — use AWS SigV4 signing to invoke it. ### package