diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 19d07cf287..9e95a3a55d 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -1542,3 +1542,174 @@ describe("CdkBackend.resolveProjectResources", () => { ).toEqual([["gateway", "gw", ["owned", "second"]]]); }); }); + +describe("CdkBackend.deploy and the Box gateway", () => { + const MEMORY = { name: "recall", eventExpiryDuration: 30, strategies: [] }; + const runtime = (name: string) => ({ + name, + build: "CodeZip", + entrypoint: "main.py", + codeLocation: `app/${name}`, + runtimeVersion: "PYTHON_3_12", + }); + const RUNTIMES = [runtime("agent"), runtime("my_agent")]; + const RECORDED = ["example-default-agent-box", "example-default-my-agent-box"]; + const LOST_MARKER_ERROR = + `agentcore.json has no "projectType": "box", so this deploy would delete the Box ` + + `gateways 'example-default-agent-box', 'example-default-my-agent-box'. Add "projectType": ` + + `"box" back to agentcore.json, or re-run with --yes to delete the gateways.`; + + async function boxProject(spec: Record): Promise { + const input = await project(); + return { ...input, spec: ProjectSpecSchema.parse({ name: "example", version: 2, ...spec }) }; + } + + async function targetState(input: Project): Promise> { + const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH); + return JSON.parse(await Bun.file(statePath).text()).targets.default; + } + + async function recordBoxGateways(input: Project, names: string[] = RECORDED): Promise { + await updateTargetState(json, input.rootPath, TARGET.name, { + stackArn: STACK_ARN, + resources: { boxGateways: names.map((name) => ({ name })) }, + }); + } + + test("records the Box gateway of each runtime after the deploy", async () => { + const input = await boxProject({ projectType: "box", runtimes: RUNTIMES }); + await writeAssembly(input, [TARGET.name]); + + await collectDeploy(harness().backend.deploy(input, deployInput())); + + expect((await targetState(input)).resources).toEqual({ + credentials: {}, + boxGateways: RECORDED.map((name) => ({ name })), + }); + }); + + test("records no Box gateway for a Box project with no runtime", async () => { + const input = await boxProject({ projectType: "box", memories: [MEMORY] }); + await writeAssembly(input, [TARGET.name]); + + await collectDeploy(harness().backend.deploy(input, deployInput())); + + expect((await targetState(input)).resources).toEqual({ credentials: {} }); + }); + + test("records the v0 name, with no target segment, for a migrated project", async () => { + const input = await boxProject({ + projectType: "box", + migratedFrom: "v1", + runtimes: [runtime("agent")], + }); + await writeAssembly(input, [TARGET.name]); + + await collectDeploy(harness().backend.deploy(input, deployInput())); + + expect((await targetState(input)).resources).toMatchObject({ + boxGateways: [{ name: "example-agent-box" }], + }); + }); + + test("stops a deploy that would delete a recorded Box gateway, before any change", async () => { + const input = await boxProject({ memories: [MEMORY] }); + await recordBoxGateways(input); + await writeAssembly(input, [TARGET.name]); + const requests: unknown[] = []; + const subject = harness(); + + await expect( + collectDeploy( + subject.backend.deploy( + input, + deployInput({ + confirmBoxGatewayDeletion: async (request) => { + requests.push(request); + return false; + }, + }), + ), + ), + ).rejects.toThrow(LOST_MARKER_ERROR); + + expect(requests).toEqual([ + { projectName: "example", targetName: "default", gatewayNames: RECORDED }, + ]); + expect(subject.commands).toEqual([]); + expect(subject.runs).toEqual([]); + expect((await targetState(input)).resources).toEqual({ + boxGateways: RECORDED.map((name) => ({ name })), + }); + }); + + test("stops the deploy when no confirmation handler is given, and names one gateway", async () => { + const input = await boxProject({ memories: [MEMORY] }); + await recordBoxGateways(input, ["example-default-agent-box"]); + await writeAssembly(input, [TARGET.name]); + + await expect(collectDeploy(harness().backend.deploy(input, deployInput()))).rejects.toThrow( + `agentcore.json has no "projectType": "box", so this deploy would delete the Box gateway ` + + `'example-default-agent-box'. Add "projectType": "box" back to agentcore.json, or re-run ` + + `with --yes to delete the gateway.`, + ); + }); + + test("deploys when the deletion is confirmed, and clears the record", async () => { + const input = await boxProject({ memories: [MEMORY] }); + await recordBoxGateways(input); + await writeAssembly(input, [TARGET.name]); + const subject = harness(); + + await collectDeploy( + subject.backend.deploy(input, deployInput({ confirmBoxGatewayDeletion: async () => true })), + ); + + expect(subject.runs.map(({ operation }) => operation.kind)).toEqual(["deploy"]); + expect((await targetState(input)).resources).toEqual({ credentials: {} }); + }); + + test("leaves a project with no resources to the teardown confirmation alone", async () => { + // The state after `agentcore remove all`: no projectType and no resources. + const input = await boxProject({}); + await recordBoxGateways(input); + await writeAssembly(input, [TARGET.name], { resources: METADATA_ONLY }); + let asked = false; + + await expect( + collectDeploy( + harness().backend.deploy( + input, + deployInput({ + confirmBoxGatewayDeletion: async () => { + asked = true; + return false; + }, + }), + ), + ), + ).rejects.toThrow(/would delete stack 'AgentCore-example-default-0'.*--yes/s); + expect(asked).toBe(false); + }); + + test("never asks about a Box gateway in a project that has never had one", async () => { + const input = await boxProject({ memories: [MEMORY] }); + await writeAssembly(input, [TARGET.name]); + let asked = false; + + await collectDeploy( + harness().backend.deploy( + input, + deployInput({ + confirmBoxGatewayDeletion: async () => { + asked = true; + return true; + }, + }), + ), + ); + + expect(asked).toBe(false); + expect((await targetState(input)).resources).toEqual({ credentials: {} }); + }); +}); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 85b1c1d337..7285837567 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -23,6 +23,12 @@ import { import { withOutputEvents } from "../events"; import type { Logger } from "../../../logging"; import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; +import { + boxGatewayAwsName, + declaresNoResources, + describeBoxGateways, + isBoxProject, +} from "../../../projectSchemas/project"; import type { DeployBackendInput, ProjectBackend, @@ -269,6 +275,7 @@ export class CdkBackend implements ProjectBackend { // before any AWS mutation, so a local problem never leaves credentials // provisioned or the stack ARN unrecorded. await this.ensureCdkDependencies(project); + await this.assertBoxGatewayKept(project, input); // Read before provisioning rewrites the credentials map: it is the only record // of what this target provisioned, so it is the only way to find a provider whose @@ -370,9 +377,19 @@ export class CdkBackend implements ProjectBackend { // Persist the deployed stack's ARN so later commands read live resource state // from CloudFormation. Merged per target, so deploying one target never drops // another's recorded state. + // The Box gateways are recorded so that a later deploy can see a lost project type. A deploy + // without it clears the record: the stack no longer has the gateways. await updateTargetState(this.json, project.rootPath, target.name, { stackArn, - resources: { bmaSession: undefined }, + resources: { + bmaSession: undefined, + boxGateways: + isBoxProject(project.spec) && project.spec.runtimes.length > 0 + ? project.spec.runtimes.map((runtime) => ({ + name: boxGatewayAwsName(project.spec, target.name, runtime.name), + })) + : undefined, + }, }); const bmaRuntimes = project.spec.runtimes.filter( @@ -422,6 +439,35 @@ export class CdkBackend implements ProjectBackend { return { outputs }; } + /** + * Stops a deploy that would delete the Box gateways because agentcore.json lost its + * "projectType": "box", unless the user confirms. A spec with no resources skips this: that is the state + * after `agentcore remove all`, and the teardown confirmation already covers every resource. + */ + private async assertBoxGatewayKept(project: Project, input: DeployBackendInput): Promise { + if (isBoxProject(project.spec) || declaresNoResources(project.spec)) return; + const { target } = input; + const state = await readDeployedState(this.json, project.rootPath); + const gatewayNames = (state.targets[target.name]?.resources?.boxGateways ?? []).map( + ({ name }) => name, + ); + if (gatewayNames.length === 0) return; + const confirmed = await input.confirmBoxGatewayDeletion?.({ + projectName: project.name, + targetName: target.name, + gatewayNames, + }); + if (!confirmed) { + const plural = gatewayNames.length === 1 ? "gateway" : "gateways"; + throw new ProjectStateError( + `agentcore.json has no "projectType": "box", so this deploy would delete ` + + `${describeBoxGateways(gatewayNames)}. Add "projectType": "box" back to agentcore.json, ` + + `or re-run with --yes to delete the ${plural}.`, + { meta: { gatewayNames } }, + ); + } + } + /** * Removes the target's stack, for a deploy of a project that declares nothing * to deploy. diff --git a/src/core/project/backends/cdk/deployedState.ts b/src/core/project/backends/cdk/deployedState.ts index 019aee363a..da6da56202 100644 --- a/src/core/project/backends/cdk/deployedState.ts +++ b/src/core/project/backends/cdk/deployedState.ts @@ -46,6 +46,12 @@ const ResourceStateSchema = z runtimeArns: z.array(z.string()), }) .optional(), + /** + * The Box gateways (one for each runtime) that the last deploy of a Box project created. A + * deploy reads them to find out that agentcore.json lost its "projectType": "box", which would + * delete the gateways. + */ + boxGateways: z.array(z.object({ name: z.string() }).passthrough()).optional(), // The legacy deployer recorded the CloudFormation stack name // here. New deploys record the stack ARN instead. Keep this // field so projects can be correctly inspected after upgrading. diff --git a/src/core/project/backends/types.ts b/src/core/project/backends/types.ts index 1f54d772b1..4dcef91406 100644 --- a/src/core/project/backends/types.ts +++ b/src/core/project/backends/types.ts @@ -5,6 +5,7 @@ import type { ResolvedDeployedResource, ResolvedProjectResource, TeardownConfirmationHandler, + BoxGatewayDeletionHandler, } from "../../../handlers/project/types"; import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; @@ -13,6 +14,8 @@ export type DeployBackendInput = { target: AwsDeploymentTarget; /** Requests approval after synthesis identifies a teardown. */ confirmTeardown: TeardownConfirmationHandler; + /** Requests approval for a deploy that would delete the Box gateway; absent means declined. */ + confirmBoxGatewayDeletion?: BoxGatewayDeletionHandler; /** Whether to enable CloudWatch Transaction Search on deploy (global config). */ transactionSearch?: boolean; /** Leaves the CDK bootstrap stack alone for accounts bootstrapped outside the CLI. */ diff --git a/src/core/project/manager.box.test.ts b/src/core/project/manager.box.test.ts new file mode 100644 index 0000000000..b3813df9a4 --- /dev/null +++ b/src/core/project/manager.box.test.ts @@ -0,0 +1,378 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { ResourceNotFoundError } from "../../errors/errors"; +import type { AddResourceInput, Project } from "../../handlers/project/types"; +import { createSilentLogger, TestIdentityClient } from "../../testing"; +import { FsProjectManager } from "./manager"; + +/** + * `"projectType": "box"` is written by `agentcore create` only. In a project with the + * Box project, the gateway entry named `box` holds the targets of the Box gateway, which the CDK + * library makes from the project type, so the entry comes and goes with its targets. A project + * without the field behaves as before and never gets it. + */ + +const tempDirectories: string[] = []; +const originalCwd = process.cwd(); + +afterEach(async () => { + process.chdir(originalCwd); + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +function subject(): FsProjectManager { + return new FsProjectManager({ + logger: createSilentLogger(), + identity: new TestIdentityClient(), + enableTransactionSearch: async () => {}, + runner: async () => {}, + checkTool: async () => {}, + }); +} + +async function drain(generator: AsyncGenerator): Promise { + while (true) { + const next = await generator.next(); + if (next.done) return next.value; + } +} + +/** A new Box project with the given runtimes. Each runtime gets the Box gateway `-box`. */ +async function createdProject( + manager: FsProjectManager, + runtimes: string[] = ["agent"], +): Promise { + const directory = await mkdtemp(join(tmpdir(), "agentcore-box-")); + tempDirectories.push(directory); + process.chdir(directory); + let project = await drain(manager.create({ name: "example", skipInstall: true, skipGit: true })); + for (const name of runtimes) { + project = await drain( + manager.addResource(project, { + resourceType: "runtime", + resourceConfig: { name, language: "Python", protocol: "HTTP" }, + } as never), + ); + } + return project; +} + +/** The agentcore.json of MyTemp3, a project made with CLI 1.0.0, as that CLI wrote it. */ +const EXISTING_PROJECT_JSON = `${JSON.stringify( + { + name: "MyTemp3", + version: 2, + managedBy: "CDK", + runtimes: [ + { + name: "agent", + modelProvider: "Bedrock", + build: "CodeZip", + entrypoint: "main.py", + codeLocation: "app/agent", + runtimeVersion: "PYTHON_3_14", + protocol: "HTTP", + }, + ], + memories: [ + { + name: "agentMemory", + eventExpiryDuration: 30, + strategies: [{ type: "SEMANTIC", namespaceTemplates: ["/users/{actorId}/facts"] }], + }, + ], + knowledgeBases: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + configBundles: [], + harnesses: [], + }, + null, + 2, +)}`; + +async function existingProject(manager: FsProjectManager): Promise { + const root = await mkdtemp(join(tmpdir(), "agentcore-box-existing-")); + tempDirectories.push(root); + await mkdir(join(root, "agentcore"), { recursive: true }); + await mkdir(join(root, "app", "agent"), { recursive: true }); + await writeFile(join(root, "agentcore", "agentcore.json"), EXISTING_PROJECT_JSON); + await writeFile(join(root, "agentcore", "aws-targets.json"), "[]\n"); + const project = await manager.resolve({ filePath: root }); + if (!project) throw new Error("expected the fixture project to resolve"); + return project; +} + +const specText = (project: Project) => + readFile(join(project.rootPath, "agentcore", "agentcore.json"), "utf8"); +const specOnDisk = async (project: Project) => + JSON.parse(await specText(project)) as Record & { + agentCoreGateways: { name: string; targets: { name: string }[] }[]; + }; + +const boxTarget = (name: string, gatewayName = "agent-box"): AddResourceInput => ({ + resourceType: "gateway-target", + gatewayName, + resourceConfig: { + name, + targetType: "passthrough", + passthrough: { endpoint: `https://${name}.example.com` }, + outboundAuth: { type: "GATEWAY_IAM_ROLE", service: "execute-api" }, + } as never, +}); + +const mcpTarget = (name: string, gatewayName = "agent-box"): AddResourceInput => ({ + resourceType: "gateway-target", + gatewayName, + resourceConfig: { + name, + targetType: "mcpServer", + endpoint: `https://${name}.example.com/mcp`, + }, +}); + +describe("agentcore create", () => { + test('writes "projectType": "box" into the new project', async () => { + const project = await createdProject(subject()); + + expect((await specOnDisk(project)).projectType).toBe("box"); + expect(project.spec.projectType).toBe("box"); + }); +}); + +describe("gateway targets in a Box project", () => { + test("add gateway-target --gateway agent-box makes the entry with AWS_IAM, then adds to it", async () => { + const manager = subject(); + let project = await createdProject(manager); + + project = await drain(manager.addResource(project, boxTarget("weather"))); + project = await drain(manager.addResource(project, boxTarget("search"))); + + const spec = await specOnDisk(project); + expect(spec.projectType).toBe("box"); + expect(spec.agentCoreGateways).toEqual([ + { + name: "agent-box", + targets: ["weather", "search"].map((name) => ({ + name, + targetType: "passthrough", + passthrough: { endpoint: `https://${name}.example.com`, protocolType: "CUSTOM" }, + outboundAuth: { type: "GATEWAY_IAM_ROLE", service: "execute-api" }, + })), + authorizerType: "AWS_IAM", + enableSemanticSearch: true, + exceptionLevel: "NONE", + protocolType: "None", + }, + ] as never); + }); + + test("adds a target only to the gateway of its runtime, and changes _ to -", async () => { + const manager = subject(); + let project = await createdProject(manager, ["agent", "my_agent"]); + + project = await drain(manager.addResource(project, boxTarget("weather", "my-agent-box"))); + + const spec = await specOnDisk(project); + expect( + spec.agentCoreGateways.map(({ name, targets }) => [name, targets.map((t) => t.name)]), + ).toEqual([["my-agent-box", ["weather"]]]); + }); + + test("--gateway box is not found, and the error lists the Box gateways", async () => { + const manager = subject(); + const project = await createdProject(manager, ["agent", "my_agent"]); + + const add = drain(manager.addResource(project, boxTarget("weather", "box"))); + + await expect(add).rejects.toBeInstanceOf(ResourceNotFoundError); + await expect(add).rejects.toThrow( + "no gateway named 'box' exists in this project. The Box gateways in this project are: 'agent-box', 'my-agent-box'.", + ); + }); + + test("add gateway-target refuses an MCP target and writes nothing", async () => { + const manager = subject(); + const project = await createdProject(manager); + const before = await specText(project); + + await expect(drain(manager.addResource(project, mcpTarget("search")))).rejects.toThrow( + "the Box gateway takes only passthrough targets, but target 'search' is mcpServer.", + ); + expect(await specText(project)).toBe(before); + }); + + test.each(["agent-box", "AGENT-BOX", "other-box"])( + "add gateway --name %s is refused, because names that end in -box are reserved", + async (name) => { + const manager = subject(); + const project = await createdProject(manager); + const before = await specText(project); + + const add = drain( + manager.addResource(project, { + resourceType: "gateway", + resourceConfig: { name, targets: [], authorizerType: "NONE" } as never, + }), + ); + + await expect(add).rejects.toThrow( + `the gateway name '${name}' is reserved: in this project, names that end in '-box' belong to the Box gateways of the runtimes.`, + ); + await expect(add).rejects.toThrow("The Box gateways in this project are: 'agent-box'."); + expect(await specText(project)).toBe(before); + }, + ); + + test("remove gateway-target keeps the entry until its last target goes", async () => { + const manager = subject(); + let project = await createdProject(manager); + project = await drain(manager.addResource(project, boxTarget("weather"))); + project = await drain(manager.addResource(project, boxTarget("search"))); + + project = ( + await manager.removeResource(project, { + resourceType: "gateway-target", + gatewayName: "agent-box", + name: "weather", + }) + ).project; + expect((await specOnDisk(project)).agentCoreGateways.map(({ name }) => name)).toEqual([ + "agent-box", + ]); + + project = ( + await manager.removeResource(project, { + resourceType: "gateway-target", + gatewayName: "agent-box", + name: "search", + }) + ).project; + const spec = await specOnDisk(project); + expect(spec.agentCoreGateways).toEqual([]); + expect(spec.projectType).toBe("box"); + }); + + test("remove gateway --name agent-box removes the entry and its targets, and keeps projectType", async () => { + const manager = subject(); + let project = await createdProject(manager); + project = await drain(manager.addResource(project, boxTarget("weather"))); + + project = ( + await manager.removeResource(project, { resourceType: "gateway", name: "agent-box" }) + ).project; + + const spec = await specOnDisk(project); + expect(spec.agentCoreGateways).toEqual([]); + expect(spec.projectType).toBe("box"); + }); + + test("remove runtime also removes the entry of its Box gateway", async () => { + const manager = subject(); + let project = await createdProject(manager, ["agent", "my_agent"]); + project = await drain(manager.addResource(project, boxTarget("weather"))); + project = await drain(manager.addResource(project, boxTarget("search", "my-agent-box"))); + + project = (await manager.removeResource(project, { resourceType: "runtime", name: "agent" })) + .project; + + const spec = await specOnDisk(project); + expect(spec.agentCoreGateways.map(({ name }) => name)).toEqual(["my-agent-box"]); + }); + + test("remove all removes projectType with every gateway entry", async () => { + const manager = subject(); + let project = await createdProject(manager); + project = await drain(manager.addResource(project, boxTarget("weather"))); + + const result = await manager.removeAllResources(project); + + const spec = await specOnDisk(result.project); + expect("projectType" in spec).toBe(false); + expect(spec.agentCoreGateways).toEqual([]); + expect(result.project.spec.projectType).toBeUndefined(); + }); +}); + +describe("a project without projectType", () => { + test("add gateway-target --gateway box fails as before and adds no projectType", async () => { + const manager = subject(); + const project = await existingProject(manager); + + const add = drain(manager.addResource(project, mcpTarget("weather", "box"))); + + await expect(add).rejects.toBeInstanceOf(ResourceNotFoundError); + await expect(add).rejects.toThrow("no gateway named 'box' exists in this project"); + expect(await specText(project)).toBe(EXISTING_PROJECT_JSON); + }); + + test("keeps a normal gateway named box when its last target goes", async () => { + const manager = subject(); + let project = await existingProject(manager); + project = await drain( + manager.addResource(project, { + resourceType: "gateway", + resourceConfig: { name: "box", targets: [], authorizerType: "NONE" } as never, + }), + ); + project = await drain(manager.addResource(project, mcpTarget("weather", "box"))); + + project = ( + await manager.removeResource(project, { + resourceType: "gateway-target", + gatewayName: "box", + name: "weather", + }) + ).project; + + const spec = await specOnDisk(project); + expect(spec.agentCoreGateways.map(({ name, targets }) => [name, targets])).toEqual([ + ["box", []], + ]); + expect("projectType" in spec).toBe(false); + }); + + test("add and remove round trips leave agentcore.json as it was", async () => { + const manager = subject(); + let project = await existingProject(manager); + const keys = Object.keys(JSON.parse(EXISTING_PROJECT_JSON) as object); + + project = await drain( + manager.addResource(project, { + resourceType: "memory", + resourceConfig: { name: "extra", eventExpiryDuration: 30, strategies: [] }, + }), + ); + project = await drain( + manager.addResource(project, { + resourceType: "gateway", + resourceConfig: { name: "tools", targets: [], authorizerType: "NONE" } as never, + }), + ); + project = await drain( + manager.addResource(project, { ...mcpTarget("weather"), gatewayName: "tools" } as never), + ); + expect(Object.keys(await specOnDisk(project))).toEqual(keys); + + project = ( + await manager.removeResource(project, { + resourceType: "gateway-target", + gatewayName: "tools", + name: "weather", + }) + ).project; + project = (await manager.removeResource(project, { resourceType: "gateway", name: "tools" })) + .project; + project = (await manager.removeResource(project, { resourceType: "memory", name: "extra" })) + .project; + + expect(await specText(project)).toBe(EXISTING_PROJECT_JSON); + }); +}); diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index 7c81b1c56a..a6bfd5dacf 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -1600,7 +1600,11 @@ describe("FsProjectManager.addResource", () => { httpRuntime: { runtime: "agent_python_minimal" }, }, }); - expect(added.spec.agentCoreGateways[0]!.targets.map((target) => target.name)).toEqual(["rt"]); + expect( + added.spec.agentCoreGateways + .find((gateway) => gateway.name === "gw")! + .targets.map((target) => target.name), + ).toEqual(["rt"]); }); test("deploy to a China target hard-fails a Gateway with a connector Target", async () => { diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 098871237f..47425f46c4 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -61,7 +61,17 @@ import { import { HarnessSpecSchema, HarnessYamlSchema } from "../../projectSchemas/harness"; import { FsTreeNode } from "./templates/fsTree"; import { getEvaluatorTemplateResolver } from "./templates/evaluator"; -import { ProjectSpecSchema, type ManagedBy } from "../../projectSchemas/project"; +import { + BOX_GATEWAY_TARGET_TYPE, + boxGatewayName, + boxGatewayNames, + isBoxExtensionEntry, + isBoxProject, + isReservedBoxGatewayName, + ProjectSpecSchema, + type ManagedBy, +} from "../../projectSchemas/project"; +import { AgentCoreGatewaySchema } from "../../projectSchemas/gateway"; import { ConfigBundleSchema } from "../../projectSchemas/config-bundle"; import { CredentialSchema, @@ -91,6 +101,7 @@ import { isChinaRegion } from "../partition"; import z from "zod"; import { CdkBackend, type TransactionSearchEnabler } from "./backends/cdk"; import { resolveAwsAccount } from "./backends/cdk/environment"; +import { readDeployedState } from "./backends/cdk/deployedState"; import type { ProjectBackend } from "./backends/types"; import { AwsDeploymentTargetSchema, @@ -110,6 +121,7 @@ import type { CreateCloudFormationClient } from "../types"; import type { CoreIdentityClient } from "../../handlers/identity/types"; import { templateManagesDependencies } from "../../handlers/project/templateProfile"; import { resolveRuntimeTemplateProfile } from "../../handlers/project/runtimeTemplateProfile"; +import { boxGatewayEntryForRuntime } from "../../handlers/project/shortcuts"; import { projectFormat, type ProjectFormat } from "./legacy/detect"; import { loadLegacyProject } from "./legacy/load"; import { assertV1Project } from "./legacy/writeGate"; @@ -506,7 +518,11 @@ export class FsProjectManager implements ProjectManager { const { tree: projectTree, envEntries } = await createProjectTree( { templateRenderer: this.templateRenderer, assetSource: this.assetSource }, { projectName: input.name }, - { runtime: scaffoldRuntimeInput, importBedrockAgent: input.importBedrockAgent }, + { + runtime: scaffoldRuntimeInput, + importBedrockAgent: input.importBedrockAgent, + region: input.region, + }, ); // Validate required tools exist before starting creation flow @@ -839,6 +855,20 @@ export class FsProjectManager implements ProjectManager { if (spec.runtimes) projectSpec.runtimes.push(...spec.runtimes); if (spec.memories) projectSpec.memories.push(...spec.memories); if (spec.credentials) projectSpec.credentials.push(...spec.credentials); + // The same Box gateway entry `create` gives a runtime, so an added one matches. + const boxEntry = + isBoxProject(projectSpec) && + boxGatewayEntryForRuntime( + input.resourceConfig.scaffoldRuntimeInput, + input.resourceConfig.region, + ); + if (boxEntry && !projectSpec.agentCoreGateways.some(({ name }) => name === boxEntry.name)) { + yield { + type: "step", + message: `adding the Bedrock target to Box gateway '${boxEntry.name}'`, + }; + projectSpec.agentCoreGateways.push(boxEntry); + } if (envEntries.length > 0) { envFile = new EnvLocalFile(project.rootPath); yield { type: "step", message: `updating secrets file at '${envFile.path}'` }; @@ -908,6 +938,16 @@ export class FsProjectManager implements ProjectManager { break; } case "gateway": + // KD6: in a Box project, names that end in -box belong to the Box gateways, in any + // letter case. The CDK library would refuse them at synth; refusing here keeps + // agentcore.json unchanged. + if (isReservedBoxGatewayName(projectSpec, input.resourceConfig.name)) { + throw new InputValidationError( + `the gateway name '${input.resourceConfig.name}' is reserved: in this project, names that ` + + `end in '-box' belong to the Box gateways of the runtimes. Give the gateway a name that ` + + `does not end in '-box'.${boxGatewayHint(projectSpec)}`, + ); + } projectSpec.agentCoreGateways.push(input.resourceConfig); break; case "payment-manager": { @@ -946,12 +986,35 @@ export class FsProjectManager implements ProjectManager { break; } case "gateway-target": { - const gatewayIndex = projectSpec.agentCoreGateways.findIndex( + let gatewayIndex = projectSpec.agentCoreGateways.findIndex( (gateway) => gateway.name === input.gatewayName, ); + // In a Box project the L3 owns the Box gateway, so the entry that holds its targets + // comes and goes with them. It states the gateway's true settings: the write-back would + // fill in authorizerType NONE, and the schema checks targets against protocolType. + const boxEntry = isBoxExtensionEntry(projectSpec, { name: input.gatewayName }); + if (boxEntry && input.resourceConfig.targetType !== BOX_GATEWAY_TARGET_TYPE) { + throw new InputValidationError( + `the Box gateway takes only passthrough targets, but target ` + + `'${input.resourceConfig.name}' is ${input.resourceConfig.targetType}. Pass ` + + `--target-configuration with "targetType": "passthrough".`, + ); + } + if (gatewayIndex < 0 && boxEntry) { + gatewayIndex = + projectSpec.agentCoreGateways.push( + parseResource(AgentCoreGatewaySchema, { + name: input.gatewayName, + authorizerType: "AWS_IAM", + protocolType: "None", + targets: [], + }), + ) - 1; + } if (gatewayIndex < 0) { + const hint = boxGatewayHint(projectSpec); throw new ResourceNotFoundError( - `no gateway named '${input.gatewayName}' exists in this project`, + `no gateway named '${input.gatewayName}' exists in this project${hint ? `.${hint}` : ""}`, ); } projectSpec.agentCoreGateways[gatewayIndex]!.targets.push(input.resourceConfig); @@ -1088,7 +1151,12 @@ export class FsProjectManager implements ProjectManager { const gateway = gateways[gatewayIndex]!; const targets = gateway.targets.filter((target) => target.name !== input.name); removed = targets.length !== gateway.targets.length; - gateways[gatewayIndex] = { ...gateway, targets }; + // The Box gateway stays without its entry, so the file shows only what the user added. + if (isBoxExtensionEntry(existingProjectSpec, gateway) && targets.length === 0) { + gateways.splice(gatewayIndex, 1); + } else { + gateways[gatewayIndex] = { ...gateway, targets }; + } newSpec = { ...existingProjectSpec, agentCoreGateways: gateways }; } else if (input.resourceType === "payment-connector") { const payments = [...(existingProjectSpec.payments ?? [])]; @@ -1129,6 +1197,17 @@ export class FsProjectManager implements ProjectManager { const newResources = existingResources.filter((resource) => resource.name !== input.name); removed = newResources.length !== existingResources.length; newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources }; + // The Box gateway of a runtime goes with the runtime (KD4), so its targets go too. An + // entry for a runtime that no longer exists would fail the synth. + if (input.resourceType === "runtime" && removed && isBoxProject(existingProjectSpec)) { + const boxName = boxGatewayName(input.name); + newSpec = { + ...(newSpec as object), + agentCoreGateways: existingProjectSpec.agentCoreGateways.filter( + (gateway) => gateway.name !== boxName, + ), + }; + } } if (!removed) { @@ -1181,8 +1260,11 @@ export class FsProjectManager implements ProjectManager { // resource collection is emptied while name, version, managedBy, and tags // survive. Code under app/ and aws-targets.json are left in place // so a following deploy can tear down the target's stack. + // The project type goes too: the L3 makes the Box gateway from it, and `remove all` removes + // every resource, so the next deploy reaches the teardown confirmation as before. + const { projectType: _projectType, ...rest } = existingProjectSpec; const newSpec = { - ...existingProjectSpec, + ...rest, runtimes: [], memories: [], knowledgeBases: [], @@ -1686,6 +1768,7 @@ export class FsProjectManager implements ProjectManager { return yield* this.backendFor(project).deploy(project, { target, confirmTeardown: input.confirmTeardown, + confirmBoxGatewayDeletion: input.confirmBoxGatewayDeletion, transactionSearch: input.transactionSearch, skipBootstrap: input.skipBootstrap, }); @@ -1711,6 +1794,11 @@ export class FsProjectManager implements ProjectManager { return targets.find((candidate) => candidate.name === input.target); } + public async recordedBoxGateways(project: Project, input: ResolveTargetInput): Promise { + const state = await readDeployedState(this.json, project.rootPath); + return (state.targets[input.target]?.resources?.boxGateways ?? []).map(({ name }) => name); + } + public async resolveDeployedResource( project: Project, input: ResolveDeployedResourceInput, @@ -2029,6 +2117,13 @@ async function readStrandsVersion(agentDir: string): Promise { } } +/** A hint that lists the Box gateways of a Box project, for errors about gateway names. */ +function boxGatewayHint(spec: { projectType?: string; runtimes: { name: string }[] }): string { + const names = boxGatewayNames(spec); + if (names.length === 0) return ""; + return ` The Box gateways in this project are: ${names.map((name) => `'${name}'`).join(", ")}.`; +} + function parseResource( schema: TSchema, input: z.input, diff --git a/src/core/project/templates/project.ts b/src/core/project/templates/project.ts index f2aa53af1a..2d0e2f50fc 100644 --- a/src/core/project/templates/project.ts +++ b/src/core/project/templates/project.ts @@ -8,6 +8,7 @@ import type { import { InputValidationError } from "../../../errors/errors"; import { getRuntimeTemplateResolver } from "./runtime"; import { mergeSpecEntries } from "./spec"; +import { boxGatewayEntryForRuntime } from "../../../handlers/project/shortcuts"; import type { Template, TemplateRenderer } from "./types"; import type { EnvLocalEntry } from "../../../handlers/project/types"; @@ -19,7 +20,12 @@ type CreateProjectConfig = { export async function createProjectTree( config: CreateProjectConfig, input: { projectName: string }, - options?: { runtime?: ScaffoldRuntimeInput; importBedrockAgent?: ImportBedrockAgentInput }, + options?: { + runtime?: ScaffoldRuntimeInput; + importBedrockAgent?: ImportBedrockAgentInput; + /** The region the command resolved; it picks the Bedrock endpoint of the Box gateway entry. */ + region?: string; + }, ): Promise<{ tree: FsTreeNode; envEntries: EnvLocalEntry[] }> { const templates: Template[] = []; if (options?.runtime) { @@ -37,6 +43,8 @@ export async function createProjectTree( } const envEntries = templates.flatMap((template) => template.envEntries ?? []); + const boxGatewayEntry = + options?.runtime && boxGatewayEntryForRuntime(options.runtime, options.region); const tree = FsTreeNode.createDirectory(".", [ FsTreeNode.createFile(".gitignore", () => @@ -49,7 +57,14 @@ export async function createProjectTree( name: input.projectName, version: 2, managedBy: "CDK", - ...mergeSpecEntries(templates.map(({ spec }) => spec)), + // Only new projects are Box projects; no other command adds this field. + projectType: "box", + ...mergeSpecEntries([ + ...templates.map(({ spec }) => spec), + // The entry that adds the runtime's model target to its Box gateway. It sits in + // agentCoreGateways, not in the template's runtime files. + ...(boxGatewayEntry ? [{ agentCoreGateways: [boxGatewayEntry] }] : []), + ]), }), ), FsTreeNode.createFile("aws-targets.json", async () => json([])), diff --git a/src/core/project/templates/spec.ts b/src/core/project/templates/spec.ts index a412ca74fa..f62127d911 100644 --- a/src/core/project/templates/spec.ts +++ b/src/core/project/templates/spec.ts @@ -6,11 +6,13 @@ export function mergeSpecEntries(entries: SpecEntries[]): SpecEntries { const credentials = entries.flatMap(({ credentials }) => credentials ?? []); const memories = entries.flatMap(({ memories }) => memories ?? []); const harnesses = entries.flatMap(({ harnesses }) => harnesses ?? []); + const agentCoreGateways = entries.flatMap(({ agentCoreGateways }) => agentCoreGateways ?? []); return { ...(runtimes.length > 0 && { runtimes }), ...(credentials.length > 0 && { credentials }), ...(memories.length > 0 && { memories }), ...(harnesses.length > 0 && { harnesses }), + ...(agentCoreGateways.length > 0 && { agentCoreGateways }), }; } diff --git a/src/core/project/templates/types.ts b/src/core/project/templates/types.ts index d7e3863015..2de0b4c02f 100644 --- a/src/core/project/templates/types.ts +++ b/src/core/project/templates/types.ts @@ -4,6 +4,7 @@ import type { MemorySchema } from "../../../projectSchemas/memory"; import type { CredentialSchema } from "../../../projectSchemas/credential"; import type { HarnessRegistryEntry } from "../../../projectSchemas/harness"; import type { Evaluator } from "../../../projectSchemas/evaluator"; +import type { AgentCoreGateway } from "../../../projectSchemas/gateway"; import type { EnvLocalEntry } from "../../../handlers/project/types"; import type z from "zod"; @@ -14,6 +15,7 @@ export type SpecEntries = { memories?: z.infer[]; harnesses?: HarnessRegistryEntry[]; evaluators?: Evaluator[]; + agentCoreGateways?: AgentCoreGateway[]; }; /** A group of files and resources that can be rendered into a project **/ diff --git a/src/handlers/project/add/gateway-connector/gateway-connector.screen.test.tsx b/src/handlers/project/add/gateway-connector/gateway-connector.screen.test.tsx index 01c17183c0..8db317338c 100644 --- a/src/handlers/project/add/gateway-connector/gateway-connector.screen.test.tsx +++ b/src/handlers/project/add/gateway-connector/gateway-connector.screen.test.tsx @@ -18,7 +18,7 @@ import { import { createRootHandler } from "../../../index"; import { InputValidationError } from "../../../../errors"; import type { AppIO } from "../../../../io"; -import { createGatewayProjectTestHarness } from "../gateway-test-support"; +import { createGatewayProjectTestHarness, removeDefaultBoxGateway } from "../gateway-test-support"; import { knowledgeBaseSchema } from "./screen"; const KB_HELP = @@ -37,10 +37,12 @@ async function targetsOf(projectRoot: string, gateway = "tools") { .targets; } -// reachConnectorStep confirms the only Gateway, leaving the wizard on the -// connector step. +// reachConnectorStep picks the test's Gateway, "tools" (listed after the +// project's "agent-box" gateway entry), leaving the wizard on the connector step. async function reachConnectorStep(screen: RenderScreenResult): Promise { await waitForText(screen.lastFrame, "which Gateway should this connector hang off?"); + await screen.press("down"); + await waitForText(screen.lastFrame, "❯ ● tools"); await screen.press("return"); await waitForText(screen.lastFrame, "which connector?"); } @@ -72,7 +74,9 @@ describe("project add gateway-connector wizard", () => { const screen = renderScreen("/agentcore/add/gateway-connector"); await waitForText(screen.lastFrame, "which Gateway should this connector hang off?"); - expect(screen.lastFrame()).toContain("❯ ● tools"); + expect(screen.lastFrame()).toContain("❯ ● agent-box"); + await screen.press("down"); + await waitForText(screen.lastFrame, "❯ ● tools"); await screen.press("return"); await waitForText(screen.lastFrame, "which connector?"); @@ -220,7 +224,7 @@ describe("project add gateway-connector wizard", () => { }); test("without a Gateway the first step says what to add and esc returns to the menu", async () => { - await inProject(); + await removeDefaultBoxGateway(await inProject()); const screen = renderScreen("/agentcore/add/gateway-connector"); await waitForText(screen.lastFrame, "no Gateways in this project"); diff --git a/src/handlers/project/add/gateway-connector/index.test.ts b/src/handlers/project/add/gateway-connector/index.test.ts index 67deae23f5..c5486a78ae 100644 --- a/src/handlers/project/add/gateway-connector/index.test.ts +++ b/src/handlers/project/add/gateway-connector/index.test.ts @@ -69,7 +69,7 @@ describe("project add gateway-connector", () => { cnConnectorTargetMessage("web-search"), RegionUnsupportedFeatureError, ); - expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets).toEqual([]); + expect((await projectSpec(projectRoot)).agentCoreGateways[1].targets).toEqual([]); }); test("--json preserves the command resource type and parent Gateway", async () => { @@ -127,7 +127,7 @@ describe("project add gateway-connector", () => { "ABCDEFGHIJ", ]); - expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets).toEqual([ + expect((await projectSpec(projectRoot)).agentCoreGateways[1].targets).toEqual([ { name: "web", targetType: "connector", @@ -164,7 +164,7 @@ describe("project add gateway-connector", () => { `file://${path}`, ]); - expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0]).toEqual(target); + expect((await projectSpec(projectRoot)).agentCoreGateways[1].targets[0]).toEqual(target); }); test("rejects a non-connector project Target", async () => { diff --git a/src/handlers/project/add/gateway-target/gateway-target.screen.test.tsx b/src/handlers/project/add/gateway-target/gateway-target.screen.test.tsx index 594abd02b7..0846b34af6 100644 --- a/src/handlers/project/add/gateway-target/gateway-target.screen.test.tsx +++ b/src/handlers/project/add/gateway-target/gateway-target.screen.test.tsx @@ -16,7 +16,7 @@ import { import { createRootHandler } from "../../../index"; import { InputValidationError } from "../../../../errors"; import type { AppIO } from "../../../../io"; -import { createGatewayProjectTestHarness } from "../gateway-test-support"; +import { createGatewayProjectTestHarness, removeDefaultBoxGateway } from "../gateway-test-support"; import { authChoices, toGatewayTargetInput } from "./screen"; const ENDPOINT = "https://mcp.example.com"; @@ -58,9 +58,12 @@ async function projectWith(extra: { return projectRoot; } -// reachKindStep confirms the only Gateway, leaving the wizard on the kind step. +// reachKindStep picks the test's Gateway, "tools" (listed after the project's +// "agent-box" gateway entry), leaving the wizard on the kind step. async function reachKindStep(screen: RenderScreenResult): Promise { await waitForText(screen.lastFrame, "which Gateway should this Target hang off?"); + await screen.press("down"); + await waitForText(screen.lastFrame, "❯ ● tools"); await screen.press("return"); await waitForText(screen.lastFrame, "what is the Target?"); } @@ -110,7 +113,9 @@ describe("project add gateway-target wizard", () => { const screen = renderScreen("/agentcore/add/gateway-target"); await waitForText(screen.lastFrame, "which Gateway should this Target hang off?"); - expect(screen.lastFrame()).toContain("❯ ● tools"); + expect(screen.lastFrame()).toContain("❯ ● agent-box"); + await screen.press("down"); + await waitForText(screen.lastFrame, "❯ ● tools"); expect(screen.lastFrame()).toContain("0 Targets · MCP servers and Runtimes"); await screen.press("return"); @@ -302,7 +307,7 @@ describe("project add gateway-target wizard", () => { }); test("without a Gateway the first step says what to add and esc returns to the menu", async () => { - await inProject(); + await removeDefaultBoxGateway(await inProject()); const screen = renderScreen("/agentcore/add/gateway-target"); await waitForText(screen.lastFrame, "no Gateways in this project"); @@ -320,6 +325,8 @@ describe("project add gateway-target wizard", () => { await waitForText(screen.lastFrame, "which Gateway should this Target hang off?"); expect(screen.lastFrame()).toContain("MCP servers only"); + await screen.press("down"); + await waitForText(screen.lastFrame, "❯ ● mcp"); await screen.press("return"); await waitForText(screen.lastFrame, "what is the Target?"); diff --git a/src/handlers/project/add/gateway-target/index.test.ts b/src/handlers/project/add/gateway-target/index.test.ts index 99aebb488c..58e82a01a3 100644 --- a/src/handlers/project/add/gateway-target/index.test.ts +++ b/src/handlers/project/add/gateway-target/index.test.ts @@ -3,7 +3,7 @@ import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { InputValidationError, ProjectStateError, ResourceNotFoundError } from "../../../../errors"; import { expectError } from "../../../../testing"; -import { createGatewayProjectTestHarness } from "../gateway-test-support"; +import { createGatewayProjectTestHarness, DEFAULT_BOX_GATEWAY } from "../gateway-test-support"; const DISCOVERY_URL = "https://idp.example.com/.well-known/openid-configuration"; const ENDPOINT = "https://mcp.example.com"; @@ -65,7 +65,7 @@ describe("project add gateway-target", () => { "DEFAULT", ]); - expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets).toEqual([ + expect((await projectSpec(projectRoot)).agentCoreGateways[1].targets).toEqual([ { name: "external", targetType: "mcpServer", @@ -125,7 +125,7 @@ describe("project add gateway-target", () => { ]); } - expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets).toEqual(targets); + expect((await projectSpec(projectRoot)).agentCoreGateways[1].targets).toEqual(targets); expect(await Bun.file(join(projectRoot, "agentcore", "assets")).exists()).toBe(false); }); @@ -149,7 +149,7 @@ describe("project add gateway-target", () => { `file://${path}`, ]); - expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0]).toEqual(target); + expect((await projectSpec(projectRoot)).agentCoreGateways[1].targets[0]).toEqual(target); }); test("accepts configured IAM auth through --target-configuration", async () => { @@ -188,7 +188,7 @@ describe("project add gateway-target", () => { ]); } - expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets).toEqual(targets); + expect((await projectSpec(projectRoot)).agentCoreGateways[1].targets).toEqual(targets); }); test("rejects shortcut flags with complete Target JSON", async () => { @@ -258,7 +258,7 @@ describe("project add gateway-target", () => { ]); } - expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets).toEqual(targets); + expect((await projectSpec(projectRoot)).agentCoreGateways[1].targets).toEqual(targets); }); test("adds an OAuth-authenticated endpoint shortcut", async () => { @@ -278,7 +278,7 @@ describe("project add gateway-target", () => { ), ]); - expect((await projectSpec(projectRoot)).agentCoreGateways[0].targets[0].outboundAuth).toEqual({ + expect((await projectSpec(projectRoot)).agentCoreGateways[1].targets[0].outboundAuth).toEqual({ type: "OAUTH", credentialName: "oauth", scopes: ["read", "write"], @@ -433,7 +433,8 @@ describe("project add gateway-target", () => { } const gateways = (await projectSpec(projectRoot)).agentCoreGateways; - expect(gateways[0].targets).toHaveLength(1); - expect(gateways[1].targets).toHaveLength(0); + expect(gateways[0]).toEqual(DEFAULT_BOX_GATEWAY); + expect(gateways[1].targets).toHaveLength(1); + expect(gateways[2].targets).toHaveLength(0); }); }); diff --git a/src/handlers/project/add/gateway-test-support.ts b/src/handlers/project/add/gateway-test-support.ts index 3950930e39..730b8eb391 100644 --- a/src/handlers/project/add/gateway-test-support.ts +++ b/src/handlers/project/add/gateway-test-support.ts @@ -7,6 +7,20 @@ import { TestGlobalConfigAccessor, testIO, } from "../../../testing"; +import { DEFAULT_CREATE_RUNTIME_NAME } from "../create"; +import { getBoxGatewayBedrockEntry } from "../shortcuts"; +import { resolveRegion } from "../../../core/region"; + +/** + * The Box gateway entry (`agent-box`, with the Bedrock target) that `agentcore create + * --template agent-python-minimal` declares for its Bedrock agent, so every harness project + * starts with it at index 0, in the region that create resolves. + */ +export const DEFAULT_BOX_GATEWAY = getBoxGatewayBedrockEntry( + DEFAULT_CREATE_RUNTIME_NAME, + // The region the test project's `create` resolves (env, profile, then the default). + await resolveRegion(undefined), +); export async function projectSpec(projectRoot: string) { return Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); @@ -19,6 +33,18 @@ export async function writeProjectSpec(projectRoot: string, spec: unknown): Prom ); } +/** + * Removes the Box gateway entry that a templated project starts with, for + * a test about a project that has no gateways at all. + */ +export async function removeDefaultBoxGateway(projectRoot: string): Promise { + const spec = await projectSpec(projectRoot); + spec.agentCoreGateways = spec.agentCoreGateways.filter( + (gateway: { name: string }) => gateway.name !== DEFAULT_BOX_GATEWAY.name, + ); + await writeProjectSpec(projectRoot, spec); +} + export function createGatewayProjectTestHarness(directoryPrefix: string) { const cleanups: Array<() => Promise> = []; diff --git a/src/handlers/project/add/gateway/gateway.screen.test.tsx b/src/handlers/project/add/gateway/gateway.screen.test.tsx index 5681428711..f55e6cdca1 100644 --- a/src/handlers/project/add/gateway/gateway.screen.test.tsx +++ b/src/handlers/project/add/gateway/gateway.screen.test.tsx @@ -8,7 +8,7 @@ import { waitForFlatText, waitForText, } from "../../../../testing"; -import { createGatewayProjectTestHarness } from "../gateway-test-support"; +import { createGatewayProjectTestHarness, DEFAULT_BOX_GATEWAY } from "../gateway-test-support"; const { cleanup, inProject, projectSpec } = createGatewayProjectTestHarness("add-gateway-wizard"); @@ -41,6 +41,7 @@ describe("project add gateway wizard", () => { await waitForText(screen.lastFrame, "added Gateway 'tools' to 'TestProject'"); expect((await projectSpec(projectRoot)).agentCoreGateways).toEqual([ + DEFAULT_BOX_GATEWAY, { name: "tools", protocolType: "None", @@ -95,7 +96,7 @@ describe("project add gateway wizard", () => { await screen.press("return"); await waitForText(screen.lastFrame, "added Gateway 'search'"); - expect((await projectSpec(projectRoot)).agentCoreGateways[0]).toMatchObject({ + expect((await projectSpec(projectRoot)).agentCoreGateways[1]).toMatchObject({ protocolType: "MCP", enableSemanticSearch: true, }); @@ -137,7 +138,7 @@ describe("project add gateway wizard", () => { await screen.press("return"); await waitForText(screen.lastFrame, "added Gateway 'secure'"); - expect((await projectSpec(projectRoot)).agentCoreGateways[0]).toMatchObject({ + expect((await projectSpec(projectRoot)).agentCoreGateways[1]).toMatchObject({ authorizerType: "CUSTOM_JWT", authorizerConfiguration: { customJwtAuthorizer: { diff --git a/src/handlers/project/add/gateway/index.test.ts b/src/handlers/project/add/gateway/index.test.ts index 4bcc3a7773..0a6f99b1c7 100644 --- a/src/handlers/project/add/gateway/index.test.ts +++ b/src/handlers/project/add/gateway/index.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expectError } from "../../../../testing"; -import { createGatewayProjectTestHarness } from "../gateway-test-support"; +import { createGatewayProjectTestHarness, DEFAULT_BOX_GATEWAY } from "../gateway-test-support"; import { InputValidationError, ResourceNotFoundError } from "../../../../errors"; const { cleanup, inProject, projectSpec, run, writeProjectSpec } = @@ -16,6 +16,7 @@ describe("project add gateway", () => { const io = await run(["add", "gateway", "--name", "tools"]); expect((await projectSpec(projectRoot)).agentCoreGateways).toEqual([ + DEFAULT_BOX_GATEWAY, { name: "tools", protocolType: "None", @@ -56,7 +57,7 @@ describe("project add gateway", () => { '{"team":"agents"}', ]); - expect((await projectSpec(projectRoot)).agentCoreGateways[0]).toMatchObject({ + expect((await projectSpec(projectRoot)).agentCoreGateways[1]).toMatchObject({ name: "tools", protocolType: "MCP", description: "Project tools", @@ -90,7 +91,7 @@ describe("project add gateway", () => { }), ); - expect((await projectSpec(projectRoot)).agentCoreGateways[0]).toMatchObject({ + expect((await projectSpec(projectRoot)).agentCoreGateways[1]).toMatchObject({ authorizerType: "CUSTOM_JWT", authorizerConfiguration: { customJwtAuthorizer: { @@ -116,7 +117,7 @@ describe("project add gateway", () => { ]), ).rejects.toThrow("customJWTAuthorizer"); - expect((await projectSpec(projectRoot)).agentCoreGateways ?? []).toEqual([]); + expect((await projectSpec(projectRoot)).agentCoreGateways).toEqual([DEFAULT_BOX_GATEWAY]); }); test("maps log-only policy mode", async () => { @@ -136,7 +137,7 @@ describe("project add gateway", () => { "log-only", ]); - expect((await projectSpec(projectRoot)).agentCoreGateways[0].policyEngineConfiguration).toEqual( + expect((await projectSpec(projectRoot)).agentCoreGateways[1].policyEngineConfiguration).toEqual( { policyEngineName: "Guardrails", mode: "LOG_ONLY", @@ -148,7 +149,7 @@ describe("project add gateway", () => { const projectRoot = await inProject(); await run(["add", "gateway", "--name", "tools", "--tags", "source=pairs", "team=agents"]); - expect((await projectSpec(projectRoot)).agentCoreGateways[0].tags).toEqual({ + expect((await projectSpec(projectRoot)).agentCoreGateways[1].tags).toEqual({ source: "pairs", team: "agents", }); diff --git a/src/handlers/project/add/policy-engine/index.test.ts b/src/handlers/project/add/policy-engine/index.test.ts index 18ed25f62a..ed8cf33032 100644 --- a/src/handlers/project/add/policy-engine/index.test.ts +++ b/src/handlers/project/add/policy-engine/index.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expectError } from "../../../../testing"; -import { createGatewayProjectTestHarness } from "../gateway-test-support"; +import { createGatewayProjectTestHarness, DEFAULT_BOX_GATEWAY } from "../gateway-test-support"; import { InputValidationError } from "../../../../errors"; const { addGateway, cleanup, inProject, projectSpec, run } = @@ -108,8 +108,11 @@ describe("project add policy-engine", () => { ]); const spec = await projectSpec(projectRoot); - expect(spec.agentCoreGateways).toHaveLength(2); - for (const gateway of spec.agentCoreGateways) { + // the agent-box gateway entry is not named, so it stays unattached. + const [box, ...attached] = spec.agentCoreGateways; + expect(box).toEqual(DEFAULT_BOX_GATEWAY); + expect(attached).toHaveLength(2); + for (const gateway of attached) { expect(gateway.policyEngineConfiguration).toEqual({ policyEngineName: "Guardrails", mode, diff --git a/src/handlers/project/add/policy-engine/policy-engine.screen.test.tsx b/src/handlers/project/add/policy-engine/policy-engine.screen.test.tsx index 50234d1c17..f48566a12d 100644 --- a/src/handlers/project/add/policy-engine/policy-engine.screen.test.tsx +++ b/src/handlers/project/add/policy-engine/policy-engine.screen.test.tsx @@ -18,7 +18,7 @@ import { import { createRootHandler } from "../../../index"; import { InputValidationError } from "../../../../errors"; import type { AppIO } from "../../../../io"; -import { createGatewayProjectTestHarness } from "../gateway-test-support"; +import { createGatewayProjectTestHarness, removeDefaultBoxGateway } from "../gateway-test-support"; const { addGateway, cleanup, inProject, projectSpec, run } = createGatewayProjectTestHarness( "add-policy-engine-wizard", @@ -36,6 +36,7 @@ async function nameStep(screen: RenderScreenResult, name: string): Promise describe("project add policy-engine wizard", () => { test("in a project without Gateways it asks only for a name", async () => { const projectRoot = await inProject(); + await removeDefaultBoxGateway(projectRoot); const screen = renderScreen("/agentcore/add/policy-engine"); await waitForText(screen.lastFrame, "what should this Policy Engine be called?"); @@ -74,11 +75,15 @@ describe("project add policy-engine wizard", () => { await nameStep(screen, "Guardrails"); await waitForText(screen.lastFrame, "attach it to any Gateways now?"); - expect(screen.lastFrame()).toContain("❯ [ ] tools"); + expect(screen.lastFrame()).toContain("❯ [ ] agent-box"); + expect(screen.lastFrame()).toContain("[ ] tools"); expect(screen.lastFrame()).toContain("[ ] search"); // Nothing checked yet, so the mode question is not on screen. expect(screen.lastFrame()).not.toContain("Enforcement on those Gateways"); + // Skip the agent-box gateway entry; attach to the two the test added. + await screen.press("down"); + await waitForText(screen.lastFrame, "❯ [ ] tools"); await screen.write(" "); await waitForText(screen.lastFrame, "[✓] tools"); // Checking a Gateway reveals the mode beneath the list, with enforce @@ -107,7 +112,9 @@ describe("project add policy-engine wizard", () => { await waitForText(screen.lastFrame, "added Policy Engine 'Guardrails'"); expect(screen.lastFrame()).toContain("attached to 2 Gateways in log-only mode"); const spec = await projectSpec(projectRoot); - for (const gateway of spec.agentCoreGateways) { + const [box, ...attached] = spec.agentCoreGateways; + expect(box.policyEngineConfiguration).toBeUndefined(); + for (const gateway of attached) { expect(gateway.policyEngineConfiguration).toEqual({ policyEngineName: "Guardrails", mode: "LOG_ONLY", @@ -133,7 +140,7 @@ describe("project add policy-engine wizard", () => { expect(screen.lastFrame()).not.toContain("attached to"); const spec = await projectSpec(projectRoot); expect(spec.policyEngines).toEqual([{ name: "Guardrails", policies: [] }]); - expect(spec.agentCoreGateways[0].policyEngineConfiguration).toBeUndefined(); + expect(spec.agentCoreGateways[1].policyEngineConfiguration).toBeUndefined(); screen.unmount(); }, 15000); @@ -143,6 +150,8 @@ describe("project add policy-engine wizard", () => { const screen = renderScreen("/agentcore/add/policy-engine"); await nameStep(screen, "Guardrails"); await waitForText(screen.lastFrame, "attach it to any Gateways now?"); + await screen.press("down"); + await waitForText(screen.lastFrame, "❯ [ ] tools"); await screen.write(" "); await screen.press("return"); await waitForText(screen.lastFrame, "❯ ● enforce (default)"); @@ -189,6 +198,8 @@ describe("project add policy-engine wizard", () => { test("a rejected add reports itself and hands the form back", async () => { const projectRoot = await inProject(); + // No Gateways, so the wizard goes from the name straight to review. + await removeDefaultBoxGateway(projectRoot); await run(["add", "policy-engine", "--name", "Guardrails"]); const screen = renderScreen("/agentcore/add/policy-engine"); await nameStep(screen, "Guardrails"); diff --git a/src/handlers/project/add/runtime/index.test.ts b/src/handlers/project/add/runtime/index.test.ts index f46787bc2f..10232d4248 100644 --- a/src/handlers/project/add/runtime/index.test.ts +++ b/src/handlers/project/add/runtime/index.test.ts @@ -343,6 +343,31 @@ describe("project add runtime --agent-id (Bedrock Agent import)", () => { "us-east-1", ]; + test("in a Box project, gives the imported Bedrock agent the Bedrock target in --region", async () => { + const { projectRoot, cleanup } = await initProject(); + cleanups.push(cleanup); + const core = new TestCoreClient(); + core.bedrockAgentImportPlans["A1B2C3D4E5/TSTALIASID"] = translatedImportPlan(); + + await run(importArgs, { core }); + + const spec = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect(spec.projectType).toBe("box"); + expect( + spec.agentCoreGateways.find((g: { name: string }) => g.name === "support-proxy-box"), + ).toMatchObject({ + authorizerType: "AWS_IAM", + protocolType: "None", + targets: [ + { + name: "bedrock", + passthrough: { endpoint: "https://bedrock-runtime.us-east-1.amazonaws.com" }, + outboundAuth: { type: "GATEWAY_IAM_ROLE", service: "bedrock", region: "us-east-1" }, + }, + ], + }); + }); + test("scaffolds owned runtime code translated from the selected agent version", async () => { const { projectRoot, cleanup } = await initProject(); cleanups.push(cleanup); diff --git a/src/handlers/project/add/runtime/index.ts b/src/handlers/project/add/runtime/index.ts index 84b033f8c3..542198d0c8 100644 --- a/src/handlers/project/add/runtime/index.ts +++ b/src/handlers/project/add/runtime/index.ts @@ -296,6 +296,7 @@ export const createAddRuntimeHandler = (config: AddProjectResourceConfig) => getDefaultMemorySpec(runtimeName), ), importBedrockAgent, + region: ctx.require(RegionKey), }), `added runtime '${flags.name}' to '${project.name}'`, { notes }, diff --git a/src/handlers/project/add/runtime/types.ts b/src/handlers/project/add/runtime/types.ts index 4ddafc7e61..04e975dc80 100644 --- a/src/handlers/project/add/runtime/types.ts +++ b/src/handlers/project/add/runtime/types.ts @@ -27,6 +27,11 @@ export const RuntimeResourceConfigSchema = RuntimeInfraConfigSchema.extend({ scaffoldRuntimeInput: ScaffoldRuntimeInputSchema, /** Present when runtime files were translated from a Bedrock Agent version. */ importBedrockAgent: ImportBedrockAgentInputSchema.optional(), + /** + * The region the command resolved; in a Box project it picks the Bedrock + * endpoint of the runtime's Box gateway entry. + */ + region: z.string().min(1).optional(), }); export type RuntimeResourceConfig = z.infer; diff --git a/src/handlers/project/buildDeploy.screen.test.tsx b/src/handlers/project/buildDeploy.screen.test.tsx index e6aa8fb1a8..0059844113 100644 --- a/src/handlers/project/buildDeploy.screen.test.tsx +++ b/src/handlers/project/buildDeploy.screen.test.tsx @@ -279,7 +279,8 @@ describe("project deploy screen", () => { is_tui: true, project_runtime_count: 1, project_memory_count: 0, - project_gateway_count: 0, + // the agent-box gateway entry that create declares. + project_gateway_count: 1, project_harness_count: 0, }); r.unmount(); diff --git a/src/handlers/project/create/create.screen.test.tsx b/src/handlers/project/create/create.screen.test.tsx index 8aadbedc9f..c5a6faa717 100644 --- a/src/handlers/project/create/create.screen.test.tsx +++ b/src/handlers/project/create/create.screen.test.tsx @@ -38,13 +38,20 @@ afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); // spyOnCreate records every CreateProjectInput handed to the manager while // still running the real FsProjectManager underneath, so a test can assert -// both the exact input and the files it produced. -function spyOnCreate(core: TestCoreClient): CreateProjectInput[] { +// both the exact input and the files it produced. The resolved region goes to +// `regions` instead of the input, so the input assertions stay on what the +// form chose. +function spyOnCreate( + core: TestCoreClient, + regions: (string | undefined)[] = [], +): CreateProjectInput[] { const inputs: CreateProjectInput[] = []; const manager = core.projectManager; const original = manager.create.bind(manager); manager.create = (input) => { - inputs.push(input); + const { region, ...chosen } = input; + inputs.push(chosen as CreateProjectInput); + regions.push(region); return original(input); }; return inputs; @@ -1016,7 +1023,8 @@ describe("project create wizard", () => { const { path: directory, cleanup } = await inTempDirectory(); cleanups.push(cleanup); const core = new TestCoreClient(); - const inputs = spyOnCreate(core); + const regions: (string | undefined)[] = []; + const inputs = spyOnCreate(core, regions); const r = renderScreen("/agentcore/create", { core }); await waitForText(r.lastFrame, "name your project"); @@ -1047,6 +1055,8 @@ describe("project create wizard", () => { runtimeName: "agent", }), }); + // The resolved region picks the Bedrock endpoint of the agent-box entry. + expect(regions).toEqual(["us-east-1"]); const spec = await Bun.file(join(directory, "HelloApp", "agentcore", "agentcore.json")).json(); expect(spec.runtimes.map((runtime: { name: string }) => runtime.name)).toEqual(["agent"]); diff --git a/src/handlers/project/create/index.ts b/src/handlers/project/create/index.ts index c8622a034f..0169501eb3 100644 --- a/src/handlers/project/create/index.ts +++ b/src/handlers/project/create/index.ts @@ -164,10 +164,13 @@ export const createCreateProjectHandler = (config: CreateProjectHandlerConfig) = // Same driver as build and deploy: a live step list in a TTY, and the previous plain // line-per-step output when stderr is not a TTY or --json wants no ANSI on it. - const project = await runWithProgress(config.projectManager.create(createInput), { - io: config.io, - interactive: ctx.require(JsonKey) ? false : undefined, - }); + const project = await runWithProgress( + config.projectManager.create({ ...createInput, region }), + { + io: config.io, + interactive: ctx.require(JsonKey) ? false : undefined, + }, + ); renderResult( ctx, diff --git a/src/handlers/project/create/screen.tsx b/src/handlers/project/create/screen.tsx index 4e61d95c82..18989d0af2 100644 --- a/src/handlers/project/create/screen.tsx +++ b/src/handlers/project/create/screen.tsx @@ -226,7 +226,7 @@ export function ProjectCreateScreen({ ctx, core }: ScreenProps) { validateCreateRegionSupport(input, resolvedRegion); const stripped = stripCreateRegionUnavailableDefaults(input, resolvedRegion); if (stripped !== undefined) yield { type: "step" as const, message: stripped }; - return yield* core.projectManager.create(input); + return yield* core.projectManager.create({ ...input, region: resolvedRegion }); }} runningLabel={`creating ${values.name}…`} successLabel={ diff --git a/src/handlers/project/deploy/index.test.ts b/src/handlers/project/deploy/index.test.ts index c6622217fb..a4bda5cced 100644 --- a/src/handlers/project/deploy/index.test.ts +++ b/src/handlers/project/deploy/index.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { LegacyDeployError, @@ -415,7 +415,8 @@ describe("project deploy handler", () => { exit_reason: "success", project_runtime_count: 1, project_memory_count: 0, - project_gateway_count: 0, + // the agent-box gateway entry that create declares. + project_gateway_count: 1, project_harness_count: 0, }); expect(subject.calls).toHaveLength(1); @@ -761,3 +762,96 @@ describe("project deploy reports which field of aws-targets.json is wrong", () = expect(subject.calls).toEqual([]); }); }); + +describe("project deploy and the Box gateway", () => { + const BOX_PROMPT = + `agentcore.json has no "projectType": "box", so this deploy will delete the Box gateway ` + + `'orders-default-agent-box'. Continue? (y/N)`; + const REQUEST = { + projectName: "orders", + targetName: "default", + gatewayNames: ["orders-default-agent-box"], + }; + + /** A Box project that lost its projectType after a deploy recorded its Box gateway. */ + async function lostMarker(): Promise { + const projectRoot = await inProjectWithTargets(); + const specPath = join(projectRoot, "agentcore", "agentcore.json"); + const { projectType: _projectType, ...spec } = JSON.parse(await readFile(specPath, "utf8")); + await writeFile(specPath, JSON.stringify(spec)); + await mkdir(join(projectRoot, "agentcore", ".cli"), { recursive: true }); + await writeFile( + join(projectRoot, "agentcore", ".cli", "deployed-state.json"), + JSON.stringify({ + targets: { + default: { resources: { boxGateways: [{ name: "orders-default-agent-box" }] } }, + }, + }), + ); + return projectRoot; + } + + const answer = (subject: { calls: { input: DeployBackendInput }[] }) => + subject.calls[0]!.input.confirmBoxGatewayDeletion!(REQUEST); + + test("asks before it deletes the Box gateway and proceeds on yes", async () => { + const subject = await testDeployCommand({ outputs: {} }, [], { isTTY: true, stdin: "y\n" }); + await lostMarker(); + + await subject.run(); + + expect(subject.io.stderr()).toContain(BOX_PROMPT); + expect(await answer(subject)).toBe(true); + }); + + test("cancels before the deploy when the user declines", async () => { + const subject = await testDeployCommand({ outputs: {} }, [], { isTTY: true, stdin: "n\n" }); + await lostMarker(); + + await expect(subject.run()).rejects.toBeInstanceOf(UserCancellationError); + expect(subject.calls).toEqual([]); + }); + + test("--yes confirms without a prompt", async () => { + const subject = await testDeployCommand({ outputs: {} }, [], { isTTY: true, stdin: "n\n" }); + await lostMarker(); + + await subject.run(["--yes"]); + + expect(subject.io.stderr()).not.toContain("(y/N)"); + expect(await answer(subject)).toBe(true); + }); + + test("declines without a prompt in a non-interactive shell", async () => { + const subject = await testDeployCommand({ outputs: {} }, [], { stdin: "y\n" }); + await lostMarker(); + + await subject.run(); + + expect(subject.io.stderr()).not.toContain("(y/N)"); + expect(await answer(subject)).toBe(false); + }); + + test("does not ask in a project that kept projectType", async () => { + const subject = await testDeployCommand({ outputs: {} }, [], { isTTY: true, stdin: "y\n" }); + await inProjectWithTargets(); + + await subject.run(); + + expect(subject.io.stderr()).not.toContain("(y/N)"); + }); + + test("a project with only projectType is a deploy, not a teardown", async () => { + const subject = await testDeployCommand({ outputs: {} }, [], { isTTY: true, stdin: "y\n" }); + const projectRoot = await inProjectWithTargets(); + await writeFile( + join(projectRoot, "agentcore", "agentcore.json"), + JSON.stringify({ name: "orders", version: 2, projectType: "box" }), + ); + + await subject.run(); + + expect(subject.io.stderr()).not.toContain("(y/N)"); + expect(await subject.calls[0]!.input.confirmTeardown(TEARDOWN)).toBe(false); + }); +}); diff --git a/src/handlers/project/deploy/index.ts b/src/handlers/project/deploy/index.ts index 95e8f837d0..839f99e96f 100644 --- a/src/handlers/project/deploy/index.ts +++ b/src/handlers/project/deploy/index.ts @@ -11,6 +11,11 @@ import { } from "../../../errors"; import type { AppIO } from "../../../io"; import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets"; +import { + declaresNoResources, + describeBoxGateways, + isBoxProject, +} from "../../../projectSchemas/project"; import { CommandRunMetricEventKey, createHandler, @@ -24,7 +29,13 @@ import { JsonRendererKey } from "../../../tui"; import { runWithProgress } from "../../../tui/progress"; import { JsonKey, RegionExplicitKey, RegionKey } from "../../keys"; import { renderJsonError } from "../../utils"; -import type { DeployResult, Project, ProjectManager, TeardownConfirmationHandler } from "../types"; +import type { + BoxGatewayDeletionHandler, + DeployResult, + Project, + ProjectManager, + TeardownConfirmationHandler, +} from "../types"; export type DeployProjectHandlerConfig = { projectManager: ProjectManager; @@ -76,7 +87,8 @@ export const createDeployProjectHandler = (config: DeployProjectHandlerConfig) = ), flag( "yes", - "confirm removing the target's stack when the project declares nothing to deploy", + "confirm removing the target's stack when the project declares nothing to deploy, " + + 'or the Box gateways when agentcore.json has lost its "projectType": "box"', z.boolean().default(false), ), flag( @@ -123,11 +135,20 @@ export const createDeployProjectHandler = (config: DeployProjectHandlerConfig) = canPrompt === true, ); + const confirmBoxGatewayDeletion = await resolveBoxGatewayDecision( + config, + project, + flags.target, + flags.yes, + canPrompt === true, + ); + const globalConfig = await ctx.require(GlobalConfigAccessorKey).get(); const deployment = config.projectManager.deploy(project, { target: flags.target, region: ctx.require(RegionKey), confirmTeardown, + confirmBoxGatewayDeletion, transactionSearch: globalConfig.transactionSearch, skipBootstrap: flags["skip-bootstrap"], }); @@ -277,22 +298,8 @@ async function delegateV0Deploy( * decides whether to ask the user before starting. */ export function declaresNothingDeployable(project: Project): boolean { - const { spec } = project; - const collections = [ - spec.runtimes, - spec.memories, - spec.knowledgeBases, - spec.credentials, - spec.evaluators, - spec.onlineEvalConfigs, - spec.agentCoreGateways, - spec.policyEngines, - spec.configBundles, - spec.harnesses, - spec.toolRuntimes ?? [], - spec.payments ?? [], - ]; - return collections.every((collection) => collection.length === 0); + // A Box project deploys the Box gateway, so it is never empty. + return !isBoxProject(project.spec) && declaresNoResources(project.spec); } /** @@ -315,7 +322,7 @@ async function resolveTeardownDecision( // fails with "no stack ... to remove" before consulting the decision. const target = await config.projectManager.resolveTarget(project, { target: targetName }); if (target) { - if (!(await promptForTeardown(config.io, project.name, target))) { + if (!(await promptForConfirmation(config.io, teardownQuestion(project.name, target)))) { throw new UserCancellationError(); } return async () => true; @@ -328,11 +335,42 @@ async function resolveTeardownDecision( return async () => false; } -async function promptForTeardown( - io: AppIO, - projectName: string, - target: { name: string; account: string; region: string }, -): Promise { +/** The question the deploy command asks before it deletes the Box gateways. */ +function boxGatewayDeletionQuestion(gatewayNames: string[]): string { + return ( + `agentcore.json has no "projectType": "box", so this deploy will delete ` + + `${describeBoxGateways(gatewayNames)}. Continue?` + ); +} + +/** + * Resolves the Box gateway question before the deploy generator starts, the same way as the + * teardown question: --yes confirms, an interactive session asks, and anything else declines, + * so the backend reports the "re-run with --yes" error. + */ +async function resolveBoxGatewayDecision( + config: DeployProjectHandlerConfig, + project: Project, + targetName: string, + confirmed: boolean, + canPrompt: boolean, +): Promise { + if (confirmed) return async () => true; + if (canPrompt && !isBoxProject(project.spec) && !declaresNoResources(project.spec)) { + const gatewayNames = await config.projectManager.recordedBoxGateways(project, { + target: targetName, + }); + if (gatewayNames.length > 0) { + if (!(await promptForConfirmation(config.io, boxGatewayDeletionQuestion(gatewayNames)))) { + throw new UserCancellationError(); + } + return async () => true; + } + } + return async () => false; +} + +async function promptForConfirmation(io: AppIO, question: string): Promise { const readline = createInterface({ input: io.stdin, output: io.stderr }); try { const cancelled = new Promise((_resolve, reject) => { @@ -340,10 +378,7 @@ async function promptForTeardown( readline.once("SIGINT", cancel); readline.once("close", cancel); }); - const answer = await Promise.race([ - readline.question(`${teardownQuestion(projectName, target)} (y/N) `), - cancelled, - ]); + const answer = await Promise.race([readline.question(`${question} (y/N) `), cancelled]); return /^(?:y|yes)$/i.test(answer.trim()); } finally { readline.close(); diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 0ace4da996..0acc7425c9 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -139,6 +139,67 @@ describe("project create", () => { expect(io.stderr()).toContain("next steps:\n cd MyAgent\n agentcore add harness"); }); + test.each([ + ["agent-python-strands", ["bedrock"]], + ["agent-python-minimal", ["bedrock"]], + ["mcp-python-fastmcp", undefined], + ["empty", undefined], + ] as const)( + "create --template %s writes the agent-box entry targets %j to agentcore.json", + async (template, targetNames) => { + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); + await run([ + "create", + "--name", + "BoxApp", + "--template", + template, + "--skip-install", + "--skip-git", + ]); + + const spec = await Bun.file(join(directory, "BoxApp", "agentcore", "agentcore.json")).json(); + // Every new project is a Box project; the CDK library makes each runtime's Box gateway. + expect(spec.projectType).toBe("box"); + if (targetNames === undefined) { + expect(spec.agentCoreGateways ?? []).toEqual([]); + return; + } + expect(spec.agentCoreGateways).toHaveLength(1); + expect(spec.agentCoreGateways[0]).toMatchObject({ + name: "agent-box", + protocolType: "None", + authorizerType: "AWS_IAM", + }); + expect( + spec.agentCoreGateways[0].targets.map((target: { name: string }) => target.name), + ).toEqual(targetNames); + }, + ); + + test("create --region points the agent-box Bedrock target at that region", async () => { + const { path: directory, cleanup } = await inTempDirectory(); + cleanups.push(cleanup); + await run([ + "create", + "--name", + "BoxApp", + "--template", + "agent-python-strands", + "--skip-install", + "--skip-git", + "--region", + "eu-central-1", + ]); + + const spec = await Bun.file(join(directory, "BoxApp", "agentcore", "agentcore.json")).json(); + expect(spec.agentCoreGateways[0].targets[0]).toMatchObject({ + passthrough: { endpoint: "https://bedrock-runtime.eu-central-1.amazonaws.com" }, + outboundAuth: { region: "eu-central-1" }, + }); + }); + test("an API key and an API key ARN are mutually exclusive on the harness path", () => { expect(() => resolveScaffoldHarnessInput({ diff --git a/src/handlers/project/remove/index.test.ts b/src/handlers/project/remove/index.test.ts index d4b755f1cb..1de0448e95 100644 --- a/src/handlers/project/remove/index.test.ts +++ b/src/handlers/project/remove/index.test.ts @@ -19,7 +19,7 @@ import { projectSpec, writeProjectSpec } from "../add/gateway-test-support"; import { credentialEnvVarName } from "../../../projectSchemas/credential"; import { ENV_LOCAL_RELATIVE_PATH } from "../../../core/project/envLocal"; import { readEnvFile } from "../../../io/envFile"; -import { APP_CODE_RETAINED_NOTICE } from "./notice"; +import { APP_CODE_RETAINED_NOTICE, BOX_GATEWAY_STAYS_NOTICE } from "./notice"; const cleanups: Array<() => Promise> = []; afterEach(() => Promise.all(cleanups.splice(0).map((cleanup) => cleanup()))); @@ -84,7 +84,8 @@ describe("project remove", () => { ["remove", "gateway", "--name", "remove"], ], specKey: "agentCoreGateways", - expectedRemaining: ["keep"], + // The agent-box entry that create declares for the Bedrock agent stays. + expectedRemaining: ["agent-box", "keep"], }, { label: "config-bundle", @@ -296,7 +297,7 @@ describe("project remove", () => { const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); expect( - agentcoreJson.agentCoreGateways[0].targets.map((target: { name: string }) => target.name), + agentcoreJson.agentCoreGateways[1].targets.map((target: { name: string }) => target.name), ).toEqual(["keep"]); expect(JSON.parse(io.stdout())).toEqual({ operation: "remove", @@ -691,3 +692,96 @@ describe("project remove all", () => { expect((await projectSpec(projectRoot)).runtimes).toEqual([]); }); }); + +describe("project remove in a Box project", () => { + async function boxProject(): Promise { + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); + await run([ + "add", + "gateway-target", + "--gateway", + "agent-box", + "--target-configuration", + JSON.stringify({ + name: "weather", + targetType: "passthrough", + passthrough: { endpoint: "https://weather.example.com" }, + outboundAuth: { type: "GATEWAY_IAM_ROLE", service: "execute-api" }, + }), + ]); + return projectRoot; + } + + test("add gateway-target --gateway agent-box writes the extension entry", async () => { + const projectRoot = await boxProject(); + + const spec = await projectSpec(projectRoot); + expect(spec.projectType).toBe("box"); + expect(spec.agentCoreGateways).toMatchObject([ + { + name: "agent-box", + authorizerType: "AWS_IAM", + protocolType: "None", + // create declares the Bedrock target for the template's Bedrock agent. + targets: [ + { name: "bedrock", targetType: "passthrough" }, + { name: "weather", targetType: "passthrough" }, + ], + }, + ]); + }); + + test("remove gateway-target of the last target removes the entry", async () => { + const projectRoot = await boxProject(); + + await run(["remove", "gateway-target", "--gateway", "agent-box", "--name", "weather"]); + expect((await projectSpec(projectRoot)).agentCoreGateways).toHaveLength(1); + await run(["remove", "gateway-target", "--gateway", "agent-box", "--name", "bedrock"]); + + const spec = await projectSpec(projectRoot); + expect(spec.agentCoreGateways).toEqual([]); + expect(spec.projectType).toBe("box"); + }); + + test.each([[[]], [["--json"]]])( + "remove gateway --name agent-box %j says that the Box gateway stays", + async (extra) => { + const projectRoot = await boxProject(); + + const { io } = await run(["remove", "gateway", "--name", "agent-box", ...extra]); + + expect(io.stderr()).toContain(BOX_GATEWAY_STAYS_NOTICE); + const spec = await projectSpec(projectRoot); + expect(spec.agentCoreGateways).toEqual([]); + expect(spec.projectType).toBe("box"); + }, + ); + + test("remove gateway --name box in a project without projectType says nothing more", async () => { + const { projectRoot, cleanup } = await initProject({ + flags: ["--template", "agent-python-minimal"], + }); + cleanups.push(cleanup); + const { projectType: _projectType, ...spec } = await projectSpec(projectRoot); + await writeProjectSpec(projectRoot, spec); + await run(["add", "gateway", "--name", "box"]); + + const { io } = await run(["remove", "gateway", "--name", "box"]); + + expect(io.stderr()).not.toContain(BOX_GATEWAY_STAYS_NOTICE); + expect(await projectSpec(projectRoot)).not.toHaveProperty("projectType"); + }); + + test("remove all removes projectType with the gateway entries", async () => { + const projectRoot = await boxProject(); + + await run(["remove", "all", "--yes"]); + + const spec = await projectSpec(projectRoot); + expect(spec).not.toHaveProperty("projectType"); + expect(spec.agentCoreGateways).toEqual([]); + }); +}); diff --git a/src/handlers/project/remove/index.ts b/src/handlers/project/remove/index.ts index 2826479d75..863de7e91a 100644 --- a/src/handlers/project/remove/index.ts +++ b/src/handlers/project/remove/index.ts @@ -8,7 +8,12 @@ import { JsonKey } from "../../keys"; import { renderResult } from "../../utils"; import type { ProjectManager, RemoveResourceInput } from "../types"; import { projectMutationResource, projectReference, type ProjectMutationResult } from "../output"; -import { APP_CODE_RETAINED_NOTICE, shouldShowAppCodeNotice } from "./notice"; +import { isBoxExtensionEntry } from "../../../projectSchemas/project"; +import { + APP_CODE_RETAINED_NOTICE, + BOX_GATEWAY_STAYS_NOTICE, + shouldShowAppCodeNotice, +} from "./notice"; type RemoveProjectResourceConfig = { projectManager: ProjectManager; @@ -157,6 +162,11 @@ export const createRemoveProjectHandler = (config: RemoveProjectResourceConfig) const result = await config.projectManager.removeResource(project, input); reportEnvCleanup(config.io, result.removedEnvKeys); + // On stderr in both modes, like the .env.local lines: a --json caller still needs to know + // that the gateway itself is not deleted. + if (resource === "gateway" && isBoxExtensionEntry(project.spec, { name })) { + config.io.stderr.write(`${BOX_GATEWAY_STAYS_NOTICE}\n`); + } const showAppCodeNotice = shouldShowAppCodeNotice(resource); renderResult( ctx, diff --git a/src/handlers/project/remove/notice.ts b/src/handlers/project/remove/notice.ts index d27aaec709..9872b982ae 100644 --- a/src/handlers/project/remove/notice.ts +++ b/src/handlers/project/remove/notice.ts @@ -6,3 +6,6 @@ export const APP_CODE_RETAINED_NOTICE = export function shouldShowAppCodeNotice(resourceType: ProjectMutationResourceType): boolean { return resourceType === "runtime" || resourceType === "harness" || resourceType === "evaluator"; } + +/** Shown when `remove gateway --name box` removes the entry that holds the Box gateway's targets. */ +export const BOX_GATEWAY_STAYS_NOTICE = "The Box gateway stays. This removes only its targets."; diff --git a/src/handlers/project/remove/remove.screen.test.tsx b/src/handlers/project/remove/remove.screen.test.tsx index a6c37d4f12..fcaad210fc 100644 --- a/src/handlers/project/remove/remove.screen.test.tsx +++ b/src/handlers/project/remove/remove.screen.test.tsx @@ -68,6 +68,19 @@ function render(path: string, core: TestCoreClient, project: Project) { return renderScreen(path, { core, withContext: (ctx) => ctx.withValue(ProjectKey, project) }); } +/** + * A project with no resources: the created one minus its scaffolded runtime, + * whose removal also removes the runtime's Box gateway entry that create declares. + */ +async function emptyProject(core: TestCoreClient): Promise { + const { project } = await createProject(core); + const { project: empty } = await core.projectManager.removeResource(project, { + resourceType: "runtime", + name: project.spec.runtimes[0]!.name, + }); + return empty; +} + async function readSpec(specPath: string): Promise { return ProjectSpecSchema.parse(JSON.parse(await readFile(specPath, "utf8"))); } @@ -223,11 +236,7 @@ describe("project remove screen", () => { test("an empty project offers no resources to remove and no all option", async () => { const core = new TestCoreClient(); - const { project } = await createProject(core); - const { project: empty } = await core.projectManager.removeResource(project, { - resourceType: "runtime", - name: project.spec.runtimes[0]!.name, - }); + const empty = await emptyProject(core); const r = render("/agentcore/remove", core, empty); await waitForText(r.lastFrame, "This project has no resources to remove."); @@ -251,11 +260,7 @@ describe("project remove screen", () => { test("remove all on an empty project shows nothing to remove", async () => { const core = new TestCoreClient(); - const { project } = await createProject(core); - const { project: empty } = await core.projectManager.removeResource(project, { - resourceType: "runtime", - name: project.spec.runtimes[0]!.name, - }); + const empty = await emptyProject(core); const r = render("/agentcore/remove/all", core, empty); await waitForText(r.lastFrame, "This project has no resources to remove."); @@ -314,8 +319,9 @@ describe("project remove screen", () => { const r = render("/agentcore/remove", core, project); await waitForText(r.lastFrame, "all"); - // `all` = the sum of the listed rows: 1 runtime + 1 policy-engine + 1 policy. - expect(r.lastFrame()).toMatch(/all\s+3/); + // `all` = the sum of the listed rows: 1 runtime + 1 policy-engine + 1 policy, + // plus the agent-box gateway entry and its bedrock gateway-target. + expect(r.lastFrame()).toMatch(/all\s+5/); r.unmount(); }); diff --git a/src/handlers/project/shortcuts.test.ts b/src/handlers/project/shortcuts.test.ts index 28b7ce7d83..e95ad73405 100644 --- a/src/handlers/project/shortcuts.test.ts +++ b/src/handlers/project/shortcuts.test.ts @@ -1,9 +1,13 @@ import { describe, expect, test } from "bun:test"; +import { AgentCoreGatewaySchema } from "../../projectSchemas/gateway"; import { EMPTY_TEMPLATE_NAME, formatTemplateParameterHelp, + boxGatewayEntryForRuntime, + getBoxGatewayBedrockEntry, getDefaultMemorySpec, PROJECT_TEMPLATE_NAMES, + resolveRuntimeTemplateShortcut, RUNTIME_TEMPLATE_SHORTCUT_NAMES, RUNTIME_TEMPLATE_SHORTCUTS, } from "./shortcuts"; @@ -77,3 +81,84 @@ test("default memory names fit the service limit for long runtime names", () => expect(memory.name).toBe(`${"a".repeat(42)}Memory`); expect(memory.name).toHaveLength(48); }); + +describe("Box gateway entry", () => { + const BEDROCK_TARGET = { + name: "bedrock", + targetType: "passthrough", + passthrough: { + endpoint: "https://bedrock-runtime.us-west-2.amazonaws.com", + protocolType: "CUSTOM", + }, + outboundAuth: { type: "GATEWAY_IAM_ROLE", service: "bedrock", region: "us-west-2" }, + } as const; + + test("is a valid project gateway, named after the runtime's Box gateway", () => { + const entry = getBoxGatewayBedrockEntry("my_agent"); + expect(AgentCoreGatewaySchema.parse(entry)).toEqual(entry); + expect(entry.name).toBe("my-agent-box"); + }); + + test.each([ + "agent-python-strands", + "agent-python-strands-container", + "agent-typescript-strands", + "a2a-python-strands", + "agui-python-strands", + "agent-python-langchain", + "agent-typescript-vercel", + "agent-python-minimal", + ] as const)("%s declares it with the Bedrock target by default", (template) => { + expect( + boxGatewayEntryForRuntime(resolveRuntimeTemplateShortcut(template, { runtimeName: "agent" })), + ).toEqual({ + name: "agent-box", + protocolType: "None", + authorizerType: "AWS_IAM", + enableSemanticSearch: true, + exceptionLevel: "NONE", + targets: [BEDROCK_TARGET], + }); + }); + + test.each(["mcp-python-fastmcp", "environment-python-bma"] as const)( + "%s, which has no model provider, declares none", + (template) => { + expect(boxGatewayEntryForRuntime(resolveRuntimeTemplateShortcut(template))).toBeUndefined(); + }, + ); + + test.each([ + ["OpenAI", { modelProvider: "OpenAI", apiKey: "sk-test" }], + ["Anthropic", { modelProvider: "Anthropic", apiKey: "sk-test" }], + ["Gemini", { modelProvider: "Gemini", apiKey: "test" }], + ["LiteLLM with a non-Bedrock model", { modelProvider: "LiteLLM", modelId: "openai/gpt-4o" }], + ] as const)("%s: the agent declares none", (_label, overrides) => { + expect( + boxGatewayEntryForRuntime(resolveRuntimeTemplateShortcut("agent-python-strands", overrides)), + ).toBeUndefined(); + }); + + test.each([ + ["eu-central-1", "https://bedrock-runtime.eu-central-1.amazonaws.com"], + ["us-gov-west-1", "https://bedrock-runtime.us-gov-west-1.amazonaws.com"], + ])("targets Bedrock in the region the command resolved, %s", (region, endpoint) => { + const [target] = getBoxGatewayBedrockEntry("agent", region).targets; + expect(target).toMatchObject({ + passthrough: { endpoint }, + outboundAuth: { type: "GATEWAY_IAM_ROLE", service: "bedrock", region }, + }); + }); + + test("falls back to us-west-2 when no region is given", () => { + expect(getBoxGatewayBedrockEntry("agent").targets[0]).toEqual(BEDROCK_TARGET); + }); + + test("a LiteLLM agent on its default Bedrock model declares the Bedrock target", () => { + expect( + boxGatewayEntryForRuntime( + resolveRuntimeTemplateShortcut("agent-python-strands", { modelProvider: "LiteLLM" }), + )?.targets, + ).toEqual([BEDROCK_TARGET]); + }); +}); diff --git a/src/handlers/project/shortcuts.ts b/src/handlers/project/shortcuts.ts index 9fb198b07a..d35fa544c2 100644 --- a/src/handlers/project/shortcuts.ts +++ b/src/handlers/project/shortcuts.ts @@ -6,6 +6,9 @@ import { type Memory, } from "../../projectSchemas/memory"; import { InputValidationError } from "../../errors"; +import type { AgentCoreGateway } from "../../projectSchemas/gateway"; +import { DEFAULT_MODEL_IDS } from "../../projectSchemas/runtime"; +import { boxGatewayName } from "../../projectSchemas/project"; import { ScaffoldRuntimeInputSchema, type ModelProvider, type ScaffoldRuntimeInput } from "./types"; import type { RuntimeTemplateProfile } from "./templateProfile"; import { BMA_TEMPLATE_NAME, BMA_TEMPLATE_PROFILE } from "./bma"; @@ -25,6 +28,77 @@ export function getDefaultMemorySpec(runtimeName: string): Memory { }; } +/** The name of the Bedrock model target in a runtime's Box gateway entry. */ +export const BOX_GATEWAY_BEDROCK_TARGET_NAME = "bedrock"; + +/** + * The region of the Bedrock target when the command resolved none (a caller + * that passes no region, such as a test). `create` and `add runtime` pass the + * region they resolved (flag, AWS_REGION, then the profile), which is the + * region a first `deploy` uses when aws-targets.json is empty. + */ +export const BOX_GATEWAY_BEDROCK_DEFAULT_REGION = "us-west-2"; + +/** + * The entry that adds a passthrough target for the Bedrock model endpoint in + * `region` to the runtime's Box gateway, `-box`, which the CDK library + * makes for each runtime of a Box project. The entry has the settings `agentcore + * add gateway-target` gives one, which are the Box gateway's own. The target + * signs with the gateway's role, which needs Bedrock permissions of its own. + */ +export function getBoxGatewayBedrockEntry( + runtimeName: string, + region: string = BOX_GATEWAY_BEDROCK_DEFAULT_REGION, +): AgentCoreGateway { + return { + name: boxGatewayName(runtimeName), + protocolType: "None", + authorizerType: "AWS_IAM", + // The value the gateway schema fills in; it has no effect without MCP. + enableSemanticSearch: true, + exceptionLevel: "NONE", + targets: [ + { + name: BOX_GATEWAY_BEDROCK_TARGET_NAME, + targetType: "passthrough", + passthrough: { + endpoint: `https://bedrock-runtime.${region}.amazonaws.com`, + protocolType: "CUSTOM", + }, + outboundAuth: { type: "GATEWAY_IAM_ROLE", service: "bedrock", region }, + }, + ], + }; +} + +/** + * The Box gateway entry that a scaffolded runtime of a Box project starts with, + * or undefined. A runtime whose agent calls Bedrock (the Bedrock provider, or + * LiteLLM on a `bedrock/` model) gets the Bedrock target. A runtime with no + * model provider (an MCP server, BMA) calls no model, and another provider's + * endpoint takes an API key that the CLI has no credential for, so those get + * none; their Box gateway still exists. `create` and `add runtime` both call + * this where they write the spec, so the two give a runtime the same entry. + */ +export function boxGatewayEntryForRuntime( + input: Pick, + region?: string, +): AgentCoreGateway | undefined { + if (input.modelProvider === undefined || !callsBedrockModel(input)) return undefined; + return getBoxGatewayBedrockEntry(input.runtimeName, region); +} + +/** + * Whether the scaffolded agent calls Bedrock for its model: the Bedrock + * provider, or LiteLLM with a `bedrock/` model id (its default). + */ +export function callsBedrockModel(input: Pick) { + const provider = input.modelProvider ?? "Bedrock"; + if (provider === "Bedrock") return true; + if (provider !== "LiteLLM") return false; + return (input.modelId ?? DEFAULT_MODEL_IDS.LiteLLM).startsWith("bedrock/"); +} + type RuntimeTemplateShortcut = { runtimeName: string; /** One line shown next to the template name in the create wizard. */ @@ -255,15 +329,17 @@ export function resolveRuntimeTemplateShortcut( const template: RuntimeTemplateShortcut = RUNTIME_TEMPLATE_SHORTCUTS[name]; const runtimeName = overrides?.runtimeName ?? template.runtimeName; + const modelProvider = template.supportsModelProviderOverride + ? (overrides?.modelProvider ?? template.modelProvider) + : template.modelProvider; + const input = { runtimeName, build: template.build, language: template.language, framework: template.framework, protocol: template.protocol, - modelProvider: template.supportsModelProviderOverride - ? (overrides?.modelProvider ?? template.modelProvider) - : template.modelProvider, + modelProvider, ...(overrides?.modelId !== undefined && { modelId: overrides.modelId }), ...(overrides?.apiKey !== undefined && { apiKey: overrides.apiKey }), ...(overrides?.apiBase !== undefined && { apiBase: overrides.apiBase }), diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index f81a8476a5..7575998254 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -30,6 +30,11 @@ type CreateProjectInputBase = { skipInstall?: boolean; /** Skip initializing a git repository. */ skipGit?: boolean; + /** + * The region the command resolved (flag, AWS_REGION, then the profile). It + * picks the Bedrock endpoint of a runtime's Box gateway entry. + */ + region?: string; }; /** Set of arguments needed to scaffold a managed code-based evaluator. */ @@ -208,6 +213,16 @@ export type TeardownConfirmationHandler = ( request: TeardownConfirmationRequest, ) => Promise; +/** A deploy that would delete the Box gateways, because agentcore.json lost its "projectType": "box". */ +export type BoxGatewayDeletionRequest = { + projectName: string; + targetName: string; + /** The AWS names of the Box gateways that the last deploy to the target recorded. */ + gatewayNames: string[]; +}; + +export type BoxGatewayDeletionHandler = (request: BoxGatewayDeletionRequest) => Promise; + export type DeployProjectInput = { /** Name of the aws-targets.json entry to deploy. */ target: string; @@ -219,6 +234,11 @@ export type DeployProjectInput = { region: string; /** Requests approval after the backend discovers that this deploy is a teardown. */ confirmTeardown: TeardownConfirmationHandler; + /** + * Requests approval for a deploy that would delete the Box gateway. Absent means declined: + * the deploy then fails with the "re-run with --yes" error. + */ + confirmBoxGatewayDeletion?: BoxGatewayDeletionHandler; /** Whether to enable CloudWatch Transaction Search on deploy (from global config). */ transactionSearch?: boolean; /** Leaves the CDK bootstrap stack alone for accounts bootstrapped outside the CLI. */ @@ -582,6 +602,12 @@ export interface ProjectManager { input: ResolveTargetInput, ): Promise; + /** + * The AWS name of the Box gateway that the last deploy to the target recorded, or undefined + * when it recorded none. Read-only, for the deploy handler's up-front confirmation. + */ + recordedBoxGateways(project: Project, input: ResolveTargetInput): Promise; + /** Locate an existing AgentCore project. Returns undefined if no project can be found. */ resolve(input: ResolveProjectInput): Promise; diff --git a/src/projectSchemas/project.test.ts b/src/projectSchemas/project.test.ts index fbba508331..885dcabf5a 100644 --- a/src/projectSchemas/project.test.ts +++ b/src/projectSchemas/project.test.ts @@ -408,3 +408,23 @@ describe("project custom validation", () => { } }); }); + +describe("projectType", () => { + test("is absent after a parse of a project without it, so a write-back adds no key", () => { + expect(ProjectSpecSchema.parse(minimalProject)).not.toHaveProperty("projectType"); + }); + + test('accepts "box"', () => { + expect(ProjectSpecSchema.parse({ ...minimalProject, projectType: "box" }).projectType).toBe( + "box", + ); + }); + + test.each([["Box"], ["standard"], [{}], [null]])("rejects %j", (projectType) => { + expect(ProjectSpecSchema.safeParse({ ...minimalProject, projectType }).success).toBe(false); + }); + + test("rejects the old box key", () => { + expect(ProjectSpecSchema.safeParse({ ...minimalProject, box: {} }).success).toBe(false); + }); +}); diff --git a/src/projectSchemas/project.ts b/src/projectSchemas/project.ts index 756e6eaec7..96694b6b25 100644 --- a/src/projectSchemas/project.ts +++ b/src/projectSchemas/project.ts @@ -27,6 +27,72 @@ export const ProjectNameSchema = z message: "This name conflicts with a reserved package dependency. Please choose a different name.", }); +/** The kind of project. `box` is the only kind: each runtime of a Box project gets a Box gateway. */ +export const ProjectTypeSchema = z.enum(["box"]); +/** Whether the project is a Box project, so the CDK library makes a Box gateway for each runtime. */ +export const isBoxProject = (spec: { projectType?: string }) => spec.projectType === "box"; +/** The suffix of each Box gateway name: the runtime `agent` gets the gateway `agent-box`. */ +export const BOX_GATEWAY_SUFFIX = "-box"; +/** The only target type the Box gateway takes: it has no MCP interface (protocolType "None"). */ +export const BOX_GATEWAY_TARGET_TYPE = "passthrough"; +/** + * The logical name of the Box gateway of a runtime, and of the gateway entry that adds targets to + * it. Gateway names do not permit `_`, so `my_agent` gets `my-agent-box`. Must match the CDK library. + */ +export const boxGatewayName = (runtimeName: string): string => + `${runtimeName.replace(/_/g, "-")}${BOX_GATEWAY_SUFFIX}`; +/** The Box gateway names of a Box project, one for each runtime; none in any other project. */ +export const boxGatewayNames = (spec: { + projectType?: string; + runtimes: { name: string }[]; +}): string[] => (isBoxProject(spec) ? spec.runtimes.map(({ name }) => boxGatewayName(name)) : []); +/** + * Whether a gateway entry only adds targets to a Box gateway: it is named `-box` for a + * runtime of a Box project. In any other project, such a name is a normal gateway. + */ +export const isBoxExtensionEntry = ( + spec: { projectType?: string; runtimes: { name: string }[] }, + gateway: { name: string }, +) => boxGatewayNames(spec).includes(gateway.name); +/** + * Whether a gateway name is reserved for Box in a Box project: each name that ends in `-box`, in + * any letter case. The CDK library refuses such a name unless it is a runtime's Box gateway. + */ +export const isReservedBoxGatewayName = (spec: { projectType?: string }, name: string) => + isBoxProject(spec) && name.toLowerCase().endsWith(BOX_GATEWAY_SUFFIX); +/** + * The AWS name the CDK library gives the Box gateway of a runtime: `---box`. + * A project migrated from v0 keeps v0's names, which have no target segment. + */ +export const boxGatewayAwsName = ( + spec: { name: string; migratedFrom?: string }, + targetName: string, + runtimeName: string, +): string => + spec.migratedFrom === undefined + ? `${spec.name}-${targetName}-${boxGatewayName(runtimeName)}` + : `${spec.name}-${boxGatewayName(runtimeName)}`; +/** "the Box gateway 'a'" or "the Box gateways 'a', 'b'", for messages about recorded gateways. */ +export const describeBoxGateways = (names: string[]): string => + `the Box gateway${names.length === 1 ? "" : "s"} ${names.map((name) => `'${name}'`).join(", ")}`; +/** Whether every resource collection of the spec is empty, as after `agentcore remove all`. */ +export function declaresNoResources(spec: ProjectSpec): boolean { + const collections = [ + spec.runtimes, + spec.memories, + spec.knowledgeBases, + spec.credentials, + spec.evaluators, + spec.onlineEvalConfigs, + spec.agentCoreGateways, + spec.policyEngines, + spec.configBundles, + spec.harnesses, + spec.toolRuntimes ?? [], + spec.payments ?? [], + ]; + return collections.every((collection) => collection.length === 0); +} const BUILTIN_EVALUATOR_PREFIX = "Builtin."; const ARN_PREFIX = "arn:"; const toEnvironmentName = (name: string) => name.replace(/-/g, "_").toUpperCase(); @@ -49,6 +115,14 @@ export const ProjectSpecSchema = z migratedFrom: z.literal("v1").optional(), managedBy: ManagedBySchema, tags: TagsSchema.optional(), + /** + * `"box"` for a Box project. `agentcore create` writes it into every new project, and the CDK + * library then adds one Box gateway for each runtime: logical name `-box`, `AWS_IAM` + * auth, `protocolType: "None"`. A gateway entry with that name only adds targets to it. No default: the spec + * is written back to disk after each parse, so a default would add the field to every + * existing project. + */ + projectType: ProjectTypeSchema.optional(), runtimes: z.array(ProjectRuntimeSchema).default([]).superRefine(uniqueNames("agent")), memories: z.array(MemorySchema).default([]).superRefine(uniqueNames("memory")), knowledgeBases: z