diff --git a/e2e-tests/web-search-lifecycle.test.ts b/e2e-tests/web-search-lifecycle.test.ts deleted file mode 100644 index 5063d858b..000000000 --- a/e2e-tests/web-search-lifecycle.test.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { type RunResult, hasAwsCredentials, parseJsonOutput, prereqs } from '../src/test-utils/index.js'; -import { installCdkTarball, runAgentCoreCLI, teardownE2EProject, writeAwsTargets } from './e2e-helper.js'; -import { randomUUID } from 'node:crypto'; -import { mkdir, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; - -const hasAws = hasAwsCredentials(); -const canRun = prereqs.npm && prereqs.git && prereqs.uv && hasAws; - -describe.sequential('e2e: web-search gateway target lifecycle', () => { - const suffix = Date.now().toString().slice(-8); - const agentName = `E2eWs${suffix}`; - const gatewayName = 'WsGw'; - const targetName = 'ws1'; - const toolName = `${targetName}___WebSearch`; - - let testDir: string; - let projectPath: string; - let originalRegion: string | undefined; - let lifecycleFailed = false; - - beforeAll(async () => { - if (!canRun) return; - - // Web search is only available in us-east-1. Pin the region - // so deploy doesn't target a region where the connector doesn't exist - originalRegion = process.env.AWS_REGION; - process.env.AWS_REGION = 'us-east-1'; - - testDir = join(tmpdir(), `agentcore-e2e-web-search-${randomUUID()}`); - await mkdir(testDir, { recursive: true }); - - const create = await runAgentCoreCLI( - ['create', '--name', agentName, '--no-agent', '--defaults', '--skip-git', '--skip-python-setup', '--json'], - testDir - ); - expect(create.exitCode, `Create failed: ${create.stderr}`).toBe(0); - projectPath = (parseJsonOutput(create.stdout) as { projectPath: string }).projectPath; - - await writeAwsTargets(projectPath); - installCdkTarball(projectPath); - }, 600_000); - - afterAll(async () => { - if (projectPath && hasAws) { - await teardownE2EProject(projectPath, agentName, 'Bedrock'); - } - if (testDir) await rm(testDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 1000 }); - if (originalRegion === undefined) delete process.env.AWS_REGION; - else process.env.AWS_REGION = originalRegion; - }, 600_000); - - const run = (args: string[]): Promise => runAgentCoreCLI(args, projectPath); - - // Each step short-circuits if a prior step in the lifecycle failed - const step = (label: string, fn: () => Promise, timeout: number) => - it.skipIf(!canRun)( - label, - async () => { - if (lifecycleFailed) { - throw new Error('Skipped: a prior step in the lifecycle failed.'); - } - try { - await fn(); - } catch (err) { - lifecycleFailed = true; - throw err; - } - }, - timeout - ); - - step( - 'adds a gateway and a web-search target', - async () => { - const gw = await run(['add', 'gateway', '--name', gatewayName, '--protocol-type', 'MCP', '--json']); - expect(gw.exitCode, `Add gateway failed: ${gw.stderr}`).toBe(0); - - const target = await run(['add', 'web-search', '--gateway', gatewayName, '--name', targetName, '--json']); - expect(target.exitCode, `Add web-search failed: ${target.stdout}\n${target.stderr}`).toBe(0); - }, - 120_000 - ); - - step( - 'deploys the stack', - async () => { - const result = await run(['deploy', '--yes', '--json']); - expect(result.exitCode, `Deploy failed: ${result.stderr}`).toBe(0); - }, - 900_000 - ); - - step( - 'status shows the gateway as deployed', - async () => { - const result = await run(['status', '--json']); - expect(result.exitCode, `Status failed: ${result.stderr}`).toBe(0); - - const json = parseJsonOutput(result.stdout) as { - success: boolean; - resources: { resourceType: string; name: string; deploymentState: string }[]; - }; - expect(json.success).toBe(true); - - const gateway = json.resources.find(r => r.resourceType === 'gateway' && r.name === gatewayName); - expect(gateway, 'Gateway should appear in status').toBeDefined(); - expect(gateway?.deploymentState).toBe('deployed'); - }, - 120_000 - ); - - step( - 'call-tool returns search results', - async () => { - const result = await run([ - 'invoke', - 'call-tool', - '--gateway', - gatewayName, - '--tool', - toolName, - '--input', - '{"query":"AWS Lambda pricing"}', - '--json', - ]); - expect(result.exitCode, `call-tool failed:\nstdout: ${result.stdout}\nstderr: ${result.stderr}`).toBe(0); - - const payload = parseJsonOutput(result.stdout) as { success: boolean; response?: string }; - expect(payload.success, `call-tool result not successful. Raw stdout:\n${result.stdout}`).toBe(true); - expect(typeof payload.response, 'response should be a JSON envelope string').toBe('string'); - - const envelope = JSON.parse(payload.response!) as { - result?: { content?: { text?: string }[] }; - }; - const text = envelope.result?.content?.[0]?.text; - expect(text, `call-tool envelope missing result.content[0].text. Envelope:\n${payload.response}`).toBeTruthy(); - - const inner = JSON.parse(text!) as { results?: { url?: string }[] }; - expect(Array.isArray(inner.results), 'Results array should be present').toBe(true); - expect(inner.results!.length, 'Results array should be non-empty').toBeGreaterThan(0); - }, - 180_000 - ); -}); diff --git a/integ-tests/add-remove-web-search.test.ts b/integ-tests/add-remove-web-search.test.ts deleted file mode 100644 index fa5d0ac98..000000000 --- a/integ-tests/add-remove-web-search.test.ts +++ /dev/null @@ -1,284 +0,0 @@ -import { createTestProject, runCLI } from '../src/test-utils/index.js'; -import type { TestProject } from '../src/test-utils/index.js'; -import { createTelemetryHelper } from '../src/test-utils/telemetry-helper.js'; -import { readFile } from 'node:fs/promises'; -import { join } from 'node:path'; -import { afterAll, beforeAll, describe, expect, it } from 'vitest'; - -const telemetry = createTelemetryHelper(); - -interface WebSearchTarget { - name: string; - targetType: string; - excludeDomains?: string[]; -} - -interface Gateway { - name: string; - targets?: WebSearchTarget[]; -} - -async function readProjectConfig(projectPath: string) { - return JSON.parse(await readFile(join(projectPath, 'agentcore/agentcore.json'), 'utf-8')); -} - -async function findTarget( - projectPath: string, - gatewayName: string, - targetName: string -): Promise { - const config = await readProjectConfig(projectPath); - const gateway = (config.agentCoreGateways as Gateway[]).find(g => g.name === gatewayName); - return gateway?.targets?.find(t => t.name === targetName); -} - -describe('integration: add and remove web-search via gateway-target form', () => { - let project: TestProject; - const gatewayName = 'WsGateway'; - - beforeAll(async () => { - project = await createTestProject({ noAgent: true }); - const result = await runCLI( - ['add', 'gateway', '--name', gatewayName, '--protocol-type', 'MCP', '--json'], - project.projectPath - ); - expect(result.exitCode).toBe(0); - }); - - afterAll(async () => { - await project.cleanup(); - telemetry.destroy(); - }); - - it('adds a web-search target with no excludeDomains', async () => { - const result = await runCLI( - ['add', 'gateway-target', '--type', 'web-search', '--gateway', gatewayName, '--name', 'ws-plain', '--json'], - project.projectPath, - { env: telemetry.env } - ); - - expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); - - const target = await findTarget(project.projectPath, gatewayName, 'ws-plain'); - expect(target).toBeTruthy(); - expect(target?.targetType).toBe('webSearch'); - expect(target?.excludeDomains).toBeUndefined(); - telemetry.assertMetricEmitted({ command: 'add.gateway-target', exit_reason: 'success' }); - }); - - it('adds a web-search target with --exclude-domains', async () => { - const result = await runCLI( - [ - 'add', - 'gateway-target', - '--type', - 'web-search', - '--gateway', - gatewayName, - '--name', - 'ws-filtered', - '--exclude-domains', - 'foo.com,bar.net', - '--json', - ], - project.projectPath - ); - - expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); - - const target = await findTarget(project.projectPath, gatewayName, 'ws-filtered'); - expect(target?.excludeDomains).toEqual(['foo.com', 'bar.net']); - }); - - it('trims whitespace in --exclude-domains values', async () => { - const result = await runCLI( - [ - 'add', - 'gateway-target', - '--type', - 'web-search', - '--gateway', - gatewayName, - '--name', - 'ws-trimmed', - '--exclude-domains', - 'a.com, b.com, c.com', - '--json', - ], - project.projectPath - ); - - expect(result.exitCode).toBe(0); - const target = await findTarget(project.projectPath, gatewayName, 'ws-trimmed'); - expect(target?.excludeDomains).toEqual(['a.com', 'b.com', 'c.com']); - }); - - it('rejects repeated --exclude-domains', async () => { - const result = await runCLI( - [ - 'add', - 'gateway-target', - '--type', - 'web-search', - '--gateway', - gatewayName, - '--name', - 'ws-repeat', - '--exclude-domains', - 'foo.com', - '--exclude-domains', - 'bar.net', - '--json', - ], - project.projectPath - ); - - expect(result.exitCode).not.toBe(0); - expect(result.stdout + result.stderr).toContain('--exclude-domains may only be specified once'); - const target = await findTarget(project.projectPath, gatewayName, 'ws-repeat'); - expect(target).toBeUndefined(); - }); - - it('removes a web-search target via remove gateway-target', async () => { - const result = await runCLI( - ['remove', 'gateway-target', '--name', 'ws-plain', '--yes', '--json'], - project.projectPath, - { env: telemetry.env } - ); - - expect(result.exitCode).toBe(0); - const target = await findTarget(project.projectPath, gatewayName, 'ws-plain'); - expect(target).toBeUndefined(); - telemetry.assertMetricEmitted({ command: 'remove.gateway-target', exit_reason: 'success' }); - }); - - it('rejects re-adding a target with the same name as an existing one', async () => { - const result = await runCLI( - ['add', 'gateway-target', '--type', 'web-search', '--gateway', gatewayName, '--name', 'ws-filtered', '--json'], - project.projectPath - ); - - expect(result.exitCode).not.toBe(0); - expect(result.stdout + result.stderr).toContain('already exists'); - }); -}); - -describe('integration: add and remove web-search via top-level shortcut', () => { - let project: TestProject; - const gatewayName = 'WsShortcutGateway'; - - beforeAll(async () => { - project = await createTestProject({ noAgent: true }); - const result = await runCLI( - ['add', 'gateway', '--name', gatewayName, '--protocol-type', 'MCP', '--json'], - project.projectPath - ); - expect(result.exitCode).toBe(0); - }); - - afterAll(async () => { - await project.cleanup(); - telemetry.destroy(); - }); - - it('add web-search produces the same spec shape as the long-form path', async () => { - const result = await runCLI( - ['add', 'web-search', '--gateway', gatewayName, '--name', 'ws1', '--json'], - project.projectPath, - { env: telemetry.env } - ); - - expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0); - const target = await findTarget(project.projectPath, gatewayName, 'ws1'); - expect(target?.targetType).toBe('webSearch'); - expect(target?.excludeDomains).toBeUndefined(); - telemetry.assertMetricEmitted({ command: 'add.web-search', exit_reason: 'success' }); - }); - - it('add web-search defaults --name to "web-search" when omitted', async () => { - const result = await runCLI(['add', 'web-search', '--gateway', gatewayName, '--json'], project.projectPath); - - expect(result.exitCode).toBe(0); - const target = await findTarget(project.projectPath, gatewayName, 'web-search'); - expect(target?.targetType).toBe('webSearch'); - }); - - it('rejects a second default-name add when "web-search" already exists', async () => { - const result = await runCLI(['add', 'web-search', '--gateway', gatewayName, '--json'], project.projectPath); - - expect(result.exitCode).not.toBe(0); - expect(result.stdout + result.stderr).toContain('already exists'); - }); - - it('add web-search persists --exclude-domains', async () => { - const result = await runCLI( - [ - 'add', - 'web-search', - '--gateway', - gatewayName, - '--name', - 'ws-with-filter', - '--exclude-domains', - 'example.com,blocked.org', - '--json', - ], - project.projectPath - ); - - expect(result.exitCode).toBe(0); - const target = await findTarget(project.projectPath, gatewayName, 'ws-with-filter'); - expect(target?.excludeDomains).toEqual(['example.com', 'blocked.org']); - }); - - it('remove web-search --name removes the target', async () => { - const result = await runCLI(['remove', 'web-search', '--name', 'ws1', '--yes', '--json'], project.projectPath, { - env: telemetry.env, - }); - - expect(result.exitCode).toBe(0); - const target = await findTarget(project.projectPath, gatewayName, 'ws1'); - expect(target).toBeUndefined(); - telemetry.assertMetricEmitted({ command: 'remove.web-search', exit_reason: 'success' }); - }); - - it('remove web-search rejects an unknown target name', async () => { - const result = await runCLI(['remove', 'web-search', '--name', 'does-not-exist', '--json'], project.projectPath); - - expect(result.exitCode).not.toBe(0); - expect(result.stdout + result.stderr).toContain('not found'); - }); - - it('remove web-search rejects a non-webSearch target name', async () => { - const addMcp = await runCLI( - [ - 'add', - 'gateway-target', - '--type', - 'mcp-server', - '--gateway', - gatewayName, - '--name', - 'mcp-tmp', - '--endpoint', - 'https://example.com/mcp', - '--json', - ], - project.projectPath - ); - expect(addMcp.exitCode).toBe(0); - - const result = await runCLI(['remove', 'web-search', '--name', 'mcp-tmp', '--json'], project.projectPath); - - expect(result.exitCode).not.toBe(0); - expect(result.stdout + result.stderr).toContain('not webSearch'); - const stillThere = await findTarget(project.projectPath, gatewayName, 'mcp-tmp'); - expect(stillThere).toBeTruthy(); - }); - - it('remove web-search without --name errors', async () => { - const result = await runCLI(['remove', 'web-search', '--json'], project.projectPath); - expect(result.exitCode).not.toBe(0); - expect(result.stdout + result.stderr).toContain('--name is required'); - }); -}); diff --git a/src/cli/primitives/GatewayTargetPrimitive.ts b/src/cli/primitives/GatewayTargetPrimitive.ts index 71313ea62..55df816a9 100644 --- a/src/cli/primitives/GatewayTargetPrimitive.ts +++ b/src/cli/primitives/GatewayTargetPrimitive.ts @@ -29,6 +29,7 @@ import { import type { AddGatewayTargetOptions as CLIAddGatewayTargetOptions } from '../commands/add/types'; import { validateAddGatewayTargetOptions } from '../commands/add/validate'; import { getErrorMessage } from '../errors'; +import { isGatedFeaturesEnabled } from '../feature-flags.js'; import { upsertAgenticRetrieveTarget } from '../operations/knowledge-base/agentic-retrieve-upsert'; import type { RemovableGatewayTarget } from '../operations/remove/remove-gateway-target'; import type { RemovalPreview, SchemaChange } from '../operations/remove/types'; @@ -274,8 +275,12 @@ export class GatewayTargetPrimitive extends BasePrimitive(option: T): T => (isGatedFeaturesEnabled() ? option : option.hideHelp()); + + const typeDescription = isGatedFeaturesEnabled() + ? 'Target type (required): mcp-server, api-gateway, open-api-schema, smithy-model, lambda-function-arn, http-runtime, connector, passthrough, web-search [non-interactive]' + : 'Target type (required): mcp-server, api-gateway, open-api-schema, smithy-model, lambda-function-arn, http-runtime, connector, passthrough [non-interactive]'; // Reject repeated use of --exclude-domains. Domains must be passed as a // single comma-separated value. @@ -305,10 +310,13 @@ export class GatewayTargetPrimitive extends BasePrimitive [...acc, val], [] as string[] ) - .option( - '--exclude-domains ', - 'Comma-separated domains to exclude from results (for --type web-search only) [non-interactive]', - excludeDomainsCoercer + .addOption( + gate( + new Option( + '--exclude-domains ', + 'Comma-separated domains to exclude from results (for --type web-search only) [non-interactive]' + ).argParser(excludeDomainsCoercer) + ) ) .option('--endpoint ', 'Server endpoint URL (for mcp-server type) [non-interactive]') .option('--language ', 'Language of target code: Python, TypeScript, Other [non-interactive]') @@ -587,6 +595,9 @@ Target types and their options: // Handle Amazon Web Search targets (managed-service backed via gateway IAM role) if (cliOptions.type === 'webSearch') { + if (!isGatedFeaturesEnabled()) { + throw new ValidationError('Web search target type is not yet available.'); + } const excludeDomains = typeof cliOptions.excludeDomains === 'string' ? cliOptions.excludeDomains @@ -876,13 +887,17 @@ Target types and their options: // Top-level shortcuts: agentcore add web-search / remove web-search // ────────────────────────────────────────────────────────────────── addCmd - .command('web-search') + .command('web-search', { hidden: !isGatedFeaturesEnabled() }) .description('Wire the Amazon Web Search managed connector to a gateway as a target.') .option('--name ', 'Target name (default: web-search) [non-interactive]') .option('--gateway ', 'Gateway to attach this target to [non-interactive]') .option('--exclude-domains ', 'Comma-separated domains to exclude from results [non-interactive]') .option('--json', 'Output as JSON [non-interactive]') .action(async (cliOptions: { name?: string; gateway?: string; excludeDomains?: string; json?: boolean }) => { + if (!isGatedFeaturesEnabled()) { + console.error('Error: Web search target type is not yet available.'); + process.exit(1); + } if (!findConfigRoot()) { console.error('No agentcore project found. Run `agentcore create` first.'); process.exit(1); @@ -970,13 +985,17 @@ Target types and their options: }); removeCmd - .command('web-search') + .command('web-search', { hidden: !isGatedFeaturesEnabled() }) .description('Remove an Amazon Web Search gateway target from the project') .option('--name ', 'Name of the web-search target to remove [non-interactive]') .option('-y, --yes', 'Skip confirmation prompt [non-interactive]') .option('--json', 'Output as JSON [non-interactive]') .action(async (cliOptions: { name?: string; yes?: boolean; json?: boolean }) => { try { + if (!isGatedFeaturesEnabled()) { + console.error('Web search target type is not yet available.'); + process.exit(1); + } if (!findConfigRoot()) { console.error('No agentcore project found. Run `agentcore create` first.'); process.exit(1); diff --git a/src/cli/tui/screens/add/AddScreen.tsx b/src/cli/tui/screens/add/AddScreen.tsx index 186309830..5da5bda5b 100644 --- a/src/cli/tui/screens/add/AddScreen.tsx +++ b/src/cli/tui/screens/add/AddScreen.tsx @@ -50,7 +50,7 @@ const ADD_RESOURCES: { id: AddResourceType; title: string; description: string } ]; const ADD_RESOURCE_ITEMS: SelectableItem[] = ADD_RESOURCES.map(r => { - const gated = r.id === 'knowledge-base' && !isGatedFeaturesEnabled(); + const gated = (r.id === 'knowledge-base' || r.id === 'web-search') && !isGatedFeaturesEnabled(); return { ...r, disabled: gated, diff --git a/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx b/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx index 82ae13de0..7ca2a3cb7 100644 --- a/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx +++ b/src/cli/tui/screens/add/__tests__/AddScreen.test.tsx @@ -32,4 +32,15 @@ describe('AddScreen', () => { expect(lastFrame()).toContain('Payment Manager'); expect(lastFrame()).toContain('Payment Connector'); }); + + it('Web Search option shows Coming soon when ENABLE_GATED_FEATURES is unset', () => { + delete process.env.ENABLE_GATED_FEATURES; + const onSelect = vi.fn(); + const onExit = vi.fn(); + + const { lastFrame } = render(); + + expect(lastFrame()).toContain('Web Search'); + expect(lastFrame()).toContain('Coming soon'); + }); }); diff --git a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx index 62ae71dfe..dcf29bb8e 100644 --- a/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx +++ b/src/cli/tui/screens/mcp/AddGatewayTargetScreen.tsx @@ -24,6 +24,7 @@ import { getOutboundAuthOptions, } from './types'; import { useAddGatewayTargetWizard } from './useAddGatewayTargetWizard'; +import { isGatedFeaturesEnabled } from '@/cli/feature-flags'; import { Box, Text } from 'ink'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; @@ -153,11 +154,15 @@ export function AddGatewayTargetScreen({ ); const targetTypeItems: SelectableItem[] = useMemo( () => - TARGET_TYPE_OPTIONS.map(o => ({ - id: o.id, - title: o.title, - description: o.description, - })), + TARGET_TYPE_OPTIONS.map(o => { + const gated = o.id === 'webSearch' && !isGatedFeaturesEnabled(); + return { + id: o.id, + title: o.title, + description: gated ? 'Coming soon' : o.description, + disabled: gated, + }; + }), [] ); const outboundAuthItems: SelectableItem[] = useMemo(