diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index ee7c11f59d6..49d6c804e00 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -1,5 +1,6 @@ import: ../../../.vscode/cspell.global.yaml words: + - agentdetect - azcloud - azdext - azurefd @@ -20,23 +21,30 @@ words: # CDN host name - gfgac2cmf7b8cuay - goversioninfo + - OPENCODE + - opencode - grpcbroker - nosec - oneof - idxs # Looks like the protogen has a spelling error for panics - pancis + - Paren - pkgux + - ppid + - PPID - proto - protobuf - protoc - protoimpl - protojson - protoreflect + - SNAPPROCESS - structpb - Retryable - runcontext - surveyterm + - Toolhelp - unmarshals - unmarshaling - unsetting diff --git a/cli/azd/cmd/auto_install.go b/cli/azd/cmd/auto_install.go index b9da1b850c3..33415e7bbfd 100644 --- a/cli/azd/cmd/auto_install.go +++ b/cli/azd/cmd/auto_install.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/internal/runcontext/agentdetect" "github.com/azure/azure-dev/cli/azd/internal/tracing/resource" "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/azure/azure-dev/cli/azd/pkg/input" @@ -509,6 +510,9 @@ func CreateGlobalFlagSet() *pflag.FlagSet { // Uses ParseErrorsAllowlist to gracefully ignore unknown flags (like extension-specific flags). // This function is designed to be called BEFORE Cobra command tree construction to enable // early access to global flag values for auto-install and other pre-execution logic. +// +// Agent Detection: If --no-prompt is not explicitly set and an AI coding agent (like Claude Code, +// GitHub Copilot CLI, Cursor, etc.) is detected as the caller, NoPrompt is automatically enabled. func ParseGlobalFlags(args []string, opts *internal.GlobalCommandOptions) error { globalFlagSet := CreateGlobalFlagSet() @@ -542,5 +546,13 @@ func ParseGlobalFlags(args []string, opts *internal.GlobalCommandOptions) error opts.NoPrompt = boolVal } + // 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. + noPromptFlag := globalFlagSet.Lookup("no-prompt") + noPromptExplicitlySet := noPromptFlag != nil && noPromptFlag.Changed + if !noPromptExplicitlySet && agentdetect.IsRunningInAgent() { + opts.NoPrompt = true + } + return nil } diff --git a/cli/azd/cmd/auto_install_integration_test.go b/cli/azd/cmd/auto_install_integration_test.go index 45719e41d8e..31a7e3730d3 100644 --- a/cli/azd/cmd/auto_install_integration_test.go +++ b/cli/azd/cmd/auto_install_integration_test.go @@ -7,8 +7,11 @@ import ( "os" "testing" + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/internal/runcontext/agentdetect" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestExecuteWithAutoInstallIntegration tests the integration between @@ -72,3 +75,133 @@ func TestExecuteWithAutoInstallIntegration(t *testing.T) { // Restore original args os.Args = originalArgs } + +// TestAgentDetectionIntegration tests the full agent detection integration flow. +func TestAgentDetectionIntegration(t *testing.T) { + tests := []struct { + name string + args []string + envVars map[string]string + expectedNoPrompt bool + description string + }{ + { + name: "Claude Code agent enables no-prompt automatically", + args: []string{"version"}, + envVars: map[string]string{"CLAUDE_CODE": "1"}, + expectedNoPrompt: true, + description: "When running under Claude Code, --no-prompt should be auto-enabled", + }, + { + name: "GitHub Copilot CLI enables no-prompt automatically", + args: []string{"deploy"}, + envVars: map[string]string{"GITHUB_COPILOT_CLI": "true"}, + expectedNoPrompt: true, + description: "When running under GitHub Copilot CLI, --no-prompt should be auto-enabled", + }, + { + name: "Gemini agent enables no-prompt automatically", + args: []string{"init"}, + envVars: map[string]string{"GEMINI_CLI": "1"}, + expectedNoPrompt: true, + description: "When running under Gemini, --no-prompt should be auto-enabled", + }, + { + name: "OpenCode agent enables no-prompt automatically", + args: []string{"provision"}, + envVars: map[string]string{"OPENCODE": "1"}, + expectedNoPrompt: true, + description: "When running under OpenCode, --no-prompt should be auto-enabled", + }, + { + name: "User can override agent detection with --no-prompt=false", + args: []string{"--no-prompt=false", "up"}, + envVars: map[string]string{"CLAUDE_CODE": "1"}, + expectedNoPrompt: false, + description: "Explicit --no-prompt=false should override agent detection", + }, + { + name: "Normal execution without agent detection", + args: []string{"version"}, + envVars: map[string]string{}, + expectedNoPrompt: false, + description: "Without agent detection, prompting should remain enabled by default", + }, + { + name: "User agent string triggers detection", + args: []string{"up"}, + envVars: map[string]string{ + internal.AzdUserAgentEnvVar: "claude-code/1.0.0", + }, + expectedNoPrompt: true, + description: "AZURE_DEV_USER_AGENT containing agent identifier should trigger detection", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear any ambient agent env vars to ensure test isolation + clearAgentEnvVarsForTest(t) + + // Reset agent detection cache for each test + agentdetect.ResetDetection() + + // Set environment variables + for k, v := range tt.envVars { + t.Setenv(k, v) + } + + // Parse global flags as would happen in real execution + opts := &internal.GlobalCommandOptions{} + err := ParseGlobalFlags(tt.args, opts) + require.NoError(t, err, "ParseGlobalFlags should not error: %s", tt.description) + + assert.Equal(t, tt.expectedNoPrompt, opts.NoPrompt, + "NoPrompt mismatch: %s", tt.description) + + // Verify agent detection status matches expectation + agent := agentdetect.GetCallingAgent() + if tt.expectedNoPrompt && len(tt.envVars) > 0 && !containsNoPromptFalse(tt.args) { + assert.True(t, agent.Detected, + "Agent should be detected when agent env vars are set: %s", tt.description) + } + + // Clean up + agentdetect.ResetDetection() + }) + } +} + +// containsNoPromptFalse checks if args contain --no-prompt=false +func containsNoPromptFalse(args []string) bool { + for _, arg := range args { + if arg == "--no-prompt=false" { + return true + } + } + return false +} + +// clearAgentEnvVarsForTest clears all environment variables that could trigger agent detection. +// This ensures tests are isolated from the ambient environment. +func clearAgentEnvVarsForTest(t *testing.T) { + envVarsToUnset := []string{ + // Claude Code + "CLAUDE_CODE", "CLAUDE_CODE_ENTRYPOINT", + // GitHub Copilot CLI + "GITHUB_COPILOT_CLI", "GH_COPILOT", + // Gemini CLI + "GEMINI_CLI", "GEMINI_CLI_NO_RELAUNCH", + // OpenCode + "OPENCODE", + // User agent + internal.AzdUserAgentEnvVar, + } + + for _, envVar := range envVarsToUnset { + if _, exists := os.LookupEnv(envVar); exists { + t.Setenv(envVar, "") + os.Unsetenv(envVar) + } + } +} diff --git a/cli/azd/cmd/auto_install_test.go b/cli/azd/cmd/auto_install_test.go index 2ffc0428b7a..20b8cb9fb73 100644 --- a/cli/azd/cmd/auto_install_test.go +++ b/cli/azd/cmd/auto_install_test.go @@ -7,9 +7,12 @@ import ( "strings" "testing" + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/azure/azure-dev/cli/azd/internal/runcontext/agentdetect" "github.com/azure/azure-dev/cli/azd/pkg/extensions" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestFindFirstNonFlagArg(t *testing.T) { @@ -327,3 +330,86 @@ func TestCheckForMatchingExtension_Unit(t *testing.T) { }) } } + +func TestParseGlobalFlags_AgentDetection(t *testing.T) { + tests := []struct { + name string + args []string + envVars map[string]string + expectedNoPrompt bool + }{ + { + name: "no agent detected, no flag", + args: []string{"up"}, + envVars: map[string]string{}, + expectedNoPrompt: false, + }, + { + name: "agent detected via env var, no flag", + args: []string{"up"}, + envVars: map[string]string{"CLAUDE_CODE": "1"}, + expectedNoPrompt: true, + }, + { + name: "agent detected but --no-prompt=false explicitly set", + args: []string{"--no-prompt=false", "up"}, + envVars: map[string]string{"CLAUDE_CODE": "1"}, + expectedNoPrompt: false, + }, + { + name: "agent detected but --no-prompt explicitly set true", + args: []string{"--no-prompt", "up"}, + envVars: map[string]string{"GEMINI_CLI": "1"}, + expectedNoPrompt: true, + }, + { + name: "no agent, --no-prompt explicitly set", + args: []string{"--no-prompt", "deploy"}, + envVars: map[string]string{}, + expectedNoPrompt: true, + }, + { + name: "Gemini agent detected", + args: []string{"init"}, + envVars: map[string]string{"GEMINI_CLI": "1"}, + expectedNoPrompt: true, + }, + { + name: "GitHub Copilot CLI agent detected", + args: []string{"deploy"}, + envVars: map[string]string{"GITHUB_COPILOT_CLI": "true"}, + expectedNoPrompt: true, + }, + { + name: "OpenCode agent detected", + args: []string{"provision"}, + envVars: map[string]string{"OPENCODE": "1"}, + expectedNoPrompt: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear any ambient agent env vars to ensure test isolation + clearAgentEnvVarsForTest(t) + + // Reset agent detection cache + agentdetect.ResetDetection() + + // Set up env vars for this test + for k, v := range tt.envVars { + t.Setenv(k, v) + } + + opts := &internal.GlobalCommandOptions{} + err := ParseGlobalFlags(tt.args, opts) + require.NoError(t, err) + + assert.Equal(t, tt.expectedNoPrompt, opts.NoPrompt, + "NoPrompt should be %v for test case: %s", tt.expectedNoPrompt, tt.name) + + // Clean up for next test + agentdetect.ResetDetection() + }) + } +} diff --git a/cli/azd/internal/runcontext/agentdetect/detect.go b/cli/azd/internal/runcontext/agentdetect/detect.go new file mode 100644 index 00000000000..cc97317077c --- /dev/null +++ b/cli/azd/internal/runcontext/agentdetect/detect.go @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agentdetect + +import ( + "log" + "sync" +) + +var ( + cachedAgent AgentInfo + detectOnce sync.Once +) + +// GetCallingAgent detects if azd was invoked by a known AI coding agent. +// The result is cached after the first call. +func GetCallingAgent() AgentInfo { + detectOnce.Do(func() { + cachedAgent = detectAgent() + if cachedAgent.Detected { + log.Printf("Agent detection result: detected=%t, agent=%s, source=%s, details=%s", + cachedAgent.Detected, cachedAgent.Name, cachedAgent.Source, cachedAgent.Details) + } else { + log.Printf("Agent detection result: detected=%t, no AI coding agent detected", + cachedAgent.Detected) + } + }) + return cachedAgent +} + +// IsRunningInAgent returns true if azd was invoked by a known AI coding agent. +func IsRunningInAgent() bool { + return GetCallingAgent().Detected +} + +// detectAgent performs the actual agent detection. +// Detection is performed in priority order: +// 1. Environment variables (most reliable) +// 2. User agent string (AZURE_DEV_USER_AGENT) +// 3. Parent process inspection (fallback) +func detectAgent() AgentInfo { + // Try environment variable detection first (most reliable) + if agent := detectFromEnvVars(); agent.Detected { + return agent + } + + // Try user agent string detection + if agent := detectFromUserAgent(); agent.Detected { + return agent + } + + // Try parent process detection as fallback + if agent := detectFromParentProcess(); agent.Detected { + return agent + } + + return NoAgent() +} + +// ResetDetection clears the cached detection result. +// This is primarily useful for testing. +func ResetDetection() { + detectOnce = sync.Once{} + cachedAgent = AgentInfo{} +} diff --git a/cli/azd/internal/runcontext/agentdetect/detect_env.go b/cli/azd/internal/runcontext/agentdetect/detect_env.go new file mode 100644 index 00000000000..45331f0cb78 --- /dev/null +++ b/cli/azd/internal/runcontext/agentdetect/detect_env.go @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agentdetect + +import ( + "os" + "strings" + + "github.com/azure/azure-dev/cli/azd/internal" +) + +// envVarPattern maps environment variables to agent types. +type envVarPattern struct { + envVar string + agentType AgentType +} + +// knownEnvVarPatterns defines environment variables that indicate known AI agents. +// These are checked in order, so more specific patterns should come first. +var knownEnvVarPatterns = []envVarPattern{ + // Claude Code - Anthropic's coding agent + {envVar: "CLAUDE_CODE", agentType: AgentTypeClaudeCode}, + {envVar: "CLAUDE_CODE_ENTRYPOINT", agentType: AgentTypeClaudeCode}, + + // GitHub Copilot CLI + {envVar: "GITHUB_COPILOT_CLI", agentType: AgentTypeGitHubCopilotCLI}, + {envVar: "GH_COPILOT", agentType: AgentTypeGitHubCopilotCLI}, + + // Google Gemini CLI + {envVar: "GEMINI_CLI", agentType: AgentTypeGemini}, + {envVar: "GEMINI_CLI_NO_RELAUNCH", agentType: AgentTypeGemini}, + + // OpenCode - AI coding CLI + {envVar: "OPENCODE", agentType: AgentTypeOpenCode}, +} + +// detectFromEnvVars checks for known AI agent environment variables. +func detectFromEnvVars() AgentInfo { + for _, pattern := range knownEnvVarPatterns { + if _, exists := os.LookupEnv(pattern.envVar); exists { + return AgentInfo{ + Type: pattern.agentType, + Name: pattern.agentType.DisplayName(), + Source: DetectionSourceEnvVar, + Detected: true, + Details: pattern.envVar, + } + } + } + + return NoAgent() +} + +// userAgentPatterns maps user agent substrings to agent types. +// Matched case-insensitively against AZURE_DEV_USER_AGENT. +var userAgentPatterns = []struct { + substring string + agentType AgentType +}{ + // VS Code GitHub Copilot extension + {substring: internal.VsCodeAzureCopilotAgentPrefix, agentType: AgentTypeVSCodeCopilot}, + {substring: "github-copilot", agentType: AgentTypeGitHubCopilotCLI}, + {substring: "copilot-cli", agentType: AgentTypeGitHubCopilotCLI}, + {substring: "claude-code", agentType: AgentTypeClaudeCode}, + {substring: "claude", agentType: AgentTypeClaudeCode}, + {substring: "gemini", agentType: AgentTypeGemini}, + {substring: "opencode", agentType: AgentTypeOpenCode}, +} + +// detectFromUserAgent checks the AZURE_DEV_USER_AGENT env var for known agents. +func detectFromUserAgent() AgentInfo { + userAgent := os.Getenv(internal.AzdUserAgentEnvVar) + if userAgent == "" { + return NoAgent() + } + + userAgentLower := strings.ToLower(userAgent) + + for _, pattern := range userAgentPatterns { + if strings.Contains(userAgentLower, strings.ToLower(pattern.substring)) { + return AgentInfo{ + Type: pattern.agentType, + Name: pattern.agentType.DisplayName(), + Source: DetectionSourceUserAgent, + Detected: true, + Details: userAgent, + } + } + } + + return NoAgent() +} diff --git a/cli/azd/internal/runcontext/agentdetect/detect_process.go b/cli/azd/internal/runcontext/agentdetect/detect_process.go new file mode 100644 index 00000000000..a81b7eba4a5 --- /dev/null +++ b/cli/azd/internal/runcontext/agentdetect/detect_process.go @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agentdetect + +import ( + "log" + "os" + "path/filepath" + "strings" +) + +// processNamePatterns maps process name patterns to agent types. +// Patterns are matched case-insensitively against process names and executable paths. +var processNamePatterns = []struct { + patterns []string // lowercase patterns to match + agentType AgentType +}{ + // Claude Code (Anthropic) - installed via npm, homebrew, or direct download + { + patterns: []string{"claude", "claude-code"}, + agentType: AgentTypeClaudeCode, + }, + // GitHub Copilot CLI - installed via npm (@github/copilot) or as gh extension + { + patterns: []string{"copilot", "copilot-cli", "gh-copilot", "github-copilot", "github-copilot-cli"}, + agentType: AgentTypeGitHubCopilotCLI, + }, + // Google Gemini CLI + { + patterns: []string{"gemini", "gemini-code", "google-gemini"}, + agentType: AgentTypeGemini, + }, + // OpenCode - AI coding CLI + { + patterns: []string{"opencode"}, + agentType: AgentTypeOpenCode, + }, +} + +// maxProcessTreeDepth limits how far up the process tree we walk to prevent infinite loops. +const maxProcessTreeDepth = 10 + +// detectFromParentProcess checks if any ancestor process is a known AI agent. +// It walks up the process tree to find agents that spawn intermediate shells. +func detectFromParentProcess() AgentInfo { + currentPid := os.Getppid() + + for depth := 0; depth < maxProcessTreeDepth && currentPid > 1; depth++ { + info, parentPid, err := getParentProcessInfoWithPPID(currentPid) + if err != nil { + log.Printf("detect_process.go: Failed to get process info for pid %d: %v", currentPid, err) + break + } + + log.Printf("detect_process.go: Parent process detection: depth=%d, pid=%d, ppid=%d, name=%q, executable=%q", + depth, currentPid, parentPid, info.Name, info.Executable) + + // Try to match this process against known agents + result := matchProcessToAgent(info) + if result.Detected { + return result + } + + // Move up to the parent + if parentPid <= 1 || parentPid == currentPid { + break + } + currentPid = parentPid + } + + log.Printf("detect_process.go: Parent process detection: no agent found in process tree") + return NoAgent() +} + +// parentProcessInfo contains information about a parent process. +type parentProcessInfo struct { + Name string + Executable string + CommandLine string // Full command line (Linux/macOS only) +} + +// matchProcessToAgent checks if a process matches any known AI agent patterns. +func matchProcessToAgent(info parentProcessInfo) AgentInfo { + if info.Name == "" && info.Executable == "" { + return NoAgent() + } + + nameLower := strings.ToLower(info.Name) + execLower := strings.ToLower(info.Executable) + execBaseLower := strings.ToLower(filepath.Base(info.Executable)) + + // Remove common executable extensions for matching + nameLower = strings.TrimSuffix(nameLower, ".exe") + execBaseLower = strings.TrimSuffix(execBaseLower, ".exe") + + for _, entry := range processNamePatterns { + for _, pattern := range entry.patterns { + // Check against process name + if nameLower == pattern || strings.Contains(nameLower, pattern) { + return AgentInfo{ + Type: entry.agentType, + Name: entry.agentType.DisplayName(), + Source: DetectionSourceParentProcess, + Detected: true, + Details: info.Name, + } + } + + // Check against executable base name + if execBaseLower == pattern || strings.Contains(execBaseLower, pattern) { + return AgentInfo{ + Type: entry.agentType, + Name: entry.agentType.DisplayName(), + Source: DetectionSourceParentProcess, + Detected: true, + Details: info.Executable, + } + } + + // Check if pattern appears in full executable path (for detection via install paths) + if strings.Contains(execLower, pattern) { + return AgentInfo{ + Type: entry.agentType, + Name: entry.agentType.DisplayName(), + Source: DetectionSourceParentProcess, + Detected: true, + Details: info.Executable, + } + } + } + } + + return NoAgent() +} diff --git a/cli/azd/internal/runcontext/agentdetect/detect_process_darwin.go b/cli/azd/internal/runcontext/agentdetect/detect_process_darwin.go new file mode 100644 index 00000000000..7c09a758f3a --- /dev/null +++ b/cli/azd/internal/runcontext/agentdetect/detect_process_darwin.go @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build darwin + +package agentdetect + +import ( + "bytes" + "fmt" + "os/exec" + "strconv" + "strings" +) + +// getParentProcessInfoWithPPID retrieves information about a process and its parent PID on macOS. +// It uses the ps command which is universally available on macOS. +func getParentProcessInfoWithPPID(pid int) (parentProcessInfo, int, error) { + info := parentProcessInfo{} + parentPid := 0 + + // Use ps to get process info and parent PID + // -o comm= gives just the command name, -o ppid= gives parent PID + cmd := exec.Command("ps", "-p", fmt.Sprintf("%d", pid), "-o", "comm=,ppid=") + output, err := cmd.Output() + if err != nil { + return info, 0, fmt.Errorf("failed to get process info: %w", err) + } + + // Parse output: "process_name ppid" + parts := strings.Fields(strings.TrimSpace(string(output))) + if len(parts) >= 1 { + info.Name = parts[0] + } + if len(parts) >= 2 { + parentPid, _ = strconv.Atoi(parts[1]) + } + + // Get the full command path + cmd = exec.Command("ps", "-p", fmt.Sprintf("%d", pid), "-o", "args=") + output, err = cmd.Output() + if err == nil { + cmdLine := strings.TrimSpace(string(output)) + info.CommandLine = cmdLine + + // Extract executable path (first argument) + cmdParts := strings.Fields(cmdLine) + if len(cmdParts) > 0 { + info.Executable = cmdParts[0] + } + } + + // If we couldn't get the executable from args, try lsof + if info.Executable == "" { + cmd = exec.Command("lsof", "-p", fmt.Sprintf("%d", pid), "-Fn") + output, err = cmd.Output() + if err == nil { + // Parse lsof output - lines starting with 'n' contain file names + lines := bytes.Split(output, []byte("\n")) + for _, line := range lines { + if len(line) > 1 && line[0] == 'n' { + path := string(line[1:]) + // Skip non-file entries + if strings.HasPrefix(path, "/") && !strings.Contains(path, " ") { + info.Executable = path + break + } + } + } + } + } + + return info, parentPid, nil +} diff --git a/cli/azd/internal/runcontext/agentdetect/detect_process_linux.go b/cli/azd/internal/runcontext/agentdetect/detect_process_linux.go new file mode 100644 index 00000000000..38e117dd3b2 --- /dev/null +++ b/cli/azd/internal/runcontext/agentdetect/detect_process_linux.go @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build linux + +package agentdetect + +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// getParentProcessInfoWithPPID retrieves information about a process and its parent PID on Linux. +// It reads from /proc filesystem which is always available on Linux. +func getParentProcessInfoWithPPID(pid int) (parentProcessInfo, int, error) { + info := parentProcessInfo{} + parentPid := 0 + + // Read process name from /proc/{pid}/comm + commPath := fmt.Sprintf("/proc/%d/comm", pid) + commData, err := os.ReadFile(commPath) + if err != nil { + return info, 0, fmt.Errorf("failed to read process comm: %w", err) + } + info.Name = strings.TrimSpace(string(commData)) + + // Read executable path from /proc/{pid}/exe symlink + exePath := fmt.Sprintf("/proc/%d/exe", pid) + exe, err := os.Readlink(exePath) + if err == nil { + info.Executable = exe + } + // exe may fail due to permissions, that's ok + + // Read command line from /proc/{pid}/cmdline + cmdlinePath := fmt.Sprintf("/proc/%d/cmdline", pid) + cmdlineData, err := os.ReadFile(cmdlinePath) + if err == nil { + // cmdline is null-separated + info.CommandLine = strings.ReplaceAll(string(cmdlineData), "\x00", " ") + info.CommandLine = strings.TrimSpace(info.CommandLine) + } + + // Read parent PID from /proc/{pid}/stat + statPath := fmt.Sprintf("/proc/%d/stat", pid) + statData, err := os.ReadFile(statPath) + if err == nil { + parentPid = parseParentPidFromStat(string(statData)) + } + + return info, parentPid, nil +} + +// parseParentPidFromStat extracts the parent PID from /proc/{pid}/stat content. +// Format: pid (comm) state ppid ... +// The comm field can contain spaces and parentheses, so we find the last ')' first. +func parseParentPidFromStat(stat string) int { + // Find the last ')' which ends the comm field + lastParen := strings.LastIndex(stat, ")") + if lastParen == -1 || lastParen+2 >= len(stat) { + return 0 + } + + // After ') ' comes: state ppid ... + rest := stat[lastParen+2:] + fields := strings.Fields(rest) + if len(fields) < 2 { + return 0 + } + + // fields[0] is state, fields[1] is ppid + ppid, err := strconv.Atoi(fields[1]) + if err != nil { + return 0 + } + return ppid +} diff --git a/cli/azd/internal/runcontext/agentdetect/detect_process_windows.go b/cli/azd/internal/runcontext/agentdetect/detect_process_windows.go new file mode 100644 index 00000000000..1d096e684e8 --- /dev/null +++ b/cli/azd/internal/runcontext/agentdetect/detect_process_windows.go @@ -0,0 +1,111 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +//go:build windows + +package agentdetect + +import ( + "fmt" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +// getParentProcessInfoWithPPID retrieves information about a process and its parent PID on Windows. +func getParentProcessInfoWithPPID(pid int) (parentProcessInfo, int, error) { + info := parentProcessInfo{} + parentPid := 0 + + // Open the process with query rights + //nolint:gosec // G115: pid is validated before use + handle, err := windows.OpenProcess( + windows.PROCESS_QUERY_LIMITED_INFORMATION, + false, + uint32(pid), + ) + if err != nil { + return info, 0, fmt.Errorf("failed to open process %d: %w", pid, err) + } + defer windows.CloseHandle(handle) + + // Get the executable path + exePath, err := getProcessImageName(handle) + if err == nil { + info.Executable = exePath + info.Name = getBaseName(exePath) + } + + // Get parent PID using Toolhelp32 process snapshot enumeration + parentPid, err = getParentPid(pid) + if err != nil { + // Non-fatal - we still have the process info + parentPid = 0 + } + + return info, parentPid, nil +} + +// getParentPid retrieves the parent process ID using process snapshot. +func getParentPid(pid int) (int, error) { + // Use CreateToolhelp32Snapshot to enumerate processes + snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return 0, fmt.Errorf("CreateToolhelp32Snapshot failed: %w", err) + } + defer windows.CloseHandle(snapshot) + + var entry windows.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + + err = windows.Process32First(snapshot, &entry) + if err != nil { + return 0, fmt.Errorf("Process32First failed: %w", err) + } + + for { + if int(entry.ProcessID) == pid { + return int(entry.ParentProcessID), nil + } + err = windows.Process32Next(snapshot, &entry) + if err != nil { + break + } + } + + return 0, fmt.Errorf("process %d not found", pid) +} + +// getProcessImageName retrieves the full path of the executable for a process. +func getProcessImageName(handle windows.Handle) (string, error) { + // Start with a reasonable buffer size + bufSize := uint32(windows.MAX_PATH) + buf := make([]uint16, bufSize) + + err := windows.QueryFullProcessImageName(handle, 0, &buf[0], &bufSize) + if err != nil { + // Try with a larger buffer + bufSize = 32768 + buf = make([]uint16, bufSize) + err = windows.QueryFullProcessImageName(handle, 0, &buf[0], &bufSize) + if err != nil { + return "", fmt.Errorf("QueryFullProcessImageName failed: %w", err) + } + } + + return syscall.UTF16ToString(buf[:bufSize]), nil +} + +// getBaseName extracts the file name from a full path. +func getBaseName(path string) string { + for i := len(path) - 1; i >= 0; i-- { + if path[i] == '\\' || path[i] == '/' { + return path[i+1:] + } + } + return path +} + +// Ensure we have the right imports for unsafe operations +var _ = unsafe.Sizeof(0) diff --git a/cli/azd/internal/runcontext/agentdetect/detect_test.go b/cli/azd/internal/runcontext/agentdetect/detect_test.go new file mode 100644 index 00000000000..b3c5051e441 --- /dev/null +++ b/cli/azd/internal/runcontext/agentdetect/detect_test.go @@ -0,0 +1,327 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agentdetect + +import ( + "os" + "testing" + + "github.com/azure/azure-dev/cli/azd/internal" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestAgentType_DisplayName(t *testing.T) { + tests := []struct { + agentType AgentType + displayName string + }{ + {AgentTypeClaudeCode, "Claude Code"}, + {AgentTypeGitHubCopilotCLI, "GitHub Copilot CLI"}, + {AgentTypeVSCodeCopilot, "VS Code GitHub Copilot"}, + {AgentTypeGemini, "Gemini"}, + {AgentTypeOpenCode, "OpenCode"}, + {AgentTypeUnknown, "Unknown"}, + } + + for _, tt := range tests { + t.Run(string(tt.agentType), func(t *testing.T) { + assert.Equal(t, tt.displayName, tt.agentType.DisplayName()) + }) + } +} + +func TestNoAgent(t *testing.T) { + agent := NoAgent() + assert.False(t, agent.Detected) + assert.Equal(t, AgentTypeUnknown, agent.Type) + assert.Empty(t, agent.Name) + assert.Equal(t, DetectionSourceNone, agent.Source) +} + +func TestDetectFromEnvVars(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + expectedAgent AgentType + detected bool + }{ + { + name: "No env vars", + envVars: map[string]string{}, + expectedAgent: AgentTypeUnknown, + detected: false, + }, + { + name: "Claude Code via CLAUDE_CODE", + envVars: map[string]string{"CLAUDE_CODE": "1"}, + expectedAgent: AgentTypeClaudeCode, + detected: true, + }, + { + name: "Claude Code via CLAUDE_CODE_ENTRYPOINT", + envVars: map[string]string{"CLAUDE_CODE_ENTRYPOINT": "/usr/bin/claude"}, + expectedAgent: AgentTypeClaudeCode, + detected: true, + }, + { + name: "GitHub Copilot CLI via GITHUB_COPILOT_CLI", + envVars: map[string]string{"GITHUB_COPILOT_CLI": "true"}, + expectedAgent: AgentTypeGitHubCopilotCLI, + detected: true, + }, + { + name: "GitHub Copilot CLI via GH_COPILOT", + envVars: map[string]string{"GH_COPILOT": "1"}, + expectedAgent: AgentTypeGitHubCopilotCLI, + detected: true, + }, + { + name: "Gemini CLI via GEMINI_CLI", + envVars: map[string]string{"GEMINI_CLI": "1"}, + expectedAgent: AgentTypeGemini, + detected: true, + }, + { + name: "Gemini CLI via GEMINI_CLI_NO_RELAUNCH", + envVars: map[string]string{"GEMINI_CLI_NO_RELAUNCH": "1"}, + expectedAgent: AgentTypeGemini, + detected: true, + }, + { + name: "OpenCode via OPENCODE", + envVars: map[string]string{"OPENCODE": "1"}, + expectedAgent: AgentTypeOpenCode, + detected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearAgentEnvVars(t) + + for k, v := range tt.envVars { + t.Setenv(k, v) + } + + result := detectFromEnvVars() + + assert.Equal(t, tt.detected, result.Detected) + assert.Equal(t, tt.expectedAgent, result.Type) + + if tt.detected { + assert.Equal(t, DetectionSourceEnvVar, result.Source) + assert.NotEmpty(t, result.Details) + } + }) + } +} + +func TestDetectFromUserAgent(t *testing.T) { + tests := []struct { + name string + userAgent string + expectedAgent AgentType + detected bool + }{ + { + name: "Empty user agent", + userAgent: "", + expectedAgent: AgentTypeUnknown, + detected: false, + }, + { + name: "Unrecognized user agent", + userAgent: "some-random-tool/1.0.0", + expectedAgent: AgentTypeUnknown, + detected: false, + }, + { + name: "Claude Code in user agent", + userAgent: "claude-code/1.2.3", + expectedAgent: AgentTypeClaudeCode, + detected: true, + }, + { + name: "GitHub Copilot in user agent", + userAgent: "github-copilot/2.0.0", + expectedAgent: AgentTypeGitHubCopilotCLI, + detected: true, + }, + { + name: "VS Code Azure Copilot extension", + userAgent: internal.VsCodeAzureCopilotAgentPrefix + "/1.0.0", + expectedAgent: AgentTypeVSCodeCopilot, + detected: true, + }, + { + name: "Gemini in user agent", + userAgent: "gemini-cli/1.0.0", + expectedAgent: AgentTypeGemini, + detected: true, + }, + { + name: "OpenCode in user agent", + userAgent: "opencode/0.1.0", + expectedAgent: AgentTypeOpenCode, + detected: true, + }, + { + name: "Case insensitive matching", + userAgent: "CLAUDE-CODE/1.0.0", + expectedAgent: AgentTypeClaudeCode, + detected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv(internal.AzdUserAgentEnvVar, tt.userAgent) + + result := detectFromUserAgent() + + assert.Equal(t, tt.detected, result.Detected) + assert.Equal(t, tt.expectedAgent, result.Type) + + if tt.detected { + assert.Equal(t, DetectionSourceUserAgent, result.Source) + assert.Equal(t, tt.userAgent, result.Details) + } + }) + } +} + +func TestMatchProcessToAgent(t *testing.T) { + tests := []struct { + name string + processInfo parentProcessInfo + expectedAgent AgentType + detected bool + }{ + { + name: "Empty process info", + processInfo: parentProcessInfo{}, + expectedAgent: AgentTypeUnknown, + detected: false, + }, + { + name: "Claude process name", + processInfo: parentProcessInfo{ + Name: "claude", + }, + expectedAgent: AgentTypeClaudeCode, + detected: true, + }, + { + name: "Claude Code process name", + processInfo: parentProcessInfo{ + Name: "claude-code", + }, + expectedAgent: AgentTypeClaudeCode, + detected: true, + }, + { + name: "GitHub Copilot CLI", + processInfo: parentProcessInfo{ + Name: "gh-copilot", + }, + expectedAgent: AgentTypeGitHubCopilotCLI, + detected: true, + }, + { + name: "Gemini process", + processInfo: parentProcessInfo{ + Name: "gemini", + }, + expectedAgent: AgentTypeGemini, + detected: true, + }, + { + name: "OpenCode process", + processInfo: parentProcessInfo{ + Name: "opencode", + }, + expectedAgent: AgentTypeOpenCode, + detected: true, + }, + { + name: "Unknown process", + processInfo: parentProcessInfo{ + Name: "bash", + Executable: "/bin/bash", + }, + expectedAgent: AgentTypeUnknown, + detected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := matchProcessToAgent(tt.processInfo) + + assert.Equal(t, tt.detected, result.Detected) + assert.Equal(t, tt.expectedAgent, result.Type) + + if tt.detected { + assert.Equal(t, DetectionSourceParentProcess, result.Source) + } + }) + } +} + +func TestGetCallingAgent_Caching(t *testing.T) { + clearAgentEnvVars(t) + ResetDetection() + + // First call - no agent + agent1 := GetCallingAgent() + require.False(t, agent1.Detected) + + // Set an env var - but cached result should be returned + t.Setenv("CLAUDE_CODE", "1") + agent2 := GetCallingAgent() + assert.False(t, agent2.Detected, "Should return cached result") + + // Reset and try again + ResetDetection() + agent3 := GetCallingAgent() + assert.True(t, agent3.Detected, "Should detect after reset") + assert.Equal(t, AgentTypeClaudeCode, agent3.Type) +} + +func TestIsRunningInAgent(t *testing.T) { + clearAgentEnvVars(t) + ResetDetection() + + assert.False(t, IsRunningInAgent()) + + t.Setenv("GEMINI_CLI", "1") + ResetDetection() + + assert.True(t, IsRunningInAgent()) +} + +// clearAgentEnvVars clears all environment variables that could trigger agent detection. +// This list must be kept in sync with knownEnvVarPatterns in detect_env.go. +func clearAgentEnvVars(t *testing.T) { + envVarsToUnset := []string{ + // Claude Code + "CLAUDE_CODE", "CLAUDE_CODE_ENTRYPOINT", + // GitHub Copilot CLI + "GITHUB_COPILOT_CLI", "GH_COPILOT", + // Gemini CLI + "GEMINI_CLI", "GEMINI_CLI_NO_RELAUNCH", + // OpenCode + "OPENCODE", + // User agent + internal.AzdUserAgentEnvVar, + } + + for _, envVar := range envVarsToUnset { + if _, exists := os.LookupEnv(envVar); exists { + t.Setenv(envVar, "") + os.Unsetenv(envVar) + } + } +} diff --git a/cli/azd/internal/runcontext/agentdetect/types.go b/cli/azd/internal/runcontext/agentdetect/types.go new file mode 100644 index 00000000000..cc972a1a80c --- /dev/null +++ b/cli/azd/internal/runcontext/agentdetect/types.go @@ -0,0 +1,86 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// Package agentdetect provides functionality to detect when azd is invoked +// by known AI coding agents (Claude Code, GitHub Copilot, Gemini, OpenCode) +// and enables automatic adjustment of behavior (e.g., no-prompt mode). +package agentdetect + +// AgentType represents a known AI coding agent. +type AgentType string + +const ( + // AgentTypeUnknown indicates no agent was detected. + AgentTypeUnknown AgentType = "" + // AgentTypeClaudeCode is Anthropic's Claude Code agent. + AgentTypeClaudeCode AgentType = "claude-code" + // AgentTypeGitHubCopilotCLI is GitHub's Copilot CLI agent. + AgentTypeGitHubCopilotCLI AgentType = "github-copilot-cli" + // AgentTypeVSCodeCopilot is VS Code GitHub Copilot extension. + AgentTypeVSCodeCopilot AgentType = "vscode-copilot" + // AgentTypeGemini is Google's Gemini CLI. + AgentTypeGemini AgentType = "gemini" + // AgentTypeOpenCode is the OpenCode AI coding CLI. + AgentTypeOpenCode AgentType = "opencode" +) + +// String returns the string representation of the agent type. +func (a AgentType) String() string { + return string(a) +} + +// DisplayName returns a human-readable name for the agent type. +func (a AgentType) DisplayName() string { + switch a { + case AgentTypeClaudeCode: + return "Claude Code" + case AgentTypeGitHubCopilotCLI: + return "GitHub Copilot CLI" + case AgentTypeVSCodeCopilot: + return "VS Code GitHub Copilot" + case AgentTypeGemini: + return "Gemini" + case AgentTypeOpenCode: + return "OpenCode" + default: + return "Unknown" + } +} + +// DetectionSource indicates how an agent was detected. +type DetectionSource string + +const ( + // DetectionSourceNone indicates no detection occurred. + DetectionSourceNone DetectionSource = "" + // DetectionSourceEnvVar indicates detection via environment variable. + DetectionSourceEnvVar DetectionSource = "env-var" + // DetectionSourceParentProcess indicates detection via parent process inspection. + DetectionSourceParentProcess DetectionSource = "parent-process" + // DetectionSourceUserAgent indicates detection via AZURE_DEV_USER_AGENT. + DetectionSourceUserAgent DetectionSource = "user-agent" +) + +// AgentInfo contains information about a detected AI coding agent. +type AgentInfo struct { + // Type is the identified agent type. + Type AgentType + // Name is a human-readable name for the agent. + Name string + // Source indicates how the agent was detected. + Source DetectionSource + // Detected is true if an agent was detected. + Detected bool + // Details contains additional detection information (e.g., matched env var or process name). + Details string +} + +// NoAgent returns an AgentInfo indicating no agent was detected. +func NoAgent() AgentInfo { + return AgentInfo{ + Type: AgentTypeUnknown, + Name: "", + Source: DetectionSourceNone, + Detected: false, + } +} diff --git a/cli/azd/internal/terminal/terminal.go b/cli/azd/internal/terminal/terminal.go index 55f86d12d46..1d828c1d231 100644 --- a/cli/azd/internal/terminal/terminal.go +++ b/cli/azd/internal/terminal/terminal.go @@ -7,6 +7,7 @@ import ( "os" "strconv" + "github.com/azure/azure-dev/cli/azd/internal/runcontext/agentdetect" "github.com/azure/azure-dev/cli/azd/internal/tracing/resource" "github.com/mattn/go-isatty" ) @@ -26,5 +27,10 @@ func IsTerminal(stdoutFd uintptr, stdinFd uintptr) bool { return false } + // If running under an AI coding agent, disable TTY mode to prevent interactive prompts. + if agentdetect.IsRunningInAgent() { + return false + } + return isatty.IsTerminal(stdoutFd) && isatty.IsTerminal(stdinFd) } diff --git a/cli/azd/internal/terminal/terminal_test.go b/cli/azd/internal/terminal/terminal_test.go new file mode 100644 index 00000000000..6dcb98603bd --- /dev/null +++ b/cli/azd/internal/terminal/terminal_test.go @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package terminal + +import ( + "os" + "testing" + + "github.com/azure/azure-dev/cli/azd/internal/runcontext/agentdetect" + "github.com/stretchr/testify/assert" +) + +func TestIsTerminal_ForceTTY(t *testing.T) { + clearTestEnvVars(t) + agentdetect.ResetDetection() + + // Test AZD_FORCE_TTY=true forces TTY mode + t.Setenv("AZD_FORCE_TTY", "true") + assert.True(t, IsTerminal(0, 0), "AZD_FORCE_TTY=true should force TTY mode") + + // Test AZD_FORCE_TTY=false forces non-TTY mode + t.Setenv("AZD_FORCE_TTY", "false") + assert.False(t, IsTerminal(0, 0), "AZD_FORCE_TTY=false should disable TTY mode") +} + +func TestIsTerminal_AgentDetection(t *testing.T) { + tests := []struct { + name string + envVars map[string]string + expected bool + }{ + { + name: "Claude Code agent disables TTY", + envVars: map[string]string{"CLAUDE_CODE": "1"}, + expected: false, + }, + { + name: "GitHub Copilot CLI disables TTY", + envVars: map[string]string{"GITHUB_COPILOT_CLI": "true"}, + expected: false, + }, + { + name: "Gemini CLI disables TTY", + envVars: map[string]string{"GEMINI_CLI": "1"}, + expected: false, + }, + { + name: "OpenCode disables TTY", + envVars: map[string]string{"OPENCODE": "1"}, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + clearTestEnvVars(t) + agentdetect.ResetDetection() + + for k, v := range tt.envVars { + t.Setenv(k, v) + } + + result := IsTerminal(0, 0) + assert.Equal(t, tt.expected, result, + "IsTerminal should return %v when agent is detected", tt.expected) + }) + } +} + +func TestIsTerminal_ForceTTYOverridesAgent(t *testing.T) { + clearTestEnvVars(t) + agentdetect.ResetDetection() + + // Set an agent env var that would normally disable TTY + t.Setenv("CLAUDE_CODE", "1") + + // But AZD_FORCE_TTY should take precedence + t.Setenv("AZD_FORCE_TTY", "true") + + assert.True(t, IsTerminal(0, 0), + "AZD_FORCE_TTY=true should override agent detection and enable TTY") +} + +// clearTestEnvVars clears environment variables that affect terminal detection. +func clearTestEnvVars(t *testing.T) { + envVarsToUnset := []string{ + "AZD_FORCE_TTY", + // Agent env vars + "CLAUDE_CODE", "CLAUDE_CODE_ENTRYPOINT", + "GITHUB_COPILOT_CLI", "GH_COPILOT", + "GEMINI_CLI", "GEMINI_CLI_NO_RELAUNCH", + "OPENCODE", + // CI env vars + "CI", "TF_BUILD", "GITHUB_ACTIONS", + } + + for _, envVar := range envVarsToUnset { + if _, exists := os.LookupEnv(envVar); exists { + t.Setenv(envVar, "") + os.Unsetenv(envVar) + } + } +} diff --git a/cli/azd/internal/tracing/fields/fields.go b/cli/azd/internal/tracing/fields/fields.go index 513462729b7..32b6d039840 100644 --- a/cli/azd/internal/tracing/fields/fields.go +++ b/cli/azd/internal/tracing/fields/fields.go @@ -276,6 +276,12 @@ const ( EnvVSCodeAzureCopilot = "VS Code Azure GitHub Copilot" EnvCloudShell = "Azure CloudShell" + // AI Coding Agent environments + EnvClaudeCode = "Claude Code" + EnvGitHubCopilotCLI = "GitHub Copilot CLI" + EnvGemini = "Gemini" + EnvOpenCode = "OpenCode" + // Continuous Integration environments EnvUnknownCI = "UnknownCI" diff --git a/cli/azd/internal/tracing/resource/exec_environment.go b/cli/azd/internal/tracing/resource/exec_environment.go index e7aaaa07c63..d5b710194fb 100644 --- a/cli/azd/internal/tracing/resource/exec_environment.go +++ b/cli/azd/internal/tracing/resource/exec_environment.go @@ -9,6 +9,7 @@ import ( "github.com/azure/azure-dev/cli/azd/internal" "github.com/azure/azure-dev/cli/azd/internal/runcontext" + "github.com/azure/azure-dev/cli/azd/internal/runcontext/agentdetect" "github.com/azure/azure-dev/cli/azd/internal/tracing/fields" ) @@ -17,6 +18,11 @@ func getExecutionEnvironment() string { // inner layers. env := execEnvFromCaller() + // Check for AI coding agents if no caller was detected via user agent + if env == "" { + env = execEnvFromAgent() + } + if env == "" { // machine-level execution environments env = execEnvForHosts() @@ -56,6 +62,30 @@ func execEnvFromCaller() string { return "" } +// execEnvFromAgent detects AI coding agents via the agentdetect package. +func execEnvFromAgent() string { + agent := agentdetect.GetCallingAgent() + if !agent.Detected { + return "" + } + + // Map agent types to telemetry environment values + switch agent.Type { + case agentdetect.AgentTypeClaudeCode: + return fields.EnvClaudeCode + case agentdetect.AgentTypeGitHubCopilotCLI: + return fields.EnvGitHubCopilotCLI + case agentdetect.AgentTypeVSCodeCopilot: + return fields.EnvVSCodeAzureCopilot + case agentdetect.AgentTypeGemini: + return fields.EnvGemini + case agentdetect.AgentTypeOpenCode: + return fields.EnvOpenCode + default: + return "" + } +} + func execEnvForHosts() string { if _, ok := os.LookupEnv(runcontext.AzdInCloudShellEnvVar); ok { return fields.EnvCloudShell