Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package cmd

import "math"

// boundedInt32Index converts a slice index into the int32 form expected by the
// azdext Select API (SelectedIndex / *resp.Value).
//
// All callers in this package index into small, statically-bounded slices
// (enum values, resource tiers, curated template lists, model name lists)
// where overflow is impossible. Routing every such conversion through this
// one helper keeps the gosec G115 suppression in a single place and makes
// the safety argument explicit:
//
// - Indices are non-negative (returned by range over a slice).
// - Slice lengths are bounded well below math.MaxInt32 at every call site.
//
// The defensive clamp below means a future caller that violates the
// invariant returns the safe default (index 0 = first option) instead of
// panicking or wrapping.
func boundedInt32Index(i int) int32 {
if i < 0 || i > math.MaxInt32 {
return 0
}
return int32(i) //nolint:gosec // G115: bounds enforced by the check above
}
52 changes: 40 additions & 12 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ type initFlags struct {
src string
env string
protocols []string
// force, when true, lets headless callers (--no-prompt) pre-consent to
// overwrite prompts that would otherwise return a structured error. It
// mirrors the `--force` convention used by `azd down`, `azd env remove`,
// `azd config reset`, and `azd infra generate`.
force bool
// noPrompt is resolved from the extension context (--no-prompt / AZD_NO_PROMPT)
// and is not registered as a CLI flag on the init command itself.
noPrompt bool
Expand Down Expand Up @@ -833,6 +838,10 @@ a reusable sample or manifest.`,
cmd.Flags().StringSliceVar(&flags.protocols, "protocol", nil,
"Protocols supported by the agent (e.g., 'responses', 'invocations'). Can be specified multiple times.")

cmd.Flags().BoolVar(&flags.force, "force", false,
"Overwrite an input manifest that already lives inside the generated src tree without prompting. "+
"Required together with --no-prompt when init would otherwise need confirmation.")

return cmd
}

Expand Down Expand Up @@ -1582,17 +1591,36 @@ func (a *InitAction) downloadAgentYaml(

// Check if manifest is under src directory
if isSubpath(absManifestPath, srcDir) {
confirmResponse, err := a.azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{
Options: &azdext.ConfirmOptions{
Message: "This operation will overwrite the provided manifest file. Continue?",
DefaultValue: new(false),
},
})
if err != nil {
return nil, "", fmt.Errorf("prompting for confirmation: %w", err)
}
if !*confirmResponse.Value {
return nil, "", exterrors.Cancelled("operation cancelled by user")
// `--force` is the explicit pre-consent path for headless
// callers: skip both the prompt and the no-prompt refusal,
// and accept the overwrite. We log the decision so debug
// output makes the choice visible in CI logs.
if a.flags.force {
log.Printf("--force: overwriting manifest %q inside src directory %q", manifestPointer, srcDir)
} else if a.flags.noPrompt {
// In no-prompt mode, refuse to silently overwrite a manifest
// that lives inside the project's src tree. Headless callers
// can pass --force to pre-consent, move the manifest, choose
// a different --src target, or run interactively to confirm.
return nil, "", exterrors.Validation(
exterrors.CodeInvalidManifestPointer,
fmt.Sprintf("manifest %q is inside the project src directory %q and would be overwritten", manifestPointer, srcDir),
"pass --force to overwrite, move the manifest outside the src tree, "+
"pass a different --src directory, or run interactively to confirm the overwrite",
)
} else {
confirmResponse, err := a.azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{
Options: &azdext.ConfirmOptions{
Message: "This operation will overwrite the provided manifest file. Continue?",
DefaultValue: new(false),
},
})
if err != nil {
return nil, "", fmt.Errorf("prompting for confirmation: %w", err)
}
if !*confirmResponse.Value {
return nil, "", exterrors.Cancelled("operation cancelled by user")
}
}
}
}
Expand Down Expand Up @@ -2240,7 +2268,7 @@ func (a *InitAction) populateContainerSettings(
if manifestResources != nil {
for i, t := range project.ResourceTiers {
if t.Cpu == manifestResources.Cpu && t.Memory == manifestResources.Memory {
defaultIndex = int32(i)
defaultIndex = boundedInt32Index(i)
break
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,11 @@ func loadAzureContext(
// ensureSubscription prompts for a subscription if not already set in the AzureContext.
// If a subscription is already set, looks up the tenant for it. Returns the (possibly refreshed)
// credential scoped to the resolved tenant. Both init flows use this.
//
// When the prompt fails (e.g. running under `--no-prompt` without
// `AZURE_SUBSCRIPTION_ID` set), the error is wrapped with a structured
// suggestion naming the env var to set so headless callers get an actionable
// message instead of a generic "prompt required".
func ensureSubscription(
ctx context.Context,
azdClient *azdext.AzdClient,
Expand All @@ -691,7 +696,24 @@ func ensureSubscription(
if exterrors.IsCancellation(err) {
return nil, exterrors.Cancelled("subscription selection was cancelled")
}
return nil, exterrors.FromPrompt(err, "failed to prompt for subscription")
// Only attach the AZURE_SUBSCRIPTION_ID-specific guidance when the
// failure is the no-prompt / "prompt required" path. Other prompt
// failures (e.g. host transport errors) get a generic message so we
// don't mislead the user into setting an env var that wouldn't have
// helped.
if exterrors.IsPromptRequired(err) {
return nil, exterrors.Dependency(
exterrors.CodeMissingAzureSubscription,
fmt.Sprintf("failed to select an Azure subscription: %s", err),
"set AZURE_SUBSCRIPTION_ID in your azd environment "+
"(run `azd env set AZURE_SUBSCRIPTION_ID <id>`), or run interactively to pick one",
)
}
return nil, exterrors.Dependency(
exterrors.CodeMissingAzureSubscription,
fmt.Sprintf("failed to select an Azure subscription: %s", err),
"retry, or run interactively to pick one",
)
Comment thread
therealjohn marked this conversation as resolved.
}

azureContext.Scope.SubscriptionId = subscriptionResponse.Subscription.Id
Expand Down Expand Up @@ -820,7 +842,15 @@ func promptLocationForInit(
if exterrors.IsCancellation(err) {
return "", exterrors.Cancelled("location selection was cancelled")
}
return "", exterrors.FromPrompt(err, "failed to prompt for location")
// Wrap with an actionable suggestion so headless callers (--no-prompt)
// learn how to supply the value instead of getting a generic
// "prompt required" propagated from the azd host.
return "", exterrors.Dependency(
exterrors.CodeLocationMismatch,
fmt.Sprintf("failed to select an Azure location: %s", err),
"set AZURE_LOCATION in your azd environment "+
"(`azd env set AZURE_LOCATION <region>`) or run interactively to pick one",
)
}

return locationResponse.Location.Name, nil
Expand Down Expand Up @@ -871,7 +901,15 @@ func selectNewModel(

modelResp, err := azdClient.Prompt().PromptAiModel(ctx, promptReq)
if err != nil {
return nil, exterrors.FromPrompt(err, "failed to prompt for model selection")
if exterrors.IsCancellation(err) {
return nil, exterrors.Cancelled("model selection was cancelled")
}
return nil, exterrors.Dependency(
exterrors.CodeModelResolutionFailed,
fmt.Sprintf("failed to select an AI model: %s", err),
"pass --model <name> (e.g. --model gpt-4.1-mini) or --project-id "+
"<id> with --model-deployment <name> to skip interactive model selection",
)
}

return modelResp.Model, nil
Expand Down Expand Up @@ -1090,7 +1128,12 @@ func selectFoundryProject(
if exterrors.IsCancellation(err) {
return nil, exterrors.Cancelled("project selection was cancelled")
}
return nil, fmt.Errorf("failed to prompt for project selection: %w", err)
return nil, exterrors.Dependency(
exterrors.CodeMissingAiProjectId,
fmt.Sprintf("failed to select a Foundry project: %s", err),
"pass --project-id <full resource id> to skip interactive project selection, "+
"or run interactively to choose from the discovered projects",
)
}

selectedIdx = *projectResp.Value
Expand Down
65 changes: 40 additions & 25 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init_from_code.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,11 @@ func (a *InitFromCodeAction) Run(ctx context.Context) error {
agentYamlPath := filepath.Join(srcDir, "agent.yaml")
if _, statErr := os.Stat(agentYamlPath); statErr == nil {
if a.flags.noPrompt {
return exterrors.Cancelled("agent.yaml already exists; overwrite declined in no-prompt mode")
return exterrors.Validation(
exterrors.CodeInvalidAgentManifest,
fmt.Sprintf("agent.yaml already exists at %q", agentYamlPath),
"delete or move the existing agent.yaml, or run interactively to confirm overwrite",
)
}

confirmResp, err := a.azdClient.Prompt().Confirm(ctx, &azdext.ConfirmRequest{
Expand Down Expand Up @@ -317,30 +321,41 @@ func (a *InitFromCodeAction) scaffoldTemplate(ctx context.Context, azdClient *az
fmt.Printf("%s %d file(s) already exist and would be overwritten.\n\n",
color.YellowString("Warning:"), len(collidingFiles))

conflictChoices := []*azdext.SelectChoice{
{Label: "Overwrite existing files", Value: "overwrite"},
{Label: "Skip existing files (keep my versions)", Value: "skip"},
{Label: "Cancel", Value: "cancel"},
}
// In no-prompt mode, default to the safest behavior: keep the user's
// existing files (skip the collisions). The user can re-run
// interactively or delete the files themselves if they want the
// template versions.
if a.flags.noPrompt {
// Diagnostic on stderr so scripted consumers can keep stdout
// clean for the regular init output stream.
fmt.Fprintln(os.Stderr, "--no-prompt: keeping existing files; template versions are skipped.")
overwriteCollisions = false
} else {
Comment thread
therealjohn marked this conversation as resolved.
conflictChoices := []*azdext.SelectChoice{
{Label: "Overwrite existing files", Value: "overwrite"},
{Label: "Skip existing files (keep my versions)", Value: "skip"},
{Label: "Cancel", Value: "cancel"},
}

conflictResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{
Options: &azdext.SelectOptions{
Message: "How would you like to handle existing files?",
Choices: conflictChoices,
},
})
if err != nil {
return fmt.Errorf("prompting for conflict resolution: %w", err)
}
conflictResp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{
Options: &azdext.SelectOptions{
Message: "How would you like to handle existing files?",
Choices: conflictChoices,
},
})
if err != nil {
return fmt.Errorf("prompting for conflict resolution: %w", err)
}

selectedValue := conflictChoices[*conflictResp.Value].Value
switch selectedValue {
case "overwrite":
overwriteCollisions = true
case "skip":
overwriteCollisions = false
case "cancel":
return fmt.Errorf("operation cancelled, no changes were made")
selectedValue := conflictChoices[*conflictResp.Value].Value
switch selectedValue {
case "overwrite":
overwriteCollisions = true
case "skip":
overwriteCollisions = false
case "cancel":
return fmt.Errorf("operation cancelled, no changes were made")
}
}
} else {
// No collisions - confirm to proceed
Expand Down Expand Up @@ -784,13 +799,13 @@ func findDefaultModelIndex(modelNames []string) int32 {
// Look for exact gpt-4o first
for i, name := range modelNames {
if name == "gpt-4o" {
return int32(i)
return boundedInt32Index(i)
}
}
// Fall back to first gpt-4 match
for i, name := range modelNames {
if strings.HasPrefix(name, "gpt-4") {
return int32(i)
return boundedInt32Index(i)
}
}
return 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,8 @@ func promptAgentTemplate(
return nil, exterrors.Validation(
exterrors.CodePromptFailed,
"template selection requires interactive mode",
"use 'azd ai agent init -m <manifest>' to initialize from a template non-interactively",
"run 'azd ai agent sample list --output json' to discover available templates, "+
"then rerun 'azd ai agent init -m <manifestUrl>' (or 'azd init -t <repoUrl>' for full template repos)",
)
}

Expand Down Expand Up @@ -349,7 +350,7 @@ func partitionFeatured(templates []AgentTemplate) (featured, rest []AgentTemplat
func findRecommendedIndex(templates []AgentTemplate) int32 {
for i, t := range templates {
if t.isRecommended() {
return int32(i) //nolint:gosec // template list length is always small
return boundedInt32Index(i)
}
}
return 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,7 @@ func (a *InitAction) selectFromList(
Label: val,
}
if val == defaultStr {
defaultIndex = int32(i)
defaultIndex = boundedInt32Index(i)
}
}
resp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{
Expand Down
60 changes: 60 additions & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,21 @@ func TestInitCommand_AgentNameFlag(t *testing.T) {
}
}

func TestInitCommand_ForceFlag(t *testing.T) {
cmd := newInitCommand(nil)

flag := cmd.Flags().Lookup("force")
if flag == nil {
t.Fatal("expected --force flag to be registered")
}
if flag.Shorthand != "" {
t.Fatalf("expected --force to have no shorthand (matches azd `infra generate --force`), got %q", flag.Shorthand)
}
if flag.DefValue != "false" {
t.Fatalf("expected --force default false, got %q", flag.DefValue)
}
}

func TestValidateInitAgentName(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -2194,3 +2209,48 @@ func TestParseAuthStatusJSON(t *testing.T) {
})
}
}

// TestDownloadAgentYaml_NoPromptManifestInSrcWithoutForce verifies that a
// headless caller pointing --manifest at a file inside `<cwd>/src/<name>` is
// refused with a structured error whose suggestion explicitly mentions
// `--force` as the pre-consent escape hatch.
//
// The InitAction is constructed with a nil azdClient because the validation
// branch returns before any prompt is invoked. The manifest is parsed (so it
// must contain a name field) but no downstream container / GitHub paths are
// reached.
func TestDownloadAgentYaml_NoPromptManifestInSrcWithoutForce(t *testing.T) {
// Not Parallel: changes process working directory.
tmp := t.TempDir()
t.Chdir(tmp)

name := "echo-agent"
srcDir := filepath.Join(tmp, "src", name)
//nolint:gosec // test fixture directory permissions are intentional
if err := os.MkdirAll(srcDir, 0755); err != nil {
t.Fatalf("mkdir src: %v", err)
}
manifestPath := filepath.Join(srcDir, "agent.yaml")
//nolint:gosec // test fixture file permissions are intentional
if err := os.WriteFile(manifestPath, []byte("name: "+name+"\n"), 0644); err != nil {
t.Fatalf("write manifest: %v", err)
}

a := &InitAction{flags: &initFlags{noPrompt: true, force: false}}

_, _, err := a.downloadAgentYaml(t.Context(), manifestPath, "")
if err == nil {
t.Fatal("expected error for no-prompt without --force")
}

localErr, ok := errors.AsType[*azdext.LocalError](err)
if !ok {
t.Fatalf("expected *azdext.LocalError, got %T: %v", err, err)
}
if localErr.Code != exterrors.CodeInvalidManifestPointer {
t.Errorf("expected code %q, got %q", exterrors.CodeInvalidManifestPointer, localErr.Code)
}
if !strings.Contains(localErr.Suggestion, "--force") {
t.Errorf("suggestion should mention --force, got: %s", localErr.Suggestion)
}
}
1 change: 1 addition & 0 deletions cli/azd/extensions/azure.ai.agents/internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ func NewRootCommand() *cobra.Command {
rootCmd.AddCommand(newFilesCommand(extCtx))
rootCmd.AddCommand(newSessionCommand(extCtx))
rootCmd.AddCommand(newProjectCommand(extCtx))
rootCmd.AddCommand(newSampleCommand(extCtx))

// Connection commands — in separate package for easy lift-and-shift later.
// When the azd core namespace change lands, move this AddCommand call
Expand Down
Loading
Loading