diff --git a/src/assets/templates/export-harness-python/main.py b/src/assets/templates/export-harness-python/main.py index 9424df446f..2cb7bbc79e 100644 --- a/src/assets/templates/export-harness-python/main.py +++ b/src/assets/templates/export-harness-python/main.py @@ -38,14 +38,13 @@ {{/if}} from bedrock_agentcore.runtime import BedrockAgentCoreApp from model.load import load_model -{{#if remoteMcpTools}} -from mcp_client.client import get_all_remote_mcp_clients -{{/if}} -{{#unless remoteMcpTools}} +{{#if (or gatewayTools remoteMcpTools)}} +from mcp_client.client import get_all_mcp_clients +{{else}} {{#unless isExportHarness}} from mcp_client.client import get_streamable_http_mcp_client {{/unless}} -{{/unless}} +{{/if}} {{#if hasMemory}} from memory.session import get_memory_session_manager {{/if}} @@ -58,12 +57,9 @@ app = BedrockAgentCoreApp() log = app.logger -{{#if remoteMcpTools}} +{{#if (or gatewayTools remoteMcpTools)}} # Define MCP clients for all configured MCP servers (gateways and/or remote MCP) -mcp_clients = [] -{{#if remoteMcpTools}} -mcp_clients += get_all_remote_mcp_clients() -{{/if}} +mcp_clients = get_all_mcp_clients() {{else}} {{#unless isExportHarness}} # Define a Streamable HTTP MCP Client @@ -279,7 +275,7 @@ def list_files(path: str) -> str: tools.extend([file_read, file_write, list_files]) {{/unless}}{{/if}} -{{#if remoteMcpTools}} +{{#if (or gatewayTools remoteMcpTools)}} # Add MCP clients to tools for mcp_client in mcp_clients: if mcp_client: diff --git a/src/assets/templates/export-harness-python/mcp_client/client.py b/src/assets/templates/export-harness-python/mcp_client/client.py index 6b600156e1..e909d5033a 100644 --- a/src/assets/templates/export-harness-python/mcp_client/client.py +++ b/src/assets/templates/export-harness-python/mcp_client/client.py @@ -5,8 +5,7 @@ logger = logging.getLogger(__name__) -{{#if remoteMcpTools}} -{{#if (some remoteMcpTools "toolPatterns")}} +{{#if (or (some remoteMcpTools "toolPatterns") (some gatewayTools "toolPatterns"))}} import fnmatch @@ -18,6 +17,92 @@ def allowed(tool, **_): return {"allowed": [allowed]} {{/if}} +{{#if gatewayTools}} +import httpx + +{{/if}} +{{#if (includes gatewayAuthTypes "AWS_IAM")}} +from botocore.auth import SigV4Auth +from botocore.awsrequest import AWSRequest +from botocore.session import get_session + + +class _SigV4Auth(httpx.Auth): + """Signs each gateway request with SigV4 using the runtime's AWS credentials.""" + + requires_request_body = True + + def __init__(self): + region = os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION")) + self._signer = SigV4Auth(get_session().get_credentials(), "bedrock-agentcore", region) + + def auth_flow(self, request): + # The gateway does not sign the connection header, so leave it out of the signature. + headers = {k: v for k, v in request.headers.items() if k.lower() != "connection"} + signed = AWSRequest(method=request.method, url=str(request.url), data=request.content, headers=headers) + self._signer.add_auth(signed) + request.headers.update(dict(signed.headers)) + yield request + +{{/if}} +{{#if (includes gatewayAuthTypes "OAUTH")}} +from bedrock_agentcore.identity.auth import requires_access_token + + +class _BearerAuth(httpx.Auth): + """Sends an AgentCore Identity access token, fetching a new one when the gateway rejects it.""" + + requires_request_body = True + + def __init__(self, get_token): + self._get_token = get_token + self._token = None + + async def async_auth_flow(self, request): + if self._token is None: + self._token = await self._get_token() + request.headers["Authorization"] = f"Bearer {self._token}" + response = yield request + if response.status_code == 401: + self._token = await self._get_token() + request.headers["Authorization"] = f"Bearer {self._token}" + yield request + +{{/if}} +{{#each gatewayTools}} +{{#if oauth}} +@requires_access_token( + provider_name={{safeJson oauth.providerName}}, + scopes={{safeJson oauth.scopes}}, + auth_flow="M2M", +{{#if oauth.customParameters}} + custom_parameters={{safeJson oauth.customParameters}}, +{{/if}} +) +async def _get_{{pythonName}}_token(*, access_token: str) -> str: + """Fetch an OAuth access token for the {{name}} gateway from AgentCore Identity.""" + return access_token + +{{/if}} +def get_{{pythonName}}_mcp_client() -> MCPClient | None: + """Returns an MCP Client for the {{name}} AgentCore Gateway.""" + url = os.environ.get("{{urlEnvVarName}}") + if not url: + logger.warning("{{urlEnvVarName}} is not set; {{name}} gateway tools are unavailable") + return None + {{#if (eq authType "AWS_IAM")}} + auth = _SigV4Auth() + {{/if}} + {{#if oauth}} + auth = _BearerAuth(_get_{{pythonName}}_token) + {{/if}} + {{#if (eq authType "NONE")}} + auth = None + {{/if}} + return MCPClient(lambda: streamablehttp_client(url, auth=auth){{#if toolPatterns}}, tool_filters=_allowed_tools({{safeJson name}}, {{#each toolPatterns}}{{safeJson this}}{{#unless @last}}, {{/unless}}{{/each}}){{/if}}) + +{{/each}} +{{#if remoteMcpTools}} {{#if (some remoteMcpTools "headerCredentials")}} from bedrock_agentcore.identity.auth import requires_api_key {{/if}} @@ -48,12 +133,21 @@ def transport(): {{/if}} {{/each}} -def get_all_remote_mcp_clients() -> list[MCPClient]: - """Returns all configured remote MCP clients.""" - clients = [{{#each remoteMcpTools}}get_{{pythonName}}_mcp_client(){{#unless @last}}, {{/unless}}{{/each}}] +{{/if}} +{{#if (or gatewayTools remoteMcpTools)}} +def get_all_mcp_clients() -> list[MCPClient]: + """Returns all configured gateway and remote MCP clients.""" + clients = [ +{{#each gatewayTools}} + get_{{pythonName}}_mcp_client(), +{{/each}} +{{#each remoteMcpTools}} + get_{{pythonName}}_mcp_client(), +{{/each}} + ] return [c for c in clients if c is not None] {{/if}} -{{#unless remoteMcpTools}} +{{#unless (or gatewayTools remoteMcpTools)}} {{#if isVpc}} # VPC mode: external MCP endpoints are not reachable without a NAT gateway. # Add an AgentCore Gateway with `agentcore add gateway`, or configure your own endpoint below. diff --git a/src/core/project/manager.export.test.ts b/src/core/project/manager.export.test.ts index 37995ae2c4..8feea92302 100644 --- a/src/core/project/manager.export.test.ts +++ b/src/core/project/manager.export.test.ts @@ -117,6 +117,62 @@ describe("FsProjectManager.exportHarness rendered tree", () => { expect(client).toContain('tool_filters=_allowed_tools("exa", "web_*")'); }); + test("connects gateway tools through their URL variable and records the connections", async () => { + const { manager: subject } = manager(); + const gatewayArn = "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/example-abc123"; + const project = await projectWithHarness(subject, { + allowedTools: ["@tools/*", "@partner"], + tools: [ + { + type: "agentcore_gateway", + name: "tools", + config: { agentCoreGateway: { gatewayArn, outboundAuth: { awsIam: {} } } }, + }, + { + type: "agentcore_gateway", + name: "partner", + config: { + agentCoreGateway: { + gatewayArn, + outboundAuth: { + oauth: { + providerArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/oauth2credentialprovider/partner-oauth", + scopes: ["partner/invoke"], + }, + }, + }, + }, + }, + ], + }); + + const result = await drain(subject.exportHarness(project, exportInput())); + + const client = await Bun.file(join(result.agentPath, "mcp_client", "client.py")).text(); + expect(client).toContain('url = os.environ.get("GATEWAY_TOOLS_URL")'); + expect(client).toMatch(/GATEWAY_TOOLS_URL"\)[\s\S]*?auth = _SigV4Auth\(\)/); + expect(client).toContain('provider_name="partner-oauth"'); + expect(client).toMatch(/auth = _BearerAuth\(_get_partner_[a-f0-9]{10}_token\)/); + const main = await Bun.file(join(result.agentPath, "main.py")).text(); + expect(main).toContain("mcp_clients = get_all_mcp_clients()"); + const pyproject = await Bun.file(join(result.agentPath, "pyproject.toml")).text(); + expect(pyproject).not.toMatch(/^ {5,}"/m); + const spec = await Bun.file(join(project.rootPath, "agentcore", "agentcore.json")).json(); + expect( + spec.runtimes.find((runtime: { name: string }) => runtime.name === "assistantAgent") + .connections, + ).toEqual([ + { id: "tools", to: { type: "gateway", arn: gatewayArn, outboundAuth: { awsIam: {} } } }, + expect.objectContaining({ id: "partner" }), + ]); + expect( + (await readEnvFile(join(project.rootPath, "agentcore", ".env.local"))).GATEWAY_TOOLS_URL, + ).toBe("https://example-abc123.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp"); + const notes = await Bun.file(join(result.agentPath, "EXPORT_NOTES.md")).text(); + expect(notes).not.toContain("Gateway tool not exported"); + }); + test("merges service model parameters under the explicit settings", async () => { const { manager: subject } = manager(); const project = await projectWithHarness(subject); diff --git a/src/core/project/templates/export.test.ts b/src/core/project/templates/export.test.ts index 0a58b0f508..f05419523e 100644 --- a/src/core/project/templates/export.test.ts +++ b/src/core/project/templates/export.test.ts @@ -396,15 +396,6 @@ describe("mapHarnessToExportPlan tools", () => { const result = plan({ spec: harness({ tools: [ - { - type: "agentcore_gateway", - name: "gw", - config: { - agentCoreGateway: { - gatewayArn: "arn:aws:bedrock-agentcore:us-east-1:111122223333:gateway/g-1", - }, - }, - }, { type: "agentcore_browser", name: "browser" }, { type: "agentcore_code_interpreter", name: "ci" }, ], @@ -415,14 +406,11 @@ describe("mapHarnessToExportPlan tools", () => { // branch to render them from — the notes below are the whole output. expect(result.context.hasBrowser).toBeUndefined(); expect(result.context.hasCodeInterpreter).toBeUndefined(); - expect(result.context.hasGateway).toBeUndefined(); expect(result.context.remoteMcpTools).toBeUndefined(); expect(categories(result)).toEqual([ - GATEWAY_TOOL_NOTE_CATEGORY, BROWSER_TOOL_NOTE_CATEGORY, CODE_INTERPRETER_TOOL_NOTE_CATEGORY, ]); - expect(result.notes[0]!.message).toContain("gateway/g-1"); }); test("includes the harness builtins unless allowedTools filters them out", () => { @@ -461,6 +449,142 @@ describe("mapHarnessToExportPlan tools", () => { }); }); +describe("mapHarnessToExportPlan gateway tools", () => { + const GATEWAY_ARN = "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/example-abc123"; + const PROVIDER_ARN = + "arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/oauth2credentialprovider/gw-oauth"; + const MEMORY_CONNECTION = { + id: "notes", + to: { + type: "memory", + arn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/notes-AbCdEf1234", + }, + } as const; + const gatewayTool = (name: string, outboundAuth?: Record) => ({ + type: "agentcore_gateway", + name, + config: { + agentCoreGateway: { gatewayArn: GATEWAY_ARN, ...(outboundAuth && { outboundAuth }) }, + }, + }); + const oauth = { + providerArn: PROVIDER_ARN, + scopes: ["tools/invoke"], + grantType: "CLIENT_CREDENTIALS" as const, + customParameters: { audience: "tools" }, + }; + + test.each([ + ["no outboundAuth", undefined, "AWS_IAM", undefined, { awsIam: {} }], + ["awsIam", { awsIam: {} }, "AWS_IAM", undefined, { awsIam: {} }], + ["none", { none: {} }, "NONE", undefined, { none: {} }], + [ + "oauth client credentials", + { oauth }, + "OAUTH", + { + providerName: "gw-oauth", + scopes: ["tools/invoke"], + customParameters: { audience: "tools" }, + }, + { oauth }, + ], + ])( + "exports a gateway tool with %s as a client and a runtime connection", + (_, outboundAuth, authType, oauthContext, connectionAuth) => { + const result = plan({ + spec: harness({ + tools: [gatewayTool("my-tools", outboundAuth)], + connections: [MEMORY_CONNECTION], + }), + }); + + expect(result.context.gatewayTools).toEqual([ + { + name: "my-tools", + pythonName: expect.stringMatching(/^my_tools_[a-f0-9]{10}$/), + urlEnvVarName: "GATEWAY_MY_TOOLS_URL", + authType, + oauth: oauthContext, + toolPatterns: undefined, + }, + ]); + expect(result.context.gatewayAuthTypes).toEqual([authType]); + expect(result.runtime.connections).toEqual([ + MEMORY_CONNECTION, + { id: "my-tools", to: { type: "gateway", arn: GATEWAY_ARN, outboundAuth: connectionAuth } }, + ]); + expect(result.envEntries).toEqual([ + { + key: "GATEWAY_MY_TOOLS_URL", + value: "https://example-abc123.gateway.bedrock-agentcore.us-east-1.amazonaws.com/mcp", + comment: expect.stringContaining('gateway tool "my-tools"'), + }, + ]); + expect(result.notes).toEqual([]); + }, + ); + + test("narrows a gateway to the tools @server/tool selects and drops an unselected one", () => { + const result = plan({ + spec: harness({ + tools: [gatewayTool("tools"), gatewayTool("other")], + allowedTools: ["@tools/search*"], + }), + }); + + expect(result.context.gatewayTools).toEqual([ + expect.objectContaining({ name: "tools", toolPatterns: ["search*"] }), + ]); + expect(result.runtime.connections?.map((connection) => connection.id)).toEqual(["tools"]); + }); + + test.each([ + [ + "the USER_FEDERATION grant type", + gatewayTool("tools", { oauth: { ...oauth, grantType: "USER_FEDERATION" } }), + [], + /USER_FEDERATION/, + ], + ["a name that cannot be a connection id", gatewayTool("3p-tools"), [], /Connection id must/], + [ + "a gatewayArn that is not a gateway ARN", + { + type: "agentcore_gateway", + name: "tools", + config: { agentCoreGateway: { gatewayArn: "example-abc123" } }, + }, + [], + /valid bedrock-agentcore gateway ARN/, + ], + ["a name that collides once tokenized", gatewayTool("my_tools"), [], /GATEWAY_MY_TOOLS/], + [ + "a name that collides with a harness connection", + gatewayTool("notes"), + [MEMORY_CONNECTION], + /GATEWAY_NOTES/, + ], + [ + "a name that collides with an id-less harness connection", + gatewayTool("gateway-example-abc123"), + [{ to: { type: "gateway", arn: GATEWAY_ARN } }], + /GATEWAY_GATEWAY_EXAMPLE_ABC123/, + ], + ])("skips a gateway tool with %s and exports the rest", (_, tool, connections, message) => { + const result = plan({ + spec: harness({ tools: [gatewayTool("my-tools"), tool], connections }), + }); + + expect((result.context.gatewayTools as { name: string }[]).map(({ name }) => name)).toEqual([ + "my-tools", + ]); + expect(result.runtime.connections?.at(-1)?.id).toBe("my-tools"); + expect(result.envEntries.map(({ key }) => key)).toEqual(["GATEWAY_MY_TOOLS_URL"]); + expect(categories(result)).toEqual([GATEWAY_TOOL_NOTE_CATEGORY]); + expect(result.notes[0]!.message).toMatch(message); + }); +}); + describe("mapHarnessToExportPlan allowedTools selection", () => { test("a bare name or glob selects builtins only", () => { const shellOnly = plan({ spec: harness({ allowedTools: ["shell"] }) }); diff --git a/src/core/project/templates/export.ts b/src/core/project/templates/export.ts index fb5751c957..1952df7ec1 100644 --- a/src/core/project/templates/export.ts +++ b/src/core/project/templates/export.ts @@ -2,6 +2,7 @@ import { createHash } from "node:crypto"; import type { z } from "zod"; import type { ProjectRuntime } from "../../../projectSchemas/runtime"; import type { + HarnessGatewayOutboundAuth, HarnessMemoryRef, HarnessMemoryRetrievalConfig, HarnessSkill, @@ -14,6 +15,11 @@ import type { HarnessTruncationConfig, } from "../../../projectSchemas/harness"; import type { ProjectSpecSchema } from "../../../projectSchemas/project"; +import { + ConnectionSchema, + connectionTokenFor, + type Connection, +} from "../../../projectSchemas/connections"; import { credentialEnvVarName, credentialProviderNameEnvVarName, @@ -23,7 +29,7 @@ import { memoryEnvVarName, type Memory } from "../../../projectSchemas/memory"; import type { EnvLocalEntry } from "../../../handlers/project/types"; import { InputValidationError } from "../../../errors/errors"; import { toPythonPackageName } from "../fsUtils"; -import { resourceNameFromArn } from "../../arn"; +import { parseArn, resourceNameFromArn, serviceIdFromArn } from "../../arn"; type ProjectSpec = z.infer; @@ -187,12 +193,12 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport ? String(memory.retrievalConfig.relevanceScore) : undefined, actorId: memory.actorId, - // Gateways are never exported as code (see resolveTools); the template still - // needs the keys so its conditionals resolve. // Tools. Empty collections become undefined: the template's custom `or`/ // `some` helpers use JS truthiness, where [] is truthy, unlike `{{#if}}`. inlineFunctionTools: undefinedIfEmpty(tools.inlineFunctionTools), remoteMcpTools: undefinedIfEmpty(tools.remoteMcpTools), + gatewayTools: undefinedIfEmpty(tools.gatewayTools), + gatewayAuthTypes: [...new Set(tools.gatewayTools.map(({ authType }) => authType))], hasShell: tools.hasShell, hasFileOperations: tools.hasFileOperations, // Skills @@ -236,7 +242,9 @@ export function mapHarnessToExportPlan(input: HarnessExportInput): HarnessExport ...(spec.lifecycleConfig && { lifecycleConfiguration: spec.lifecycleConfig }), ...(filesystemConfigurations.length > 0 && { filesystemConfigurations }), ...(additionalPolicies.length > 0 && { additionalPolicies }), - ...(spec.connections?.length && { connections: spec.connections }), + ...((spec.connections?.length || tools.connections.length) && { + connections: [...(spec.connections ?? []), ...tools.connections], + }), ...(spec.tags && { tags: spec.tags }), // NOTE: the harness's executionRoleArn is deliberately NOT carried over. The // exported agent is a new runtime that needs its own CDK-managed role so the @@ -566,6 +574,16 @@ interface ToolsResolution { pythonName: string; }[]; }[]; + gatewayTools: { + name: string; + pythonName: string; + urlEnvVarName: string; + authType: "AWS_IAM" | "NONE" | "OAUTH"; + oauth?: { providerName: string; scopes: string[]; customParameters?: Record }; + toolPatterns?: string[]; + }[]; + /** Runtime connections that make deploy grant gateway access and set each tool's URL variable. */ + connections: Connection[]; hasShell: boolean; hasFileOperations: boolean; } @@ -581,12 +599,15 @@ function resolveTools( const result: ToolsResolution = { inlineFunctionTools: [], remoteMcpTools: [], + gatewayTools: [], + connections: [], // Builtin tools are always available in the harness runtime; include them // unless the allowedTools filter excludes them. hasShell: isBuiltinIncluded("shell", allowedPatterns), hasFileOperations: isBuiltinIncluded("file_operations", allowedPatterns), }; + const connectionTokens = new Set((spec.connections ?? []).map(connectionTokenFor)); for (const tool of spec.tools) { const allowed = tool.type === "inline_function" @@ -663,15 +684,71 @@ function resolveTools( break; } case "agentcore_gateway": { - const cfg = configOf(tool, "agentCoreGateway") as { gatewayArn?: string } | undefined; - notes.push({ - category: GATEWAY_TOOL_NOTE_CATEGORY, - message: - `The gateway tool "${tool.name}"${cfg?.gatewayArn ? ` (${cfg.gatewayArn})` : ""} was ` + - `not exported: gateway URL discovery, outbound auth, and IAM wiring are managed by ` + - `the harness runtime. To keep these tools, connect an MCP client to the gateway in ` + - `mcp_client/client.py and grant the runtime role bedrock-agentcore:InvokeGateway on ` + - `the gateway (or its OAuth token flow) before deploying.`, + const cfg = configOf(tool, "agentCoreGateway") as + { gatewayArn: string; outboundAuth?: HarnessGatewayOutboundAuth } | undefined; + if (!cfg) break; + const skip = (reason: string) => + notes.push({ + category: GATEWAY_TOOL_NOTE_CATEGORY, + message: + `The gateway tool "${tool.name}" (${cfg.gatewayArn}) was not exported: ${reason} ` + + `Connect to the gateway in mcp_client/client.py yourself, and add a gateway ` + + `connection to this runtime in agentcore.json so deploy grants access to it.`, + }); + const auth = cfg.outboundAuth ?? { awsIam: {} }; + if ("oauth" in auth && auth.oauth.grantType === "USER_FEDERATION") { + skip( + "it uses the OAuth grant type USER_FEDERATION, which needs a user consent flow the " + + "exported agent does not have.", + ); + break; + } + const parsed = ConnectionSchema.safeParse({ + id: tool.name, + to: { + type: "gateway", + arn: cfg.gatewayArn, + outboundAuth: + "oauth" in auth + ? { oauth: { ...auth.oauth, grantType: "CLIENT_CREDENTIALS" } } + : auth, + }, + }); + if (!parsed.success) { + skip( + `the runtime connection it needs is invalid ` + + `(${parsed.error.issues.map((issue) => issue.message).join(", ")}).`, + ); + break; + } + const token = connectionTokenFor(parsed.data); + if (connectionTokens.has(token)) { + skip( + `another gateway tool or connection on this harness already uses the environment ` + + `variable prefix GATEWAY_${token}.`, + ); + break; + } + connectionTokens.add(token); + const urlEnvVarName = `GATEWAY_${token}_URL`; + const oauth = "oauth" in auth ? auth.oauth : undefined; + result.connections.push(parsed.data); + envEntries.push({ + key: urlEnvVarName, + value: gatewayUrl(cfg.gatewayArn), + comment: `URL of gateway tool "${tool.name}" for agentcore dev (deploy sets it on the runtime)`, + }); + result.gatewayTools.push({ + name: tool.name, + pythonName: stablePythonIdentifier(tool.name), + urlEnvVarName, + authType: oauth ? "OAUTH" : "none" in auth ? "NONE" : "AWS_IAM", + oauth: oauth && { + providerName: resourceNameFromArn(oauth.providerArn), + scopes: oauth.scopes, + customParameters: oauth.customParameters, + }, + toolPatterns: serverToolPatterns(tool.name, allowedPatterns), }); break; } @@ -704,6 +781,13 @@ function resolveTools( return result; } +/** The MCP endpoint of a gateway, as @aws/agentcore-cdk derives it from the ARN at deploy. */ +function gatewayUrl(gatewayArn: string): string { + const { partition, region } = parseArn(gatewayArn)!; + const suffix = partition === "aws-cn" ? "amazonaws.com.cn" : "amazonaws.com"; + return `https://${serviceIdFromArn(gatewayArn)}.gateway.bedrock-agentcore.${region}.${suffix}/mcp`; +} + function undefinedIfEmpty(values: T[]): T[] | undefined { return values.length > 0 ? values : undefined; } diff --git a/src/projectSchemas/connections.ts b/src/projectSchemas/connections.ts index fbd4e236ee..84ac82abbc 100644 --- a/src/projectSchemas/connections.ts +++ b/src/projectSchemas/connections.ts @@ -92,3 +92,20 @@ export const ConnectionSchema = z .strict(); export type Connection = z.infer; export const ConnectionsSchema = z.array(ConnectionSchema); + +/** + Mirrors @aws/agentcore-cdk: deploy names a connection's env vars __ with this token. +**/ +export function connectionTokenFor(connection: Connection): string { + return connectionEnvToken(connection.id ?? connectionIdForTarget(connection.to)); +} + +function connectionEnvToken(id: string): string { + return id.toUpperCase().replace(/[^A-Z0-9]/g, "_"); +} + +function connectionIdForTarget(target: ConnectionTarget): string { + const afterColon = "arn" in target && target.arn ? target.arn.split(":").pop()! : undefined; + const suffix = afterColon ? afterColon.slice(afterColon.lastIndexOf("/") + 1) : target.type; + return `${target.type}-${suffix}`.replace(/[^a-zA-Z0-9_-]/g, "-").slice(0, 64); +}