From 953439f643e555332aaac33cdcd656bbd834c3d8 Mon Sep 17 00:00:00 2001 From: gitikavj Date: Mon, 12 Oct 2026 19:08:43 +0000 Subject: [PATCH] feat(compat): keep v0 credential provider names v0 named Identity credential providers by the bare credential name, and the agent code it deployed looks them up by that name. For v0 projects, provision, update, record and remove providers under that bare name instead of the project/target-scoped v1 name, so a deploy never creates a second set of providers beside the ones in use. v0 providers can be shared between projects in an account, so teardown and orphan removal never delete a v0 project's API key or OAuth providers. Payment providers are still deleted, as v0's teardown did. v1 projects are unchanged. --- .../project/backends/cdk/credentials.test.ts | 107 ++++++++++++++++++ src/core/project/backends/cdk/credentials.ts | 24 +++- 2 files changed, 127 insertions(+), 4 deletions(-) diff --git a/src/core/project/backends/cdk/credentials.test.ts b/src/core/project/backends/cdk/credentials.test.ts index 49aea80bc..63903c7e2 100644 --- a/src/core/project/backends/cdk/credentials.test.ts +++ b/src/core/project/backends/cdk/credentials.test.ts @@ -12,6 +12,8 @@ import type { Project, ProjectEvent } from "../../../../handlers/project/types"; import { ProjectSpecSchema } from "../../../../projectSchemas/project"; import type { CoreOptions } from "../../../types"; import { EnvLocalFile } from "../../envLocal"; +import { v0Fixture } from "../../legacy/fixtures-test-support"; +import { translateV0Spec } from "../../legacy/translate"; import { createCredentialProvisioner, createCredentialRemover, @@ -80,6 +82,15 @@ async function project(credentials: unknown[], envLocal?: string): Promise { + const v0Spec = v0Fixture("baseline.json"); + return { + ...(await project(credentials, envLocal)), + legacy: { format: "v0", v0Spec, extras: translateV0Spec(v0Spec).extras }, + }; +} + type ProviderKind = "apikey" | "oauth" | "payment"; /** @@ -986,3 +997,99 @@ describe("createCredentialRemover", () => { expect(subject.contents()).toEqual({ [OPENAI_PROVIDER]: existing }); }); }); + +describe("v0 projects", () => { + // The v0 CLI named every provider by the bare credential name, and the agent code it + // deployed looks providers up by that name, so a v0 project keeps it. + const COINBASE_ENV = + "AGENTCORE_CREDENTIAL_WALLET_API_KEY_ID='key-1'\n" + + "AGENTCORE_CREDENTIAL_WALLET_API_KEY_SECRET='secret-1'\n" + + "AGENTCORE_CREDENTIAL_WALLET_WALLET_SECRET='wallet-1'\n"; + + test("creates providers under the bare credential name, recorded under the spec name", async () => { + const subject = account(); + const input = await legacyProject( + [API_KEY, COINBASE], + "AGENTCORE_CREDENTIAL_OPENAI_KEY='sk-live'\n" + COINBASE_ENV, + ); + + const { events, result } = await run(subject.provision, input); + + expect(stepMessages(events)).toEqual([ + "Preparing credential provider 'openai-key'", + "Preparing credential provider 'wallet'", + ]); + expect(Object.keys(subject.contents())).toEqual(["openai-key", "wallet"]); + expect(subject.configFor("openai-key")).toEqual({ name: "openai-key", apiKey: "sk-live" }); + expect(result).toEqual({ + "openai-key": recordedAs("ApiKeyCredentialProvider", { + credentialProviderArn: "arn:apikey:openai-key", + clientSecretArn: "arn:secret:openai-key", + }), + wallet: recordedAs("PaymentCredentialProvider", { + credentialProviderArn: "arn:payment:wallet", + }), + }); + }); + + test("updates the provider v0 created rather than creating a scoped one", async () => { + const subject = account({ "my-oauth": { credentialProviderArn: "arn:oauth:my-oauth" } }); + const input = await legacyProject( + [OAUTH], + "AGENTCORE_CREDENTIAL_MY_OAUTH_CLIENT_SECRET='rotated'\n", + ); + + const { result } = await run(subject.provision, input); + + expect(Object.keys(subject.contents())).toEqual(["my-oauth"]); + expect(subject.contents()["my-oauth"]?.operation).toBe("updated"); + expect(result["my-oauth"]?.credentialProviderArn).toBe("arn:oauth:my-oauth"); + }); + + test("reuses the provider v0 created when no secret is available locally", async () => { + const existing = { credentialProviderArn: "arn:apikey:openai-key" }; + const subject = account({ "openai-key": existing }); + + const { result } = await run(subject.provision, await legacyProject([API_KEY])); + + expect(subject.contents()).toEqual({ "openai-key": existing }); + expect(result).toEqual({ "openai-key": recordedAs("ApiKeyCredentialProvider", existing) }); + }); + + test("rolls back a provider it created under the bare name", async () => { + const subject = account({}, { createFailsFor: "wallet" }); + const input = await legacyProject( + [API_KEY, COINBASE], + "AGENTCORE_CREDENTIAL_OPENAI_KEY='sk-live'\n" + COINBASE_ENV, + ); + + await runFailing(subject.provision, input); + + expect(subject.deleted).toEqual([{ kind: "apikey", name: "openai-key" }]); + }); + + test("never deletes API key or OAuth providers, which v0 projects may share", async () => { + const subject = account({ + "openai-key": { credentialProviderArn: "arn:apikey:openai-key" }, + "my-oauth": { credentialProviderArn: "arn:oauth:my-oauth" }, + wallet: { credentialProviderArn: "arn:payment:wallet" }, + }); + const input = await legacyProject([]); + + const { events } = await collect( + createCredentialRemover(subject.client)(input, { + ...INPUT, + providers: [API_KEY, OAUTH, COINBASE], + }), + ); + + // Payment providers are removed as v0's teardown removed them, by the bare name. + expect(subject.deleted).toEqual([{ kind: "payment", name: "wallet" }]); + expect(Object.keys(subject.contents())).toEqual(["openai-key", "my-oauth"]); + expect(stepMessages(events)).toEqual([ + "Leaving credential provider 'openai-key' in place: v0 projects can share it", + "Leaving credential provider 'my-oauth' in place: v0 projects can share it", + "Removing credential provider 'wallet'", + ]); + }); +}); diff --git a/src/core/project/backends/cdk/credentials.ts b/src/core/project/backends/cdk/credentials.ts index 654b1bd77..0c782f888 100644 --- a/src/core/project/backends/cdk/credentials.ts +++ b/src/core/project/backends/cdk/credentials.ts @@ -39,9 +39,14 @@ export type DeployedCredentials = Record; /** * 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. + * + * A v0 project 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. */ -function providerName(projectName: string, targetName: string, credentialName: string): string { - return `${projectName}_${targetName}_${credentialName}`; +function providerName(project: Project, targetName: string, credentialName: string): string { + if (project.legacy) return credentialName; + return `${project.name}_${targetName}_${credentialName}`; } const PROVIDER_NAME_MAX_LENGTH = 128; @@ -133,6 +138,10 @@ export function orphanedCredentials( * nothing outside this target can be using it. A provider that is already gone is * not an error, and a provider that cannot be deleted is reported rather than * failing the deploy that already changed the stack. + * + * A v0 project's providers carry the bare credential name, so another project in the + * account can be using the same API key or OAuth provider; those are never deleted. + * Payment providers are deleted, as v0's own teardown deleted them. */ export function createCredentialRemover(identity: ProviderDeletes): CredentialRemover { return async function* removeCredentials( @@ -141,7 +150,14 @@ export function createCredentialRemover(identity: ProviderDeletes): CredentialRe ) { const options: CoreOptions = { region, credentials }; for (const { name, authorizerType } of providers) { - const provider = providerName(project.name, targetName, name); + const provider = providerName(project, targetName, name); + if (project.legacy && authorizerType !== "PaymentCredentialProvider") { + yield { + type: "step", + message: `Leaving credential provider '${provider}' in place: v0 projects can share it`, + }; + continue; + } yield { type: "step", message: `Removing credential provider '${provider}'` }; try { await deleteProvider(identity, authorizerType, provider, options); @@ -202,7 +218,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.name, targetName, credential.name); + const provider = providerName(project, targetName, credential.name); if (provider.length > PROVIDER_NAME_MAX_LENGTH) { throw new ProjectStateError( `Credential '${credential.name}' would create a credential provider named ` +