From 53ace260b3f5bf17c89a01f8fc612091cf895823 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Wed, 22 Apr 2026 18:05:18 -0400 Subject: [PATCH] fix(harness): complete credential flow for non-Bedrock model providers The harness API key flow had multiple gaps blocking end-to-end deploy+invoke for OpenAI and Gemini harnesses. This change fixes the credential lifecycle end-to-end: TUI/CLI collection, schema validation, deploy-time resolution, and the vended CDK's IAM policy wiring. Highlights - Vended CDK (src/assets/cdk/bin/cdk.ts) now resolves model.apiKeyCredential to the token-vault provider ARN from deployed state, so AgentCoreHarnessRole receives the required bedrock-agentcore:GetResourceApiKey policy. Without this, invokes always failed with AccessDeniedException. - Cross-provider credential dedup filters candidates by authorizerType and provider name so OpenAI and Gemini credentials never contaminate each other at deploy time. - Preflight validator rejects dangling apiKeyCredential references and OAuth credentials used as API key credentials, surfacing the error before CDK synth. - Pre-deploy identity escalates skipped credentials that harnesses reference and rolls back newly-created token-vault providers when the subsequent CDK deploy fails. - Harness primitive writes in rollback-safe order (.env.local then harness.json then agentcore.json as commit point) and rejects skip-on- API-key for non-Bedrock providers, both in the TUI wizard and primitive add(). - Schema now trims apiKeyArn, requires min length on apiKeyCredential, rejects Secrets Manager ARNs case-insensitively, and fails non-Bedrock providers that ship with neither credential field set. - CLI guards: agent-path rejects --api-key-arn; harness path rejects both --api-key and --api-key-arn; Secrets Manager ARN rejected at validate time with a clear remediation message. - Extracted computeCredentialName to credential-utils so schema-mapper and CredentialPrimitive share one implementation. Testing - 85 schema tests, 6 preflight validator tests, 4 cross-provider isolation tests, 5 HarnessPrimitive tests including dedup paths, 6 end-to-end integration tests against tmpdir with real ConfigIO, and a source-level wiring guard for useCreateFlow. - Verified end-to-end in AWS: OpenAI (us-east-1) and Gemini harnesses deploy, invoke, and return responses. M3/M4/M5/M7 error paths produce the expected messages. --- .gitignore | 1 + src/assets/cdk/bin/cdk.ts | 23 +- .../create/__tests__/harness-action.test.ts | 3 +- .../create/__tests__/harness-validate.test.ts | 10 +- src/cli/commands/create/command.tsx | 17 +- src/cli/commands/create/harness-action.ts | 6 +- src/cli/commands/create/harness-validate.ts | 20 +- src/cli/commands/deploy/actions.ts | 69 +++++- .../agent/generate/schema-mapper.ts | 10 +- .../deploy/__tests__/preflight.test.ts | 102 ++++++++- .../__tests__/harness-mapper.test.ts | 71 +++++- .../imperative/deployers/harness-mapper.ts | 25 +- src/cli/operations/deploy/index.ts | 4 + .../operations/deploy/pre-deploy-identity.ts | 67 +++++- src/cli/operations/deploy/preflight.ts | 49 ++++ .../resolve-credential-strategy.test.ts | 86 +++++++ src/cli/primitives/CredentialPrimitive.tsx | 14 +- src/cli/primitives/HarnessPrimitive.ts | 104 +++++++-- .../HarnessPrimitive.integration.test.ts | 213 ++++++++++++++++++ .../__tests__/HarnessPrimitive.test.ts | 99 +++++++- src/cli/primitives/credential-utils.ts | 10 + src/cli/tui/components/SecretInput.tsx | 4 +- .../__tests__/useCreateFlow.wiring.test.ts | 29 +++ src/cli/tui/screens/create/useCreateFlow.ts | 2 +- .../tui/screens/harness/AddHarnessFlow.tsx | 2 +- .../tui/screens/harness/AddHarnessScreen.tsx | 30 ++- src/cli/tui/screens/harness/types.ts | 10 +- .../screens/harness/useAddHarnessWizard.ts | 14 +- .../primitives/__tests__/harness.test.ts | 124 ++++++++++ src/schema/schemas/primitives/harness.ts | 29 ++- 30 files changed, 1140 insertions(+), 107 deletions(-) create mode 100644 src/cli/primitives/__tests__/HarnessPrimitive.integration.test.ts create mode 100644 src/cli/tui/screens/create/__tests__/useCreateFlow.wiring.test.ts diff --git a/.gitignore b/.gitignore index 3b00d92a..3643d65f 100644 --- a/.gitignore +++ b/.gitignore @@ -67,3 +67,4 @@ ProtocolTesting/ # Auto-cloned CDK constructs (from scripts/bundle.mjs) .cdk-constructs-clone/ +.omc/ diff --git a/src/assets/cdk/bin/cdk.ts b/src/assets/cdk/bin/cdk.ts index 12b6f370..4416bcd8 100644 --- a/src/assets/cdk/bin/cdk.ts +++ b/src/assets/cdk/bin/cdk.ts @@ -59,7 +59,7 @@ async function main() { // so we read them dynamically via specAny (same pattern as gateways above). // Harness paths in agentcore.json are relative to the project root (parent of agentcore/). const projectRoot = path.resolve(configRoot, '..'); - const harnessConfigs: { + interface HarnessRawConfig { name: string; executionRoleArn?: string; memoryName?: string; @@ -67,12 +67,14 @@ async function main() { hasDockerfile?: boolean; tools?: { type: string; name: string }[]; apiKeyArn?: string; - }[] = []; + apiKeyCredential?: string; + } + const harnessRawConfigs: HarnessRawConfig[] = []; for (const entry of specAny.harnesses ?? []) { const harnessPath = path.resolve(projectRoot, entry.path, 'harness.json'); try { const harnessSpec = JSON.parse(fs.readFileSync(harnessPath, 'utf-8')); - harnessConfigs.push({ + harnessRawConfigs.push({ name: entry.name, executionRoleArn: harnessSpec.executionRoleArn, memoryName: harnessSpec.memory?.name, @@ -80,6 +82,7 @@ async function main() { hasDockerfile: !!harnessSpec.dockerfile, tools: harnessSpec.tools, apiKeyArn: harnessSpec.model?.apiKeyArn, + apiKeyCredential: harnessSpec.model?.apiKeyCredential, }); } catch (err) { throw new Error( @@ -103,6 +106,20 @@ async function main() { | Record | undefined; + // Resolve apiKeyCredential (credential name in agentcore.json) to the token-vault + // provider ARN from deployed state. This is what the harness execution role needs + // to grant bedrock-agentcore:GetResourceApiKey on. + const harnessConfigs = harnessRawConfigs.map(raw => { + if (raw.apiKeyArn) { + return raw; + } + if (raw.apiKeyCredential) { + const resolved = credentials?.[raw.apiKeyCredential]?.credentialProviderArn; + return { ...raw, apiKeyArn: resolved }; + } + return raw; + }); + new AgentCoreStack(app, stackName, { spec, mcpSpec, diff --git a/src/cli/commands/create/__tests__/harness-action.test.ts b/src/cli/commands/create/__tests__/harness-action.test.ts index 07b36114..cae472c8 100644 --- a/src/cli/commands/create/__tests__/harness-action.test.ts +++ b/src/cli/commands/create/__tests__/harness-action.test.ts @@ -49,7 +49,8 @@ describe('createProjectWithHarness', () => { cwd: testDir, modelProvider: 'open_ai', modelId: 'gpt-4', - apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:my-key', + apiKeyCredentialArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/apikeycredentialprovider/my-key', skipMemory: true, maxIterations: 10, maxTokens: 2000, diff --git a/src/cli/commands/create/__tests__/harness-validate.test.ts b/src/cli/commands/create/__tests__/harness-validate.test.ts index 743edcd6..a851c6b2 100644 --- a/src/cli/commands/create/__tests__/harness-validate.test.ts +++ b/src/cli/commands/create/__tests__/harness-validate.test.ts @@ -68,16 +68,16 @@ describe('validateCreateHarnessOptions', () => { testDir ); expect(result.valid).toBe(false); - expect(result.error).toContain('--api-key-arn'); + expect(result.error).toContain('--api-key'); }); - it('accepts non-bedrock provider with api-key-arn', () => { + it('accepts non-bedrock provider with api-key', () => { const result = validateCreateHarnessOptions( { name: 'myHarness4', modelProvider: 'open_ai', modelId: 'gpt-4', - apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:my-key', + apiKey: 'sk-test-key-12345', }, testDir ); @@ -100,7 +100,7 @@ describe('validateCreateHarnessOptions', () => { name: 'myHarness6', modelProvider: 'OpenAI', modelId: 'gpt-4', - apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:my-key', + apiKey: 'sk-test-key-12345', }; const result = validateCreateHarnessOptions(options, testDir); expect(result.valid).toBe(true); @@ -112,7 +112,7 @@ describe('validateCreateHarnessOptions', () => { name: 'myHarness7', modelProvider: 'Gemini', modelId: 'gemini-pro', - apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:my-key', + apiKey: 'sk-test-key-12345', }; const result = validateCreateHarnessOptions(options, testDir); expect(result.valid).toBe(true); diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index 7a32d57a..c747e9b8 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -28,6 +28,7 @@ const AGENT_PATH_FLAGS = ['framework', 'language', 'build', 'protocol', 'type', /** Flags that are harness-only */ const HARNESS_ONLY_FLAGS = [ 'modelId', + 'apiKey', 'apiKeyArn', 'maxIterations', 'maxTokens', @@ -133,6 +134,7 @@ async function handleCreateHarnessCLI(options: CreateOptions): Promise { name: options.name, modelProvider: options.modelProvider, modelId: options.modelId, + apiKey: options.apiKey, apiKeyArn: options.apiKeyArn, }, cwd @@ -173,7 +175,8 @@ async function handleCreateHarnessCLI(options: CreateOptions): Promise { cwd, modelProvider: provider, modelId, - apiKeyArn: options.apiKeyArn, + apiKey: options.apiKey, + apiKeyCredentialArn: options.apiKeyArn, containerUri: containerOption.containerUri, dockerfilePath: containerOption.dockerfilePath, skipMemory: options.harnessMemory === false, @@ -206,6 +209,17 @@ async function handleCreateHarnessCLI(options: CreateOptions): Promise { async function handleCreateAgentCLI(options: CreateOptions): Promise { const cwd = options.outputDir ?? getWorkingDirectory(); + if (options.apiKeyArn) { + const errorMsg = + '--api-key-arn is a harness-only flag. Drop --framework/--language/--protocol to use the harness path, or use --api-key for the agent path.'; + if (options.json) { + console.log(JSON.stringify({ success: false, error: errorMsg })); + } else { + console.error(errorMsg); + } + process.exit(1); + } + const validation = validateCreateOptions(options, cwd); if (!validation.valid) { if (options.json) { @@ -381,6 +395,7 @@ export const registerCreate = (program: Command) => { options.dryRun ?? options.json ?? options.modelId ?? + options.apiKey ?? options.apiKeyArn ?? (options.harnessMemory === false ? true : null) ?? options.maxIterations ?? diff --git a/src/cli/commands/create/harness-action.ts b/src/cli/commands/create/harness-action.ts index 84c97fc7..94d4ee91 100644 --- a/src/cli/commands/create/harness-action.ts +++ b/src/cli/commands/create/harness-action.ts @@ -11,7 +11,8 @@ export interface CreateHarnessProjectOptions { cwd: string; modelProvider: HarnessModelProvider; modelId: string; - apiKeyArn?: string; + apiKey?: string; + apiKeyCredentialArn?: string; skipMemory?: boolean; containerUri?: string; dockerfilePath?: string; @@ -55,7 +56,8 @@ export async function createProjectWithHarness(options: CreateHarnessProjectOpti name: options.name, modelProvider: options.modelProvider, modelId: options.modelId, - apiKeyArn: options.apiKeyArn, + apiKey: options.apiKey, + apiKeyCredentialArn: options.apiKeyCredentialArn, containerUri: options.containerUri, dockerfilePath: options.dockerfilePath, skipMemory: options.skipMemory, diff --git a/src/cli/commands/create/harness-validate.ts b/src/cli/commands/create/harness-validate.ts index 54cb8210..6c7f858d 100644 --- a/src/cli/commands/create/harness-validate.ts +++ b/src/cli/commands/create/harness-validate.ts @@ -5,6 +5,7 @@ export interface CreateHarnessCliOptions { name?: string; modelProvider?: string; modelId?: string; + apiKey?: string; apiKeyArn?: string; container?: string; noMemory?: boolean; @@ -79,8 +80,23 @@ export function validateCreateHarnessOptions(options: CreateHarnessCliOptions, c }; options.modelId ??= defaultModelIds[options.modelProvider] ?? 'global.anthropic.claude-sonnet-4-6'; - if (options.modelProvider !== 'bedrock' && !options.apiKeyArn) { - return { valid: false, error: `--api-key-arn is required for ${options.modelProvider} provider` }; + if (options.apiKey && options.apiKeyArn) { + return { + valid: false, + error: 'Use --api-key (primary) OR --api-key-arn (BYO token-vault ARN), not both.', + }; + } + + if (options.modelProvider !== 'bedrock' && !options.apiKey && !options.apiKeyArn) { + return { valid: false, error: `--api-key or --api-key-arn is required for ${options.modelProvider} provider` }; + } + + if (options.apiKeyArn && /^arn:aws:secretsmanager:/i.test(options.apiKeyArn.trim())) { + return { + valid: false, + error: + '--api-key-arn must be a token-vault credential provider ARN (arn:aws:bedrock-agentcore:...). Secrets Manager ARNs are not accepted. Use --api-key to create a managed credential from a raw API key.', + }; } return { valid: true }; diff --git a/src/cli/commands/deploy/actions.ts b/src/cli/commands/deploy/actions.ts index 2a609aa1..769257ad 100644 --- a/src/cli/commands/deploy/actions.ts +++ b/src/cli/commands/deploy/actions.ts @@ -21,10 +21,12 @@ import { buildCdkProject, checkBootstrapNeeded, checkStackDeployability, + escalateSkippedCredentialsReferencedByHarnesses, getAllCredentials, hasIdentityApiProviders, hasIdentityOAuthProviders, performStackTeardown, + rollbackNewlyCreatedApiKeyProviders, setupApiKeyProviders, setupOAuth2Providers, setupTransactionSearch, @@ -50,6 +52,29 @@ export interface ValidatedDeployOptions { const AGENT_NEXT_STEPS = ['agentcore invoke', 'agentcore status']; const MEMORY_ONLY_NEXT_STEPS = ['agentcore add agent', 'agentcore status']; +/** + * Collect the set of credential names that harness.json files reference via + * `model.apiKeyCredential`. Used by the deploy flow to escalate skipped + * credentials that a harness actually depends on. + */ +async function collectHarnessCredentialReferences( + configIO: ConfigIO, + projectSpec: import('../../../schema').AgentCoreProjectSpec +): Promise> { + const referenced = new Set(); + for (const harness of projectSpec.harnesses ?? []) { + try { + const spec = await configIO.readHarnessSpec(harness.name); + const ref = spec.model.apiKeyCredential; + if (ref) referenced.add(ref); + } catch { + // Skip harnesses with unreadable specs; the preflight validator will have + // already surfaced any schema/read issues earlier in the flow. + } + } + return referenced; +} + export async function handleDeploy(options: ValidatedDeployOptions): Promise { let toolkitWrapper = null; const logger = new ExecLogger({ command: 'deploy' }); @@ -143,16 +168,24 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise = {}; + const newlyCreatedApiKeyProviders: string[] = []; if (hasIdentityApiProviders(context.projectSpec)) { startStep('Creating credentials...'); - const identityResult = await setupApiKeyProviders({ + const setupResult = await setupApiKeyProviders({ projectSpec: context.projectSpec, configBaseDir: configIO.getConfigRoot(), region: target.region, runtimeCredentials, enableKmsEncryption: true, }); + + // Escalate any skipped credentials that harnesses depend on: otherwise the + // user hits a confusing mapper-level "not in deployed state" failure later + // whose root cause is actually a missing env var. + const referencedByHarnesses = await collectHarnessCredentialReferences(configIO, context.projectSpec); + const identityResult = escalateSkippedCredentialsReferencedByHarnesses(setupResult, referencedByHarnesses); + if (identityResult.hasErrors) { const errorResult = identityResult.results.find(r => r.status === 'error'); const errorMsg = @@ -163,6 +196,10 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise 0) { + try { + const rollback = await rollbackNewlyCreatedApiKeyProviders(target.region, newlyCreatedApiKeyProviders); + if (rollback.deleted.length > 0) { + logger.log( + `Rolled back ${rollback.deleted.length} newly-created credential provider(s): ${rollback.deleted.join(', ')}` + ); + } + if (rollback.failed.length > 0) { + logger.log( + `Failed to roll back ${rollback.failed.length} credential provider(s). Manual cleanup may be required.`, + 'error' + ); + } + } catch (rollbackErr) { + logger.log( + `Credential provider rollback failed: ${rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr)}`, + 'error' + ); + } + } + throw deployErr; + } // Disable verbose output if (switchableIoHost) { diff --git a/src/cli/operations/agent/generate/schema-mapper.ts b/src/cli/operations/agent/generate/schema-mapper.ts index 6d8ca15a..f858e0f6 100644 --- a/src/cli/operations/agent/generate/schema-mapper.ts +++ b/src/cli/operations/agent/generate/schema-mapper.ts @@ -13,6 +13,7 @@ import { DEFAULT_EPISODIC_REFLECTION_NAMESPACES, DEFAULT_STRATEGY_NAMESPACES } f import { GatewayPrimitive } from '../../../primitives/GatewayPrimitive'; import { buildAuthorizerConfigFromJwtConfig } from '../../../primitives/auth-utils'; import { + computeCredentialName, computeDefaultCredentialEnvVarName, computeManagedOAuthCredentialName, } from '../../../primitives/credential-utils'; @@ -40,15 +41,6 @@ export interface GenerateConfigMappingResult { credentials: Credential[]; } -/** - * Compute the credential name for a model provider. - * Scoped to project (not agent) to avoid conflicts across projects. - * Format: {projectName}{providerName} - */ -function computeCredentialName(projectName: string, providerName: string): string { - return `${projectName}${providerName}`; -} - /** * Maps GenerateConfig memory option to v2 Memory resources. * diff --git a/src/cli/operations/deploy/__tests__/preflight.test.ts b/src/cli/operations/deploy/__tests__/preflight.test.ts index ff4af8c5..2e5e3e74 100644 --- a/src/cli/operations/deploy/__tests__/preflight.test.ts +++ b/src/cli/operations/deploy/__tests__/preflight.test.ts @@ -1,14 +1,19 @@ -import { formatError, validateProject } from '../preflight.js'; +import { formatError, validateHarnessCredentialReferences, validateProject } from '../preflight.js'; import { afterEach, describe, expect, it, vi } from 'vitest'; -const { mockReadProjectSpec, mockReadAWSDeploymentTargets, mockReadDeployedState, mockConfigExists } = vi.hoisted( - () => ({ - mockReadProjectSpec: vi.fn(), - mockReadAWSDeploymentTargets: vi.fn(), - mockReadDeployedState: vi.fn(), - mockConfigExists: vi.fn(), - }) -); +const { + mockReadProjectSpec, + mockReadAWSDeploymentTargets, + mockReadDeployedState, + mockConfigExists, + mockReadHarnessSpec, +} = vi.hoisted(() => ({ + mockReadProjectSpec: vi.fn(), + mockReadAWSDeploymentTargets: vi.fn(), + mockReadDeployedState: vi.fn(), + mockConfigExists: vi.fn(), + mockReadHarnessSpec: vi.fn(), +})); const { mockValidate } = vi.hoisted(() => ({ mockValidate: vi.fn(), @@ -31,6 +36,7 @@ vi.mock('../../../../lib/index.js', () => ({ readAWSDeploymentTargets = mockReadAWSDeploymentTargets; resolveAWSDeploymentTargets = mockReadAWSDeploymentTargets; readDeployedState = mockReadDeployedState; + readHarnessSpec = mockReadHarnessSpec; configExists = mockConfigExists; }, requireConfigRoot: mockRequireConfigRoot, @@ -118,6 +124,84 @@ describe('validateProject', () => { }); }); +describe('validateHarnessCredentialReferences', () => { + afterEach(() => vi.clearAllMocks()); + + function mockConfigIO() { + return { readHarnessSpec: mockReadHarnessSpec } as any; + } + + it('passes when there are no harnesses', async () => { + const projectSpec = { credentials: [], harnesses: [] } as any; + await expect(validateHarnessCredentialReferences(projectSpec, mockConfigIO())).resolves.toBeUndefined(); + }); + + it('passes when harness has no apiKeyCredential', async () => { + const projectSpec = { credentials: [], harnesses: [{ name: 'h1', path: 'app/h1' }] } as any; + mockReadHarnessSpec.mockResolvedValue({ name: 'h1', model: { provider: 'bedrock', modelId: 'claude' } }); + await expect(validateHarnessCredentialReferences(projectSpec, mockConfigIO())).resolves.toBeUndefined(); + }); + + it('throws when apiKeyCredential references a credential not in project', async () => { + const projectSpec = { credentials: [], harnesses: [{ name: 'h1', path: 'app/h1' }] } as any; + mockReadHarnessSpec.mockResolvedValue({ + name: 'h1', + model: { provider: 'open_ai', modelId: 'gpt-4o', apiKeyCredential: 'missingCred' }, + }); + await expect(validateHarnessCredentialReferences(projectSpec, mockConfigIO())).rejects.toThrow( + /references credential "missingCred".*no credential with that name exists/ + ); + }); + + it('throws when apiKeyCredential references an OAuth credential', async () => { + const projectSpec = { + credentials: [{ name: 'oauthCred', authorizerType: 'OAuthCredentialProvider' }], + harnesses: [{ name: 'h1', path: 'app/h1' }], + } as any; + mockReadHarnessSpec.mockResolvedValue({ + name: 'h1', + model: { provider: 'open_ai', modelId: 'gpt-4o', apiKeyCredential: 'oauthCred' }, + }); + await expect(validateHarnessCredentialReferences(projectSpec, mockConfigIO())).rejects.toThrow( + /authorizerType "OAuthCredentialProvider"/ + ); + }); + + it('passes when apiKeyCredential references a valid ApiKey credential', async () => { + const projectSpec = { + credentials: [{ name: 'goodCred', authorizerType: 'ApiKeyCredentialProvider' }], + harnesses: [{ name: 'h1', path: 'app/h1' }], + } as any; + mockReadHarnessSpec.mockResolvedValue({ + name: 'h1', + model: { provider: 'open_ai', modelId: 'gpt-4o', apiKeyCredential: 'goodCred' }, + }); + await expect(validateHarnessCredentialReferences(projectSpec, mockConfigIO())).resolves.toBeUndefined(); + }); + + it('collects multiple harness errors', async () => { + const projectSpec = { + credentials: [{ name: 'oauthCred', authorizerType: 'OAuthCredentialProvider' }], + harnesses: [ + { name: 'h1', path: 'app/h1' }, + { name: 'h2', path: 'app/h2' }, + ], + } as any; + mockReadHarnessSpec + .mockResolvedValueOnce({ + name: 'h1', + model: { provider: 'open_ai', modelId: 'gpt', apiKeyCredential: 'missing' }, + }) + .mockResolvedValueOnce({ + name: 'h2', + model: { provider: 'gemini', modelId: 'gem', apiKeyCredential: 'oauthCred' }, + }); + await expect(validateHarnessCredentialReferences(projectSpec, mockConfigIO())).rejects.toThrow( + /h1.*missing[\s\S]*h2.*oauthCred/ + ); + }); +}); + describe('formatError', () => { it('formats a simple Error', () => { const err = new Error('Something went wrong'); diff --git a/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts index 0d00573b..5d8be393 100644 --- a/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts +++ b/src/cli/operations/deploy/imperative/deployers/__tests__/harness-mapper.test.ts @@ -62,24 +62,34 @@ describe('mapHarnessSpecToCreateOptions', () => { }); }); - it('maps open_ai provider with apiKeyArn', async () => { + it('maps open_ai provider with apiKeyCredential resolved from deployed state', async () => { const spec = minimalSpec({ model: { provider: 'open_ai', modelId: 'gpt-4o', - apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:openai-key', + apiKeyCredential: 'myprojectOpenAI', temperature: 0.5, topP: 0.8, maxTokens: 2048, }, }); - const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + const deployedResources: DeployedResourceState = { + credentials: { + myprojectOpenAI: { + credentialProviderArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/apikeycredentialprovider/myprojectOpenAI', + }, + }, + }; + + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec, deployedResources }); expect(result.model).toEqual({ openAiModelConfig: { modelId: 'gpt-4o', - apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:openai-key', + apiKeyArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/apikeycredentialprovider/myprojectOpenAI', temperature: 0.5, topP: 0.8, maxTokens: 2048, @@ -87,23 +97,68 @@ describe('mapHarnessSpecToCreateOptions', () => { }); }); - it('maps gemini provider with topK', async () => { + it('maps open_ai provider with BYO apiKeyArn passthrough', async () => { + const spec = minimalSpec({ + model: { + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/apikeycredentialprovider/my-key', + }, + }); + + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + + expect(result.model).toEqual({ + openAiModelConfig: { + modelId: 'gpt-4o', + apiKeyArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/apikeycredentialprovider/my-key', + }, + }); + }); + + it('throws when apiKeyCredential is not found in deployed state', async () => { + const spec = minimalSpec({ + model: { + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyCredential: 'nonexistent', + }, + }); + + await expect(mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec })).rejects.toThrow( + 'Credential "nonexistent" referenced by harness model is not in deployed state' + ); + }); + + it('maps gemini provider with topK and apiKeyCredential', async () => { const spec = minimalSpec({ model: { provider: 'gemini', modelId: 'gemini-1.5-pro', - apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:gemini-key', + apiKeyCredential: 'myprojectGemini', topK: 0.4, temperature: 0.3, }, }); - const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec }); + const deployedResources: DeployedResourceState = { + credentials: { + myprojectGemini: { + credentialProviderArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/apikeycredentialprovider/myprojectGemini', + }, + }, + }; + + const result = await mapHarnessSpecToCreateOptions({ ...BASE_OPTIONS, harnessSpec: spec, deployedResources }); expect(result.model).toEqual({ geminiModelConfig: { modelId: 'gemini-1.5-pro', - apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:gemini-key', + apiKeyArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/apikeycredentialprovider/myprojectGemini', topK: 0.4, temperature: 0.3, }, diff --git a/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts b/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts index 8a4b7122..ad527165 100644 --- a/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts +++ b/src/cli/operations/deploy/imperative/deployers/harness-mapper.ts @@ -50,7 +50,7 @@ export async function mapHarnessSpecToCreateOptions(options: MapHarnessOptions): }; // Model - result.model = mapModel(harnessSpec.model); + result.model = mapModel(harnessSpec.model, deployedResources); // System prompt (may read from disk or auto-discover system-prompt.md) if (harnessSpec.systemPrompt !== undefined) { @@ -139,8 +139,9 @@ export async function mapHarnessSpecToCreateOptions(options: MapHarnessOptions): // Model Mapping // ============================================================================ -function mapModel(model: HarnessSpec['model']): HarnessModelConfiguration { - const { provider, modelId, apiKeyArn, temperature, topP, topK, maxTokens } = model; +function mapModel(model: HarnessSpec['model'], deployedResources?: DeployedResourceState): HarnessModelConfiguration { + const { provider, modelId, temperature, topP, topK, maxTokens } = model; + const resolvedApiKeyArn = resolveApiKeyArn(model, deployedResources); switch (provider) { case 'bedrock': @@ -156,7 +157,7 @@ function mapModel(model: HarnessSpec['model']): HarnessModelConfiguration { return { openAiModelConfig: { modelId, - ...(apiKeyArn && { apiKeyArn }), + ...(resolvedApiKeyArn && { apiKeyArn: resolvedApiKeyArn }), ...(temperature !== undefined && { temperature }), ...(topP !== undefined && { topP }), ...(maxTokens !== undefined && { maxTokens }), @@ -166,7 +167,7 @@ function mapModel(model: HarnessSpec['model']): HarnessModelConfiguration { return { geminiModelConfig: { modelId, - ...(apiKeyArn && { apiKeyArn }), + ...(resolvedApiKeyArn && { apiKeyArn: resolvedApiKeyArn }), ...(temperature !== undefined && { temperature }), ...(topP !== undefined && { topP }), ...(topK !== undefined && { topK }), @@ -176,6 +177,20 @@ function mapModel(model: HarnessSpec['model']): HarnessModelConfiguration { } } +function resolveApiKeyArn(model: HarnessSpec['model'], deployedResources?: DeployedResourceState): string | undefined { + if (model.apiKeyArn) return model.apiKeyArn; + if (!model.apiKeyCredential) return undefined; + + const credential = deployedResources?.credentials?.[model.apiKeyCredential]; + if (!credential) { + throw new Error( + `Credential "${model.apiKeyCredential}" referenced by harness model is not in deployed state. ` + + 'Ensure the credential is defined in agentcore.json and has been deployed.' + ); + } + return credential.credentialProviderArn; +} + // ============================================================================ // System Prompt Mapping // ============================================================================ diff --git a/src/cli/operations/deploy/index.ts b/src/cli/operations/deploy/index.ts index a5b9a2f9..e1b5ed9a 100644 --- a/src/cli/operations/deploy/index.ts +++ b/src/cli/operations/deploy/index.ts @@ -21,6 +21,8 @@ export { hasIdentityOAuthProviders, getMissingCredentials, getAllCredentials, + escalateSkippedCredentialsReferencedByHarnesses, + rollbackNewlyCreatedApiKeyProviders, type SetupApiKeyProvidersOptions, type SetupOAuth2ProvidersOptions, type PreDeployIdentityResult, @@ -30,6 +32,8 @@ export { type MissingCredential, } from './pre-deploy-identity'; +export { validateHarnessCredentialReferences } from './preflight'; + // Teardown utilities (moved from destroy operations) export { discoverDeployedTargets, diff --git a/src/cli/operations/deploy/pre-deploy-identity.ts b/src/cli/operations/deploy/pre-deploy-identity.ts index 8484f16a..28c55d3c 100644 --- a/src/cli/operations/deploy/pre-deploy-identity.ts +++ b/src/cli/operations/deploy/pre-deploy-identity.ts @@ -13,7 +13,11 @@ import { updateApiKeyProvider, updateOAuth2Provider, } from '../identity'; -import { BedrockAgentCoreControlClient, GetTokenVaultCommand } from '@aws-sdk/client-bedrock-agentcore-control'; +import { + BedrockAgentCoreControlClient, + DeleteApiKeyCredentialProviderCommand, + GetTokenVaultCommand, +} from '@aws-sdk/client-bedrock-agentcore-control'; import { CreateKeyCommand, KMSClient } from '@aws-sdk/client-kms'; // ───────────────────────────────────────────────────────────────────────────── @@ -31,6 +35,64 @@ export interface PreDeployIdentityResult { results: ApiKeyProviderSetupResult[]; hasErrors: boolean; kmsKeyArn?: string; + /** + * Names of credentials newly created during this setup (not pre-existing updates). + * Used by the deploy orchestrator to clean up orphaned providers if a subsequent + * CDK deploy fails. + */ + newlyCreatedProviders?: string[]; +} + +/** + * Delete API key credential providers that were newly created during this deploy. + * Best-effort: logs failures but does not throw. Called on CDK deploy failure to + * avoid orphaning providers in the token vault. + */ +export async function rollbackNewlyCreatedApiKeyProviders( + region: string, + providerNames: string[] +): Promise<{ deleted: string[]; failed: { name: string; error: string }[] }> { + const deleted: string[] = []; + const failed: { name: string; error: string }[] = []; + if (providerNames.length === 0) return { deleted, failed }; + + const credentials = getCredentialProvider(); + const client = new BedrockAgentCoreControlClient({ region, credentials }); + + for (const name of providerNames) { + try { + await client.send(new DeleteApiKeyCredentialProviderCommand({ name })); + deleted.push(name); + } catch (err) { + failed.push({ name, error: err instanceof Error ? err.message : String(err) }); + } + } + return { deleted, failed }; +} + +/** + * Escalate a skipped credential to an error if any harness references it. Used after + * setupApiKeyProviders runs: a credential can be skipped (no key in .env.local), + * but if a harness depends on it, deploying would produce confusing downstream failures. + */ +export function escalateSkippedCredentialsReferencedByHarnesses( + result: PreDeployIdentityResult, + referencedCredentialNames: Set +): PreDeployIdentityResult { + const upgraded = result.results.map(r => { + if (r.status !== 'skipped' || !referencedCredentialNames.has(r.providerName)) return r; + const envVarName = computeDefaultCredentialEnvVarName(r.providerName); + return { + ...r, + status: 'error' as const, + error: `Credential "${r.providerName}" is referenced by a harness but ${envVarName} is missing from agentcore/.env.local. Add the API key and redeploy, or remove the apiKeyCredential reference from harness.json.`, + }; + }); + return { + ...result, + results: upgraded, + hasErrors: upgraded.some(r => r.status === 'error'), + }; } // ───────────────────────────────────────────────────────────────────────────── @@ -91,10 +153,13 @@ export async function setupApiKeyProviders(options: SetupApiKeyProvidersOptions) } } + const newlyCreatedProviders = results.filter(r => r.status === 'created').map(r => r.providerName); + return { results, hasErrors: results.some(r => r.status === 'error'), kmsKeyArn, + newlyCreatedProviders, }; } diff --git a/src/cli/operations/deploy/preflight.ts b/src/cli/operations/deploy/preflight.ts index fccd51c4..baad75c3 100644 --- a/src/cli/operations/deploy/preflight.ts +++ b/src/cli/operations/deploy/preflight.ts @@ -114,6 +114,9 @@ export async function validateProject(): Promise { // Validate Container agents have Dockerfiles validateContainerAgents(projectSpec, configRoot); + // Validate harness credential references are consistent with project credentials + await validateHarnessCredentialReferences(projectSpec, configIO); + // Validate AWS credentials before proceeding with build/synth. // Skip for teardown deploys — callers validate after teardown confirmation. if (!isTeardownDeploy) { @@ -163,6 +166,52 @@ function validateHarnessNames(projectSpec: AgentCoreProjectSpec): void { } } +/** + * Validates that every harness.json `model.apiKeyCredential` name references a + * real credential in agentcore.json with authorizerType ApiKeyCredentialProvider. + * Fails early with a clear error so the user doesn't hit a confusing deploy-time + * failure (e.g., OAuth credential resolved into an OpenAI apiKeyArn slot). + */ +export async function validateHarnessCredentialReferences( + projectSpec: AgentCoreProjectSpec, + configIO: ConfigIO +): Promise { + const harnesses = projectSpec.harnesses ?? []; + if (harnesses.length === 0) return; + + const credByName = new Map(projectSpec.credentials.map(c => [c.name, c])); + const errors: string[] = []; + + for (const harnessEntry of harnesses) { + let harnessSpec; + try { + harnessSpec = await configIO.readHarnessSpec(harnessEntry.name); + } catch { + continue; + } + + const credName = harnessSpec.model.apiKeyCredential; + if (!credName) continue; + + const cred = credByName.get(credName); + if (!cred) { + errors.push( + `Harness "${harnessEntry.name}" references credential "${credName}" via model.apiKeyCredential, but no credential with that name exists in agentcore.json. Run \`agentcore add credential --name ${credName}\` or update the harness to reference an existing credential.` + ); + continue; + } + if (cred.authorizerType !== 'ApiKeyCredentialProvider') { + errors.push( + `Harness "${harnessEntry.name}" references credential "${credName}" via model.apiKeyCredential, but that credential has authorizerType "${cred.authorizerType}". apiKeyCredential must reference a credential with authorizerType "ApiKeyCredentialProvider".` + ); + } + } + + if (errors.length > 0) { + throw new Error(errors.join('\n')); + } +} + /** * Validates that Container agents have required Dockerfiles. */ diff --git a/src/cli/operations/identity/__tests__/resolve-credential-strategy.test.ts b/src/cli/operations/identity/__tests__/resolve-credential-strategy.test.ts index 989f97ee..62905820 100644 --- a/src/cli/operations/identity/__tests__/resolve-credential-strategy.test.ts +++ b/src/cli/operations/identity/__tests__/resolve-credential-strategy.test.ts @@ -332,4 +332,90 @@ describe('resolveCredentialStrategy', () => { expect(result.envVarName).toBe('AGENTCORE_CREDENTIAL_MYPROJECTGEMINI'); }); }); + + describe('cross-provider isolation', () => { + it('does NOT reuse an OpenAI credential for a Gemini harness even if keys match', async () => { + const existingCredentials: Credential[] = [ + { name: 'MyProjectOpenAI', authorizerType: 'ApiKeyCredentialProvider' }, + ]; + mockGetEnvVar.mockResolvedValue('shared-key-value'); + + const result = await primitive.resolveCredentialStrategy( + projectName, + agentName, + 'Gemini', + 'shared-key-value', + configBaseDir, + existingCredentials + ); + + expect(result).toEqual({ + reuse: false, + credentialName: 'MyProjectGemini', + envVarName: 'AGENTCORE_CREDENTIAL_MYPROJECTGEMINI', + isAgentScoped: false, + }); + }); + + it('does NOT reuse a Gemini credential for an OpenAI harness even if keys match', async () => { + const existingCredentials: Credential[] = [ + { name: 'MyProjectGemini', authorizerType: 'ApiKeyCredentialProvider' }, + ]; + mockGetEnvVar.mockResolvedValue('shared-key-value'); + + const result = await primitive.resolveCredentialStrategy( + projectName, + agentName, + 'OpenAI', + 'shared-key-value', + configBaseDir, + existingCredentials + ); + + expect(result.reuse).toBe(false); + expect(result.credentialName).toBe('MyProjectOpenAI'); + }); + + it('skips OAuth credentials when looking for reuse', async () => { + const existingCredentials: Credential[] = [ + { name: 'someOAuth', authorizerType: 'OAuthCredentialProvider' } as any, + ]; + mockGetEnvVar.mockResolvedValue('key'); + + const result = await primitive.resolveCredentialStrategy( + projectName, + agentName, + 'OpenAI', + 'key', + configBaseDir, + existingCredentials + ); + + expect(result.reuse).toBe(false); + expect(mockGetEnvVar).not.toHaveBeenCalled(); + }); + + it('still reuses same-provider credential with matching key', async () => { + const existingCredentials: Credential[] = [ + { name: 'MyProjectOpenAI', authorizerType: 'ApiKeyCredentialProvider' }, + ]; + mockGetEnvVar.mockResolvedValue('the-same-key'); + + const result = await primitive.resolveCredentialStrategy( + projectName, + agentName, + 'OpenAI', + 'the-same-key', + configBaseDir, + existingCredentials + ); + + expect(result).toEqual({ + reuse: true, + credentialName: 'MyProjectOpenAI', + envVarName: 'AGENTCORE_CREDENTIAL_MYPROJECTOPENAI', + isAgentScoped: false, + }); + }); + }); }); diff --git a/src/cli/primitives/CredentialPrimitive.tsx b/src/cli/primitives/CredentialPrimitive.tsx index 48f51845..9aa04710 100644 --- a/src/cli/primitives/CredentialPrimitive.tsx +++ b/src/cli/primitives/CredentialPrimitive.tsx @@ -5,7 +5,7 @@ import { validateAddCredentialOptions } from '../commands/add/validate'; import { getErrorMessage } from '../errors'; import type { RemovalPreview, RemovalResult, SchemaChange } from '../operations/remove/types'; import { BasePrimitive } from './BasePrimitive'; -import { computeDefaultCredentialEnvVarName } from './credential-utils'; +import { computeCredentialName, computeDefaultCredentialEnvVarName } from './credential-utils'; import type { AddResult, AddScreenComponent, RemovableResource } from './types'; import type { Command } from '@commander-js/extra-typings'; @@ -223,18 +223,22 @@ export class CredentialPrimitive extends BasePrimitive c.name === projectScopedName); if (!hasProjectScoped) { @@ -244,7 +248,7 @@ export class CredentialPrimitive extends BasePrimitive, ModelProvider> = { + open_ai: 'OpenAI', + gemini: 'Gemini', +}; + export interface AddHarnessOptions { name: string; modelProvider: HarnessModelProvider; modelId: string; - apiKeyArn?: string; + apiKey?: string; + apiKeyCredentialArn?: string; systemPrompt?: string; skipMemory?: boolean; containerUri?: string; @@ -59,6 +68,21 @@ export class HarnessPrimitive extends BasePrimitive> { try { + if (options.apiKey && options.apiKeyCredentialArn) { + return { + success: false, + error: + 'Use --api-key (primary) OR --api-key-arn (BYO token-vault ARN), not both. Choose one credential source.', + }; + } + + if (options.modelProvider !== 'bedrock' && !options.apiKey && !options.apiKeyCredentialArn) { + return { + success: false, + error: `Model provider "${options.modelProvider}" requires a credential. Provide --api-key (creates a managed credential) or --api-key-arn (bring your own).`, + }; + } + const configBaseDir = options.configBaseDir ?? findConfigRoot(); if (!configBaseDir) { return { success: false, error: 'No agentcore project found. Run `agentcore create` first.' }; @@ -113,12 +137,36 @@ export class HarnessPrimitive extends BasePrimitive'); - template = template.replace('{{REGION}}', ''); - await writeFile(invokeScriptPath, template, 'utf-8'); - } - + // Build the final project spec in memory (don't write yet — agentcore.json is the commit point) if (memoryName) { const strategyTypes: MemoryStrategyType[] = ['SEMANTIC', 'USER_PREFERENCE', 'SUMMARIZATION', 'EPISODIC']; const strategies: MemoryStrategy[] = strategyTypes.map(type => ({ @@ -187,6 +219,31 @@ export class HarnessPrimitive extends BasePrimitive'); + template = template.replace('{{REGION}}', ''); + await writeFile(invokeScriptPath, template, 'utf-8'); + } + await this.writeProjectSpec(project, configIO); if (options.jwtConfig?.clientId && options.jwtConfig?.clientSecret) { @@ -301,7 +358,8 @@ export class HarnessPrimitive extends BasePrimitive', 'Harness name (start with letter, alphanumeric + underscores, max 48 chars)') .option('--model-provider ', 'Model provider: bedrock, open_ai, gemini') .option('--model-id ', 'Model ID (e.g., anthropic.claude-3-5-sonnet-20240620-v1:0)') - .option('--api-key-arn ', 'API key ARN for non-Bedrock providers') + .option('--api-key ', 'API key for non-Bedrock providers (stored securely in AgentCore Identity)') + .option('--api-key-arn ', 'Token-vault credential provider ARN (advanced, for pre-existing credentials)') .option('--container ', 'Container image URI or path to a Dockerfile') .option('--no-memory', 'Skip auto-creating memory') .option('--max-iterations ', 'Max iterations', parseInt) @@ -329,6 +387,7 @@ export class HarnessPrimitive extends BasePrimitive { + let testDir: string; + let agentcoreDir: string; + + beforeEach(async () => { + testDir = await mkdtemp(join(tmpdir(), 'harness-integ-')); + agentcoreDir = join(testDir, 'agentcore'); + await mkdir(agentcoreDir, { recursive: true }); + await writeFile( + join(agentcoreDir, 'agentcore.json'), + JSON.stringify( + { + $schema: 'https://schema.agentcore.aws.dev/v1/agentcore.json', + name: 'IntegProject', + version: 1, + managedBy: 'CDK', + runtimes: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + harnesses: [], + }, + null, + 2 + ) + ); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it('creates OpenAI harness with API key: agentcore.json + harness.json + .env.local are all consistent', async () => { + const primitive = new HarnessPrimitive(); + + const result = await primitive.add({ + name: 'H1', + modelProvider: 'open_ai', + modelId: 'gpt-4o', + apiKey: 'sk-test-integration', + skipMemory: true, + configBaseDir: agentcoreDir, + }); + + expect(result.success).toBe(true); + + // agentcore.json should have the new credential and harness entry + const configIO = new ConfigIO({ baseDir: agentcoreDir }); + const project = await configIO.readProjectSpec(); + const cred = project.credentials.find(c => c.name === 'IntegProjectOpenAI'); + expect(cred).toBeDefined(); + expect(cred?.authorizerType).toBe('ApiKeyCredentialProvider'); + const harnessEntry = project.harnesses?.find(h => h.name === 'H1'); + expect(harnessEntry).toBeDefined(); + + // harness.json should reference the credential by name, NOT by ARN + const harnessSpec = await configIO.readHarnessSpec('H1'); + expect(harnessSpec.model.apiKeyCredential).toBe('IntegProjectOpenAI'); + expect(harnessSpec.model.apiKeyArn).toBeUndefined(); + + // .env.local should contain the API key under the expected env var name + const storedKey = await getEnvVar('AGENTCORE_CREDENTIAL_INTEGPROJECTOPENAI', agentcoreDir); + expect(storedKey).toBe('sk-test-integration'); + }); + + it('creates harness with BYO apiKeyCredentialArn: writes apiKeyArn, does not create credential', async () => { + const primitive = new HarnessPrimitive(); + const byoArn = + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/apikeycredentialprovider/my-key'; + + const result = await primitive.add({ + name: 'H2', + modelProvider: 'open_ai', + modelId: 'gpt-4o', + apiKeyCredentialArn: byoArn, + skipMemory: true, + configBaseDir: agentcoreDir, + }); + + expect(result.success).toBe(true); + + const configIO = new ConfigIO({ baseDir: agentcoreDir }); + const project = await configIO.readProjectSpec(); + expect(project.credentials).toHaveLength(0); + + const harnessSpec = await configIO.readHarnessSpec('H2'); + expect(harnessSpec.model.apiKeyArn).toBe(byoArn); + expect(harnessSpec.model.apiKeyCredential).toBeUndefined(); + }); + + it('two OpenAI harnesses with same API key: dedup to a single project-scoped credential', async () => { + const primitive = new HarnessPrimitive(); + + const first = await primitive.add({ + name: 'H1', + modelProvider: 'open_ai', + modelId: 'gpt-4o', + apiKey: 'shared-key', + skipMemory: true, + configBaseDir: agentcoreDir, + }); + expect(first.success).toBe(true); + + const second = await primitive.add({ + name: 'H2', + modelProvider: 'open_ai', + modelId: 'gpt-4o', + apiKey: 'shared-key', + skipMemory: true, + configBaseDir: agentcoreDir, + }); + expect(second.success).toBe(true); + + const configIO = new ConfigIO({ baseDir: agentcoreDir }); + const project = await configIO.readProjectSpec(); + const openaiCreds = project.credentials.filter(c => c.name.endsWith('OpenAI')); + expect(openaiCreds).toHaveLength(1); + + const h1 = await configIO.readHarnessSpec('H1'); + const h2 = await configIO.readHarnessSpec('H2'); + expect(h1.model.apiKeyCredential).toBe('IntegProjectOpenAI'); + expect(h2.model.apiKeyCredential).toBe('IntegProjectOpenAI'); + }); + + it('OpenAI + Gemini harnesses with same key: two distinct provider-scoped credentials, no cross-contamination', async () => { + const primitive = new HarnessPrimitive(); + + const openaiResult = await primitive.add({ + name: 'OpenAIHarness', + modelProvider: 'open_ai', + modelId: 'gpt-4o', + apiKey: 'shared-key', + skipMemory: true, + configBaseDir: agentcoreDir, + }); + expect(openaiResult.success).toBe(true); + + const geminiResult = await primitive.add({ + name: 'GeminiHarness', + modelProvider: 'gemini', + modelId: 'gemini-2.5-flash', + apiKey: 'shared-key', + skipMemory: true, + configBaseDir: agentcoreDir, + }); + expect(geminiResult.success).toBe(true); + + const configIO = new ConfigIO({ baseDir: agentcoreDir }); + const project = await configIO.readProjectSpec(); + const credNames = project.credentials.map(c => c.name).sort(); + expect(credNames).toEqual(['IntegProjectGemini', 'IntegProjectOpenAI']); + + const openaiHarness = await configIO.readHarnessSpec('OpenAIHarness'); + const geminiHarness = await configIO.readHarnessSpec('GeminiHarness'); + expect(openaiHarness.model.apiKeyCredential).toBe('IntegProjectOpenAI'); + expect(geminiHarness.model.apiKeyCredential).toBe('IntegProjectGemini'); + }); + + it('rejects non-bedrock provider with no credential source', async () => { + const primitive = new HarnessPrimitive(); + const result = await primitive.add({ + name: 'Broken', + modelProvider: 'open_ai', + modelId: 'gpt-4o', + skipMemory: true, + configBaseDir: agentcoreDir, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toMatch(/requires a credential/); + } + + // Verify nothing was written + const configIO = new ConfigIO({ baseDir: agentcoreDir }); + const project = await configIO.readProjectSpec(); + expect(project.harnesses).toHaveLength(0); + expect(project.credentials).toHaveLength(0); + }); + + it('rejects both --api-key and --api-key-arn set simultaneously', async () => { + const primitive = new HarnessPrimitive(); + const result = await primitive.add({ + name: 'Broken', + modelProvider: 'open_ai', + modelId: 'gpt-4o', + apiKey: 'sk-test', + apiKeyCredentialArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/apikeycredentialprovider/my-key', + skipMemory: true, + configBaseDir: agentcoreDir, + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toMatch(/OR --api-key-arn/); + } + }); +}); diff --git a/src/cli/primitives/__tests__/HarnessPrimitive.test.ts b/src/cli/primitives/__tests__/HarnessPrimitive.test.ts index f9634798..30c46854 100644 --- a/src/cli/primitives/__tests__/HarnessPrimitive.test.ts +++ b/src/cli/primitives/__tests__/HarnessPrimitive.test.ts @@ -24,6 +24,14 @@ vi.mock('../../../lib', () => ({ setEnvVar: vi.fn().mockResolvedValue(undefined), })); +const mockResolveCredentialStrategy = vi.fn(); + +vi.mock('../CredentialPrimitive', () => ({ + CredentialPrimitive: class { + resolveCredentialStrategy = mockResolveCredentialStrategy; + }, +})); + vi.mock('fs/promises', () => ({ access: vi.fn().mockResolvedValue(undefined), writeFile: vi.fn().mockResolvedValue(undefined), @@ -270,14 +278,93 @@ describe('HarnessPrimitive', () => { ); }); - it('includes API key ARN for non-Bedrock providers', async () => { + it('includes BYO apiKeyArn for non-Bedrock providers', async () => { + mockReadProjectSpec.mockResolvedValue(JSON.parse(JSON.stringify(baseProject))); + + await primitive.add({ + name: 'testHarness', + modelProvider: 'open_ai', + modelId: 'gpt-4', + apiKeyCredentialArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/apikeycredentialprovider/my-key', + }); + + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'testHarness', + expect.objectContaining({ + model: { + provider: 'open_ai', + modelId: 'gpt-4', + apiKeyArn: + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/apikeycredentialprovider/my-key', + }, + }) + ); + }); + + it('creates credential and stores API key for non-Bedrock providers via apiKey flow', async () => { + mockReadProjectSpec.mockResolvedValue(JSON.parse(JSON.stringify(baseProject))); + mockResolveCredentialStrategy.mockResolvedValue({ + reuse: false, + credentialName: 'TestProjectOpenAI', + envVarName: 'AGENTCORE_CREDENTIAL_TESTPROJECTOPENAI', + isAgentScoped: false, + }); + + await primitive.add({ + name: 'testHarness', + modelProvider: 'open_ai', + modelId: 'gpt-4', + apiKey: 'sk-test-12345', + }); + + expect(mockResolveCredentialStrategy).toHaveBeenCalledWith( + 'TestProject', + 'testHarness', + 'OpenAI', + 'sk-test-12345', + '/tmp/test/agentcore', + expect.any(Array) + ); + + expect(mockWriteHarnessSpec).toHaveBeenCalledWith( + 'testHarness', + expect.objectContaining({ + model: { + provider: 'open_ai', + modelId: 'gpt-4', + apiKeyCredential: 'TestProjectOpenAI', + }, + }) + ); + + expect(mockWriteProjectSpec).toHaveBeenCalledWith( + expect.objectContaining({ + credentials: [{ authorizerType: 'ApiKeyCredentialProvider', name: 'TestProjectOpenAI' }], + }) + ); + + expect(setEnvVar).toHaveBeenCalledWith( + 'AGENTCORE_CREDENTIAL_TESTPROJECTOPENAI', + 'sk-test-12345', + '/tmp/test/agentcore' + ); + }); + + it('reuses existing credential when strategy says reuse', async () => { mockReadProjectSpec.mockResolvedValue(JSON.parse(JSON.stringify(baseProject))); + mockResolveCredentialStrategy.mockResolvedValue({ + reuse: true, + credentialName: 'TestProjectOpenAI', + envVarName: 'AGENTCORE_CREDENTIAL_TESTPROJECTOPENAI', + isAgentScoped: false, + }); await primitive.add({ name: 'testHarness', modelProvider: 'open_ai', modelId: 'gpt-4', - apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:openai-key', + apiKey: 'sk-test-12345', }); expect(mockWriteHarnessSpec).toHaveBeenCalledWith( @@ -286,10 +373,16 @@ describe('HarnessPrimitive', () => { model: { provider: 'open_ai', modelId: 'gpt-4', - apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:openai-key', + apiKeyCredential: 'TestProjectOpenAI', }, }) ); + + expect(mockWriteProjectSpec).toHaveBeenCalledWith( + expect.objectContaining({ + credentials: [], + }) + ); }); it('includes system prompt when provided', async () => { diff --git a/src/cli/primitives/credential-utils.ts b/src/cli/primitives/credential-utils.ts index 8c1df16e..e4f4828b 100644 --- a/src/cli/primitives/credential-utils.ts +++ b/src/cli/primitives/credential-utils.ts @@ -15,3 +15,13 @@ export function computeDefaultCredentialEnvVarName(credentialName: string): stri export function computeManagedOAuthCredentialName(gatewayName: string): string { return `${gatewayName}-oauth`; } + +/** + * Compute the default credential name for a model provider. + * Project-scoped (not resource-scoped) to enable sharing across agents/harnesses + * that use the same API key. Format: {projectName}{providerName}. + * Must stay in sync with the lookup logic in CredentialPrimitive.resolveCredentialStrategy. + */ +export function computeCredentialName(projectName: string, providerName: string): string { + return `${projectName}${providerName}`; +} diff --git a/src/cli/tui/components/SecretInput.tsx b/src/cli/tui/components/SecretInput.tsx index 7794b4aa..80fd6ba1 100644 --- a/src/cli/tui/components/SecretInput.tsx +++ b/src/cli/tui/components/SecretInput.tsx @@ -256,8 +256,8 @@ export interface ApiKeySecretInputProps { envVarName: string; /** Called when user submits an API key */ onSubmit: (apiKey: string) => void; - /** Called when user skips */ - onSkip: () => void; + /** Called when user skips. When omitted, the skip option is hidden and the user must enter a key. */ + onSkip?: () => void; /** Called when user cancels */ onCancel: () => void; /** Whether this component should receive input */ diff --git a/src/cli/tui/screens/create/__tests__/useCreateFlow.wiring.test.ts b/src/cli/tui/screens/create/__tests__/useCreateFlow.wiring.test.ts new file mode 100644 index 00000000..9646f82e --- /dev/null +++ b/src/cli/tui/screens/create/__tests__/useCreateFlow.wiring.test.ts @@ -0,0 +1,29 @@ +import { readFile } from 'fs/promises'; +import { join } from 'path'; +import { describe, expect, it } from 'vitest'; + +/** + * Guards the field wiring from AddHarnessConfig → harnessPrimitive.add in + * useCreateFlow.ts. Prevents regressions of the useCreateFlow.ts:497 class of bug + * (config field silently dropped because the caller used an old field name). + * + * This is a source-level assertion (grepping the file) rather than a runtime test + * because useCreateFlow is deeply wired to react state and TUI components; the + * wiring itself is simple enough that a static assertion catches real regressions + * without the overhead of a full render harness. + */ +describe('useCreateFlow harness wiring', () => { + const filePath = join(__dirname, '..', 'useCreateFlow.ts'); + + it('passes apiKey (not apiKeyArn) from AddHarnessConfig to harnessPrimitive.add', async () => { + const source = await readFile(filePath, 'utf-8'); + expect(source).toMatch(/apiKey:\s*addHarnessConfig\.apiKey\b/); + // Must not contain the old broken field name + expect(source).not.toMatch(/apiKeyArn:\s*addHarnessConfig\.apiKeyArn/); + }); + + it('does not reference removed AddHarnessConfig.apiKeyArn field', async () => { + const source = await readFile(filePath, 'utf-8'); + expect(source).not.toMatch(/addHarnessConfig\.apiKeyArn/); + }); +}); diff --git a/src/cli/tui/screens/create/useCreateFlow.ts b/src/cli/tui/screens/create/useCreateFlow.ts index c9ec5076..ee38a4d7 100644 --- a/src/cli/tui/screens/create/useCreateFlow.ts +++ b/src/cli/tui/screens/create/useCreateFlow.ts @@ -494,7 +494,7 @@ export function useCreateFlow(cwd: string): CreateFlowState { name: addHarnessConfig.name, modelProvider: addHarnessConfig.modelProvider, modelId: addHarnessConfig.modelId, - apiKeyArn: addHarnessConfig.apiKeyArn, + apiKey: addHarnessConfig.apiKey, skipMemory: addHarnessConfig.skipMemory, containerUri: addHarnessConfig.containerUri, dockerfilePath: addHarnessConfig.dockerfilePath, diff --git a/src/cli/tui/screens/harness/AddHarnessFlow.tsx b/src/cli/tui/screens/harness/AddHarnessFlow.tsx index 180d7304..f3781b1e 100644 --- a/src/cli/tui/screens/harness/AddHarnessFlow.tsx +++ b/src/cli/tui/screens/harness/AddHarnessFlow.tsx @@ -50,7 +50,7 @@ export function AddHarnessFlow({ isInteractive = true, onExit, onBack, onDev, on name: config.name, modelProvider: config.modelProvider, modelId: config.modelId, - apiKeyArn: config.apiKeyArn, + apiKey: config.apiKey, skipMemory: config.skipMemory, containerUri: config.containerUri, dockerfilePath: config.dockerfilePath, diff --git a/src/cli/tui/screens/harness/AddHarnessScreen.tsx b/src/cli/tui/screens/harness/AddHarnessScreen.tsx index dac1f608..8f5e7cfa 100644 --- a/src/cli/tui/screens/harness/AddHarnessScreen.tsx +++ b/src/cli/tui/screens/harness/AddHarnessScreen.tsx @@ -4,6 +4,7 @@ import { HarnessNameSchema, HarnessTruncationStrategySchema } from '../../../../ import { ARN_VALIDATION_MESSAGE, isValidArn } from '../../../commands/shared/arn-utils'; import { computeManagedOAuthCredentialName } from '../../../primitives/credential-utils'; import { + ApiKeySecretInput, ConfirmReview, Panel, Screen, @@ -32,6 +33,17 @@ import { import { useAddHarnessWizard } from './useAddHarnessWizard'; import React, { useMemo } from 'react'; +function getHarnessProviderInfo(provider: HarnessModelProvider): { name: string; envVarName: string } { + switch (provider) { + case 'open_ai': + return { name: 'OpenAI', envVarName: 'OPENAI_API_KEY' }; + case 'gemini': + return { name: 'Google Gemini', envVarName: 'GEMINI_API_KEY' }; + case 'bedrock': + return { name: 'Amazon Bedrock', envVarName: '' }; + } +} + interface AddHarnessScreenProps { existingHarnessNames: string[]; onComplete: (config: AddHarnessConfig) => void; @@ -88,7 +100,7 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A const isNameStep = wizard.step === 'name'; const isModelProviderStep = wizard.step === 'model-provider'; - const isApiKeyArnStep = wizard.step === 'api-key-arn'; + const isApiKeyStep = wizard.step === 'api-key'; const isContainerStep = wizard.step === 'container'; const isContainerUriStep = wizard.step === 'container-uri'; const isContainerDockerfileStep = wizard.step === 'container-dockerfile'; @@ -209,8 +221,8 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A { label: 'Model ID', value: wizard.config.modelId }, ]; - if (wizard.config.apiKeyArn) { - fields.push({ label: 'API Key ARN', value: wizard.config.apiKeyArn }); + if (wizard.config.apiKey) { + fields.push({ label: 'API Key', value: 'Provided (stored securely)' }); } if (wizard.config.skipMemory !== undefined) { @@ -339,14 +351,12 @@ export function AddHarnessScreen({ existingHarnessNames, onComplete, onExit }: A /> )} - {isApiKeyArnStep && ( - wizard.goBack()} - customValidation={value => isValidArn(value) || ARN_VALIDATION_MESSAGE} /> )} diff --git a/src/cli/tui/screens/harness/types.ts b/src/cli/tui/screens/harness/types.ts index b3a5b747..6f08be83 100644 --- a/src/cli/tui/screens/harness/types.ts +++ b/src/cli/tui/screens/harness/types.ts @@ -6,7 +6,7 @@ export type ContainerMode = 'none' | 'uri' | 'dockerfile'; export type AddHarnessStep = | 'name' | 'model-provider' - | 'api-key-arn' + | 'api-key' | 'container' | 'container-uri' | 'container-dockerfile' @@ -34,7 +34,7 @@ export interface AddHarnessConfig { name: string; modelProvider: HarnessModelProvider; modelId: string; - apiKeyArn?: string; + apiKey?: string; skipMemory?: boolean; containerMode?: ContainerMode; containerUri?: string; @@ -60,7 +60,7 @@ export interface AddHarnessConfig { export const HARNESS_STEP_LABELS: Record = { name: 'Name', 'model-provider': 'Model provider', - 'api-key-arn': 'API key ARN', + 'api-key': 'API key', container: 'Custom environment', 'container-uri': 'Container URI', 'container-dockerfile': 'Dockerfile path', @@ -96,12 +96,12 @@ export const MODEL_PROVIDER_OPTIONS = [ { id: 'open_ai' as const, title: 'OpenAI', - description: `Default: ${DEFAULT_MODEL_IDS.open_ai} (requires API key ARN)`, + description: `Default: ${DEFAULT_MODEL_IDS.open_ai} (requires API key)`, }, { id: 'gemini' as const, title: 'Google Gemini', - description: `Default: ${DEFAULT_MODEL_IDS.gemini} (requires API key ARN)`, + description: `Default: ${DEFAULT_MODEL_IDS.gemini} (requires API key)`, }, ] as const; diff --git a/src/cli/tui/screens/harness/useAddHarnessWizard.ts b/src/cli/tui/screens/harness/useAddHarnessWizard.ts index 8f6b5714..35e58c28 100644 --- a/src/cli/tui/screens/harness/useAddHarnessWizard.ts +++ b/src/cli/tui/screens/harness/useAddHarnessWizard.ts @@ -57,7 +57,7 @@ export function useAddHarnessWizard() { const steps: AddHarnessStep[] = ['name', 'model-provider']; if (config.modelProvider !== 'bedrock') { - steps.push('api-key-arn'); + steps.push('api-key'); } steps.push('container'); @@ -151,16 +151,16 @@ export function useAddHarnessWizard() { const setModelProvider = useCallback((modelProvider: HarnessModelProvider) => { setConfig(c => ({ ...c, modelProvider, modelId: DEFAULT_MODEL_IDS[modelProvider] })); if (modelProvider !== 'bedrock') { - setStep('api-key-arn'); + setStep('api-key'); } else { setStep('container'); } }, []); - const setApiKeyArn = useCallback( - (apiKeyArn: string) => { - setConfig(c => ({ ...c, apiKeyArn })); - const next = nextStep('api-key-arn'); + const setApiKey = useCallback( + (apiKey: string) => { + setConfig(c => ({ ...c, apiKey })); + const next = nextStep('api-key'); if (next) setStep(next); }, [nextStep] @@ -396,7 +396,7 @@ export function useAddHarnessWizard() { goBack, setName, setModelProvider, - setApiKeyArn, + setApiKey, setContainerMode, setContainerUri, setDockerfilePath, diff --git a/src/schema/schemas/primitives/__tests__/harness.test.ts b/src/schema/schemas/primitives/__tests__/harness.test.ts index d32b5db7..7afbfd63 100644 --- a/src/schema/schemas/primitives/__tests__/harness.test.ts +++ b/src/schema/schemas/primitives/__tests__/harness.test.ts @@ -337,6 +337,130 @@ describe('HarnessModelSchema', () => { }); expect(result.success).toBe(false); }); + + it('accepts apiKeyCredential as credential name', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyCredential: 'myprojectOpenAI', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.apiKeyCredential).toBe('myprojectOpenAI'); + } + }); + + it('rejects having both apiKeyArn and apiKeyCredential', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyArn: 'arn:aws:bedrock-agentcore:us-west-2:123:apikey/abc', + apiKeyCredential: 'myprojectOpenAI', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('mutually exclusive'))).toBe(true); + } + }); + + it('rejects Secrets Manager ARN in apiKeyArn', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyArn: 'arn:aws:secretsmanager:us-east-1:123456789012:secret:openai-key', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('Secrets Manager ARNs are not accepted'))).toBe(true); + } + }); + + it('accepts token-vault credential provider ARN in apiKeyArn', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/apikeycredentialprovider/my-key', + }); + expect(result.success).toBe(true); + }); + + it('rejects empty string apiKeyArn', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyArn: '', + }); + expect(result.success).toBe(false); + }); + + it('rejects empty string apiKeyCredential', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyCredential: '', + }); + expect(result.success).toBe(false); + }); + + it('rejects whitespace-only apiKeyArn after trim', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyArn: ' ', + }); + expect(result.success).toBe(false); + }); + + it('rejects Secrets Manager ARN with leading whitespace', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyArn: ' arn:aws:secretsmanager:us-east-1:123456789012:secret:openai-key', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('Secrets Manager ARNs are not accepted'))).toBe(true); + } + }); + + it('rejects mixed-case Secrets Manager ARN', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'open_ai', + modelId: 'gpt-4o', + apiKeyArn: 'ARN:AWS:SecretsManager:us-east-1:123456789012:secret:openai-key', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('Secrets Manager ARNs are not accepted'))).toBe(true); + } + }); + + it('rejects open_ai provider with neither apiKeyArn nor apiKeyCredential', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'open_ai', + modelId: 'gpt-4o', + }); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.issues.some(i => i.message.includes('requires either apiKeyCredential'))).toBe(true); + } + }); + + it('rejects gemini provider with neither apiKeyArn nor apiKeyCredential', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'gemini', + modelId: 'gemini-2.5-flash', + }); + expect(result.success).toBe(false); + }); + + it('accepts bedrock provider with neither apiKeyArn nor apiKeyCredential', () => { + const result = HarnessModelSchema.safeParse({ + provider: 'bedrock', + modelId: 'anthropic.claude-sonnet-4-5-20250514-v1:0', + }); + expect(result.success).toBe(true); + }); }); describe('HarnessSpecSchema', () => { diff --git a/src/schema/schemas/primitives/harness.ts b/src/schema/schemas/primitives/harness.ts index 0ffe1ab6..7e838258 100644 --- a/src/schema/schemas/primitives/harness.ts +++ b/src/schema/schemas/primitives/harness.ts @@ -1,6 +1,5 @@ import { NetworkModeSchema } from '../../constants'; -import { NetworkConfigSchema } from '../agent-env'; -import { LifecycleConfigurationSchema } from '../agent-env'; +import { LifecycleConfigurationSchema, NetworkConfigSchema } from '../agent-env'; import { AuthorizerConfigSchema, RuntimeAuthorizerTypeSchema } from '../auth'; import { uniqueBy } from '../zod-util'; import { TagsSchema } from './tags'; @@ -30,7 +29,8 @@ export const HarnessModelSchema = z .object({ provider: HarnessModelProviderSchema, modelId: z.string().min(1, 'Model ID is required'), - apiKeyArn: z.string().optional(), + apiKeyArn: z.string().trim().min(1).optional(), + apiKeyCredential: z.string().min(1).optional(), temperature: z.number().min(0).max(2).optional(), topP: z.number().min(0).max(1).optional(), topK: z.number().min(0).max(1).optional(), @@ -44,6 +44,28 @@ export const HarnessModelSchema = z path: ['topK'], }); } + if (model.apiKeyArn && model.apiKeyCredential) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'apiKeyArn and apiKeyCredential are mutually exclusive — use one or the other', + path: ['apiKeyArn'], + }); + } + if (model.apiKeyArn && /^arn:aws:secretsmanager:/i.test(model.apiKeyArn)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + 'Secrets Manager ARNs are not accepted for apiKeyArn. Use a token-vault credential provider ARN (arn:aws:bedrock-agentcore:...) or use apiKeyCredential with a credential name instead.', + path: ['apiKeyArn'], + }); + } + if (model.provider !== 'bedrock' && !model.apiKeyArn && !model.apiKeyCredential) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `Model provider "${model.provider}" requires either apiKeyCredential (credential name) or apiKeyArn (token-vault ARN).`, + path: ['apiKeyCredential'], + }); + } }); export type HarnessModel = z.infer; @@ -200,6 +222,7 @@ export const AllowedToolSchema = z .string() .min(1) .max(64) + // eslint-disable-next-line security/detect-unsafe-regex .regex(/^(\*|@?[^/]+(\/[^/]+)?)$/, 'Must be "*" or a tool name pattern (max 64 chars)'); // ============================================================================