Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions src/core/project/backends/cdk/credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -80,6 +82,15 @@ async function project(credentials: unknown[], envLocal?: string): Promise<Proje
};
}

/** The same project loaded from a v0 `agentcore.json`. */
async function legacyProject(credentials: unknown[], envLocal?: string): Promise<Project> {
const v0Spec = v0Fixture("baseline.json");
return {
...(await project(credentials, envLocal)),
legacy: { format: "v0", v0Spec, extras: translateV0Spec(v0Spec).extras },
};
}

type ProviderKind = "apikey" | "oauth" | "payment";

/**
Expand Down Expand Up @@ -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'",
]);
});
});
24 changes: 20 additions & 4 deletions src/core/project/backends/cdk/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,14 @@ export type DeployedCredentials = Record<string, DeployedCredential>;
/**
* 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;
Expand Down Expand Up @@ -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(
Expand All @@ -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);
Expand Down Expand Up @@ -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 ` +
Expand Down
Loading