From dc15b38a0101026593543af723264e316b31f94d Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 26 Aug 2026 21:55:55 +0000 Subject: [PATCH 01/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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/25] 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;