Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -67,3 +67,4 @@ ProtocolTesting/

# Auto-cloned CDK constructs (from scripts/bundle.mjs)
.cdk-constructs-clone/
.omc/
23 changes: 20 additions & 3 deletions src/assets/cdk/bin/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,27 +59,30 @@ 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 {
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why per-target credential resolution in cdk.ts

AgentCoreHarnessRole scopes its bedrock-agentcore:GetResourceApiKey policy to the specific ARN passed in via harness.apiKeyArn. Since our flow stores the credential by name (apiKeyCredential), we have to resolve that name → ARN from deployed state before CDK synth so the IAM statement is concrete.

That's why the harness config is built in two steps:

  • harnessRawConfigs — read once from each harness.json on disk (pre-loop)
  • harnessConfigs — resolved per-target inside the for (const target of targets) loop, using that target's credentials map from deployed state

Alternative we could have taken

AgentCoreRuntime skips per-credential resolution entirely and grants a wildcard policy via grantCredentialAccess():

actions: ['bedrock-agentcore:GetResourceApiKey', ...],
resources: [
  `arn:aws:bedrock-agentcore:*:${account}:token-vault/*`,
  `arn:aws:bedrock-agentcore:*:${account}:apikeycredentialprovider/*`,
],

We could change AgentCoreHarnessRole to do the same. That would eliminate the resolution block in cdk.ts entirely — but it relaxes the harness role's IAM scoping from "this one credential" to "any credential in this account." Current approach preserves the tighter scoping the construct already had.

name: string;
executionRoleArn?: string;
memoryName?: string;
containerUri?: string;
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,
containerUri: harnessSpec.containerUri,
hasDockerfile: !!harnessSpec.dockerfile,
tools: harnessSpec.tools,
apiKeyArn: harnessSpec.model?.apiKeyArn,
apiKeyCredential: harnessSpec.model?.apiKeyCredential,
});
} catch (err) {
throw new Error(
Expand All @@ -103,6 +106,20 @@ async function main() {
| Record<string, { credentialProviderArn: string; clientSecretArn?: string }>
| 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,
Expand Down
3 changes: 2 additions & 1 deletion src/cli/commands/create/__tests__/harness-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
10 changes: 5 additions & 5 deletions src/cli/commands/create/__tests__/harness-validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
Expand All @@ -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);
Expand All @@ -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);
Expand Down
17 changes: 16 additions & 1 deletion src/cli/commands/create/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -133,6 +134,7 @@ async function handleCreateHarnessCLI(options: CreateOptions): Promise<void> {
name: options.name,
modelProvider: options.modelProvider,
modelId: options.modelId,
apiKey: options.apiKey,
apiKeyArn: options.apiKeyArn,
},
cwd
Expand Down Expand Up @@ -173,7 +175,8 @@ async function handleCreateHarnessCLI(options: CreateOptions): Promise<void> {
cwd,
modelProvider: provider,
modelId,
apiKeyArn: options.apiKeyArn,
apiKey: options.apiKey,
apiKeyCredentialArn: options.apiKeyArn,
containerUri: containerOption.containerUri,
dockerfilePath: containerOption.dockerfilePath,
skipMemory: options.harnessMemory === false,
Expand Down Expand Up @@ -206,6 +209,17 @@ async function handleCreateHarnessCLI(options: CreateOptions): Promise<void> {
async function handleCreateAgentCLI(options: CreateOptions): Promise<void> {
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) {
Expand Down Expand Up @@ -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 ??
Expand Down
6 changes: 4 additions & 2 deletions src/cli/commands/create/harness-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 18 additions & 2 deletions src/cli/commands/create/harness-validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export interface CreateHarnessCliOptions {
name?: string;
modelProvider?: string;
modelId?: string;
apiKey?: string;
apiKeyArn?: string;
container?: string;
noMemory?: boolean;
Expand Down Expand Up @@ -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 };
Expand Down
69 changes: 67 additions & 2 deletions src/cli/commands/deploy/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ import {
buildCdkProject,
checkBootstrapNeeded,
checkStackDeployability,
escalateSkippedCredentialsReferencedByHarnesses,
getAllCredentials,
hasIdentityApiProviders,
hasIdentityOAuthProviders,
performStackTeardown,
rollbackNewlyCreatedApiKeyProviders,
setupApiKeyProviders,
setupOAuth2Providers,
setupTransactionSearch,
Expand All @@ -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<Set<string>> {
const referenced = new Set<string>();
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<DeployResult> {
let toolkitWrapper = null;
const logger = new ExecLogger({ command: 'deploy' });
Expand Down Expand Up @@ -143,16 +168,24 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
{ credentialProviderArn: string; clientSecretArn?: string; callbackUrl?: string }
> = {};

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 =
Expand All @@ -163,6 +196,10 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
}
identityKmsKeyArn = identityResult.kmsKeyArn;

if (identityResult.newlyCreatedProviders) {
newlyCreatedApiKeyProviders.push(...identityResult.newlyCreatedProviders);
}

// Collect API Key credential ARNs for deployed state
for (const result of identityResult.results) {
if (result.credentialProviderArn) {
Expand Down Expand Up @@ -335,7 +372,35 @@ export async function handleDeploy(options: ValidatedDeployOptions): Promise<Dep
switchableIoHost.setVerbose(true);
}

await toolkitWrapper.deploy();
try {
await toolkitWrapper.deploy();
} catch (deployErr) {
// If CDK deploy fails, clean up newly created token-vault credential providers
// so they don't orphan in AWS. Updates to pre-existing providers are left alone
// (the user still wants those on next retry).
if (newlyCreatedApiKeyProviders.length > 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) {
Expand Down
10 changes: 1 addition & 9 deletions src/cli/operations/agent/generate/schema-mapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
*
Expand Down
Loading