From 9b87e81df970da76f2d28197900e41531af8226d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:06:24 +0000 Subject: [PATCH 1/2] Initial plan From da33d758c0bc78aa239aa508884eb5843b4eb9b7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:10:59 +0000 Subject: [PATCH 2/2] Provision Foundry GitHub remote-tool connection securely with Bicep Co-authored-by: paulyuk <1968137+paulyuk@users.noreply.github.com> --- IMPLEMENTATION_NOTES.md | 43 +++++++++-- infra/ai-gateway/README.md | 43 +++++++++++ infra/ai-gateway/main.bicep | 18 ++++- infra/foundry-agents/github-connection.bicep | 37 ++++++++++ infra/main.bicep | 3 + infra/scripts/configure-ai-gateway.ps1 | 14 +--- infra/scripts/configure-ai-gateway.sh | 14 +--- tests/test-ai-gateway-model-registration.sh | 75 ++++++++++++++++++++ 8 files changed, 217 insertions(+), 30 deletions(-) create mode 100644 infra/ai-gateway/README.md create mode 100644 infra/foundry-agents/github-connection.bicep diff --git a/IMPLEMENTATION_NOTES.md b/IMPLEMENTATION_NOTES.md index a900863..76caab5 100644 --- a/IMPLEMENTATION_NOTES.md +++ b/IMPLEMENTATION_NOTES.md @@ -26,7 +26,8 @@ Functions application. - `infra/foundry-agents/main.bicep` creates the agent project, storage, Container Registry, monitoring, connections, and RBAC. - `infra/ai-gateway/main.bicep` creates AI Gateway, its Connector Namespace, - model provider, model registrations, runtime key, and monitoring. + model provider, model registrations, runtime key, and monitoring, and invokes + `infra/foundry-agents/github-connection.bicep` in the agents resource group. - `chat.py` is a local console client for the Responses endpoint. The scheduled routine is named `daily-repo-digest`. It runs at 9 AM in the @@ -56,7 +57,7 @@ flowchart LR Model requests use `/default/models/openai/v1/chat/completions` or the corresponding Responses route. Tool requests use -`/default/toolservers/github/mcp`. +`/default/mcp/github/mcp`. The agent does not call a Foundry model endpoint directly. This keeps model traffic behind AI Gateway policies. `FoundryChatClient` is intentionally not @@ -71,9 +72,11 @@ Agent -> Foundry Toolbox -> AI Gateway -> GitHub MCP ``` `toolbox.yaml` declares one MCP source backed by the `aigw-github` project -connection. The postprovision hook stores the AI Gateway `Api-Key` in that -connection. `FoundryToolbox` authenticates the agent-to-Toolbox request with -Entra identity. +connection. Bicep creates that `RemoteTool` / `CustomKeys` connection and stores +the generated AI Gateway `Api-Key` in it. The postprovision hook creates +`repo-digest-tools` through the Foundry data-plane API using that connection; +it no longer runs `azd ai connection create`. `FoundryToolbox` authenticates +the agent-to-Toolbox request with Entra identity. The GitHub source sets `require_approval: "never"` because all three exposed operations are read-only and the scheduled routine cannot pause for approval. @@ -291,6 +294,36 @@ The predefined `workspaces/default` resource is the parent of every workspace-scoped child. The postprovision hook waits for the workspace child collection before configuring the GitHub ToolServer. +The gateway module receives the agents account and project names from the +Foundry module outputs, so it waits for project provisioning. Its nested +connection module targets the agents resource group and depends on the gateway +and `apiKeys/default`. The connection target is +`/default/mcp/github/mcp`, with `isSharedToAll: true` and +`metadata.type: custom_MCP`. Stable resource names and incremental deployments +allow repeated `azd up` runs to update the same connection. + +The preview key contract used by the deployment is +`runtimeApiKey.listSecrets().key` at `2025-09-01-preview`. Bicep compiles this +created-resource call to ARM `listSecrets(resourceId(...), apiVersion).key` +and adds the key resource dependency. The value crosses the module boundary +only through an `@secure()` parameter (`securestring` in ARM), never an output. +The deploying identity needs permission to list the gateway key and write +project connections in the agents resource group. Do not log resolved +deployment parameters or run the hooks with shell tracing/debug logging. + +Both postprovision hooks still retrieve the key with `listSecrets` (preferring +`key`, with legacy response-field and `listValues` compatibility fallbacks) and +save `AZURE_AI_GATEWAY_API_KEY` to the local azd environment for +`create-dev-env.sh`. They also inject the GitHub credential, check model-route +readiness, and create the data-plane Toolbox if absent. Failed-resource +recovery remains in the lifecycle scripts. + +Compilation and regression tests verify the ARM expression, secure parameter, +dependencies, target, and absence of secret outputs. A fresh and repeated +`azd up` against the live preview service are still required to validate its +response contract and Toolbox attachment end to end; compilation alone cannot +verify a service response. + The Foundry model provider uses: - `kind: Foundry` diff --git a/infra/ai-gateway/README.md b/infra/ai-gateway/README.md new file mode 100644 index 0000000..8bc1569 --- /dev/null +++ b/infra/ai-gateway/README.md @@ -0,0 +1,43 @@ +# AI Gateway provisioning + +The subscription entry point, `../main.bicep`, deploys this module into the +gateway resource group after the Foundry project and model resources are ready. +This module creates the gateway and `apiKeys/default`, then deploys +`../foundry-agents/github-connection.bicep` into the agents resource group. + +The `aigw-github` project connection uses +`Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview`, +category `RemoteTool`, authentication `CustomKeys`, and target +`https:///default/mcp/github/mcp`. It is shared with the project +and has `metadata.type: custom_MCP`. + +The gateway's `2025-09-01-preview` key is read with +`runtimeApiKey.listSecrets().key` and passed only through the connection +module's `@secure()` `gatewayApiKey` parameter. ARM waits for key creation +before evaluating the nested deployment. No deployment output contains the +secret. The deployment identity needs key-listing permission in the gateway +resource group and connection-write permission in the agents resource group. +Stable names and incremental deployment keep repeat provisioning idempotent. + +The POSIX and PowerShell postprovision hooks no longer create the connection. +They still register the GitHub ToolServer using the local `GH_TOKEN` or GitHub +CLI credential, verify model-route readiness, save `AZURE_AI_GATEWAY_API_KEY` +to the local azd environment for development, and create the +`repo-digest-tools` Toolbox through the Foundry data-plane API. +`toolbox.yaml` references `project_connection_id: aigw-github`. +Failed-resource recovery remains in the lifecycle hooks. + +Do not enable shell tracing or debug logging when handling keys. Local +environment files are private deployment state, not source configuration. + +From the repository root, validate the compiled contract with: + +```bash +bash tests/test-ai-gateway-model-registration.sh +bash tests/test-apim-lifecycle.sh +``` + +Live verification requires Azure credentials: run `azd up` on a fresh +environment, confirm Toolbox resolves `aigw-github`, and repeat `azd up` to +check idempotency. The focused tests verify generated ARM, not live preview +responses. diff --git a/infra/ai-gateway/main.bicep b/infra/ai-gateway/main.bicep index b827825..c9de809 100644 --- a/infra/ai-gateway/main.bicep +++ b/infra/ai-gateway/main.bicep @@ -17,6 +17,9 @@ param foundryResourceIds array = [] param foundryManagedIdentityResource string = 'https://cognitiveservices.azure.com/' param foundryModels array = [] param modelAliasesConfig array = [] +param foundryAgentsResourceGroupName string = resourceGroup().name +param foundryAgentsAccountName string = '' +param foundryAgentsProjectName string = '' @secure() param toolServerConfigs object = {} @@ -187,6 +190,19 @@ resource runtimeApiKey 'Microsoft.ApiManagement/aigateways/apiKeys@2025-09-01-pr } } +var runtimeHostname = deployApimGatewayViaBicep ? (aiGateway!.properties.?frontend.?defaultHostname ?? replace(aiGateway!.properties.?gatewayUrl ?? '', 'https://', '')) : '' + +module githubConnection '../foundry-agents/github-connection.bicep' = if (deployApimGatewayViaBicep && !empty(foundryAgentsProjectName)) { + name: 'github-connection-${uniqueString(gatewayName, foundryAgentsProjectName)}' + scope: resourceGroup(foundryAgentsResourceGroupName) + params: { + aiServicesName: foundryAgentsAccountName + aiProjectName: foundryAgentsProjectName + gatewayUrl: 'https://${runtimeHostname}' + gatewayApiKey: runtimeApiKey!.listSecrets().key + } +} + var modelAliasResourceIds = [for alias in modelAliasesConfig: resourceId('Microsoft.ApiManagement/aigateways/workspaces/aliases', gatewayName, telemetryWorkspaceName, alias.armName)] output apimGatewayId string = resourceId('Microsoft.ApiManagement/aigateways', gatewayName) @@ -196,7 +212,7 @@ output appInsightsName string = enableMonitoring ? appInsights.name : '' output logAnalyticsWorkspaceId string = enableMonitoring ? logAnalyticsWorkspace.id : '' output logAnalyticsWorkspaceName string = enableMonitoring ? logAnalyticsWorkspace.name : '' output telemetryExporterId string = enableMonitoring && deployApimGatewayViaBicep ? telemetryExporter.id : '' -output runtimeHostname string = deployApimGatewayViaBicep ? (aiGateway!.properties.?frontend.?defaultHostname ?? replace(aiGateway!.properties.?gatewayUrl ?? '', 'https://', '')) : '' +output runtimeHostname string = runtimeHostname output runtimeApiKeyId string = deployApimGatewayViaBicep ? runtimeApiKey.id : '' output gatewayPrincipalId string = deployApimGatewayViaBicep ? aiGateway!.identity.principalId : '' output foundryUserRoleAssignmentName string = deployFoundryProvider && deployApimGatewayViaBicep && !empty(foundryResourceIds) ? foundryUserRoleAssignments[0]!.outputs.roleAssignmentName : '' diff --git a/infra/foundry-agents/github-connection.bicep b/infra/foundry-agents/github-connection.bicep new file mode 100644 index 0000000..39bcd15 --- /dev/null +++ b/infra/foundry-agents/github-connection.bicep @@ -0,0 +1,37 @@ +targetScope = 'resourceGroup' + +param aiServicesName string +param aiProjectName string +param gatewayUrl string +@secure() +param gatewayApiKey string + +var normalizedGatewayUrl = endsWith(gatewayUrl, '/') ? take(gatewayUrl, length(gatewayUrl) - 1) : gatewayUrl + +resource aiServices 'Microsoft.CognitiveServices/accounts@2025-04-01-preview' existing = { + name: aiServicesName +} + +resource aiProject 'Microsoft.CognitiveServices/accounts/projects@2025-04-01-preview' existing = { + parent: aiServices + name: aiProjectName +} + +resource githubConnection 'Microsoft.CognitiveServices/accounts/projects/connections@2025-04-01-preview' = { + parent: aiProject + name: 'aigw-github' + properties: { + category: 'RemoteTool' + authType: 'CustomKeys' + target: '${normalizedGatewayUrl}/default/mcp/github/mcp' + credentials: { + keys: { + 'Api-Key': gatewayApiKey + } + } + isSharedToAll: true + metadata: { + type: 'custom_MCP' + } + } +} diff --git a/infra/main.bicep b/infra/main.bicep index 1d3b743..0bc87cb 100644 --- a/infra/main.bicep +++ b/infra/main.bicep @@ -227,6 +227,9 @@ module aiGateway './ai-gateway/main.bicep' = { location: location gatewayLocation: aiGatewayLocation gatewayName: effectiveAiGatewayName + foundryAgentsResourceGroupName: foundryAgentsRg.name + foundryAgentsAccountName: foundryAgents.outputs.aiServicesName + foundryAgentsProjectName: foundryAgents.outputs.aiProjectName tags: tags enableMonitoring: true deployApimGatewayViaBicep: true diff --git a/infra/scripts/configure-ai-gateway.ps1 b/infra/scripts/configure-ai-gateway.ps1 index 9abbe78..95055c9 100644 --- a/infra/scripts/configure-ai-gateway.ps1 +++ b/infra/scripts/configure-ai-gateway.ps1 @@ -7,7 +7,6 @@ $foundryUserRoleId = "53ca6127-db72-4b80-b1b0-d745d6d5456d" $defaultRepository = "microsoft/agent-framework" $githubMcpServer = "https://api.githubcopilot.com/mcp/" $githubMcpTools = "list_pull_requests,list_issues,actions_list" -$toolboxConnectionName = "aigw-github" $toolboxName = "repo-digest-tools" $repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "../..")).Path @@ -72,7 +71,7 @@ function Invoke-AzRestPutJson($Uri, $Body) { function Get-GatewayApiKeyValue($GatewayResourceId) { $listSecretsUri = "https://management.azure.com${GatewayResourceId}/apiKeys/default/listSecrets?api-version=$aiGatewayApiVersion" try { - $value = az rest --method post --uri $listSecretsUri --body "{}" --query "primaryKey || properties.primaryKey || primaryValue || properties.primaryValue" -o tsv 2>$null + $value = az rest --method post --uri $listSecretsUri --body "{}" --query "key || primaryKey || properties.primaryKey || primaryValue || properties.primaryValue" -o tsv 2>$null if (-not [string]::IsNullOrWhiteSpace($value)) { return $value.Trim() } } catch { Write-Verbose "The current backend does not expose listSecrets; trying listValues." @@ -369,16 +368,7 @@ azd env set GITHUB_REPOSITORY $githubRepository azd env set AZURE_AI_GATEWAY_API_KEY $gatewayApiKey | Out-Null azd env set TOOLBOX_NAME $toolboxName -Write-Host "Connecting Foundry Toolbox to the AI Gateway GitHub ToolServer." -azd ai connection create $toolboxConnectionName ` - --kind remote-tool ` - --target ($gatewayUrl.TrimEnd("/") + "/default/toolservers/github/mcp") ` - --auth-type custom-keys ` - --custom-key "Api-Key=$gatewayApiKey" ` - --force ` - --no-prompt ` - --project-endpoint $projectEndpoint ` - -o json | Out-Null +Write-Host "Connecting Foundry Toolbox using the Bicep-provisioned aigw-github connection." $toolboxExists = $true try { diff --git a/infra/scripts/configure-ai-gateway.sh b/infra/scripts/configure-ai-gateway.sh index 114ad48..af22cba 100755 --- a/infra/scripts/configure-ai-gateway.sh +++ b/infra/scripts/configure-ai-gateway.sh @@ -8,7 +8,6 @@ FOUNDRY_USER_ROLE_ID="53ca6127-db72-4b80-b1b0-d745d6d5456d" DEFAULT_REPOSITORY="microsoft/agent-framework" GITHUB_MCP_SERVER="https://api.githubcopilot.com/mcp/" GITHUB_MCP_TOOLS="list_pull_requests,list_issues,actions_list" -TOOLBOX_CONNECTION_NAME="aigw-github" TOOLBOX_NAME="repo-digest-tools" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" @@ -58,7 +57,7 @@ get_gateway_api_key_value() { --method post \ --uri "https://management.azure.com${gateway_resource_id}/apiKeys/default/listSecrets?api-version=${AI_GATEWAY_API_VERSION}" \ --body '{}' \ - --query "primaryKey || properties.primaryKey || primaryValue || properties.primaryValue" \ + --query "key || primaryKey || properties.primaryKey || primaryValue || properties.primaryValue" \ -o tsv 2>/dev/null)" && [ -n "$value" ]; then printf '%s' "$value" return 0 @@ -368,16 +367,7 @@ azd env set GITHUB_REPOSITORY "$github_repository" azd env set AZURE_AI_GATEWAY_API_KEY "$gateway_api_key" >/dev/null azd env set TOOLBOX_NAME "$TOOLBOX_NAME" -echo "Connecting Foundry Toolbox to the AI Gateway GitHub ToolServer." -azd ai connection create "$TOOLBOX_CONNECTION_NAME" \ - --kind remote-tool \ - --target "${gateway_url%/}/default/toolservers/github/mcp" \ - --auth-type custom-keys \ - --custom-key "Api-Key=${gateway_api_key}" \ - --force \ - --no-prompt \ - --project-endpoint "$project_endpoint" \ - -o json >/dev/null +echo "Connecting Foundry Toolbox using the Bicep-provisioned aigw-github connection." if ! toolbox_json="$(azd ai toolbox show "$TOOLBOX_NAME" \ --no-prompt \ diff --git a/tests/test-ai-gateway-model-registration.sh b/tests/test-ai-gateway-model-registration.sh index a34270c..a29b1f4 100755 --- a/tests/test-ai-gateway-model-registration.sh +++ b/tests/test-ai-gateway-model-registration.sh @@ -139,4 +139,79 @@ if [ "$mini_capacity" != "200" ]; then exit 1 fi +if ! jq -e ' + .resources[] + | select(.type == "Microsoft.Resources/deployments" and .properties.parameters.gatewayApiKey != null) + | . as $module + | .properties.template as $template + | .properties.parameters.gatewayApiKey.value as $key + | ($module.resourceGroup == "[parameters('\''foundryAgentsResourceGroupName'\'')]") + and ($module.properties.mode == "Incremental") + and ($module.condition | contains("deployApimGatewayViaBicep") and contains("foundryAgentsProjectName")) + and ($template.parameters.gatewayApiKey.type == "securestring") + and ($key == "[listSecrets(resourceId('\''Microsoft.ApiManagement/aigateways/apiKeys'\'', parameters('\''gatewayName'\''), '\''default'\''), '\''2025-09-01-preview'\'').key]") + and ($module.dependsOn | any(contains("Microsoft.ApiManagement/aigateways/apiKeys"))) + and ($template.resources | length == 1) + and ($template.resources[0] | + .type == "Microsoft.CognitiveServices/accounts/projects/connections" + and .apiVersion == "2025-04-01-preview" + and (.name | contains("aigw-github")) + and .properties.category == "RemoteTool" + and .properties.authType == "CustomKeys" + and .properties.target == "[format('\''{0}/default/mcp/github/mcp'\'', variables('\''normalizedGatewayUrl'\''))]" + and .properties.credentials.keys["Api-Key"] == "[parameters('\''gatewayApiKey'\'')]" + and .properties.isSharedToAll == true + and .properties.metadata.type == "custom_MCP") +' "$compiled_template" >/dev/null; then + echo "GitHub connection must use a secure key parameter, the MCP route, and depend on the generated key." >&2 + exit 1 +fi + +if ! jq -e ' + .resources[] + | select(.properties.parameters.foundryAgentsProjectName != null) + | (.properties.parameters.foundryAgentsResourceGroupName.value == "[variables('\''effectiveFoundryAgentsResourceGroupName'\'')]") + and (.properties.parameters.foundryAgentsAccountName.value | contains("outputs.aiServicesName.value")) + and (.properties.parameters.foundryAgentsProjectName.value | contains("outputs.aiProjectName.value")) + and (.dependsOn | any(contains("foundry-agents-"))) +' "$compiled_root_template" >/dev/null; then + echo "Gateway connection provisioning must wait for the Foundry agents project in its own resource group." >&2 + exit 1 +fi + +if ! jq -e ' + [.. | objects | .outputs? // {} | to_entries[] + | select((.value.value | tostring | test("listSecrets|listValues|parameters\\('\''gatewayApiKey'\''\\)")) + or (.key | test("(?i)^(gatewayApiKey|AI_GATEWAY_API_KEY|AZURE_AI_GATEWAY_API_KEY)$")))] + | length == 0 +' "$compiled_root_template" >/dev/null; then + echo "Gateway API key values must never be emitted in deployment outputs." >&2 + exit 1 +fi + +for hook in "$repo_root/infra/scripts/configure-ai-gateway.sh" "$repo_root/infra/scripts/configure-ai-gateway.ps1"; do + if grep -Eq 'azd[[:space:]]+ai[[:space:]]+connection[[:space:]]+create|--custom-key' "$hook"; then + echo "Postprovision must not create the Bicep-owned connection or pass its key to a connection command." >&2 + exit 1 + fi + for token in \ + '/apiKeys/default/listSecrets?' \ + '--query "key || primaryKey' \ + 'azd env set AZURE_AI_GATEWAY_API_KEY' \ + 'toolServers/github' \ + 'GH_TOKEN' \ + 'azd ai toolbox show' \ + 'azd ai toolbox create'; do + if ! grep -Fq -- "$token" "$hook"; then + echo "Postprovision must preserve local key retrieval, GitHub registration, and Toolbox setup: $token" >&2 + exit 1 + fi + done +done + +if ! grep -Fq 'project_connection_id: aigw-github' "$repo_root/toolbox.yaml"; then + echo "Toolbox must attach the Bicep-provisioned aigw-github connection." >&2 + exit 1 +fi + echo "AI Gateway model registration test passed."