From dc15b38a0101026593543af723264e316b31f94d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 26 Aug 2026 21:55:55 +0000 Subject: [PATCH 01/31] feat(project): model payment connector variants --- src/projectSchemas/credential.test.ts | 25 +++++++ src/projectSchemas/payment.test.ts | 61 ++++++++++++++++- src/projectSchemas/payment.ts | 40 ++++++++++- src/projectSchemas/project.test.ts | 95 +++++++++++++++++++++++++++ src/projectSchemas/project.ts | 26 ++++++++ 5 files changed, 244 insertions(+), 3 deletions(-) diff --git a/src/projectSchemas/credential.test.ts b/src/projectSchemas/credential.test.ts index c176ecb4e..5639ce669 100644 --- a/src/projectSchemas/credential.test.ts +++ b/src/projectSchemas/credential.test.ts @@ -50,6 +50,22 @@ describe("credential schema", () => { clientSecretRef: SECRET_REF, }, ], + [ + "a Coinbase payment credential", + { + authorizerType: "PaymentCredentialProvider", + name: "coinbase", + provider: "CoinbaseCDP", + }, + ], + [ + "a StripePrivy payment credential", + { + authorizerType: "PaymentCredentialProvider", + name: "stripe", + provider: "StripePrivy", + }, + ], ])("accepts %s and retains its fields", (_label, value) => { const result = CredentialSchema.safeParse(value); expect(result.success).toBe(true); @@ -124,6 +140,15 @@ describe("credential schema", () => { { authorizerType: "ApiKeyCredentialProvider", name: "bad name!" }, /alphanumeric/, ], + [ + "an unsupported payment provider", + { + authorizerType: "PaymentCredentialProvider", + name: "payment", + provider: "Unsupported", + }, + /provider/, + ], [ "a credential name shorter than 3 characters", { authorizerType: "ApiKeyCredentialProvider", name: "ab" }, diff --git a/src/projectSchemas/payment.test.ts b/src/projectSchemas/payment.test.ts index b7c4c41eb..8b7910846 100644 --- a/src/projectSchemas/payment.test.ts +++ b/src/projectSchemas/payment.test.ts @@ -1,5 +1,49 @@ import { describe, expect, it } from "bun:test"; -import { PaymentManagerSchema } from "./payment"; +import { PaymentConnectorSchema, PaymentManagerSchema } from "./payment"; + +describe("payment connector schema", () => { + it("accepts manual connectors with implicit or explicit provisioning mode", () => { + const connector = { + name: "coinbase", + provider: "CoinbaseCDP", + credentialName: "coinbase-credential", + }; + + expect(PaymentConnectorSchema.safeParse(connector).success).toBe(true); + expect( + PaymentConnectorSchema.safeParse({ ...connector, provisionMode: "MANUAL" }).success, + ).toBe(true); + }); + + it("accepts Coinbase Quick Create without a credential", () => { + expect( + PaymentConnectorSchema.safeParse({ + name: "coinbase", + provider: "CoinbaseCDP", + provisionMode: "QUICK_CREATE", + }).success, + ).toBe(true); + }); + + it("rejects StripePrivy Quick Create and Quick Create credential references", () => { + expect( + PaymentConnectorSchema.safeParse({ + name: "stripe", + provider: "StripePrivy", + provisionMode: "QUICK_CREATE", + }).success, + ).toBe(false); + expect( + PaymentConnectorSchema.safeParse({ + name: "coinbase", + provider: "CoinbaseCDP", + provisionMode: "QUICK_CREATE", + credentialName: "unexpected", + }).success, + ).toBe(false); + }); +}); + describe("payment manager custom validation", () => { it("requires JWT authorizer configuration only for CUSTOM_JWT", () => { expect( @@ -20,4 +64,19 @@ describe("payment manager custom validation", () => { }).success, ).toBe(true); }); + + it("rejects duplicate connector names within one manager", () => { + const connector = { + name: "duplicate", + provider: "CoinbaseCDP", + credentialName: "credential", + }; + + expect( + PaymentManagerSchema.safeParse({ + name: "payments", + connectors: [connector, connector], + }).success, + ).toBe(false); + }); }); diff --git a/src/projectSchemas/payment.ts b/src/projectSchemas/payment.ts index 48e040037..05ce22043 100644 --- a/src/projectSchemas/payment.ts +++ b/src/projectSchemas/payment.ts @@ -19,11 +19,30 @@ export const PaymentConnectorNameSchema = z /^[a-zA-Z][a-zA-Z0-9_]{0,47}$/, "Must begin with a letter and contain only alphanumeric characters and underscores (max 48 chars)", ); -export const PaymentConnectorSchema = z.object({ + +export const PaymentProvisionModeSchema = z.enum(["MANUAL", "QUICK_CREATE"]); +export type PaymentProvisionMode = z.infer; + +export const ManualPaymentConnectorSchema = z.object({ name: PaymentConnectorNameSchema, provider: PaymentProviderSchema.default("CoinbaseCDP"), + provisionMode: z.literal("MANUAL").optional(), credentialName: z.string().min(1), }); +export type ManualPaymentConnector = z.infer; + +export const QuickCreatePaymentConnectorSchema = z.object({ + name: PaymentConnectorNameSchema, + provider: z.literal("CoinbaseCDP"), + provisionMode: z.literal("QUICK_CREATE"), + credentialName: z.never().optional(), +}); +export type QuickCreatePaymentConnector = z.infer; + +export const PaymentConnectorSchema = z.union([ + QuickCreatePaymentConnectorSchema, + ManualPaymentConnectorSchema, +]); export type PaymentConnector = z.infer; export const PaymentManagerSchema = z .object({ @@ -42,7 +61,12 @@ export const PaymentManagerSchema = z connectors: z.array(PaymentConnectorSchema).default([]), description: z.string().optional(), autoPayment: z.boolean().default(DEFAULT_AUTO_PAYMENT), - defaultSpendLimit: z.string().default(DEFAULT_SPEND_LIMIT), + defaultSpendLimit: z + .string() + .refine((value) => Number.isFinite(Number(value)) && Number(value) >= 0, { + message: "Default spend limit must be a non-negative number", + }) + .default(DEFAULT_SPEND_LIMIT), paymentToolAllowlist: z.array(z.string()).optional(), networkPreferences: z.array(z.string()).optional(), }) @@ -58,6 +82,18 @@ export const PaymentManagerSchema = z path: ["authorizerConfiguration"], }); } + + const connectorNames = new Set(); + for (const [index, connector] of data.connectors.entries()) { + if (connectorNames.has(connector.name)) { + ctx.addIssue({ + code: "custom", + message: `Duplicate payment connector name: ${connector.name}`, + path: ["connectors", index, "name"], + }); + } + connectorNames.add(connector.name); + } }); export type PaymentManager = z.infer; export const PaymentAuthorizerTypeSchema = z.enum(["AWS_IAM", "CUSTOM_JWT"]); diff --git a/src/projectSchemas/project.test.ts b/src/projectSchemas/project.test.ts index e70425e13..83f85b89d 100644 --- a/src/projectSchemas/project.test.ts +++ b/src/projectSchemas/project.test.ts @@ -244,4 +244,99 @@ describe("project custom validation", () => { }).success, ).toBe(true); }); + + it("validates payment connector credential providers and skips credentials for Quick Create", () => { + const credential = { + authorizerType: "PaymentCredentialProvider" as const, + name: "credential", + provider: "CoinbaseCDP" as const, + }; + const manualPayment = { + name: "manual", + connectors: [ + { + name: "stripe", + provider: "StripePrivy" as const, + credentialName: credential.name, + }, + ], + }; + + expect( + ProjectSpecSchema.safeParse({ + ...minimalProject, + credentials: [credential], + payments: [manualPayment], + }).success, + ).toBe(false); + + expect( + ProjectSpecSchema.safeParse({ + ...minimalProject, + payments: [ + { + name: "quick", + connectors: [ + { + name: "coinbase", + provider: "CoinbaseCDP", + provisionMode: "QUICK_CREATE", + }, + ], + }, + ], + }).success, + ).toBe(true); + }); + + it("allows the same payment connector name under different managers", () => { + const credential = { + authorizerType: "PaymentCredentialProvider" as const, + name: "credential", + provider: "CoinbaseCDP" as const, + }; + const connector = { + name: "shared", + provider: "CoinbaseCDP" as const, + credentialName: credential.name, + }; + + expect( + ProjectSpecSchema.safeParse({ + ...minimalProject, + credentials: [credential], + payments: [ + { + name: "first", + connectors: [connector], + }, + { + name: "second", + connectors: [connector], + }, + ], + }).success, + ).toBe(true); + }); + + it("rejects credential names that derive the same environment variable prefix", () => { + const result = ProjectSpecSchema.safeParse({ + ...minimalProject, + credentials: [ + { authorizerType: "ApiKeyCredentialProvider", name: "service-key" }, + { + authorizerType: "PaymentCredentialProvider", + name: "service_key", + provider: "CoinbaseCDP", + }, + ], + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((issue) => issue.message.includes("environment variable")), + ).toBe(true); + } + }); }); diff --git a/src/projectSchemas/project.ts b/src/projectSchemas/project.ts index 8e83c994e..7dc2b7a52 100644 --- a/src/projectSchemas/project.ts +++ b/src/projectSchemas/project.ts @@ -248,8 +248,26 @@ export const ProjectSpecSchema = z } } } + const credentialEnvironmentNames = new Map(); + for (const [credentialIndex, credential] of spec.credentials.entries()) { + const environmentName = credential.name.replace(/-/g, "_").toUpperCase(); + const conflictingName = credentialEnvironmentNames.get(environmentName); + if (conflictingName) { + ctx.addIssue({ + code: "custom", + message: + `Credential "${credential.name}" and "${conflictingName}" derive the same environment variable name; ` + + "choose names that differ by more than '-' and '_'", + path: ["credentials", credentialIndex, "name"], + }); + } else { + credentialEnvironmentNames.set(environmentName, credential.name); + } + } for (const [paymentIndex, payment] of (spec.payments ?? []).entries()) { for (const [connectorIndex, connector] of payment.connectors.entries()) { + if (connector.provisionMode === "QUICK_CREATE") continue; + const credential = spec.credentials.find((c) => c.name === connector.credentialName); if (!credential) { ctx.addIssue({ @@ -263,6 +281,14 @@ export const ProjectSpecSchema = z message: `Payment connector "${connector.name}" in manager "${payment.name}" references credential "${connector.credentialName}" which is a ${credential.authorizerType}, not a PaymentCredentialProvider`, path: ["payments", paymentIndex, "connectors", connectorIndex, "credentialName"], }); + } else if (credential.provider !== connector.provider) { + ctx.addIssue({ + code: "custom", + message: + `Payment connector "${connector.name}" in manager "${payment.name}" uses provider "${connector.provider}", ` + + `but credential "${connector.credentialName}" uses provider "${credential.provider}"`, + path: ["payments", paymentIndex, "connectors", connectorIndex, "provider"], + }); } } } From 752c1df5bc36f4157decade24da54255562100f5 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 26 Aug 2026 21:56:12 +0000 Subject: [PATCH 02/31] feat(project): support payment resource mutations --- src/core/project/manager.tsx | 36 +++++++++++++++++++++++++++++++++-- src/handlers/project/types.ts | 27 +++++++++++++++++++++++++- 2 files changed, 60 insertions(+), 3 deletions(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 042c6a13a..9ead04dc3 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -32,6 +32,7 @@ import { CredentialSchema } from "../../projectSchemas/credential"; import { MemorySchema } from "../../projectSchemas/memory"; import { EvaluatorSchema } from "../../projectSchemas/evaluator"; import { OnlineEvalConfigSchema } from "../../projectSchemas/online-eval-config"; +import { PaymentConnectorSchema, PaymentManagerSchema } from "../../projectSchemas/payment"; import { PolicyEngineSchema, PolicySchema } from "../../projectSchemas/policy"; import { enclosingProjectRoot, projectSpecPath } from "./fsUtils"; import { @@ -161,7 +162,7 @@ export class FsProjectManager implements ProjectManager { yield { message: `Reading project spec file at '${agentCoreSpecPath}'` }; const projectSpec = await this.json.read(agentCoreSpecPath, ProjectSpecSchema); - const existingResources = projectSpec[projectSpecKey]; + const existingResources = projectSpec[projectSpecKey] ?? []; if (input.resourceType === "gateway-target") { // Current L3 outputs are keyed only by Target name, so names must remain // project-unique until those outputs include the parent Gateway. @@ -190,6 +191,22 @@ export class FsProjectManager implements ProjectManager { `a policy with name '${input.resourceConfig.name}' already exists in policy engine '${engine.name}'`, ); } + } else if (input.resourceType === "payment-connector") { + const manager = projectSpec.payments?.find( + (candidate) => candidate.name === input.managerName, + ); + if (!manager) { + throw new InputValidationError( + `payment manager '${input.managerName}' does not exist in this project`, + ); + } + if ( + manager.connectors.some((connector) => connector.name === input.resourceConfig.name) + ) { + throw new InputValidationError( + `a payment connector with name '${input.resourceConfig.name}' already exists in manager '${input.managerName}'`, + ); + } } else if (existingResources.find((resource) => resource.name === input.resourceConfig.name)) { throw new InputValidationError( `a ${input.resourceType} with name '${input.resourceConfig.name}' already exists`, @@ -261,6 +278,11 @@ export class FsProjectManager implements ProjectManager { case "gateway": projectSpec.agentCoreGateways.push(input.resourceConfig); break; + case "payment-manager": { + projectSpec.payments ??= []; + projectSpec.payments.push(parseResource(PaymentManagerSchema, input.resourceConfig)); + break; + } case "policy-engine": { projectSpec.policyEngines.push(parseResource(PolicyEngineSchema, input.resourceConfig)); for (const gatewayName of input.attachGateways?.names ?? []) { @@ -303,6 +325,13 @@ export class FsProjectManager implements ProjectManager { projectSpec.agentCoreGateways[gatewayIndex]!.targets.push(input.resourceConfig); break; } + case "payment-connector": { + const manager = projectSpec.payments!.find( + (candidate) => candidate.name === input.managerName, + )!; + manager.connectors.push(parseResource(PaymentConnectorSchema, input.resourceConfig)); + break; + } default: { const unhandled: never = input; throw new NotImplementedError(`unsupported project resource: ${String(unhandled)}`); @@ -402,7 +431,7 @@ export class FsProjectManager implements ProjectManager { newSpec = { ...existingProjectSpec, agentCoreGateways: gateways }; } else { const projectSpecKey = toProjectSpecKey(input.resourceType); - const existingResources = existingProjectSpec[projectSpecKey]; + const existingResources = existingProjectSpec[projectSpecKey] ?? []; const newResources = existingResources.filter((resource) => resource.name !== input.name); removed = newResources.length !== existingResources.length; newSpec = { ...existingProjectSpec, [projectSpecKey]: newResources }; @@ -537,6 +566,9 @@ function toProjectSpecKey(resourceType: ProjectResource) { case "policy-engine": case "policy": return "policyEngines"; + case "payment-manager": + case "payment-connector": + return "payments"; } } diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index c96b2e7af..dc35e6caa 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -1,5 +1,6 @@ import { HarnessSpecSchema } from "../../projectSchemas/harness"; import type { CredentialSchema } from "../../projectSchemas/credential"; +import type { PaymentConnectorSchema, PaymentManagerSchema } from "../../projectSchemas/payment"; import type { ConfigBundleSchema } from "../../projectSchemas/config-bundle"; import type { MemorySchema } from "../../projectSchemas/memory"; import type { EvaluatorSchema } from "../../projectSchemas/evaluator"; @@ -195,13 +196,32 @@ export type AddResourceInput = resourceType: "policy"; engineName: string; resourceConfig: z.input; + } + | { + resourceType: "payment-manager"; + resourceConfig: z.input; + } + | { + resourceType: "payment-connector"; + managerName: string; + resourceConfig: z.input; }; export type ProjectResource = AddResourceInput["resourceType"]; export type RemoveResourceInput = | { - resourceType: Exclude; + resourceType: + | "harness" + | "runtime" + | "credential" + | "config-bundle" + | "online-eval" + | "online-insight" + | "memory" + | "gateway" + | "policy-engine" + | "payment-manager"; name: string; } | { @@ -213,6 +233,11 @@ export type RemoveResourceInput = resourceType: "policy"; engineName?: string; name: string; + } + | { + resourceType: "payment-connector"; + managerName: string; + name: string; }; /** From d1858b241ecaccb705e039e699cada58d6d2a64d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 26 Aug 2026 21:56:26 +0000 Subject: [PATCH 03/31] feat(project): add payment credential command --- src/handlers/project/add/credentials/index.ts | 2 + .../add/credentials/payment/index.test.ts | 72 +++++++++++++++++++ .../project/add/credentials/payment/index.ts | 36 ++++++++++ .../project/add/payment-test-support.ts | 56 +++++++++++++++ 4 files changed, 166 insertions(+) create mode 100644 src/handlers/project/add/credentials/payment/index.test.ts create mode 100644 src/handlers/project/add/credentials/payment/index.ts create mode 100644 src/handlers/project/add/payment-test-support.ts diff --git a/src/handlers/project/add/credentials/index.ts b/src/handlers/project/add/credentials/index.ts index 179ee66c3..c78c69033 100644 --- a/src/handlers/project/add/credentials/index.ts +++ b/src/handlers/project/add/credentials/index.ts @@ -2,6 +2,7 @@ import { Router } from "../../../../router"; import type { AddProjectResourceConfig } from "../types"; import { createAddApiKeyCredentialHandler } from "./api-key"; import { createAddOauthCredentialHandler } from "./oauth"; +import { createAddPaymentCredentialHandler } from "./payment"; export function createAddCredentialsHandler(config: AddProjectResourceConfig): Router { const credentials = new Router( @@ -10,5 +11,6 @@ export function createAddCredentialsHandler(config: AddProjectResourceConfig): R ); credentials.handler(createAddApiKeyCredentialHandler(config)); credentials.handler(createAddOauthCredentialHandler(config)); + credentials.handler(createAddPaymentCredentialHandler(config)); return credentials; } diff --git a/src/handlers/project/add/credentials/payment/index.test.ts b/src/handlers/project/add/credentials/payment/index.test.ts new file mode 100644 index 000000000..f0e1cf6a6 --- /dev/null +++ b/src/handlers/project/add/credentials/payment/index.test.ts @@ -0,0 +1,72 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createPaymentProjectTestHarness } from "../../payment-test-support"; + +const { cleanup, inProject, projectSpec, run } = + createPaymentProjectTestHarness("payment-credential"); + +afterEach(cleanup); + +describe("project add credentials payment", () => { + test.each(["CoinbaseCDP", "StripePrivy"] as const)( + "adds a reusable %s payment credential", + async (provider) => { + const projectRoot = await inProject(); + + const io = await run([ + "add", + "credentials", + "payment", + "--name", + `${provider.toLowerCase()}-credential`, + "--provider", + provider, + ]); + + expect((await projectSpec(projectRoot)).credentials).toEqual([ + { + authorizerType: "PaymentCredentialProvider", + name: `${provider.toLowerCase()}-credential`, + provider, + }, + ]); + expect(io.stderr()).toContain(`added credential '${provider.toLowerCase()}-credential'`); + }, + ); + + test.each([ + ["missing name", ["--provider", "CoinbaseCDP"], "required option '--name"], + ["missing provider", ["--name", "payment-credential"], "required option '--provider"], + [ + "unsupported provider", + ["--name", "payment-credential", "--provider", "Unsupported"], + "Invalid value for option '--provider'", + ], + ])("rejects %s", async (_label, flags, message) => { + const projectRoot = await inProject(); + + await expect(run(["add", "credentials", "payment", ...flags])).rejects.toThrow(message); + expect((await projectSpec(projectRoot)).credentials ?? []).toEqual([]); + }); + + test("rejects duplicate names across credential types", async () => { + const projectRoot = await inProject(); + await run(["add", "credentials", "api-key", "--name", "shared"]); + + await expect( + run(["add", "credentials", "payment", "--name", "shared", "--provider", "CoinbaseCDP"]), + ).rejects.toThrow("already exists"); + + expect((await projectSpec(projectRoot)).credentials).toHaveLength(1); + }); + + test("rejects names that collide after environment normalization", async () => { + const projectRoot = await inProject(); + await run(["add", "credentials", "api-key", "--name", "service-key"]); + + await expect( + run(["add", "credentials", "payment", "--name", "service_key", "--provider", "CoinbaseCDP"]), + ).rejects.toThrow("environment variable"); + + expect((await projectSpec(projectRoot)).credentials).toHaveLength(1); + }); +}); diff --git a/src/handlers/project/add/credentials/payment/index.ts b/src/handlers/project/add/credentials/payment/index.ts new file mode 100644 index 000000000..c1a402648 --- /dev/null +++ b/src/handlers/project/add/credentials/payment/index.ts @@ -0,0 +1,36 @@ +import z from "zod"; +import { InputValidationError } from "../../../../../errors"; +import { PaymentProviderSchema } from "../../../../../projectSchemas/payment"; +import { createHandler, flag } from "../../../../../router"; +import type { AddProjectResourceConfig } from "../../types"; +import { addCredentialToProject } from "../shared"; + +export const createAddPaymentCredentialHandler = (config: AddProjectResourceConfig) => + createHandler({ + name: "payment", + description: "add a payment credential provider to the current project", + flags: [ + flag("name", "the name of the credential provider", z.string().optional()), + flag( + "provider", + "the payment provider: CoinbaseCDP or StripePrivy", + PaymentProviderSchema.optional(), + ), + ], + handle: async (ctx, flags) => { + if (!flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + if (!flags.provider) { + throw new InputValidationError("required option '--provider ' not specified"); + } + + await addCredentialToProject(ctx, config, { + resourceConfig: { + authorizerType: "PaymentCredentialProvider", + name: flags.name, + provider: flags.provider, + }, + }); + }, + }); diff --git a/src/handlers/project/add/payment-test-support.ts b/src/handlers/project/add/payment-test-support.ts new file mode 100644 index 000000000..6ef95e707 --- /dev/null +++ b/src/handlers/project/add/payment-test-support.ts @@ -0,0 +1,56 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createRootHandler } from "../../index"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../../testing"; + +export function createPaymentProjectTestHarness(directoryPrefix: string) { + const originalCwd = process.cwd(); + const tempDirectories: string[] = []; + + async function run(args: string[]) { + const io = testIO(); + const root = createRootHandler(new TestCoreClient(), { + io: io.io, + globalConfigAccessor: new TestGlobalConfigAccessor(), + logger: createSilentLogger(), + }); + await root.route(["node", "agentcore", "project", ...args]); + return io; + } + + async function inProject(name = "TestProject"): Promise { + const directory = await mkdtemp(join(tmpdir(), `agentcore-${directoryPrefix}-`)); + tempDirectories.push(directory); + process.chdir(directory); + await run(["create", "--name", name, "--skip-install", "--skip-git"]); + const projectRoot = join(directory, name); + process.chdir(projectRoot); + return projectRoot; + } + + async function projectSpec(projectRoot: string) { + return Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + } + + async function writeProjectSpec(projectRoot: string, spec: unknown): Promise { + await Bun.write( + join(projectRoot, "agentcore", "agentcore.json"), + JSON.stringify(spec, undefined, 2), + ); + } + + async function cleanup(): Promise { + process.chdir(originalCwd); + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); + } + + return { cleanup, inProject, projectSpec, run, writeProjectSpec }; +} From 3f153fc25714e571ee6d3c8416ad4eec4a600044 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 26 Aug 2026 21:56:53 +0000 Subject: [PATCH 04/31] feat(project): add payment manager and connector commands --- src/handlers/project/add/index.ts | 4 + .../add/payment-connector/index.test.ts | 294 ++++++++++++++++++ .../project/add/payment-connector/index.ts | 109 +++++++ .../project/add/payment-manager/index.test.ts | 114 +++++++ .../project/add/payment-manager/index.ts | 98 ++++++ 5 files changed, 619 insertions(+) create mode 100644 src/handlers/project/add/payment-connector/index.test.ts create mode 100644 src/handlers/project/add/payment-connector/index.ts create mode 100644 src/handlers/project/add/payment-manager/index.test.ts create mode 100644 src/handlers/project/add/payment-manager/index.ts diff --git a/src/handlers/project/add/index.ts b/src/handlers/project/add/index.ts index 48914aef3..02f0fce60 100644 --- a/src/handlers/project/add/index.ts +++ b/src/handlers/project/add/index.ts @@ -14,6 +14,8 @@ import { createAddGatewayConnectorHandler } from "./gateway-connector"; import { createAddPolicyEngineHandler } from "./policy-engine"; import { createAddPolicyHandler } from "./policy"; import type { AddProjectResourceConfig } from "./types"; +import { createAddPaymentConnectorHandler } from "./payment-connector"; +import { createAddPaymentManagerHandler } from "./payment-manager"; export function createAddProjectResourceHandler(config: AddProjectResourceConfig): Router { const projectAdd = new Router("add", "add project resources"); @@ -31,5 +33,7 @@ export function createAddProjectResourceHandler(config: AddProjectResourceConfig projectAdd.handler(createAddGatewayConnectorHandler(config)); projectAdd.handler(createAddPolicyEngineHandler(config)); projectAdd.handler(createAddPolicyHandler(config)); + projectAdd.handler(createAddPaymentManagerHandler(config)); + projectAdd.handler(createAddPaymentConnectorHandler(config)); return projectAdd; } diff --git a/src/handlers/project/add/payment-connector/index.test.ts b/src/handlers/project/add/payment-connector/index.test.ts new file mode 100644 index 000000000..975cb9ba6 --- /dev/null +++ b/src/handlers/project/add/payment-connector/index.test.ts @@ -0,0 +1,294 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createPaymentProjectTestHarness } from "../payment-test-support"; + +const { cleanup, inProject, projectSpec, run, writeProjectSpec } = + createPaymentProjectTestHarness("payment-connector"); + +afterEach(cleanup); + +async function addManager() { + await run(["add", "payment-manager", "--name", "payments"]); +} + +async function addCredential(name: string, provider: "CoinbaseCDP" | "StripePrivy") { + await run(["add", "credentials", "payment", "--name", name, "--provider", provider]); +} + +describe("project add payment-connector", () => { + test.each([ + ["CoinbaseCDP", "coinbase"], + ["StripePrivy", "stripe"], + ] as const)("reuses an existing %s credential", async (provider, name) => { + const projectRoot = await inProject(); + await addManager(); + await addCredential(`${name}-credential`, provider); + + await run([ + "add", + "payment-connector", + "--manager", + "payments", + "--name", + name, + "--credential", + `${name}-credential`, + ]); + + expect((await projectSpec(projectRoot)).payments[0].connectors).toEqual([ + { + name, + provider, + credentialName: `${name}-credential`, + }, + ]); + }); + + test.each([ + ["CoinbaseCDP", "coinbase"], + ["StripePrivy", "stripe"], + ] as const)("atomically creates a %s credential and connector", async (provider, name) => { + const projectRoot = await inProject(); + await addManager(); + + await run([ + "add", + "payment-connector", + "--manager", + "payments", + "--name", + name, + "--create-credential", + `${name}-credential`, + "--provider", + provider, + ]); + + const spec = await projectSpec(projectRoot); + expect(spec.credentials).toEqual([ + { + authorizerType: "PaymentCredentialProvider", + name: `${name}-credential`, + provider, + }, + ]); + expect(spec.payments[0].connectors).toEqual([ + { + name, + provider, + credentialName: `${name}-credential`, + }, + ]); + }); + + test("adds Quick Create without a payment credential", async () => { + const projectRoot = await inProject(); + await addManager(); + + await run([ + "add", + "payment-connector", + "--manager", + "payments", + "--name", + "coinbase", + "--quick-create", + ]); + + const spec = await projectSpec(projectRoot); + expect(spec.credentials).toEqual([]); + expect(spec.payments[0].connectors).toEqual([ + { + name: "coinbase", + provider: "CoinbaseCDP", + provisionMode: "QUICK_CREATE", + }, + ]); + }); + + test.each([ + ["missing manager", ["--name", "connector", "--quick-create"], "required option '--manager"], + ["missing name", ["--manager", "payments", "--quick-create"], "required option '--name"], + ["no mode", ["--manager", "payments", "--name", "connector"], "specify exactly one"], + [ + "multiple modes", + [ + "--manager", + "payments", + "--name", + "connector", + "--credential", + "existing", + "--quick-create", + ], + "specify exactly one", + ], + [ + "create without provider", + ["--manager", "payments", "--name", "connector", "--create-credential", "new-credential"], + "--create-credential requires --provider", + ], + [ + "provider outside create mode", + [ + "--manager", + "payments", + "--name", + "connector", + "--quick-create", + "--provider", + "CoinbaseCDP", + ], + "--provider is valid only with --create-credential", + ], + ])("rejects %s", async (_label, flags, message) => { + const projectRoot = await inProject(); + await addManager(); + + await expect(run(["add", "payment-connector", ...flags])).rejects.toThrow(message); + expect((await projectSpec(projectRoot)).payments[0].connectors).toEqual([]); + }); + + test("rejects unknown managers and credentials", async () => { + const projectRoot = await inProject(); + await addManager(); + + await expect( + run([ + "add", + "payment-connector", + "--manager", + "missing", + "--name", + "connector", + "--quick-create", + ]), + ).rejects.toThrow("does not exist"); + await expect( + run([ + "add", + "payment-connector", + "--manager", + "payments", + "--name", + "connector", + "--credential", + "missing", + ]), + ).rejects.toThrow("does not exist in credentials[]"); + + expect((await projectSpec(projectRoot)).payments[0].connectors).toEqual([]); + }); + + test("rejects non-payment credentials", async () => { + const projectRoot = await inProject(); + await addManager(); + await run(["add", "credentials", "api-key", "--name", "api-key"]); + + await expect( + run([ + "add", + "payment-connector", + "--manager", + "payments", + "--name", + "connector", + "--credential", + "api-key", + ]), + ).rejects.toThrow("not a PaymentCredentialProvider"); + + expect((await projectSpec(projectRoot)).payments[0].connectors).toEqual([]); + }); + + test("rejects duplicate connector names without creating an orphan credential", async () => { + const projectRoot = await inProject(); + await addManager(); + await run([ + "add", + "payment-connector", + "--manager", + "payments", + "--name", + "connector", + "--quick-create", + ]); + + await expect( + run([ + "add", + "payment-connector", + "--manager", + "payments", + "--name", + "connector", + "--create-credential", + "orphan", + "--provider", + "CoinbaseCDP", + ]), + ).rejects.toThrow("already exists"); + + const spec = await projectSpec(projectRoot); + expect(spec.credentials).toEqual([]); + expect(spec.payments[0].connectors).toHaveLength(1); + }); + + test("leaves no credential or connector after whole-project validation fails", async () => { + const projectRoot = await inProject(); + await addManager(); + await run(["add", "credentials", "api-key", "--name", "service-key"]); + + await expect( + run([ + "add", + "payment-connector", + "--manager", + "payments", + "--name", + "connector", + "--create-credential", + "service_key", + "--provider", + "CoinbaseCDP", + ]), + ).rejects.toThrow("environment variable"); + + const spec = await projectSpec(projectRoot); + expect(spec.credentials).toEqual([ + { + authorizerType: "ApiKeyCredentialProvider", + name: "service-key", + }, + ]); + expect(spec.payments[0].connectors).toEqual([]); + }); + + test("rejects provider mismatches in complete project data", async () => { + const projectRoot = await inProject(); + const spec = await projectSpec(projectRoot); + spec.credentials = [ + { + authorizerType: "PaymentCredentialProvider", + name: "coinbase", + provider: "CoinbaseCDP", + }, + ]; + spec.payments = [ + { + name: "payments", + connectors: [ + { + name: "stripe", + provider: "StripePrivy", + credentialName: "coinbase", + }, + ], + }, + ]; + await writeProjectSpec(projectRoot, spec); + + await expect(run(["add", "credentials", "api-key", "--name", "trigger"])).rejects.toThrow( + "uses provider", + ); + }); +}); diff --git a/src/handlers/project/add/payment-connector/index.ts b/src/handlers/project/add/payment-connector/index.ts new file mode 100644 index 000000000..7594de1e5 --- /dev/null +++ b/src/handlers/project/add/payment-connector/index.ts @@ -0,0 +1,109 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import type { PaymentCredential } from "../../../../projectSchemas/credential"; +import { PaymentProviderSchema } from "../../../../projectSchemas/payment"; +import { createHandler, flag, ProjectKey } from "../../../../router"; +import type { AddProjectResourceConfig } from "../types"; + +export const createAddPaymentConnectorHandler = (config: AddProjectResourceConfig) => + createHandler({ + name: "payment-connector", + description: "adds a connector to a project payment manager", + flags: [ + flag("manager", "the parent payment manager", z.string().optional()), + flag("name", "the payment connector name", z.string().optional()), + flag("credential", "an existing payment credential to reuse", z.string().optional()), + flag( + "create-credential", + "a new payment credential to create with the connector", + z.string().optional(), + ), + flag( + "provider", + "provider for a newly created payment credential", + PaymentProviderSchema.optional(), + ), + flag("quick-create", "create a CoinbaseCDP connector through Quick Create", z.boolean()), + ], + handle: async (ctx, flags) => { + if (!flags.manager) { + throw new InputValidationError("required option '--manager ' not specified"); + } + if (!flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + + const modes = [ + flags.credential !== undefined, + flags["create-credential"] !== undefined, + flags["quick-create"], + ].filter(Boolean); + if (modes.length !== 1) { + throw new InputValidationError( + "specify exactly one of '--credential', '--create-credential', or '--quick-create'", + ); + } + if (flags["create-credential"] && !flags.provider) { + throw new InputValidationError("--create-credential requires --provider"); + } + if (!flags["create-credential"] && flags.provider) { + throw new InputValidationError("--provider is valid only with --create-credential"); + } + + const project = ctx.require(ProjectKey); + let credentialConfig: PaymentCredential | undefined; + let provider: PaymentCredential["provider"]; + let credentialName: string | undefined; + + if (flags["quick-create"]) { + provider = "CoinbaseCDP"; + } else if (flags["create-credential"]) { + provider = flags.provider!; + credentialName = flags["create-credential"]; + credentialConfig = { + authorizerType: "PaymentCredentialProvider", + name: credentialName, + provider, + }; + } else { + credentialName = flags.credential!; + const credential = project.spec.credentials.find( + (candidate) => candidate.name === credentialName, + ); + if (!credential) { + throw new InputValidationError( + `credential '${credentialName}' does not exist in credentials[]`, + ); + } + if (credential.authorizerType !== "PaymentCredentialProvider") { + throw new InputValidationError( + `credential '${credentialName}' is a ${credential.authorizerType}, not a PaymentCredentialProvider`, + ); + } + provider = credential.provider; + } + + for await (const event of config.projectManager.addResource(project, { + resourceType: "payment-connector", + managerName: flags.manager, + resourceConfig: flags["quick-create"] + ? { + name: flags.name, + provider: "CoinbaseCDP", + provisionMode: "QUICK_CREATE", + } + : { + name: flags.name, + provider, + credentialName: credentialName!, + }, + credentialConfig, + })) { + config.io.stderr.write(`${event.message}\n`); + } + + config.io.stderr.write( + `added payment connector '${flags.name}' to manager '${flags.manager}' in '${project.name}'\n`, + ); + }, + }); diff --git a/src/handlers/project/add/payment-manager/index.test.ts b/src/handlers/project/add/payment-manager/index.test.ts new file mode 100644 index 000000000..807cfe63f --- /dev/null +++ b/src/handlers/project/add/payment-manager/index.test.ts @@ -0,0 +1,114 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createPaymentProjectTestHarness } from "../payment-test-support"; + +const DISCOVERY_URL = "https://idp.example.com/.well-known/openid-configuration"; +const { cleanup, inProject, projectSpec, run } = createPaymentProjectTestHarness("payment-manager"); + +afterEach(cleanup); + +describe("project add payment-manager", () => { + test("adds a manager with materialized defaults", async () => { + const projectRoot = await inProject(); + + const io = await run(["add", "payment-manager", "--name", "payments"]); + + expect((await projectSpec(projectRoot)).payments).toEqual([ + { + name: "payments", + authorizerType: "AWS_IAM", + connectors: [], + autoPayment: true, + defaultSpendLimit: "10.00", + }, + ]); + expect(io.stderr()).toContain("added payment manager 'payments'"); + }); + + test("maps custom JWT and payment behavior flags", async () => { + const projectRoot = await inProject(); + + await run([ + "add", + "payment-manager", + "--name", + "securePayments", + "--authorizer-type", + "CUSTOM_JWT", + "--discovery-url", + DISCOVERY_URL, + "--allowed-clients", + "client-a", + "client-b", + "--allowed-audience", + "payments", + "--allowed-scopes", + "pay", + "refund", + "--description", + "Secure payments", + "--no-auto-payment", + "--default-spend-limit", + "25.50", + "--tool-allowlist", + "checkout", + "refund", + "--network-preferences", + "eip155:8453", + "eip155:1", + ]); + + expect((await projectSpec(projectRoot)).payments[0]).toEqual({ + name: "securePayments", + authorizerType: "CUSTOM_JWT", + authorizerConfiguration: { + customJWTAuthorizer: { + discoveryUrl: DISCOVERY_URL, + allowedClients: ["client-a", "client-b"], + allowedAudience: ["payments"], + allowedScopes: ["pay", "refund"], + }, + }, + connectors: [], + description: "Secure payments", + autoPayment: false, + defaultSpendLimit: "25.50", + paymentToolAllowlist: ["checkout", "refund"], + networkPreferences: ["eip155:8453", "eip155:1"], + }); + }); + + test.each([ + ["missing name", [], "required option '--name"], + [ + "CUSTOM_JWT without discovery URL", + ["--name", "payments", "--authorizer-type", "CUSTOM_JWT"], + "requires --discovery-url", + ], + [ + "JWT fields with AWS_IAM", + ["--name", "payments", "--allowed-scopes", "pay"], + "valid only with CUSTOM_JWT", + ], + [ + "negative default spend limit", + ["--name", "payments", "--default-spend-limit", "-1"], + "non-negative", + ], + ["invalid name", ["--name", "bad-name"], "alphanumeric"], + ])("rejects %s", async (_label, flags, message) => { + const projectRoot = await inProject(); + + await expect(run(["add", "payment-manager", ...flags])).rejects.toThrow(message); + expect((await projectSpec(projectRoot)).payments ?? []).toEqual([]); + }); + + test("rejects duplicate manager names", async () => { + const projectRoot = await inProject(); + await run(["add", "payment-manager", "--name", "payments"]); + + await expect(run(["add", "payment-manager", "--name", "payments"])).rejects.toThrow( + "already exists", + ); + expect((await projectSpec(projectRoot)).payments).toHaveLength(1); + }); +}); diff --git a/src/handlers/project/add/payment-manager/index.ts b/src/handlers/project/add/payment-manager/index.ts new file mode 100644 index 000000000..f2b794471 --- /dev/null +++ b/src/handlers/project/add/payment-manager/index.ts @@ -0,0 +1,98 @@ +import z from "zod"; +import { InputValidationError } from "../../../../errors"; +import { + DEFAULT_AUTO_PAYMENT, + DEFAULT_SPEND_LIMIT, + PaymentAuthorizerTypeSchema, +} from "../../../../projectSchemas/payment"; +import { createHandler, flag, ProjectKey } from "../../../../router"; +import type { AddProjectResourceConfig } from "../types"; + +export const createAddPaymentManagerHandler = (config: AddProjectResourceConfig) => + createHandler({ + name: "payment-manager", + description: "adds a payment manager to the current project", + flags: [ + flag("name", "the payment manager name", z.string().optional()), + flag( + "authorizer-type", + "payment authorization type", + PaymentAuthorizerTypeSchema.default("AWS_IAM"), + ), + flag( + "discovery-url", + "OIDC discovery URL for CUSTOM_JWT authorization", + z.string().optional(), + ), + flag("allowed-clients", "allowed JWT client IDs", z.array(z.string()).optional()), + flag("allowed-audience", "allowed JWT audiences", z.array(z.string()).optional()), + flag("allowed-scopes", "allowed JWT scopes", z.array(z.string()).optional()), + flag("description", "payment manager description", z.string().optional()), + flag( + "auto-payment", + "automatically settle payment requests", + z.boolean().default(DEFAULT_AUTO_PAYMENT), + ), + flag( + "default-spend-limit", + "default payment-session spend limit", + z.string().default(DEFAULT_SPEND_LIMIT), + ), + flag( + "tool-allowlist", + "tools eligible for automatic payment", + z.array(z.string()).optional(), + ), + flag("network-preferences", "preferred payment networks", z.array(z.string()).optional()), + ], + handle: async (ctx, flags) => { + if (!flags.name) { + throw new InputValidationError("required option '--name ' not specified"); + } + + const jwtFlags = [ + flags["discovery-url"], + flags["allowed-clients"], + flags["allowed-audience"], + flags["allowed-scopes"], + ]; + if (flags["authorizer-type"] === "CUSTOM_JWT" && !flags["discovery-url"]) { + throw new InputValidationError("CUSTOM_JWT requires --discovery-url"); + } + if ( + flags["authorizer-type"] !== "CUSTOM_JWT" && + jwtFlags.some((value) => value !== undefined) + ) { + throw new InputValidationError("JWT authorization flags are valid only with CUSTOM_JWT"); + } + + const project = ctx.require(ProjectKey); + for await (const event of config.projectManager.addResource(project, { + resourceType: "payment-manager", + resourceConfig: { + name: flags.name, + authorizerType: flags["authorizer-type"], + authorizerConfiguration: + flags["authorizer-type"] === "CUSTOM_JWT" + ? { + customJWTAuthorizer: { + discoveryUrl: flags["discovery-url"]!, + allowedClients: flags["allowed-clients"], + allowedAudience: flags["allowed-audience"], + allowedScopes: flags["allowed-scopes"], + }, + } + : undefined, + connectors: [], + description: flags.description, + autoPayment: flags["auto-payment"], + defaultSpendLimit: flags["default-spend-limit"], + paymentToolAllowlist: flags["tool-allowlist"], + networkPreferences: flags["network-preferences"], + }, + })) { + config.io.stderr.write(`${event.message}\n`); + } + config.io.stderr.write(`added payment manager '${flags.name}' to '${project.name}'\n`); + }, + }); From f232a037054e3bdbf01c2b9d5667a036053c8aea Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 26 Aug 2026 21:57:27 +0000 Subject: [PATCH 05/31] feat(project): wire Quick Create into generated CDK --- src/assets/cdk/bin/cdk.ts | 34 ++++++++++++--- src/assets/cdk/lib/cdk-stack.ts | 47 +++++++++++++++++---- src/assets/cdk/package.json | 2 +- src/assets/cdk/test/cdk.test.ts | 73 ++++++++++++++++++++++++++++++++- 4 files changed, 140 insertions(+), 16 deletions(-) diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 701339bce..eb7062720 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -146,14 +146,12 @@ async function main() { // Extract credentials from deployed state for this target const targetState = (deployedState as Record)?.targets as - | Record> - | undefined; + Record> | undefined; const targetResources = target ? (targetState?.[target.name]?.resources as Record | undefined) : undefined; const credentials = targetResources?.credentials as - | Record - | undefined; + Record | undefined; // Payment credential provider ARNs live in the same credentials map as identity credentials const paymentCredentials = credentials; @@ -168,7 +166,12 @@ async function main() { autoPayment?: boolean; paymentToolAllowlist?: string[]; networkPreferences?: string[]; - connectors: { name: string; provider?: string; credentialName: string }[]; + connectors: { + name: string; + provider?: 'CoinbaseCDP' | 'StripePrivy'; + provisionMode?: 'MANUAL' | 'QUICK_CREATE'; + credentialName?: string; + }[]; }) => ({ name: p.name, description: p.description, @@ -178,6 +181,19 @@ async function main() { paymentToolAllowlist: p.paymentToolAllowlist, networkPreferences: p.networkPreferences, connectors: p.connectors.map(c => { + if (c.provisionMode === 'QUICK_CREATE') { + return { + name: c.name, + provider: 'CoinbaseCDP' as const, + provisionMode: 'QUICK_CREATE' as const, + }; + } + + if (!c.credentialName) { + throw new Error( + `Manual payment connector "${c.name}" on manager "${p.name}" is missing its credential name.` + ); + } const credentialProviderArn = paymentCredentials?.[c.credentialName]?.credentialProviderArn; if (!credentialProviderArn) { // Fail fast with an actionable message rather than passing an empty @@ -188,7 +204,13 @@ async function main() { `Run \`agentcore deploy\` so the credential provider is created first.` ); } - return { name: c.name, provider: c.provider, credentialProviderArn }; + return { + name: c.name, + provider: c.provider ?? ('CoinbaseCDP' as const), + ...(c.provisionMode && { provisionMode: c.provisionMode }), + credentialName: c.credentialName, + credentialProviderArn, + }; }), }) ) diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index 3dac0669d..40008eef2 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -19,12 +19,24 @@ import { Construct } from 'constructs'; */ export type HarnessConfig = HarnessDeploymentConfig; -export interface PaymentConnectorSpec { +export interface ManualPaymentConnectorSpec { name: string; provider: 'CoinbaseCDP' | 'StripePrivy'; + provisionMode?: 'MANUAL'; + credentialName: string; credentialProviderArn: string; } +export interface QuickCreatePaymentConnectorSpec { + name: string; + provider: 'CoinbaseCDP'; + provisionMode: 'QUICK_CREATE'; + credentialName?: never; + credentialProviderArn?: never; +} + +export type PaymentConnectorSpec = ManualPaymentConnectorSpec | QuickCreatePaymentConnectorSpec; + export interface PaymentSpec { name: string; description?: string; @@ -203,13 +215,24 @@ export class AgentCoreStack extends Stack { // Create connectors for this manager for (const connector of payment.connectors) { const connId = toCdkId(connector.name); - const conn = new AgentCorePaymentConnector(this, `Payment${mgrId}${connId}`, { - projectName: spec.name, - paymentManager: manager, - connectorName: connector.name, - connectorType: connector.provider, - credentialProviderArn: connector.credentialProviderArn, - }); + const conn = + connector.provisionMode === 'QUICK_CREATE' + ? new AgentCorePaymentConnector(this, `Payment${mgrId}${connId}`, { + projectName: spec.name, + paymentManager: manager, + connector, + }) + : new AgentCorePaymentConnector(this, `Payment${mgrId}${connId}`, { + projectName: spec.name, + paymentManager: manager, + connector: { + name: connector.name, + provider: connector.provider, + provisionMode: connector.provisionMode, + credentialName: connector.credentialName, + }, + credentialProviderArn: connector.credentialProviderArn, + }); // Wire first connector's ID as env var (eligible agents only) if (connector === payment.connectors[0]) { @@ -222,6 +245,14 @@ export class AgentCoreStack extends Stack { new CfnOutput(this, `Payment${mgrId}${connId}ConnectorId`, { value: conn.paymentConnectorId, }); + if (connector.provisionMode === 'QUICK_CREATE') { + new CfnOutput(this, `Payment${mgrId}${connId}ConnectorStatus`, { + value: conn.paymentConnectorStatus, + }); + new CfnOutput(this, `Payment${mgrId}${connId}AuthorizationUrl`, { + value: conn.authorizationUrl, + }); + } } // CFN Outputs for post-deploy state parsing diff --git a/src/assets/cdk/package.json b/src/assets/cdk/package.json index 0ac28f946..407a29fbd 100644 --- a/src/assets/cdk/package.json +++ b/src/assets/cdk/package.json @@ -23,7 +23,7 @@ "typescript": "~5.9.3" }, "dependencies": { - "@aws/agentcore-cdk": "0.1.0-alpha.45", + "@aws/agentcore-cdk": "0.1.0-alpha.49", "aws-cdk-lib": "~2.266.0", "constructs": "~10.7.0" } diff --git a/src/assets/cdk/test/cdk.test.ts b/src/assets/cdk/test/cdk.test.ts index 8db318ada..3c75dcbf3 100644 --- a/src/assets/cdk/test/cdk.test.ts +++ b/src/assets/cdk/test/cdk.test.ts @@ -1,7 +1,20 @@ +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; import * as cdk from 'aws-cdk-lib'; -import { Template } from 'aws-cdk-lib/assertions'; +import { Match, Template } from 'aws-cdk-lib/assertions'; import { AgentCoreStack } from '../lib/cdk-stack'; +const testConfigDir = join(process.cwd(), 'agentcore'); + +beforeAll(() => { + mkdirSync(testConfigDir, { recursive: true }); + writeFileSync(join(testConfigDir, 'agentcore.json'), '{}'); +}); + +afterAll(() => { + rmSync(testConfigDir, { recursive: true, force: true }); +}); + test('AgentCoreStack synthesizes with empty spec', () => { const app = new cdk.App(); const stack = new AgentCoreStack(app, 'TestStack', { @@ -29,3 +42,61 @@ test('AgentCoreStack synthesizes with empty spec', () => { Description: 'Name of the CloudFormation Stack', }); }); + +test('AgentCoreStack synthesizes manual and Quick Create payment connectors', () => { + const app = new cdk.App(); + const stack = new AgentCoreStack(app, 'TestStack', { + spec: { + name: 'testproject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + configBundles: [], + policyEngines: [], + payments: [], + agentCoreGateways: [], + mcpRuntimeTools: [], + unassignedTargets: [], + datasets: [], + knowledgeBases: [], + }, + paymentSpec: [ + { + name: 'Payments', + authorizerType: 'AWS_IAM', + connectors: [ + { + name: 'Manual', + provider: 'CoinbaseCDP', + credentialName: 'coinbase', + credentialProviderArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/paymentcredentialprovider/coinbase', + }, + { + name: 'Quick', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + ], + }, + ], + }); + const template = Template.fromStack(stack); + + template.resourceCountIs('AWS::BedrockAgentCore::PaymentConnector', 2); + template.hasResourceProperties('AWS::BedrockAgentCore::PaymentConnector', { + ConnectorName: 'Manual', + ProvisionMode: Match.absent(), + }); + template.hasResourceProperties('AWS::BedrockAgentCore::PaymentConnector', { + ConnectorName: 'Quick', + ConnectorType: 'CoinbaseCDP', + ProvisionMode: 'QUICK_CREATE', + CredentialProviderConfigurations: [], + }); + template.hasOutput('PaymentPaymentsQuickAuthorizationUrl', {}); +}); From 49b8139fdd61c236537259101041c1ed66844434 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 26 Aug 2026 22:25:51 +0000 Subject: [PATCH 06/31] refactor(project): use type aliases for payment connector specs --- src/assets/cdk/lib/cdk-stack.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index 40008eef2..7524c34b4 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -19,21 +19,21 @@ import { Construct } from 'constructs'; */ export type HarnessConfig = HarnessDeploymentConfig; -export interface ManualPaymentConnectorSpec { +export type ManualPaymentConnectorSpec = { name: string; provider: 'CoinbaseCDP' | 'StripePrivy'; provisionMode?: 'MANUAL'; credentialName: string; credentialProviderArn: string; -} +}; -export interface QuickCreatePaymentConnectorSpec { +export type QuickCreatePaymentConnectorSpec = { name: string; provider: 'CoinbaseCDP'; provisionMode: 'QUICK_CREATE'; credentialName?: never; credentialProviderArn?: never; -} +}; export type PaymentConnectorSpec = ManualPaymentConnectorSpec | QuickCreatePaymentConnectorSpec; From 9cdfd58ee3e22eae55c244c8d89b599b61baec19 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 26 Aug 2026 22:29:33 +0000 Subject: [PATCH 07/31] refactor(project): clarify payment connector construction --- src/assets/cdk/lib/cdk-stack.ts | 38 +++++++++++++++++---------------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index 7524c34b4..9bee0b996 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -215,24 +215,26 @@ export class AgentCoreStack extends Stack { // Create connectors for this manager for (const connector of payment.connectors) { const connId = toCdkId(connector.name); - const conn = - connector.provisionMode === 'QUICK_CREATE' - ? new AgentCorePaymentConnector(this, `Payment${mgrId}${connId}`, { - projectName: spec.name, - paymentManager: manager, - connector, - }) - : new AgentCorePaymentConnector(this, `Payment${mgrId}${connId}`, { - projectName: spec.name, - paymentManager: manager, - connector: { - name: connector.name, - provider: connector.provider, - provisionMode: connector.provisionMode, - credentialName: connector.credentialName, - }, - credentialProviderArn: connector.credentialProviderArn, - }); + let conn: AgentCorePaymentConnector; + if (connector.provisionMode === 'QUICK_CREATE') { + conn = new AgentCorePaymentConnector(this, `Payment${mgrId}${connId}`, { + projectName: spec.name, + paymentManager: manager, + connector, + }); + } else { + conn = new AgentCorePaymentConnector(this, `Payment${mgrId}${connId}`, { + projectName: spec.name, + paymentManager: manager, + connector: { + name: connector.name, + provider: connector.provider, + provisionMode: connector.provisionMode, + credentialName: connector.credentialName, + }, + credentialProviderArn: connector.credentialProviderArn, + }); + } // Wire first connector's ID as env var (eligible agents only) if (connector === payment.connectors[0]) { From 521dd6378a96485a6d04dcb14beb6999dd90ae7f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 26 Aug 2026 23:12:35 +0000 Subject: [PATCH 08/31] feat(project): add payment manager removal --- src/handlers/project/remove/index.test.ts | 31 +++++++++++++++++++++++ src/handlers/project/remove/index.ts | 1 + 2 files changed, 32 insertions(+) diff --git a/src/handlers/project/remove/index.test.ts b/src/handlers/project/remove/index.test.ts index a6423ee05..ca146e4eb 100644 --- a/src/handlers/project/remove/index.test.ts +++ b/src/handlers/project/remove/index.test.ts @@ -163,6 +163,37 @@ describe("project remove", () => { ).toEqual(["keep"]); }); + test("removes a payment manager with its connectors while preserving reusable credentials", async () => { + const projectRoot = await inProject(); + await run(["add", "credentials", "payment", "--name", "shared", "--provider", "CoinbaseCDP"]); + await run(["add", "payment-manager", "--name", "keep"]); + await run(["add", "payment-manager", "--name", "remove"]); + await run([ + "add", + "payment-connector", + "--manager", + "remove", + "--name", + "connector", + "--credential", + "shared", + ]); + + await run(["remove", "payment-manager", "--name", "remove"]); + + const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect(agentcoreJson.payments.map((manager: { name: string }) => manager.name)).toEqual([ + "keep", + ]); + expect(agentcoreJson.credentials).toEqual([ + { + authorizerType: "PaymentCredentialProvider", + name: "shared", + provider: "CoinbaseCDP", + }, + ]); + }); + // Verifies that missing required inputs are rejected before calling the manager. test.each<[string, string[]]>([ ["missing resource argument", ["remove", "--name", "x"]], diff --git a/src/handlers/project/remove/index.ts b/src/handlers/project/remove/index.ts index 8d5973d92..04be54c56 100644 --- a/src/handlers/project/remove/index.ts +++ b/src/handlers/project/remove/index.ts @@ -31,6 +31,7 @@ export const createRemoveProjectHandler = (config: RemoveProjectResourceConfig) "gateway-connector", "policy-engine", "policy", + "payment-manager", ]) .optional(), ), From cdd09109f11e010148770880574d651946863a84 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 00:25:43 +0000 Subject: [PATCH 09/31] feat(project): add payment connector removal --- src/core/project/manager.tsx | 10 +++++ src/handlers/project/remove/index.test.ts | 48 +++++++++++++++++++++++ src/handlers/project/remove/index.ts | 18 +++++++++ 3 files changed, 76 insertions(+) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 9ead04dc3..44ab76da1 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -429,6 +429,16 @@ export class FsProjectManager implements ProjectManager { gateways[gatewayIndex] = { ...gateway, targets }; } newSpec = { ...existingProjectSpec, agentCoreGateways: gateways }; + } else if (input.resourceType === "payment-connector") { + const payments = [...(existingProjectSpec.payments ?? [])]; + const managerIndex = payments.findIndex((manager) => manager.name === input.managerName); + if (managerIndex >= 0) { + const manager = payments[managerIndex]!; + const connectors = manager.connectors.filter((connector) => connector.name !== input.name); + removed = connectors.length !== manager.connectors.length; + payments[managerIndex] = { ...manager, connectors }; + } + newSpec = { ...existingProjectSpec, payments }; } else { const projectSpecKey = toProjectSpecKey(input.resourceType); const existingResources = existingProjectSpec[projectSpecKey] ?? []; diff --git a/src/handlers/project/remove/index.test.ts b/src/handlers/project/remove/index.test.ts index ca146e4eb..e791aa481 100644 --- a/src/handlers/project/remove/index.test.ts +++ b/src/handlers/project/remove/index.test.ts @@ -194,11 +194,55 @@ describe("project remove", () => { ]); }); + test("removes a nested payment connector while preserving siblings and credentials", async () => { + const projectRoot = await inProject(); + await run(["add", "credentials", "payment", "--name", "shared", "--provider", "CoinbaseCDP"]); + await run(["add", "payment-manager", "--name", "payments"]); + await run([ + "add", + "payment-connector", + "--manager", + "payments", + "--name", + "keep", + "--credential", + "shared", + ]); + await run([ + "add", + "payment-connector", + "--manager", + "payments", + "--name", + "remove", + "--credential", + "shared", + ]); + + await run(["remove", "payment-connector", "--manager", "payments", "--name", "remove"]); + + const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect( + agentcoreJson.payments[0].connectors.map((connector: { name: string }) => connector.name), + ).toEqual(["keep"]); + expect(agentcoreJson.credentials).toEqual([ + { + authorizerType: "PaymentCredentialProvider", + name: "shared", + provider: "CoinbaseCDP", + }, + ]); + }); + // Verifies that missing required inputs are rejected before calling the manager. test.each<[string, string[]]>([ ["missing resource argument", ["remove", "--name", "x"]], ["missing --name flag", ["remove", "harness"]], ["missing --gateway for a Target", ["remove", "gateway-target", "--name", "target"]], + [ + "missing --manager for a payment connector", + ["remove", "payment-connector", "--name", "connector"], + ], [ "--gateway on a non-Target resource", ["remove", "gateway", "--gateway", "tools", "--name", "tools"], @@ -207,6 +251,10 @@ describe("project remove", () => { "--engine on a non-policy resource", ["remove", "gateway", "--engine", "Guardrails", "--name", "tools"], ], + [ + "--manager on a non-payment-connector resource", + ["remove", "payment-manager", "--manager", "payments", "--name", "payments"], + ], ])("%s", async (_label, args) => { await inProject(); await expect(run(args)).rejects.toBeInstanceOf(InputValidationError); diff --git a/src/handlers/project/remove/index.ts b/src/handlers/project/remove/index.ts index 04be54c56..13f66f669 100644 --- a/src/handlers/project/remove/index.ts +++ b/src/handlers/project/remove/index.ts @@ -17,6 +17,11 @@ export const createRemoveProjectHandler = (config: RemoveProjectResourceConfig) flag("name", "name of the resource to remove", z.string().min(1).optional()), flag("gateway", "name of the parent Gateway for a Target", z.string().min(1).optional()), flag("engine", "name of the parent Policy Engine for a Policy", z.string().min(1).optional()), + flag( + "manager", + "name of the parent payment manager for a connector", + z.string().min(1).optional(), + ), ], arguments: [ argument( @@ -32,6 +37,7 @@ export const createRemoveProjectHandler = (config: RemoveProjectResourceConfig) "policy-engine", "policy", "payment-manager", + "payment-connector", ]) .optional(), ), @@ -50,6 +56,9 @@ export const createRemoveProjectHandler = (config: RemoveProjectResourceConfig) if (flags.engine && resource !== "policy") { throw new InputValidationError(`--engine is valid only when removing a policy`); } + if (flags.manager && resource !== "payment-connector") { + throw new InputValidationError(`--manager is valid only when removing a payment-connector`); + } const project = ctx.require(ProjectKey); if (resource === "gateway-target" || resource === "gateway-connector") { @@ -67,6 +76,15 @@ export const createRemoveProjectHandler = (config: RemoveProjectResourceConfig) engineName: flags.engine, name, }); + } else if (resource === "payment-connector") { + if (!flags.manager) { + throw new InputValidationError(`--manager is required option`); + } + await config.projectManager.removeResource(project, { + resourceType: "payment-connector", + managerName: flags.manager, + name, + }); } else { await config.projectManager.removeResource(project, { resourceType: resource, From 580bed97bc31a84af4697e9555087f66e3005e37 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 01:19:30 +0000 Subject: [PATCH 10/31] fix(project): reject payment manager environment collisions --- src/projectSchemas/project.test.ts | 19 +++++++++++++++++++ src/projectSchemas/project.ts | 18 +++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/projectSchemas/project.test.ts b/src/projectSchemas/project.test.ts index 83f85b89d..705f5eb56 100644 --- a/src/projectSchemas/project.test.ts +++ b/src/projectSchemas/project.test.ts @@ -289,6 +289,25 @@ describe("project custom validation", () => { ).toBe(true); }); + it("rejects payment manager names that collide after environment normalization", () => { + const result = ProjectSpecSchema.safeParse({ + ...minimalProject, + payments: [ + { name: "Payments", connectors: [] }, + { name: "payments", connectors: [] }, + ], + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((issue) => + issue.message.includes("payment manager environment name"), + ), + ).toBe(true); + } + }); + it("allows the same payment connector name under different managers", () => { const credential = { authorizerType: "PaymentCredentialProvider" as const, diff --git a/src/projectSchemas/project.ts b/src/projectSchemas/project.ts index 7dc2b7a52..04c7c8d66 100644 --- a/src/projectSchemas/project.ts +++ b/src/projectSchemas/project.ts @@ -36,6 +36,7 @@ export const ProjectNameSchema = z }); const BUILTIN_EVALUATOR_PREFIX = "Builtin."; const ARN_PREFIX = "arn:"; +const toEnvironmentName = (name: string) => name.replace(/-/g, "_").toUpperCase(); const uniqueNames = (resource: string) => uniqueBy<{ name: string }>( ({ name }) => name, @@ -250,7 +251,7 @@ export const ProjectSpecSchema = z } const credentialEnvironmentNames = new Map(); for (const [credentialIndex, credential] of spec.credentials.entries()) { - const environmentName = credential.name.replace(/-/g, "_").toUpperCase(); + const environmentName = toEnvironmentName(credential.name); const conflictingName = credentialEnvironmentNames.get(environmentName); if (conflictingName) { ctx.addIssue({ @@ -264,7 +265,22 @@ export const ProjectSpecSchema = z credentialEnvironmentNames.set(environmentName, credential.name); } } + const paymentManagerEnvironmentNames = new Map(); for (const [paymentIndex, payment] of (spec.payments ?? []).entries()) { + const environmentName = toEnvironmentName(payment.name); + const conflictingName = paymentManagerEnvironmentNames.get(environmentName); + if (conflictingName) { + ctx.addIssue({ + code: "custom", + message: + `Payment managers "${payment.name}" and "${conflictingName}" derive the same payment manager environment name; ` + + "choose names that differ by more than letter casing", + path: ["payments", paymentIndex, "name"], + }); + } else { + paymentManagerEnvironmentNames.set(environmentName, payment.name); + } + for (const [connectorIndex, connector] of payment.connectors.entries()) { if (connector.provisionMode === "QUICK_CREATE") continue; From 51d9ba9d7e9b084aa9e2d5d6df890688cacd2e35 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 01:19:38 +0000 Subject: [PATCH 11/31] fix(project): guard quick create for legacy CDK assets --- src/assets/cdk/package.json | 5 ++++ src/core/project/manager.tsx | 22 +++++++++++++++++ .../add/payment-connector/index.test.ts | 24 +++++++++++++++++++ 3 files changed, 51 insertions(+) diff --git a/src/assets/cdk/package.json b/src/assets/cdk/package.json index 407a29fbd..8a811a874 100644 --- a/src/assets/cdk/package.json +++ b/src/assets/cdk/package.json @@ -1,6 +1,11 @@ { "name": "agentcore-cdk-app", "version": "0.1.0", + "agentcoreProject": { + "capabilities": [ + "payment-connector-quick-create" + ] + }, "bin": { "cdk": "dist/bin/cdk.js" }, diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 44ab76da1..7242bfd54 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -50,6 +50,14 @@ import type { TemplateRenderer } from "./templates/types"; import { HandlebarsTemplateRenderer } from "./templates/renderer"; const TARGETS_EXAMPLE = '[{ "name": "default", "account": "111122223333", "region": "us-east-1" }]'; +const PAYMENT_CONNECTOR_QUICK_CREATE_CAPABILITY = "payment-connector-quick-create"; +const GeneratedCdkPackageSchema = z.object({ + agentcoreProject: z + .object({ + capabilities: z.array(z.string()), + }) + .optional(), +}); type ProjectManagerConfig = { logger: Logger; @@ -326,6 +334,20 @@ export class FsProjectManager implements ProjectManager { break; } case "payment-connector": { + if (input.resourceConfig.provisionMode === "QUICK_CREATE") { + const cdkPackagePath = join(project.rootPath, "agentcore", "cdk", "package.json"); + const cdkPackage = await this.json.read(cdkPackagePath, GeneratedCdkPackageSchema); + if ( + !cdkPackage.agentcoreProject?.capabilities.includes( + PAYMENT_CONNECTOR_QUICK_CREATE_CAPABILITY, + ) + ) { + throw new InputValidationError( + "Quick Create requires current generated CDK assets; update 'agentcore/cdk' from a project created by this CLI version, then retry", + ); + } + } + const manager = projectSpec.payments!.find( (candidate) => candidate.name === input.managerName, )!; diff --git a/src/handlers/project/add/payment-connector/index.test.ts b/src/handlers/project/add/payment-connector/index.test.ts index 975cb9ba6..808ae1a7d 100644 --- a/src/handlers/project/add/payment-connector/index.test.ts +++ b/src/handlers/project/add/payment-connector/index.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { join } from "node:path"; import { createPaymentProjectTestHarness } from "../payment-test-support"; const { cleanup, inProject, projectSpec, run, writeProjectSpec } = @@ -105,6 +106,29 @@ describe("project add payment-connector", () => { ]); }); + test("rejects Quick Create when the project has legacy generated CDK assets", async () => { + const projectRoot = await inProject(); + await addManager(); + const packagePath = join(projectRoot, "agentcore", "cdk", "package.json"); + const packageJson = await Bun.file(packagePath).json(); + delete packageJson.agentcoreProject; + await Bun.write(packagePath, JSON.stringify(packageJson, undefined, 2)); + + await expect( + run([ + "add", + "payment-connector", + "--manager", + "payments", + "--name", + "coinbase", + "--quick-create", + ]), + ).rejects.toThrow("generated CDK assets"); + + expect((await projectSpec(projectRoot)).payments[0].connectors).toEqual([]); + }); + test.each([ ["missing manager", ["--name", "connector", "--quick-create"], "required option '--manager"], ["missing name", ["--manager", "payments", "--quick-create"], "required option '--name"], From ab65a6322d1e9913c1ee4492754ffad08573b6b9 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 01:19:44 +0000 Subject: [PATCH 12/31] fix(project): preserve payment connector CDK identities --- src/assets/cdk/lib/cdk-stack.ts | 18 ++++----- src/assets/cdk/test/cdk.test.ts | 68 ++++++++++++++++++++++++++++++++- 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index 9bee0b996..0a51e6af8 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -76,8 +76,8 @@ export interface AgentCoreStackProps extends StackProps { paymentSpec?: PaymentSpec[]; } -function toCdkId(name: string): string { - return name.replace(/_/g, ''); +function paymentConnectorCdkId(managerName: string, connectorName: string): string { + return `PaymentM${managerName.length}${managerName}C${connectorName.length}${connectorName}`; } /** @@ -139,7 +139,7 @@ export class AgentCoreStack extends Stack { // Create payment infrastructure via CFN constructs if (paymentSpec && paymentSpec.length > 0) { for (const payment of paymentSpec) { - const mgrId = toCdkId(payment.name); + const mgrId = payment.name; const manager = new AgentCorePaymentManager(this, `Payment${mgrId}`, { projectName: spec.name, name: payment.name, @@ -214,16 +214,16 @@ export class AgentCoreStack extends Stack { // Create connectors for this manager for (const connector of payment.connectors) { - const connId = toCdkId(connector.name); + const connectorCdkId = paymentConnectorCdkId(payment.name, connector.name); let conn: AgentCorePaymentConnector; if (connector.provisionMode === 'QUICK_CREATE') { - conn = new AgentCorePaymentConnector(this, `Payment${mgrId}${connId}`, { + conn = new AgentCorePaymentConnector(this, connectorCdkId, { projectName: spec.name, paymentManager: manager, connector, }); } else { - conn = new AgentCorePaymentConnector(this, `Payment${mgrId}${connId}`, { + conn = new AgentCorePaymentConnector(this, connectorCdkId, { projectName: spec.name, paymentManager: manager, connector: { @@ -244,14 +244,14 @@ export class AgentCoreStack extends Stack { } } - new CfnOutput(this, `Payment${mgrId}${connId}ConnectorId`, { + new CfnOutput(this, `${connectorCdkId}ConnectorId`, { value: conn.paymentConnectorId, }); if (connector.provisionMode === 'QUICK_CREATE') { - new CfnOutput(this, `Payment${mgrId}${connId}ConnectorStatus`, { + new CfnOutput(this, `${connectorCdkId}ConnectorStatus`, { value: conn.paymentConnectorStatus, }); - new CfnOutput(this, `Payment${mgrId}${connId}AuthorizationUrl`, { + new CfnOutput(this, `${connectorCdkId}AuthorizationUrl`, { value: conn.authorizationUrl, }); } diff --git a/src/assets/cdk/test/cdk.test.ts b/src/assets/cdk/test/cdk.test.ts index 3c75dcbf3..53a586301 100644 --- a/src/assets/cdk/test/cdk.test.ts +++ b/src/assets/cdk/test/cdk.test.ts @@ -98,5 +98,71 @@ test('AgentCoreStack synthesizes manual and Quick Create payment connectors', () ProvisionMode: 'QUICK_CREATE', CredentialProviderConfigurations: [], }); - template.hasOutput('PaymentPaymentsQuickAuthorizationUrl', {}); + template.hasOutput('PaymentM8PaymentsC5QuickAuthorizationUrl', {}); +}); + +test('AgentCoreStack preserves complete payment connector identities', () => { + const app = new cdk.App(); + const stack = new AgentCoreStack(app, 'TestStack', { + spec: { + name: 'testproject', + version: 1, + managedBy: 'CDK' as const, + runtimes: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + configBundles: [], + policyEngines: [], + payments: [], + agentCoreGateways: [], + mcpRuntimeTools: [], + unassignedTargets: [], + datasets: [], + knowledgeBases: [], + }, + paymentSpec: [ + { + name: 'Payments', + authorizerType: 'AWS_IAM', + connectors: [ + { + name: 'foo_bar', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + { + name: 'foobar', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + ], + }, + { + name: 'A', + authorizerType: 'AWS_IAM', + connectors: [ + { + name: 'BC', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + ], + }, + { + name: 'AB', + authorizerType: 'AWS_IAM', + connectors: [ + { + name: 'C', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + ], + }, + ], + }); + + Template.fromStack(stack).resourceCountIs('AWS::BedrockAgentCore::PaymentConnector', 4); }); From f7fa5c5d9a81f3e0174006aec1866504f317587a Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 18:41:24 +0000 Subject: [PATCH 13/31] fix(project): collect payment credential values --- .../add/credentials/payment/index.test.ts | 75 ++++++++++ .../project/add/credentials/payment/index.ts | 9 ++ .../project/add/credentials/payment/input.ts | 129 ++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 src/handlers/project/add/credentials/payment/input.ts diff --git a/src/handlers/project/add/credentials/payment/index.test.ts b/src/handlers/project/add/credentials/payment/index.test.ts index f0e1cf6a6..c197199f3 100644 --- a/src/handlers/project/add/credentials/payment/index.test.ts +++ b/src/handlers/project/add/credentials/payment/index.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { join } from "node:path"; import { createPaymentProjectTestHarness } from "../../payment-test-support"; const { cleanup, inProject, projectSpec, run } = @@ -30,9 +31,83 @@ describe("project add credentials payment", () => { }, ]); expect(io.stderr()).toContain(`added credential '${provider.toLowerCase()}-credential'`); + const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); + const prefix = `AGENTCORE_CREDENTIAL_${provider.toUpperCase()}_CREDENTIAL`; + const suffixes = + provider === "CoinbaseCDP" + ? ["API_KEY_ID", "API_KEY_SECRET", "WALLET_SECRET"] + : ["APP_ID", "APP_SECRET", "AUTHORIZATION_PRIVATE_KEY", "AUTHORIZATION_ID"]; + for (const suffix of suffixes) { + expect(env).toContain(`${prefix}_${suffix}=\n`); + expect(io.stderr()).toContain(`Set ${prefix}_${suffix} in agentcore/.env.local`); + } }, ); + test("stores source-aware Coinbase credential values outside the project schema", async () => { + const projectRoot = await inProject(); + const apiKeySecretPath = join(projectRoot, "api-key-secret.txt"); + const walletSecretPath = join(projectRoot, "wallet-secret.txt"); + await Bun.write(apiKeySecretPath, "api-secret\n"); + await Bun.write(walletSecretPath, "wallet-secret\n"); + + await run([ + "add", + "credentials", + "payment", + "--name", + "coinbase-prod", + "--provider", + "CoinbaseCDP", + "--api-key-id", + "api-key-id", + "--api-key-secret", + `file://${apiKeySecretPath}`, + "--wallet-secret", + `file://${walletSecretPath}`, + ]); + + expect((await projectSpec(projectRoot)).credentials).toEqual([ + { + authorizerType: "PaymentCredentialProvider", + name: "coinbase-prod", + provider: "CoinbaseCDP", + }, + ]); + const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); + expect(env).toContain("AGENTCORE_CREDENTIAL_COINBASE_PROD_API_KEY_ID='api-key-id'"); + expect(env).toContain("AGENTCORE_CREDENTIAL_COINBASE_PROD_API_KEY_SECRET='api-secret'"); + expect(env).toContain("AGENTCORE_CREDENTIAL_COINBASE_PROD_WALLET_SECRET='wallet-secret'"); + }); + + test.each([ + [ + "inline Coinbase API key secret", + ["--provider", "CoinbaseCDP", "--api-key-secret", "inline-secret"], + "must come from stdin", + ], + [ + "Stripe option with Coinbase", + ["--provider", "CoinbaseCDP", "--app-id", "privy-app"], + "not valid with --provider CoinbaseCDP", + ], + [ + "Coinbase option with Stripe", + ["--provider", "StripePrivy", "--api-key-id", "coinbase-key"], + "not valid with --provider StripePrivy", + ], + ])("rejects %s without mutating the project", async (_label, flags, message) => { + const projectRoot = await inProject(); + + await expect( + run(["add", "credentials", "payment", "--name", "payment-credential", ...flags]), + ).rejects.toThrow(message); + + expect((await projectSpec(projectRoot)).credentials ?? []).toEqual([]); + const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); + expect(env).not.toContain("AGENTCORE_CREDENTIAL_PAYMENT_CREDENTIAL"); + }); + test.each([ ["missing name", ["--provider", "CoinbaseCDP"], "required option '--name"], ["missing provider", ["--name", "payment-credential"], "required option '--provider"], diff --git a/src/handlers/project/add/credentials/payment/index.ts b/src/handlers/project/add/credentials/payment/index.ts index c1a402648..ba750c93d 100644 --- a/src/handlers/project/add/credentials/payment/index.ts +++ b/src/handlers/project/add/credentials/payment/index.ts @@ -4,6 +4,7 @@ import { PaymentProviderSchema } from "../../../../../projectSchemas/payment"; import { createHandler, flag } from "../../../../../router"; import type { AddProjectResourceConfig } from "../../types"; import { addCredentialToProject } from "../shared"; +import { paymentCredentialInputFlags, resolvePaymentCredentialEnvEntries } from "./input"; export const createAddPaymentCredentialHandler = (config: AddProjectResourceConfig) => createHandler({ @@ -16,6 +17,7 @@ export const createAddPaymentCredentialHandler = (config: AddProjectResourceConf "the payment provider: CoinbaseCDP or StripePrivy", PaymentProviderSchema.optional(), ), + ...paymentCredentialInputFlags, ], handle: async (ctx, flags) => { if (!flags.name) { @@ -25,12 +27,19 @@ export const createAddPaymentCredentialHandler = (config: AddProjectResourceConf throw new InputValidationError("required option '--provider ' not specified"); } + const envEntries = await resolvePaymentCredentialEnvEntries({ + name: flags.name, + provider: flags.provider, + flags, + io: config.io, + }); await addCredentialToProject(ctx, config, { resourceConfig: { authorizerType: "PaymentCredentialProvider", name: flags.name, provider: flags.provider, }, + envEntries, }); }, }); diff --git a/src/handlers/project/add/credentials/payment/input.ts b/src/handlers/project/add/credentials/payment/input.ts new file mode 100644 index 000000000..6dfecdfba --- /dev/null +++ b/src/handlers/project/add/credentials/payment/input.ts @@ -0,0 +1,129 @@ +import z from "zod"; +import { InputValidationError } from "../../../../../errors"; +import { type AppIO, SourceResolver } from "../../../../../io"; +import type { PaymentProvider } from "../../../../../projectSchemas/payment"; +import { flag } from "../../../../../router"; +import type { EnvLocalEntry } from "../../../types"; +import { credentialEnvVarName } from "../shared"; + +export const paymentCredentialInputFlags = [ + flag("api-key-id", "Coinbase CDP API key ID", z.string().optional()), + flag( + "api-key-secret", + "Coinbase CDP API key secret (file://path or - for stdin; inline values are rejected)", + z.string().optional(), + { sensitive: true }, + ), + flag( + "wallet-secret", + "Coinbase CDP wallet secret (file://path or - for stdin; inline values are rejected)", + z.string().optional(), + { sensitive: true }, + ), + flag("app-id", "Privy application ID", z.string().optional()), + flag( + "app-secret", + "Privy application secret (file://path or - for stdin; inline values are rejected)", + z.string().optional(), + { sensitive: true }, + ), + flag( + "authorization-private-key", + "Stripe/Privy authorization private key (file://path or - for stdin; inline values are rejected)", + z.string().optional(), + { sensitive: true }, + ), + flag("authorization-id", "Stripe/Privy authorization identifier", z.string().optional()), +] as const; + +export type PaymentCredentialInputFlags = { + "api-key-id"?: string; + "api-key-secret"?: string; + "wallet-secret"?: string; + "app-id"?: string; + "app-secret"?: string; + "authorization-private-key"?: string; + "authorization-id"?: string; +}; + +const COINBASE_FLAGS = ["api-key-id", "api-key-secret", "wallet-secret"] as const; +const STRIPE_FLAGS = [ + "app-id", + "app-secret", + "authorization-private-key", + "authorization-id", +] as const; + +export function hasPaymentCredentialInput(flags: PaymentCredentialInputFlags): boolean { + return [...COINBASE_FLAGS, ...STRIPE_FLAGS].some((name) => flags[name] !== undefined); +} + +export async function resolvePaymentCredentialEnvEntries(input: { + name: string; + provider: PaymentProvider; + flags: PaymentCredentialInputFlags; + io: AppIO; +}): Promise { + const { name, provider, flags, io } = input; + const invalidFlags = (provider === "CoinbaseCDP" ? STRIPE_FLAGS : COINBASE_FLAGS).filter( + (flagName) => flags[flagName] !== undefined, + ); + if (invalidFlags.length > 0) { + throw new InputValidationError( + `${invalidFlags.map((flagName) => `--${flagName}`).join(", ")} ${ + invalidFlags.length === 1 ? "is" : "are" + } not valid with --provider ${provider}`, + ); + } + + const resolver = new SourceResolver({ stdin: io.stdin }); + if (provider === "StripePrivy") { + const appSecret = await resolver.resolveSecret("app-secret", flags["app-secret"]); + const authorizationPrivateKey = await resolver.resolveSecret( + "authorization-private-key", + flags["authorization-private-key"], + ); + return [ + { + key: credentialEnvVarName(name, "_APP_ID"), + value: flags["app-id"], + comment: `Privy application ID for payment credential provider '${name}' (set before deploy)`, + }, + { + key: credentialEnvVarName(name, "_APP_SECRET"), + value: appSecret, + comment: `Privy application secret for payment credential provider '${name}' (set before deploy)`, + }, + { + key: credentialEnvVarName(name, "_AUTHORIZATION_PRIVATE_KEY"), + value: authorizationPrivateKey, + comment: `Stripe/Privy authorization private key for payment credential provider '${name}' (set before deploy)`, + }, + { + key: credentialEnvVarName(name, "_AUTHORIZATION_ID"), + value: flags["authorization-id"], + comment: `Stripe/Privy authorization ID for payment credential provider '${name}' (set before deploy)`, + }, + ]; + } + + const apiKeySecret = await resolver.resolveSecret("api-key-secret", flags["api-key-secret"]); + const walletSecret = await resolver.resolveSecret("wallet-secret", flags["wallet-secret"]); + return [ + { + key: credentialEnvVarName(name, "_API_KEY_ID"), + value: flags["api-key-id"], + comment: `Coinbase CDP API key ID for payment credential provider '${name}' (set before deploy)`, + }, + { + key: credentialEnvVarName(name, "_API_KEY_SECRET"), + value: apiKeySecret, + comment: `Coinbase CDP API key secret for payment credential provider '${name}' (set before deploy)`, + }, + { + key: credentialEnvVarName(name, "_WALLET_SECRET"), + value: walletSecret, + comment: `Coinbase CDP wallet secret for payment credential provider '${name}' (set before deploy)`, + }, + ]; +} From 8be1082eb58329bf27b6e5939959be3a6c3b5115 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 18:43:16 +0000 Subject: [PATCH 14/31] fix(project): persist connector-created payment credentials --- .../add/payment-connector/index.test.ts | 68 ++++++++++++++++++- .../project/add/payment-connector/index.ts | 20 +++++- 2 files changed, 85 insertions(+), 3 deletions(-) diff --git a/src/handlers/project/add/payment-connector/index.test.ts b/src/handlers/project/add/payment-connector/index.test.ts index 808ae1a7d..1ede9bba0 100644 --- a/src/handlers/project/add/payment-connector/index.test.ts +++ b/src/handlers/project/add/payment-connector/index.test.ts @@ -79,6 +79,55 @@ describe("project add payment-connector", () => { credentialName: `${name}-credential`, }, ]); + const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); + const prefix = `AGENTCORE_CREDENTIAL_${name.toUpperCase()}_CREDENTIAL`; + const suffixes = + provider === "CoinbaseCDP" + ? ["API_KEY_ID", "API_KEY_SECRET", "WALLET_SECRET"] + : ["APP_ID", "APP_SECRET", "AUTHORIZATION_PRIVATE_KEY", "AUTHORIZATION_ID"]; + for (const suffix of suffixes) { + expect(env).toContain(`${prefix}_${suffix}=\n`); + } + }); + + test("atomically stores supplied payment credential values with a connector", async () => { + const projectRoot = await inProject(); + await addManager(); + const appSecretPath = join(projectRoot, "app-secret.txt"); + const privateKeyPath = join(projectRoot, "private-key.txt"); + await Bun.write(appSecretPath, "app-secret\n"); + await Bun.write(privateKeyPath, "private-key\n"); + + await run([ + "add", + "payment-connector", + "--manager", + "payments", + "--name", + "stripe", + "--create-credential", + "stripe-credential", + "--provider", + "StripePrivy", + "--app-id", + "app-id", + "--app-secret", + `file://${appSecretPath}`, + "--authorization-private-key", + `file://${privateKeyPath}`, + "--authorization-id", + "authorization-id", + ]); + + const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); + expect(env).toContain("AGENTCORE_CREDENTIAL_STRIPE_CREDENTIAL_APP_ID='app-id'"); + expect(env).toContain("AGENTCORE_CREDENTIAL_STRIPE_CREDENTIAL_APP_SECRET='app-secret'"); + expect(env).toContain( + "AGENTCORE_CREDENTIAL_STRIPE_CREDENTIAL_AUTHORIZATION_PRIVATE_KEY='private-key'", + ); + expect(env).toContain( + "AGENTCORE_CREDENTIAL_STRIPE_CREDENTIAL_AUTHORIZATION_ID='authorization-id'", + ); }); test("adds Quick Create without a payment credential", async () => { @@ -162,7 +211,20 @@ describe("project add payment-connector", () => { "--provider", "CoinbaseCDP", ], - "--provider is valid only with --create-credential", + "valid only with --create-credential", + ], + [ + "credential values outside create mode", + [ + "--manager", + "payments", + "--name", + "connector", + "--quick-create", + "--api-key-id", + "api-key-id", + ], + "valid only with --create-credential", ], ])("rejects %s", async (_label, flags, message) => { const projectRoot = await inProject(); @@ -255,6 +317,8 @@ describe("project add payment-connector", () => { const spec = await projectSpec(projectRoot); expect(spec.credentials).toEqual([]); expect(spec.payments[0].connectors).toHaveLength(1); + const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); + expect(env).not.toContain("AGENTCORE_CREDENTIAL_ORPHAN"); }); test("leaves no credential or connector after whole-project validation fails", async () => { @@ -285,6 +349,8 @@ describe("project add payment-connector", () => { }, ]); expect(spec.payments[0].connectors).toEqual([]); + const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); + expect(env).not.toContain("AGENTCORE_CREDENTIAL_SERVICE_KEY_API_KEY_ID"); }); test("rejects provider mismatches in complete project data", async () => { diff --git a/src/handlers/project/add/payment-connector/index.ts b/src/handlers/project/add/payment-connector/index.ts index 7594de1e5..3293140ff 100644 --- a/src/handlers/project/add/payment-connector/index.ts +++ b/src/handlers/project/add/payment-connector/index.ts @@ -4,6 +4,11 @@ import type { PaymentCredential } from "../../../../projectSchemas/credential"; import { PaymentProviderSchema } from "../../../../projectSchemas/payment"; import { createHandler, flag, ProjectKey } from "../../../../router"; import type { AddProjectResourceConfig } from "../types"; +import { + hasPaymentCredentialInput, + paymentCredentialInputFlags, + resolvePaymentCredentialEnvEntries, +} from "../credentials/payment/input"; export const createAddPaymentConnectorHandler = (config: AddProjectResourceConfig) => createHandler({ @@ -24,6 +29,7 @@ export const createAddPaymentConnectorHandler = (config: AddProjectResourceConfi PaymentProviderSchema.optional(), ), flag("quick-create", "create a CoinbaseCDP connector through Quick Create", z.boolean()), + ...paymentCredentialInputFlags, ], handle: async (ctx, flags) => { if (!flags.manager) { @@ -46,12 +52,15 @@ export const createAddPaymentConnectorHandler = (config: AddProjectResourceConfi if (flags["create-credential"] && !flags.provider) { throw new InputValidationError("--create-credential requires --provider"); } - if (!flags["create-credential"] && flags.provider) { - throw new InputValidationError("--provider is valid only with --create-credential"); + if (!flags["create-credential"] && (flags.provider || hasPaymentCredentialInput(flags))) { + throw new InputValidationError( + "--provider and payment credential options are valid only with --create-credential", + ); } const project = ctx.require(ProjectKey); let credentialConfig: PaymentCredential | undefined; + let envEntries; let provider: PaymentCredential["provider"]; let credentialName: string | undefined; @@ -65,6 +74,12 @@ export const createAddPaymentConnectorHandler = (config: AddProjectResourceConfi name: credentialName, provider, }; + envEntries = await resolvePaymentCredentialEnvEntries({ + name: credentialName, + provider, + flags, + io: config.io, + }); } else { credentialName = flags.credential!; const credential = project.spec.credentials.find( @@ -98,6 +113,7 @@ export const createAddPaymentConnectorHandler = (config: AddProjectResourceConfi credentialName: credentialName!, }, credentialConfig, + envEntries, })) { config.io.stderr.write(`${event.message}\n`); } From 0fc5ba32eb291991756ff7962987f6052d8a50b0 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 18:53:56 +0000 Subject: [PATCH 15/31] fix(project): require explicit payment credentials --- .../add/payment-connector/index.test.ts | 158 +----------------- .../project/add/payment-connector/index.ts | 56 +------ 2 files changed, 5 insertions(+), 209 deletions(-) diff --git a/src/handlers/project/add/payment-connector/index.test.ts b/src/handlers/project/add/payment-connector/index.test.ts index 1ede9bba0..9e298e456 100644 --- a/src/handlers/project/add/payment-connector/index.test.ts +++ b/src/handlers/project/add/payment-connector/index.test.ts @@ -44,92 +44,6 @@ describe("project add payment-connector", () => { ]); }); - test.each([ - ["CoinbaseCDP", "coinbase"], - ["StripePrivy", "stripe"], - ] as const)("atomically creates a %s credential and connector", async (provider, name) => { - const projectRoot = await inProject(); - await addManager(); - - await run([ - "add", - "payment-connector", - "--manager", - "payments", - "--name", - name, - "--create-credential", - `${name}-credential`, - "--provider", - provider, - ]); - - const spec = await projectSpec(projectRoot); - expect(spec.credentials).toEqual([ - { - authorizerType: "PaymentCredentialProvider", - name: `${name}-credential`, - provider, - }, - ]); - expect(spec.payments[0].connectors).toEqual([ - { - name, - provider, - credentialName: `${name}-credential`, - }, - ]); - const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); - const prefix = `AGENTCORE_CREDENTIAL_${name.toUpperCase()}_CREDENTIAL`; - const suffixes = - provider === "CoinbaseCDP" - ? ["API_KEY_ID", "API_KEY_SECRET", "WALLET_SECRET"] - : ["APP_ID", "APP_SECRET", "AUTHORIZATION_PRIVATE_KEY", "AUTHORIZATION_ID"]; - for (const suffix of suffixes) { - expect(env).toContain(`${prefix}_${suffix}=\n`); - } - }); - - test("atomically stores supplied payment credential values with a connector", async () => { - const projectRoot = await inProject(); - await addManager(); - const appSecretPath = join(projectRoot, "app-secret.txt"); - const privateKeyPath = join(projectRoot, "private-key.txt"); - await Bun.write(appSecretPath, "app-secret\n"); - await Bun.write(privateKeyPath, "private-key\n"); - - await run([ - "add", - "payment-connector", - "--manager", - "payments", - "--name", - "stripe", - "--create-credential", - "stripe-credential", - "--provider", - "StripePrivy", - "--app-id", - "app-id", - "--app-secret", - `file://${appSecretPath}`, - "--authorization-private-key", - `file://${privateKeyPath}`, - "--authorization-id", - "authorization-id", - ]); - - const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); - expect(env).toContain("AGENTCORE_CREDENTIAL_STRIPE_CREDENTIAL_APP_ID='app-id'"); - expect(env).toContain("AGENTCORE_CREDENTIAL_STRIPE_CREDENTIAL_APP_SECRET='app-secret'"); - expect(env).toContain( - "AGENTCORE_CREDENTIAL_STRIPE_CREDENTIAL_AUTHORIZATION_PRIVATE_KEY='private-key'", - ); - expect(env).toContain( - "AGENTCORE_CREDENTIAL_STRIPE_CREDENTIAL_AUTHORIZATION_ID='authorization-id'", - ); - }); - test("adds Quick Create without a payment credential", async () => { const projectRoot = await inProject(); await addManager(); @@ -195,37 +109,6 @@ describe("project add payment-connector", () => { ], "specify exactly one", ], - [ - "create without provider", - ["--manager", "payments", "--name", "connector", "--create-credential", "new-credential"], - "--create-credential requires --provider", - ], - [ - "provider outside create mode", - [ - "--manager", - "payments", - "--name", - "connector", - "--quick-create", - "--provider", - "CoinbaseCDP", - ], - "valid only with --create-credential", - ], - [ - "credential values outside create mode", - [ - "--manager", - "payments", - "--name", - "connector", - "--quick-create", - "--api-key-id", - "api-key-id", - ], - "valid only with --create-credential", - ], ])("rejects %s", async (_label, flags, message) => { const projectRoot = await inProject(); await addManager(); @@ -286,7 +169,7 @@ describe("project add payment-connector", () => { expect((await projectSpec(projectRoot)).payments[0].connectors).toEqual([]); }); - test("rejects duplicate connector names without creating an orphan credential", async () => { + test("rejects duplicate connector names", async () => { const projectRoot = await inProject(); await addManager(); await run([ @@ -307,50 +190,13 @@ describe("project add payment-connector", () => { "payments", "--name", "connector", - "--create-credential", - "orphan", - "--provider", - "CoinbaseCDP", + "--quick-create", ]), ).rejects.toThrow("already exists"); const spec = await projectSpec(projectRoot); expect(spec.credentials).toEqual([]); expect(spec.payments[0].connectors).toHaveLength(1); - const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); - expect(env).not.toContain("AGENTCORE_CREDENTIAL_ORPHAN"); - }); - - test("leaves no credential or connector after whole-project validation fails", async () => { - const projectRoot = await inProject(); - await addManager(); - await run(["add", "credentials", "api-key", "--name", "service-key"]); - - await expect( - run([ - "add", - "payment-connector", - "--manager", - "payments", - "--name", - "connector", - "--create-credential", - "service_key", - "--provider", - "CoinbaseCDP", - ]), - ).rejects.toThrow("environment variable"); - - const spec = await projectSpec(projectRoot); - expect(spec.credentials).toEqual([ - { - authorizerType: "ApiKeyCredentialProvider", - name: "service-key", - }, - ]); - expect(spec.payments[0].connectors).toEqual([]); - const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); - expect(env).not.toContain("AGENTCORE_CREDENTIAL_SERVICE_KEY_API_KEY_ID"); }); test("rejects provider mismatches in complete project data", async () => { diff --git a/src/handlers/project/add/payment-connector/index.ts b/src/handlers/project/add/payment-connector/index.ts index 3293140ff..9c90f7baf 100644 --- a/src/handlers/project/add/payment-connector/index.ts +++ b/src/handlers/project/add/payment-connector/index.ts @@ -1,14 +1,7 @@ import z from "zod"; import { InputValidationError } from "../../../../errors"; -import type { PaymentCredential } from "../../../../projectSchemas/credential"; -import { PaymentProviderSchema } from "../../../../projectSchemas/payment"; import { createHandler, flag, ProjectKey } from "../../../../router"; import type { AddProjectResourceConfig } from "../types"; -import { - hasPaymentCredentialInput, - paymentCredentialInputFlags, - resolvePaymentCredentialEnvEntries, -} from "../credentials/payment/input"; export const createAddPaymentConnectorHandler = (config: AddProjectResourceConfig) => createHandler({ @@ -18,18 +11,7 @@ export const createAddPaymentConnectorHandler = (config: AddProjectResourceConfi flag("manager", "the parent payment manager", z.string().optional()), flag("name", "the payment connector name", z.string().optional()), flag("credential", "an existing payment credential to reuse", z.string().optional()), - flag( - "create-credential", - "a new payment credential to create with the connector", - z.string().optional(), - ), - flag( - "provider", - "provider for a newly created payment credential", - PaymentProviderSchema.optional(), - ), flag("quick-create", "create a CoinbaseCDP connector through Quick Create", z.boolean()), - ...paymentCredentialInputFlags, ], handle: async (ctx, flags) => { if (!flags.manager) { @@ -39,47 +21,17 @@ export const createAddPaymentConnectorHandler = (config: AddProjectResourceConfi throw new InputValidationError("required option '--name ' not specified"); } - const modes = [ - flags.credential !== undefined, - flags["create-credential"] !== undefined, - flags["quick-create"], - ].filter(Boolean); + const modes = [flags.credential !== undefined, flags["quick-create"]].filter(Boolean); if (modes.length !== 1) { - throw new InputValidationError( - "specify exactly one of '--credential', '--create-credential', or '--quick-create'", - ); - } - if (flags["create-credential"] && !flags.provider) { - throw new InputValidationError("--create-credential requires --provider"); - } - if (!flags["create-credential"] && (flags.provider || hasPaymentCredentialInput(flags))) { - throw new InputValidationError( - "--provider and payment credential options are valid only with --create-credential", - ); + throw new InputValidationError("specify exactly one of '--credential' or '--quick-create'"); } const project = ctx.require(ProjectKey); - let credentialConfig: PaymentCredential | undefined; - let envEntries; - let provider: PaymentCredential["provider"]; + let provider: "CoinbaseCDP" | "StripePrivy"; let credentialName: string | undefined; if (flags["quick-create"]) { provider = "CoinbaseCDP"; - } else if (flags["create-credential"]) { - provider = flags.provider!; - credentialName = flags["create-credential"]; - credentialConfig = { - authorizerType: "PaymentCredentialProvider", - name: credentialName, - provider, - }; - envEntries = await resolvePaymentCredentialEnvEntries({ - name: credentialName, - provider, - flags, - io: config.io, - }); } else { credentialName = flags.credential!; const credential = project.spec.credentials.find( @@ -112,8 +64,6 @@ export const createAddPaymentConnectorHandler = (config: AddProjectResourceConfi provider, credentialName: credentialName!, }, - credentialConfig, - envEntries, })) { config.io.stderr.write(`${event.message}\n`); } From 1f5888ff2e4f476f4df66a4a663e286760f5fdb0 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 21:17:56 +0000 Subject: [PATCH 16/31] style(project): format payment connector validation --- src/core/project/manager.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 7242bfd54..96a4d09f3 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -208,9 +208,7 @@ export class FsProjectManager implements ProjectManager { `payment manager '${input.managerName}' does not exist in this project`, ); } - if ( - manager.connectors.some((connector) => connector.name === input.resourceConfig.name) - ) { + if (manager.connectors.some((connector) => connector.name === input.resourceConfig.name)) { throw new InputValidationError( `a payment connector with name '${input.resourceConfig.name}' already exists in manager '${input.managerName}'`, ); From 519eb6b197647b40f89f28e6e2e610ed5220e24a Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 21:48:07 +0000 Subject: [PATCH 17/31] refactor(project): centralize credential environment keys --- src/projectSchemas/credential.test.ts | 49 ++++++++++++++++++++++++++- src/projectSchemas/credential.ts | 24 +++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/src/projectSchemas/credential.test.ts b/src/projectSchemas/credential.test.ts index 5639ce669..f27b4faaf 100644 --- a/src/projectSchemas/credential.test.ts +++ b/src/projectSchemas/credential.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "bun:test"; -import { CredentialSchema } from "./credential"; +import { CredentialSchema, credentialEnvironmentVariableNames } from "./credential"; const DISCOVERY_URL = "https://idp.example.com/.well-known/openid-configuration"; const SECRET_REF = { @@ -83,6 +83,53 @@ describe("credential schema", () => { expect(result.data).toMatchObject({ vendor: "CustomOauth2" }); }); + it("derives the environment variables used by each credential type", () => { + const environmentNames = (value: Record) => + credentialEnvironmentVariableNames(CredentialSchema.parse(value)); + + expect( + environmentNames({ authorizerType: "ApiKeyCredentialProvider", name: "service-key" }), + ).toEqual(["AGENTCORE_CREDENTIAL_SERVICE_KEY"]); + expect( + environmentNames({ + authorizerType: "ApiKeyCredentialProvider", + name: "service-key", + secretRef: SECRET_REF, + }), + ).toEqual([]); + expect( + environmentNames({ + authorizerType: "OAuthCredentialProvider", + name: "github", + vendor: "GithubOauth2", + providerConfig: { githubOauth2ProviderConfig: { clientId: "client-1" } }, + }), + ).toEqual(["AGENTCORE_CREDENTIAL_GITHUB_CLIENT_SECRET"]); + expect( + environmentNames({ + authorizerType: "PaymentCredentialProvider", + name: "coinbase", + provider: "CoinbaseCDP", + }), + ).toEqual([ + "AGENTCORE_CREDENTIAL_COINBASE_API_KEY_ID", + "AGENTCORE_CREDENTIAL_COINBASE_API_KEY_SECRET", + "AGENTCORE_CREDENTIAL_COINBASE_WALLET_SECRET", + ]); + expect( + environmentNames({ + authorizerType: "PaymentCredentialProvider", + name: "stripe", + provider: "StripePrivy", + }), + ).toEqual([ + "AGENTCORE_CREDENTIAL_STRIPE_APP_ID", + "AGENTCORE_CREDENTIAL_STRIPE_APP_SECRET", + "AGENTCORE_CREDENTIAL_STRIPE_AUTHORIZATION_PRIVATE_KEY", + "AGENTCORE_CREDENTIAL_STRIPE_AUTHORIZATION_ID", + ]); + }); + it.each<[string, Record, RegExp]>([ [ "a vendored OAuth credential without a provider config", diff --git a/src/projectSchemas/credential.ts b/src/projectSchemas/credential.ts index 49db8a4d6..5fe17526b 100644 --- a/src/projectSchemas/credential.ts +++ b/src/projectSchemas/credential.ts @@ -113,3 +113,27 @@ export const CredentialSchema = z.discriminatedUnion("authorizerType", [ PaymentCredentialSchema, ]); export type Credential = z.infer; + +/** Derives the .env.local variable name used for credential material. */ +export function credentialEnvVarName(credentialName: string, suffix = ""): string { + return `AGENTCORE_CREDENTIAL_${credentialName.replace(/-/g, "_").toUpperCase()}${suffix}`; +} + +/** Returns every .env.local key a credential reserves when it does not use an external secret. */ +export function credentialEnvironmentVariableNames(credential: Credential): string[] { + switch (credential.authorizerType) { + case "ApiKeyCredentialProvider": + return credential.secretRef ? [] : [credentialEnvVarName(credential.name)]; + case "OAuthCredentialProvider": + return credential.clientSecretRef + ? [] + : [credentialEnvVarName(credential.name, "_CLIENT_SECRET")]; + case "PaymentCredentialProvider": { + const suffixes = + credential.provider === "CoinbaseCDP" + ? ["_API_KEY_ID", "_API_KEY_SECRET", "_WALLET_SECRET"] + : ["_APP_ID", "_APP_SECRET", "_AUTHORIZATION_PRIVATE_KEY", "_AUTHORIZATION_ID"]; + return suffixes.map((suffix) => credentialEnvVarName(credential.name, suffix)); + } + } +} From 7e1468b6d48225309ed7a242b3a6c6f9dc47652f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 21:48:12 +0000 Subject: [PATCH 18/31] fix(project): reject credential environment key collisions --- .../add/credentials/payment/index.test.ts | 6 ++-- .../project/add/credentials/shared.ts | 32 +++++++++++-------- src/projectSchemas/project.test.ts | 25 +++++++++++++-- src/projectSchemas/project.ts | 27 ++++++++-------- 4 files changed, 57 insertions(+), 33 deletions(-) diff --git a/src/handlers/project/add/credentials/payment/index.test.ts b/src/handlers/project/add/credentials/payment/index.test.ts index c197199f3..3d09472e3 100644 --- a/src/handlers/project/add/credentials/payment/index.test.ts +++ b/src/handlers/project/add/credentials/payment/index.test.ts @@ -134,12 +134,12 @@ describe("project add credentials payment", () => { expect((await projectSpec(projectRoot)).credentials).toHaveLength(1); }); - test("rejects names that collide after environment normalization", async () => { + test("rejects credentials that generate overlapping environment variables", async () => { const projectRoot = await inProject(); - await run(["add", "credentials", "api-key", "--name", "service-key"]); + await run(["add", "credentials", "api-key", "--name", "stripe_app_id"]); await expect( - run(["add", "credentials", "payment", "--name", "service_key", "--provider", "CoinbaseCDP"]), + run(["add", "credentials", "payment", "--name", "stripe", "--provider", "StripePrivy"]), ).rejects.toThrow("environment variable"); expect((await projectSpec(projectRoot)).credentials).toHaveLength(1); diff --git a/src/handlers/project/add/credentials/shared.ts b/src/handlers/project/add/credentials/shared.ts index a9a1445c9..a36abd506 100644 --- a/src/handlers/project/add/credentials/shared.ts +++ b/src/handlers/project/add/credentials/shared.ts @@ -3,11 +3,12 @@ import { InputValidationError } from "../../../../errors"; import { parseSecretReference } from "../../../identity/parser"; import type { AddProjectResourceConfig } from "../types"; import type { AddResourceInput } from "../../types"; +import { + credentialEnvironmentVariableNames, + credentialEnvVarName, +} from "../../../../projectSchemas/credential"; -/** Derives the .env.local variable name a credential's secret is stored under. */ -export function credentialEnvVarName(credentialName: string, suffix = ""): string { - return `AGENTCORE_CREDENTIAL_${credentialName.replace(/-/g, "_").toUpperCase()}${suffix}`; -} +export { credentialEnvVarName }; /** Parses a secret-reference flag, rejecting a directly supplied secret alongside it. */ export function parseExclusiveSecretRef( @@ -31,18 +32,21 @@ export async function addCredentialToProject( ): Promise { const project = ctx.require(ProjectKey); - // Two names that differ only by '-' vs '_' derive the same environment - // variable, which would silently reuse one secret for both providers. const newName = input.resourceConfig.name; - const clash = project.spec.credentials.find( - (existing) => - existing.name !== newName && - credentialEnvVarName(existing.name) === credentialEnvVarName(newName), - ); - if (clash) { + const existingEnvironmentNames = new Map(); + for (const credential of project.spec.credentials) { + for (const environmentName of credentialEnvironmentVariableNames(credential)) { + existingEnvironmentNames.set(environmentName, credential.name); + } + } + const conflictingEnvironmentName = input.envEntries?.find((entry) => + existingEnvironmentNames.has(entry.key), + )?.key; + if (conflictingEnvironmentName) { + const conflictingName = existingEnvironmentNames.get(conflictingEnvironmentName)!; throw new InputValidationError( - `credential '${newName}' and '${clash.name}' derive the same environment variable name; ` + - "choose a name that differs by more than '-' and '_'", + `credential '${newName}' and '${conflictingName}' derive the same environment variable ` + + `'${conflictingEnvironmentName}'; choose credential names that produce distinct environment variables`, ); } diff --git a/src/projectSchemas/project.test.ts b/src/projectSchemas/project.test.ts index 705f5eb56..91a32cb0d 100644 --- a/src/projectSchemas/project.test.ts +++ b/src/projectSchemas/project.test.ts @@ -343,10 +343,27 @@ describe("project custom validation", () => { ...minimalProject, credentials: [ { authorizerType: "ApiKeyCredentialProvider", name: "service-key" }, + { authorizerType: "ApiKeyCredentialProvider", name: "service_key" }, + ], + }); + + expect(result.success).toBe(false); + if (!result.success) { + expect( + result.error.issues.some((issue) => issue.message.includes("environment variable")), + ).toBe(true); + } + }); + + it("rejects different credential types that derive the same environment variable", () => { + const result = ProjectSpecSchema.safeParse({ + ...minimalProject, + credentials: [ + { authorizerType: "ApiKeyCredentialProvider", name: "stripe_app_id" }, { authorizerType: "PaymentCredentialProvider", - name: "service_key", - provider: "CoinbaseCDP", + name: "stripe", + provider: "StripePrivy", }, ], }); @@ -354,7 +371,9 @@ describe("project custom validation", () => { expect(result.success).toBe(false); if (!result.success) { expect( - result.error.issues.some((issue) => issue.message.includes("environment variable")), + result.error.issues.some((issue) => + issue.message.includes("AGENTCORE_CREDENTIAL_STRIPE_APP_ID"), + ), ).toBe(true); } }); diff --git a/src/projectSchemas/project.ts b/src/projectSchemas/project.ts index 04c7c8d66..cd438115d 100644 --- a/src/projectSchemas/project.ts +++ b/src/projectSchemas/project.ts @@ -8,7 +8,7 @@ import { } from "./gateway"; import { ABTestSchema } from "./ab-test"; import { ConfigBundleSchema } from "./config-bundle"; -import { CredentialSchema } from "./credential"; +import { CredentialSchema, credentialEnvironmentVariableNames } from "./credential"; import { DatasetSchema } from "./dataset"; import { EvaluatorSchema } from "./evaluator"; import { HarnessRegistryEntrySchema } from "./harness"; @@ -251,18 +251,19 @@ export const ProjectSpecSchema = z } const credentialEnvironmentNames = new Map(); for (const [credentialIndex, credential] of spec.credentials.entries()) { - const environmentName = toEnvironmentName(credential.name); - const conflictingName = credentialEnvironmentNames.get(environmentName); - if (conflictingName) { - ctx.addIssue({ - code: "custom", - message: - `Credential "${credential.name}" and "${conflictingName}" derive the same environment variable name; ` + - "choose names that differ by more than '-' and '_'", - path: ["credentials", credentialIndex, "name"], - }); - } else { - credentialEnvironmentNames.set(environmentName, credential.name); + for (const environmentName of credentialEnvironmentVariableNames(credential)) { + const conflictingName = credentialEnvironmentNames.get(environmentName); + if (conflictingName) { + ctx.addIssue({ + code: "custom", + message: + `Credential "${credential.name}" and "${conflictingName}" derive the same environment variable "${environmentName}"; ` + + "choose credential names that produce distinct environment variables", + path: ["credentials", credentialIndex, "name"], + }); + } else { + credentialEnvironmentNames.set(environmentName, credential.name); + } } } const paymentManagerEnvironmentNames = new Map(); From 4f952237df253f6d2c7513a5da459adf50e8b2b9 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 21:48:25 +0000 Subject: [PATCH 19/31] fix(project): separate payment CDK identity namespaces --- src/assets/cdk/lib/cdk-stack.ts | 18 +++++++++++------- src/assets/cdk/test/cdk.test.ts | 16 +++++++++++++--- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index 0a51e6af8..4ee14258b 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -76,8 +76,12 @@ export interface AgentCoreStackProps extends StackProps { paymentSpec?: PaymentSpec[]; } +function paymentManagerCdkId(managerName: string): string { + return `PaymentManagerM${managerName.length}${managerName}`; +} + function paymentConnectorCdkId(managerName: string, connectorName: string): string { - return `PaymentM${managerName.length}${managerName}C${connectorName.length}${connectorName}`; + return `PaymentConnectorM${managerName.length}${managerName}C${connectorName.length}${connectorName}`; } /** @@ -139,8 +143,8 @@ export class AgentCoreStack extends Stack { // Create payment infrastructure via CFN constructs if (paymentSpec && paymentSpec.length > 0) { for (const payment of paymentSpec) { - const mgrId = payment.name; - const manager = new AgentCorePaymentManager(this, `Payment${mgrId}`, { + const managerCdkId = paymentManagerCdkId(payment.name); + const manager = new AgentCorePaymentManager(this, managerCdkId, { projectName: spec.name, name: payment.name, authorizerType: payment.authorizerType, @@ -258,16 +262,16 @@ export class AgentCoreStack extends Stack { } // CFN Outputs for post-deploy state parsing - new CfnOutput(this, `Payment${mgrId}ManagerArn`, { + new CfnOutput(this, `${managerCdkId}Arn`, { value: manager.paymentManagerArn, }); - new CfnOutput(this, `Payment${mgrId}ManagerId`, { + new CfnOutput(this, `${managerCdkId}Id`, { value: manager.paymentManagerId, }); - new CfnOutput(this, `Payment${mgrId}ProcessPaymentRoleArn`, { + new CfnOutput(this, `${managerCdkId}ProcessPaymentRoleArn`, { value: manager.processPaymentRoleArn, }); - new CfnOutput(this, `Payment${mgrId}ResourceRetrievalRoleArn`, { + new CfnOutput(this, `${managerCdkId}ResourceRetrievalRoleArn`, { value: manager.resourceRetrievalRoleArn, }); } diff --git a/src/assets/cdk/test/cdk.test.ts b/src/assets/cdk/test/cdk.test.ts index 53a586301..8e086edbc 100644 --- a/src/assets/cdk/test/cdk.test.ts +++ b/src/assets/cdk/test/cdk.test.ts @@ -98,10 +98,10 @@ test('AgentCoreStack synthesizes manual and Quick Create payment connectors', () ProvisionMode: 'QUICK_CREATE', CredentialProviderConfigurations: [], }); - template.hasOutput('PaymentM8PaymentsC5QuickAuthorizationUrl', {}); + template.hasOutput('PaymentConnectorM8PaymentsC5QuickAuthorizationUrl', {}); }); -test('AgentCoreStack preserves complete payment connector identities', () => { +test('AgentCoreStack preserves complete and type-distinct payment resource identities', () => { const app = new cdk.App(); const stack = new AgentCoreStack(app, 'TestStack', { spec: { @@ -143,6 +143,11 @@ test('AgentCoreStack preserves complete payment connector identities', () => { name: 'A', authorizerType: 'AWS_IAM', connectors: [ + { + name: 'B', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, { name: 'BC', provider: 'CoinbaseCDP', @@ -161,8 +166,13 @@ test('AgentCoreStack preserves complete payment connector identities', () => { }, ], }, + { + name: 'M1AC1B', + authorizerType: 'AWS_IAM', + connectors: [], + }, ], }); - Template.fromStack(stack).resourceCountIs('AWS::BedrockAgentCore::PaymentConnector', 4); + Template.fromStack(stack).resourceCountIs('AWS::BedrockAgentCore::PaymentConnector', 5); }); From f142215d3c474bbe00993b0aca5fb3160adfb95e Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 23:09:12 +0000 Subject: [PATCH 20/31] fix(project): remove generated CDK capability marker --- src/assets/cdk/package.json | 5 ---- src/core/project/manager.tsx | 22 ----------------- .../add/payment-connector/index.test.ts | 24 ------------------- 3 files changed, 51 deletions(-) diff --git a/src/assets/cdk/package.json b/src/assets/cdk/package.json index 8a811a874..407a29fbd 100644 --- a/src/assets/cdk/package.json +++ b/src/assets/cdk/package.json @@ -1,11 +1,6 @@ { "name": "agentcore-cdk-app", "version": "0.1.0", - "agentcoreProject": { - "capabilities": [ - "payment-connector-quick-create" - ] - }, "bin": { "cdk": "dist/bin/cdk.js" }, diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 96a4d09f3..cd3921a12 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -50,14 +50,6 @@ import type { TemplateRenderer } from "./templates/types"; import { HandlebarsTemplateRenderer } from "./templates/renderer"; const TARGETS_EXAMPLE = '[{ "name": "default", "account": "111122223333", "region": "us-east-1" }]'; -const PAYMENT_CONNECTOR_QUICK_CREATE_CAPABILITY = "payment-connector-quick-create"; -const GeneratedCdkPackageSchema = z.object({ - agentcoreProject: z - .object({ - capabilities: z.array(z.string()), - }) - .optional(), -}); type ProjectManagerConfig = { logger: Logger; @@ -332,20 +324,6 @@ export class FsProjectManager implements ProjectManager { break; } case "payment-connector": { - if (input.resourceConfig.provisionMode === "QUICK_CREATE") { - const cdkPackagePath = join(project.rootPath, "agentcore", "cdk", "package.json"); - const cdkPackage = await this.json.read(cdkPackagePath, GeneratedCdkPackageSchema); - if ( - !cdkPackage.agentcoreProject?.capabilities.includes( - PAYMENT_CONNECTOR_QUICK_CREATE_CAPABILITY, - ) - ) { - throw new InputValidationError( - "Quick Create requires current generated CDK assets; update 'agentcore/cdk' from a project created by this CLI version, then retry", - ); - } - } - const manager = projectSpec.payments!.find( (candidate) => candidate.name === input.managerName, )!; diff --git a/src/handlers/project/add/payment-connector/index.test.ts b/src/handlers/project/add/payment-connector/index.test.ts index 9e298e456..a4c9ab704 100644 --- a/src/handlers/project/add/payment-connector/index.test.ts +++ b/src/handlers/project/add/payment-connector/index.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { join } from "node:path"; import { createPaymentProjectTestHarness } from "../payment-test-support"; const { cleanup, inProject, projectSpec, run, writeProjectSpec } = @@ -69,29 +68,6 @@ describe("project add payment-connector", () => { ]); }); - test("rejects Quick Create when the project has legacy generated CDK assets", async () => { - const projectRoot = await inProject(); - await addManager(); - const packagePath = join(projectRoot, "agentcore", "cdk", "package.json"); - const packageJson = await Bun.file(packagePath).json(); - delete packageJson.agentcoreProject; - await Bun.write(packagePath, JSON.stringify(packageJson, undefined, 2)); - - await expect( - run([ - "add", - "payment-connector", - "--manager", - "payments", - "--name", - "coinbase", - "--quick-create", - ]), - ).rejects.toThrow("generated CDK assets"); - - expect((await projectSpec(projectRoot)).payments[0].connectors).toEqual([]); - }); - test.each([ ["missing manager", ["--name", "connector", "--quick-create"], "required option '--manager"], ["missing name", ["--manager", "payments", "--quick-create"], "required option '--name"], From d1948b841266a4147fd57c7bdf1df6c711628b08 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 23:31:03 +0000 Subject: [PATCH 21/31] fix(project): validate payment credential keys --- .../add/credentials/payment/index.test.ts | 42 +++++++++++++++-- .../project/add/credentials/payment/input.ts | 24 +++++++++- .../credentials/payment/validation.test.ts | 31 +++++++++++++ .../add/credentials/payment/validation.ts | 45 +++++++++++++++++++ 4 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 src/handlers/project/add/credentials/payment/validation.test.ts create mode 100644 src/handlers/project/add/credentials/payment/validation.ts diff --git a/src/handlers/project/add/credentials/payment/index.test.ts b/src/handlers/project/add/credentials/payment/index.test.ts index 3d09472e3..d83e35f48 100644 --- a/src/handlers/project/add/credentials/payment/index.test.ts +++ b/src/handlers/project/add/credentials/payment/index.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; +import { generateKeyPairSync } from "node:crypto"; import { join } from "node:path"; import { createPaymentProjectTestHarness } from "../../payment-test-support"; @@ -48,8 +49,14 @@ describe("project add credentials payment", () => { const projectRoot = await inProject(); const apiKeySecretPath = join(projectRoot, "api-key-secret.txt"); const walletSecretPath = join(projectRoot, "wallet-secret.txt"); - await Bun.write(apiKeySecretPath, "api-secret\n"); - await Bun.write(walletSecretPath, "wallet-secret\n"); + const apiKeySecret = generateKeyPairSync("ed25519") + .privateKey.export({ type: "pkcs8", format: "der" }) + .toString("base64"); + const walletSecret = generateKeyPairSync("ec", { namedCurve: "P-256" }) + .privateKey.export({ type: "pkcs8", format: "der" }) + .toString("base64"); + await Bun.write(apiKeySecretPath, `${apiKeySecret}\n`); + await Bun.write(walletSecretPath, `${walletSecret}\n`); await run([ "add", @@ -76,8 +83,35 @@ describe("project add credentials payment", () => { ]); const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); expect(env).toContain("AGENTCORE_CREDENTIAL_COINBASE_PROD_API_KEY_ID='api-key-id'"); - expect(env).toContain("AGENTCORE_CREDENTIAL_COINBASE_PROD_API_KEY_SECRET='api-secret'"); - expect(env).toContain("AGENTCORE_CREDENTIAL_COINBASE_PROD_WALLET_SECRET='wallet-secret'"); + expect(env).toContain(`AGENTCORE_CREDENTIAL_COINBASE_PROD_API_KEY_SECRET='${apiKeySecret}'`); + expect(env).toContain(`AGENTCORE_CREDENTIAL_COINBASE_PROD_WALLET_SECRET='${walletSecret}'`); + }); + + test("normalizes the documented Stripe authorization key prefix", async () => { + const projectRoot = await inProject(); + const privateKeyPath = join(projectRoot, "private-key.txt"); + const privateKey = generateKeyPairSync("ec", { namedCurve: "P-256" }) + .privateKey.export({ type: "pkcs8", format: "der" }) + .toString("base64"); + await Bun.write(privateKeyPath, `wallet-auth:${privateKey}\n`); + + await run([ + "add", + "credentials", + "payment", + "--name", + "stripe-prod", + "--provider", + "StripePrivy", + "--authorization-private-key", + `file://${privateKeyPath}`, + ]); + + const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); + expect(env).toContain( + `AGENTCORE_CREDENTIAL_STRIPE_PROD_AUTHORIZATION_PRIVATE_KEY='${privateKey}'`, + ); + expect(env).not.toContain("wallet-auth:"); }); test.each([ diff --git a/src/handlers/project/add/credentials/payment/input.ts b/src/handlers/project/add/credentials/payment/input.ts index 6dfecdfba..1503c92a7 100644 --- a/src/handlers/project/add/credentials/payment/input.ts +++ b/src/handlers/project/add/credentials/payment/input.ts @@ -5,6 +5,12 @@ import type { PaymentProvider } from "../../../../../projectSchemas/payment"; import { flag } from "../../../../../router"; import type { EnvLocalEntry } from "../../../types"; import { credentialEnvVarName } from "../shared"; +import { + stripWalletAuthPrefix, + validateApiKeySecret, + validateAuthorizationPrivateKey, + validateWalletSecret, +} from "./validation"; export const paymentCredentialInputFlags = [ flag("api-key-id", "Coinbase CDP API key ID", z.string().optional()), @@ -79,10 +85,18 @@ export async function resolvePaymentCredentialEnvEntries(input: { const resolver = new SourceResolver({ stdin: io.stdin }); if (provider === "StripePrivy") { const appSecret = await resolver.resolveSecret("app-secret", flags["app-secret"]); - const authorizationPrivateKey = await resolver.resolveSecret( + const resolvedAuthorizationPrivateKey = await resolver.resolveSecret( "authorization-private-key", flags["authorization-private-key"], ); + const authorizationPrivateKey = + resolvedAuthorizationPrivateKey === undefined + ? undefined + : stripWalletAuthPrefix(resolvedAuthorizationPrivateKey); + if (authorizationPrivateKey !== undefined) { + const validation = validateAuthorizationPrivateKey(authorizationPrivateKey); + if (validation !== true) throw new InputValidationError(validation); + } return [ { key: credentialEnvVarName(name, "_APP_ID"), @@ -109,6 +123,14 @@ export async function resolvePaymentCredentialEnvEntries(input: { const apiKeySecret = await resolver.resolveSecret("api-key-secret", flags["api-key-secret"]); const walletSecret = await resolver.resolveSecret("wallet-secret", flags["wallet-secret"]); + if (apiKeySecret !== undefined) { + const validation = validateApiKeySecret(apiKeySecret); + if (validation !== true) throw new InputValidationError(validation); + } + if (walletSecret !== undefined) { + const validation = validateWalletSecret(walletSecret); + if (validation !== true) throw new InputValidationError(validation); + } return [ { key: credentialEnvVarName(name, "_API_KEY_ID"), diff --git a/src/handlers/project/add/credentials/payment/validation.test.ts b/src/handlers/project/add/credentials/payment/validation.test.ts new file mode 100644 index 000000000..43995a45d --- /dev/null +++ b/src/handlers/project/add/credentials/payment/validation.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, test } from "bun:test"; +import { + stripWalletAuthPrefix, + validateApiKeySecret, + validateAuthorizationPrivateKey, + validateWalletSecret, +} from "./validation"; + +const ed25519Key = Buffer.alloc(48, 0x41).toString("base64"); +const p256Key = Buffer.alloc(138, 0x41).toString("base64"); + +describe("payment credential key validation", () => { + test("accepts supported Coinbase key formats", () => { + expect(validateApiKeySecret(ed25519Key)).toBe(true); + expect(validateWalletSecret(p256Key)).toBe(true); + }); + + test("rejects invalid Coinbase key formats", () => { + expect(validateApiKeySecret("not-base64")).toContain("Ed25519"); + expect(validateWalletSecret(ed25519Key)).toContain("P-256"); + }); + + test("accepts and normalizes a prefixed Stripe authorization key", () => { + expect(validateAuthorizationPrivateKey(`wallet-auth:${p256Key}`)).toBe(true); + expect(stripWalletAuthPrefix(`wallet-auth:${p256Key}`)).toBe(p256Key); + }); + + test("rejects invalid Stripe authorization keys", () => { + expect(validateAuthorizationPrivateKey("wallet-auth:not-base64")).toContain("base64"); + }); +}); diff --git a/src/handlers/project/add/credentials/payment/validation.ts b/src/handlers/project/add/credentials/payment/validation.ts new file mode 100644 index 000000000..26952338b --- /dev/null +++ b/src/handlers/project/add/credentials/payment/validation.ts @@ -0,0 +1,45 @@ +const BASE64_PATTERN = /^[A-Za-z0-9+/]+=*$/; +const ED25519_MIN_BYTES = 32; +const ED25519_MAX_BYTES = 64; +const P256_MIN_BYTES = 100; +const P256_MAX_BYTES = 200; +const WALLET_AUTH_PREFIX = "wallet-auth:"; + +function decodedBase64Length(value: string): number | undefined { + if (!BASE64_PATTERN.test(value)) return undefined; + return Buffer.from(value, "base64").length; +} + +export function validateApiKeySecret(value: string): true | string { + const length = decodedBase64Length(value.trim()); + if (length === undefined) return "apiKeySecret must be a base64-encoded Ed25519 private key"; + if (length < ED25519_MIN_BYTES || length > ED25519_MAX_BYTES) { + return "apiKeySecret must be a base64-encoded Ed25519 private key (unexpected length)"; + } + return true; +} + +export function validateWalletSecret(value: string): true | string { + const length = decodedBase64Length(value.trim()); + if (length === undefined) return "walletSecret must be a base64-encoded EC P-256 private key"; + if (length < P256_MIN_BYTES || length > P256_MAX_BYTES) { + return "walletSecret must be a base64-encoded EC P-256 private key (unexpected length)"; + } + return true; +} + +export function stripWalletAuthPrefix(value: string): string { + const trimmed = value.trim(); + return trimmed.startsWith(WALLET_AUTH_PREFIX) + ? trimmed.slice(WALLET_AUTH_PREFIX.length) + : trimmed; +} + +export function validateAuthorizationPrivateKey(value: string): true | string { + const length = decodedBase64Length(stripWalletAuthPrefix(value)); + if (length === undefined) return "authorizationPrivateKey must be base64-encoded"; + if (length < P256_MIN_BYTES || length > P256_MAX_BYTES) { + return "authorizationPrivateKey must be a base64-encoded EC P-256 private key (unexpected length)"; + } + return true; +} From 5f0e06415d55917d106ac2951f1b1515180281a4 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 23:31:09 +0000 Subject: [PATCH 22/31] fix(project): secure local credential storage --- src/core/project/envLocal.test.ts | 17 ++++++++++++++++- src/core/project/envLocal.ts | 5 +++-- src/io/atomicWrite.test.ts | 11 ++++++++++- src/io/atomicWrite.ts | 12 ++++++++++-- 4 files changed, 39 insertions(+), 6 deletions(-) diff --git a/src/core/project/envLocal.test.ts b/src/core/project/envLocal.test.ts index 44834806d..32855c201 100644 --- a/src/core/project/envLocal.test.ts +++ b/src/core/project/envLocal.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, test } from "bun:test"; import { existsSync } from "node:fs"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, rm, stat } from "node:fs/promises"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; import { parseEnv } from "node:util"; @@ -21,6 +21,21 @@ async function tempRoot(): Promise { const ENTRY = { key: "SECRET", value: "v", comment: "c" }; +test("creates and replaces the secrets file with owner-only permissions", async () => { + const root = await tempRoot(); + const file = new EnvLocalFile(root); + + await file.insertIfNew([ENTRY]); + expect((await stat(file.path)).mode & 0o777).toBe(0o600); + + await chmod(file.path, 0o644); + await file.insertIfNew([{ key: "SECOND", value: "v", comment: "c" }]); + expect((await stat(file.path)).mode & 0o777).toBe(0o600); + + await file.rollback(); + expect((await stat(file.path)).mode & 0o777).toBe(0o600); +}); + test("rollback deletes the file it created", async () => { const root = await tempRoot(); const file = new EnvLocalFile(root); diff --git a/src/core/project/envLocal.ts b/src/core/project/envLocal.ts index 5774dde36..4afb06bb8 100644 --- a/src/core/project/envLocal.ts +++ b/src/core/project/envLocal.ts @@ -8,6 +8,7 @@ import type { EnvLocalEntry } from "../../handlers/project/types"; export const ENV_LOCAL_RELATIVE_PATH = join("agentcore", ".env.local"); const KEY_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/; +const SECRET_FILE_MODE = 0o600; /** * The project's `.env.local` secrets file, edited transactionally. `insertIfNew` @@ -58,7 +59,7 @@ export class EnvLocalFile { if (written.length > 0) { this.snapshot = existing; - await atomicWrite(this.path, content); + await atomicWrite(this.path, content, { mode: SECRET_FILE_MODE }); } return { written, skipped }; } @@ -67,7 +68,7 @@ export class EnvLocalFile { async rollback(): Promise { if (this.snapshot === undefined) return; if (this.snapshot === null) await rm(this.path, { force: true }); - else await atomicWrite(this.path, this.snapshot); + else await atomicWrite(this.path, this.snapshot, { mode: SECRET_FILE_MODE }); } private async readOrNull(): Promise { diff --git a/src/io/atomicWrite.test.ts b/src/io/atomicWrite.test.ts index a298311f9..7bd891631 100644 --- a/src/io/atomicWrite.test.ts +++ b/src/io/atomicWrite.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test"; -import { mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Readable, Transform } from "node:stream"; @@ -37,6 +37,15 @@ test("overwrites an existing file", async () => { expect(await readdir(dir)).toEqual(["out.txt"]); }); +test("creates the replacement file with the requested mode", async () => { + const dir = await tempDir(); + const target = join(dir, "secret.txt"); + + await atomicWrite(target, "secret", { mode: 0o600 }); + + expect((await stat(target)).mode & 0o777).toBe(0o600); +}); + test("cleans up the temp file when rename fails", async () => { const dir = await tempDir(); // Target path is a directory → rename onto it fails, exercising the catch. diff --git a/src/io/atomicWrite.ts b/src/io/atomicWrite.ts index 9d08906f7..07501baea 100644 --- a/src/io/atomicWrite.ts +++ b/src/io/atomicWrite.ts @@ -13,10 +13,18 @@ export interface AtomicWriteStreamOptions { transforms?: Transform[]; } -export async function atomicWrite(path: string, contents: string | Uint8Array): Promise { +export interface AtomicWriteOptions { + mode?: number; +} + +export async function atomicWrite( + path: string, + contents: string | Uint8Array, + options: AtomicWriteOptions = {}, +): Promise { const tempPath = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`); try { - await writeFile(tempPath, contents); + await writeFile(tempPath, contents, { mode: options.mode }); await rename(tempPath, path); } catch (error) { await rm(tempPath, { force: true }); From 10127477d43d3e42eed305848d34469c172e356b Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 23:31:17 +0000 Subject: [PATCH 23/31] fix(project): align payment manager validation --- src/projectSchemas/auth.ts | 17 ++++++--------- src/projectSchemas/payment.test.ts | 35 ++++++++++++++++++++++++++++++ src/projectSchemas/payment.ts | 28 +++++++++++++++++------- 3 files changed, 62 insertions(+), 18 deletions(-) diff --git a/src/projectSchemas/auth.ts b/src/projectSchemas/auth.ts index fff8fcc99..1c8444480 100644 --- a/src/projectSchemas/auth.ts +++ b/src/projectSchemas/auth.ts @@ -6,7 +6,7 @@ export type GatewayAuthorizerType = z.infer; export const RuntimeAuthorizerTypeSchema = z.enum(["AWS_IAM", "CUSTOM_JWT"]); export type RuntimeAuthorizerType = z.infer; const OIDC_WELL_KNOWN_SUFFIX = "/.well-known/openid-configuration"; -const OidcDiscoveryUrlSchema = z +export const OidcDiscoveryUrlSchema = z .string() .url("Must be a valid URL") .refine((url) => url.startsWith("https://"), { @@ -17,6 +17,11 @@ const OidcDiscoveryUrlSchema = z }); const MATCH_VALUE_PATTERN = /^[A-Za-z0-9_.-]+$/; const ALLOWED_SCOPE_PATTERN = /^[\x21\x23-\x5B\x5D-\x7E]+$/; +export const AllowedScopeSchema = z + .string() + .min(1) + .max(255) + .regex(ALLOWED_SCOPE_PATTERN, "Scope must be printable ASCII with no spaces or quotes"); const CLAIM_NAME_PATTERN = /^[A-Za-z0-9_.:-]+$/; const RESERVED_CLAIM_NAMES = ["client_id"]; export const ClaimMatchOperatorSchema = z.enum(["EQUALS", "CONTAINS", "CONTAINS_ANY"]); @@ -144,15 +149,7 @@ export const CustomJwtAuthorizerConfigSchema = z discoveryUrl: OidcDiscoveryUrlSchema, allowedAudience: z.array(z.string().min(1)).optional(), allowedClients: z.array(z.string().min(1)).optional(), - allowedScopes: z - .array( - z - .string() - .min(1) - .max(255) - .regex(ALLOWED_SCOPE_PATTERN, "Scope must be printable ASCII with no spaces or quotes"), - ) - .optional(), + allowedScopes: z.array(AllowedScopeSchema).optional(), customClaims: z.array(CustomClaimValidationSchema).min(1).optional(), privateEndpoint: PrivateEndpointSchema.optional(), privateEndpointOverrides: z.array(PrivateEndpointOverrideSchema).max(5).optional(), diff --git a/src/projectSchemas/payment.test.ts b/src/projectSchemas/payment.test.ts index 8b7910846..84868d55c 100644 --- a/src/projectSchemas/payment.test.ts +++ b/src/projectSchemas/payment.test.ts @@ -79,4 +79,39 @@ describe("payment manager custom validation", () => { }).success, ).toBe(false); }); + + it.each([ + [ + "a discovery URL without the OIDC well-known suffix", + { + authorizerType: "CUSTOM_JWT", + authorizerConfiguration: { + customJWTAuthorizer: { discoveryUrl: "https://example.com/discovery" }, + }, + }, + ], + [ + "an invalid scope", + { + authorizerType: "CUSTOM_JWT", + authorizerConfiguration: { + customJWTAuthorizer: { + discoveryUrl: "https://example.com/.well-known/openid-configuration", + allowedScopes: ["scope with spaces"], + }, + }, + }, + ], + ["a description containing punctuation", { description: "Payments!" }], + ["a description longer than 4096 characters", { description: "a".repeat(4097) }], + ["a blank default spend limit", { defaultSpendLimit: " " }], + ])("rejects %s", (_label, overrides) => { + expect( + PaymentManagerSchema.safeParse({ + name: "payments", + connectors: [], + ...overrides, + }).success, + ).toBe(false); + }); }); diff --git a/src/projectSchemas/payment.ts b/src/projectSchemas/payment.ts index 05ce22043..2efd03da5 100644 --- a/src/projectSchemas/payment.ts +++ b/src/projectSchemas/payment.ts @@ -1,3 +1,4 @@ +import { AllowedScopeSchema, OidcDiscoveryUrlSchema } from "./auth"; import { z } from "zod"; export const PaymentProviderSchema = z.enum(["CoinbaseCDP", "StripePrivy"]); export type PaymentProvider = z.infer; @@ -44,6 +45,14 @@ export const PaymentConnectorSchema = z.union([ ManualPaymentConnectorSchema, ]); export type PaymentConnector = z.infer; +export const PaymentManagerDescriptionSchema = z + .string() + .min(1) + .max(4096) + .regex( + /^[a-zA-Z0-9\s]+$/, + "Payment manager description must contain only alphanumeric characters and whitespace", + ); export const PaymentManagerSchema = z .object({ name: PaymentManagerNameSchema, @@ -51,21 +60,24 @@ export const PaymentManagerSchema = z authorizerConfiguration: z .object({ customJWTAuthorizer: z.object({ - discoveryUrl: z.string().url(), - allowedClients: z.array(z.string()).optional(), - allowedAudience: z.array(z.string()).optional(), - allowedScopes: z.array(z.string()).optional(), + discoveryUrl: OidcDiscoveryUrlSchema, + allowedClients: z.array(z.string()).min(1).optional(), + allowedAudience: z.array(z.string()).min(1).optional(), + allowedScopes: z.array(AllowedScopeSchema).min(1).optional(), }), }) .optional(), connectors: z.array(PaymentConnectorSchema).default([]), - description: z.string().optional(), + description: PaymentManagerDescriptionSchema.optional(), autoPayment: z.boolean().default(DEFAULT_AUTO_PAYMENT), defaultSpendLimit: z .string() - .refine((value) => Number.isFinite(Number(value)) && Number(value) >= 0, { - message: "Default spend limit must be a non-negative number", - }) + .refine( + (value) => value.trim().length > 0 && Number.isFinite(Number(value)) && Number(value) >= 0, + { + message: "Default spend limit must be a non-negative number", + }, + ) .default(DEFAULT_SPEND_LIMIT), paymentToolAllowlist: z.array(z.string()).optional(), networkPreferences: z.array(z.string()).optional(), From 85e901399ba2ec68cc7e493eec95d4b38fae3c13 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 23:31:29 +0000 Subject: [PATCH 24/31] fix(project): warn about payment runtime behavior --- .../project/add/payment-manager/index.test.ts | 6 +++++- src/handlers/project/add/payment-manager/index.ts | 13 +++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/src/handlers/project/add/payment-manager/index.test.ts b/src/handlers/project/add/payment-manager/index.test.ts index 807cfe63f..965b3581f 100644 --- a/src/handlers/project/add/payment-manager/index.test.ts +++ b/src/handlers/project/add/payment-manager/index.test.ts @@ -22,12 +22,14 @@ describe("project add payment-manager", () => { }, ]); expect(io.stderr()).toContain("added payment manager 'payments'"); + expect(io.stderr()).toContain("auto-payment is ENABLED"); + expect(io.stderr()).toContain("does not modify runtime source code"); }); test("maps custom JWT and payment behavior flags", async () => { const projectRoot = await inProject(); - await run([ + const io = await run([ "add", "payment-manager", "--name", @@ -75,6 +77,8 @@ describe("project add payment-manager", () => { paymentToolAllowlist: ["checkout", "refund"], networkPreferences: ["eip155:8453", "eip155:1"], }); + expect(io.stderr()).not.toContain("auto-payment is ENABLED"); + expect(io.stderr()).toContain("does not modify runtime source code"); }); test.each([ diff --git a/src/handlers/project/add/payment-manager/index.ts b/src/handlers/project/add/payment-manager/index.ts index f2b794471..490786fee 100644 --- a/src/handlers/project/add/payment-manager/index.ts +++ b/src/handlers/project/add/payment-manager/index.ts @@ -94,5 +94,18 @@ export const createAddPaymentManagerHandler = (config: AddProjectResourceConfig) config.io.stderr.write(`${event.message}\n`); } config.io.stderr.write(`added payment manager '${flags.name}' to '${project.name}'\n`); + if (flags["auto-payment"]) { + config.io.stderr.write( + `Warning: auto-payment is ENABLED for manager '${flags.name}'. Agents can automatically settle ` + + `402 responses up to the per-session spend limit ($${flags["default-spend-limit"]}) without human approval. ` + + "Use --no-auto-payment to require manual approval.\n", + ); + } + if (project.spec.runtimes.length > 0) { + config.io.stderr.write( + "Warning: project add payment-manager does not modify runtime source code. " + + "Configure the Payments SDK or plugin in supported runtimes before invoking payment-enabled agents.\n", + ); + } }, }); From 95646405d555fd08790bf2200c4a03453169c565 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 23:47:31 +0000 Subject: [PATCH 25/31] fix(project): match Coinbase key service format --- .../project/add/credentials/payment/index.test.ts | 13 ++++++++++--- .../add/credentials/payment/validation.test.ts | 3 ++- .../project/add/credentials/payment/validation.ts | 5 ++--- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/handlers/project/add/credentials/payment/index.test.ts b/src/handlers/project/add/credentials/payment/index.test.ts index d83e35f48..c52fcb9a4 100644 --- a/src/handlers/project/add/credentials/payment/index.test.ts +++ b/src/handlers/project/add/credentials/payment/index.test.ts @@ -8,6 +8,15 @@ const { cleanup, inProject, projectSpec, run } = afterEach(cleanup); +function coinbaseApiKeySecret(): string { + const { privateKey } = generateKeyPairSync("ed25519"); + const key = privateKey.export({ format: "jwk" }); + return Buffer.concat([ + Buffer.from(key.d!, "base64url"), + Buffer.from(key.x!, "base64url"), + ]).toString("base64"); +} + describe("project add credentials payment", () => { test.each(["CoinbaseCDP", "StripePrivy"] as const)( "adds a reusable %s payment credential", @@ -49,9 +58,7 @@ describe("project add credentials payment", () => { const projectRoot = await inProject(); const apiKeySecretPath = join(projectRoot, "api-key-secret.txt"); const walletSecretPath = join(projectRoot, "wallet-secret.txt"); - const apiKeySecret = generateKeyPairSync("ed25519") - .privateKey.export({ type: "pkcs8", format: "der" }) - .toString("base64"); + const apiKeySecret = coinbaseApiKeySecret(); const walletSecret = generateKeyPairSync("ec", { namedCurve: "P-256" }) .privateKey.export({ type: "pkcs8", format: "der" }) .toString("base64"); diff --git a/src/handlers/project/add/credentials/payment/validation.test.ts b/src/handlers/project/add/credentials/payment/validation.test.ts index 43995a45d..e469f50eb 100644 --- a/src/handlers/project/add/credentials/payment/validation.test.ts +++ b/src/handlers/project/add/credentials/payment/validation.test.ts @@ -6,7 +6,7 @@ import { validateWalletSecret, } from "./validation"; -const ed25519Key = Buffer.alloc(48, 0x41).toString("base64"); +const ed25519Key = Buffer.alloc(64, 0x41).toString("base64"); const p256Key = Buffer.alloc(138, 0x41).toString("base64"); describe("payment credential key validation", () => { @@ -17,6 +17,7 @@ describe("payment credential key validation", () => { test("rejects invalid Coinbase key formats", () => { expect(validateApiKeySecret("not-base64")).toContain("Ed25519"); + expect(validateApiKeySecret(Buffer.alloc(48, 0x41).toString("base64"))).toContain("length"); expect(validateWalletSecret(ed25519Key)).toContain("P-256"); }); diff --git a/src/handlers/project/add/credentials/payment/validation.ts b/src/handlers/project/add/credentials/payment/validation.ts index 26952338b..8c6fff247 100644 --- a/src/handlers/project/add/credentials/payment/validation.ts +++ b/src/handlers/project/add/credentials/payment/validation.ts @@ -1,6 +1,5 @@ const BASE64_PATTERN = /^[A-Za-z0-9+/]+=*$/; -const ED25519_MIN_BYTES = 32; -const ED25519_MAX_BYTES = 64; +const ED25519_KEY_LENGTHS = new Set([32, 64]); const P256_MIN_BYTES = 100; const P256_MAX_BYTES = 200; const WALLET_AUTH_PREFIX = "wallet-auth:"; @@ -13,7 +12,7 @@ function decodedBase64Length(value: string): number | undefined { export function validateApiKeySecret(value: string): true | string { const length = decodedBase64Length(value.trim()); if (length === undefined) return "apiKeySecret must be a base64-encoded Ed25519 private key"; - if (length < ED25519_MIN_BYTES || length > ED25519_MAX_BYTES) { + if (!ED25519_KEY_LENGTHS.has(length)) { return "apiKeySecret must be a base64-encoded Ed25519 private key (unexpected length)"; } return true; From 458ef85e1e1c086c78b50486ba398c3152c4103d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 28 Aug 2026 01:46:44 +0000 Subject: [PATCH 26/31] fix(project): avoid unenforced spend cap warning --- src/handlers/project/add/payment-manager/index.test.ts | 2 ++ src/handlers/project/add/payment-manager/index.ts | 3 +-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/handlers/project/add/payment-manager/index.test.ts b/src/handlers/project/add/payment-manager/index.test.ts index 965b3581f..cb4ad8cb5 100644 --- a/src/handlers/project/add/payment-manager/index.test.ts +++ b/src/handlers/project/add/payment-manager/index.test.ts @@ -23,6 +23,8 @@ describe("project add payment-manager", () => { ]); expect(io.stderr()).toContain("added payment manager 'payments'"); expect(io.stderr()).toContain("auto-payment is ENABLED"); + expect(io.stderr()).not.toContain("$10.00"); + expect(io.stderr()).not.toContain("spend limit"); expect(io.stderr()).toContain("does not modify runtime source code"); }); diff --git a/src/handlers/project/add/payment-manager/index.ts b/src/handlers/project/add/payment-manager/index.ts index 490786fee..14623d86c 100644 --- a/src/handlers/project/add/payment-manager/index.ts +++ b/src/handlers/project/add/payment-manager/index.ts @@ -97,8 +97,7 @@ export const createAddPaymentManagerHandler = (config: AddProjectResourceConfig) if (flags["auto-payment"]) { config.io.stderr.write( `Warning: auto-payment is ENABLED for manager '${flags.name}'. Agents can automatically settle ` + - `402 responses up to the per-session spend limit ($${flags["default-spend-limit"]}) without human approval. ` + - "Use --no-auto-payment to require manual approval.\n", + "402 responses without human approval. Use --no-auto-payment to require manual approval.\n", ); } if (project.spec.runtimes.length > 0) { From d6246d60dc869b950bf7a3b2d523fa7407b889a8 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 28 Aug 2026 01:46:50 +0000 Subject: [PATCH 27/31] fix(project): harden existing secret file permissions --- src/core/project/envLocal.test.ts | 7 ++++++- src/core/project/envLocal.ts | 7 ++++++- src/io/atomicWrite.test.ts | 3 ++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/core/project/envLocal.test.ts b/src/core/project/envLocal.test.ts index 32855c201..37d8cb4ca 100644 --- a/src/core/project/envLocal.test.ts +++ b/src/core/project/envLocal.test.ts @@ -20,14 +20,19 @@ async function tempRoot(): Promise { } const ENTRY = { key: "SECRET", value: "v", comment: "c" }; +const testPosix = process.platform === "win32" ? test.skip : test; -test("creates and replaces the secrets file with owner-only permissions", async () => { +testPosix("creates and replaces the secrets file with owner-only permissions", async () => { const root = await tempRoot(); const file = new EnvLocalFile(root); await file.insertIfNew([ENTRY]); expect((await stat(file.path)).mode & 0o777).toBe(0o600); + await chmod(file.path, 0o644); + await file.insertIfNew([ENTRY]); + expect((await stat(file.path)).mode & 0o777).toBe(0o600); + await chmod(file.path, 0o644); await file.insertIfNew([{ key: "SECOND", value: "v", comment: "c" }]); expect((await stat(file.path)).mode & 0o777).toBe(0o600); diff --git a/src/core/project/envLocal.ts b/src/core/project/envLocal.ts index 4afb06bb8..32143f3db 100644 --- a/src/core/project/envLocal.ts +++ b/src/core/project/envLocal.ts @@ -1,4 +1,4 @@ -import { rm } from "node:fs/promises"; +import { chmod, rm } from "node:fs/promises"; import { join } from "node:path"; import { atomicWrite, readTextFile } from "../../io"; import { InputValidationError } from "../../errors"; @@ -36,6 +36,7 @@ export class EnvLocalFile { */ async insertIfNew(entries: EnvLocalEntry[]): Promise<{ written: string[]; skipped: string[] }> { const existing = await this.readOrNull(); + if (existing !== null) await this.enforcePermissions(); const existingKeys = new Set( (existing ?? "") .split("\n") @@ -79,6 +80,10 @@ export class EnvLocalFile { throw error; } } + + private async enforcePermissions(): Promise { + if (process.platform !== "win32") await chmod(this.path, SECRET_FILE_MODE); + } } /** diff --git a/src/io/atomicWrite.test.ts b/src/io/atomicWrite.test.ts index 7bd891631..71db57f4f 100644 --- a/src/io/atomicWrite.test.ts +++ b/src/io/atomicWrite.test.ts @@ -6,6 +6,7 @@ import { Readable, Transform } from "node:stream"; import { atomicWrite, atomicWriteStream } from "./atomicWrite"; const dirs: string[] = []; +const testPosix = process.platform === "win32" ? test.skip : test; afterEach(async () => { await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true }))); }); @@ -37,7 +38,7 @@ test("overwrites an existing file", async () => { expect(await readdir(dir)).toEqual(["out.txt"]); }); -test("creates the replacement file with the requested mode", async () => { +testPosix("creates the replacement file with the requested mode", async () => { const dir = await tempDir(); const target = join(dir, "secret.txt"); From 63c3f948505082d25c2aa0ddf2f55f5b84c7fef5 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 28 Aug 2026 01:46:54 +0000 Subject: [PATCH 28/31] fix(project): enforce payment service input constraints --- .../add/credentials/payment/index.test.ts | 38 +++++++++++++++++++ .../project/add/credentials/payment/input.ts | 35 ++++++++++++++--- .../credentials/payment/validation.test.ts | 14 +++++++ .../add/credentials/payment/validation.ts | 16 ++++++++ 4 files changed, 98 insertions(+), 5 deletions(-) diff --git a/src/handlers/project/add/credentials/payment/index.test.ts b/src/handlers/project/add/credentials/payment/index.test.ts index c52fcb9a4..13bfe7322 100644 --- a/src/handlers/project/add/credentials/payment/index.test.ts +++ b/src/handlers/project/add/credentials/payment/index.test.ts @@ -94,6 +94,39 @@ describe("project add credentials payment", () => { expect(env).toContain(`AGENTCORE_CREDENTIAL_COINBASE_PROD_WALLET_SECRET='${walletSecret}'`); }); + test("normalizes Coinbase key whitespace before persistence", async () => { + const projectRoot = await inProject(); + const apiKeySecretPath = join(projectRoot, "api-key-secret-padded.txt"); + const walletSecretPath = join(projectRoot, "wallet-secret-padded.txt"); + const apiKeySecret = coinbaseApiKeySecret(); + const walletSecret = generateKeyPairSync("ec", { namedCurve: "P-256" }) + .privateKey.export({ type: "pkcs8", format: "der" }) + .toString("base64"); + await Bun.write(apiKeySecretPath, ` ${apiKeySecret} \n`); + await Bun.write(walletSecretPath, ` ${walletSecret} \n`); + + await run([ + "add", + "credentials", + "payment", + "--name", + "coinbase-padded", + "--provider", + "CoinbaseCDP", + "--api-key-id", + "coinbase_key", + "--api-key-secret", + `file://${apiKeySecretPath}`, + "--wallet-secret", + `file://${walletSecretPath}`, + ]); + + const env = await Bun.file(join(projectRoot, "agentcore", ".env.local")).text(); + expect(env).toContain(`AGENTCORE_CREDENTIAL_COINBASE_PADDED_API_KEY_SECRET='${apiKeySecret}'`); + expect(env).toContain(`AGENTCORE_CREDENTIAL_COINBASE_PADDED_WALLET_SECRET='${walletSecret}'`); + expect(env).not.toContain(`' ${apiKeySecret} '`); + }); + test("normalizes the documented Stripe authorization key prefix", async () => { const projectRoot = await inProject(); const privateKeyPath = join(projectRoot, "private-key.txt"); @@ -137,6 +170,11 @@ describe("project add credentials payment", () => { ["--provider", "StripePrivy", "--api-key-id", "coinbase-key"], "not valid with --provider StripePrivy", ], + [ + "invalid Coinbase API key ID", + ["--provider", "CoinbaseCDP", "--api-key-id", "invalid key!"], + "apiKeyId", + ], ])("rejects %s without mutating the project", async (_label, flags, message) => { const projectRoot = await inProject(); diff --git a/src/handlers/project/add/credentials/payment/input.ts b/src/handlers/project/add/credentials/payment/input.ts index 1503c92a7..038e3b41d 100644 --- a/src/handlers/project/add/credentials/payment/input.ts +++ b/src/handlers/project/add/credentials/payment/input.ts @@ -7,8 +7,10 @@ import type { EnvLocalEntry } from "../../../types"; import { credentialEnvVarName } from "../shared"; import { stripWalletAuthPrefix, + validateAppSecret, validateApiKeySecret, validateAuthorizationPrivateKey, + validatePaymentIdentifier, validateWalletSecret, } from "./validation"; @@ -64,6 +66,14 @@ export function hasPaymentCredentialInput(flags: PaymentCredentialInputFlags): b return [...COINBASE_FLAGS, ...STRIPE_FLAGS].some((name) => flags[name] !== undefined); } +function normalizedIdentifier(name: string, value: string | undefined): string | undefined { + if (value === undefined) return undefined; + const normalized = value.trim(); + const validation = validatePaymentIdentifier(name, normalized); + if (validation !== true) throw new InputValidationError(validation); + return normalized; +} + export async function resolvePaymentCredentialEnvEntries(input: { name: string; provider: PaymentProvider; @@ -84,7 +94,13 @@ export async function resolvePaymentCredentialEnvEntries(input: { const resolver = new SourceResolver({ stdin: io.stdin }); if (provider === "StripePrivy") { + const appId = normalizedIdentifier("appId", flags["app-id"]); + const authorizationId = normalizedIdentifier("authorizationId", flags["authorization-id"]); const appSecret = await resolver.resolveSecret("app-secret", flags["app-secret"]); + if (appSecret !== undefined) { + const validation = validateAppSecret(appSecret); + if (validation !== true) throw new InputValidationError(validation); + } const resolvedAuthorizationPrivateKey = await resolver.resolveSecret( "authorization-private-key", flags["authorization-private-key"], @@ -100,7 +116,7 @@ export async function resolvePaymentCredentialEnvEntries(input: { return [ { key: credentialEnvVarName(name, "_APP_ID"), - value: flags["app-id"], + value: appId, comment: `Privy application ID for payment credential provider '${name}' (set before deploy)`, }, { @@ -115,14 +131,23 @@ export async function resolvePaymentCredentialEnvEntries(input: { }, { key: credentialEnvVarName(name, "_AUTHORIZATION_ID"), - value: flags["authorization-id"], + value: authorizationId, comment: `Stripe/Privy authorization ID for payment credential provider '${name}' (set before deploy)`, }, ]; } - const apiKeySecret = await resolver.resolveSecret("api-key-secret", flags["api-key-secret"]); - const walletSecret = await resolver.resolveSecret("wallet-secret", flags["wallet-secret"]); + const apiKeyId = normalizedIdentifier("apiKeyId", flags["api-key-id"]); + const resolvedApiKeySecret = await resolver.resolveSecret( + "api-key-secret", + flags["api-key-secret"], + ); + const resolvedWalletSecret = await resolver.resolveSecret( + "wallet-secret", + flags["wallet-secret"], + ); + const apiKeySecret = resolvedApiKeySecret?.trim(); + const walletSecret = resolvedWalletSecret?.trim(); if (apiKeySecret !== undefined) { const validation = validateApiKeySecret(apiKeySecret); if (validation !== true) throw new InputValidationError(validation); @@ -134,7 +159,7 @@ export async function resolvePaymentCredentialEnvEntries(input: { return [ { key: credentialEnvVarName(name, "_API_KEY_ID"), - value: flags["api-key-id"], + value: apiKeyId, comment: `Coinbase CDP API key ID for payment credential provider '${name}' (set before deploy)`, }, { diff --git a/src/handlers/project/add/credentials/payment/validation.test.ts b/src/handlers/project/add/credentials/payment/validation.test.ts index e469f50eb..ac72406b4 100644 --- a/src/handlers/project/add/credentials/payment/validation.test.ts +++ b/src/handlers/project/add/credentials/payment/validation.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; import { stripWalletAuthPrefix, + validateAppSecret, validateApiKeySecret, validateAuthorizationPrivateKey, + validatePaymentIdentifier, validateWalletSecret, } from "./validation"; @@ -29,4 +31,16 @@ describe("payment credential key validation", () => { test("rejects invalid Stripe authorization keys", () => { expect(validateAuthorizationPrivateKey("wallet-auth:not-base64")).toContain("base64"); }); + + test("rejects identifiers outside the Payment service contract", () => { + expect(validatePaymentIdentifier("apiKeyId", "valid_key-1")).toBe(true); + expect(validatePaymentIdentifier("apiKeyId", "invalid key!")).toContain("apiKeyId"); + expect(validatePaymentIdentifier("apiKeyId", "a".repeat(513))).toContain("512"); + }); + + test("rejects app secrets outside the Payment service contract", () => { + expect(validateAppSecret("valid+/=_-secret")).toBe(true); + expect(validateAppSecret("invalid!secret")).toContain("appSecret"); + expect(validateAppSecret("a".repeat(2049))).toContain("2048"); + }); }); diff --git a/src/handlers/project/add/credentials/payment/validation.ts b/src/handlers/project/add/credentials/payment/validation.ts index 8c6fff247..9160258eb 100644 --- a/src/handlers/project/add/credentials/payment/validation.ts +++ b/src/handlers/project/add/credentials/payment/validation.ts @@ -3,6 +3,8 @@ const ED25519_KEY_LENGTHS = new Set([32, 64]); const P256_MIN_BYTES = 100; const P256_MAX_BYTES = 200; const WALLET_AUTH_PREFIX = "wallet-auth:"; +const PAYMENT_IDENTIFIER_PATTERN = /^[A-Za-z0-9_-]+$/; +const APP_SECRET_PATTERN = /^[A-Za-z0-9+/=_\-\s]*$/; function decodedBase64Length(value: string): number | undefined { if (!BASE64_PATTERN.test(value)) return undefined; @@ -42,3 +44,17 @@ export function validateAuthorizationPrivateKey(value: string): true | string { } return true; } + +export function validatePaymentIdentifier(name: string, value: string): true | string { + if (value.length < 1 || value.length > 512) return `${name} must be between 1 and 512 characters`; + if (!PAYMENT_IDENTIFIER_PATTERN.test(value)) { + return `${name} must contain only alphanumeric characters, hyphens, and underscores`; + } + return true; +} + +export function validateAppSecret(value: string): true | string { + if (value.length > 2048) return "appSecret must be 2048 characters or less"; + if (!APP_SECRET_PATTERN.test(value)) return "appSecret contains unsupported characters"; + return true; +} From d63ca1c163641305cad44d5584331467a7e16ea3 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 27 Aug 2026 23:48:21 +0000 Subject: [PATCH 29/31] refactor(project): delegate payments to L3 construct --- src/assets/cdk/bin/cdk.ts | 64 ---------- src/assets/cdk/lib/cdk-stack.ts | 206 ++------------------------------ src/assets/cdk/test/cdk.test.ts | 174 +++++++++++++++------------ 3 files changed, 103 insertions(+), 341 deletions(-) diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index eb7062720..9e308d1de 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -153,76 +153,12 @@ async function main() { const credentials = targetResources?.credentials as Record | undefined; - // Payment credential provider ARNs live in the same credentials map as identity credentials - const paymentCredentials = credentials; - - const paymentSpec = specAny.payments?.length - ? specAny.payments.map( - (p: { - name: string; - description?: string; - authorizerType: 'AWS_IAM' | 'CUSTOM_JWT'; - authorizerConfiguration?: unknown; - autoPayment?: boolean; - paymentToolAllowlist?: string[]; - networkPreferences?: string[]; - connectors: { - name: string; - provider?: 'CoinbaseCDP' | 'StripePrivy'; - provisionMode?: 'MANUAL' | 'QUICK_CREATE'; - credentialName?: string; - }[]; - }) => ({ - name: p.name, - description: p.description, - authorizerType: p.authorizerType, - authorizerConfiguration: p.authorizerConfiguration, - autoPayment: p.autoPayment, - paymentToolAllowlist: p.paymentToolAllowlist, - networkPreferences: p.networkPreferences, - connectors: p.connectors.map(c => { - if (c.provisionMode === 'QUICK_CREATE') { - return { - name: c.name, - provider: 'CoinbaseCDP' as const, - provisionMode: 'QUICK_CREATE' as const, - }; - } - - if (!c.credentialName) { - throw new Error( - `Manual payment connector "${c.name}" on manager "${p.name}" is missing its credential name.` - ); - } - const credentialProviderArn = paymentCredentials?.[c.credentialName]?.credentialProviderArn; - if (!credentialProviderArn) { - // Fail fast with an actionable message rather than passing an empty - // ARN that fails opaquely server-side at CreatePaymentConnector. - throw new Error( - `Payment connector "${c.name}" on manager "${p.name}" references credential ` + - `"${c.credentialName}", but no deployed credential provider was found for it. ` + - `Run \`agentcore deploy\` so the credential provider is created first.` - ); - } - return { - name: c.name, - provider: c.provider ?? ('CoinbaseCDP' as const), - ...(c.provisionMode && { provisionMode: c.provisionMode }), - credentialName: c.credentialName, - credentialProviderArn, - }; - }), - }) - ) - : undefined; - new AgentCoreStack(app, stackName, { spec, mcpSpec, credentials, connectorParametersByFile, harnesses: harnessConfigs.length > 0 ? harnessConfigs : undefined, - paymentSpec, env, description: target ? `AgentCore stack for ${spec.name} deployed to ${target.name} (${target.region})` diff --git a/src/assets/cdk/lib/cdk-stack.ts b/src/assets/cdk/lib/cdk-stack.ts index 4ee14258b..9592561d2 100644 --- a/src/assets/cdk/lib/cdk-stack.ts +++ b/src/assets/cdk/lib/cdk-stack.ts @@ -1,15 +1,12 @@ import { AgentCoreApplication, AgentCoreMcp, - AgentCorePaymentManager, - AgentCorePaymentConnector, + AgentCorePayments, type AgentCoreProjectSpec, type AgentCoreMcpSpec, - type CustomJWTAuthorizerConfig, type HarnessDeploymentConfig, } from '@aws/agentcore-cdk'; import { CfnOutput, Stack, type StackProps } from 'aws-cdk-lib'; -import * as iam from 'aws-cdk-lib/aws-iam'; import { Construct } from 'constructs'; /** @@ -19,35 +16,6 @@ import { Construct } from 'constructs'; */ export type HarnessConfig = HarnessDeploymentConfig; -export type ManualPaymentConnectorSpec = { - name: string; - provider: 'CoinbaseCDP' | 'StripePrivy'; - provisionMode?: 'MANUAL'; - credentialName: string; - credentialProviderArn: string; -}; - -export type QuickCreatePaymentConnectorSpec = { - name: string; - provider: 'CoinbaseCDP'; - provisionMode: 'QUICK_CREATE'; - credentialName?: never; - credentialProviderArn?: never; -}; - -export type PaymentConnectorSpec = ManualPaymentConnectorSpec | QuickCreatePaymentConnectorSpec; - -export interface PaymentSpec { - name: string; - description?: string; - authorizerType: 'AWS_IAM' | 'CUSTOM_JWT'; - authorizerConfiguration?: { customJWTAuthorizer: CustomJWTAuthorizerConfig }; - autoPayment?: boolean; - paymentToolAllowlist?: string[]; - networkPreferences?: string[]; - connectors: PaymentConnectorSpec[]; -} - export interface AgentCoreStackProps extends StackProps { /** * The AgentCore project specification containing agents, memories, and credentials. @@ -70,34 +38,6 @@ export interface AgentCoreStackProps extends StackProps { * connectorConfigFile path. Forwarded to AgentCoreApplication. */ connectorParametersByFile?: Record>; - /** - * Payment specifications with resolved credential provider ARNs. - */ - paymentSpec?: PaymentSpec[]; -} - -function paymentManagerCdkId(managerName: string): string { - return `PaymentManagerM${managerName.length}${managerName}`; -} - -function paymentConnectorCdkId(managerName: string, connectorName: string): string { - return `PaymentConnectorM${managerName.length}${managerName}C${connectorName.length}${connectorName}`; -} - -/** - * Decide whether a deployed runtime should receive payment env vars + IAM grants. - * Payments today only ships a runtime shim for Python HTTP runtimes; injecting - * AGENTCORE_PAYMENT_* env vars into TypeScript / MCP / A2A / AGUI runtimes - * would surface env vars they cannot consume and would dilute least-privilege - * IAM grants for runtimes that never call ProcessPayment. - */ -function isPaymentEligibleAgent(agent: { entrypoint?: string; protocol?: string }): boolean { - if (agent.protocol && agent.protocol !== 'HTTP') { - return false; - } - const entrypoint = typeof agent.entrypoint === 'string' ? agent.entrypoint : ''; - const entrypointFile = entrypoint.split(':')[0] ?? ''; - return entrypointFile.endsWith('.py'); } /** @@ -113,7 +53,7 @@ export class AgentCoreStack extends Stack { constructor(scope: Construct, id: string, props: AgentCoreStackProps) { super(scope, id, props); - const { spec, mcpSpec, credentials, harnesses, connectorParametersByFile, paymentSpec } = props; + const { spec, mcpSpec, credentials, harnesses, connectorParametersByFile } = props; // Create AgentCoreApplication with all agents and harness roles // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -128,6 +68,11 @@ export class AgentCoreStack extends Stack { appProps.credentials = credentials; } this.application = new AgentCoreApplication(this, 'Application', appProps as any); + new AgentCorePayments(this, 'Payments', { + spec, + credentials, + agentCoreApplication: this.application, + }); // Create AgentCoreMcp if there are gateways configured if (mcpSpec?.agentCoreGateways && mcpSpec.agentCoreGateways.length > 0) { @@ -140,143 +85,6 @@ export class AgentCoreStack extends Stack { }); } - // Create payment infrastructure via CFN constructs - if (paymentSpec && paymentSpec.length > 0) { - for (const payment of paymentSpec) { - const managerCdkId = paymentManagerCdkId(payment.name); - const manager = new AgentCorePaymentManager(this, managerCdkId, { - projectName: spec.name, - name: payment.name, - authorizerType: payment.authorizerType, - description: payment.description, - authorizerConfiguration: payment.authorizerConfiguration, - tags: spec.tags, - }); - - const prefix = `AGENTCORE_PAYMENT_${payment.name.toUpperCase().replace(/-/g, '_')}`; - - // Wire env vars from construct output tokens into eligible agent environments only. - // See isPaymentEligibleAgent — non-Python or non-HTTP runtimes have no shim that - // can consume these env vars, and giving them sts:AssumeRole on the - // ProcessPaymentRole would broaden the privilege surface unnecessarily. - for (const env of this.application.environments.values()) { - if (!isPaymentEligibleAgent(env.agent)) { - continue; - } - env.runtime.addEnvironmentVariable(`${prefix}_MANAGER_ARN`, manager.paymentManagerArn); - env.runtime.addEnvironmentVariable(`${prefix}_PROCESS_PAYMENT_ROLE_ARN`, manager.processPaymentRoleArn); - - // Grant runtime execution role permission to assume the ProcessPaymentRole. - // The ProcessPaymentRole's trust policy allows AccountRootPrincipal, but the - // caller still needs sts:AssumeRole on its own role to perform the assumption. - env.runtime.role.addToPrincipalPolicy( - new iam.PolicyStatement({ - actions: ['sts:AssumeRole'], - resources: [manager.processPaymentRoleArn], - }) - ); - - // Grant payment data-plane actions directly to the runtime role. - // - // NOTE: This deviates from the canonical role model in the AgentCore Payments - // beta guide, which assigns Get/List/Create instrument+session actions to a - // separate ManagementRole and limits the agent's role to ProcessPayment only. - // The current SDK plugin (AgentCorePaymentsPlugin.generate_payment_header) - // calls GetPaymentInstrument internally during the 402 auto-pay path, so the - // runtime role needs read access. CreatePaymentSession is included so - // `agentcore invoke --auto-session` works without a separate ManagementRole - // call. Tighten this if the SDK is updated to accept pre-fetched instrument - // details and split create-session into a backend-only flow. - env.runtime.role.addToPrincipalPolicy( - new iam.PolicyStatement({ - actions: [ - 'bedrock-agentcore:GetPaymentInstrument', - 'bedrock-agentcore:ListPaymentInstruments', - 'bedrock-agentcore:GetPaymentInstrumentBalance', - 'bedrock-agentcore:GetPaymentSession', - 'bedrock-agentcore:ListPaymentSessions', - 'bedrock-agentcore:CreatePaymentSession', - 'bedrock-agentcore:ProcessPayment', - ], - resources: [manager.paymentManagerArn, `${manager.paymentManagerArn}/*`], - }) - ); - - if (payment.autoPayment !== undefined) { - env.runtime.addEnvironmentVariable(`${prefix}_AUTO_PAYMENT`, String(payment.autoPayment)); - } - if (payment.paymentToolAllowlist) { - env.runtime.addEnvironmentVariable(`${prefix}_TOOL_ALLOWLIST`, payment.paymentToolAllowlist.join(',')); - } - if (payment.networkPreferences) { - env.runtime.addEnvironmentVariable(`${prefix}_NETWORK_PREFERENCES`, payment.networkPreferences.join(',')); - } - if (payment.authorizerType === 'CUSTOM_JWT') { - env.runtime.addEnvironmentVariable(`${prefix}_AUTH_MODE`, 'bearer'); - } - } - - // Create connectors for this manager - for (const connector of payment.connectors) { - const connectorCdkId = paymentConnectorCdkId(payment.name, connector.name); - let conn: AgentCorePaymentConnector; - if (connector.provisionMode === 'QUICK_CREATE') { - conn = new AgentCorePaymentConnector(this, connectorCdkId, { - projectName: spec.name, - paymentManager: manager, - connector, - }); - } else { - conn = new AgentCorePaymentConnector(this, connectorCdkId, { - projectName: spec.name, - paymentManager: manager, - connector: { - name: connector.name, - provider: connector.provider, - provisionMode: connector.provisionMode, - credentialName: connector.credentialName, - }, - credentialProviderArn: connector.credentialProviderArn, - }); - } - - // Wire first connector's ID as env var (eligible agents only) - if (connector === payment.connectors[0]) { - for (const env of this.application.environments.values()) { - if (!isPaymentEligibleAgent(env.agent)) continue; - env.runtime.addEnvironmentVariable(`${prefix}_CONNECTOR_ID`, conn.paymentConnectorId); - } - } - - new CfnOutput(this, `${connectorCdkId}ConnectorId`, { - value: conn.paymentConnectorId, - }); - if (connector.provisionMode === 'QUICK_CREATE') { - new CfnOutput(this, `${connectorCdkId}ConnectorStatus`, { - value: conn.paymentConnectorStatus, - }); - new CfnOutput(this, `${connectorCdkId}AuthorizationUrl`, { - value: conn.authorizationUrl, - }); - } - } - - // CFN Outputs for post-deploy state parsing - new CfnOutput(this, `${managerCdkId}Arn`, { - value: manager.paymentManagerArn, - }); - new CfnOutput(this, `${managerCdkId}Id`, { - value: manager.paymentManagerId, - }); - new CfnOutput(this, `${managerCdkId}ProcessPaymentRoleArn`, { - value: manager.processPaymentRoleArn, - }); - new CfnOutput(this, `${managerCdkId}ResourceRetrievalRoleArn`, { - value: manager.resourceRetrievalRoleArn, - }); - } - } - // Stack-level output new CfnOutput(this, 'StackNameOutput', { description: 'Name of the CloudFormation Stack', diff --git a/src/assets/cdk/test/cdk.test.ts b/src/assets/cdk/test/cdk.test.ts index 8e086edbc..2db16484f 100644 --- a/src/assets/cdk/test/cdk.test.ts +++ b/src/assets/cdk/test/cdk.test.ts @@ -1,18 +1,28 @@ -import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; import { join } from 'node:path'; import * as cdk from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; -import { AgentCoreStack } from '../lib/cdk-stack'; -const testConfigDir = join(process.cwd(), 'agentcore'); +const originalCwd = process.cwd(); +const originalInitCwd = process.env.INIT_CWD; +const testRoot = mkdtempSync(join(tmpdir(), 'agentcore-cdk-test-')); +const testConfigDir = join(testRoot, 'agentcore'); +let AgentCoreStack: typeof import('../lib/cdk-stack').AgentCoreStack; -beforeAll(() => { +beforeAll(async () => { + process.chdir(testRoot); + process.env.INIT_CWD = testRoot; mkdirSync(testConfigDir, { recursive: true }); writeFileSync(join(testConfigDir, 'agentcore.json'), '{}'); + ({ AgentCoreStack } = await import('../lib/cdk-stack')); }); afterAll(() => { - rmSync(testConfigDir, { recursive: true, force: true }); + process.chdir(originalCwd); + if (originalInitCwd === undefined) delete process.env.INIT_CWD; + else process.env.INIT_CWD = originalInitCwd; + rmSync(testRoot, { recursive: true, force: true }); }); test('AgentCoreStack synthesizes with empty spec', () => { @@ -52,38 +62,47 @@ test('AgentCoreStack synthesizes manual and Quick Create payment connectors', () managedBy: 'CDK' as const, runtimes: [], memories: [], - credentials: [], + credentials: [ + { + authorizerType: 'PaymentCredentialProvider', + name: 'coinbase', + provider: 'CoinbaseCDP', + }, + ], evaluators: [], onlineEvalConfigs: [], configBundles: [], policyEngines: [], - payments: [], + payments: [ + { + name: 'Payments', + authorizerType: 'AWS_IAM', + connectors: [ + { + name: 'Manual', + provider: 'CoinbaseCDP', + credentialName: 'coinbase', + }, + { + name: 'Quick', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + ], + }, + ], agentCoreGateways: [], mcpRuntimeTools: [], unassignedTargets: [], datasets: [], knowledgeBases: [], }, - paymentSpec: [ - { - name: 'Payments', - authorizerType: 'AWS_IAM', - connectors: [ - { - name: 'Manual', - provider: 'CoinbaseCDP', - credentialName: 'coinbase', - credentialProviderArn: - 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/paymentcredentialprovider/coinbase', - }, - { - name: 'Quick', - provider: 'CoinbaseCDP', - provisionMode: 'QUICK_CREATE', - }, - ], + credentials: { + coinbase: { + credentialProviderArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/paymentcredentialprovider/coinbase', }, - ], + }, }); const template = Template.fromStack(stack); @@ -98,7 +117,7 @@ test('AgentCoreStack synthesizes manual and Quick Create payment connectors', () ProvisionMode: 'QUICK_CREATE', CredentialProviderConfigurations: [], }); - template.hasOutput('PaymentConnectorM8PaymentsC5QuickAuthorizationUrl', {}); + expect(Object.keys(template.findOutputs('*')).some(key => key.includes('AuthorizationUrl'))).toBe(true); }); test('AgentCoreStack preserves complete and type-distinct payment resource identities', () => { @@ -115,63 +134,62 @@ test('AgentCoreStack preserves complete and type-distinct payment resource ident onlineEvalConfigs: [], configBundles: [], policyEngines: [], - payments: [], + payments: [ + { + name: 'Payments', + authorizerType: 'AWS_IAM', + connectors: [ + { + name: 'foo_bar', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + { + name: 'foobar', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + ], + }, + { + name: 'A', + authorizerType: 'AWS_IAM', + connectors: [ + { + name: 'B', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + { + name: 'BC', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + ], + }, + { + name: 'AB', + authorizerType: 'AWS_IAM', + connectors: [ + { + name: 'C', + provider: 'CoinbaseCDP', + provisionMode: 'QUICK_CREATE', + }, + ], + }, + { + name: 'M1AC1B', + authorizerType: 'AWS_IAM', + connectors: [], + }, + ], agentCoreGateways: [], mcpRuntimeTools: [], unassignedTargets: [], datasets: [], knowledgeBases: [], }, - paymentSpec: [ - { - name: 'Payments', - authorizerType: 'AWS_IAM', - connectors: [ - { - name: 'foo_bar', - provider: 'CoinbaseCDP', - provisionMode: 'QUICK_CREATE', - }, - { - name: 'foobar', - provider: 'CoinbaseCDP', - provisionMode: 'QUICK_CREATE', - }, - ], - }, - { - name: 'A', - authorizerType: 'AWS_IAM', - connectors: [ - { - name: 'B', - provider: 'CoinbaseCDP', - provisionMode: 'QUICK_CREATE', - }, - { - name: 'BC', - provider: 'CoinbaseCDP', - provisionMode: 'QUICK_CREATE', - }, - ], - }, - { - name: 'AB', - authorizerType: 'AWS_IAM', - connectors: [ - { - name: 'C', - provider: 'CoinbaseCDP', - provisionMode: 'QUICK_CREATE', - }, - ], - }, - { - name: 'M1AC1B', - authorizerType: 'AWS_IAM', - connectors: [], - }, - ], }); Template.fromStack(stack).resourceCountIs('AWS::BedrockAgentCore::PaymentConnector', 5); From 009bb718d7dd4ca35373e582cb6dd358d11013a3 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 28 Aug 2026 02:49:31 +0000 Subject: [PATCH 30/31] refactor(project): share payment spend-limit validation --- .../project/add/payment-manager/index.ts | 3 ++- src/projectSchemas/payment.ts | 18 +++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/src/handlers/project/add/payment-manager/index.ts b/src/handlers/project/add/payment-manager/index.ts index 14623d86c..79c03bc24 100644 --- a/src/handlers/project/add/payment-manager/index.ts +++ b/src/handlers/project/add/payment-manager/index.ts @@ -4,6 +4,7 @@ import { DEFAULT_AUTO_PAYMENT, DEFAULT_SPEND_LIMIT, PaymentAuthorizerTypeSchema, + PaymentSpendLimitSchema, } from "../../../../projectSchemas/payment"; import { createHandler, flag, ProjectKey } from "../../../../router"; import type { AddProjectResourceConfig } from "../types"; @@ -36,7 +37,7 @@ export const createAddPaymentManagerHandler = (config: AddProjectResourceConfig) flag( "default-spend-limit", "default payment-session spend limit", - z.string().default(DEFAULT_SPEND_LIMIT), + PaymentSpendLimitSchema.default(DEFAULT_SPEND_LIMIT), ), flag( "tool-allowlist", diff --git a/src/projectSchemas/payment.ts b/src/projectSchemas/payment.ts index 2efd03da5..98df91c72 100644 --- a/src/projectSchemas/payment.ts +++ b/src/projectSchemas/payment.ts @@ -53,6 +53,14 @@ export const PaymentManagerDescriptionSchema = z /^[a-zA-Z0-9\s]+$/, "Payment manager description must contain only alphanumeric characters and whitespace", ); +export const PaymentSpendLimitSchema = z + .string() + .refine( + (value) => value.trim().length > 0 && Number.isFinite(Number(value)) && Number(value) >= 0, + { + message: "Default spend limit must be a non-negative number", + }, + ); export const PaymentManagerSchema = z .object({ name: PaymentManagerNameSchema, @@ -70,15 +78,7 @@ export const PaymentManagerSchema = z connectors: z.array(PaymentConnectorSchema).default([]), description: PaymentManagerDescriptionSchema.optional(), autoPayment: z.boolean().default(DEFAULT_AUTO_PAYMENT), - defaultSpendLimit: z - .string() - .refine( - (value) => value.trim().length > 0 && Number.isFinite(Number(value)) && Number(value) >= 0, - { - message: "Default spend limit must be a non-negative number", - }, - ) - .default(DEFAULT_SPEND_LIMIT), + defaultSpendLimit: PaymentSpendLimitSchema.default(DEFAULT_SPEND_LIMIT), paymentToolAllowlist: z.array(z.string()).optional(), networkPreferences: z.array(z.string()).optional(), }) From 7adecd9c9ba19b211760665d187e960474ca5e1b Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 28 Aug 2026 02:50:00 +0000 Subject: [PATCH 31/31] fix(project): accept legacy blank spend limits --- .../project/add/payment-manager/index.test.ts | 5 +++++ src/projectSchemas/payment.test.ts | 12 +++++++++++- src/projectSchemas/payment.ts | 4 +++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/handlers/project/add/payment-manager/index.test.ts b/src/handlers/project/add/payment-manager/index.test.ts index cb4ad8cb5..8943d3771 100644 --- a/src/handlers/project/add/payment-manager/index.test.ts +++ b/src/handlers/project/add/payment-manager/index.test.ts @@ -100,6 +100,11 @@ describe("project add payment-manager", () => { ["--name", "payments", "--default-spend-limit", "-1"], "non-negative", ], + [ + "blank default spend limit", + ["--name", "payments", "--default-spend-limit", ""], + "non-negative", + ], ["invalid name", ["--name", "bad-name"], "alphanumeric"], ])("rejects %s", async (_label, flags, message) => { const projectRoot = await inProject(); diff --git a/src/projectSchemas/payment.test.ts b/src/projectSchemas/payment.test.ts index 84868d55c..6a24c94d2 100644 --- a/src/projectSchemas/payment.test.ts +++ b/src/projectSchemas/payment.test.ts @@ -104,7 +104,7 @@ describe("payment manager custom validation", () => { ], ["a description containing punctuation", { description: "Payments!" }], ["a description longer than 4096 characters", { description: "a".repeat(4097) }], - ["a blank default spend limit", { defaultSpendLimit: " " }], + ["a whitespace-only default spend limit", { defaultSpendLimit: " " }], ])("rejects %s", (_label, overrides) => { expect( PaymentManagerSchema.safeParse({ @@ -114,4 +114,14 @@ describe("payment manager custom validation", () => { }).success, ).toBe(false); }); + + it("accepts an empty persisted spend limit from released projects", () => { + expect( + PaymentManagerSchema.safeParse({ + name: "payments", + connectors: [], + defaultSpendLimit: "", + }).success, + ).toBe(true); + }); }); diff --git a/src/projectSchemas/payment.ts b/src/projectSchemas/payment.ts index 98df91c72..648775173 100644 --- a/src/projectSchemas/payment.ts +++ b/src/projectSchemas/payment.ts @@ -78,7 +78,9 @@ export const PaymentManagerSchema = z connectors: z.array(PaymentConnectorSchema).default([]), description: PaymentManagerDescriptionSchema.optional(), autoPayment: z.boolean().default(DEFAULT_AUTO_PAYMENT), - defaultSpendLimit: PaymentSpendLimitSchema.default(DEFAULT_SPEND_LIMIT), + defaultSpendLimit: z + .union([z.literal(""), PaymentSpendLimitSchema]) + .default(DEFAULT_SPEND_LIMIT), paymentToolAllowlist: z.array(z.string()).optional(), networkPreferences: z.array(z.string()).optional(), })