From 0ce88de5e0940b5408752f35b941926b29f21a75 Mon Sep 17 00:00:00 2001 From: gitikavj Date: Tue, 13 Oct 2026 06:48:46 +0000 Subject: [PATCH] feat(dev): use deployed resources in local runtimes --- command.md | 11 +- src/core/project/backends/cdk.test.ts | 61 +++++ src/core/project/backends/cdk.ts | 20 ++ .../backends/cdk/devEnvironment.test.ts | 232 ++++++++++++++++++ .../project/backends/cdk/devEnvironment.ts | 216 ++++++++++++++++ src/core/project/backends/types.ts | 9 + src/core/project/manager.tsx | 14 ++ src/handlers/project/dev/environment.test.ts | 78 +++++- src/handlers/project/dev/environment.ts | 20 +- src/handlers/project/dev/index.test.ts | 135 +++++++++- src/handlers/project/dev/index.ts | 71 +++++- src/handlers/project/types.ts | 16 ++ 12 files changed, 860 insertions(+), 23 deletions(-) create mode 100644 src/core/project/backends/cdk/devEnvironment.test.ts create mode 100644 src/core/project/backends/cdk/devEnvironment.ts diff --git a/command.md b/command.md index d62fe6d1a8..b8d01629f9 100644 --- a/command.md +++ b/command.md @@ -266,15 +266,18 @@ create a new project agentcore dev [options] ``` -run the project locally for development +test project changes: run Runtimes locally or deploy for Harnesses; --mode browser opens Agent Inspector. **Options** -- `--agent `: Runtime to run +- `--agent `: runtime or harness to run +- `--ui-port `: port for the Agent Inspector web UI (browser mode) +- `--mode `: how to run: browser (Agent Inspector web UI) or headless (runtime output or harness invoke guidance) (default: "headless") +- `--target `: deployment target whose resources local Runtimes use, or to deploy for Harnesses (default: "default" when present) - `--port `: port for the development server - `--no-traces`: disable local OTEL trace collection -- `--mode `: how to run: browser (Agent Inspector web UI) or headless (agents stream to the terminal) (default: "headless") -- `--ui-port `: port for the Agent Inspector web UI (browser mode) +- `--yes`: confirm removing the target's stack when the project declares nothing to deploy (default: false) +- `--skip-deploy`: skip deployment and use the deployed harnesses (default: false) ### `agentcore deploy` diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index 695d856c28..15bd6bc3ed 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -1330,3 +1330,64 @@ describe("CdkBackend.resolveProjectResources", () => { ).toEqual([["gateway", "gw", ["owned", "second"]]]); }); }); + +describe("CdkBackend.resolveDevEnvironment", () => { + test("reads a v0 project's deployed resource state without calling AWS", async () => { + const input = await project(); + input.spec = { + ...input.spec, + memories: [{ name: "history" }], + } as unknown as typeof input.spec; + input.legacy = { + format: "v0", + v0Spec: {} as never, + extras: {} as never, + }; + await updateTargetState(json, input.rootPath, TARGET.name, { + resources: { + stackName: "AgentCore-example-default", + memories: { + history: { + memoryId: "memory-v0", + memoryArn: "arn:memory/memory-v0", + }, + }, + }, + }); + const subject = harness(); + + const env = await subject.backend.resolveDevEnvironment(input, { target: TARGET }); + + expect(env).toEqual({ MEMORY_HISTORY_ID: "memory-v0" }); + expect(subject.stackReads).toEqual([]); + expect(subject.credentialRegions).toEqual([]); + }); + + test("reads a v1 project's deployed resource values from its stack outputs", async () => { + const input = await project(); + input.spec = { + ...input.spec, + memories: [{ name: "history" }], + } as unknown as typeof input.spec; + await updateTargetState(json, input.rootPath, TARGET.name, { stackArn: STACK_ARN }); + const subject = harness({ + describedStack: { + StackName: "AgentCore-example-default", + CreationTime: new Date(0), + StackStatus: "CREATE_COMPLETE", + Outputs: [ + { + ExportName: "AgentCore-example-default-Memory-history-Id", + OutputValue: "memory-v1", + }, + ], + }, + }); + + const env = await subject.backend.resolveDevEnvironment(input, { target: TARGET }); + + expect(env).toEqual({ MEMORY_HISTORY_ID: "memory-v1" }); + expect(subject.stackReads).toHaveLength(1); + expect(subject.credentialRegions).toEqual([TARGET.region]); + }); +}); diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index 28f4636751..4f64d3fd31 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -26,6 +26,7 @@ import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; import type { DeployBackendInput, ProjectBackend, + ResolveDevEnvironmentBackendInput, ResolveDeployedResourcesBackendInput, ResolveProjectResourcesBackendInput, } from "./types"; @@ -75,6 +76,7 @@ import { type PaymentConnectorAuthorizationUrlReporter, } from "./cdk/paymentConnectorAuthorizationUrls"; import { describeStack } from "./cdk/stackReader"; +import { projectDevEnvironment } from "./cdk/devEnvironment"; type StackDescriber = typeof describeStack; @@ -667,6 +669,24 @@ export class CdkBackend implements ProjectBackend { ]; } + public async resolveDevEnvironment( + project: Project, + input: ResolveDevEnvironmentBackendInput, + ): Promise> { + const deployedState = await readDeployedState(this.json, project.rootPath); + const recorded = deployedState.targets[input.target.name]; + const stackReference = stackReferenceOf(recorded); + const stack = + stackReference && !project.legacy + ? await this.describeStack( + input.target.region, + await this.credentialsForTarget(input.target), + stackReference, + ) + : undefined; + return projectDevEnvironment({ project, recorded, stack: stack ?? undefined }); + } + private async credentialsForTarget(target: AwsDeploymentTarget) { const credentials = await this.resolveCredentials(target.region); const account = await this.resolveAccount(target.region, credentials); diff --git a/src/core/project/backends/cdk/devEnvironment.test.ts b/src/core/project/backends/cdk/devEnvironment.test.ts new file mode 100644 index 0000000000..b8e4cd2a6f --- /dev/null +++ b/src/core/project/backends/cdk/devEnvironment.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, test } from "bun:test"; +import type { Stack } from "@aws-sdk/client-cloudformation"; +import type { Project } from "../../../../handlers/project/types"; +import { ProjectSpecSchema } from "../../../../projectSchemas/project"; +import type { TargetState } from "./deployedState"; +import { projectDevEnvironment } from "./devEnvironment"; + +function project(overrides: Partial = {}): Project { + return { + name: "orders", + rootPath: "/workspace/orders", + spec: { + ...ProjectSpecSchema.parse({ name: "orders", version: 2 }), + ...overrides, + }, + }; +} + +const configuredProject = project({ + memories: [{ name: "order_history" }], + agentCoreGateways: [ + { + name: "tools-gateway", + authorizerType: "AWS_IAM", + protocolType: "MCP", + targets: [], + }, + ], + payments: [ + { + name: "Billing", + authorizerType: "CUSTOM_JWT", + connectors: [ + { + name: "primary_wallet", + provider: "CoinbaseCDP", + provisionMode: "QUICK_CREATE", + }, + ], + autoPayment: true, + defaultSpendLimit: "10.00", + paymentToolAllowlist: ["pay"], + networkPreferences: ["eip155:1"], + }, + ], +} as unknown as Partial); + +describe("projectDevEnvironment", () => { + test("reads the v0 memory, gateway, and payment state", () => { + const recorded = { + resources: { + memories: { + order_history: { + memoryId: "memory-1", + memoryArn: "arn:memory/memory-1", + }, + }, + mcp: { + gateways: { + "tools-gateway": { + gatewayId: "gateway-1", + gatewayArn: "arn:gateway/gateway-1", + gatewayUrl: "https://gateway.example.test", + }, + }, + }, + payments: { + Billing: { + managerId: "billing-1", + managerArn: "arn:payment/billing-1", + processPaymentRoleArn: "arn:role/process-payment", + resourceRetrievalRoleArn: "arn:role/resource-retrieval", + authorizerType: "AWS_IAM", + autoPayment: false, + paymentToolAllowlist: ["refund"], + networkPreferences: ["eip155:8453"], + connectors: { + primary_wallet: { + connectorId: "connector-1", + provisionMode: "QUICK_CREATE", + }, + }, + }, + }, + }, + } as TargetState; + + expect(projectDevEnvironment({ project: configuredProject, recorded })).toEqual({ + MEMORY_ORDER_HISTORY_ID: "memory-1", + AGENTCORE_GATEWAY_TOOLS_GATEWAY_URL: "https://gateway.example.test", + AGENTCORE_GATEWAY_TOOLS_GATEWAY_AUTH_TYPE: "AWS_IAM", + AGENTCORE_PAYMENT_BILLING_MANAGER_ARN: "arn:payment/billing-1", + AGENTCORE_PAYMENT_BILLING_PROCESS_PAYMENT_ROLE_ARN: "arn:role/process-payment", + AGENTCORE_PAYMENT_BILLING_CONNECTOR_ID: "connector-1", + AGENTCORE_PAYMENT_BILLING_AUTO_PAYMENT: "false", + AGENTCORE_PAYMENT_BILLING_TOOL_ALLOWLIST: "refund", + AGENTCORE_PAYMENT_BILLING_NETWORK_PREFERENCES: "eip155:8453", + }); + }); + + test("keeps discovering v0 resources that are only present in deployed state", () => { + const recorded = { + resources: { + memories: { + "removed-memory": { + memoryId: "memory-legacy", + }, + }, + mcp: { + gateways: { + "removed-gateway": { + gatewayUrl: "https://legacy-gateway.example.test", + }, + }, + }, + gateways: { + "removed-gateway": { + gatewayUrl: "https://stale-layout.example.test", + }, + }, + payments: { + "removed-payment": { + managerArn: "arn:payment/legacy", + connectors: { + "first-connector": { connectorId: "connector-first" }, + "second-connector": { connectorId: "connector-second" }, + }, + paymentToolAllowlist: [], + networkPreferences: [], + }, + }, + }, + } as TargetState; + + expect(projectDevEnvironment({ project: project(), recorded })).toEqual({ + MEMORY_REMOVED_MEMORY_ID: "memory-legacy", + AGENTCORE_GATEWAY_REMOVED_GATEWAY_URL: "https://legacy-gateway.example.test", + AGENTCORE_GATEWAY_REMOVED_GATEWAY_AUTH_TYPE: "NONE", + AGENTCORE_PAYMENT_REMOVED_PAYMENT_MANAGER_ARN: "arn:payment/legacy", + AGENTCORE_PAYMENT_REMOVED_PAYMENT_CONNECTOR_ID: "connector-first", + }); + }); + + test("falls back to v1 CloudFormation outputs", () => { + const stack = { + StackName: "AgentCore-orders-default", + Outputs: [ + { + ExportName: "AgentCore-orders-default-Memory-order-history-Id", + OutputValue: "memory-2", + }, + { + ExportName: "AgentCore-orders-default-Gateway-tools-gateway-Url", + OutputValue: "https://v1-gateway.example.test", + }, + { OutputKey: "PaymentBillingManagerArn", OutputValue: "arn:payment/billing-2" }, + { + OutputKey: "PaymentBillingProcessPaymentRoleArn", + OutputValue: "arn:role/process-payment-2", + }, + { + OutputKey: "PaymentBillingprimarywalletConnectorId", + OutputValue: "connector-2", + }, + ], + } as Stack; + + expect(projectDevEnvironment({ project: configuredProject, stack })).toEqual({ + MEMORY_ORDER_HISTORY_ID: "memory-2", + AGENTCORE_GATEWAY_TOOLS_GATEWAY_URL: "https://v1-gateway.example.test", + AGENTCORE_GATEWAY_TOOLS_GATEWAY_AUTH_TYPE: "AWS_IAM", + AGENTCORE_PAYMENT_BILLING_MANAGER_ARN: "arn:payment/billing-2", + AGENTCORE_PAYMENT_BILLING_PROCESS_PAYMENT_ROLE_ARN: "arn:role/process-payment-2", + AGENTCORE_PAYMENT_BILLING_CONNECTOR_ID: "connector-2", + AGENTCORE_PAYMENT_BILLING_AUTO_PAYMENT: "true", + AGENTCORE_PAYMENT_BILLING_TOOL_ALLOWLIST: "pay", + AGENTCORE_PAYMENT_BILLING_NETWORK_PREFERENCES: "eip155:1", + AGENTCORE_PAYMENT_BILLING_AUTH_MODE: "bearer", + }); + }); + + test("omits empty v1 payment list settings", () => { + const withoutLists = project({ + payments: [ + { + name: "Billing", + authorizerType: "AWS_IAM", + connectors: [], + autoPayment: false, + defaultSpendLimit: "10.00", + paymentToolAllowlist: [], + networkPreferences: [], + }, + ], + } as unknown as Partial); + const stack = { + StackName: "AgentCore-orders-default", + Outputs: [{ OutputKey: "PaymentBillingManagerArn", OutputValue: "arn:payment/billing" }], + } as Stack; + + expect(projectDevEnvironment({ project: withoutLists, stack })).toEqual({ + AGENTCORE_PAYMENT_BILLING_MANAGER_ARN: "arn:payment/billing", + AGENTCORE_PAYMENT_BILLING_AUTO_PAYMENT: "false", + }); + }); + + test("rejects resource names that collide after environment sanitization", () => { + const colliding = project({ + agentCoreGateways: [ + { name: "Tools", authorizerType: "NONE", protocolType: "MCP", targets: [] }, + { name: "tools", authorizerType: "NONE", protocolType: "MCP", targets: [] }, + ], + } as unknown as Partial); + const stack = { + StackName: "AgentCore-orders-default", + Outputs: [ + { + ExportName: "AgentCore-orders-default-Gateway-Tools-Url", + OutputValue: "https://first.example.test", + }, + { + ExportName: "AgentCore-orders-default-Gateway-tools-Url", + OutputValue: "https://second.example.test", + }, + ], + } as Stack; + + expect(() => projectDevEnvironment({ project: colliding, stack })).toThrow( + "both map to environment variable AGENTCORE_GATEWAY_TOOLS_URL", + ); + }); +}); diff --git a/src/core/project/backends/cdk/devEnvironment.ts b/src/core/project/backends/cdk/devEnvironment.ts new file mode 100644 index 0000000000..b91e9c4ad5 --- /dev/null +++ b/src/core/project/backends/cdk/devEnvironment.ts @@ -0,0 +1,216 @@ +import type { Stack } from "@aws-sdk/client-cloudformation"; +import { InputValidationError } from "../../../../errors"; +import type { Project } from "../../../../handlers/project/types"; +import type { TargetState } from "./deployedState"; + +function record(value: unknown): Record | undefined { + return typeof value === "object" && value !== null + ? (value as Record) + : undefined; +} + +function stringAt(value: unknown, ...path: string[]): string | undefined { + let current = value; + for (const part of path) { + current = record(current)?.[part]; + } + return typeof current === "string" ? current : undefined; +} + +function valueAt(value: unknown, ...path: string[]): unknown { + let current = value; + for (const part of path) { + current = record(current)?.[part]; + } + return current; +} + +function stringList(value: unknown): string[] | undefined { + return Array.isArray(value) && value.every((item) => typeof item === "string") + ? value + : undefined; +} + +function entriesAt(value: unknown, ...path: string[]): [string, unknown][] { + return Object.entries(record(valueAt(value, ...path)) ?? {}); +} + +function token(name: string): string { + return name.toUpperCase().replace(/[^A-Z0-9]/g, "_"); +} + +function paymentId(name: string): string { + return name.replace(/_/g, ""); +} + +function byExportName(stack: Stack | undefined, ...parts: string[]): string | undefined { + if (!stack?.StackName) return undefined; + const want = [stack.StackName, ...parts] + .map((part) => part.replace(/_/g, "-").replace(/[^a-zA-Z0-9:-]/g, "")) + .join("-"); + return stack.Outputs?.find((output) => output.ExportName === want)?.OutputValue; +} + +function byOutputKey(stack: Stack | undefined, key: string): string | undefined { + return stack?.Outputs?.find((output) => output.OutputKey === key)?.OutputValue; +} + +type EnvironmentWriter = (name: string, value: string | undefined, owner: string) => void; + +function writer(env: Record): EnvironmentWriter { + const owners = new Map(); + return (name, value, owner) => { + if (value === undefined) return; + const previous = env[name]; + if (previous !== undefined && previous !== value) { + throw new InputValidationError( + `${owners.get(name)} and ${owner} both map to environment variable ${name}. ` + + "Rename one of the resources so local dev does not select an arbitrary value.", + ); + } + env[name] = value; + owners.set(name, owner); + }; +} + +/** + * Builds the v0-compatible discovery variables for local dev, using the v0 + * state file when it contains the values and live stack outputs as the v1 + * fallback. The caller merges explicit local environment values afterwards. + */ +export function projectDevEnvironment({ + project, + recorded, + stack, +}: { + project: Project; + recorded?: TargetState; + stack?: Stack; +}): Record { + const env: Record = {}; + const set = writer(env); + const resources = recorded?.resources; + + // v0 discovered resources from deployed state rather than the current + // project spec. Preserve that behavior when a resource was removed from + // agentcore.json after its last deploy. + for (const [name, memory] of entriesAt(resources, "memories")) { + set(`MEMORY_${token(name)}_ID`, stringAt(memory, "memoryId"), `Memory "${name}"`); + } + + for (const memory of project.spec.memories) { + set( + `MEMORY_${token(memory.name)}_ID`, + stringAt(resources, "memories", memory.name, "memoryId") ?? + byExportName(stack, "Memory", memory.name, "Id"), + `Memory "${memory.name}"`, + ); + } + + const gatewayAuthType = (name: string): string => + project.spec.agentCoreGateways.find((gateway) => gateway.name === name)?.authorizerType ?? + "NONE"; + const recordedGateways = new Map(entriesAt(resources, "gateways")); + // The released v0 CLI reads the nested MCP branch. Prefer it when a state + // file happens to contain both historical gateway layouts. + for (const entry of entriesAt(resources, "mcp", "gateways")) { + recordedGateways.set(...entry); + } + for (const [name, gateway] of recordedGateways) { + const url = stringAt(gateway, "gatewayUrl"); + if (url === undefined) continue; + const prefix = `AGENTCORE_GATEWAY_${token(name)}`; + set(`${prefix}_URL`, url, `Gateway "${name}"`); + set(`${prefix}_AUTH_TYPE`, gatewayAuthType(name), `Gateway "${name}"`); + } + + for (const gateway of project.spec.agentCoreGateways) { + const prefix = `AGENTCORE_GATEWAY_${token(gateway.name)}`; + const stateUrl = stringAt(recordedGateways.get(gateway.name), "gatewayUrl"); + const url = stateUrl ?? byExportName(stack, "Gateway", gateway.name, "Url"); + if (url !== undefined) { + set(`${prefix}_URL`, url, `Gateway "${gateway.name}"`); + set(`${prefix}_AUTH_TYPE`, gateway.authorizerType ?? "NONE", `Gateway "${gateway.name}"`); + } + } + + const recordedPayments = new Set(); + for (const [name, payment] of entriesAt(resources, "payments")) { + const managerArn = stringAt(payment, "managerArn"); + if (managerArn === undefined) continue; + recordedPayments.add(name); + const prefix = `AGENTCORE_PAYMENT_${token(name)}`; + const owner = `Payment manager "${name}"`; + const firstConnector = entriesAt(payment, "connectors").find( + ([, connector]) => stringAt(connector, "connectorId") !== undefined, + ); + const autoPayment = valueAt(payment, "autoPayment"); + const allowlist = stringList(valueAt(payment, "paymentToolAllowlist")); + const networks = stringList(valueAt(payment, "networkPreferences")); + + set(`${prefix}_MANAGER_ARN`, managerArn, owner); + set(`${prefix}_PROCESS_PAYMENT_ROLE_ARN`, stringAt(payment, "processPaymentRoleArn"), owner); + if (firstConnector) { + set( + `${prefix}_CONNECTOR_ID`, + stringAt(firstConnector[1], "connectorId"), + `Payment connector "${firstConnector[0]}"`, + ); + } + set( + `${prefix}_AUTO_PAYMENT`, + typeof autoPayment === "boolean" ? String(autoPayment) : undefined, + owner, + ); + set(`${prefix}_TOOL_ALLOWLIST`, allowlist?.length ? allowlist.join(",") : undefined, owner); + set(`${prefix}_NETWORK_PREFERENCES`, networks?.length ? networks.join(",") : undefined, owner); + set( + `${prefix}_AUTH_MODE`, + stringAt(payment, "authorizerType") === "CUSTOM_JWT" ? "bearer" : undefined, + owner, + ); + } + + for (const payment of project.spec.payments ?? []) { + if (recordedPayments.has(payment.name)) continue; + const prefix = `AGENTCORE_PAYMENT_${token(payment.name)}`; + const managerId = `Payment${paymentId(payment.name)}`; + const managerArn = byOutputKey(stack, `${managerId}ManagerArn`); + if (managerArn === undefined) continue; + + set(`${prefix}_MANAGER_ARN`, managerArn, `Payment manager "${payment.name}"`); + set( + `${prefix}_PROCESS_PAYMENT_ROLE_ARN`, + byOutputKey(stack, `${managerId}ProcessPaymentRoleArn`), + `Payment manager "${payment.name}"`, + ); + + const firstConnector = payment.connectors[0]; + if (firstConnector) { + set( + `${prefix}_CONNECTOR_ID`, + byOutputKey(stack, `${managerId}${paymentId(firstConnector.name)}ConnectorId`), + `Payment connector "${firstConnector.name}"`, + ); + } + + set(`${prefix}_AUTO_PAYMENT`, String(payment.autoPayment), `Payment manager "${payment.name}"`); + set( + `${prefix}_TOOL_ALLOWLIST`, + payment.paymentToolAllowlist?.length ? payment.paymentToolAllowlist.join(",") : undefined, + `Payment manager "${payment.name}"`, + ); + set( + `${prefix}_NETWORK_PREFERENCES`, + payment.networkPreferences?.length ? payment.networkPreferences.join(",") : undefined, + `Payment manager "${payment.name}"`, + ); + set( + `${prefix}_AUTH_MODE`, + payment.authorizerType === "CUSTOM_JWT" ? "bearer" : undefined, + `Payment manager "${payment.name}"`, + ); + } + + return env; +} diff --git a/src/core/project/backends/types.ts b/src/core/project/backends/types.ts index 315ba3c0de..9c6eeeb414 100644 --- a/src/core/project/backends/types.ts +++ b/src/core/project/backends/types.ts @@ -25,6 +25,10 @@ export type ResolveProjectResourcesBackendInput = { target: AwsDeploymentTarget; }; +export type ResolveDevEnvironmentBackendInput = { + target: AwsDeploymentTarget; +}; + /** Builds the deployable artifacts owned by a project's selected backend. */ export interface ProjectBackend { build(project: Project): AsyncGenerator; @@ -44,4 +48,9 @@ export interface ProjectBackend { project: Project, input: ResolveProjectResourcesBackendInput, ): Promise; + /** Resolves environment variables that let a local runtime use deployed project resources. */ + resolveDevEnvironment?( + project: Project, + input: ResolveDevEnvironmentBackendInput, + ): Promise>; } diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 0254b003a2..e6c3374e47 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -13,8 +13,10 @@ import type { ExportHarnessResult, ResolveDeployedResourceInput, ResolveDeployedResourcesInput, + ResolveDevEnvironmentInput, ResolvedDeployedResource, ResolvedDeployedResources, + ResolvedDevEnvironment, ResolveProjectInput, ResolveProjectResourcesInput, ResolvedProjectResources, @@ -1622,6 +1624,18 @@ export class FsProjectManager implements ProjectManager { return { resources, target }; } + public async resolveDevEnvironment( + project: Project, + input: ResolveDevEnvironmentInput, + ): Promise { + const target = await this.resolveExistingTarget(project, input.target); + const backend = this.backendFor(project); + const env = backend.resolveDevEnvironment + ? await backend.resolveDevEnvironment(project, { target }) + : {}; + return { env, target }; + } + private async resolveExistingTarget( project: Project, name: string, diff --git a/src/handlers/project/dev/environment.test.ts b/src/handlers/project/dev/environment.test.ts index ee8c733540..005aa8d06f 100644 --- a/src/handlers/project/dev/environment.test.ts +++ b/src/handlers/project/dev/environment.test.ts @@ -7,12 +7,23 @@ import { LocalSecrets } from "../../../io/localSecrets"; const projectRoot = "/workspace/project"; const runtime = (envVars: { name: string; value: string }[] = []) => - ({ name: "orders", build: "Container", envVars }) as ProjectRuntime; + ({ + name: "orders", + build: "Container", + protocol: "HTTP", + entrypoint: "main.py", + codeLocation: "app/orders", + envVars, + }) as ProjectRuntime; -const input = (envVars: { name: string; value: string }[] = []) => ({ +const input = ( + envVars: { name: string; value: string }[] = [], + deployedEnvironment: Record = {}, +) => ({ projectRoot, runtime: runtime(envVars), region: "us-east-1", + deployedEnvironment, }); describe("createDevEnvironmentLoader", () => { @@ -22,7 +33,12 @@ describe("createDevEnvironmentLoader", () => { readFile: async () => `MODEL_API_KEY='${encrypted.MODEL_API_KEY}'\nSTAGE=dev\n`, }); await expect(loader(input())).resolves.toEqual({ - env: { MODEL_API_KEY: "agent-value", STAGE: "dev", AWS_REGION: "us-east-1" }, + env: { + MODEL_API_KEY: "agent-value", + STAGE: "dev", + AWS_REGION: "us-east-1", + AWS_DEFAULT_REGION: "us-east-1", + }, }); }); @@ -41,22 +57,60 @@ second" await expect( loader( - input([ - { name: "SHARED", value: "runtime" }, - { name: "RUNTIME_ONLY", value: "yes" }, - { name: "PORT", value: "1234" }, - ]), + input( + [ + { name: "SHARED", value: "runtime" }, + { name: "RUNTIME_ONLY", value: "yes" }, + { name: "PORT", value: "1234" }, + ], + { + SHARED: "deployed", + MEMORY_ORDERS_ID: "memory-1", + AGENTCORE_PAYMENT_BILLING_MANAGER_ARN: "arn:payment/billing", + }, + ), ), ).resolves.toEqual({ env: { SHARED: "local value", RUNTIME_ONLY: "yes", - AWS_REGION: "local-region", + AWS_REGION: "us-east-1", + AWS_DEFAULT_REGION: "us-east-1", + MEMORY_ORDERS_ID: "memory-1", + AGENTCORE_PAYMENT_BILLING_MANAGER_ARN: "arn:payment/billing", MULTILINE: "first\nsecond", }, }); }); + test("does not expose payment configuration to an ineligible runtime", async () => { + const loader = createDevEnvironmentLoader({ + readFile: async () => { + throw Object.assign(new Error("missing"), { code: "ENOENT" }); + }, + }); + const ineligible = { + ...runtime(), + entrypoint: "main.ts", + } as ProjectRuntime; + + await expect( + loader({ + ...input([], { + MEMORY_ORDERS_ID: "memory-1", + AGENTCORE_PAYMENT_BILLING_MANAGER_ARN: "arn:payment/billing", + }), + runtime: ineligible, + }), + ).resolves.toEqual({ + env: { + AWS_REGION: "us-east-1", + AWS_DEFAULT_REGION: "us-east-1", + MEMORY_ORDERS_ID: "memory-1", + }, + }); + }); + test.each([ ["ENOENT", undefined], [ @@ -75,7 +129,11 @@ second" await expect(pending).rejects.toThrow(expectedError); } else { await expect(pending).resolves.toEqual({ - env: { RUNTIME_ONLY: "yes", AWS_REGION: "us-east-1" }, + env: { + RUNTIME_ONLY: "yes", + AWS_REGION: "us-east-1", + AWS_DEFAULT_REGION: "us-east-1", + }, }); } }); diff --git a/src/handlers/project/dev/environment.ts b/src/handlers/project/dev/environment.ts index 27129c56bc..b1ecc434df 100644 --- a/src/handlers/project/dev/environment.ts +++ b/src/handlers/project/dev/environment.ts @@ -8,6 +8,8 @@ export type DevEnvironmentInput = { projectRoot: string; runtime: ProjectRuntime; region?: string; + /** Discovery variables resolved from the selected deployment target. */ + deployedEnvironment?: Record; }; export type DevEnvironment = { @@ -20,6 +22,11 @@ type DevEnvironmentLoaderConfig = { readFile?: (path: string, encoding: BufferEncoding) => Promise; }; +function supportsPayments(runtime: ProjectRuntime): boolean { + if (runtime.protocol && runtime.protocol !== "HTTP") return false; + return (runtime.entrypoint.split(":")[0] ?? "").endsWith(".py"); +} + export function createDevEnvironmentLoader( config: DevEnvironmentLoaderConfig = {}, ): DevEnvironmentLoader { @@ -27,8 +34,15 @@ export function createDevEnvironmentLoader( const env = Object.fromEntries( (input.runtime.envVars ?? []).map(({ name, value }) => [name, value]), ); - if (input.region) env.AWS_REGION = input.region; + const deployedEnvironment = supportsPayments(input.runtime) + ? input.deployedEnvironment + : Object.fromEntries( + Object.entries(input.deployedEnvironment ?? {}).filter( + ([name]) => !name.startsWith("AGENTCORE_PAYMENT_"), + ), + ); + Object.assign(env, deployedEnvironment); Object.assign( env, await readEnvFile(join(input.projectRoot, "agentcore", ".env.local"), { @@ -36,6 +50,10 @@ export function createDevEnvironmentLoader( select: (name) => !RESERVED_ENV_KEYS.some((reserved) => reserved === name), }), ); + if (input.region) { + env.AWS_REGION = input.region; + env.AWS_DEFAULT_REGION = input.region; + } for (const key of RESERVED_ENV_KEYS) delete env[key]; return { env }; diff --git a/src/handlers/project/dev/index.test.ts b/src/handlers/project/dev/index.test.ts index ccca68cb30..cca3888c37 100644 --- a/src/handlers/project/dev/index.test.ts +++ b/src/handlers/project/dev/index.test.ts @@ -5,6 +5,7 @@ import { type InvokeHarnessStreamOutput, } from "@aws-sdk/client-bedrock-agentcore"; import type { GetHarnessResponse } from "@aws-sdk/client-bedrock-agentcore-control"; +import type { AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; import type { ProjectRuntime } from "../../../projectSchemas/runtime"; import { InputValidationError, @@ -118,6 +119,8 @@ type HarnessOptions = { checkPort?: PortChecker; json?: boolean; loadEnvironment?: DevProjectHandlerConfig["loadDevEnvironment"]; + targets?: AwsDeploymentTarget[]; + deployedEnvironment?: Record; }; function harness(options: HarnessOptions = {}) { @@ -134,6 +137,7 @@ function harness(options: HarnessOptions = {}) { const resolutions: Parameters< DevProjectHandlerConfig["projectManager"]["resolveDeployedResource"] >[1][] = []; + const environmentResolutions: string[] = []; const core = new TestCoreClient(); const harnessClient = core.harness.setGetResponse({ harness: { arn: "arn:aws:bedrock-agentcore:eu-west-1:111122223333:harness/test" }, @@ -145,6 +149,14 @@ function harness(options: HarnessOptions = {}) { return { outputs: {} }; }; projectManager.resolveTarget = async () => undefined; + projectManager.listTargets = async () => options.targets ?? []; + projectManager.resolveDevEnvironment = async (_project, input) => { + environmentResolutions.push(input.target); + return { + env: options.deployedEnvironment ?? {}, + target: options.targets!.find(({ name }) => name === input.target)!, + }; + }; projectManager.resolveDeployedResource = async (_project, input) => { resolutions.push(input); return { @@ -214,6 +226,7 @@ function harness(options: HarnessOptions = {}) { deployments, legacyDeployments, resolutions, + environmentResolutions, harnessClient, io, ui, @@ -267,6 +280,7 @@ describe("project dev selection and dispatch", () => { projectRoot: "/workspace/project", runtime: expect.objectContaining({ name: "orders" }), region: "us-west-2", + deployedEnvironment: {}, }, ]); expect(subject.codeZip.inputs).toHaveLength(1); @@ -339,6 +353,7 @@ describe("project dev selection and dispatch", () => { projectRoot: "/workspace/project", runtime: expect.objectContaining({ name: "support" }), region: "us-west-2", + deployedEnvironment: {}, }, ]); expect(subject.container.inputs[0]).toMatchObject({ @@ -355,6 +370,67 @@ describe("project dev selection and dispatch", () => { expect(subject.io.stderr()).not.toContain("Skipping runtime"); }); + test("uses the selected target's region and deployed resource environment", async () => { + const staging = { + name: "staging", + account: "111122223333", + region: "eu-west-1", + } as const; + const subject = harness({ + targets: [staging], + deployedEnvironment: { + MEMORY_ORDERS_ID: "memory-1", + AGENTCORE_GATEWAY_TOOLS_URL: "https://tools.example.test", + }, + }); + + await subject.run({ agent: "orders", target: "staging", traces: false }); + + expect(subject.environmentResolutions).toEqual(["staging"]); + expect(subject.environmentInputs).toEqual([ + { + projectRoot: "/workspace/project", + runtime: expect.objectContaining({ name: "orders" }), + region: "eu-west-1", + deployedEnvironment: { + MEMORY_ORDERS_ID: "memory-1", + AGENTCORE_GATEWAY_TOOLS_URL: "https://tools.example.test", + }, + }, + ]); + }); + + test("uses the default target when several targets are configured", async () => { + const defaultTarget = { + name: "default", + account: "111122223333", + region: "us-east-2", + } as const; + const subject = harness({ + targets: [{ name: "staging", account: "111122223333", region: "eu-west-1" }, defaultTarget], + }); + + await subject.run({ agent: "orders", traces: false }); + + expect(subject.environmentResolutions).toEqual(["default"]); + expect(subject.environmentInputs[0]?.region).toBe("us-east-2"); + }); + + test("requires --target when several non-default targets are configured", async () => { + const subject = harness({ + targets: [ + { name: "staging", account: "111122223333", region: "eu-west-1" }, + { name: "production", account: "111122223333", region: "us-east-1" }, + ], + }); + + await expect(subject.run({ agent: "orders", traces: false })).rejects.toThrow( + "Pass --target to choose which deployed resources local runtimes use", + ); + expect(subject.environmentResolutions).toEqual([]); + expect(subject.codeZip.inputs).toEqual([]); + }); + test("announces an automatically selected port", async () => { const checked: number[] = []; const subject = harness({ @@ -428,6 +504,29 @@ describe("project dev headless multi-agent", () => { await expect(pending).rejects.toMatchObject({ exitCode: 130 }); }); + test("shares one deployed environment read across agents launched together", async () => { + const codeZip = stayingRunner(); + const container = stayingRunner(); + const subject = harness({ + project: twoRuntimes(), + codeZip, + container, + targets: [ + { + name: "default", + account: "111122223333", + region: "us-east-1", + }, + ], + }); + const { pending } = await supervised(subject); + + expect(subject.environmentResolutions).toEqual(["default"]); + + process.emit("SIGINT", "SIGINT"); + await expect(pending).rejects.toMatchObject({ exitCode: 130 }); + }); + test("one agent failing to start leaves the others running", async () => { const subject = harness({ project: twoRuntimes(), @@ -631,6 +730,40 @@ describe("project dev Inspector UI mode", () => { await pending.catch(() => undefined); }); + test("refreshes deployed resource variables on every Inspector start attempt", async () => { + const subject = harness({ + targets: [ + { + name: "staging", + account: "111122223333", + region: "eu-west-1", + }, + ], + deployedEnvironment: { MEMORY_ORDERS_ID: "memory-1" }, + }); + const { pending } = await runUi(subject, ["--target", "staging", "--no-traces"]); + + await inspectorRequest({ + subject, + url: "/api/start", + body: { agentName: "orders" }, + }); + await inspectorRequest({ + subject, + url: "/api/start", + body: { agentName: "orders" }, + }); + + expect(subject.environmentResolutions).toEqual(["staging", "staging"]); + expect(subject.environmentInputs).toHaveLength(2); + expect(subject.environmentInputs.map(({ deployedEnvironment }) => deployedEnvironment)).toEqual( + [{ MEMORY_ORDERS_ID: "memory-1" }, { MEMORY_ORDERS_ID: "memory-1" }], + ); + + process.emit("SIGINT", "SIGINT"); + await pending.catch(() => undefined); + }); + test("an explicit --ui-port that is taken fails fast", async () => { const subject = harness({ checkPort: async () => false }); await expect(subject.run({ mode: "browser", "ui-port": 9999 })).rejects.toThrow( @@ -776,7 +909,7 @@ describe("project dev harnesses", () => { test("rejects harness flags on runtime dev", async () => { const subject = harness(); await expect(subject.route(["--target=default", "--yes", "--skip-deploy"])).rejects.toThrow( - "--target, --yes, --skip-deploy does not apply to runtime dev", + "--yes, --skip-deploy does not apply to runtime dev", ); expect(subject.codeZip.inputs).toHaveLength(0); }); diff --git a/src/handlers/project/dev/index.ts b/src/handlers/project/dev/index.ts index 2b69458192..c6c7ee3108 100644 --- a/src/handlers/project/dev/index.ts +++ b/src/handlers/project/dev/index.ts @@ -23,7 +23,7 @@ import { JsonRendererKey, type JsonRenderer } from "../../../tui"; import { JsonKey, RegionKey } from "../../keys"; import { coreOptsFromCtx } from "../../utils"; import type { CoreHarnessClient } from "../../harness/types"; -import { DEFAULT_TARGET_NAME } from "../../../projectSchemas/aws-targets"; +import { DEFAULT_TARGET_NAME, type AwsDeploymentTarget } from "../../../projectSchemas/aws-targets"; import { createDeployProjectHandler, DEPLOY_NEXT_STEP } from "../deploy"; import type { Project, ProjectManager } from "../types"; import { BMA_TEMPLATE_NAME, isBmaRuntime } from "../bma"; @@ -139,9 +139,8 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => }), flag( "target", - 'name of the aws-targets.json entry to deploy; the default target is created automatically from your AWS account and region on first deploy (default: "default")', + 'deployment target whose resources local Runtimes use, or to deploy for Harnesses (default: "default" when present)', z.string().min(1).optional(), - { group: HARNESS_OPTIONS_GROUP }, ), flag( "yes", @@ -181,9 +180,7 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => }, }); } else { - const unsupportedFlags = (["target", "yes", "skip-deploy"] as const).filter( - (name) => flags[name], - ); + const unsupportedFlags = (["yes", "skip-deploy"] as const).filter((name) => flags[name]); if (unsupportedFlags.length) { throw new InputValidationError( `${unsupportedFlags.map((name) => `--${name}`).join(", ")} does not apply to runtime dev.`, @@ -199,6 +196,7 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => traces: flags.traces, mode: flags.mode, uiPort: flags["ui-port"], + deploymentTarget: flags.target, }, }); } @@ -349,6 +347,7 @@ async function runRuntimeDev({ traces: boolean; mode: "browser" | "headless"; uiPort?: number; + deploymentTarget?: string; }; }): Promise { const { runtimes } = input; @@ -358,7 +357,13 @@ async function runRuntimeDev({ let collector: DevTraceCollector | undefined; try { const project = ctx.require(ProjectKey); - const region = ctx.require(RegionKey); + let environmentProject = project; + const deploymentTarget = await resolveRuntimeDevTarget( + config.projectManager, + project, + input.deploymentTarget, + ); + const region = deploymentTarget?.region ?? ctx.require(RegionKey); if (runtimes.length > 1 && input.port !== undefined) { throw new InputValidationError( "--port applies to a single runtime. Use --agent to select one.", @@ -381,13 +386,34 @@ async function runRuntimeDev({ : undefined; signal.throwIfAborted(); + let deployedEnvironmentInFlight: Promise> | undefined; + const resolveDeployedEnvironment = async (): Promise> => { + if (!deploymentTarget) return {}; + if (!deployedEnvironmentInFlight) { + const currentProject = environmentProject; + deployedEnvironmentInFlight = config.projectManager + .resolveDevEnvironment(currentProject, { + target: deploymentTarget.name, + }) + .then(({ env }) => env); + } + const request = deployedEnvironmentInFlight; + try { + return await request; + } finally { + if (deployedEnvironmentInFlight === request) deployedEnvironmentInFlight = undefined; + } + }; + const getDevEnvVarsForRuntime = async ( runtime: ProjectRuntime, ): Promise> => { + const deployedEnvironment = await resolveDeployedEnvironment(); const { env } = await config.loadDevEnvironment({ projectRoot: project.rootPath, runtime, region, + deployedEnvironment, }); const otel = collector && (runtime.instrumentation?.enableOtel ?? true) @@ -465,6 +491,7 @@ async function runRuntimeDev({ try { const reloaded = await config.projectManager.resolve({ filePath: project.rootPath }); if (!reloaded) return; + environmentProject = reloaded; const runtimes = reloaded.spec.runtimes.filter(supportsLocalDev); supervisor.setRuntimes( input.agent ? runtimes.filter((runtime) => runtime.name === input.agent) : runtimes, @@ -494,6 +521,36 @@ async function runRuntimeDev({ ); } +async function resolveRuntimeDevTarget( + projectManager: ProjectManager, + project: Project, + requested?: string, +): Promise { + const targets = await projectManager.listTargets(project); + if (requested) { + const target = targets.find(({ name }) => name === requested); + if (!target) { + throw new ResourceNotFoundError( + `Project '${project.name}' has no deployment target named '${requested}'. Available targets: ${ + targets.map(({ name }) => name).join(", ") || "none" + }.`, + ); + } + return target; + } + const defaultTarget = targets.find(({ name }) => name === DEFAULT_TARGET_NAME); + if (defaultTarget) return defaultTarget; + if (targets.length === 1) return targets[0]; + if (targets.length > 1) { + throw new InputValidationError( + `Project '${project.name}' has multiple deployment targets (${targets + .map(({ name }) => name) + .join(", ")}). Pass --target to choose which deployed resources local runtimes use.`, + ); + } + return undefined; +} + async function startRuntimeTraceCollector({ config, project, diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 38e75d2009..c59d8669e1 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -275,6 +275,16 @@ export type ResolveProjectResourcesInput = { target: string; }; +export type ResolveDevEnvironmentInput = { + /** Name of the aws-targets.json entry whose deployed resources local agents should use. */ + target: string; +}; + +export type ResolvedDevEnvironment = { + env: Record; + target: AwsDeploymentTarget; +}; + /** * Every resource type a project can declare and deploy. Broader than * {@link ProjectInvokableResource}: status reports all of them, while invoke only @@ -573,6 +583,12 @@ export interface ProjectManager { input: ResolveProjectResourcesInput, ): Promise; + /** Resolve discovery variables for resources deployed to the named target. */ + resolveDevEnvironment( + project: Project, + input: ResolveDevEnvironmentInput, + ): Promise; + /** Add a resource to an existing AgentCore project. */ addResource( project: Project,