Skip to content

Commit c13775a

Browse files
authored
feat: smart credential detection for multi-agent projects (#182)
* feat: smart credential detection for multi-agent projects - Add resolveCredentialStrategy() to detect same vs different API keys - Same key reuses project-scoped credential, different key creates agent-scoped - Clean up agent-scoped credentials on agent removal - Fix deploy flow: always update credentials on deploy (not just manual entry) - Add getAllCredentials() for credential prompt, updateApiKeyProvider() for updates - Fix double-execution bugs in useCdkPreflight and CredentialSourcePrompt - Add unit and integration tests * fix: type errors in multi-agent credential code - Add non-null assertions for credentials[0] after length check - Fix credential type 'ApiKey' -> 'ApiKeyCredentialProvider' in tests - Fix result.agentName -> result.providerName in useCdkPreflight * fix: reuse credentials across agents with same key, preserve on removal - Check ALL existing credentials for matching API key (not just project-scoped) - Agent3 can now reuse Agent2's credential if they share the same key - Preserve credentials on agent removal for potential reuse - If agent re-added with different key, credential is updated
1 parent 96c99c8 commit c13775a

20 files changed

Lines changed: 1256 additions & 102 deletions
Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,282 @@
1+
/* eslint-disable security/detect-non-literal-fs-filename */
2+
import { runCLI } from '../../../../test-utils/index.js';
3+
import { randomUUID } from 'node:crypto';
4+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
5+
import { tmpdir } from 'node:os';
6+
import { join } from 'node:path';
7+
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
8+
9+
/**
10+
* Integration tests for multi-agent credential behavior (Option C).
11+
*
12+
* Tests the smart credential detection:
13+
* - Same API key → reuse existing project-scoped credential
14+
* - Different API key → create agent-scoped credential
15+
* - Remove agent → clean up agent-scoped credentials
16+
*/
17+
describe('multi-agent credential behavior', () => {
18+
let testDir: string;
19+
let projectDir: string;
20+
const projectName = 'MultiAgentProj';
21+
22+
beforeAll(async () => {
23+
testDir = join(tmpdir(), `agentcore-multi-agent-cred-${randomUUID()}`);
24+
await mkdir(testDir, { recursive: true });
25+
26+
// Create project without agent
27+
const result = await runCLI(['create', '--name', projectName, '--no-agent'], testDir);
28+
if (result.exitCode !== 0) {
29+
throw new Error(`Failed to create project: ${result.stdout} ${result.stderr}`);
30+
}
31+
projectDir = join(testDir, projectName);
32+
});
33+
34+
afterAll(async () => {
35+
await rm(testDir, { recursive: true, force: true });
36+
});
37+
38+
async function readProjectSpec() {
39+
const content = await readFile(join(projectDir, 'agentcore/agentcore.json'), 'utf-8');
40+
return JSON.parse(content);
41+
}
42+
43+
async function readEnvLocal() {
44+
try {
45+
return await readFile(join(projectDir, 'agentcore/.env.local'), 'utf-8');
46+
} catch {
47+
return '';
48+
}
49+
}
50+
51+
describe('credential reuse with same API key', () => {
52+
it('first agent creates project-scoped credential', async () => {
53+
const result = await runCLI(
54+
[
55+
'add',
56+
'agent',
57+
'--name',
58+
'Agent1',
59+
'--language',
60+
'Python',
61+
'--framework',
62+
'Strands',
63+
'--model-provider',
64+
'Gemini',
65+
'--api-key',
66+
'KEY1',
67+
'--memory',
68+
'none',
69+
'--json',
70+
],
71+
projectDir
72+
);
73+
74+
expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0);
75+
76+
const spec = await readProjectSpec();
77+
expect(spec.credentials).toHaveLength(1);
78+
expect(spec.credentials[0].name).toBe(`${projectName}Gemini`);
79+
80+
const env = await readEnvLocal();
81+
expect(env).toContain('AGENTCORE_CREDENTIAL_MULTIAGENTPROJGEMINI=');
82+
expect(env).toContain('KEY1');
83+
});
84+
85+
it('second agent with same key reuses credential (no duplicate)', async () => {
86+
const result = await runCLI(
87+
[
88+
'add',
89+
'agent',
90+
'--name',
91+
'Agent2',
92+
'--language',
93+
'Python',
94+
'--framework',
95+
'Strands',
96+
'--model-provider',
97+
'Gemini',
98+
'--api-key',
99+
'KEY1',
100+
'--memory',
101+
'none',
102+
'--json',
103+
],
104+
projectDir
105+
);
106+
107+
expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0);
108+
109+
const spec = await readProjectSpec();
110+
// Should still have only 1 credential (reused)
111+
expect(spec.credentials).toHaveLength(1);
112+
expect(spec.credentials[0].name).toBe(`${projectName}Gemini`);
113+
114+
// Should have 2 agents
115+
expect(spec.agents).toHaveLength(2);
116+
});
117+
});
118+
119+
describe('agent-scoped credential with different API key', () => {
120+
it('third agent with different key creates agent-scoped credential', async () => {
121+
const result = await runCLI(
122+
[
123+
'add',
124+
'agent',
125+
'--name',
126+
'Agent3',
127+
'--language',
128+
'Python',
129+
'--framework',
130+
'Strands',
131+
'--model-provider',
132+
'Gemini',
133+
'--api-key',
134+
'KEY2',
135+
'--memory',
136+
'none',
137+
'--json',
138+
],
139+
projectDir
140+
);
141+
142+
expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0);
143+
144+
const spec = await readProjectSpec();
145+
// Should now have 2 credentials
146+
expect(spec.credentials).toHaveLength(2);
147+
148+
const credNames = spec.credentials.map((c: { name: string }) => c.name);
149+
expect(credNames).toContain(`${projectName}Gemini`);
150+
expect(credNames).toContain(`${projectName}Agent3Gemini`);
151+
152+
// Should have 3 agents
153+
expect(spec.agents).toHaveLength(3);
154+
155+
// .env.local should have both keys
156+
const env = await readEnvLocal();
157+
expect(env).toContain('AGENTCORE_CREDENTIAL_MULTIAGENTPROJGEMINI=');
158+
expect(env).toContain('KEY1');
159+
expect(env).toContain('AGENTCORE_CREDENTIAL_MULTIAGENTPROJAGENT3GEMINI=');
160+
expect(env).toContain('KEY2');
161+
162+
// Generated code should reference correct credentials
163+
const agent1Code = await readFile(join(projectDir, 'app/Agent1/model/load.py'), 'utf-8');
164+
expect(agent1Code).toContain(`IDENTITY_PROVIDER_NAME = "${projectName}Gemini"`);
165+
166+
const agent3Code = await readFile(join(projectDir, 'app/Agent3/model/load.py'), 'utf-8');
167+
expect(agent3Code).toContain(`IDENTITY_PROVIDER_NAME = "${projectName}Agent3Gemini"`);
168+
});
169+
});
170+
171+
describe('credential persistence on agent removal', () => {
172+
it('removing agent preserves agent-scoped credential for reuse', async () => {
173+
const result = await runCLI(['remove', 'agent', '--name', 'Agent3', '--json'], projectDir);
174+
175+
expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0);
176+
177+
const spec = await readProjectSpec();
178+
// Credentials preserved (both project-scoped and agent-scoped)
179+
expect(spec.credentials).toHaveLength(2);
180+
expect(spec.credentials.map((c: { name: string }) => c.name)).toContain(`${projectName}Gemini`);
181+
expect(spec.credentials.map((c: { name: string }) => c.name)).toContain(`${projectName}Agent3Gemini`);
182+
183+
// Should have 2 agents
184+
expect(spec.agents).toHaveLength(2);
185+
});
186+
187+
it('removing agent with shared credential preserves credential', async () => {
188+
// Remove Agent2 (uses shared project-scoped credential)
189+
const result = await runCLI(['remove', 'agent', '--name', 'Agent2', '--json'], projectDir);
190+
191+
expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0);
192+
193+
const spec = await readProjectSpec();
194+
// Both credentials still exist
195+
expect(spec.credentials).toHaveLength(2);
196+
197+
// Should have 1 agent
198+
expect(spec.agents).toHaveLength(1);
199+
});
200+
});
201+
202+
describe('BYO (bring-your-own) agent path', () => {
203+
it('BYO agent with same key reuses credential', async () => {
204+
// Create a code directory for BYO agent
205+
const byoDir = join(projectDir, 'app/ByoAgent');
206+
await mkdir(byoDir, { recursive: true });
207+
await writeFile(join(byoDir, 'main.py'), '# BYO agent');
208+
209+
const specBefore = await readProjectSpec();
210+
const credCountBefore = specBefore.credentials.length;
211+
212+
const result = await runCLI(
213+
[
214+
'add',
215+
'agent',
216+
'--name',
217+
'ByoAgent',
218+
'--type',
219+
'byo',
220+
'--language',
221+
'Python',
222+
'--framework',
223+
'Strands',
224+
'--code-location',
225+
'app/ByoAgent/',
226+
'--model-provider',
227+
'Gemini',
228+
'--api-key',
229+
'KEY1',
230+
'--json',
231+
],
232+
projectDir
233+
);
234+
235+
expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0);
236+
237+
const spec = await readProjectSpec();
238+
// Should still have same number of credentials (reused)
239+
expect(spec.credentials).toHaveLength(credCountBefore);
240+
});
241+
242+
it('BYO agent with different key creates agent-scoped credential', async () => {
243+
const byoDir2 = join(projectDir, 'app/ByoAgent2');
244+
await mkdir(byoDir2, { recursive: true });
245+
await writeFile(join(byoDir2, 'main.py'), '# BYO agent 2');
246+
247+
const specBefore = await readProjectSpec();
248+
const credCountBefore = specBefore.credentials.length;
249+
250+
const result = await runCLI(
251+
[
252+
'add',
253+
'agent',
254+
'--name',
255+
'ByoAgent2',
256+
'--type',
257+
'byo',
258+
'--language',
259+
'Python',
260+
'--framework',
261+
'Strands',
262+
'--code-location',
263+
'app/ByoAgent2/',
264+
'--model-provider',
265+
'Gemini',
266+
'--api-key',
267+
'DIFFERENT_KEY',
268+
'--json',
269+
],
270+
projectDir
271+
);
272+
273+
expect(result.exitCode, `stdout: ${result.stdout}, stderr: ${result.stderr}`).toBe(0);
274+
275+
const spec = await readProjectSpec();
276+
// Should have one more credential
277+
expect(spec.credentials).toHaveLength(credCountBefore + 1);
278+
const credNames = spec.credentials.map((c: { name: string }) => c.name);
279+
expect(credNames).toContain(`${projectName}ByoAgent2Gemini`);
280+
});
281+
});
282+
});

src/cli/commands/add/actions.ts

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,10 @@ import { setupPythonProject } from '../../operations';
1414
import {
1515
mapGenerateConfigToRenderConfig,
1616
mapModelProviderToCredentials,
17+
mapModelProviderToIdentityProviders,
1718
writeAgentToProject,
1819
} from '../../operations/agent/generate';
19-
import { computeDefaultCredentialEnvVarName, createCredential } from '../../operations/identity/create-identity';
20+
import { createCredential, resolveCredentialStrategy } from '../../operations/identity/create-identity';
2021
import { createGatewayFromWizard, createToolFromWizard } from '../../operations/mcp/create-mcp';
2122
import { createMemory } from '../../operations/memory/create-memory';
2223
import { createRenderer } from '../../templates';
@@ -115,22 +116,47 @@ async function handleCreatePath(options: ValidatedAddAgentOptions, configBaseDir
115116

116117
const agentPath = join(projectRoot, APP_DIR, options.name);
117118

118-
// Pass actual project name for credential naming in templates
119-
const renderConfig = mapGenerateConfigToRenderConfig(generateConfig, project.name);
119+
// Resolve credential strategy FIRST to determine correct credential name
120+
let identityProviders: ReturnType<typeof mapModelProviderToIdentityProviders> = [];
121+
let strategy: Awaited<ReturnType<typeof resolveCredentialStrategy>> | undefined;
122+
123+
if (options.modelProvider !== 'Bedrock') {
124+
strategy = await resolveCredentialStrategy(
125+
project.name,
126+
options.name,
127+
options.modelProvider,
128+
options.apiKey,
129+
configBaseDir,
130+
project.credentials
131+
);
132+
133+
// Build identity providers with the correct credential name from strategy
134+
identityProviders = [
135+
{
136+
name: strategy.credentialName,
137+
envVarName: strategy.envVarName,
138+
},
139+
];
140+
}
141+
142+
// Render templates with correct identity provider
143+
const renderConfig = mapGenerateConfigToRenderConfig(generateConfig, identityProviders);
120144
const renderer = createRenderer(renderConfig);
121145
await renderer.render({ outputDir: projectRoot });
122146

123-
await writeAgentToProject(generateConfig, { configBaseDir });
147+
// Write agent to project config
148+
if (strategy) {
149+
await writeAgentToProject(generateConfig, { configBaseDir, credentialStrategy: strategy });
124150

125-
if (options.language === 'Python') {
126-
await setupPythonProject({ projectDir: agentPath });
151+
if (options.apiKey) {
152+
await setEnvVar(strategy.envVarName, options.apiKey, configBaseDir);
153+
}
154+
} else {
155+
await writeAgentToProject(generateConfig, { configBaseDir });
127156
}
128157

129-
if (options.apiKey && options.modelProvider !== 'Bedrock') {
130-
// Use project-scoped credential name: {projectName}{modelProvider}
131-
const credentialName = `${project.name}${options.modelProvider}`;
132-
const envVarName = computeDefaultCredentialEnvVarName(credentialName);
133-
await setEnvVar(envVarName, options.apiKey, configBaseDir);
158+
if (options.language === 'Python') {
159+
await setupPythonProject({ projectDir: agentPath });
134160
}
135161

136162
return { success: true, agentName: options.name, agentPath };
@@ -157,19 +183,32 @@ async function handleByoPath(
157183

158184
project.agents.push(agent);
159185

160-
// Add credential for non-Bedrock providers
161-
const credentials = mapModelProviderToCredentials(options.modelProvider, project.name);
162-
project.credentials.push(...credentials);
163-
164-
await configIO.writeProjectSpec(project);
186+
// Handle credential creation with smart reuse detection
187+
if (options.modelProvider !== 'Bedrock') {
188+
const strategy = await resolveCredentialStrategy(
189+
project.name,
190+
options.name,
191+
options.modelProvider,
192+
options.apiKey,
193+
configBaseDir,
194+
project.credentials
195+
);
196+
197+
if (!strategy.reuse) {
198+
const credentials = mapModelProviderToCredentials(options.modelProvider, project.name);
199+
if (credentials.length > 0) {
200+
credentials[0]!.name = strategy.credentialName;
201+
project.credentials.push(...credentials);
202+
}
203+
}
165204

166-
if (options.apiKey && options.modelProvider !== 'Bedrock') {
167-
// Use project-scoped credential name: {projectName}{modelProvider}
168-
const credentialName = `${project.name}${options.modelProvider}`;
169-
const envVarName = computeDefaultCredentialEnvVarName(credentialName);
170-
await setEnvVar(envVarName, options.apiKey, configBaseDir);
205+
if (options.apiKey) {
206+
await setEnvVar(strategy.envVarName, options.apiKey, configBaseDir);
207+
}
171208
}
172209

210+
await configIO.writeProjectSpec(project);
211+
173212
return { success: true, agentName: options.name };
174213
}
175214

0 commit comments

Comments
 (0)