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 ` +