From cee99d5a37a66991b24edb260355cc244d685880 Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Mon, 3 Aug 2026 13:20:40 -0400 Subject: [PATCH 1/5] fix(agents): reuse existing azure.yaml agent config on init Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: fed9e97b-e79b-4889-ac76-0d9a428599cd --- .../azure.ai.agents/internal/cmd/init.go | 67 ++++++ .../internal/cmd/init_reuse_project_agent.go | 187 ++++++++++++++++ .../cmd/init_reuse_project_agent_test.go | 210 ++++++++++++++++++ 3 files changed, 464 insertions(+) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go create mode 100644 cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 884ca71f428..825cd68362a 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1295,6 +1295,73 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } } + // When the project's own azure.yaml already declares agent + // service(s), the values init would prompt for (agent name, + // protocols, deploy mode) are already recorded there. Offer to + // reuse that configuration instead of re-asking (issue #9154). + // This is the unified-format counterpart to the bare agent.yaml + // reuse above: the definition now lives inline on the service + // entry rather than in a separate file. + // + // An explicit --agent-name states intent to set up that specific + // agent, so it opts out of reuse and falls through to the normal + // flow rather than silently adopting whatever azure.yaml already + // declares. + if flags.manifestPointer == "" && !manifestDetectedButDeclined && flags.agentName == "" { + checkDir := flags.src + if checkDir == "" { + checkDir = "." + } + manifestPath, findErr := findProjectManifest(checkDir) + if findErr != nil { + return findErr + } + if manifestPath != "" { + agentServices, servicesErr := findProjectAgentServices(manifestPath) + if servicesErr != nil { + return servicesErr + } + if len(agentServices) > 0 { + displayPath, relErr := filepath.Rel(checkDir, manifestPath) + if relErr != nil || displayPath == "" { + displayPath = manifestPath + } + + useExisting := flags.noPrompt + if !flags.noPrompt { + confirmResp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: fmt.Sprintf( + "%s already configures %s. Use it?", + displayPath, + describeProjectAgentServices(agentServices), + ), + DefaultValue: new(true), + }, + }) + if promptErr != nil { + if exterrors.IsCancellation(promptErr) { + return exterrors.Cancelled("initialization was cancelled") + } + return fmt.Errorf("prompting for project agent reuse: %w", promptErr) + } + useExisting = *confirmResp.Value + } + if useExisting { + if flags.src == "" { + flags.src = checkDir + } + if err := runReuseProjectAgentServices( + ctx, flags, azdClient, checkDir, displayPath, agentServices, + ); err != nil { + return err + } + return ejectInfraAfterInit(infraProvider) + } + } + } + } + if flags.manifestPointer != "" { // Fail fast when the user accidentally passes a directory // instead of a manifest file — before downloading templates. diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go new file mode 100644 index 00000000000..cc320ab4ceb --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go @@ -0,0 +1,187 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + + "azureaiagent/internal/cmd/nextstep" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/fatih/color" + "go.yaml.in/yaml/v3" +) + +// projectManifestCandidates lists the project manifest file names scanned when +// looking for an already-configured agent service, in priority order. +var projectManifestCandidates = []string{"azure.yaml", "azure.yml"} + +// projectAgentService is an agent already declared in the project's azure.yaml. +// ServiceName is the key under services:; AgentName is the agent's own name from +// the inline definition, which may differ from the service key. +type projectAgentService struct { + ServiceName string + AgentName string +} + +// azureYamlAgentServices is the minimal view needed to spot agent services in a +// unified azure.yaml. yaml.v3 ignores every other key, so this stays tolerant of +// the rest of the (large) service schema. +type azureYamlAgentServices struct { + Services map[string]struct { + Host string `yaml:"host"` + Name string `yaml:"name"` + Kind string `yaml:"kind"` + // Config carries the deprecated config-nested definition shape. + Config struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + } `yaml:"config"` + } `yaml:"services"` +} + +// findProjectManifest returns the path to the project's azure.yaml in dir, or an +// empty string when none exists. The scan is shallow, mirroring +// findExistingAgentYaml. +func findProjectManifest(dir string) (string, error) { + for _, name := range projectManifestCandidates { + candidate := filepath.Join(dir, name) + info, err := os.Stat(candidate) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return "", fmt.Errorf("checking for %s: %w", candidate, err) + } + if info.IsDir() { + continue + } + return candidate, nil + } + + return "", nil +} + +// findProjectAgentServices returns the agent services already declared in the +// project manifest at path, sorted by service name so output is deterministic. +// +// The agent definition is carried inline on the service entry in the unified +// azure.yaml format; older projects nest it under config:. Both shapes are +// recognized, matching adoptedAgentNameConfig. +// +// A manifest that cannot be parsed yields no services rather than an error: the +// caller treats "nothing detected" as "fall through to the normal init prompts", +// which is the safe outcome for a malformed file. +func findProjectAgentServices(path string) ([]projectAgentService, error) { + //nolint:gosec // path comes from findProjectManifest against a user-controlled directory + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + + var doc azureYamlAgentServices + if err := yaml.Unmarshal(data, &doc); err != nil { + return nil, nil + } + + var found []projectAgentService + for serviceName, svc := range doc.Services { + if strings.TrimSpace(svc.Host) != AiAgentHost { + continue + } + + agentName := strings.TrimSpace(svc.Name) + if strings.TrimSpace(svc.Kind) == "" && strings.TrimSpace(svc.Config.Kind) != "" { + // Deprecated config-nested definition. + agentName = strings.TrimSpace(svc.Config.Name) + } + if agentName == "" { + agentName = serviceName + } + + found = append(found, projectAgentService{ServiceName: serviceName, AgentName: agentName}) + } + + slices.SortFunc(found, func(a, b projectAgentService) int { + return strings.Compare(a.ServiceName, b.ServiceName) + }) + + return found, nil +} + +// describeProjectAgentServices renders the detected agent services for a prompt +// or status line, e.g. `"chat" (agent: my-chat-agent)`. +func describeProjectAgentServices(services []projectAgentService) string { + parts := make([]string, 0, len(services)) + for _, svc := range services { + if svc.AgentName != "" && svc.AgentName != svc.ServiceName { + parts = append(parts, fmt.Sprintf("%q (agent: %s)", svc.ServiceName, svc.AgentName)) + } else { + parts = append(parts, fmt.Sprintf("%q", svc.ServiceName)) + } + } + return strings.Join(parts, ", ") +} + +// runReuseProjectAgentServices completes init for a project whose azure.yaml +// already declares its agents, without re-asking for the values the manifest +// already answers (agent name, protocols, deploy mode, ...). +// +// The definitions already live in azure.yaml, so there is nothing to write: +// this ensures an azd environment exists and then hands off to the shared +// next-step resolver. It mirrors runReuseDefinition (issue #7268), which does +// the same for a bare on-disk agent.yaml; the unified azure.yaml format moved +// the definition inline, and this is the inline equivalent. +func runReuseProjectAgentServices( + ctx context.Context, + flags *initFlags, + azdClient *azdext.AzdClient, + srcDir string, + manifestDisplayPath string, + services []projectAgentService, +) error { + fmt.Println(color.HiBlackString( + "Detected existing agent configuration in %s: %s.", + manifestDisplayPath, + describeProjectAgentServices(services), + )) + + if _, err := ensureProject(ctx, flags, azdClient, "."); err != nil { + return err + } + + env := getExistingEnvironment(ctx, flags.env, azdClient) + if env == nil { + envName := flags.env + if envName == "" { + envName = sanitizeAgentName(services[0].AgentName + "-dev") + } + var err error + env, err = createNewEnvironment(ctx, azdClient, envName) + if err != nil { + return fmt.Errorf("failed to create azd environment: %w", err) + } + flags.env = env.Name + } + + fmt.Println(color.HiBlackString( + "Reusing the agent configuration already in %s.", manifestDisplayPath, + )) + + // Advisory only, matching the other reuse paths. The deploy-mode specific + // checks need a CodeConfiguration, which is not re-parsed here. + validatePostInit(srcDir, nil) + + state, _ := nextstep.AssembleState(ctx, azdClient) + _ = printAllNextIfTerminal(os.Stdout, nextstep.ResolveAfterInit(state, readmeExistsForProject(ctx, azdClient))) + + return nil +} diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go new file mode 100644 index 00000000000..5d0964a3581 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go @@ -0,0 +1,210 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeProjectManifest(t *testing.T, dir, name, content string) string { + t.Helper() + path := filepath.Join(dir, name) + require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) + return path +} + +func TestFindProjectManifest(t *testing.T) { + t.Parallel() + + t.Run("returns empty when absent", func(t *testing.T) { + t.Parallel() + + got, err := findProjectManifest(t.TempDir()) + require.NoError(t, err) + assert.Empty(t, got) + }) + + t.Run("finds azure.yaml", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + want := writeProjectManifest(t, dir, "azure.yaml", "name: sample\n") + + got, err := findProjectManifest(dir) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("finds azure.yml", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + want := writeProjectManifest(t, dir, "azure.yml", "name: sample\n") + + got, err := findProjectManifest(dir) + require.NoError(t, err) + assert.Equal(t, want, got) + }) + + t.Run("ignores a directory named azure.yaml", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "azure.yaml"), 0o750)) + + got, err := findProjectManifest(dir) + require.NoError(t, err) + assert.Empty(t, got) + }) +} + +func TestFindProjectAgentServices(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + want []projectAgentService + }{ + { + name: "inline agent definition on the service entry", + content: `name: sample +services: + chat: + host: azure.ai.agent + project: . + kind: hosted + name: my-chat-agent +`, + want: []projectAgentService{{ServiceName: "chat", AgentName: "my-chat-agent"}}, + }, + { + name: "deprecated config-nested definition", + content: `name: sample +services: + chat: + host: azure.ai.agent + project: . + config: + kind: hosted + name: legacy-agent +`, + want: []projectAgentService{{ServiceName: "chat", AgentName: "legacy-agent"}}, + }, + { + name: "falls back to the service key when the agent has no name", + content: `name: sample +services: + chat: + host: azure.ai.agent + project: . + kind: hosted +`, + want: []projectAgentService{{ServiceName: "chat", AgentName: "chat"}}, + }, + { + name: "multiple agents are sorted by service name", + content: `name: sample +services: + zeta: + host: azure.ai.agent + kind: hosted + name: zeta-agent + alpha: + host: azure.ai.agent + kind: hosted + name: alpha-agent +`, + want: []projectAgentService{ + {ServiceName: "alpha", AgentName: "alpha-agent"}, + {ServiceName: "zeta", AgentName: "zeta-agent"}, + }, + }, + { + name: "non-agent Foundry services are ignored", + content: `name: sample +services: + project: + host: azure.ai.project + name: my-project + api: + host: containerapp +`, + want: nil, + }, + { + name: "project with no services", + content: `name: sample +`, + want: nil, + }, + { + name: "malformed yaml yields no services rather than an error", + content: "name: sample\nservices: [this is not a map\n", + want: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := writeProjectManifest(t, dir, "azure.yaml", tt.content) + + got, err := findProjectAgentServices(path) + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestFindProjectAgentServices_MissingFile(t *testing.T) { + t.Parallel() + + _, err := findProjectAgentServices(filepath.Join(t.TempDir(), "azure.yaml")) + require.Error(t, err) +} + +func TestDescribeProjectAgentServices(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + services []projectAgentService + want string + }{ + { + name: "agent name differs from the service key", + services: []projectAgentService{{ServiceName: "chat", AgentName: "my-chat-agent"}}, + want: `"chat" (agent: my-chat-agent)`, + }, + { + name: "agent name matching the service key is not repeated", + services: []projectAgentService{{ServiceName: "chat", AgentName: "chat"}}, + want: `"chat"`, + }, + { + name: "multiple services are comma separated", + services: []projectAgentService{ + {ServiceName: "alpha", AgentName: "alpha"}, + {ServiceName: "zeta", AgentName: "zeta-agent"}, + }, + want: `"alpha", "zeta" (agent: zeta-agent)`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, describeProjectAgentServices(tt.services)) + }) + } +} From a7d9f38c5d247fa3e518e8e3933dd25fa37fb402 Mon Sep 17 00:00:00 2001 From: Glenn Harper <64209257+glharper@users.noreply.github.com> Date: Tue, 4 Aug 2026 09:46:41 -0400 Subject: [PATCH 2/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cli/azd/extensions/azure.ai.agents/internal/cmd/init.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 825cd68362a..1e1979abb7f 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1308,9 +1308,11 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, // flow rather than silently adopting whatever azure.yaml already // declares. if flags.manifestPointer == "" && !manifestDetectedButDeclined && flags.agentName == "" { - checkDir := flags.src - if checkDir == "" { +checkDir, projectErr := azdext.GetProjectDir() + if errors.Is(projectErr, azdext.ErrProjectNotFound) { checkDir = "." + } else if projectErr != nil { + return fmt.Errorf("resolving existing project directory: %w", projectErr) } manifestPath, findErr := findProjectManifest(checkDir) if findErr != nil { From 00f592d463360ef9afbb7101ef4eede76f443426 Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Tue, 4 Aug 2026 10:22:07 -0400 Subject: [PATCH 3/5] fix(agents): detect existing agents via the azd host, not a private parser Addresses review feedback on #9404. Detection went through its own azure.yaml reader rooted at --src, which is the agent source directory rather than the project root, so a project found by walking up from the cwd was missed and init re-prompted anyway. It also carried a private azure.yaml/azure.yml candidate list and a hand-rolled service struct. Ask the host instead: Project().Get() resolves the manifest the same way every other azd command does (walking up from the cwd, honoring azure.yml), and adoptedAgentNameConfig already reads the agent name from both the inline and the deprecated config: shapes. A project the host cannot load still yields no detections so init falls through to its normal prompts, but the cause is now logged instead of silently swallowed. Reuse also opted out only on --agent-name. Since --no-prompt makes reuse unconditional, a scripted run passing --deploy-mode/--runtime/--entry-point or any other agent-defining flag would silently no-op; every such flag now opts out. --src counts only when passed explicitly, because applyPositionalArg folds a positional path into the same field and `azd ai agent init .` must keep reusing. Also drops a validatePostInit call that could never run, since it returns immediately when its codeConfig argument is nil. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1d5b46e6-fc04-48bb-9a2c-156105966b48 --- .../azure.ai.agents/internal/cmd/init.go | 116 +++++---- .../internal/cmd/init_reuse_project_agent.go | 123 +++------ .../cmd/init_reuse_project_agent_test.go | 244 +++++++++--------- 3 files changed, 225 insertions(+), 258 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 1e1979abb7f..2fcfb386216 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1046,6 +1046,35 @@ func runInitFromManifest( return action.Run(ctx) } +// agentDefiningFlagsSet reports whether the caller passed any flag that +// describes the agent to set up. +// +// Reusing an already-configured project is only safe when the command was not +// told what to build. Each of these flags feeds a value init would otherwise +// prompt for, so reusing while one is set would silently discard it — notably +// under --no-prompt, where reuse is unconditional. +// +// srcExplicit must come from cmd.Flags().Changed("src"), not from flags.src: +// applyPositionalArg folds a positional directory into flags.src, so testing +// the field would make `azd ai agent init .` — a documented form — skip reuse +// and re-prompt, which is the very behavior issue #9154 reports. +// +// --env and --infra are deliberately absent: they describe the environment and +// the IaC output rather than the agent, and both stay meaningful on a reuse run. +func agentDefiningFlagsSet(flags *initFlags, srcExplicit bool) bool { + return flags.agentName != "" || + flags.deployMode != "" || + flags.runtime != "" || + flags.entryPoint != "" || + flags.depResolution != "" || + flags.model != "" || + flags.modelDeployment != "" || + flags.projectResourceId != "" || + flags.image != "" || + srcExplicit || + len(flags.protocols) > 0 +} + func newInitCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &initFlags{} extCtx = ensureExtensionContext(extCtx) @@ -1295,7 +1324,7 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } } - // When the project's own azure.yaml already declares agent + // When the project's own manifest already declares agent // service(s), the values init would prompt for (agent name, // protocols, deploy mode) are already recorded there. Offer to // reuse that configuration instead of re-asking (issue #9154). @@ -1303,63 +1332,40 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, // reuse above: the definition now lives inline on the service // entry rather than in a separate file. // - // An explicit --agent-name states intent to set up that specific - // agent, so it opts out of reuse and falls through to the normal - // flow rather than silently adopting whatever azure.yaml already - // declares. - if flags.manifestPointer == "" && !manifestDetectedButDeclined && flags.agentName == "" { -checkDir, projectErr := azdext.GetProjectDir() - if errors.Is(projectErr, azdext.ErrProjectNotFound) { - checkDir = "." - } else if projectErr != nil { - return fmt.Errorf("resolving existing project directory: %w", projectErr) - } - manifestPath, findErr := findProjectManifest(checkDir) - if findErr != nil { - return findErr - } - if manifestPath != "" { - agentServices, servicesErr := findProjectAgentServices(manifestPath) - if servicesErr != nil { - return servicesErr - } - if len(agentServices) > 0 { - displayPath, relErr := filepath.Rel(checkDir, manifestPath) - if relErr != nil || displayPath == "" { - displayPath = manifestPath - } - - useExisting := flags.noPrompt - if !flags.noPrompt { - confirmResp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ - Options: &azdext.ConfirmOptions{ - Message: fmt.Sprintf( - "%s already configures %s. Use it?", - displayPath, - describeProjectAgentServices(agentServices), - ), - DefaultValue: new(true), - }, - }) - if promptErr != nil { - if exterrors.IsCancellation(promptErr) { - return exterrors.Cancelled("initialization was cancelled") - } - return fmt.Errorf("prompting for project agent reuse: %w", promptErr) + // Any flag that describes the agent to set up states intent to + // configure that agent, so it opts out of reuse and falls through + // to the normal flow. Without that, a scripted + // `--no-prompt --deploy-mode code --runtime ...` in a repo that + // already declares an agent would silently no-op instead of + // honoring the flags the caller passed. + if flags.manifestPointer == "" && !manifestDetectedButDeclined && + !agentDefiningFlagsSet(flags, cmd.Flags().Changed("src")) { + agentServices := findProjectAgentServices(ctx, azdClient) + if len(agentServices) > 0 { + useExisting := flags.noPrompt + if !flags.noPrompt { + confirmResp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ + Options: &azdext.ConfirmOptions{ + Message: fmt.Sprintf( + "This project already configures %s. Use it?", + describeProjectAgentServices(agentServices), + ), + DefaultValue: new(true), + }, + }) + if promptErr != nil { + if exterrors.IsCancellation(promptErr) { + return exterrors.Cancelled("initialization was cancelled") } - useExisting = *confirmResp.Value + return fmt.Errorf("prompting for project agent reuse: %w", promptErr) } - if useExisting { - if flags.src == "" { - flags.src = checkDir - } - if err := runReuseProjectAgentServices( - ctx, flags, azdClient, checkDir, displayPath, agentServices, - ); err != nil { - return err - } - return ejectInfraAfterInit(infraProvider) + useExisting = *confirmResp.Value + } + if useExisting { + if err := runReuseProjectAgentServices(ctx, flags, azdClient, agentServices); err != nil { + return err } + return ejectInfraAfterInit(infraProvider) } } } diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go index cc320ab4ceb..8390dc92971 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go @@ -5,11 +5,9 @@ package cmd import ( "context" - "errors" "fmt" - "io/fs" + "log" "os" - "path/filepath" "slices" "strings" @@ -17,92 +15,52 @@ import ( "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/fatih/color" - "go.yaml.in/yaml/v3" ) -// projectManifestCandidates lists the project manifest file names scanned when -// looking for an already-configured agent service, in priority order. -var projectManifestCandidates = []string{"azure.yaml", "azure.yml"} - -// projectAgentService is an agent already declared in the project's azure.yaml. +// projectAgentService is an agent already declared in the project manifest. // ServiceName is the key under services:; AgentName is the agent's own name from -// the inline definition, which may differ from the service key. +// the definition, which may differ from the service key. type projectAgentService struct { ServiceName string AgentName string } -// azureYamlAgentServices is the minimal view needed to spot agent services in a -// unified azure.yaml. yaml.v3 ignores every other key, so this stays tolerant of -// the rest of the (large) service schema. -type azureYamlAgentServices struct { - Services map[string]struct { - Host string `yaml:"host"` - Name string `yaml:"name"` - Kind string `yaml:"kind"` - // Config carries the deprecated config-nested definition shape. - Config struct { - Name string `yaml:"name"` - Kind string `yaml:"kind"` - } `yaml:"config"` - } `yaml:"services"` -} - -// findProjectManifest returns the path to the project's azure.yaml in dir, or an -// empty string when none exists. The scan is shallow, mirroring -// findExistingAgentYaml. -func findProjectManifest(dir string) (string, error) { - for _, name := range projectManifestCandidates { - candidate := filepath.Join(dir, name) - info, err := os.Stat(candidate) - if errors.Is(err, fs.ErrNotExist) { - continue - } - if err != nil { - return "", fmt.Errorf("checking for %s: %w", candidate, err) - } - if info.IsDir() { - continue - } - return candidate, nil - } - - return "", nil -} - -// findProjectAgentServices returns the agent services already declared in the -// project manifest at path, sorted by service name so output is deterministic. +// findProjectAgentServices returns the agent services the azd host reports for +// the current project, sorted by service name so output is deterministic. +// +// Project discovery is left to the host: it resolves the manifest by walking up +// from the working directory and accepts both azure.yaml and azure.yml, so this +// sees exactly the manifest every other azd command does, including when init +// runs from a subdirectory of the project. // // The agent definition is carried inline on the service entry in the unified -// azure.yaml format; older projects nest it under config:. Both shapes are -// recognized, matching adoptedAgentNameConfig. +// format and nested under config: in older projects; adoptedAgentNameConfig +// resolves the name from either shape. // -// A manifest that cannot be parsed yields no services rather than an error: the -// caller treats "nothing detected" as "fall through to the normal init prompts", -// which is the safe outcome for a malformed file. -func findProjectAgentServices(path string) ([]projectAgentService, error) { - //nolint:gosec // path comes from findProjectManifest against a user-controlled directory - data, err := os.ReadFile(path) +// A project that cannot be loaded (none present, or a manifest azd rejects) +// yields no detections, so init falls through to its normal prompts rather than +// hard-failing on a file the user has not been asked about yet. The cause is +// logged so --debug still surfaces a typo'd manifest. +func findProjectAgentServices(ctx context.Context, azdClient *azdext.AzdClient) []projectAgentService { + projectResponse, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) if err != nil { - return nil, fmt.Errorf("read %s: %w", path, err) + log.Printf("agent reuse: project config unavailable, continuing with normal init: %v", err) + return nil } - var doc azureYamlAgentServices - if err := yaml.Unmarshal(data, &doc); err != nil { - return nil, nil - } + return projectAgentServicesFrom(projectResponse.GetProject().GetServices()) +} +// projectAgentServicesFrom selects the agent services out of a project's service +// map, resolving each display name and sorting by service name. +func projectAgentServicesFrom(services map[string]*azdext.ServiceConfig) []projectAgentService { var found []projectAgentService - for serviceName, svc := range doc.Services { - if strings.TrimSpace(svc.Host) != AiAgentHost { + for serviceName, svc := range services { + if svc.GetHost() != AiAgentHost { continue } - agentName := strings.TrimSpace(svc.Name) - if strings.TrimSpace(svc.Kind) == "" && strings.TrimSpace(svc.Config.Kind) != "" { - // Deprecated config-nested definition. - agentName = strings.TrimSpace(svc.Config.Name) - } + agentName, _ := adoptedAgentNameConfig(svc) if agentName == "" { agentName = serviceName } @@ -114,7 +72,7 @@ func findProjectAgentServices(path string) ([]projectAgentService, error) { return strings.Compare(a.ServiceName, b.ServiceName) }) - return found, nil + return found } // describeProjectAgentServices renders the detected agent services for a prompt @@ -131,26 +89,23 @@ func describeProjectAgentServices(services []projectAgentService) string { return strings.Join(parts, ", ") } -// runReuseProjectAgentServices completes init for a project whose azure.yaml +// runReuseProjectAgentServices completes init for a project whose manifest // already declares its agents, without re-asking for the values the manifest // already answers (agent name, protocols, deploy mode, ...). // -// The definitions already live in azure.yaml, so there is nothing to write: -// this ensures an azd environment exists and then hands off to the shared +// The definitions already live in the project manifest, so there is nothing to +// write: this ensures an azd environment exists and then hands off to the shared // next-step resolver. It mirrors runReuseDefinition (issue #7268), which does -// the same for a bare on-disk agent.yaml; the unified azure.yaml format moved -// the definition inline, and this is the inline equivalent. +// the same for a bare on-disk agent.yaml; the unified format moved the +// definition inline, and this is the inline equivalent. func runReuseProjectAgentServices( ctx context.Context, flags *initFlags, azdClient *azdext.AzdClient, - srcDir string, - manifestDisplayPath string, services []projectAgentService, ) error { fmt.Println(color.HiBlackString( - "Detected existing agent configuration in %s: %s.", - manifestDisplayPath, + "Detected existing agent configuration: %s.", describeProjectAgentServices(services), )) @@ -172,13 +127,7 @@ func runReuseProjectAgentServices( flags.env = env.Name } - fmt.Println(color.HiBlackString( - "Reusing the agent configuration already in %s.", manifestDisplayPath, - )) - - // Advisory only, matching the other reuse paths. The deploy-mode specific - // checks need a CodeConfiguration, which is not re-parsed here. - validatePostInit(srcDir, nil) + fmt.Println(color.HiBlackString("Reusing the agent configuration already in this project.")) state, _ := nextstep.AssembleState(ctx, azdClient) _ = printAllNextIfTerminal(os.Stdout, nextstep.ResolveAfterInit(state, readmeExistsForProject(ctx, azdClient))) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go index 5d0964a3581..f6c6998f9c0 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go @@ -4,123 +4,64 @@ package cmd import ( - "os" - "path/filepath" "testing" + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" ) -func writeProjectManifest(t *testing.T, dir, name, content string) string { - t.Helper() - path := filepath.Join(dir, name) - require.NoError(t, os.WriteFile(path, []byte(content), 0o600)) - return path -} - -func TestFindProjectManifest(t *testing.T) { - t.Parallel() - - t.Run("returns empty when absent", func(t *testing.T) { - t.Parallel() - - got, err := findProjectManifest(t.TempDir()) - require.NoError(t, err) - assert.Empty(t, got) - }) - - t.Run("finds azure.yaml", func(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - want := writeProjectManifest(t, dir, "azure.yaml", "name: sample\n") - - got, err := findProjectManifest(dir) - require.NoError(t, err) - assert.Equal(t, want, got) - }) - - t.Run("finds azure.yml", func(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - want := writeProjectManifest(t, dir, "azure.yml", "name: sample\n") - - got, err := findProjectManifest(dir) - require.NoError(t, err) - assert.Equal(t, want, got) - }) - - t.Run("ignores a directory named azure.yaml", func(t *testing.T) { - t.Parallel() - - dir := t.TempDir() - require.NoError(t, os.Mkdir(filepath.Join(dir, "azure.yaml"), 0o750)) - - got, err := findProjectManifest(dir) - require.NoError(t, err) - assert.Empty(t, got) - }) +// legacyConfigAgentService builds the deprecated shape, where the agent +// definition is nested under config: rather than inline on the service entry. +func legacyConfigAgentService(serviceName, agentName string) *azdext.ServiceConfig { + return &azdext.ServiceConfig{ + Name: serviceName, + Host: AiAgentHost, + Config: &structpb.Struct{Fields: map[string]*structpb.Value{ + "kind": structpb.NewStringValue(string(agent_yaml.AgentKindHosted)), + "name": structpb.NewStringValue(agentName), + }}, + } } -func TestFindProjectAgentServices(t *testing.T) { +func TestProjectAgentServicesFrom(t *testing.T) { t.Parallel() tests := []struct { - name string - content string - want []projectAgentService + name string + services map[string]*azdext.ServiceConfig + want []projectAgentService }{ { name: "inline agent definition on the service entry", - content: `name: sample -services: - chat: - host: azure.ai.agent - project: . - kind: hosted - name: my-chat-agent -`, + services: map[string]*azdext.ServiceConfig{ + "chat": inlineAgentService(t, "chat", "my-chat-agent"), + }, want: []projectAgentService{{ServiceName: "chat", AgentName: "my-chat-agent"}}, }, { name: "deprecated config-nested definition", - content: `name: sample -services: - chat: - host: azure.ai.agent - project: . - config: - kind: hosted - name: legacy-agent -`, + services: map[string]*azdext.ServiceConfig{ + "chat": legacyConfigAgentService("chat", "legacy-agent"), + }, want: []projectAgentService{{ServiceName: "chat", AgentName: "legacy-agent"}}, }, { - name: "falls back to the service key when the agent has no name", - content: `name: sample -services: - chat: - host: azure.ai.agent - project: . - kind: hosted -`, + name: "falls back to the service key when the definition lives in agent.yaml", + services: map[string]*azdext.ServiceConfig{ + "chat": {Name: "chat", Host: AiAgentHost}, + }, want: []projectAgentService{{ServiceName: "chat", AgentName: "chat"}}, }, { name: "multiple agents are sorted by service name", - content: `name: sample -services: - zeta: - host: azure.ai.agent - kind: hosted - name: zeta-agent - alpha: - host: azure.ai.agent - kind: hosted - name: alpha-agent -`, + services: map[string]*azdext.ServiceConfig{ + "zeta": inlineAgentService(t, "zeta", "zeta-agent"), + "alpha": inlineAgentService(t, "alpha", "alpha-agent"), + }, want: []projectAgentService{ {ServiceName: "alpha", AgentName: "alpha-agent"}, {ServiceName: "zeta", AgentName: "zeta-agent"}, @@ -128,48 +69,119 @@ services: }, { name: "non-agent Foundry services are ignored", - content: `name: sample -services: - project: - host: azure.ai.project - name: my-project - api: - host: containerapp -`, + services: map[string]*azdext.ServiceConfig{ + "project": {Name: "project", Host: "azure.ai.project"}, + "api": {Name: "api", Host: "containerapp"}, + }, want: nil, }, { - name: "project with no services", - content: `name: sample -`, - want: nil, + name: "project with no services", + services: nil, + want: nil, }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, projectAgentServicesFrom(tt.services)) + }) + } +} + +// Ordering must not depend on Go's randomized map iteration, so the same input +// is evaluated repeatedly. +func TestProjectAgentServicesFrom_OrderingIsStable(t *testing.T) { + t.Parallel() + + services := map[string]*azdext.ServiceConfig{ + "delta": {Name: "delta", Host: AiAgentHost}, + "alpha": {Name: "alpha", Host: AiAgentHost}, + "charlie": inlineAgentService(t, "charlie", "c-agent"), + "bravo": {Name: "bravo", Host: AiAgentHost}, + } + want := []projectAgentService{ + {ServiceName: "alpha", AgentName: "alpha"}, + {ServiceName: "bravo", AgentName: "bravo"}, + {ServiceName: "charlie", AgentName: "c-agent"}, + {ServiceName: "delta", AgentName: "delta"}, + } + + for range 20 { + assert.Equal(t, want, projectAgentServicesFrom(services)) + } +} + +// Reuse is only safe when the caller did not describe an agent to set up. +// Under --no-prompt reuse is unconditional, so any of these flags being ignored +// would silently no-op a scripted run. +func TestAgentDefiningFlagsSet(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flags *initFlags + srcExplicit bool + want bool + }{ + {name: "no flags", flags: &initFlags{}, want: false}, + {name: "agent-name", flags: &initFlags{agentName: "my-agent"}, want: true}, + {name: "deploy-mode", flags: &initFlags{deployMode: "code"}, want: true}, + {name: "runtime", flags: &initFlags{runtime: "python_3_13"}, want: true}, + {name: "entry-point", flags: &initFlags{entryPoint: "app.py"}, want: true}, + {name: "dep-resolution", flags: &initFlags{depResolution: "bundled"}, want: true}, + {name: "model", flags: &initFlags{model: "gpt-5.4-mini"}, want: true}, + {name: "model-deployment", flags: &initFlags{modelDeployment: "my-deployment"}, want: true}, + {name: "project-id", flags: &initFlags{projectResourceId: "/subscriptions/x"}, want: true}, + {name: "image", flags: &initFlags{image: "myacr.azurecr.io/agent:1"}, want: true}, + {name: "protocol", flags: &initFlags{protocols: []string{"responses"}}, want: true}, + + // An explicit --src names where a new agent's source goes, so it opts + // out; the same field populated from a positional path must not. { - name: "malformed yaml yields no services rather than an error", - content: "name: sample\nservices: [this is not a map\n", - want: nil, + name: "explicit --src", + flags: &initFlags{src: "agents/chat"}, + srcExplicit: true, + want: true, }, + { + name: "src folded from a positional arg does not opt out", + flags: &initFlags{src: "."}, + want: false, + }, + + // Neither describes the agent, so both stay compatible with reuse. + {name: "env alone does not opt out", flags: &initFlags{env: "dev"}, want: false}, + {name: "infra alone does not opt out", flags: &initFlags{infra: "bicep"}, want: false}, + {name: "no-prompt alone does not opt out", flags: &initFlags{noPrompt: true}, want: false}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { t.Parallel() - dir := t.TempDir() - path := writeProjectManifest(t, dir, "azure.yaml", tt.content) - - got, err := findProjectAgentServices(path) - require.NoError(t, err) - assert.Equal(t, tt.want, got) + assert.Equal(t, tt.want, agentDefiningFlagsSet(tt.flags, tt.srcExplicit)) }) } } -func TestFindProjectAgentServices_MissingFile(t *testing.T) { +// Regression guard for the positional form documented in the command's Use +// string: `azd ai agent init .` resolves to flags.src via applyPositionalArg, +// which must not be mistaken for an explicit --src and disable reuse (#9154). +func TestAgentDefiningFlagsSet_PositionalPathKeepsReuse(t *testing.T) { t.Parallel() - _, err := findProjectAgentServices(filepath.Join(t.TempDir(), "azure.yaml")) - require.Error(t, err) + dir := t.TempDir() + flags := &initFlags{} + cmd := newInitCommand(&azdext.ExtensionContext{}) + + require.NoError(t, applyPositionalArg(dir, flags, cmd)) + require.Equal(t, dir, flags.src, "a positional directory is folded into flags.src") + + assert.False(t, agentDefiningFlagsSet(flags, cmd.Flags().Changed("src")), + "a positional path must not opt the project out of agent reuse") } func TestDescribeProjectAgentServices(t *testing.T) { From 23bd0aeb267871cb4bb8a7110b3603c821ad44c5 Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Tue, 4 Aug 2026 15:45:17 -0400 Subject: [PATCH 4/5] fix(agents): avoid redundant project setup on config reuse Host-based detection already proves Project().Get succeeded before the reuse path runs. Calling ensureProject again could not reach its scaffold branch, discarded the returned project, and printed the false status that an agent was being added even though reuse intentionally writes no service. Remove the second round-trip and document the precondition. Also distinguish a positional project-root path from a positional agent source directory. `init .` at the project root can reuse its configured agent, while `init ./agents/new` must fall through and honor the selected source instead of silently ignoring it. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1d5b46e6-fc04-48bb-9a2c-156105966b48 --- .../azure.ai.agents/internal/cmd/init.go | 11 ++-- .../internal/cmd/init_reuse_project_agent.go | 61 ++++++++++++++++--- .../cmd/init_reuse_project_agent_test.go | 60 ++++++++++++++++-- 3 files changed, 116 insertions(+), 16 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 2fcfb386216..16bcd021548 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1340,15 +1340,16 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, // honoring the flags the caller passed. if flags.manifestPointer == "" && !manifestDetectedButDeclined && !agentDefiningFlagsSet(flags, cmd.Flags().Changed("src")) { - agentServices := findProjectAgentServices(ctx, azdClient) - if len(agentServices) > 0 { + detection := detectProjectAgentServices(ctx, azdClient) + if len(detection.services) > 0 && + !positionalSourceOptsOutOfReuse(flags.src, detection.projectRoot) { useExisting := flags.noPrompt if !flags.noPrompt { confirmResp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ Options: &azdext.ConfirmOptions{ Message: fmt.Sprintf( "This project already configures %s. Use it?", - describeProjectAgentServices(agentServices), + describeProjectAgentServices(detection.services), ), DefaultValue: new(true), }, @@ -1362,7 +1363,9 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, useExisting = *confirmResp.Value } if useExisting { - if err := runReuseProjectAgentServices(ctx, flags, azdClient, agentServices); err != nil { + if err := runReuseProjectAgentServices( + ctx, flags, azdClient, detection.services, + ); err != nil { return err } return ejectInfraAfterInit(infraProvider) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go index 8390dc92971..3c9366e5b54 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go @@ -8,6 +8,7 @@ import ( "fmt" "log" "os" + "path/filepath" "slices" "strings" @@ -25,7 +26,12 @@ type projectAgentService struct { AgentName string } -// findProjectAgentServices returns the agent services the azd host reports for +type projectAgentDetection struct { + services []projectAgentService + projectRoot string +} + +// detectProjectAgentServices returns the agent services the azd host reports for // the current project, sorted by service name so output is deterministic. // // Project discovery is left to the host: it resolves the manifest by walking up @@ -41,14 +47,18 @@ type projectAgentService struct { // yields no detections, so init falls through to its normal prompts rather than // hard-failing on a file the user has not been asked about yet. The cause is // logged so --debug still surfaces a typo'd manifest. -func findProjectAgentServices(ctx context.Context, azdClient *azdext.AzdClient) []projectAgentService { +func detectProjectAgentServices(ctx context.Context, azdClient *azdext.AzdClient) projectAgentDetection { projectResponse, err := azdClient.Project().Get(ctx, &azdext.EmptyRequest{}) if err != nil { log.Printf("agent reuse: project config unavailable, continuing with normal init: %v", err) - return nil + return projectAgentDetection{} } - return projectAgentServicesFrom(projectResponse.GetProject().GetServices()) + project := projectResponse.GetProject() + return projectAgentDetection{ + services: projectAgentServicesFrom(project.GetServices()), + projectRoot: project.GetPath(), + } } // projectAgentServicesFrom selects the agent services out of a project's service @@ -75,6 +85,42 @@ func projectAgentServicesFrom(services map[string]*azdext.ServiceConfig) []proje return found } +// positionalSourceOptsOutOfReuse reports whether a positional source directory +// selects something below or outside the active project root. +// +// `azd ai agent init .` from the project root is a documented way to initialize +// the current project and may reuse its configured agent. A positional +// `./agents/new`, however, explicitly selects source for a new agent and must +// not be silently ignored by reuse. +func positionalSourceOptsOutOfReuse(src, projectRoot string) bool { + if src == "" { + return false + } + if projectRoot == "" { + return true + } + + srcPath, err := filepath.Abs(src) + if err != nil { + return true + } + rootPath, err := filepath.Abs(projectRoot) + if err != nil { + return true + } + + srcInfo, err := os.Stat(srcPath) + if err != nil { + return true + } + rootInfo, err := os.Stat(rootPath) + if err != nil { + return true + } + + return !os.SameFile(srcInfo, rootInfo) +} + // describeProjectAgentServices renders the detected agent services for a prompt // or status line, e.g. `"chat" (agent: my-chat-agent)`. func describeProjectAgentServices(services []projectAgentService) string { @@ -98,6 +144,9 @@ func describeProjectAgentServices(services []projectAgentService) string { // next-step resolver. It mirrors runReuseDefinition (issue #7268), which does // the same for a bare on-disk agent.yaml; the unified format moved the // definition inline, and this is the inline equivalent. +// +// The caller reaches this function only after detectProjectAgentServices has +// loaded the project through the azd host, so no project setup is needed here. func runReuseProjectAgentServices( ctx context.Context, flags *initFlags, @@ -109,10 +158,6 @@ func runReuseProjectAgentServices( describeProjectAgentServices(services), )) - if _, err := ensureProject(ctx, flags, azdClient, "."); err != nil { - return err - } - env := getExistingEnvironment(ctx, flags.env, azdClient) if env == nil { envName := flags.env diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go index f6c6998f9c0..15e890c68da 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go @@ -4,6 +4,8 @@ package cmd import ( + "os" + "path/filepath" "testing" "azureaiagent/internal/pkg/agents/agent_yaml" @@ -139,7 +141,8 @@ func TestAgentDefiningFlagsSet(t *testing.T) { {name: "protocol", flags: &initFlags{protocols: []string{"responses"}}, want: true}, // An explicit --src names where a new agent's source goes, so it opts - // out; the same field populated from a positional path must not. + // out. A positional path is classified separately against the project + // root after detection. { name: "explicit --src", flags: &initFlags{src: "agents/chat"}, @@ -147,7 +150,7 @@ func TestAgentDefiningFlagsSet(t *testing.T) { want: true, }, { - name: "src folded from a positional arg does not opt out", + name: "src folded from a positional arg is not an explicit flag", flags: &initFlags{src: "."}, want: false, }, @@ -169,7 +172,8 @@ func TestAgentDefiningFlagsSet(t *testing.T) { // Regression guard for the positional form documented in the command's Use // string: `azd ai agent init .` resolves to flags.src via applyPositionalArg, -// which must not be mistaken for an explicit --src and disable reuse (#9154). +// which must not be mistaken for an explicit --src before it is compared with +// the active project root (#9154). func TestAgentDefiningFlagsSet_PositionalPathKeepsReuse(t *testing.T) { t.Parallel() @@ -181,7 +185,55 @@ func TestAgentDefiningFlagsSet_PositionalPathKeepsReuse(t *testing.T) { require.Equal(t, dir, flags.src, "a positional directory is folded into flags.src") assert.False(t, agentDefiningFlagsSet(flags, cmd.Flags().Changed("src")), - "a positional path must not opt the project out of agent reuse") + "a positional path must not be treated as an explicit --src flag") +} + +func TestPositionalSourceOptsOutOfReuse(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + agentDir := filepath.Join(projectRoot, "agents", "new") + require.NoError(t, os.MkdirAll(agentDir, 0o750)) + + tests := []struct { + name string + src string + projectRoot string + want bool + }{ + { + name: "project root keeps reuse", + src: projectRoot, + projectRoot: projectRoot, + want: false, + }, + { + name: "selected agent directory opts out", + src: agentDir, + projectRoot: projectRoot, + want: true, + }, + { + name: "no positional source keeps reuse", + src: "", + projectRoot: projectRoot, + want: false, + }, + { + name: "missing project root opts out conservatively", + src: projectRoot, + projectRoot: "", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, positionalSourceOptsOutOfReuse(tt.src, tt.projectRoot)) + }) + } } func TestDescribeProjectAgentServices(t *testing.T) { From 1ece3461e0a6d800d9ec2388d6fdaf5577186b03 Mon Sep 17 00:00:00 2001 From: Glenn Harper Date: Wed, 5 Aug 2026 16:31:24 -0400 Subject: [PATCH 5/5] fix(agents): validate project reuse and preserve selected environment Reuse only project services whose definitions resolve successfully through the production inline, config, ref, or disk loader. Validate service source paths at the project boundary so absolute, traversal, and symlink escapes cannot authorize reuse. Run project-owned reuse before bare agent.yaml reuse so a configured disk-backed service is not added or replaced. Share caller-intent guards while retaining explicit --src support for the bare-file path that consumes it. Assemble next-step state from the environment selected for init without changing the active environment, and qualify emitted azd commands so copied guidance continues targeting that environment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: 1d5b46e6-fc04-48bb-9a2c-156105966b48 --- .../azure.ai.agents/internal/cmd/init.go | 126 ++++--- .../internal/cmd/init_reuse_project_agent.go | 75 ++++- .../cmd/init_reuse_project_agent_test.go | 310 ++++++++++++++++-- .../internal/cmd/nextstep/resolver.go | 16 + .../internal/cmd/nextstep/resolver_test.go | 18 + .../internal/cmd/nextstep/state.go | 21 +- .../internal/cmd/nextstep/state_test.go | 23 ++ .../internal/cmd/nextstep/types.go | 5 + 8 files changed, 499 insertions(+), 95 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 16bcd021548..314e20fae94 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1054,14 +1054,17 @@ func runInitFromManifest( // prompt for, so reusing while one is set would silently discard it — notably // under --no-prompt, where reuse is unconditional. // -// srcExplicit must come from cmd.Flags().Changed("src"), not from flags.src: +// srcBlocksReuse must come from cmd.Flags().Changed("src") for project reuse, +// not from flags.src: // applyPositionalArg folds a positional directory into flags.src, so testing // the field would make `azd ai agent init .` — a documented form — skip reuse // and re-prompt, which is the very behavior issue #9154 reports. +// Bare agent.yaml reuse passes false because it consumes --src as the directory +// containing the definition instead of ignoring it. // // --env and --infra are deliberately absent: they describe the environment and // the IaC output rather than the agent, and both stay meaningful on a reuse run. -func agentDefiningFlagsSet(flags *initFlags, srcExplicit bool) bool { +func agentDefiningFlagsSet(flags *initFlags, srcBlocksReuse bool) bool { return flags.agentName != "" || flags.deployMode != "" || flags.runtime != "" || @@ -1071,10 +1074,23 @@ func agentDefiningFlagsSet(flags *initFlags, srcExplicit bool) bool { flags.modelDeployment != "" || flags.projectResourceId != "" || flags.image != "" || - srcExplicit || + srcBlocksReuse || len(flags.protocols) > 0 } +// canReuseExistingAgentConfiguration reports whether init may reuse either a +// project-owned definition or a bare agent.yaml without discarding caller +// intent. +func canReuseExistingAgentConfiguration( + flags *initFlags, + manifestDetectedButDeclined bool, + srcBlocksReuse bool, +) bool { + return flags.manifestPointer == "" && + !manifestDetectedButDeclined && + !agentDefiningFlagsSet(flags, srcBlocksReuse) +} + func newInitCommand(extCtx *azdext.ExtensionContext) *cobra.Command { flags := &initFlags{} extCtx = ensureExtensionContext(extCtx) @@ -1280,26 +1296,41 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } } - // When no manifest was detected, look for a bare agent.yaml definition - // to reuse (issue #7268). Skips the init-mode prompt and from-code - // scaffolding. Bypassed when the user already declined a manifest above. - if flags.manifestPointer == "" && !manifestDetectedButDeclined { - checkDir := flags.src - if checkDir == "" { - checkDir = "." - } - existing, findErr := findExistingAgentYaml(checkDir) - if findErr != nil { - return findErr - } - if existing != "" { + // When the project's own manifest already declares agent + // service(s), the values init would prompt for (agent name, + // protocols, deploy mode) are already recorded there. Offer to + // reuse that configuration instead of re-asking (issue #9154). + // + // This check runs before the bare agent.yaml scan below. A configured + // service may legitimately load its definition from agent.yaml; in + // that case project reuse must win so init does not add or replace a + // service that azure.yaml already owns. + // + // Any flag that describes the agent to set up states intent to + // configure that agent, so it opts out of reuse and falls through + // to the normal flow. Without that, a scripted + // `--no-prompt --deploy-mode code --runtime ...` in a repo that + // already declares an agent would silently no-op instead of + // honoring the flags the caller passed. + if canReuseExistingAgentConfiguration( + flags, + manifestDetectedButDeclined, + cmd.Flags().Changed("src"), + ) { + detection := detectProjectAgentServices(ctx, azdClient) + if len(detection.services) > 0 && + !positionalSourceOptsOutOfReuse( + flags.src, + detection.projectRoot, + detection.services, + ) { useExisting := flags.noPrompt if !flags.noPrompt { confirmResp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ Options: &azdext.ConfirmOptions{ Message: fmt.Sprintf( - "An existing agent definition was found at %q. Use it?", - existing, + "This project already configures %s. Use it?", + describeProjectAgentServices(detection.services), ), DefaultValue: new(true), }, @@ -1308,15 +1339,14 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, if exterrors.IsCancellation(promptErr) { return exterrors.Cancelled("initialization was cancelled") } - return fmt.Errorf("prompting for definition reuse: %w", promptErr) + return fmt.Errorf("prompting for project agent reuse: %w", promptErr) } useExisting = *confirmResp.Value } if useExisting { - if flags.src == "" { - flags.src = checkDir - } - if err := runReuseDefinition(ctx, flags, azdClient, httpClient, checkDir, existing); err != nil { + if err := runReuseProjectAgentServices( + ctx, flags, azdClient, detection.services, + ); err != nil { return err } return ejectInfraAfterInit(infraProvider) @@ -1324,32 +1354,31 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, } } - // When the project's own manifest already declares agent - // service(s), the values init would prompt for (agent name, - // protocols, deploy mode) are already recorded there. Offer to - // reuse that configuration instead of re-asking (issue #9154). - // This is the unified-format counterpart to the bare agent.yaml - // reuse above: the definition now lives inline on the service - // entry rather than in a separate file. - // - // Any flag that describes the agent to set up states intent to - // configure that agent, so it opts out of reuse and falls through - // to the normal flow. Without that, a scripted - // `--no-prompt --deploy-mode code --runtime ...` in a repo that - // already declares an agent would silently no-op instead of - // honoring the flags the caller passed. - if flags.manifestPointer == "" && !manifestDetectedButDeclined && - !agentDefiningFlagsSet(flags, cmd.Flags().Changed("src")) { - detection := detectProjectAgentServices(ctx, azdClient) - if len(detection.services) > 0 && - !positionalSourceOptsOutOfReuse(flags.src, detection.projectRoot) { + // When no manifest was detected, look for a bare agent.yaml definition + // to reuse (issue #7268). Skips the init-mode prompt and from-code + // scaffolding. Bypassed when the user already declined a manifest + // above or supplied agent-defining flags that reuse would ignore. + if canReuseExistingAgentConfiguration( + flags, + manifestDetectedButDeclined, + false, + ) { + checkDir := flags.src + if checkDir == "" { + checkDir = "." + } + existing, findErr := findExistingAgentYaml(checkDir) + if findErr != nil { + return findErr + } + if existing != "" { useExisting := flags.noPrompt if !flags.noPrompt { confirmResp, promptErr := azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{ Options: &azdext.ConfirmOptions{ Message: fmt.Sprintf( - "This project already configures %s. Use it?", - describeProjectAgentServices(detection.services), + "An existing agent definition was found at %q. Use it?", + existing, ), DefaultValue: new(true), }, @@ -1358,14 +1387,15 @@ from code-deploy ZIP packaging (uses .gitignore syntax).`, if exterrors.IsCancellation(promptErr) { return exterrors.Cancelled("initialization was cancelled") } - return fmt.Errorf("prompting for project agent reuse: %w", promptErr) + return fmt.Errorf("prompting for definition reuse: %w", promptErr) } useExisting = *confirmResp.Value } if useExisting { - if err := runReuseProjectAgentServices( - ctx, flags, azdClient, detection.services, - ); err != nil { + if flags.src == "" { + flags.src = checkDir + } + if err := runReuseDefinition(ctx, flags, azdClient, httpClient, checkDir, existing); err != nil { return err } return ejectInfraAfterInit(infraProvider) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go index 3c9366e5b54..6d8d5fe8d5c 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent.go @@ -13,6 +13,8 @@ import ( "strings" "azureaiagent/internal/cmd/nextstep" + "azureaiagent/internal/pkg/paths" + projectpkg "azureaiagent/internal/project" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/fatih/color" @@ -24,6 +26,10 @@ import ( type projectAgentService struct { ServiceName string AgentName string + // RelativePath is the configured service source directory. It lets a + // positional `.` from that directory reuse the owning service rather than + // falling through to bare agent.yaml reuse. + RelativePath string } type projectAgentDetection struct { @@ -55,34 +61,65 @@ func detectProjectAgentServices(ctx context.Context, azdClient *azdext.AzdClient } project := projectResponse.GetProject() + services, diagnostics := projectAgentServicesFrom(project.GetServices(), project.GetPath()) + for _, diagnostic := range diagnostics { + log.Printf("agent reuse: configured service is not reusable: %s", diagnostic) + } + return projectAgentDetection{ - services: projectAgentServicesFrom(project.GetServices()), + services: services, projectRoot: project.GetPath(), } } // projectAgentServicesFrom selects the agent services out of a project's service -// map, resolving each display name and sorting by service name. -func projectAgentServicesFrom(services map[string]*azdext.ServiceConfig) []projectAgentService { +// map only when their definitions resolve successfully. Invalid or missing +// definitions are returned as diagnostics so no-prompt reuse cannot report +// success for an incomplete service. +func projectAgentServicesFrom( + services map[string]*azdext.ServiceConfig, + projectRoot string, +) ([]projectAgentService, []string) { var found []projectAgentService + var diagnostics []string for serviceName, svc := range services { if svc.GetHost() != AiAgentHost { continue } + if _, err := paths.JoinAllowRoot(projectRoot, svc.GetRelativePath()); err != nil { + diagnostics = append(diagnostics, + fmt.Sprintf("service %q has invalid project path: %v", serviceName, err)) + continue + } + + definition, _, _, err := projectpkg.LoadAgentDefinition(svc, projectRoot) + if err != nil { + diagnostics = append(diagnostics, + fmt.Sprintf("service %q: %v", serviceName, err)) + continue + } + agentName, _ := adoptedAgentNameConfig(svc) + if agentName == "" { + agentName = definition.Name + } if agentName == "" { agentName = serviceName } - found = append(found, projectAgentService{ServiceName: serviceName, AgentName: agentName}) + found = append(found, projectAgentService{ + ServiceName: serviceName, + AgentName: agentName, + RelativePath: svc.GetRelativePath(), + }) } slices.SortFunc(found, func(a, b projectAgentService) int { return strings.Compare(a.ServiceName, b.ServiceName) }) - return found + return found, diagnostics } // positionalSourceOptsOutOfReuse reports whether a positional source directory @@ -92,7 +129,11 @@ func projectAgentServicesFrom(services map[string]*azdext.ServiceConfig) []proje // the current project and may reuse its configured agent. A positional // `./agents/new`, however, explicitly selects source for a new agent and must // not be silently ignored by reuse. -func positionalSourceOptsOutOfReuse(src, projectRoot string) bool { +func positionalSourceOptsOutOfReuse( + src string, + projectRoot string, + services []projectAgentService, +) bool { if src == "" { return false } @@ -118,7 +159,25 @@ func positionalSourceOptsOutOfReuse(src, projectRoot string) bool { return true } - return !os.SameFile(srcInfo, rootInfo) + if os.SameFile(srcInfo, rootInfo) { + return false + } + + for _, service := range services { + if service.RelativePath == "" { + continue + } + servicePath, err := paths.JoinAllowRoot(projectRoot, service.RelativePath) + if err != nil { + continue + } + serviceInfo, err := os.Stat(servicePath) + if err == nil && os.SameFile(srcInfo, serviceInfo) { + return false + } + } + + return true } // describeProjectAgentServices renders the detected agent services for a prompt @@ -174,7 +233,7 @@ func runReuseProjectAgentServices( fmt.Println(color.HiBlackString("Reusing the agent configuration already in this project.")) - state, _ := nextstep.AssembleState(ctx, azdClient) + state, _ := nextstep.AssembleState(ctx, azdClient, nextstep.WithEnvironment(env.Name)) _ = printAllNextIfTerminal(os.Stdout, nextstep.ResolveAfterInit(state, readmeExistsForProject(ctx, azdClient))) return nil diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go index 15e890c68da..0b74aca6412 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init_reuse_project_agent_test.go @@ -6,6 +6,7 @@ package cmd import ( "os" "path/filepath" + "runtime" "testing" "azureaiagent/internal/pkg/agents/agent_yaml" @@ -33,9 +34,10 @@ func TestProjectAgentServicesFrom(t *testing.T) { t.Parallel() tests := []struct { - name string - services map[string]*azdext.ServiceConfig - want []projectAgentService + name string + services map[string]*azdext.ServiceConfig + want []projectAgentService + wantErrCount int }{ { name: "inline agent definition on the service entry", @@ -52,11 +54,11 @@ func TestProjectAgentServicesFrom(t *testing.T) { want: []projectAgentService{{ServiceName: "chat", AgentName: "legacy-agent"}}, }, { - name: "falls back to the service key when the definition lives in agent.yaml", + name: "service without a definition is not reusable", services: map[string]*azdext.ServiceConfig{ "chat": {Name: "chat", Host: AiAgentHost}, }, - want: []projectAgentService{{ServiceName: "chat", AgentName: "chat"}}, + wantErrCount: 1, }, { name: "multiple agents are sorted by service name", @@ -88,21 +90,136 @@ func TestProjectAgentServicesFrom(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, tt.want, projectAgentServicesFrom(tt.services)) + got, errs := projectAgentServicesFrom(tt.services, t.TempDir()) + assert.Equal(t, tt.want, got) + assert.Len(t, errs, tt.wantErrCount) }) } } +func TestProjectAgentServicesFrom_DiskDefinition(t *testing.T) { + t.Parallel() + + projectRoot := t.TempDir() + serviceDir := filepath.Join(projectRoot, "src", "chat") + require.NoError(t, os.MkdirAll(serviceDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(serviceDir, "agent.yaml"), + []byte("kind: hosted\nname: disk-agent\nprotocols:\n"+ + " - protocol: responses\n version: \"1.0.0\"\n"), + 0o600, + )) + + services, errs := projectAgentServicesFrom(map[string]*azdext.ServiceConfig{ + "chat": { + Name: "chat", + Host: AiAgentHost, + RelativePath: "src/chat", + }, + }, projectRoot) + + require.Empty(t, errs) + assert.Equal(t, + []projectAgentService{{ + ServiceName: "chat", + AgentName: "disk-agent", + RelativePath: "src/chat", + }}, + services, + ) +} + +func TestProjectAgentServicesFrom_RejectsUnsafeServicePaths(t *testing.T) { + projectRoot := t.TempDir() + outsideDir := t.TempDir() + traversalPath, err := filepath.Rel(projectRoot, outsideDir) + require.NoError(t, err) + + tests := []struct { + name string + relativePath string + }{ + {name: "absolute path", relativePath: outsideDir}, + {name: "traversal path", relativePath: traversalPath}, + } + if runtime.GOOS != "windows" { + linkPath := filepath.Join(projectRoot, "linked-agent") + require.NoError(t, os.Symlink(outsideDir, linkPath)) + tests = append(tests, struct { + name string + relativePath string + }{name: "symlink escape", relativePath: "linked-agent"}) + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + svc := inlineAgentService(t, "chat", "inline-agent") + svc.RelativePath = tt.relativePath + + services, diagnostics := projectAgentServicesFrom( + map[string]*azdext.ServiceConfig{"chat": svc}, + projectRoot, + ) + assert.Empty(t, services) + require.Len(t, diagnostics, 1) + assert.Contains(t, diagnostics[0], "invalid project path") + }) + } +} + +// Project detection runs before the bare agent.yaml reuse path. A service that +// already owns an on-disk definition must therefore be recognized from a +// service subdirectory instead of being scaffolded as a second service. +func TestDetectProjectAgentServices_ConfiguredDiskDefinitionFromServiceDir(t *testing.T) { + projectRoot := t.TempDir() + serviceDir := filepath.Join(projectRoot, "src", "chat") + require.NoError(t, os.MkdirAll(serviceDir, 0o750)) + require.NoError(t, os.WriteFile( + filepath.Join(serviceDir, "agent.yaml"), + []byte("kind: hosted\nname: disk-agent\nprotocols:\n"+ + " - protocol: responses\n version: \"1.0.0\"\n"), + 0o600, + )) + t.Chdir(serviceDir) + + client := newHelpersTestAzdClient(t, + &helpersProjectServer{project: &azdext.ProjectConfig{ + Path: projectRoot, + Services: map[string]*azdext.ServiceConfig{ + "chat": { + Name: "chat", + Host: AiAgentHost, + RelativePath: "src/chat", + }, + }, + }}, + &helpersPromptServer{}, + ) + + detection := detectProjectAgentServices(t.Context(), client) + assert.Equal(t, projectRoot, detection.projectRoot) + assert.Equal(t, + []projectAgentService{{ + ServiceName: "chat", + AgentName: "disk-agent", + RelativePath: "src/chat", + }}, + detection.services, + ) + assert.False(t, positionalSourceOptsOutOfReuse(".", projectRoot, detection.services), + "a positional dot from the configured service directory must reuse the project service") +} + // Ordering must not depend on Go's randomized map iteration, so the same input // is evaluated repeatedly. func TestProjectAgentServicesFrom_OrderingIsStable(t *testing.T) { t.Parallel() services := map[string]*azdext.ServiceConfig{ - "delta": {Name: "delta", Host: AiAgentHost}, - "alpha": {Name: "alpha", Host: AiAgentHost}, + "delta": inlineAgentService(t, "delta", "delta"), + "alpha": inlineAgentService(t, "alpha", "alpha"), "charlie": inlineAgentService(t, "charlie", "c-agent"), - "bravo": {Name: "bravo", Host: AiAgentHost}, + "bravo": inlineAgentService(t, "bravo", "bravo"), } want := []projectAgentService{ {ServiceName: "alpha", AgentName: "alpha"}, @@ -110,9 +227,12 @@ func TestProjectAgentServicesFrom_OrderingIsStable(t *testing.T) { {ServiceName: "charlie", AgentName: "c-agent"}, {ServiceName: "delta", AgentName: "delta"}, } + projectRoot := t.TempDir() for range 20 { - assert.Equal(t, want, projectAgentServicesFrom(services)) + got, errs := projectAgentServicesFrom(services, projectRoot) + require.Empty(t, errs) + assert.Equal(t, want, got) } } @@ -123,10 +243,10 @@ func TestAgentDefiningFlagsSet(t *testing.T) { t.Parallel() tests := []struct { - name string - flags *initFlags - srcExplicit bool - want bool + name string + flags *initFlags + srcBlocksReuse bool + want bool }{ {name: "no flags", flags: &initFlags{}, want: false}, {name: "agent-name", flags: &initFlags{agentName: "my-agent"}, want: true}, @@ -144,10 +264,10 @@ func TestAgentDefiningFlagsSet(t *testing.T) { // out. A positional path is classified separately against the project // root after detection. { - name: "explicit --src", - flags: &initFlags{src: "agents/chat"}, - srcExplicit: true, - want: true, + name: "explicit --src", + flags: &initFlags{src: "agents/chat"}, + srcBlocksReuse: true, + want: true, }, { name: "src folded from a positional arg is not an explicit flag", @@ -165,7 +285,68 @@ func TestAgentDefiningFlagsSet(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, tt.want, agentDefiningFlagsSet(tt.flags, tt.srcExplicit)) + assert.Equal(t, tt.want, agentDefiningFlagsSet(tt.flags, tt.srcBlocksReuse)) + }) + } +} + +func TestCanReuseExistingAgentConfiguration(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flags *initFlags + manifestDeclined bool + srcBlocksReuse bool + want bool + }{ + {name: "no caller intent allows reuse", flags: &initFlags{}, want: true}, + { + name: "no-prompt alone allows reuse", + flags: &initFlags{noPrompt: true}, + want: true, + }, + { + name: "agent-defining flags block reuse under no-prompt", + flags: &initFlags{ + noPrompt: true, + deployMode: "code", + runtime: "python_3_13", + entryPoint: "app.py", + }, + }, + { + name: "manifest pointer blocks reuse", + flags: &initFlags{manifestPointer: "agent.manifest.yaml"}, + }, + { + name: "declined manifest blocks rediscovery", + flags: &initFlags{}, + manifestDeclined: true, + }, + { + name: "explicit source blocks reuse", + flags: &initFlags{src: "src/new"}, + srcBlocksReuse: true, + }, + { + name: "explicit source is valid for bare-definition reuse", + flags: &initFlags{ + src: "src/existing", + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.want, canReuseExistingAgentConfiguration( + tt.flags, + tt.manifestDeclined, + tt.srcBlocksReuse, + )) }) } } @@ -193,13 +374,19 @@ func TestPositionalSourceOptsOutOfReuse(t *testing.T) { projectRoot := t.TempDir() agentDir := filepath.Join(projectRoot, "agents", "new") + otherDir := filepath.Join(projectRoot, "agents", "other") + outsideDir := t.TempDir() require.NoError(t, os.MkdirAll(agentDir, 0o750)) + require.NoError(t, os.MkdirAll(otherDir, 0o750)) + traversalPath, err := filepath.Rel(projectRoot, outsideDir) + require.NoError(t, err) tests := []struct { - name string - src string - projectRoot string - want bool + name string + src string + projectRoot string + serviceRelativePath string + want bool }{ { name: "project root keeps reuse", @@ -208,22 +395,46 @@ func TestPositionalSourceOptsOutOfReuse(t *testing.T) { want: false, }, { - name: "selected agent directory opts out", - src: agentDir, - projectRoot: projectRoot, - want: true, + name: "configured service directory keeps reuse", + src: agentDir, + projectRoot: projectRoot, + serviceRelativePath: "agents/new", + want: false, }, { - name: "no positional source keeps reuse", - src: "", - projectRoot: projectRoot, - want: false, + name: "unconfigured agent directory opts out", + src: otherDir, + projectRoot: projectRoot, + serviceRelativePath: "agents/new", + want: true, }, { - name: "missing project root opts out conservatively", - src: projectRoot, - projectRoot: "", - want: true, + name: "no positional source keeps reuse", + src: "", + projectRoot: projectRoot, + serviceRelativePath: "agents/new", + want: false, + }, + { + name: "missing project root opts out conservatively", + src: projectRoot, + projectRoot: "", + serviceRelativePath: "agents/new", + want: true, + }, + { + name: "absolute service path cannot authorize reuse", + src: outsideDir, + projectRoot: projectRoot, + serviceRelativePath: outsideDir, + want: true, + }, + { + name: "traversal service path cannot authorize reuse", + src: outsideDir, + projectRoot: projectRoot, + serviceRelativePath: traversalPath, + want: true, }, } @@ -231,11 +442,38 @@ func TestPositionalSourceOptsOutOfReuse(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - assert.Equal(t, tt.want, positionalSourceOptsOutOfReuse(tt.src, tt.projectRoot)) + services := []projectAgentService{{ + ServiceName: "new", + RelativePath: tt.serviceRelativePath, + }} + assert.Equal(t, tt.want, positionalSourceOptsOutOfReuse( + tt.src, + tt.projectRoot, + services, + )) }) } } +func TestPositionalSourceOptsOutOfReuse_RejectsSymlinkEscape(t *testing.T) { + t.Parallel() + if runtime.GOOS == "windows" { + t.Skip("creating symlinks requires elevated privileges on some Windows hosts") + } + + projectRoot := t.TempDir() + outsideDir := t.TempDir() + linkPath := filepath.Join(projectRoot, "linked-agent") + require.NoError(t, os.Symlink(outsideDir, linkPath)) + + services := []projectAgentService{{ + ServiceName: "linked", + RelativePath: "linked-agent", + }} + assert.True(t, positionalSourceOptsOutOfReuse(linkPath, projectRoot, services), + "a service path that resolves outside the project must not authorize reuse") +} + func TestDescribeProjectAgentServices(t *testing.T) { t.Parallel() diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go index 361bdcb9c6a..3fbebc14c5d 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver.go @@ -294,9 +294,25 @@ func ResolveAfterInit(state *State, readmeExists func(relativePath string) bool) Trailing: true, }) + if state.EnvironmentName != "" { + for i := range out { + out[i].Command = qualifyCommandEnvironment(out[i].Command, state.EnvironmentName) + } + } + return out } +// qualifyCommandEnvironment targets an azd command at an explicitly selected +// environment. Non-azd guidance (for example cd/edit/see commands) is returned +// unchanged. +func qualifyCommandEnvironment(command, environmentName string) string { + if environmentName == "" || !strings.HasPrefix(command, "azd ") { + return command + } + return fmt.Sprintf("azd --environment %q %s", environmentName, strings.TrimPrefix(command, "azd ")) +} + // runFollowUpDescription picks the description for the // `azd ai agent run` follow-up emitted after the toolbox / manual-vars // branch, so the suffix reflects which categories of work the user diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go index 0eb89375e65..63aa46863fc 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/resolver_test.go @@ -214,6 +214,24 @@ func TestResolveAfterInit_MissingAzureContextVarsPrecedeProvision(t *testing.T) assert.True(t, out[3].Trailing, "deploy footer must be Trailing") } +func TestResolveAfterInit_QualifiesExplicitEnvironment(t *testing.T) { + t.Parallel() + + state := &State{ + EnvironmentName: "prod west", + PendingProvisionReasons: []string{"project"}, + MissingAzureContextVars: []string{"AZURE_SUBSCRIPTION_ID"}, + } + out := ResolveAfterInit(state, nil) + require.Len(t, out, 3) + assert.Equal(t, + `azd --environment "prod west" env set AZURE_SUBSCRIPTION_ID `, + out[0].Command, + ) + assert.Equal(t, `azd --environment "prod west" provision`, out[1].Command) + assert.Equal(t, `azd --environment "prod west" deploy`, out[2].Command) +} + func TestResolveAfterInit_NilState(t *testing.T) { t.Parallel() assert.Nil(t, ResolveAfterInit(nil, nil)) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go index 0f2739ff1a5..e651b86c008 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state.go @@ -152,6 +152,10 @@ func (s *clientSource) EnvValue(ctx context.Context, envName, key string) (strin type Option func(*config) type config struct { + // environmentName, when non-empty, selects the environment to inspect + // without changing azd's active environment. + environmentName string + // openAPIAgent and openAPISuffix together enable a cache-only OpenAPI // payload lookup. The zero value (empty strings) disables the probe. openAPIAgent string @@ -171,6 +175,12 @@ type config struct { createdFolderDisplay string } +// WithEnvironment selects the azd environment used to assemble next-step +// state. It does not change the project's active environment. +func WithEnvironment(name string) Option { + return func(c *config) { c.environmentName = name } +} + // WithOpenAPIProbe enables a cache-only OpenAPI lookup for (agentName, suffix). // Empty inputs disable the probe; misses or malformed specs leave HasOpenAPI // false. Combine with WithLiveOpenAPIProbe to prefer a fresh in-process fetch. @@ -241,11 +251,16 @@ func assembleState(ctx context.Context, src Source, opts ...Option) (*State, []e state := &State{} state.CreatedFolderDisplay = cfg.createdFolderDisplay + state.EnvironmentName = cfg.environmentName var errs []error - envName, err := src.CurrentEnvName(ctx) - if err != nil { - errs = append(errs, fmt.Errorf("read current environment: %w", err)) + envName := cfg.environmentName + if envName == "" { + var err error + envName, err = src.CurrentEnvName(ctx) + if err != nil { + errs = append(errs, fmt.Errorf("read current environment: %w", err)) + } } if envName != "" { diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go index e118f83a8bd..97c804d4791 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/state_test.go @@ -250,6 +250,27 @@ func TestAssembleState(t *testing.T) { } } +func TestAssembleState_WithEnvironmentDoesNotReadCurrent(t *testing.T) { + t.Parallel() + + src := &fakeSource{ + envName: "dev", + envNameErr: errors.New("current environment should not be read"), + project: &azdext.ProjectConfig{Name: "demo"}, + values: map[string]string{ + "prod/FOUNDRY_PROJECT_ENDPOINT": "https://x.services.ai.azure.com", + "prod/AZURE_SUBSCRIPTION_ID": "sub-id", + "prod/AZURE_LOCATION": "eastus", + }, + } + + state, errs := assembleState(t.Context(), src, WithEnvironment("prod")) + require.Empty(t, errs) + assert.Equal(t, "prod", state.EnvironmentName) + assert.True(t, state.HasProjectEndpoint) + assert.Empty(t, state.MissingAzureContextVars) +} + func TestAssembleState_NilServiceEntriesAreIgnored(t *testing.T) { t.Parallel() @@ -296,8 +317,10 @@ func TestOptionsApplyCleanly(t *testing.T) { t.Parallel() cfg := &config{} + WithEnvironment("prod")(cfg) WithOpenAPIProbe("echo", "local")(cfg) WithLiveOpenAPIProbe(func(context.Context) ([]byte, error) { return nil, nil })(cfg) + assert.Equal(t, "prod", cfg.environmentName) assert.Equal(t, "echo", cfg.openAPIAgent) assert.Equal(t, "local", cfg.openAPISuffix) assert.NotNil(t, cfg.openAPILiveFetch) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/types.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/types.go index 0eac6c2d0dc..714f06e9661 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/types.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/nextstep/types.go @@ -46,6 +46,11 @@ type Suggestion struct { // marked optional below are populated only by the resolver paths that // need them — see field docs. type State struct { + // EnvironmentName is the explicitly selected azd environment that emitted + // commands must target. It stays empty when assembly uses the current + // environment, preserving the existing unqualified command shape. + EnvironmentName string + // HasProjectEndpoint reports whether FOUNDRY_PROJECT_ENDPOINT is set // (and non-empty) in the active azd environment. HasProjectEndpoint bool