From 5c7aeb0ea26cc4a8a8965bad0eaf1f97f6b971c8 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Wed, 4 Nov 2026 13:51:01 -0500 Subject: [PATCH 1/2] fix(payments): preserve existing provider names and use connector-compatible names for new providers --- README.md | 7 + src/core/project/backends/cdk.test.ts | 138 +++++++++++++++++- src/core/project/backends/cdk.ts | 16 +- .../project/backends/cdk/credentials.test.ts | 129 +++++++++++++++- src/core/project/backends/cdk/credentials.ts | 110 ++++++++++++-- 5 files changed, 373 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 07988524a..faa4a50c1 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,13 @@ up from the current directory. Use `--mode browser` for Agent Inspector, `--skip-deploy` to reuse deployed harnesses, and `--agent ` to choose an agent in mixed projects. +New payment credentials receive provider names that payment connectors can reference. +An existing payment credential keeps the provider ARN recorded for its deployment +target. If that ARN is incompatible with a manual connector, deploy stops before +provisioning anything. Add a payment credential under a different name and point +the connector to it; the old provider remains until you remove its credential +from the project and deploy each affected target. + ## Commands | Command | Purpose | diff --git a/src/core/project/backends/cdk.test.ts b/src/core/project/backends/cdk.test.ts index d9099ef84..19d07cf28 100644 --- a/src/core/project/backends/cdk.test.ts +++ b/src/core/project/backends/cdk.test.ts @@ -30,6 +30,10 @@ const TARGET = { } as const; const STACK_ARN = "arn:aws:cloudformation:us-east-1:111122223333:stack/AgentCore-example-default/abc"; +const OLD_PAYMENT_PROVIDER = "example_default_wallet"; +const OLD_PAYMENT_ARN = + "arn:aws:bedrock-agentcore:us-east-1:111122223333:" + + `token-vault/default/paymentcredentialprovider/${OLD_PAYMENT_PROVIDER}`; const json = new FsReadWriteJson({ logger: createSilentLogger() }); /** A template holding only what CDK adds itself, as an empty project synthesizes. */ @@ -609,7 +613,12 @@ describe("CdkBackend.deploy", () => { // The target's name scopes the provider names, so provisioning needs it. expect(handed).toEqual([ - { credentials: subject.credentials, region: TARGET.region, targetName: TARGET.name }, + { + credentials: subject.credentials, + region: TARGET.region, + targetName: TARGET.name, + recorded: {}, + }, ]); // The credential step runs (and its ARNs are recorded) before synthesis, so // the assembly is synthesized against a state file that already describes them. @@ -697,6 +706,60 @@ describe("CdkBackend.deploy", () => { expect(subject.runs).toEqual([]); }); + test("rejects an incompatible recorded payment ARN before provisioning", async () => { + const input = await project(); + input.spec = ProjectSpecSchema.parse({ + name: "example", + version: 2, + credentials: [ + { name: "wallet", authorizerType: "PaymentCredentialProvider", provider: "CoinbaseCDP" }, + ], + payments: [ + { + name: "MyPayments", + connectors: [{ name: "Coinbase", provider: "CoinbaseCDP", credentialName: "wallet" }], + }, + ], + }); + const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH); + await mkdir(dirname(statePath), { recursive: true }); + await writeFile( + statePath, + JSON.stringify({ + targets: { + default: { + resources: { + credentials: { + wallet: { + authorizerType: "PaymentCredentialProvider", + credentialProviderArn: OLD_PAYMENT_ARN, + }, + }, + }, + }, + }, + }), + ); + let provisioned = false; + const provisionCredentials: CredentialProvisioner = async function* () { + provisioned = true; + yield { type: "step", message: "preparing payment credential" }; + return {}; + }; + const subject = harness({ provisionCredentials }); + + await expect(collectDeploy(subject.backend.deploy(input, deployInput()))).rejects.toThrow( + /connector 'Coinbase'.*credential 'wallet'.*target 'default'.*different name/s, + ); + expect(provisioned).toBe(false); + expect(subject.commands).toEqual([]); + expect(subject.runs).toEqual([]); + expect( + JSON.parse(await Bun.file(statePath).text()).targets.default.resources.credentials.wallet + .credentialProviderArn, + ).toBe(OLD_PAYMENT_ARN); + }); + test("will not remove a stack the user did not ask to remove", async () => { const input = await project(); await writeAssembly(input, [TARGET.name], { resources: {} }); @@ -846,7 +909,7 @@ describe("CdkBackend.deploy", () => { const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH); await mkdir(dirname(statePath), { recursive: true }); const recordedWallet = { - credentialProviderArn: "arn:payment:wallet", + credentialProviderArn: OLD_PAYMENT_ARN, authorizerType: "PaymentCredentialProvider" as const, }; await writeFile( @@ -877,7 +940,66 @@ describe("CdkBackend.deploy", () => { credentials: subject.credentials, region: TARGET.region, targetName: TARGET.name, - providers: [{ name: "wallet", authorizerType: "PaymentCredentialProvider" }], + providers: [ + { + name: "wallet", + authorizerType: "PaymentCredentialProvider", + physicalName: OLD_PAYMENT_PROVIDER, + }, + ], + }, + ]); + }); + + test("teardown removes a declared payment credential by its recorded physical name", async () => { + const input = await project(); + input.spec = ProjectSpecSchema.parse({ + name: "example", + version: 2, + credentials: [ + { name: "wallet", authorizerType: "PaymentCredentialProvider", provider: "CoinbaseCDP" }, + ], + }); + await writeAssembly(input, [TARGET.name], { resources: METADATA_ONLY }); + const statePath = join(input.rootPath, DEPLOYED_STATE_RELATIVE_PATH); + await mkdir(dirname(statePath), { recursive: true }); + const recordedWallet = { + credentialProviderArn: OLD_PAYMENT_ARN, + authorizerType: "PaymentCredentialProvider" as const, + }; + await writeFile( + statePath, + JSON.stringify({ + targets: { + default: { + stackArn: "arn:stack:default", + resources: { credentials: { wallet: recordedWallet } }, + }, + }, + }), + ); + + const handed: CredentialRemovalInput[] = []; + const subject = harness({ + provisionCredentials: async function* () { + yield { type: "step", message: "reusing existing payment credential" }; + return { wallet: recordedWallet }; + }, + removeCredentials: async function* (_project, removalInput) { + handed.push(removalInput); + yield { type: "step", message: "removing existing payment credential" }; + }, + }); + + await collectDeploy( + subject.backend.deploy(input, deployInput({ confirmTeardown: async () => true })), + ); + + expect(handed[0]?.providers).toEqual([ + { + name: "wallet", + authorizerType: "PaymentCredentialProvider", + physicalName: OLD_PAYMENT_PROVIDER, }, ]); }); @@ -905,7 +1027,7 @@ describe("CdkBackend.deploy", () => { authorizerType: "ApiKeyCredentialProvider", }, wallet: { - credentialProviderArn: "arn:payment:wallet", + credentialProviderArn: OLD_PAYMENT_ARN, authorizerType: "PaymentCredentialProvider", }, }, @@ -933,7 +1055,13 @@ describe("CdkBackend.deploy", () => { credentials: subject.credentials, region: TARGET.region, targetName: TARGET.name, - providers: [{ name: "wallet", authorizerType: "PaymentCredentialProvider" }], + providers: [ + { + name: "wallet", + authorizerType: "PaymentCredentialProvider", + physicalName: OLD_PAYMENT_PROVIDER, + }, + ], }, ]); // After the stack update, since a resource in the stack may have used the diff --git a/src/core/project/backends/cdk.ts b/src/core/project/backends/cdk.ts index eccd3fbcc..b9e20e645 100644 --- a/src/core/project/backends/cdk.ts +++ b/src/core/project/backends/cdk.ts @@ -32,13 +32,16 @@ import type { import { createCloudFormationClient } from "../../factories"; import type { AwsCredentials, CreateCloudFormationClient } from "../../types"; import { + assertPaymentConnectorCredentials, createCredentialProvisioner, createCredentialRemover, orphanedCredentials, + providerRefFromRecorded, type CredentialProviderCalls, type CredentialProviderRef, type CredentialProvisioner, type CredentialRemover, + type DeployedCredentials, } from "./cdk/credentials"; import { countDeployableResources, @@ -273,6 +276,7 @@ export class CdkBackend implements ProjectBackend { const recorded = (await readDeployedState(this.json, project.rootPath)).targets[target.name]?.resources ?.credentials ?? {}; + assertPaymentConnectorCredentials(project, target.name, recorded); const orphaned = orphanedCredentials(recorded, project.spec.credentials); // Credential providers aren't stack resources; the synthesized app reads their @@ -281,6 +285,7 @@ export class CdkBackend implements ProjectBackend { credentials, region: target.region, targetName: target.name, + recorded, }); // Recorded every deploy (even when empty) so dropping the last credential // from the spec clears the stale entry instead of leaving it advertised. @@ -297,7 +302,7 @@ export class CdkBackend implements ProjectBackend { // with nothing in it as an instruction to delete the stack, and reports that // as an ordinary successful deploy. if ((await countDeployableResources(this.json, assemblyDirectory, artifact)) === 0) { - return yield* this.teardown({ project, artifact, input, options, orphaned }); + return yield* this.teardown({ project, artifact, input, options, orphaned, recorded }); } // Only after a real deploy is confirmed: a teardown (empty assembly) returns @@ -431,6 +436,7 @@ export class CdkBackend implements ProjectBackend { input, options, orphaned, + recorded, }: { project: Project; artifact: StackArtifact; @@ -438,6 +444,7 @@ export class CdkBackend implements ProjectBackend { options: CdkRunOptions; /** Providers recorded for this target before the deploy that the spec no longer declares. */ orphaned: CredentialProviderRef[]; + recorded: DeployedCredentials; }): AsyncGenerator { const { target } = input; if (!(await this.describeStack(target.region, options.credentials, artifact.stackName))) { @@ -472,7 +479,12 @@ export class CdkBackend implements ProjectBackend { credentials: options.credentials, region: target.region, targetName: target.name, - providers: [...orphaned, ...project.spec.credentials], + providers: [ + ...orphaned, + ...project.spec.credentials.map((credential) => + providerRefFromRecorded(credential, recorded), + ), + ], }); await removeTargetState(this.json, project.rootPath, target.name); return { outputs: {}, tornDown: true }; diff --git a/src/core/project/backends/cdk/credentials.test.ts b/src/core/project/backends/cdk/credentials.test.ts index 820b4852e..373baa167 100644 --- a/src/core/project/backends/cdk/credentials.test.ts +++ b/src/core/project/backends/cdk/credentials.test.ts @@ -14,6 +14,7 @@ import type { CoreOptions } from "../../../types"; import { EnvLocalFile } from "../../envLocal"; import { v0Fixture } from "../../legacy/fixtures-test-support"; import { + assertPaymentConnectorCredentials, createCredentialProvisioner, createCredentialRemover, orphanedCredentials, @@ -59,7 +60,11 @@ const COINBASE = { */ const OPENAI_PROVIDER = "example_dev_openai-key"; const OAUTH_PROVIDER = "example_dev_my-oauth"; -const WALLET_PROVIDER = "example_dev_wallet"; +const WALLET_PROVIDER = "example-dev-wallet-02961cdcdac06c7f"; +const OLD_WALLET_PROVIDER = "example_dev_wallet"; +const PAY_PROVIDER = "example-dev-pay-a5e6e3eef03187be"; +const paymentArn = (name: string) => + `arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/paymentcredentialprovider/${name}`; const tempDirectories: string[] = []; afterEach(async () => { @@ -291,8 +296,9 @@ async function collect( async function run( provision: CredentialProvisioner, input: Project, + target: CredentialProvisionInput = INPUT, ): Promise<{ events: ProjectEvent[]; result: DeployedCredentials }> { - const generator = provision(input, INPUT); + const generator = provision(input, target); const events: ProjectEvent[] = []; while (true) { const next = await generator.next(); @@ -715,8 +721,60 @@ describe("createCredentialProvisioner", () => { }); }); + test("uses a connector-compatible name for a new credential containing underscores", async () => { + const subject = account(); + const input = await project( + [{ ...COINBASE, name: "wallet_us" }], + "AGENTCORE_CREDENTIAL_WALLET_US_API_KEY_ID='key-1'\n" + + "AGENTCORE_CREDENTIAL_WALLET_US_API_KEY_SECRET='key-secret'\n" + + "AGENTCORE_CREDENTIAL_WALLET_US_WALLET_SECRET='wallet-secret'\n", + ); + + await run(subject.provision, input, { ...INPUT, targetName: "dev-stage" }); + + const [name] = Object.keys(subject.contents()); + expect(name).toMatch(/^example-dev-stage-wallet-us-[a-f0-9]{16}$/); + expect(name).toMatch(/^[a-zA-Z0-9.-]+$/); + }); + + test("reuses the recorded physical name of an existing payment provider", async () => { + const arn = paymentArn(OLD_WALLET_PROVIDER); + const subject = account({ [OLD_WALLET_PROVIDER]: { credentialProviderArn: arn } }); + const input = await project([COINBASE]); + + const { result } = await run(subject.provision, input, { + ...INPUT, + recorded: { wallet: { credentialProviderArn: arn } }, + }); + + expect(Object.keys(subject.contents())).toEqual([OLD_WALLET_PROVIDER]); + expect(result.wallet?.credentialProviderArn).toBe(arn); + expect(subject.contents()[OLD_WALLET_PROVIDER]?.operation).toBeUndefined(); + }); + + test("keeps normalized payment names distinct after the display prefix is truncated", async () => { + const names = ["w".repeat(127) + "x", "w".repeat(127) + "y"]; + const processEnv = Object.fromEntries( + names.flatMap((name) => [ + [`AGENTCORE_CREDENTIAL_${name.toUpperCase()}_API_KEY_ID`, "key-1"], + [`AGENTCORE_CREDENTIAL_${name.toUpperCase()}_API_KEY_SECRET`, "key-secret"], + [`AGENTCORE_CREDENTIAL_${name.toUpperCase()}_WALLET_SECRET`, "wallet-secret"], + ]), + ); + const subject = account({}, {}, processEnv); + const input = await project(names.map((name) => ({ ...COINBASE, name }))); + + await run(subject.provision, input); + + const physicalNames = Object.keys(subject.contents()); + expect(physicalNames).toHaveLength(2); + expect(new Set(physicalNames).size).toBe(2); + expect(physicalNames.every((name) => name.length <= 128)).toBe(true); + expect(physicalNames.every((name) => /^[a-zA-Z0-9.-]+$/.test(name))).toBe(true); + }); + test("updates an existing StripePrivy payment provider", async () => { - const subject = account({ example_dev_pay: { credentialProviderArn: "arn:existing" } }); + const subject = account({ [PAY_PROVIDER]: { credentialProviderArn: "arn:existing" } }); const input = await project( [{ authorizerType: "PaymentCredentialProvider", name: "pay", provider: "StripePrivy" }], "AGENTCORE_CREDENTIAL_PAY_APP_ID='app-1'\n" + @@ -727,9 +785,9 @@ describe("createCredentialProvisioner", () => { await run(subject.provision, input); - expect(subject.contents()["example_dev_pay"]?.operation).toBe("updated"); - expect(subject.configFor("example_dev_pay")).toEqual({ - name: "example_dev_pay", + expect(subject.contents()[PAY_PROVIDER]?.operation).toBe("updated"); + expect(subject.configFor(PAY_PROVIDER)).toEqual({ + name: PAY_PROVIDER, credentialProviderVendor: "StripePrivy", providerConfigurationInput: { stripePrivyConfiguration: { @@ -938,6 +996,45 @@ describe("orphanedCredentials", () => { { name: "openai-key", authorizerType: "ApiKeyCredentialProvider" }, ]); }); + + test("retains an old payment provider while declared and identifies it when removed", async () => { + const recorded = { + wallet: recordedAs("PaymentCredentialProvider", { + credentialProviderArn: paymentArn(OLD_WALLET_PROVIDER), + }), + }; + const input = await project([COINBASE]); + + expect(orphanedCredentials(recorded, input.spec.credentials)).toEqual([]); + expect(orphanedCredentials(recorded, [])).toEqual([ + { + name: "wallet", + authorizerType: "PaymentCredentialProvider", + physicalName: OLD_WALLET_PROVIDER, + }, + ]); + }); +}); + +test("a manual connector can reuse a compatible recorded payment ARN", async () => { + const input = await project([COINBASE]); + input.spec = ProjectSpecSchema.parse({ + ...input.spec, + payments: [ + { + name: "MyPayments", + connectors: [{ name: "Coinbase", provider: "CoinbaseCDP", credentialName: "wallet" }], + }, + ], + }); + + expect(() => + assertPaymentConnectorCredentials(input, "dev", { + wallet: recordedAs("PaymentCredentialProvider", { + credentialProviderArn: paymentArn("manually-created-wallet"), + }), + }), + ).not.toThrow(); }); describe("createCredentialRemover", () => { @@ -984,6 +1081,26 @@ describe("createCredentialRemover", () => { ]); }); + test("deletes the recorded old physical name after the credential is removed", async () => { + const subject = account({ + [OLD_WALLET_PROVIDER]: { credentialProviderArn: paymentArn(OLD_WALLET_PROVIDER) }, + [WALLET_PROVIDER]: { credentialProviderArn: paymentArn(WALLET_PROVIDER) }, + }); + const removed = orphanedCredentials( + { + wallet: recordedAs("PaymentCredentialProvider", { + credentialProviderArn: paymentArn(OLD_WALLET_PROVIDER), + }), + }, + [], + ); + + await remove(subject, removed); + + expect(subject.deleted).toEqual([{ kind: "payment", name: OLD_WALLET_PROVIDER }]); + expect(Object.keys(subject.contents())).toEqual([WALLET_PROVIDER]); + }); + test("reports a provider it could not delete, naming the command that deletes its kind", async () => { const existing = { credentialProviderArn: "arn:apikey" }; const subject = account( diff --git a/src/core/project/backends/cdk/credentials.ts b/src/core/project/backends/cdk/credentials.ts index f44b0aa17..adc431584 100644 --- a/src/core/project/backends/cdk/credentials.ts +++ b/src/core/project/backends/cdk/credentials.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { join } from "node:path"; import { ResourceNotFoundException, @@ -46,19 +47,75 @@ function keepsV0CredentialNames(project: Project): boolean { } /** - * The Identity name a credential's provider is created under. Scoped to the project - * and target so two targets in one account and region never share a provider. + * The Identity name a credential's provider is created under. Existing payment + * providers keep the physical name in deployed state. New payment providers need + * a name whose ARN CreatePaymentConnector can accept. * - * A v0 project, and one migrated from v0, keeps the bare credential name v0 created - * its providers under: the agent code it deployed looks them up by that name, and a - * scoped name would create a second set of providers beside the ones still in use. + * A raw v0 project without recorded payment state keeps v0's bare credential + * name. Other existing payment providers use the recorded physical name, so + * upgrading the CLI does not replace one that agent code may still use. */ -function providerName(project: Project, targetName: string, credentialName: string): string { - if (keepsV0CredentialNames(project)) return credentialName; - return `${project.name}_${targetName}_${credentialName}`; +function providerName( + project: Project, + targetName: string, + credential: CredentialProviderRef, + recorded: DeployedCredentials, +): string { + if (credential.authorizerType === "PaymentCredentialProvider") { + const existing = recorded[credential.name]; + if (existing) + return paymentProviderNameFromArn(credential.name, existing.credentialProviderArn); + if (project.legacy) return credential.name; + + const parts = [project.name, targetName, credential.name]; + const suffix = createHash("sha256").update(JSON.stringify(parts)).digest("hex").slice(0, 16); + const prefix = parts.join("-").replaceAll("_", "-"); + return `${prefix.slice(0, PROVIDER_NAME_MAX_LENGTH - suffix.length - 1)}-${suffix}`; + } + if (keepsV0CredentialNames(project)) return credential.name; + return `${project.name}_${targetName}_${credential.name}`; } const PROVIDER_NAME_MAX_LENGTH = 128; +const CONNECTOR_PAYMENT_NAME_PATTERN = /^[a-zA-Z0-9.-]+$/; + +function paymentProviderNameFromArn(credentialName: string, arn: string): string { + const name = /\/paymentcredentialprovider\/([^/]+)$/.exec(arn)?.[1]; + if (!name) { + throw new ProjectStateError( + `Credential '${credentialName}' has an invalid recorded payment provider ARN '${arn}' ` + + "in agentcore/.cli/deployed-state.json.", + ); + } + return name; +} + +/** Checks recorded ARNs before deploy can write any provider or CloudFormation resource. */ +export function assertPaymentConnectorCredentials( + project: Project, + targetName: string, + recorded: DeployedCredentials, +): void { + for (const manager of project.spec.payments ?? []) { + for (const connector of manager.connectors) { + if (!connector.credentialName) continue; + const existing = recorded[connector.credentialName]; + if (!existing) continue; + const name = paymentProviderNameFromArn( + connector.credentialName, + existing.credentialProviderArn, + ); + if (CONNECTOR_PAYMENT_NAME_PATTERN.test(name)) continue; + throw new ProjectStateError( + `Payment connector '${connector.name}' in manager '${manager.name}' cannot use ` + + `credential '${connector.credentialName}' on target '${targetName}': its recorded ` + + `provider ARN '${existing.credentialProviderArn}' has a provider name that ` + + `CreatePaymentConnector rejects. Create a payment credential under a different ` + + `name, point the connector to it, then deploy. The existing provider is unchanged.`, + ); + } + } +} const DELETE_COMMANDS: Record = { ApiKeyCredentialProvider: "delete-api-key-credential-provider", @@ -101,6 +158,8 @@ export type CredentialProvisionInput = { /** Credential provider shared with the rest of the deployment preflight. */ credentials: CdkCredentialProvider; targetName: string; + /** Existing target state, used to preserve deployed payment provider names. */ + recorded?: DeployedCredentials; }; export type CredentialProvisioner = ( @@ -109,7 +168,9 @@ export type CredentialProvisioner = ( ) => AsyncGenerator; /** A credential's spec name and provider kind, enough to name and delete its provider. */ -export type CredentialProviderRef = Pick; +export type CredentialProviderRef = Pick & { + physicalName?: string; +}; export type CredentialRemovalInput = CredentialProvisionInput & { /** The credentials whose providers to delete. The caller decides the set. */ @@ -136,10 +197,27 @@ export function orphanedCredentials( ): CredentialProviderRef[] { const names = new Set(declared.map(({ name }) => name)); return Object.entries(recorded).flatMap(([name, { authorizerType }]) => - authorizerType && !names.has(name) ? [{ name, authorizerType }] : [], + authorizerType && !names.has(name) + ? [providerRefFromRecorded({ name, authorizerType }, recorded)] + : [], ); } +/** Carries a payment provider's recorded physical name into an explicit removal. */ +export function providerRefFromRecorded( + credential: CredentialProviderRef, + recorded: DeployedCredentials, +): CredentialProviderRef { + const existing = recorded[credential.name]; + return existing && credential.authorizerType === "PaymentCredentialProvider" + ? { + name: credential.name, + authorizerType: credential.authorizerType, + physicalName: paymentProviderNameFromArn(credential.name, existing.credentialProviderArn), + } + : { name: credential.name, authorizerType: credential.authorizerType }; +} + /** * Deletes the given credentials' providers under their target-scoped names. * @@ -158,8 +236,9 @@ export function createCredentialRemover(identity: ProviderDeletes): CredentialRe { region, credentials, targetName, providers }, ) { const options: CoreOptions = { region, credentials }; - for (const { name, authorizerType } of providers) { - const provider = providerName(project, targetName, name); + for (const { name, authorizerType, physicalName } of providers) { + const provider = + physicalName ?? providerName(project, targetName, { name, authorizerType }, {}); if (keepsV0CredentialNames(project) && authorizerType !== "PaymentCredentialProvider") { yield { type: "step", @@ -200,7 +279,10 @@ export function createCredentialProvisioner( identity: CredentialProviderCalls, processEnv: Record = process.env, ): CredentialProvisioner { - return async function* provisionCredentials(project, { region, credentials, targetName }) { + return async function* provisionCredentials( + project, + { region, credentials, targetName, recorded = {} }, + ) { const declared = project.spec.credentials; if (declared.length === 0) return {}; @@ -227,7 +309,7 @@ export function createCredentialProvisioner( // Every name is composed and checked before the first lookup, so one that is // too long fails the deploy before it touches Identity. const named: NamedCredential[] = declared.map((credential) => { - const provider = providerName(project, targetName, credential.name); + const provider = providerName(project, targetName, credential, recorded); if (provider.length > PROVIDER_NAME_MAX_LENGTH) { throw new ProjectStateError( `Credential '${credential.name}' would create a credential provider named ` + From 12e7b6c80b0c1073d85f8e63f7c3c6c3b7288062 Mon Sep 17 00:00:00 2001 From: Nicolas Borges Date: Thu, 5 Nov 2026 15:25:48 -0500 Subject: [PATCH 2/2] fix(payments): reject unrecorded legacy credential providers --- .../project/backends/cdk/credentials.test.ts | 20 +++++++++++++ src/core/project/backends/cdk/credentials.ts | 30 +++++++++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) diff --git a/src/core/project/backends/cdk/credentials.test.ts b/src/core/project/backends/cdk/credentials.test.ts index 373baa167..1f3b56dfc 100644 --- a/src/core/project/backends/cdk/credentials.test.ts +++ b/src/core/project/backends/cdk/credentials.test.ts @@ -752,6 +752,26 @@ describe("createCredentialProvisioner", () => { expect(subject.contents()[OLD_WALLET_PROVIDER]?.operation).toBeUndefined(); }); + test("fails before writing when an old payment provider is not recorded for the target", async () => { + const oldProvider = { credentialProviderArn: paymentArn(OLD_WALLET_PROVIDER) }; + const subject = account({ [OLD_WALLET_PROVIDER]: oldProvider }); + const input = await project( + [API_KEY, COINBASE], + "AGENTCORE_CREDENTIAL_OPENAI_KEY='sk-live'\n" + + "AGENTCORE_CREDENTIAL_WALLET_API_KEY_ID='key-1'\n" + + "AGENTCORE_CREDENTIAL_WALLET_API_KEY_SECRET='key-secret'\n" + + "AGENTCORE_CREDENTIAL_WALLET_WALLET_SECRET='wallet-secret'\n", + ); + + await expect(run(subject.provision, input)).rejects.toThrow( + `Payment credential 'wallet' has no provider recorded for target 'dev' in ` + + `agentcore/.cli/deployed-state.json, but provider '${OLD_WALLET_PROVIDER}' exists in AWS. ` + + `Restore the target's deployed-state.json entry, or delete that provider if unused, ` + + `then re-deploy.`, + ); + expect(subject.contents()).toEqual({ [OLD_WALLET_PROVIDER]: oldProvider }); + }); + test("keeps normalized payment names distinct after the display prefix is truncated", async () => { const names = ["w".repeat(127) + "x", "w".repeat(127) + "y"]; const processEnv = Object.fromEntries( diff --git a/src/core/project/backends/cdk/credentials.ts b/src/core/project/backends/cdk/credentials.ts index adc431584..7f8d1cb0d 100644 --- a/src/core/project/backends/cdk/credentials.ts +++ b/src/core/project/backends/cdk/credentials.ts @@ -46,6 +46,15 @@ function keepsV0CredentialNames(project: Project): boolean { return project.legacy !== undefined || project.spec.migratedFrom === "v1"; } +function previousProviderName( + project: Project, + targetName: string, + credentialName: string, +): string { + if (keepsV0CredentialNames(project)) return credentialName; + return `${project.name}_${targetName}_${credentialName}`; +} + /** * The Identity name a credential's provider is created under. Existing payment * providers keep the physical name in deployed state. New payment providers need @@ -72,8 +81,7 @@ function providerName( const prefix = parts.join("-").replaceAll("_", "-"); return `${prefix.slice(0, PROVIDER_NAME_MAX_LENGTH - suffix.length - 1)}-${suffix}`; } - if (keepsV0CredentialNames(project)) return credential.name; - return `${project.name}_${targetName}_${credential.name}`; + return previousProviderName(project, targetName, credential.name); } const PROVIDER_NAME_MAX_LENGTH = 128; @@ -326,6 +334,24 @@ export function createCredentialProvisioner( // first provider is written, not partway through the list. const plans: (NamedCredential & { provision: Provision })[] = []; for (const { credential, provider } of named) { + // Lost target state must not turn an existing payment provider into an + // untracked orphan while a newly named provider takes its place. + if (credential.authorizerType === "PaymentCredentialProvider" && !recorded[credential.name]) { + const previousName = previousProviderName(project, targetName, credential.name); + if ( + previousName !== provider && + (await undefinedWhenAbsent(() => + identity.getPaymentCredentialProvider(previousName, options), + )) + ) { + throw new ProjectStateError( + `Payment credential '${credential.name}' has no provider recorded for target ` + + `'${targetName}' in agentcore/.cli/deployed-state.json, but provider ` + + `'${previousName}' exists in AWS. Restore the target's deployed-state.json ` + + `entry, or delete that provider if unused, then re-deploy.`, + ); + } + } plans.push({ credential, provider,