diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index 3b2982a5252..7853531bc2a 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -11,6 +11,7 @@ import ( "log" "os" "slices" + "strconv" "strings" "github.com/azure/azure-dev/cli/azd/internal" @@ -617,6 +618,11 @@ func CreateGlobalFlagSet() *pflag.FlagSet { "no-prompt", false, "Accepts the default value instead of prompting, or it fails if there is no default.") + globalFlags.Bool( + "non-interactive", + false, + "Alias for --no-prompt.") + _ = globalFlags.MarkHidden("non-interactive") // The telemetry system is responsible for reading these flags value and using it to configure the telemetry // system, but we still need to add it to our flag set so that when we parse the command line with Cobra we @@ -666,15 +672,42 @@ func ParseGlobalFlags(args []string, opts *internal.GlobalCommandOptions) error opts.EnableDebugLogging = boolVal } - if boolVal, err := globalFlagSet.GetBool("no-prompt"); err == nil { - opts.NoPrompt = boolVal - } + // --non-interactive is an alias for --no-prompt; either flag sets NoPrompt. + // When both are present, true wins (either flag opting in is sufficient). + noPromptVal, _ := globalFlagSet.GetBool("no-prompt") + nonInteractiveVal, _ := globalFlagSet.GetBool("non-interactive") + opts.NoPrompt = noPromptVal || nonInteractiveVal - // Agent Detection: If --no-prompt was not explicitly set and we detect an AI coding agent - // as the caller, automatically enable no-prompt mode for non-interactive execution. + // Check if either flag was explicitly provided on the command line noPromptFlag := globalFlagSet.Lookup("no-prompt") - noPromptExplicitlySet := noPromptFlag != nil && noPromptFlag.Changed - if !noPromptExplicitlySet && agentdetect.IsRunningInAgent() { + nonInteractiveFlag := globalFlagSet.Lookup("non-interactive") + flagExplicitlySet := (noPromptFlag != nil && noPromptFlag.Changed) || + (nonInteractiveFlag != nil && nonInteractiveFlag.Changed) + + // Environment variable: AZD_NON_INTERACTIVE enables no-prompt mode when set to a + // truthy value (parsed via strconv.ParseBool: "true", "1", "TRUE", etc.). + // Explicit flags take precedence over this env var. + // When this env var is present (regardless of value), it also suppresses + // agent auto-detection since the user has made an explicit choice. + envVarPresent := false + if !flagExplicitlySet { + if envVal, ok := os.LookupEnv("AZD_NON_INTERACTIVE"); ok { + envVarPresent = true + if parsed, err := strconv.ParseBool(envVal); err == nil && parsed { + opts.NoPrompt = true + } else if err != nil { + log.Printf( + "warning: AZD_NON_INTERACTIVE=%q is not a valid boolean"+ + " (expected true/false/1/0), ignoring", + envVal, + ) + } + } + } + + // Agent Detection: If no explicit flag or env var was set and we detect an AI coding + // agent as the caller, automatically enable no-prompt mode for non-interactive execution. + if !flagExplicitlySet && !envVarPresent && agentdetect.IsRunningInAgent() { opts.NoPrompt = true } diff --git a/cli/azd/cmd/auto_install_integration_test.go b/cli/azd/cmd/auto_install_integration_test.go index a8fe0cae325..c2a01dba71a 100644 --- a/cli/azd/cmd/auto_install_integration_test.go +++ b/cli/azd/cmd/auto_install_integration_test.go @@ -247,6 +247,8 @@ func clearAgentEnvVarsForTest(t *testing.T) { "GEMINI_CLI", "GEMINI_CLI_NO_RELAUNCH", // OpenCode "OPENCODE", + // Non-interactive env var + "AZD_NON_INTERACTIVE", // User agent internal.AzdUserAgentEnvVar, } diff --git a/cli/azd/cmd/auto_install_test.go b/cli/azd/cmd/auto_install_test.go index e3295de9e84..5ff9be17269 100644 --- a/cli/azd/cmd/auto_install_test.go +++ b/cli/azd/cmd/auto_install_test.go @@ -421,3 +421,143 @@ func TestParseGlobalFlags_AgentDetection(t *testing.T) { }) } } + +func TestParseGlobalFlags_NonInteractiveAliasAndEnvVar(t *testing.T) { + tests := []struct { + name string + args []string + envKey string + envVal string + wantNoPrompt bool + }{ + { + name: "no flags or env", + args: []string{}, + wantNoPrompt: false, + }, + { + name: "--no-prompt sets NoPrompt", + args: []string{"--no-prompt"}, + wantNoPrompt: true, + }, + { + name: "--non-interactive sets NoPrompt", + args: []string{"--non-interactive"}, + wantNoPrompt: true, + }, + { + name: "--no-prompt=false keeps NoPrompt false", + args: []string{"--no-prompt=false"}, + wantNoPrompt: false, + }, + { + name: "AZD_NON_INTERACTIVE=true sets NoPrompt", + args: []string{}, + envKey: "AZD_NON_INTERACTIVE", + envVal: "true", + wantNoPrompt: true, + }, + { + name: "AZD_NON_INTERACTIVE=1 sets NoPrompt", + args: []string{}, + envKey: "AZD_NON_INTERACTIVE", + envVal: "1", + wantNoPrompt: true, + }, + { + name: "AZD_NON_INTERACTIVE=false does not set NoPrompt", + args: []string{}, + envKey: "AZD_NON_INTERACTIVE", + envVal: "false", + wantNoPrompt: false, + }, + { + name: "AZD_NON_INTERACTIVE=0 does not set NoPrompt", + args: []string{}, + envKey: "AZD_NON_INTERACTIVE", + envVal: "0", + wantNoPrompt: false, + }, + { + name: "explicit --no-prompt=false overrides env true", + args: []string{"--no-prompt=false"}, + envKey: "AZD_NON_INTERACTIVE", + envVal: "true", + wantNoPrompt: false, + }, + { + name: "explicit --no-prompt overrides env false", + args: []string{"--no-prompt"}, + envKey: "AZD_NON_INTERACTIVE", + envVal: "false", + wantNoPrompt: true, + }, + { + name: "--non-interactive overrides env false", + args: []string{"--non-interactive"}, + envKey: "AZD_NON_INTERACTIVE", + envVal: "false", + wantNoPrompt: true, + }, + { + name: "AZD_NON_INTERACTIVE=TRUE (uppercase)", + args: []string{}, + envKey: "AZD_NON_INTERACTIVE", + envVal: "TRUE", + wantNoPrompt: true, + }, + { + name: "both flags coexist", + args: []string{"--no-prompt", "--non-interactive"}, + wantNoPrompt: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear agent detection and AZD_NON_INTERACTIVE to isolate + // from the ambient environment. + clearAgentEnvVarsForTest(t) + agentdetect.ResetDetection() + + // Skip if we're inside an agent and expect false + if !tt.wantNoPrompt && tt.envKey == "" && len(tt.args) == 0 { + if agentdetect.GetCallingAgent().Detected { + t.Skip("skipping: agent process detected") + } + agentdetect.ResetDetection() + } + + if tt.envKey != "" { + t.Setenv(tt.envKey, tt.envVal) + } + + opts := &internal.GlobalCommandOptions{} + err := ParseGlobalFlags(tt.args, opts) + require.NoError(t, err) + assert.Equal(t, tt.wantNoPrompt, opts.NoPrompt) + + agentdetect.ResetDetection() + }) + } + + // Standalone test: prove that AZD_NON_INTERACTIVE presence suppresses agent detection. + // CLAUDE_CODE=1 would normally trigger NoPrompt via agent detection, but + // AZD_NON_INTERACTIVE=false should suppress agent detection entirely. + t.Run("AZD_NON_INTERACTIVE=false suppresses agent detection with CLAUDE_CODE set", func(t *testing.T) { + clearAgentEnvVarsForTest(t) + agentdetect.ResetDetection() + + t.Setenv("CLAUDE_CODE", "1") + t.Setenv("AZD_NON_INTERACTIVE", "false") + agentdetect.ResetDetection() + + opts := &internal.GlobalCommandOptions{} + err := ParseGlobalFlags([]string{}, opts) + require.NoError(t, err) + assert.False(t, opts.NoPrompt, + "AZD_NON_INTERACTIVE=false should suppress agent detection from setting NoPrompt") + + agentdetect.ResetDetection() + }) +} diff --git a/cli/azd/internal/global_command_options.go b/cli/azd/internal/global_command_options.go index 742fe421490..53b5f47f2a7 100644 --- a/cli/azd/internal/global_command_options.go +++ b/cli/azd/internal/global_command_options.go @@ -14,8 +14,15 @@ type GlobalCommandOptions struct { // launched tools. It's enabled with `--debug`, for any command. EnableDebugLogging bool - // when true, interactive prompts should behave as if the user selected the default value. - // if there is no default value the prompt returns an error. + // NoPrompt controls non-interactive mode. When true, interactive prompts should behave as + // if the user selected the default value. If there is no default value the prompt returns + // an error. + // + // Can be enabled via: + // - --no-prompt flag + // - --non-interactive flag (alias for --no-prompt) + // - AZD_NON_INTERACTIVE=true environment variable + // - Automatic agent detection (lowest priority) NoPrompt bool // EnableTelemetry indicates if telemetry should be sent. diff --git a/cli/azd/pkg/azdext/mcp_security_test.go b/cli/azd/pkg/azdext/mcp_security_test.go index d4d568c41dc..1b2934e8551 100644 --- a/cli/azd/pkg/azdext/mcp_security_test.go +++ b/cli/azd/pkg/azdext/mcp_security_test.go @@ -12,6 +12,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/stretchr/testify/require" ) func TestMCPSecurityCheckURL_BlocksMetadataEndpoints(t *testing.T) { @@ -318,25 +320,12 @@ func TestMCPSecurityFluentBuilder(t *testing.T) { RedactHeaders("Authorization"). ValidatePathsWithinBase("/tmp") - if policy == nil { - t.Fatal("fluent builder should return non-nil policy") - } - - if !policy.blockMetadata { - t.Error("blockMetadata should be true") - } - if !policy.blockPrivate { - t.Error("blockPrivate should be true") - } - if !policy.requireHTTPS { - t.Error("requireHTTPS should be true") - } - if !policy.IsHeaderBlocked("Authorization") { - t.Error("Authorization should be blocked") - } - if len(policy.allowedBasePaths) != 1 { - t.Errorf("expected 1 base path, got %d", len(policy.allowedBasePaths)) - } + require.NotNil(t, policy, "fluent builder should return non-nil policy") + require.True(t, policy.blockMetadata, "blockMetadata should be true") + require.True(t, policy.blockPrivate, "blockPrivate should be true") + require.True(t, policy.requireHTTPS, "requireHTTPS should be true") + require.True(t, policy.IsHeaderBlocked("Authorization"), "Authorization should be blocked") + require.Len(t, policy.allowedBasePaths, 1, "expected 1 base path") } func TestSSRFSafeRedirect_SchemeDowngrade(t *testing.T) { diff --git a/cli/azd/pkg/ux/ux_additional_test.go b/cli/azd/pkg/ux/ux_additional_test.go index b77e390729c..e81ae449b6a 100644 --- a/cli/azd/pkg/ux/ux_additional_test.go +++ b/cli/azd/pkg/ux/ux_additional_test.go @@ -40,16 +40,19 @@ func TestConsoleWidth_empty_COLUMNS_uses_default(t *testing.T) { func TestPtr(t *testing.T) { intVal := 42 p := Ptr(intVal) - if p == nil { + switch { + case p == nil: t.Fatal("Ptr should return non-nil pointer") - } - if *p != 42 { + case *p != 42: t.Fatalf("*Ptr(42) = %d, want 42", *p) } strVal := "hello" sp := Ptr(strVal) - if *sp != "hello" { + switch { + case sp == nil: + t.Fatal("Ptr should return non-nil pointer for string") + case *sp != "hello": t.Fatalf("*Ptr(hello) = %q, want hello", *sp) } }