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
17 changes: 7 additions & 10 deletions src/assets/templates/export-harness-python/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -316,11 +316,11 @@ def _make_conversation_manager():
{{#if hasMemory}}
def agent_factory():
cache = {}
def get_or_create_agent(session_id, user_id{{#if hasSkillsFetcher}}, skill_plugins=None{{/if}}):
def get_or_create_agent(session_id{{#if hasSkillsFetcher}}, skill_plugins=None{{/if}}):
{{#if actorId}}
_actor_id = "{{actorId}}"
_actor_id = {{safeJson actorId}}
{{else}}
_actor_id = user_id
raise ValueError("Memory requires a configured source actorId; review EXPORT_NOTES.md")
{{/if}}
key = f"{session_id}/{_actor_id}"
if key not in cache:
Expand Down Expand Up @@ -469,13 +469,10 @@ async def invoke(payload, context):
{{/if}}

{{#if hasMemory}}
session_id = getattr(context, 'session_id', 'default-session')
{{#if actorId}}
user_id = "{{actorId}}"
{{else}}
user_id = getattr(context, 'user_id', 'default-user')
{{/if}}
agent = get_or_create_agent(session_id, user_id{{#if hasSkillsFetcher}}, _skill_plugins{{/if}})
session_id = getattr(context, 'session_id', None)
if not session_id:
raise ValueError("Memory requires a Runtime session id")
agent = get_or_create_agent(session_id{{#if hasSkillsFetcher}}, _skill_plugins{{/if}})
{{else}}
session_id = getattr(context, 'session_id', 'default-session')
agent = get_or_create_agent(session_id{{#if hasSkillsFetcher}}, _skill_plugins{{/if}})
Expand Down
31 changes: 22 additions & 9 deletions src/assets/templates/export-harness-python/mcp_client/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,32 @@
import logging
from mcp.client.streamable_http import streamablehttp_client
from strands.tools.mcp.mcp_client import MCPClient
{{#if hasIamGateway}}
from mcp_proxy_for_aws.client import aws_iam_streamablehttp_client
{{/if}}
{{#if (some remoteMcpTools "toolPatterns")}}
import fnmatch
{{/if}}
{{#if (some remoteMcpTools "headerCredentials")}}
from bedrock_agentcore.identity.auth import requires_api_key
{{/if}}

logger = logging.getLogger(__name__)

{{#if remoteMcpTools}}
{{#if (some remoteMcpTools "toolPatterns")}}
import fnmatch


def _allowed_tools(server, *patterns):
"""Load only the tools allowedTools selects, by the server's name or as <server>_<tool>."""
def allowed(tool, **_):
names = (tool.mcp_tool.name, f"{server}_{tool.mcp_tool.name}")
return any(fnmatch.fnmatchcase(name, pattern) for name in names for pattern in patterns)
return {"allowed": [allowed]}

{{/if}}
{{#if (some remoteMcpTools "headerCredentials")}}
from bedrock_agentcore.identity.auth import requires_api_key
{{/if}}
{{#each remoteMcpTools}}
{{#if headerCredentials}}
{{#each headerCredentials}}
@requires_api_key(provider_name="{{credentialName}}")
@requires_api_key(provider_name={{#if existing}}{{safeJson credentialName}}{{else}}os.environ.get("{{envVarName}}_NAME", {{safeJson credentialName}}){{/if}})
def _get_{{pythonName}}_key(api_key: str) -> str:
"""Fetch {{headerKey}} credential for {{../name}} from AgentCore Identity."""
return api_key
Expand All @@ -33,19 +36,29 @@ def _get_{{pythonName}}_key(api_key: str) -> str:
{{/if}}
def get_{{pythonName}}_mcp_client() -> MCPClient | None:
"""Returns an MCP Client for the {{name}} remote MCP server."""
{{#if urlEnvVar}}
url = os.environ.get("{{urlEnvVar}}")
if not url:
raise RuntimeError("Missing gateway endpoint: {{urlEnvVar}}")
{{else}}
url = {{safeJson url}}
{{/if}}
{{#if headerCredentials}}
def transport():
if os.getenv("LOCAL_DEV") == "1":
headers = { {{#each headerCredentials}}{{safeJson headerKey}}: os.environ.get("{{envVarName}}", ""){{#unless @last}}, {{/unless}}{{/each}} }
headers = { {{#each headerCredentials}}{{safeJson headerKey}}: {{#if existing}}{{safeJson prefix}} + {{/if}}os.environ["{{envVarName}}"]{{#if existing}} + {{safeJson suffix}}{{/if}}{{#unless @last}}, {{/unless}}{{/each}} }
else:
headers = { {{#each headerCredentials}}{{safeJson headerKey}}: _get_{{pythonName}}_key(){{#unless @last}}, {{/unless}}{{/each}} }
headers = { {{#each headerCredentials}}{{safeJson headerKey}}: {{#if existing}}{{safeJson prefix}} + {{/if}}_get_{{pythonName}}_key(){{#if existing}} + {{safeJson suffix}}{{/if}}{{#unless @last}}, {{/unless}}{{/each}} }
return streamablehttp_client(url, headers=headers)

return MCPClient(transport{{#if toolPatterns}}, tool_filters=_allowed_tools({{safeJson name}}, {{#each toolPatterns}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}){{/if}})
{{else}}
{{#if awsIam}}
return MCPClient(lambda: aws_iam_streamablehttp_client(endpoint=url, aws_service="bedrock-agentcore", aws_region={{safeJson awsRegion}}){{#if toolPatterns}}, tool_filters=_allowed_tools({{safeJson name}}, {{#each toolPatterns}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}){{/if}})
{{else}}
return MCPClient(lambda: streamablehttp_client(url){{#if toolPatterns}}, tool_filters=_allowed_tools({{safeJson name}}, {{#each toolPatterns}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}){{/if}})
{{/if}}
{{/if}}

{{/each}}
def get_all_remote_mcp_clients() -> list[MCPClient]:
Expand Down
24 changes: 14 additions & 10 deletions src/assets/templates/export-harness-python/memory/session.py
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
import os
import uuid
from typing import Optional

from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig{{#if memoryStrategies.length}}, RetrievalConfig{{/if}}
from bedrock_agentcore.memory.integrations.strands.config import AgentCoreMemoryConfig{{#if (or memoryStrategies.length memoryRetrievalNamespaces)}}, RetrievalConfig{{/if}}
from bedrock_agentcore.memory.integrations.strands.session_manager import AgentCoreMemorySessionManager

MEMORY_ID = os.getenv("{{memoryEnvVarName}}")
REGION = os.getenv("AWS_REGION")
REGION = {{#if memoryRegion}}{{safeJson memoryRegion}}{{else}}os.getenv("AWS_REGION"){{/if}}


def get_memory_session_manager(
session_id: Optional[str], actor_id: str
) -> Optional[AgentCoreMemorySessionManager]:
session_id: str, actor_id: str
) -> AgentCoreMemorySessionManager:
if not MEMORY_ID:
return None

session_id = session_id or uuid.uuid4().hex
raise RuntimeError("Missing memory binding: {{memoryEnvVarName}}")

{{#if memoryRetrievalNamespaces}}
retrieval_config = {
{{#each memoryRetrievalNamespaces}}
{{safeJson namespace}}: RetrievalConfig(top_k={{topK}}, relevance_score={{relevanceScore}}{{#if strategyId}}, strategy_id={{safeJson strategyId}}{{/if}}),
{{/each}}
}
{{else}}
{{#if memoryStrategies.length}}
retrieval_config = {
{{#if (includes memoryStrategies "SEMANTIC")}}
Expand All @@ -32,14 +35,15 @@ def get_memory_session_manager(
f"/summaries/{actor_id}": RetrievalConfig(top_k={{#if memoryRetrievalTopK}}{{memoryRetrievalTopK}}{{else}}3{{/if}}, relevance_score={{#if memoryRetrievalRelevanceScore}}{{memoryRetrievalRelevanceScore}}{{else}}0.5{{/if}}),
{{/if}}
}
{{/if}}
{{/if}}

return AgentCoreMemorySessionManager(
AgentCoreMemoryConfig(
memory_id=MEMORY_ID,
session_id=session_id,
actor_id=actor_id,
{{#if memoryStrategies.length}}
{{#if (or memoryStrategies.length memoryRetrievalNamespaces)}}
retrieval_config=retrieval_config,
{{/if}}
),
Expand Down
2 changes: 2 additions & 0 deletions src/assets/templates/export-harness-python/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ dependencies = [
"bedrock-agentcore[strands-agents] >= 1.18.1, < 2.0.0",
"botocore[crt] ~= 1.43.0",
"mcp >= 1.28.1, < 2.0.0",
{{#if hasIamGateway}}"mcp-proxy-for-aws >= 1.7.0, < 2.0.0",
{{/if}}
{{#if bedrockMantle}}"aws-bedrock-token-generator >= 1.1.0, < 2.0.0",
{{/if}}"strands-agents{{#if strandsExtras}}[{{strandsExtras}}]{{/if}} >= 1.54.0, < 2.0.0",

Expand Down
121 changes: 121 additions & 0 deletions src/core/executionRoleSource.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import {
GetRoleCommand,
GetRolePolicyCommand,
ListAttachedRolePoliciesCommand,
ListRolePoliciesCommand,
ListRoleTagsCommand,
} from "@aws-sdk/client-iam";
import type {
CoreExecutionRoleSourceReader,
ExecutionRoleSource,
} from "../handlers/project/export/types";
import { InputValidationError, MalformedServiceResponseError } from "../errors";
import type { AwsClients, CoreOptions } from "./types";

export class ExecutionRoleSourceReader implements CoreExecutionRoleSourceReader {
constructor(private readonly clients: Pick<AwsClients, "iam">) {}

async read(roleArn: string, options: CoreOptions): Promise<ExecutionRoleSource> {
const match = /^arn:[^:]+:iam::\d{12}:role\/(?:[^/]+\/)*([^/]+)$/.exec(roleArn);
if (!match?.[1]) throw new InputValidationError(`Invalid execution role ARN: ${roleArn}`);
// AgentCore endpoint overrides do not apply to IAM.
const iam = this.clients.iam({ region: options.region, credentials: options.credentials });
const RoleName = match[1];
const { Role } = await iam.send(new GetRoleCommand({ RoleName }));
if (Role?.Arn !== roleArn) this.incomplete(roleArn, "GetRole");
const boundary = Role.PermissionsBoundary;
if (boundary && !boundary.PermissionsBoundaryArn)
this.incomplete(roleArn, "permissions boundary");
const source: ExecutionRoleSource = {
roleArn,
inlinePolicies: [],
managedPolicyArns: [],
tags: {},
...(boundary && { permissionsBoundaryArn: boundary.PermissionsBoundaryArn }),
};
let Marker: string | undefined;
do {
const page = await iam.send(new ListRolePoliciesCommand({ RoleName, Marker }));
if (!page.PolicyNames) this.incomplete(roleArn, "ListRolePolicies");
for (const name of page.PolicyNames) {
if (!name) this.incomplete(roleArn, "ListRolePolicies");
const policy = await iam.send(new GetRolePolicyCommand({ RoleName, PolicyName: name }));
source.inlinePolicies.push({
name,
document: this.policyDocument(policy.PolicyDocument, roleArn, name),
});
}
Marker = this.nextMarker(page, Marker, roleArn);
} while (Marker);
do {
const page = await iam.send(new ListAttachedRolePoliciesCommand({ RoleName, Marker }));
if (!page.AttachedPolicies) this.incomplete(roleArn, "ListAttachedRolePolicies");
for (const policy of page.AttachedPolicies) {
if (!policy.PolicyArn) this.incomplete(roleArn, "ListAttachedRolePolicies");
source.managedPolicyArns.push(policy.PolicyArn);
}
Marker = this.nextMarker(page, Marker, roleArn);
} while (Marker);
do {
const page = await iam.send(new ListRoleTagsCommand({ RoleName, Marker }));
if (!page.Tags) this.incomplete(roleArn, "ListRoleTags");
for (const tag of page.Tags) {
if (!tag.Key || tag.Value === undefined) this.incomplete(roleArn, "ListRoleTags");
if (!tag.Key.toLowerCase().startsWith("aws:")) {
source.tags[tag.Key] = tag.Value;
}
}
Marker = this.nextMarker(page, Marker, roleArn);
} while (Marker);
return source;
}

private nextMarker(
page: { IsTruncated?: boolean; Marker?: string },
previous: string | undefined,
roleArn: string,
): string | undefined {
if (typeof page.IsTruncated !== "boolean") this.incomplete(roleArn, "IAM pagination");
if (!page.IsTruncated) return undefined;
if (!page.Marker || page.Marker === previous) this.incomplete(roleArn, "IAM pagination");
return page.Marker;
}

private policyDocument(
raw: string | undefined,
roleArn: string,
name: string,
): Record<string, unknown> {
try {
if (!raw) throw new Error("Missing policy document");
const document = JSON.parse(raw.trimStart().startsWith("{") ? raw : decodeURIComponent(raw));
const statements = Array.isArray(document?.Statement)
? document.Statement
: [document?.Statement];
if (
!document ||
typeof document !== "object" ||
Array.isArray(document) ||
statements.length === 0 ||
statements.some(
(statement: unknown) =>
!statement || typeof statement !== "object" || Array.isArray(statement),
)
) {
throw new Error("Missing policy statements");
}
return document;
} catch (cause) {
throw new MalformedServiceResponseError(
`Cannot capture inline policy "${name}" on ${roleArn}.`,
{ cause },
);
}
}

private incomplete(roleArn: string, operation: string): never {
throw new MalformedServiceResponseError(
`Cannot capture execution role ${roleArn}: incomplete ${operation} response.`,
);
}
}
2 changes: 2 additions & 0 deletions src/core/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { EvalClient } from "./eval";
import { GatewayClient } from "./gateway";
import { HarnessClient } from "./harness";
import type { OpenHarnessShell } from "./harnessShell";
import { ExecutionRoleSourceReader } from "./executionRoleSource";
import { IdentityClient } from "./identity";
import { MemoryClient } from "./memory";
import { PaymentClient } from "./payment";
Expand Down Expand Up @@ -85,6 +86,7 @@ export class CoreClient implements AwsClients {

// Feature-scoped sub-clients. Access as e.g. `coreClient.harness.getHarness(...)`.
readonly harness: HarnessClient;
readonly executionRoleSource = new ExecutionRoleSourceReader(this);
readonly identity: IdentityClient = new IdentityClient(this);
readonly memory: MemoryClient = new MemoryClient(this);
readonly runtime: RuntimeClient;
Expand Down
3 changes: 2 additions & 1 deletion src/core/project/backends/cdk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import type {
ResolvedDeployedResource,
ResolvedProjectResource,
} from "../../../handlers/project/types";
import { cdkCompatibilityWarning } from "./cdk/compatibility";
import { cdkCompatibilityWarning, requireRuntimeCapabilities } from "./cdk/compatibility";
import {
createLineSplitter,
FsReadWriteJson,
Expand Down Expand Up @@ -210,6 +210,7 @@ export class CdkBackend implements ProjectBackend {
await this.ensureCdkDependencies(project);

const compatibilityWarning = await cdkCompatibilityWarning(this.cdkDirectory(project));
requireRuntimeCapabilities(this.cdkDirectory(project), project.spec.runtimes);
if (compatibilityWarning) {
yield { type: "warning", message: compatibilityWarning };
}
Expand Down
45 changes: 44 additions & 1 deletion src/core/project/backends/cdk/compatibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@ import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { cdkCompatibilityWarning, MINIMUM_COMPATIBLE_CDK_VERSION } from "./compatibility";
import {
cdkCompatibilityWarning,
MINIMUM_COMPATIBLE_CDK_VERSION,
requireRuntimeCapabilities,
} from "./compatibility";
import { ProjectRuntimeSchema } from "../../../../projectSchemas/runtime";

const tempDirectories: string[] = [];

Expand All @@ -22,6 +27,44 @@ async function cdkDirectoryWith(version: unknown): Promise<string> {
}

describe("cdkCompatibilityWarning", () => {
test("fails closed when a companion schema strips binding or role restrictions", async () => {
const directory = await cdkDirectoryWith("999.0.0");
const index = join(directory, "node_modules", "@aws", "agentcore-cdk", "index.js");
await writeFile(
index,
"exports.AgentEnvSpecSchema = { safeParse: value => ({ success: true, data: {} }) };",
);
const runtime = ProjectRuntimeSchema.parse({
name: "Export",
build: "CodeZip",
entrypoint: "main.py",
codeLocation: "app/Export",
runtimeVersion: "PYTHON_3_14",
bindingMode: "explicit",
executionRoleConfig: { policyMode: "explicit", tags: { team: "source" } },
connections: [{ to: { type: "memory", name: "Selected" } }],
});
expect(() => requireRuntimeCapabilities(directory, [runtime])).toThrow(
"access restrictions must not be silently stripped",
);
expect(() => requireRuntimeCapabilities(directory, [])).not.toThrow();
});
test("accepts a companion that retains the requested settings", async () => {
const directory = await cdkDirectoryWith("999.0.0");
await writeFile(
join(directory, "node_modules", "@aws", "agentcore-cdk", "index.js"),
"exports.AgentEnvSpecSchema = { safeParse: value => ({ success: true, data: value }) };",
);
const runtime = ProjectRuntimeSchema.parse({
name: "Export",
build: "CodeZip",
entrypoint: "main.py",
codeLocation: "app/Export",
runtimeVersion: "PYTHON_3_14",
bindingMode: "explicit",
});
expect(() => requireRuntimeCapabilities(directory, [runtime])).not.toThrow();
});
test.each(["0.0.0-0", "1.0.0-rc.1", "1.0.0-rc.2"])(
"warns for incompatible version %s",
async (version) => {
Expand Down
Loading
Loading