From 8101c0292dcb7bd19829c97d84a5f9d60719f7e7 Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Mon, 20 Jul 2026 10:07:25 -0400 Subject: [PATCH 01/12] fix(agents): prompt for unified config env values Prompt for unset bare ${VAR} references in adopted Foundry service configuration and persist the responses to the active azd environment. Preserve defaults, Foundry expressions, existing values, hook behavior, and --no-prompt execution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b4617dfd-adfb-4b30-9222-477a041f8af9 --- .../internal/cmd/init_adopt.go | 11 + .../azure.ai.agents/internal/cmd/init_env.go | 253 +++++++++++++++++ .../internal/cmd/init_env_test.go | 267 ++++++++++++++++++ 3 files changed, 531 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go index f71af284c78..37a613633b4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_adopt.go @@ -981,6 +981,17 @@ func runInitFromAzureYaml( } } + // scaffoldProject changes the extension process into the adopted project root. + if err := configureAzureYamlEnvironmentVariables( + ctx, + azdClient, + env.Name, + ".", + flags.noPrompt, + ); err != nil { + return err + } + fmt.Printf( "\nAdopted the sample's azure.yaml as the project manifest at %s.\n", output.WithHighLightFormat("azure.yaml"), diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go new file mode 100644 index 00000000000..08794fe2cc6 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go @@ -0,0 +1,253 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + + "azureaiagent/internal/exterrors" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "gopkg.in/yaml.v3" +) + +// azureYamlEnvRefPattern matches bare ${VAR} references and references with a +// fallback. Group 2 is non-empty for ${VAR:-default}, which does not require an +// environment value because the runtime expander supplies the fallback. +var azureYamlEnvRefPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:-[^}]*)?\}`) + +type azureYamlEnvironmentReference struct { + Name string + Secret bool +} + +// configureAzureYamlEnvironmentVariables prompts for unset environment values +// referenced by the adopted azure.yaml and persists them in the active azd +// environment. +func configureAzureYamlEnvironmentVariables( + ctx context.Context, + azdClient *azdext.AzdClient, + envName string, + projectDir string, + noPrompt bool, +) error { + if noPrompt { + return nil + } + + manifestPath := filepath.Join(projectDir, "azure.yaml") + //nolint:gosec // projectDir is the user-selected project root created by init + content, err := os.ReadFile(manifestPath) + if err != nil { + return fmt.Errorf("reading adopted azure.yaml: %w", err) + } + + references, err := findAzureYamlEnvironmentReferences(content) + if err != nil { + return fmt.Errorf("finding environment variable references in azure.yaml: %w", err) + } + if len(references) == 0 { + return nil + } + + envResp, err := azdClient.Environment().GetValues(ctx, &azdext.GetEnvironmentRequest{ + Name: envName, + }) + if err != nil { + return fmt.Errorf("reading azd environment %q: %w", envName, err) + } + + existing := make(map[string]string, len(envResp.KeyValues)) + for _, keyValue := range envResp.KeyValues { + existing[keyValue.Key] = keyValue.Value + } + + missing := make([]azureYamlEnvironmentReference, 0, len(references)) + for _, reference := range references { + if existing[reference.Name] == "" { + missing = append(missing, reference) + } + } + if len(missing) == 0 { + return nil + } + + fmt.Println() + fmt.Println("azure.yaml references environment variables that need to be configured:") + fmt.Println() + + for _, reference := range missing { + promptResp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: fmt.Sprintf("Enter a value for %s", reference.Name), + HelpMessage: "The value will be stored in the current azd environment.", + Required: true, + RequiredMessage: fmt.Sprintf("A value is required for %s.", reference.Name), + IgnoreHintKeys: true, + Secret: reference.Secret, + ClearOnCompletion: reference.Secret, + }, + }) + if err != nil { + return exterrors.FromPrompt( + err, + fmt.Sprintf("failed to prompt for environment variable %s", reference.Name), + ) + } + if promptResp == nil || promptResp.Value == "" { + return fmt.Errorf("no value provided for environment variable %s", reference.Name) + } + + if err := setEnvValue(ctx, azdClient, envName, reference.Name, promptResp.Value); err != nil { + return err + } + } + + return nil +} + +func findAzureYamlEnvironmentReferences(content []byte) ([]azureYamlEnvironmentReference, error) { + var document yaml.Node + if err := yaml.Unmarshal(content, &document); err != nil { + return nil, fmt.Errorf("parsing azure.yaml: %w", err) + } + + if len(document.Content) == 0 { + return nil, nil + } + + services := yamlMappingValue(document.Content[0], "services") + if services == nil || services.Kind != yaml.MappingNode { + return nil, nil + } + + var references []azureYamlEnvironmentReference + indexByName := make(map[string]int) + for i := 0; i+1 < len(services.Content); i += 2 { + serviceName := services.Content[i].Value + service := services.Content[i+1] + if !isFoundryAzureYamlService(service) { + continue + } + + collectAzureYamlEnvironmentReferences( + service, + []string{"services", serviceName}, + &references, + indexByName, + ) + } + return references, nil +} + +func yamlMappingValue(node *yaml.Node, key string) *yaml.Node { + if node == nil || node.Kind != yaml.MappingNode { + return nil + } + + for i := 0; i+1 < len(node.Content); i += 2 { + if node.Content[i].Value == key { + return node.Content[i+1] + } + } + return nil +} + +func isFoundryAzureYamlService(service *yaml.Node) bool { + host := yamlMappingValue(service, "host") + if host == nil || host.Kind != yaml.ScalarNode { + return false + } + + _, knownHost := foundryServiceHosts[host.Value] + return knownHost || strings.HasPrefix(host.Value, "azure.ai.") +} + +func collectAzureYamlEnvironmentReferences( + node *yaml.Node, + path []string, + references *[]azureYamlEnvironmentReference, + indexByName map[string]int, +) { + if node == nil { + return + } + + switch node.Kind { + case yaml.DocumentNode, yaml.SequenceNode: + for _, child := range node.Content { + collectAzureYamlEnvironmentReferences(child, path, references, indexByName) + } + case yaml.MappingNode: + for i := 0; i+1 < len(node.Content); i += 2 { + key := node.Content[i] + if key.Value == "hooks" { + continue + } + value := node.Content[i+1] + childPath := append(slices.Clone(path), key.Value) + collectAzureYamlEnvironmentReferences(value, childPath, references, indexByName) + } + case yaml.AliasNode: + collectAzureYamlEnvironmentReferences(node.Alias, path, references, indexByName) + case yaml.ScalarNode: + for _, match := range azureYamlEnvRefPattern.FindAllStringSubmatchIndex(node.Value, -1) { + if isEscapedAzureYamlEnvironmentReference(node.Value, match[0]) { + continue + } + if match[4] != -1 { + continue + } + + name := node.Value[match[2]:match[3]] + secret := isSecretAzureYamlEnvironmentReference(path, name) + if index, ok := indexByName[name]; ok { + if secret { + (*references)[index].Secret = true + } + continue + } + + indexByName[name] = len(*references) + *references = append(*references, azureYamlEnvironmentReference{ + Name: name, + Secret: secret, + }) + } + } +} + +func isEscapedAzureYamlEnvironmentReference(value string, start int) bool { + precedingDollars := 0 + for i := start - 1; i >= 0 && value[i] == '$'; i-- { + precedingDollars++ + } + return precedingDollars%2 == 1 +} + +func isSecretAzureYamlEnvironmentReference(path []string, name string) bool { + for _, segment := range path { + normalized := strings.ToLower(segment) + if strings.Contains(normalized, "credential") || strings.Contains(normalized, "secret") { + return true + } + } + + for token := range strings.FieldsFuncSeq(strings.ToUpper(name), func(r rune) bool { + return r == '_' || r == '-' + }) { + switch token { + case "CREDENTIAL", "CREDENTIALS", "KEY", "PASSWORD", "PASSPHRASE", "SECRET", "TOKEN": + return true + } + } + + return false +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go new file mode 100644 index 00000000000..e8492a92e93 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go @@ -0,0 +1,267 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestFindAzureYamlEnvironmentReferences(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + want []azureYamlEnvironmentReference + wantErr bool + }{ + { + name: "finds issue references and classifies credential as secret", + content: `name: playwright-agent +services: + playwright: + host: azure.ai.connection + credentials: + key: '${PLAYWRIGHT_SERVICE_ACCESS_TOKEN}' + metadata: + resourceId: '${PLAYWRIGHT_SERVICE_RESOURCE_ID}' +`, + want: []azureYamlEnvironmentReference{ + {Name: "PLAYWRIGHT_SERVICE_ACCESS_TOKEN", Secret: true}, + {Name: "PLAYWRIGHT_SERVICE_RESOURCE_ID"}, + }, + }, + { + name: "deduplicates and upgrades secret classification", + content: `name: sample +services: + connection: + host: azure.ai.connection + metadata: + resourceId: ${SHARED_VALUE} + credentials: + key: ${SHARED_VALUE} +`, + want: []azureYamlEnvironmentReference{ + {Name: "SHARED_VALUE", Secret: true}, + }, + }, + { + name: "ignores defaults foundry templates comments and escaped references", + content: `name: sample +# ${COMMENT_ONLY} +services: + agent: + host: azure.ai.agent + environmentVariables: + - name: DEFAULTED + value: ${DEFAULTED:-fallback} + - name: FOUNDRY + value: ${{connections.search.credentials.key}} + - name: ESCAPED + value: $${ESCAPED} + - name: EXPANDED_AFTER_LITERAL_DOLLAR + value: $$${EXPANDED_AFTER_LITERAL_DOLLAR} +`, + want: []azureYamlEnvironmentReference{ + {Name: "EXPANDED_AFTER_LITERAL_DOLLAR"}, + }, + }, + { + name: "uses variable name to identify secrets outside credential paths", + content: `name: sample +services: + agent: + host: azure.ai.agent + environmentVariables: + - name: API_TOKEN + value: ${SERVICE_API_TOKEN} + - name: ENDPOINT + value: ${SERVICE_ENDPOINT} +`, + want: []azureYamlEnvironmentReference{ + {Name: "SERVICE_API_TOKEN", Secret: true}, + {Name: "SERVICE_ENDPOINT"}, + }, + }, + { + name: "ignores project hooks service hooks and unrelated services", + content: `name: sample +hooks: + preprovision: + shell: sh + run: echo ${PROJECT_HOOK_VALUE} +services: + web: + host: containerapp + env: + WEB_VALUE: ${WEB_VALUE} + agent: + host: azure.ai.agent + hooks: + predeploy: + shell: sh + run: echo ${SERVICE_HOOK_VALUE} + environmentVariables: + - name: AGENT_VALUE + value: ${AGENT_VALUE} +`, + want: []azureYamlEnvironmentReference{ + {Name: "AGENT_VALUE"}, + }, + }, + { + name: "malformed yaml", + content: "name: [unterminated", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got, err := findAzureYamlEnvironmentReferences([]byte(tt.content)) + if tt.wantErr { + require.Error(t, err) + return + } + + require.NoError(t, err) + require.Equal(t, tt.want, got) + }) + } +} + +func TestConfigureAzureYamlEnvironmentVariables_PromptsAndPersistsMissingValues(t *testing.T) { + projectDir := t.TempDir() + content := `name: playwright-agent +services: + playwright: + host: azure.ai.connection + credentials: + key: '${PLAYWRIGHT_SERVICE_ACCESS_TOKEN}' + metadata: + resourceId: '${PLAYWRIGHT_SERVICE_RESOURCE_ID}' + existing: '${ALREADY_SET}' +` + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "azure.yaml"), []byte(content), 0600)) + t.Chdir(projectDir) + + envServer := &testEnvironmentServiceServer{ + values: map[string]map[string]string{ + "dev": { + "ALREADY_SET": "existing-value", + }, + }, + } + promptServer := &testPromptServiceServer{ + promptResponses: []string{"access-token", "/subscriptions/sub/resourceGroups/rg/providers/test"}, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}, promptServer) + + err := configureAzureYamlEnvironmentVariables( + t.Context(), + azdClient, + "dev", + ".", + false, + ) + require.NoError(t, err) + + require.Equal(t, "access-token", envServer.values["dev"]["PLAYWRIGHT_SERVICE_ACCESS_TOKEN"]) + require.Equal( + t, + "/subscriptions/sub/resourceGroups/rg/providers/test", + envServer.values["dev"]["PLAYWRIGHT_SERVICE_RESOURCE_ID"], + ) + require.Equal(t, "existing-value", envServer.values["dev"]["ALREADY_SET"]) + + require.Len(t, promptServer.promptRequests, 2) + require.Equal( + t, + "Enter a value for PLAYWRIGHT_SERVICE_ACCESS_TOKEN", + promptServer.promptRequests[0].Options.Message, + ) + require.True(t, promptServer.promptRequests[0].Options.Required) + require.True(t, promptServer.promptRequests[0].Options.Secret) + require.True(t, promptServer.promptRequests[0].Options.ClearOnCompletion) + + require.Equal( + t, + "Enter a value for PLAYWRIGHT_SERVICE_RESOURCE_ID", + promptServer.promptRequests[1].Options.Message, + ) + require.True(t, promptServer.promptRequests[1].Options.Required) + require.False(t, promptServer.promptRequests[1].Options.Secret) + require.False(t, promptServer.promptRequests[1].Options.ClearOnCompletion) +} + +func TestConfigureAzureYamlEnvironmentVariables_NoPromptSkipsPrompts(t *testing.T) { + t.Parallel() + + projectDir := t.TempDir() + content := `name: sample +services: + connection: + host: azure.ai.connection + credentials: + key: ${API_TOKEN} +` + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "azure.yaml"), []byte(content), 0600)) + + envServer := &testEnvironmentServiceServer{} + promptServer := &testPromptServiceServer{} + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}, promptServer) + + err := configureAzureYamlEnvironmentVariables( + t.Context(), + azdClient, + "dev", + projectDir, + true, + ) + require.NoError(t, err) + require.Empty(t, promptServer.promptRequests) + require.Empty(t, envServer.values) +} + +func TestConfigureAzureYamlEnvironmentVariables_SkipsConfiguredValues(t *testing.T) { + t.Parallel() + + projectDir := t.TempDir() + content := `name: sample +services: + connection: + host: azure.ai.connection + credentials: + key: ${API_TOKEN} +` + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "azure.yaml"), []byte(content), 0600)) + + envServer := &testEnvironmentServiceServer{ + values: map[string]map[string]string{ + "dev": { + "API_TOKEN": "configured", + }, + }, + } + promptServer := &testPromptServiceServer{} + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}, promptServer) + + err := configureAzureYamlEnvironmentVariables( + t.Context(), + azdClient, + "dev", + projectDir, + false, + ) + require.NoError(t, err) + require.Empty(t, promptServer.promptRequests) + require.Equal(t, "configured", envServer.values["dev"]["API_TOKEN"]) +} From 7162aaf3cbaf54714b30c36b8e6f3ea75d80c84f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:28:11 +0000 Subject: [PATCH 02/12] fix(agents): resolve refs when prompting env values Co-authored-by: glharper <64209257+glharper@users.noreply.github.com> --- .../azure.ai.agents/internal/cmd/init_env.go | 31 +++++++++-- .../internal/cmd/init_env_test.go | 55 ++++++++++++++++++- 2 files changed, 79 insertions(+), 7 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go index 08794fe2cc6..e09b94fdeba 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go @@ -15,6 +15,7 @@ import ( "azureaiagent/internal/exterrors" "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/foundry" "gopkg.in/yaml.v3" ) @@ -23,6 +24,8 @@ import ( // environment value because the runtime expander supplies the fallback. var azureYamlEnvRefPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:-[^}]*)?\}`) +var foundryTemplateSpanPattern = regexp.MustCompile(`(?s)\$\{\{.*?\}\}`) + type azureYamlEnvironmentReference struct { Name string Secret bool @@ -49,7 +52,7 @@ func configureAzureYamlEnvironmentVariables( return fmt.Errorf("reading adopted azure.yaml: %w", err) } - references, err := findAzureYamlEnvironmentReferences(content) + references, err := findAzureYamlEnvironmentReferences(content, projectDir) if err != nil { return fmt.Errorf("finding environment variable references in azure.yaml: %w", err) } @@ -113,7 +116,7 @@ func configureAzureYamlEnvironmentVariables( return nil } -func findAzureYamlEnvironmentReferences(content []byte) ([]azureYamlEnvironmentReference, error) { +func findAzureYamlEnvironmentReferences(content []byte, projectDir string) ([]azureYamlEnvironmentReference, error) { var document yaml.Node if err := yaml.Unmarshal(content, &document); err != nil { return nil, fmt.Errorf("parsing azure.yaml: %w", err) @@ -137,8 +140,21 @@ func findAzureYamlEnvironmentReferences(content []byte) ([]azureYamlEnvironmentR continue } + var raw map[string]any + if err := service.Decode(&raw); err != nil { + return nil, fmt.Errorf("decoding service %q: %w", serviceName, err) + } + resolved, err := foundry.ResolveFileRefs(raw, projectDir) + if err != nil { + return nil, fmt.Errorf("resolving $ref includes for service %q: %w", serviceName, err) + } + var resolvedService yaml.Node + if err := resolvedService.Encode(resolved); err != nil { + return nil, fmt.Errorf("encoding resolved service %q: %w", serviceName, err) + } + collectAzureYamlEnvironmentReferences( - service, + &resolvedService, []string{"services", serviceName}, &references, indexByName, @@ -198,15 +214,18 @@ func collectAzureYamlEnvironmentReferences( case yaml.AliasNode: collectAzureYamlEnvironmentReferences(node.Alias, path, references, indexByName) case yaml.ScalarNode: - for _, match := range azureYamlEnvRefPattern.FindAllStringSubmatchIndex(node.Value, -1) { - if isEscapedAzureYamlEnvironmentReference(node.Value, match[0]) { + value := foundryTemplateSpanPattern.ReplaceAllStringFunc(node.Value, func(span string) string { + return strings.Repeat(" ", len(span)) + }) + for _, match := range azureYamlEnvRefPattern.FindAllStringSubmatchIndex(value, -1) { + if isEscapedAzureYamlEnvironmentReference(value, match[0]) { continue } if match[4] != -1 { continue } - name := node.Value[match[2]:match[3]] + name := value[match[2]:match[3]] secret := isSecretAzureYamlEnvironmentReference(path, name) if index, ok := indexByName[name]; ok { if secret { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go index e8492a92e93..a63565c5955 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go @@ -63,6 +63,11 @@ services: value: ${DEFAULTED:-fallback} - name: FOUNDRY value: ${{connections.search.credentials.key}} + - name: MULTILINE_FOUNDRY + value: | + ${{ event.body + ?? '${FOUNDRY_INNER_VALUE}' + }} - name: ESCAPED value: $${ESCAPED} - name: EXPANDED_AFTER_LITERAL_DOLLAR @@ -126,7 +131,7 @@ services: t.Run(tt.name, func(t *testing.T) { t.Parallel() - got, err := findAzureYamlEnvironmentReferences([]byte(tt.content)) + got, err := findAzureYamlEnvironmentReferences([]byte(tt.content), ".") if tt.wantErr { require.Error(t, err) return @@ -138,6 +143,54 @@ services: } } +func TestConfigureAzureYamlEnvironmentVariables_ResolvesServiceRefs(t *testing.T) { + t.Parallel() + + projectDir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(projectDir, "services"), 0700)) + require.NoError(t, os.WriteFile( + filepath.Join(projectDir, "services", "connection.yaml"), + []byte(`credentials: + key: ${REFERENCED_API_TOKEN} +metadata: + resourceId: ${OVERRIDDEN_RESOURCE_ID} +`), + 0600, + )) + require.NoError(t, os.WriteFile( + filepath.Join(projectDir, "azure.yaml"), + []byte(`name: sample +services: + connection: + host: azure.ai.connection + $ref: ./services/connection.yaml + metadata: + resourceId: ${ROOT_RESOURCE_ID} +`), + 0600, + )) + + envServer := &testEnvironmentServiceServer{} + promptServer := &testPromptServiceServer{ + promptResponses: []string{"api-token", "resource-id"}, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}, promptServer) + + err := configureAzureYamlEnvironmentVariables( + t.Context(), + azdClient, + "dev", + projectDir, + false, + ) + require.NoError(t, err) + require.Equal(t, "api-token", envServer.values["dev"]["REFERENCED_API_TOKEN"]) + require.Equal(t, "resource-id", envServer.values["dev"]["ROOT_RESOURCE_ID"]) + require.NotContains(t, envServer.values["dev"], "OVERRIDDEN_RESOURCE_ID") + require.Len(t, promptServer.promptRequests, 2) + require.True(t, promptServer.promptRequests[0].Options.Secret) +} + func TestConfigureAzureYamlEnvironmentVariables_PromptsAndPersistsMissingValues(t *testing.T) { projectDir := t.TempDir() content := `name: playwright-agent From 5097c606a47025413f733ac7ab655f859e1730dd Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Mon, 20 Jul 2026 18:04:58 -0400 Subject: [PATCH 03/12] fix(agents): derive secret prompts from config Mask prompted values only when the reference appears under explicit credential or secret configuration. Avoid inferring sensitivity from arbitrary environment variable names while continuing to prompt for all unset Foundry references. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b4617dfd-adfb-4b30-9222-477a041f8af9 --- .../azure.ai.agents/internal/cmd/init_env.go | 19 ++++++------------- .../internal/cmd/init_env_test.go | 9 +++++++-- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go index e09b94fdeba..5fffbe7f0a4 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go @@ -226,7 +226,7 @@ func collectAzureYamlEnvironmentReferences( } name := value[match[2]:match[3]] - secret := isSecretAzureYamlEnvironmentReference(path, name) + secret := isSecretAzureYamlEnvironmentReference(path) if index, ok := indexByName[name]; ok { if secret { (*references)[index].Secret = true @@ -251,19 +251,12 @@ func isEscapedAzureYamlEnvironmentReference(value string, start int) bool { return precedingDollars%2 == 1 } -func isSecretAzureYamlEnvironmentReference(path []string, name string) bool { +// Secret masking is based on explicit configuration structure. Environment +// variable names are user-defined and are not a reliable sensitivity signal. +func isSecretAzureYamlEnvironmentReference(path []string) bool { for _, segment := range path { - normalized := strings.ToLower(segment) - if strings.Contains(normalized, "credential") || strings.Contains(normalized, "secret") { - return true - } - } - - for token := range strings.FieldsFuncSeq(strings.ToUpper(name), func(r rune) bool { - return r == '_' || r == '-' - }) { - switch token { - case "CREDENTIAL", "CREDENTIALS", "KEY", "PASSWORD", "PASSPHRASE", "SECRET", "TOKEN": + switch strings.ToLower(segment) { + case "credential", "credentials", "secret", "secrets": return true } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go index a63565c5955..b64df359775 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go @@ -28,10 +28,12 @@ services: host: azure.ai.connection credentials: key: '${PLAYWRIGHT_SERVICE_ACCESS_TOKEN}' + connectionString: '${DATABASE_CONNECTION_STRING}' metadata: resourceId: '${PLAYWRIGHT_SERVICE_RESOURCE_ID}' `, want: []azureYamlEnvironmentReference{ + {Name: "DATABASE_CONNECTION_STRING", Secret: true}, {Name: "PLAYWRIGHT_SERVICE_ACCESS_TOKEN", Secret: true}, {Name: "PLAYWRIGHT_SERVICE_RESOURCE_ID"}, }, @@ -78,7 +80,7 @@ services: }, }, { - name: "uses variable name to identify secrets outside credential paths", + name: "does not infer secrets from variable names outside credential paths", content: `name: sample services: agent: @@ -86,11 +88,14 @@ services: environmentVariables: - name: API_TOKEN value: ${SERVICE_API_TOKEN} + - name: DATABASE_CONNECTION_STRING + value: ${DATABASE_CONNECTION_STRING} - name: ENDPOINT value: ${SERVICE_ENDPOINT} `, want: []azureYamlEnvironmentReference{ - {Name: "SERVICE_API_TOKEN", Secret: true}, + {Name: "SERVICE_API_TOKEN"}, + {Name: "DATABASE_CONNECTION_STRING"}, {Name: "SERVICE_ENDPOINT"}, }, }, From da8c0f6df626494ecf6b968141292cb5ba0c42b9 Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Tue, 21 Jul 2026 16:49:41 -0400 Subject: [PATCH 04/12] fix(agents): honor process env during init Use persisted azd values first and fall back to the process environment before prompting for unified Foundry references. Keep explicitly empty persisted values missing so users can replace them interactively. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b4617dfd-adfb-4b30-9222-477a041f8af9 --- .../azure.ai.agents/internal/cmd/init_env.go | 14 +++- .../internal/cmd/init_env_test.go | 68 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go index 5fffbe7f0a4..25e337704ba 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go @@ -74,7 +74,7 @@ func configureAzureYamlEnvironmentVariables( missing := make([]azureYamlEnvironmentReference, 0, len(references)) for _, reference := range references { - if existing[reference.Name] == "" { + if !hasAzureYamlEnvironmentValue(existing, reference.Name) { missing = append(missing, reference) } } @@ -116,6 +116,18 @@ func configureAzureYamlEnvironmentVariables( return nil } +// hasAzureYamlEnvironmentValue mirrors Foundry expansion precedence: the azd +// environment wins when the key exists, otherwise the process environment is +// used. An explicitly empty azd value remains missing instead of falling back. +func hasAzureYamlEnvironmentValue(azdEnv map[string]string, name string) bool { + if value, ok := azdEnv[name]; ok { + return value != "" + } + + _, ok := os.LookupEnv(name) + return ok +} + func findAzureYamlEnvironmentReferences(content []byte, projectDir string) ([]azureYamlEnvironmentReference, error) { var document yaml.Node if err := yaml.Unmarshal(content, &document); err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go index b64df359775..f111137ae81 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go @@ -323,3 +323,71 @@ services: require.Empty(t, promptServer.promptRequests) require.Equal(t, "configured", envServer.values["dev"]["API_TOKEN"]) } + +func TestConfigureAzureYamlEnvironmentVariables_UsesProcessEnvironmentFallback(t *testing.T) { + const envVarName = "AZD_TEST_INIT_PROCESS_ONLY_VALUE" + t.Setenv(envVarName, "from-process") + + projectDir := t.TempDir() + content := `name: sample +services: + connection: + host: azure.ai.connection + metadata: + processValue: ${AZD_TEST_INIT_PROCESS_ONLY_VALUE} +` + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "azure.yaml"), []byte(content), 0600)) + + envServer := &testEnvironmentServiceServer{} + promptServer := &testPromptServiceServer{} + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}, promptServer) + + err := configureAzureYamlEnvironmentVariables( + t.Context(), + azdClient, + "dev", + projectDir, + false, + ) + require.NoError(t, err) + require.Empty(t, promptServer.promptRequests) + require.Empty(t, envServer.values) +} + +func TestConfigureAzureYamlEnvironmentVariables_EmptyAzdValueBlocksProcessFallback(t *testing.T) { + const envVarName = "AZD_TEST_INIT_EXPLICIT_EMPTY_VALUE" + t.Setenv(envVarName, "from-process") + + projectDir := t.TempDir() + content := `name: sample +services: + connection: + host: azure.ai.connection + metadata: + explicitEmpty: ${AZD_TEST_INIT_EXPLICIT_EMPTY_VALUE} +` + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "azure.yaml"), []byte(content), 0600)) + + envServer := &testEnvironmentServiceServer{ + values: map[string]map[string]string{ + "dev": { + envVarName: "", + }, + }, + } + promptServer := &testPromptServiceServer{ + promptResponses: []string{"prompted-value"}, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}, promptServer) + + err := configureAzureYamlEnvironmentVariables( + t.Context(), + azdClient, + "dev", + projectDir, + false, + ) + require.NoError(t, err) + require.Len(t, promptServer.promptRequests, 1) + require.Equal(t, "prompted-value", envServer.values["dev"][envVarName]) +} From 6afd0e904e604197b9be4ae2863efea3ed46ddc3 Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Tue, 21 Jul 2026 17:08:30 -0400 Subject: [PATCH 05/12] fix(agents): persist process env during init Persist process-only values referenced by unified Foundry services into the active azd environment. This keeps providers that resolve only persisted values consistent while preserving explicit empty azd values as prompt-required. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b4617dfd-adfb-4b30-9222-477a041f8af9 --- .../azure.ai.agents/internal/cmd/init_env.go | 29 ++++++++++--------- .../internal/cmd/init_env_test.go | 14 +++++---- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go index 25e337704ba..4ab6ac1ae6d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go @@ -74,9 +74,22 @@ func configureAzureYamlEnvironmentVariables( missing := make([]azureYamlEnvironmentReference, 0, len(references)) for _, reference := range references { - if !hasAzureYamlEnvironmentValue(existing, reference.Name) { - missing = append(missing, reference) + if value, ok := existing[reference.Name]; ok { + if value == "" { + missing = append(missing, reference) + } + continue + } + + if value, ok := os.LookupEnv(reference.Name); ok { + if err := setEnvValue(ctx, azdClient, envName, reference.Name, value); err != nil { + return err + } + existing[reference.Name] = value + continue } + + missing = append(missing, reference) } if len(missing) == 0 { return nil @@ -116,18 +129,6 @@ func configureAzureYamlEnvironmentVariables( return nil } -// hasAzureYamlEnvironmentValue mirrors Foundry expansion precedence: the azd -// environment wins when the key exists, otherwise the process environment is -// used. An explicitly empty azd value remains missing instead of falling back. -func hasAzureYamlEnvironmentValue(azdEnv map[string]string, name string) bool { - if value, ok := azdEnv[name]; ok { - return value != "" - } - - _, ok := os.LookupEnv(name) - return ok -} - func findAzureYamlEnvironmentReferences(content []byte, projectDir string) ([]azureYamlEnvironmentReference, error) { var document yaml.Node if err := yaml.Unmarshal(content, &document); err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go index f111137ae81..8446fc5efc7 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go @@ -324,17 +324,19 @@ services: require.Equal(t, "configured", envServer.values["dev"]["API_TOKEN"]) } -func TestConfigureAzureYamlEnvironmentVariables_UsesProcessEnvironmentFallback(t *testing.T) { +func TestConfigureAzureYamlEnvironmentVariables_PersistsProcessEnvironmentFallback(t *testing.T) { const envVarName = "AZD_TEST_INIT_PROCESS_ONLY_VALUE" t.Setenv(envVarName, "from-process") projectDir := t.TempDir() content := `name: sample services: - connection: - host: azure.ai.connection - metadata: - processValue: ${AZD_TEST_INIT_PROCESS_ONLY_VALUE} + toolbox: + host: azure.ai.toolbox + tools: + - name: process-value + configuration: + value: ${AZD_TEST_INIT_PROCESS_ONLY_VALUE} ` require.NoError(t, os.WriteFile(filepath.Join(projectDir, "azure.yaml"), []byte(content), 0600)) @@ -351,7 +353,7 @@ services: ) require.NoError(t, err) require.Empty(t, promptServer.promptRequests) - require.Empty(t, envServer.values) + require.Equal(t, "from-process", envServer.values["dev"][envVarName]) } func TestConfigureAzureYamlEnvironmentVariables_EmptyAzdValueBlocksProcessFallback(t *testing.T) { From 2c5799ff6d10c827886cb631695bbc38fa95597d Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Wed, 22 Jul 2026 09:58:22 -0400 Subject: [PATCH 06/12] fix(agents): scan only expandable env fields Limit unified init environment discovery to the fields each Foundry provider actually expands, including deprecated nested agent, toolbox, and routine configuration. Treat empty process values as missing instead of persisting unusable values. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b4617dfd-adfb-4b30-9222-477a041f8af9 --- .../azure.ai.agents/internal/cmd/init_env.go | 116 ++++++++++++-- .../internal/cmd/init_env_test.go | 150 ++++++++++++++++++ 2 files changed, 252 insertions(+), 14 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go index 4ab6ac1ae6d..8ab2b5d789a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go @@ -26,6 +26,38 @@ var azureYamlEnvRefPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:- var foundryTemplateSpanPattern = regexp.MustCompile(`(?s)\$\{\{.*?\}\}`) +var azureYamlEnvironmentReferencePaths = map[string][][]string{ + "azure.ai.agent": { + {"environmentVariables", "*", "value"}, + {"config", "environmentVariables", "*", "value"}, + }, + "azure.ai.connection": { + {"target"}, + {"credentials"}, + {"metadata"}, + }, + "azure.ai.project": { + {"network", "agentSubnet", "vnet"}, + {"network", "peSubnet", "vnet"}, + {"network", "dns", "subscription"}, + }, + "azure.ai.routine": { + {"action", "input"}, + {"config", "action", "input"}, + }, + "azure.ai.toolbox": { + {"endpoint"}, + {"tools"}, + {"config", "endpoint"}, + {"config", "tools"}, + }, + "microsoft.foundry": { + {"network", "agentSubnet", "vnet"}, + {"network", "peSubnet", "vnet"}, + {"network", "dns", "subscription"}, + }, +} + type azureYamlEnvironmentReference struct { Name string Secret bool @@ -81,7 +113,7 @@ func configureAzureYamlEnvironmentVariables( continue } - if value, ok := os.LookupEnv(reference.Name); ok { + if value, ok := os.LookupEnv(reference.Name); ok && value != "" { if err := setEnvValue(ctx, azdClient, envName, reference.Name, value); err != nil { return err } @@ -149,7 +181,12 @@ func findAzureYamlEnvironmentReferences(content []byte, projectDir string) ([]az for i := 0; i+1 < len(services.Content); i += 2 { serviceName := services.Content[i].Value service := services.Content[i+1] - if !isFoundryAzureYamlService(service) { + host, ok := foundryAzureYamlServiceHost(service) + if !ok { + continue + } + referencePaths, ok := azureYamlEnvironmentReferencePaths[host] + if !ok { continue } @@ -166,12 +203,15 @@ func findAzureYamlEnvironmentReferences(content []byte, projectDir string) ([]az return nil, fmt.Errorf("encoding resolved service %q: %w", serviceName, err) } - collectAzureYamlEnvironmentReferences( - &resolvedService, - []string{"services", serviceName}, - &references, - indexByName, - ) + for _, referencePath := range referencePaths { + collectAzureYamlEnvironmentReferencesAtPath( + &resolvedService, + referencePath, + []string{"services", serviceName}, + &references, + indexByName, + ) + } } return references, nil } @@ -189,14 +229,65 @@ func yamlMappingValue(node *yaml.Node, key string) *yaml.Node { return nil } -func isFoundryAzureYamlService(service *yaml.Node) bool { +func foundryAzureYamlServiceHost(service *yaml.Node) (string, bool) { host := yamlMappingValue(service, "host") if host == nil || host.Kind != yaml.ScalarNode { - return false + return "", false } _, knownHost := foundryServiceHosts[host.Value] - return knownHost || strings.HasPrefix(host.Value, "azure.ai.") + if !knownHost && !strings.HasPrefix(host.Value, "azure.ai.") { + return "", false + } + return host.Value, true +} + +func collectAzureYamlEnvironmentReferencesAtPath( + node *yaml.Node, + referencePath []string, + path []string, + references *[]azureYamlEnvironmentReference, + indexByName map[string]int, +) { + if node == nil { + return + } + if node.Kind == yaml.AliasNode { + node = node.Alias + } + if len(referencePath) == 0 { + collectAzureYamlEnvironmentReferences(node, path, references, indexByName) + return + } + + segment := referencePath[0] + if segment == "*" { + if node.Kind != yaml.SequenceNode { + return + } + for _, child := range node.Content { + collectAzureYamlEnvironmentReferencesAtPath( + child, + referencePath[1:], + append(slices.Clone(path), segment), + references, + indexByName, + ) + } + return + } + + child := yamlMappingValue(node, segment) + if child == nil { + return + } + collectAzureYamlEnvironmentReferencesAtPath( + child, + referencePath[1:], + append(slices.Clone(path), segment), + references, + indexByName, + ) } func collectAzureYamlEnvironmentReferences( @@ -217,9 +308,6 @@ func collectAzureYamlEnvironmentReferences( case yaml.MappingNode: for i := 0; i+1 < len(node.Content); i += 2 { key := node.Content[i] - if key.Value == "hooks" { - continue - } value := node.Content[i+1] childPath := append(slices.Clone(path), key.Value) collectAzureYamlEnvironmentReferences(value, childPath, references, indexByName) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go index 8446fc5efc7..7be3144b91e 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go @@ -125,6 +125,124 @@ services: {Name: "AGENT_VALUE"}, }, }, + { + name: "project scans only expanded network fields", + content: `name: sample +services: + project: + host: azure.ai.project + endpoint: ${RAW_PROJECT_ENDPOINT} + deployments: + - name: ${RAW_DEPLOYMENT_NAME} + model: + name: ${RAW_MODEL_NAME} + sku: + name: ${RAW_SKU_NAME} + network: + agentSubnet: + vnet: ${AGENT_VNET_ID} + peSubnet: + vnet: ${PE_VNET_ID} + dns: + subscription: ${DNS_SUBSCRIPTION_ID} +`, + want: []azureYamlEnvironmentReference{ + {Name: "AGENT_VNET_ID"}, + {Name: "PE_VNET_ID"}, + {Name: "DNS_SUBSCRIPTION_ID"}, + }, + }, + { + name: "connection scans only target credentials and metadata", + content: `name: sample +services: + connection: + host: azure.ai.connection + category: ${RAW_CATEGORY} + authType: ${RAW_AUTH_TYPE} + target: ${CONNECTION_TARGET} + credentials: + key: ${CONNECTION_KEY} + metadata: + resourceId: ${CONNECTION_RESOURCE_ID} +`, + want: []azureYamlEnvironmentReference{ + {Name: "CONNECTION_TARGET"}, + {Name: "CONNECTION_KEY", Secret: true}, + {Name: "CONNECTION_RESOURCE_ID"}, + }, + }, + { + name: "toolbox scans endpoint and tools but not description", + content: `name: sample +services: + toolbox: + host: azure.ai.toolbox + description: ${RAW_TOOLBOX_DESCRIPTION} + endpoint: ${TOOLBOX_ENDPOINT} + tools: + - name: search + configuration: + key: ${TOOLBOX_KEY} +`, + want: []azureYamlEnvironmentReference{ + {Name: "TOOLBOX_ENDPOINT"}, + {Name: "TOOLBOX_KEY"}, + }, + }, + { + name: "routine scans action input but not triggers or description", + content: `name: sample +services: + routine: + host: azure.ai.routine + description: ${RAW_ROUTINE_DESCRIPTION} + triggers: + - type: ${RAW_TRIGGER_TYPE} + action: + input: + value: ${ROUTINE_INPUT} +`, + want: []azureYamlEnvironmentReference{ + {Name: "ROUTINE_INPUT"}, + }, + }, + { + name: "skill fields are not expanded", + content: `name: sample +services: + skill: + host: azure.ai.skill + description: ${RAW_SKILL_DESCRIPTION} + instructions: ${RAW_SKILL_INSTRUCTIONS} +`, + want: nil, + }, + { + name: "deprecated toolbox and routine config fields are scanned", + content: `name: sample +services: + routine: + host: azure.ai.routine + config: + action: + input: + value: ${LEGACY_ROUTINE_INPUT} + toolbox: + host: azure.ai.toolbox + config: + endpoint: ${LEGACY_TOOLBOX_ENDPOINT} + tools: + - name: legacy + configuration: + key: ${LEGACY_TOOLBOX_KEY} +`, + want: []azureYamlEnvironmentReference{ + {Name: "LEGACY_ROUTINE_INPUT"}, + {Name: "LEGACY_TOOLBOX_ENDPOINT"}, + {Name: "LEGACY_TOOLBOX_KEY"}, + }, + }, { name: "malformed yaml", content: "name: [unterminated", @@ -393,3 +511,35 @@ services: require.Len(t, promptServer.promptRequests, 1) require.Equal(t, "prompted-value", envServer.values["dev"][envVarName]) } + +func TestConfigureAzureYamlEnvironmentVariables_EmptyProcessValuePrompts(t *testing.T) { + const envVarName = "AZD_TEST_INIT_EMPTY_PROCESS_VALUE" + t.Setenv(envVarName, "") + + projectDir := t.TempDir() + content := `name: sample +services: + connection: + host: azure.ai.connection + metadata: + emptyProcess: ${AZD_TEST_INIT_EMPTY_PROCESS_VALUE} +` + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "azure.yaml"), []byte(content), 0600)) + + envServer := &testEnvironmentServiceServer{} + promptServer := &testPromptServiceServer{ + promptResponses: []string{"prompted-value"}, + } + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}, promptServer) + + err := configureAzureYamlEnvironmentVariables( + t.Context(), + azdClient, + "dev", + projectDir, + false, + ) + require.NoError(t, err) + require.Len(t, promptServer.promptRequests, 1) + require.Equal(t, "prompted-value", envServer.values["dev"][envVarName]) +} From e1020e4bee8f209f8481affbd4e58ff33fb0e90b Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Wed, 22 Jul 2026 10:15:09 -0400 Subject: [PATCH 07/12] fix(agents): honor active config shape Mirror provider precedence when discovering environment references so inline agent, toolbox, and routine configuration suppresses stale deprecated config values. Preserve config fallback when no active inline shape exists. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b4617dfd-adfb-4b30-9222-477a041f8af9 --- .../azure.ai.agents/internal/cmd/init_env.go | 80 +++++++++++++++++-- .../internal/cmd/init_env_test.go | 53 ++++++++++++ 2 files changed, 128 insertions(+), 5 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go index 8ab2b5d789a..a4418d88e59 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go @@ -29,7 +29,6 @@ var foundryTemplateSpanPattern = regexp.MustCompile(`(?s)\$\{\{.*?\}\}`) var azureYamlEnvironmentReferencePaths = map[string][][]string{ "azure.ai.agent": { {"environmentVariables", "*", "value"}, - {"config", "environmentVariables", "*", "value"}, }, "azure.ai.connection": { {"target"}, @@ -43,13 +42,10 @@ var azureYamlEnvironmentReferencePaths = map[string][][]string{ }, "azure.ai.routine": { {"action", "input"}, - {"config", "action", "input"}, }, "azure.ai.toolbox": { {"endpoint"}, {"tools"}, - {"config", "endpoint"}, - {"config", "tools"}, }, "microsoft.foundry": { {"network", "agentSubnet", "vnet"}, @@ -58,6 +54,27 @@ var azureYamlEnvironmentReferencePaths = map[string][][]string{ }, } +var azureYamlCoreServiceFields = map[string]struct{}{ + "apiVersion": {}, + "condition": {}, + "config": {}, + "dist": {}, + "docker": {}, + "env": {}, + "hooks": {}, + "host": {}, + "image": {}, + "infra": {}, + "k8s": {}, + "language": {}, + "module": {}, + "project": {}, + "remoteBuild": {}, + "resourceGroup": {}, + "resourceName": {}, + "uses": {}, +} + type azureYamlEnvironmentReference struct { Name string Secret bool @@ -185,7 +202,7 @@ func findAzureYamlEnvironmentReferences(content []byte, projectDir string) ([]az if !ok { continue } - referencePaths, ok := azureYamlEnvironmentReferencePaths[host] + _, ok = azureYamlEnvironmentReferencePaths[host] if !ok { continue } @@ -203,6 +220,7 @@ func findAzureYamlEnvironmentReferences(content []byte, projectDir string) ([]az return nil, fmt.Errorf("encoding resolved service %q: %w", serviceName, err) } + referencePaths := activeAzureYamlEnvironmentReferencePaths(host, &resolvedService) for _, referencePath := range referencePaths { collectAzureYamlEnvironmentReferencesAtPath( &resolvedService, @@ -242,6 +260,58 @@ func foundryAzureYamlServiceHost(service *yaml.Node) (string, bool) { return host.Value, true } +func activeAzureYamlEnvironmentReferencePaths(host string, service *yaml.Node) [][]string { + referencePaths := azureYamlEnvironmentReferencePaths[host] + + switch host { + case "azure.ai.agent": + if hasNonEmptyAzureYamlString(service, "kind") { + return referencePaths + } + config := yamlMappingValue(service, "config") + if hasNonEmptyAzureYamlString(config, "kind") { + return prefixAzureYamlEnvironmentReferencePaths("config", referencePaths) + } + return nil + case "azure.ai.routine", "azure.ai.toolbox": + if hasAzureYamlInlineProperties(service) { + return referencePaths + } + if yamlMappingValue(service, "config") != nil { + return prefixAzureYamlEnvironmentReferencePaths("config", referencePaths) + } + return nil + default: + return referencePaths + } +} + +func hasNonEmptyAzureYamlString(node *yaml.Node, key string) bool { + value := yamlMappingValue(node, key) + return value != nil && value.Kind == yaml.ScalarNode && value.Value != "" +} + +func hasAzureYamlInlineProperties(service *yaml.Node) bool { + if service == nil || service.Kind != yaml.MappingNode { + return false + } + + for i := 0; i+1 < len(service.Content); i += 2 { + if _, isCoreField := azureYamlCoreServiceFields[service.Content[i].Value]; !isCoreField { + return true + } + } + return false +} + +func prefixAzureYamlEnvironmentReferencePaths(prefix string, paths [][]string) [][]string { + prefixed := make([][]string, len(paths)) + for i, path := range paths { + prefixed[i] = append([]string{prefix}, path...) + } + return prefixed +} + func collectAzureYamlEnvironmentReferencesAtPath( node *yaml.Node, referencePath []string, diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go index 7be3144b91e..2a6152b62bd 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go @@ -60,6 +60,7 @@ services: services: agent: host: azure.ai.agent + kind: hosted environmentVariables: - name: DEFAULTED value: ${DEFAULTED:-fallback} @@ -85,6 +86,7 @@ services: services: agent: host: azure.ai.agent + kind: hosted environmentVariables: - name: API_TOKEN value: ${SERVICE_API_TOKEN} @@ -113,6 +115,7 @@ services: WEB_VALUE: ${WEB_VALUE} agent: host: azure.ai.agent + kind: hosted hooks: predeploy: shell: sh @@ -222,6 +225,13 @@ services: name: "deprecated toolbox and routine config fields are scanned", content: `name: sample services: + agent: + host: azure.ai.agent + config: + kind: hosted + environmentVariables: + - name: LEGACY_AGENT_VALUE + value: ${LEGACY_AGENT_VALUE} routine: host: azure.ai.routine config: @@ -238,11 +248,54 @@ services: key: ${LEGACY_TOOLBOX_KEY} `, want: []azureYamlEnvironmentReference{ + {Name: "LEGACY_AGENT_VALUE"}, {Name: "LEGACY_ROUTINE_INPUT"}, {Name: "LEGACY_TOOLBOX_ENDPOINT"}, {Name: "LEGACY_TOOLBOX_KEY"}, }, }, + { + name: "inline properties take precedence over stale config fields", + content: `name: sample +services: + agent: + host: azure.ai.agent + kind: hosted + environmentVariables: + - name: INLINE_AGENT_VALUE + value: ${INLINE_AGENT_VALUE} + config: + kind: hosted + environmentVariables: + - name: STALE_AGENT_VALUE + value: ${STALE_AGENT_VALUE} + routine: + host: azure.ai.routine + description: inline routine + action: + input: + value: ${INLINE_ROUTINE_INPUT} + config: + action: + input: + value: ${STALE_ROUTINE_INPUT} + toolbox: + host: azure.ai.toolbox + description: inline toolbox + endpoint: ${INLINE_TOOLBOX_ENDPOINT} + config: + endpoint: ${STALE_TOOLBOX_ENDPOINT} + tools: + - name: stale + configuration: + key: ${STALE_TOOLBOX_KEY} +`, + want: []azureYamlEnvironmentReference{ + {Name: "INLINE_AGENT_VALUE"}, + {Name: "INLINE_ROUTINE_INPUT"}, + {Name: "INLINE_TOOLBOX_ENDPOINT"}, + }, + }, { name: "malformed yaml", content: "name: [unterminated", From fdbe373449f151e97913864d0aec36b754302afb Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Fri, 24 Jul 2026 11:52:18 -0400 Subject: [PATCH 08/12] refactor(agents): type env reference discovery Replace string-based service field paths with concrete typed views of the fields each Foundry provider expands. Preserve active config precedence, ref resolution, secret handling, ordering, and unsupported-host behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b4617dfd-adfb-4b30-9222-477a041f8af9 --- .../azure.ai.agents/internal/cmd/init_env.go | 365 +++++++++++------- .../internal/cmd/init_env_test.go | 10 + 2 files changed, 246 insertions(+), 129 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go index a4418d88e59..806a1e8e805 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go @@ -9,7 +9,6 @@ import ( "os" "path/filepath" "regexp" - "slices" "strings" "azureaiagent/internal/exterrors" @@ -26,32 +25,60 @@ var azureYamlEnvRefPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:- var foundryTemplateSpanPattern = regexp.MustCompile(`(?s)\$\{\{.*?\}\}`) -var azureYamlEnvironmentReferencePaths = map[string][][]string{ - "azure.ai.agent": { - {"environmentVariables", "*", "value"}, - }, - "azure.ai.connection": { - {"target"}, - {"credentials"}, - {"metadata"}, - }, - "azure.ai.project": { - {"network", "agentSubnet", "vnet"}, - {"network", "peSubnet", "vnet"}, - {"network", "dns", "subscription"}, - }, - "azure.ai.routine": { - {"action", "input"}, - }, - "azure.ai.toolbox": { - {"endpoint"}, - {"tools"}, - }, - "microsoft.foundry": { - {"network", "agentSubnet", "vnet"}, - {"network", "peSubnet", "vnet"}, - {"network", "dns", "subscription"}, - }, +// These types mirror only the fields each Foundry provider expands from the +// azd environment. The owning provider types are unexported or live in sibling +// extension modules, so init keeps small typed views instead of string paths. +// +// Keep these views aligned with: +// - agent environmentVariables values +// - connection target, credentials, and metadata +// - project network VNet IDs and DNS subscription +// - routine action input +// - toolbox endpoint and tools +type azureYamlAgentEnvironmentConfig struct { + Kind string `yaml:"kind,omitempty"` + EnvironmentVariables []azureYamlEnvironmentVariable `yaml:"environmentVariables,omitempty"` +} + +type azureYamlEnvironmentVariable struct { + Value string `yaml:"value,omitempty"` +} + +type azureYamlConnectionEnvironmentConfig struct { + Target string `yaml:"target,omitempty"` + Credentials map[string]any `yaml:"credentials,omitempty"` + Metadata map[string]string `yaml:"metadata,omitempty"` +} + +type azureYamlProjectEnvironmentConfig struct { + Network *azureYamlNetworkEnvironmentConfig `yaml:"network,omitempty"` +} + +type azureYamlNetworkEnvironmentConfig struct { + AgentSubnet *azureYamlSubnetEnvironmentConfig `yaml:"agentSubnet,omitempty"` + PESubnet *azureYamlSubnetEnvironmentConfig `yaml:"peSubnet,omitempty"` + DNS *azureYamlDNSEnvironmentConfig `yaml:"dns,omitempty"` +} + +type azureYamlSubnetEnvironmentConfig struct { + VNet string `yaml:"vnet,omitempty"` +} + +type azureYamlDNSEnvironmentConfig struct { + Subscription string `yaml:"subscription,omitempty"` +} + +type azureYamlRoutineEnvironmentConfig struct { + Action *azureYamlRoutineActionEnvironmentConfig `yaml:"action,omitempty"` +} + +type azureYamlRoutineActionEnvironmentConfig struct { + Input any `yaml:"input,omitempty"` +} + +type azureYamlToolboxEnvironmentConfig struct { + Endpoint string `yaml:"endpoint,omitempty"` + Tools []map[string]any `yaml:"tools,omitempty"` } var azureYamlCoreServiceFields = map[string]struct{}{ @@ -202,8 +229,7 @@ func findAzureYamlEnvironmentReferences(content []byte, projectDir string) ([]az if !ok { continue } - _, ok = azureYamlEnvironmentReferencePaths[host] - if !ok { + if !supportsAzureYamlEnvironmentReferences(host) { continue } @@ -220,15 +246,14 @@ func findAzureYamlEnvironmentReferences(content []byte, projectDir string) ([]az return nil, fmt.Errorf("encoding resolved service %q: %w", serviceName, err) } - referencePaths := activeAzureYamlEnvironmentReferencePaths(host, &resolvedService) - for _, referencePath := range referencePaths { - collectAzureYamlEnvironmentReferencesAtPath( - &resolvedService, - referencePath, - []string{"services", serviceName}, - &references, - indexByName, - ) + if err := collectAzureYamlServiceEnvironmentReferences( + host, + serviceName, + &resolvedService, + &references, + indexByName, + ); err != nil { + return nil, err } } return references, nil @@ -260,29 +285,38 @@ func foundryAzureYamlServiceHost(service *yaml.Node) (string, bool) { return host.Value, true } -func activeAzureYamlEnvironmentReferencePaths(host string, service *yaml.Node) [][]string { - referencePaths := azureYamlEnvironmentReferencePaths[host] +func supportsAzureYamlEnvironmentReferences(host string) bool { + switch host { + case "azure.ai.agent", + "azure.ai.connection", + "azure.ai.project", + "azure.ai.routine", + "azure.ai.toolbox", + "microsoft.foundry": + return true + default: + return false + } +} +func activeAzureYamlServiceConfiguration(host string, service *yaml.Node) *yaml.Node { switch host { case "azure.ai.agent": if hasNonEmptyAzureYamlString(service, "kind") { - return referencePaths + return service } config := yamlMappingValue(service, "config") if hasNonEmptyAzureYamlString(config, "kind") { - return prefixAzureYamlEnvironmentReferencePaths("config", referencePaths) + return config } return nil case "azure.ai.routine", "azure.ai.toolbox": if hasAzureYamlInlineProperties(service) { - return referencePaths + return service } - if yamlMappingValue(service, "config") != nil { - return prefixAzureYamlEnvironmentReferencePaths("config", referencePaths) - } - return nil + return yamlMappingValue(service, "config") default: - return referencePaths + return service } } @@ -304,65 +338,135 @@ func hasAzureYamlInlineProperties(service *yaml.Node) bool { return false } -func prefixAzureYamlEnvironmentReferencePaths(prefix string, paths [][]string) [][]string { - prefixed := make([][]string, len(paths)) - for i, path := range paths { - prefixed[i] = append([]string{prefix}, path...) - } - return prefixed -} - -func collectAzureYamlEnvironmentReferencesAtPath( - node *yaml.Node, - referencePath []string, - path []string, +func collectAzureYamlServiceEnvironmentReferences( + host string, + serviceName string, + service *yaml.Node, references *[]azureYamlEnvironmentReference, indexByName map[string]int, -) { - if node == nil { - return - } - if node.Kind == yaml.AliasNode { - node = node.Alias - } - if len(referencePath) == 0 { - collectAzureYamlEnvironmentReferences(node, path, references, indexByName) - return +) error { + active := activeAzureYamlServiceConfiguration(host, service) + if active == nil { + return nil } - segment := referencePath[0] - if segment == "*" { - if node.Kind != yaml.SequenceNode { - return + switch host { + case "azure.ai.agent": + var config azureYamlAgentEnvironmentConfig + if err := active.Decode(&config); err != nil { + return fmt.Errorf("decoding agent service %q: %w", serviceName, err) } - for _, child := range node.Content { - collectAzureYamlEnvironmentReferencesAtPath( - child, - referencePath[1:], - append(slices.Clone(path), segment), + for _, variable := range config.EnvironmentVariables { + collectAzureYamlEnvironmentReferences(variable.Value, false, references, indexByName) + } + case "azure.ai.connection": + var config azureYamlConnectionEnvironmentConfig + if err := active.Decode(&config); err != nil { + return fmt.Errorf("decoding connection service %q: %w", serviceName, err) + } + collectAzureYamlEnvironmentReferences(config.Target, false, references, indexByName) + if err := collectAzureYamlEnvironmentReferencesFromValue( + config.Credentials, + true, + references, + indexByName, + ); err != nil { + return fmt.Errorf("scanning connection service %q credentials: %w", serviceName, err) + } + if err := collectAzureYamlEnvironmentReferencesFromValue( + config.Metadata, + false, + references, + indexByName, + ); err != nil { + return fmt.Errorf("scanning connection service %q metadata: %w", serviceName, err) + } + case "azure.ai.project", "microsoft.foundry": + var config azureYamlProjectEnvironmentConfig + if err := active.Decode(&config); err != nil { + return fmt.Errorf("decoding project service %q: %w", serviceName, err) + } + if config.Network != nil { + if config.Network.AgentSubnet != nil { + collectAzureYamlEnvironmentReferences( + config.Network.AgentSubnet.VNet, + false, + references, + indexByName, + ) + } + if config.Network.PESubnet != nil { + collectAzureYamlEnvironmentReferences( + config.Network.PESubnet.VNet, + false, + references, + indexByName, + ) + } + if config.Network.DNS != nil { + collectAzureYamlEnvironmentReferences( + config.Network.DNS.Subscription, + false, + references, + indexByName, + ) + } + } + case "azure.ai.routine": + var config azureYamlRoutineEnvironmentConfig + if err := active.Decode(&config); err != nil { + return fmt.Errorf("decoding routine service %q: %w", serviceName, err) + } + if config.Action != nil { + if err := collectAzureYamlEnvironmentReferencesFromValue( + config.Action.Input, + false, references, indexByName, - ) + ); err != nil { + return fmt.Errorf("scanning routine service %q action input: %w", serviceName, err) + } + } + case "azure.ai.toolbox": + var config azureYamlToolboxEnvironmentConfig + if err := active.Decode(&config); err != nil { + return fmt.Errorf("decoding toolbox service %q: %w", serviceName, err) + } + collectAzureYamlEnvironmentReferences(config.Endpoint, false, references, indexByName) + if err := collectAzureYamlEnvironmentReferencesFromValue( + config.Tools, + false, + references, + indexByName, + ); err != nil { + return fmt.Errorf("scanning toolbox service %q tools: %w", serviceName, err) } - return } - child := yamlMappingValue(node, segment) - if child == nil { - return + return nil +} + +func collectAzureYamlEnvironmentReferencesFromValue( + value any, + secret bool, + references *[]azureYamlEnvironmentReference, + indexByName map[string]int, +) error { + if value == nil { + return nil } - collectAzureYamlEnvironmentReferencesAtPath( - child, - referencePath[1:], - append(slices.Clone(path), segment), - references, - indexByName, - ) + + var node yaml.Node + if err := node.Encode(value); err != nil { + return fmt.Errorf("encoding value: %w", err) + } + collectAzureYamlEnvironmentReferencesFromNode(&node, secret, references, indexByName) + return nil } -func collectAzureYamlEnvironmentReferences( +func collectAzureYamlEnvironmentReferencesFromNode( node *yaml.Node, - path []string, + secret bool, references *[]azureYamlEnvironmentReference, indexByName map[string]int, ) { @@ -373,44 +477,52 @@ func collectAzureYamlEnvironmentReferences( switch node.Kind { case yaml.DocumentNode, yaml.SequenceNode: for _, child := range node.Content { - collectAzureYamlEnvironmentReferences(child, path, references, indexByName) + collectAzureYamlEnvironmentReferencesFromNode(child, secret, references, indexByName) } case yaml.MappingNode: for i := 0; i+1 < len(node.Content); i += 2 { key := node.Content[i] value := node.Content[i+1] - childPath := append(slices.Clone(path), key.Value) - collectAzureYamlEnvironmentReferences(value, childPath, references, indexByName) + childSecret := secret || isSecretAzureYamlEnvironmentKey(key.Value) + collectAzureYamlEnvironmentReferencesFromNode(value, childSecret, references, indexByName) } case yaml.AliasNode: - collectAzureYamlEnvironmentReferences(node.Alias, path, references, indexByName) + collectAzureYamlEnvironmentReferencesFromNode(node.Alias, secret, references, indexByName) case yaml.ScalarNode: - value := foundryTemplateSpanPattern.ReplaceAllStringFunc(node.Value, func(span string) string { - return strings.Repeat(" ", len(span)) - }) - for _, match := range azureYamlEnvRefPattern.FindAllStringSubmatchIndex(value, -1) { - if isEscapedAzureYamlEnvironmentReference(value, match[0]) { - continue - } - if match[4] != -1 { - continue - } + collectAzureYamlEnvironmentReferences(node.Value, secret, references, indexByName) + } +} - name := value[match[2]:match[3]] - secret := isSecretAzureYamlEnvironmentReference(path) - if index, ok := indexByName[name]; ok { - if secret { - (*references)[index].Secret = true - } - continue - } +func collectAzureYamlEnvironmentReferences( + value string, + secret bool, + references *[]azureYamlEnvironmentReference, + indexByName map[string]int, +) { + value = foundryTemplateSpanPattern.ReplaceAllStringFunc(value, func(span string) string { + return strings.Repeat(" ", len(span)) + }) + for _, match := range azureYamlEnvRefPattern.FindAllStringSubmatchIndex(value, -1) { + if isEscapedAzureYamlEnvironmentReference(value, match[0]) { + continue + } + if match[4] != -1 { + continue + } - indexByName[name] = len(*references) - *references = append(*references, azureYamlEnvironmentReference{ - Name: name, - Secret: secret, - }) + name := value[match[2]:match[3]] + if index, ok := indexByName[name]; ok { + if secret { + (*references)[index].Secret = true + } + continue } + + indexByName[name] = len(*references) + *references = append(*references, azureYamlEnvironmentReference{ + Name: name, + Secret: secret, + }) } } @@ -422,15 +534,10 @@ func isEscapedAzureYamlEnvironmentReference(value string, start int) bool { return precedingDollars%2 == 1 } -// Secret masking is based on explicit configuration structure. Environment -// variable names are user-defined and are not a reliable sensitivity signal. -func isSecretAzureYamlEnvironmentReference(path []string) bool { - for _, segment := range path { - switch strings.ToLower(segment) { - case "credential", "credentials", "secret", "secrets": - return true - } +func isSecretAzureYamlEnvironmentKey(key string) bool { + switch strings.ToLower(key) { + case "credential", "credentials", "secret", "secrets": + return true } - return false } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go index 2a6152b62bd..f131dc3df09 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go @@ -218,6 +218,16 @@ services: host: azure.ai.skill description: ${RAW_SKILL_DESCRIPTION} instructions: ${RAW_SKILL_INSTRUCTIONS} +`, + want: nil, + }, + { + name: "unsupported host refs are ignored before resolution", + content: `name: sample +services: + skill: + host: azure.ai.skill + $ref: ./missing-skill.yaml `, want: nil, }, From e0639bab47a7e5e329e794a9ef7df49dcb79311b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:05:40 +0000 Subject: [PATCH 09/12] fix(agents): align project env reference escaping Co-authored-by: glharper <64209257+glharper@users.noreply.github.com> --- .../azure.ai.agents/internal/cmd/init_env.go | 14 +++++++++----- .../azure.ai.agents/internal/cmd/init_env_test.go | 2 +- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go index 806a1e8e805..61a98e495e5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go @@ -357,14 +357,14 @@ func collectAzureYamlServiceEnvironmentReferences( return fmt.Errorf("decoding agent service %q: %w", serviceName, err) } for _, variable := range config.EnvironmentVariables { - collectAzureYamlEnvironmentReferences(variable.Value, false, references, indexByName) + collectAzureYamlEnvironmentReferences(variable.Value, false, true, references, indexByName) } case "azure.ai.connection": var config azureYamlConnectionEnvironmentConfig if err := active.Decode(&config); err != nil { return fmt.Errorf("decoding connection service %q: %w", serviceName, err) } - collectAzureYamlEnvironmentReferences(config.Target, false, references, indexByName) + collectAzureYamlEnvironmentReferences(config.Target, false, true, references, indexByName) if err := collectAzureYamlEnvironmentReferencesFromValue( config.Credentials, true, @@ -391,6 +391,7 @@ func collectAzureYamlServiceEnvironmentReferences( collectAzureYamlEnvironmentReferences( config.Network.AgentSubnet.VNet, false, + false, references, indexByName, ) @@ -399,6 +400,7 @@ func collectAzureYamlServiceEnvironmentReferences( collectAzureYamlEnvironmentReferences( config.Network.PESubnet.VNet, false, + false, references, indexByName, ) @@ -407,6 +409,7 @@ func collectAzureYamlServiceEnvironmentReferences( collectAzureYamlEnvironmentReferences( config.Network.DNS.Subscription, false, + false, references, indexByName, ) @@ -432,7 +435,7 @@ func collectAzureYamlServiceEnvironmentReferences( if err := active.Decode(&config); err != nil { return fmt.Errorf("decoding toolbox service %q: %w", serviceName, err) } - collectAzureYamlEnvironmentReferences(config.Endpoint, false, references, indexByName) + collectAzureYamlEnvironmentReferences(config.Endpoint, false, true, references, indexByName) if err := collectAzureYamlEnvironmentReferencesFromValue( config.Tools, false, @@ -489,13 +492,14 @@ func collectAzureYamlEnvironmentReferencesFromNode( case yaml.AliasNode: collectAzureYamlEnvironmentReferencesFromNode(node.Alias, secret, references, indexByName) case yaml.ScalarNode: - collectAzureYamlEnvironmentReferences(node.Value, secret, references, indexByName) + collectAzureYamlEnvironmentReferences(node.Value, secret, true, references, indexByName) } } func collectAzureYamlEnvironmentReferences( value string, secret bool, + honorEscaping bool, references *[]azureYamlEnvironmentReference, indexByName map[string]int, ) { @@ -503,7 +507,7 @@ func collectAzureYamlEnvironmentReferences( return strings.Repeat(" ", len(span)) }) for _, match := range azureYamlEnvRefPattern.FindAllStringSubmatchIndex(value, -1) { - if isEscapedAzureYamlEnvironmentReference(value, match[0]) { + if honorEscaping && isEscapedAzureYamlEnvironmentReference(value, match[0]) { continue } if match[4] != -1 { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go index f131dc3df09..16bd1d0d76f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go @@ -143,7 +143,7 @@ services: name: ${RAW_SKU_NAME} network: agentSubnet: - vnet: ${AGENT_VNET_ID} + vnet: $${AGENT_VNET_ID} peSubnet: vnet: ${PE_VNET_ID} dns: From 9b75e586738f9eb3625f5b3eba628a3c9fd1ffb1 Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Fri, 24 Jul 2026 14:25:27 -0400 Subject: [PATCH 10/12] test(agents): isolate env prompt cases Clear process environment values in prompt-dependent tests so exported developer or runner variables cannot bypass the prompts under test. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b4617dfd-adfb-4b30-9222-477a041f8af9 --- .../azure.ai.agents/internal/cmd/init_env_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go index 16bd1d0d76f..e0c81c4e447 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go @@ -330,7 +330,8 @@ services: } func TestConfigureAzureYamlEnvironmentVariables_ResolvesServiceRefs(t *testing.T) { - t.Parallel() + t.Setenv("REFERENCED_API_TOKEN", "") + t.Setenv("ROOT_RESOURCE_ID", "") projectDir := t.TempDir() require.NoError(t, os.Mkdir(filepath.Join(projectDir, "services"), 0700)) @@ -378,6 +379,9 @@ services: } func TestConfigureAzureYamlEnvironmentVariables_PromptsAndPersistsMissingValues(t *testing.T) { + t.Setenv("PLAYWRIGHT_SERVICE_ACCESS_TOKEN", "") + t.Setenv("PLAYWRIGHT_SERVICE_RESOURCE_ID", "") + projectDir := t.TempDir() content := `name: playwright-agent services: From 2f44dfe3e9f9dd2bed6b8418eceb4ac233836c9b Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Fri, 24 Jul 2026 14:41:49 -0400 Subject: [PATCH 11/12] test(agents): document env escape parity Name and document the escape modes used by each owning provider expander, and cover a second project-network reference that intentionally ignores leading-dollar escaping. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b4617dfd-adfb-4b30-9222-477a041f8af9 --- .../azure.ai.agents/internal/cmd/init_env.go | 47 ++++++++++++++++--- .../internal/cmd/init_env_test.go | 2 +- 2 files changed, 41 insertions(+), 8 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go index 61a98e495e5..0d3f8faf4d5 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go @@ -25,6 +25,15 @@ var azureYamlEnvRefPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:- var foundryTemplateSpanPattern = regexp.MustCompile(`(?s)\$\{\{.*?\}\}`) +// Escape handling must match the expander that owns each field. +// foundry.ExpandEnv treats an odd leading '$' as an escape, while the project +// synthesizers' resolveVars helper expands every ${VAR} match regardless of +// a preceding '$'. +const ( + honorAzureYamlEnvironmentEscaping = true + ignoreAzureYamlEnvironmentEscaping = false +) + // These types mirror only the fields each Foundry provider expands from the // azd environment. The owning provider types are unexported or live in sibling // extension modules, so init keeps small typed views instead of string paths. @@ -357,14 +366,26 @@ func collectAzureYamlServiceEnvironmentReferences( return fmt.Errorf("decoding agent service %q: %w", serviceName, err) } for _, variable := range config.EnvironmentVariables { - collectAzureYamlEnvironmentReferences(variable.Value, false, true, references, indexByName) + collectAzureYamlEnvironmentReferences( + variable.Value, + false, + honorAzureYamlEnvironmentEscaping, + references, + indexByName, + ) } case "azure.ai.connection": var config azureYamlConnectionEnvironmentConfig if err := active.Decode(&config); err != nil { return fmt.Errorf("decoding connection service %q: %w", serviceName, err) } - collectAzureYamlEnvironmentReferences(config.Target, false, true, references, indexByName) + collectAzureYamlEnvironmentReferences( + config.Target, + false, + honorAzureYamlEnvironmentEscaping, + references, + indexByName, + ) if err := collectAzureYamlEnvironmentReferencesFromValue( config.Credentials, true, @@ -391,7 +412,7 @@ func collectAzureYamlServiceEnvironmentReferences( collectAzureYamlEnvironmentReferences( config.Network.AgentSubnet.VNet, false, - false, + ignoreAzureYamlEnvironmentEscaping, references, indexByName, ) @@ -400,7 +421,7 @@ func collectAzureYamlServiceEnvironmentReferences( collectAzureYamlEnvironmentReferences( config.Network.PESubnet.VNet, false, - false, + ignoreAzureYamlEnvironmentEscaping, references, indexByName, ) @@ -409,7 +430,7 @@ func collectAzureYamlServiceEnvironmentReferences( collectAzureYamlEnvironmentReferences( config.Network.DNS.Subscription, false, - false, + ignoreAzureYamlEnvironmentEscaping, references, indexByName, ) @@ -435,7 +456,13 @@ func collectAzureYamlServiceEnvironmentReferences( if err := active.Decode(&config); err != nil { return fmt.Errorf("decoding toolbox service %q: %w", serviceName, err) } - collectAzureYamlEnvironmentReferences(config.Endpoint, false, true, references, indexByName) + collectAzureYamlEnvironmentReferences( + config.Endpoint, + false, + honorAzureYamlEnvironmentEscaping, + references, + indexByName, + ) if err := collectAzureYamlEnvironmentReferencesFromValue( config.Tools, false, @@ -492,7 +519,13 @@ func collectAzureYamlEnvironmentReferencesFromNode( case yaml.AliasNode: collectAzureYamlEnvironmentReferencesFromNode(node.Alias, secret, references, indexByName) case yaml.ScalarNode: - collectAzureYamlEnvironmentReferences(node.Value, secret, true, references, indexByName) + collectAzureYamlEnvironmentReferences( + node.Value, + secret, + honorAzureYamlEnvironmentEscaping, + references, + indexByName, + ) } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go index e0c81c4e447..0e6d14d5767 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go @@ -145,7 +145,7 @@ services: agentSubnet: vnet: $${AGENT_VNET_ID} peSubnet: - vnet: ${PE_VNET_ID} + vnet: $${PE_VNET_ID} dns: subscription: ${DNS_SUBSCRIPTION_ID} `, From 4ae31c30877dafc993f29775840e3482457f3550 Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Fri, 24 Jul 2026 17:35:32 -0400 Subject: [PATCH 12/12] fix(agents): persist env values without prompts Continue environment discovery in no-prompt mode so exported values are persisted for providers that read only azd state. Derive protected reference occurrences through foundry.ExpandEnv with unique probes instead of duplicating its server-span matcher. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: b4617dfd-adfb-4b30-9222-477a041f8af9 --- .../azure.ai.agents/internal/cmd/init_env.go | 61 +++++++++++++++---- .../internal/cmd/init_env_test.go | 40 +++++++++++- 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go index 0d3f8faf4d5..019a8ac0945 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env.go @@ -23,8 +23,6 @@ import ( // environment value because the runtime expander supplies the fallback. var azureYamlEnvRefPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(:-[^}]*)?\}`) -var foundryTemplateSpanPattern = regexp.MustCompile(`(?s)\$\{\{.*?\}\}`) - // Escape handling must match the expander that owns each field. // foundry.ExpandEnv treats an odd leading '$' as an escape, while the project // synthesizers' resolveVars helper expands every ${VAR} match regardless of @@ -126,10 +124,6 @@ func configureAzureYamlEnvironmentVariables( projectDir string, noPrompt bool, ) error { - if noPrompt { - return nil - } - manifestPath := filepath.Join(projectDir, "azure.yaml") //nolint:gosec // projectDir is the user-selected project root created by init content, err := os.ReadFile(manifestPath) @@ -176,7 +170,7 @@ func configureAzureYamlEnvironmentVariables( missing = append(missing, reference) } - if len(missing) == 0 { + if len(missing) == 0 || noPrompt { return nil } @@ -536,13 +530,15 @@ func collectAzureYamlEnvironmentReferences( references *[]azureYamlEnvironmentReference, indexByName map[string]int, ) { - value = foundryTemplateSpanPattern.ReplaceAllStringFunc(value, func(span string) string { - return strings.Repeat(" ", len(span)) - }) - for _, match := range azureYamlEnvRefPattern.FindAllStringSubmatchIndex(value, -1) { + matches := azureYamlEnvRefPattern.FindAllStringSubmatchIndex(value, -1) + protected := protectedAzureYamlEnvironmentReferenceOccurrences(value, matches, honorEscaping) + for i, match := range matches { if honorEscaping && isEscapedAzureYamlEnvironmentReference(value, match[0]) { continue } + if protected[i] { + continue + } if match[4] != -1 { continue } @@ -563,6 +559,49 @@ func collectAzureYamlEnvironmentReferences( } } +// protectedAzureYamlEnvironmentReferenceOccurrences reports which candidate +// references are inside server-side ${{...}} spans. Each candidate is replaced +// with a unique probe before running [foundry.ExpandEnv]; probes left verbatim +// are protected by the shared expander. This keeps discovery linked to the +// owning implementation without ambiguous name-based occurrence counting. +func protectedAzureYamlEnvironmentReferenceOccurrences( + value string, + matches [][]int, + honorEscaping bool, +) []bool { + protected := make([]bool, len(matches)) + if !honorEscaping || len(matches) == 0 { + return protected + } + + probePrefix := "AZD_ENV_REFERENCE_PROBE_" + for strings.Contains(value, probePrefix) { + probePrefix += "_" + } + + probeRefs := make([]string, len(matches)) + var probed strings.Builder + last := 0 + for i, match := range matches { + probed.WriteString(value[last:match[0]]) + probeRefs[i] = fmt.Sprintf("${%s%d}", probePrefix, i) + probed.WriteString(probeRefs[i]) + last = match[1] + } + probed.WriteString(value[last:]) + + expanded, err := foundry.ExpandEnv(probed.String(), func(name string) string { + return "expanded_" + name + }) + if err != nil { + return protected + } + for i, probeRef := range probeRefs { + protected[i] = strings.Contains(expanded, probeRef) + } + return protected +} + func isEscapedAzureYamlEnvironmentReference(value string, start int) bool { precedingDollars := 0 for i := start - 1; i >= 0 && value[i] == '$'; i-- { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go index 0e6d14d5767..41018fcb2df 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_env_test.go @@ -75,9 +75,15 @@ services: value: $${ESCAPED} - name: EXPANDED_AFTER_LITERAL_DOLLAR value: $$${EXPANDED_AFTER_LITERAL_DOLLAR} + - name: MIXED_ESCAPE + value: ${MIXED_REF} literal-$${MIXED_REF} + - name: MIXED_SERVER + value: ${SERVER_SHARED} ${{ event.body ?? '${SERVER_SHARED}' }} `, want: []azureYamlEnvironmentReference{ {Name: "EXPANDED_AFTER_LITERAL_DOLLAR"}, + {Name: "MIXED_REF"}, + {Name: "SERVER_SHARED"}, }, }, { @@ -446,7 +452,7 @@ services: } func TestConfigureAzureYamlEnvironmentVariables_NoPromptSkipsPrompts(t *testing.T) { - t.Parallel() + t.Setenv("API_TOKEN", "") projectDir := t.TempDir() content := `name: sample @@ -474,6 +480,38 @@ services: require.Empty(t, envServer.values) } +func TestConfigureAzureYamlEnvironmentVariables_NoPromptPersistsProcessEnvironmentFallback(t *testing.T) { + const envVarName = "AZD_TEST_INIT_NO_PROMPT_PROCESS_VALUE" + t.Setenv(envVarName, "from-process") + + projectDir := t.TempDir() + content := `name: sample +services: + toolbox: + host: azure.ai.toolbox + tools: + - name: process-value + configuration: + value: ${AZD_TEST_INIT_NO_PROMPT_PROCESS_VALUE} +` + require.NoError(t, os.WriteFile(filepath.Join(projectDir, "azure.yaml"), []byte(content), 0600)) + + envServer := &testEnvironmentServiceServer{} + promptServer := &testPromptServiceServer{} + azdClient := newTestAzdClient(t, envServer, &testWorkflowServiceServer{}, promptServer) + + err := configureAzureYamlEnvironmentVariables( + t.Context(), + azdClient, + "dev", + projectDir, + true, + ) + require.NoError(t, err) + require.Empty(t, promptServer.promptRequests) + require.Equal(t, "from-process", envServer.values["dev"][envVarName]) +} + func TestConfigureAzureYamlEnvironmentVariables_SkipsConfiguredValues(t *testing.T) { t.Parallel()