From 8101c0292dcb7bd19829c97d84a5f9d60719f7e7 Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Mon, 20 Jul 2026 10:07:25 -0400 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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",