From 8da0e044db13aa6783c659845dd05ff87c1402cf Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 25 Mar 2026 12:38:09 -0400 Subject: [PATCH] fix: pass requestHeaderAllowlist through create flow and fix tag command types Two fixes: 1. The create flow was dropping requestHeaderAllowlist when building the GenerateConfig from AddAgentConfig. Headers entered through the TUI Advanced settings were collected correctly but never written to agentcore.json because two GenerateConfig construction sites omitted the field. Adds the missing field to useCreateFlow.ts (TUI create path) and create/action.ts (CLI create path). 2. The tag command referenced a missing ./types module and stale readMcpSpec/writeMcpSpec methods removed in the mcp.json merge (#605). Creates the types file and updates gateway tag operations to use the project spec instead. --- src/cli/commands/create/action.ts | 3 + src/cli/commands/tag/__tests__/action.test.ts | 185 +++++++++++++++ src/cli/commands/tag/action.ts | 221 ++++++++++++++++++ src/cli/commands/tag/types.ts | 32 +++ src/cli/tui/screens/create/useCreateFlow.ts | 1 + 5 files changed, 442 insertions(+) create mode 100644 src/cli/commands/tag/__tests__/action.test.ts create mode 100644 src/cli/commands/tag/action.ts create mode 100644 src/cli/commands/tag/types.ts diff --git a/src/cli/commands/create/action.ts b/src/cli/commands/create/action.ts index 0bbd60047..ac11e14ed 100644 --- a/src/cli/commands/create/action.ts +++ b/src/cli/commands/create/action.ts @@ -136,6 +136,7 @@ export interface CreateWithAgentOptions { networkMode?: NetworkMode; subnets?: string[]; securityGroups?: string[]; + requestHeaderAllowlist?: string[]; agentId?: string; agentAliasId?: string; region?: string; @@ -158,6 +159,7 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P networkMode, subnets, securityGroups, + requestHeaderAllowlist, skipGit, skipPythonSetup, onProgress, @@ -234,6 +236,7 @@ export async function createProjectWithAgent(options: CreateWithAgentOptions): P networkMode, subnets, securityGroups, + requestHeaderAllowlist, }; // Resolve credential strategy FIRST (new project has no existing credentials) diff --git a/src/cli/commands/tag/__tests__/action.test.ts b/src/cli/commands/tag/__tests__/action.test.ts new file mode 100644 index 000000000..b673c4b20 --- /dev/null +++ b/src/cli/commands/tag/__tests__/action.test.ts @@ -0,0 +1,185 @@ +import { addTag, listTags, removeDefaultTag, removeTag, setDefaultTag } from '../action.js'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockReadProjectSpec, mockWriteProjectSpec, mockFindConfigRoot } = vi.hoisted(() => ({ + mockReadProjectSpec: vi.fn(), + mockWriteProjectSpec: vi.fn(), + mockFindConfigRoot: vi.fn(), +})); + +vi.mock('../../../../lib/index.js', () => ({ + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + writeProjectSpec = mockWriteProjectSpec; + }, + findConfigRoot: mockFindConfigRoot, + NoProjectError: class NoProjectError extends Error { + constructor() { + super('No AgentCore project found'); + this.name = 'NoProjectError'; + } + }, +})); + +const baseSpec = () => ({ + name: 'TestProject', + version: 1, + tags: { 'agentcore:created-by': 'agentcore-cli' }, + agents: [ + { + type: 'AgentCoreRuntime', + name: 'myAgent', + build: 'CodeZip', + entrypoint: 'main.py', + codeLocation: 'app/myAgent', + runtimeVersion: 'python3.13', + protocol: 'HTTP', + }, + ], + memories: [{ type: 'AgentCoreMemory', name: 'myMemory', eventExpiryDuration: 30, strategies: [] }], + credentials: [], + agentCoreGateways: [ + { + name: 'myGateway', + targets: [], + authorizerType: 'NONE', + enableSemanticSearch: true, + exceptionLevel: 'NONE', + }, + ], +}); + +beforeEach(() => { + vi.clearAllMocks(); + mockFindConfigRoot.mockReturnValue('/fake/config/root'); + mockReadProjectSpec.mockResolvedValue(baseSpec()); + mockWriteProjectSpec.mockResolvedValue(undefined); +}); + +describe('listTags', () => { + it('returns project defaults and all resources with merged tags', async () => { + const result = await listTags(); + expect(result.projectDefaults).toEqual({ 'agentcore:created-by': 'agentcore-cli' }); + expect(result.resources).toHaveLength(3); + expect(result.resources[0]).toEqual({ + type: 'agent', + name: 'myAgent', + tags: { 'agentcore:created-by': 'agentcore-cli' }, + }); + }); + + it('filters by resource ref', async () => { + const result = await listTags('agent:myAgent'); + expect(result.resources).toHaveLength(1); + expect(result.resources[0]!.name).toBe('myAgent'); + }); + + it('throws on nonexistent resource', async () => { + await expect(listTags('agent:nonexistent')).rejects.toThrow('not found'); + }); +}); + +describe('addTag', () => { + it('adds tag to agent and writes spec', async () => { + const result = await addTag('agent:myAgent', 'env', 'prod'); + expect(result.success).toBe(true); + expect(mockWriteProjectSpec).toHaveBeenCalledTimes(1); + const written = mockWriteProjectSpec.mock.calls[0]![0]; + expect(written.agents[0].tags).toEqual({ env: 'prod' }); + }); + + it('adds tag to gateway and writes project spec', async () => { + const result = await addTag('gateway:myGateway', 'env', 'prod'); + expect(result.success).toBe(true); + expect(mockWriteProjectSpec).toHaveBeenCalledTimes(1); + const written = mockWriteProjectSpec.mock.calls[0]![0]; + expect(written.agentCoreGateways[0].tags).toEqual({ env: 'prod' }); + }); + + it('throws for invalid resource ref', async () => { + await expect(addTag('invalid', 'key', 'value')).rejects.toThrow('Invalid resource reference'); + }); + + it('throws for nonexistent resource', async () => { + await expect(addTag('agent:noSuchAgent', 'key', 'value')).rejects.toThrow('not found'); + }); + + it('rejects empty tag key', async () => { + await expect(addTag('agent:myAgent', '', 'value')).rejects.toThrow('Invalid tag key'); + }); + + it('rejects tag key exceeding 128 chars', async () => { + await expect(addTag('agent:myAgent', 'k'.repeat(129), 'value')).rejects.toThrow('Invalid tag key'); + }); + + it('rejects tag value exceeding 256 chars', async () => { + await expect(addTag('agent:myAgent', 'key', 'v'.repeat(257))).rejects.toThrow('Invalid tag value'); + }); + + it('rejects tag key with invalid characters', async () => { + await expect(addTag('agent:myAgent', 'key\x00bad', 'value')).rejects.toThrow('Invalid tag key'); + }); + + it('rejects tag value with invalid characters', async () => { + await expect(addTag('agent:myAgent', 'key', 'value\x00bad')).rejects.toThrow('Invalid tag value'); + }); + + it('rejects agentcore: prefixed keys', async () => { + await expect(addTag('agent:myAgent', 'agentcore:custom', 'value')).rejects.toThrow('managed by the system'); + }); +}); + +describe('removeTag', () => { + it('removes tag from agent', async () => { + const spec = baseSpec(); + (spec.agents[0] as Record).tags = { env: 'prod', team: 'a' }; + mockReadProjectSpec.mockResolvedValue(spec); + + const result = await removeTag('agent:myAgent', 'env'); + expect(result.success).toBe(true); + const written = mockWriteProjectSpec.mock.calls[0]![0]; + expect(written.agents[0].tags).toEqual({ team: 'a' }); + }); + + it('throws when key not found with hint about defaults', async () => { + await expect(removeTag('agent:myAgent', 'nonexistent')).rejects.toThrow('remove-defaults'); + }); + + it('rejects agentcore: prefixed keys', async () => { + await expect(removeTag('agent:myAgent', 'agentcore:created-by')).rejects.toThrow('managed by the system'); + }); +}); + +describe('setDefaultTag', () => { + it('sets project-level default tag', async () => { + const result = await setDefaultTag('team', 'platform'); + expect(result.success).toBe(true); + const written = mockWriteProjectSpec.mock.calls[0]![0]; + expect(written.tags).toEqual({ 'agentcore:created-by': 'agentcore-cli', team: 'platform' }); + }); + + it('rejects agentcore: prefixed keys', async () => { + await expect(setDefaultTag('agentcore:custom', 'value')).rejects.toThrow('managed by the system'); + }); +}); + +describe('removeDefaultTag', () => { + it('rejects removal of agentcore: prefixed system tags', async () => { + await expect(removeDefaultTag('agentcore:created-by')).rejects.toThrow('managed by the system'); + }); + + it('removes user-defined project-level default tag', async () => { + const spec = baseSpec(); + (spec.tags as Record).team = 'platform'; + mockReadProjectSpec.mockResolvedValue(spec); + + const result = await removeDefaultTag('team'); + expect(result.success).toBe(true); + const written = mockWriteProjectSpec.mock.calls[0]![0]; + expect(written.tags).toEqual({ 'agentcore:created-by': 'agentcore-cli' }); + }); + + it('throws when key not found', async () => { + await expect(removeDefaultTag('nonexistent')).rejects.toThrow('not found'); + }); +}); diff --git a/src/cli/commands/tag/action.ts b/src/cli/commands/tag/action.ts new file mode 100644 index 000000000..408494b36 --- /dev/null +++ b/src/cli/commands/tag/action.ts @@ -0,0 +1,221 @@ +import { ConfigIO, NoProjectError, findConfigRoot } from '../../../lib'; +import { TagKeySchema, TagValueSchema } from '../../../schema/schemas/primitives/tags'; +import type { ResourceRef, ResourceTagInfo, TagListResult, TaggableResourceType } from './types'; +import { TAGGABLE_RESOURCE_TYPES } from './types'; + +function getConfigIO(): ConfigIO { + const configRoot = findConfigRoot(); + if (!configRoot) { + throw new NoProjectError(); + } + return new ConfigIO({ baseDir: configRoot }); +} + +function parseResourceRef(ref: string): ResourceRef { + const colonIndex = ref.indexOf(':'); + if (colonIndex === -1) { + throw new Error(`Invalid resource reference "${ref}". Expected format: type:name (e.g., agent:MyAgent)`); + } + const type = ref.substring(0, colonIndex) as TaggableResourceType; + const name = ref.substring(colonIndex + 1); + + if (!TAGGABLE_RESOURCE_TYPES.includes(type)) { + throw new Error(`Invalid resource type "${type}". Taggable types: ${TAGGABLE_RESOURCE_TYPES.join(', ')}`); + } + if (!name) { + throw new Error(`Resource name is required in reference "${ref}".`); + } + return { type, name }; +} + +export async function listTags(resourceFilter?: string): Promise { + const configIO = getConfigIO(); + const spec = await configIO.readProjectSpec(); + const projectDefaults = spec.tags ?? {}; + const resources: ResourceTagInfo[] = []; + + // Collect agents + for (const agent of spec.agents ?? []) { + resources.push({ + type: 'agent', + name: agent.name, + tags: { ...projectDefaults, ...(agent.tags ?? {}) }, + }); + } + + // Collect memories + for (const memory of spec.memories ?? []) { + resources.push({ + type: 'memory', + name: memory.name, + tags: { ...projectDefaults, ...(memory.tags ?? {}) }, + }); + } + + // Collect gateways (now in project spec after mcp.json merge) + for (const gateway of spec.agentCoreGateways ?? []) { + resources.push({ + type: 'gateway', + name: gateway.name, + tags: { ...projectDefaults, ...(gateway.tags ?? {}) }, + }); + } + + // Collect evaluators + for (const evaluator of spec.evaluators ?? []) { + resources.push({ + type: 'evaluator', + name: evaluator.name, + tags: { ...projectDefaults, ...(evaluator.tags ?? {}) }, + }); + } + + // Collect policy engines + for (const engine of spec.policyEngines ?? []) { + resources.push({ + type: 'policy-engine', + name: engine.name, + tags: { ...projectDefaults, ...(engine.tags ?? {}) }, + }); + } + + // Collect online eval configs + for (const config of spec.onlineEvalConfigs ?? []) { + resources.push({ + type: 'online-eval-config', + name: config.name, + tags: { ...projectDefaults, ...(config.tags ?? {}) }, + }); + } + + // Apply filter if specified + if (resourceFilter) { + const ref = parseResourceRef(resourceFilter); + const filtered = resources.filter(r => r.type === ref.type && r.name === ref.name); + if (filtered.length === 0) { + throw new Error(`Resource "${resourceFilter}" not found.`); + } + return { projectDefaults, resources: filtered }; + } + + return { projectDefaults, resources }; +} + +function validateTagKeyValue(key: string, value: string): void { + if (key.startsWith('agentcore:')) { + throw new Error('Tag keys starting with "agentcore:" are managed by the system and cannot be modified.'); + } + const keyResult = TagKeySchema.safeParse(key); + if (!keyResult.success) { + throw new Error(`Invalid tag key: ${keyResult.error.issues[0]?.message ?? 'validation failed'}`); + } + const valueResult = TagValueSchema.safeParse(value); + if (!valueResult.success) { + throw new Error(`Invalid tag value: ${valueResult.error.issues[0]?.message ?? 'validation failed'}`); + } +} + +export async function addTag(resourceRefStr: string, key: string, value: string): Promise<{ success: boolean }> { + validateTagKeyValue(key, value); + const ref = parseResourceRef(resourceRefStr); + const configIO = getConfigIO(); + + if ( + ref.type === 'agent' || + ref.type === 'memory' || + ref.type === 'evaluator' || + ref.type === 'policy-engine' || + ref.type === 'online-eval-config' + ) { + const spec = await configIO.readProjectSpec(); + let collection: { name: string; tags?: Record }[] | undefined; + if (ref.type === 'agent') collection = spec.agents; + else if (ref.type === 'memory') collection = spec.memories; + else if (ref.type === 'evaluator') collection = spec.evaluators; + else if (ref.type === 'policy-engine') collection = spec.policyEngines; + else if (ref.type === 'online-eval-config') collection = spec.onlineEvalConfigs; + + const resource = (collection ?? []).find(r => r.name === ref.name); + if (!resource) { + throw new Error(`${ref.type} "${ref.name}" not found in project.`); + } + resource.tags = { ...(resource.tags ?? {}), [key]: value }; + await configIO.writeProjectSpec(spec); + } else if (ref.type === 'gateway') { + const spec = await configIO.readProjectSpec(); + const gateway = spec.agentCoreGateways.find(g => g.name === ref.name); + if (!gateway) { + throw new Error(`gateway "${ref.name}" not found in project.`); + } + gateway.tags = { ...(gateway.tags ?? {}), [key]: value }; + await configIO.writeProjectSpec(spec); + } + + return { success: true }; +} + +export async function removeTag(resourceRefStr: string, key: string): Promise<{ success: boolean }> { + if (key.startsWith('agentcore:')) { + throw new Error('Tag keys starting with "agentcore:" are managed by the system and cannot be modified.'); + } + const ref = parseResourceRef(resourceRefStr); + const configIO = getConfigIO(); + const spec = await configIO.readProjectSpec(); + + let collection: { name: string; tags?: Record }[] | undefined; + if (ref.type === 'agent') collection = spec.agents; + else if (ref.type === 'memory') collection = spec.memories; + else if (ref.type === 'evaluator') collection = spec.evaluators; + else if (ref.type === 'policy-engine') collection = spec.policyEngines; + else if (ref.type === 'online-eval-config') collection = spec.onlineEvalConfigs; + else if (ref.type === 'gateway') collection = spec.agentCoreGateways; + + const resource = (collection ?? []).find(r => r.name === ref.name); + if (!resource) { + throw new Error(`${ref.type} "${ref.name}" not found in project.`); + } + if (!resource.tags || !(key in resource.tags)) { + throw new Error( + `Tag key "${key}" not found on ${ref.type} "${ref.name}". ` + + `If this is an inherited project default, use "tag remove-defaults --key ${key}" instead.` + ); + } + delete resource.tags[key]; + if (Object.keys(resource.tags).length === 0) { + resource.tags = undefined; + } + await configIO.writeProjectSpec(spec); + + return { success: true }; +} + +export async function setDefaultTag(key: string, value: string): Promise<{ success: boolean }> { + validateTagKeyValue(key, value); + const configIO = getConfigIO(); + const spec = await configIO.readProjectSpec(); + spec.tags = { ...(spec.tags ?? {}), [key]: value }; + await configIO.writeProjectSpec(spec); + return { success: true }; +} + +export async function removeDefaultTag(key: string): Promise<{ success: boolean }> { + if (key.startsWith('agentcore:')) { + throw new Error('Tag keys starting with "agentcore:" are managed by the system and cannot be modified.'); + } + const configIO = getConfigIO(); + const spec = await configIO.readProjectSpec(); + if (!spec.tags || !(key in spec.tags)) { + throw new Error(`Default tag key "${key}" not found.`); + } + delete spec.tags[key]; + if (Object.keys(spec.tags).length === 0) { + spec.tags = undefined; + } + await configIO.writeProjectSpec(spec); + return { success: true }; +} + +export async function getAvailableResources(): Promise { + const result = await listTags(); + return result.resources.map(r => `${r.type}:${r.name}`); +} diff --git a/src/cli/commands/tag/types.ts b/src/cli/commands/tag/types.ts new file mode 100644 index 000000000..a1d000d38 --- /dev/null +++ b/src/cli/commands/tag/types.ts @@ -0,0 +1,32 @@ +export type TaggableResourceType = + | 'agent' + | 'memory' + | 'gateway' + | 'evaluator' + | 'policy-engine' + | 'online-eval-config'; + +export const TAGGABLE_RESOURCE_TYPES: TaggableResourceType[] = [ + 'agent', + 'memory', + 'gateway', + 'evaluator', + 'policy-engine', + 'online-eval-config', +]; + +export interface ResourceRef { + type: TaggableResourceType; + name: string; +} + +export interface ResourceTagInfo { + type: TaggableResourceType; + name: string; + tags: Record; +} + +export interface TagListResult { + projectDefaults: Record; + resources: ResourceTagInfo[]; +} diff --git a/src/cli/tui/screens/create/useCreateFlow.ts b/src/cli/tui/screens/create/useCreateFlow.ts index 1e2fed9c2..a1dbc2ae2 100644 --- a/src/cli/tui/screens/create/useCreateFlow.ts +++ b/src/cli/tui/screens/create/useCreateFlow.ts @@ -285,6 +285,7 @@ export function useCreateFlow(cwd: string): CreateFlowState { networkMode: addAgentConfig.networkMode, subnets: addAgentConfig.subnets, securityGroups: addAgentConfig.securityGroups, + requestHeaderAllowlist: addAgentConfig.requestHeaderAllowlist, }; logger.logSubStep(`Framework: ${generateConfig.sdk}`);