diff --git a/cli/azd/internal/runcontext/agentdetect/detect_process.go b/cli/azd/internal/runcontext/agentdetect/detect_process.go index a81b7eba4a5..9ea5e750d85 100644 --- a/cli/azd/internal/runcontext/agentdetect/detect_process.go +++ b/cli/azd/internal/runcontext/agentdetect/detect_process.go @@ -6,15 +6,14 @@ 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. +// processNamePatterns maps process executable names to agent types. var processNamePatterns = []struct { - patterns []string // lowercase patterns to match - agentType AgentType + patterns []string // lowercase executable names to match + agentType AgentType + exactMatch bool }{ // Claude Code (Anthropic) - installed via npm, homebrew, or direct download { @@ -25,6 +24,8 @@ var processNamePatterns = []struct { { patterns: []string{"copilot", "copilot-cli", "gh-copilot", "github-copilot", "github-copilot-cli"}, agentType: AgentTypeGitHubCopilotCLI, + // Avoid classifying host applications and installation paths containing the generic "copilot" term. + exactMatch: true, }, // Google Gemini CLI { @@ -86,18 +87,12 @@ func matchProcessToAgent(info parentProcessInfo) AgentInfo { 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") + nameLower := normalizeProcessName(info.Name) + execBaseLower := normalizeProcessName(info.Executable) for _, entry := range processNamePatterns { for _, pattern := range entry.patterns { - // Check against process name - if nameLower == pattern || strings.Contains(nameLower, pattern) { + if processNameMatches(nameLower, pattern, entry.exactMatch) { return AgentInfo{ Type: entry.agentType, Name: entry.agentType.DisplayName(), @@ -107,19 +102,8 @@ func matchProcessToAgent(info parentProcessInfo) AgentInfo { } } - // 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) { + if processNameMatches(execBaseLower, pattern, entry.exactMatch) || + (!entry.exactMatch && strings.Contains(strings.ToLower(info.Executable), pattern)) { return AgentInfo{ Type: entry.agentType, Name: entry.agentType.DisplayName(), @@ -133,3 +117,20 @@ func matchProcessToAgent(info parentProcessInfo) AgentInfo { return NoAgent() } + +func processNameMatches(processName string, pattern string, exactMatch bool) bool { + if exactMatch { + return processName == pattern + } + + return strings.Contains(processName, pattern) +} + +func normalizeProcessName(processPath string) string { + normalizedPath := strings.ReplaceAll(processPath, "\\", "/") + if index := strings.LastIndex(normalizedPath, "/"); index >= 0 { + normalizedPath = normalizedPath[index+1:] + } + + return strings.TrimSuffix(strings.ToLower(normalizedPath), ".exe") +} diff --git a/cli/azd/internal/runcontext/agentdetect/detect_test.go b/cli/azd/internal/runcontext/agentdetect/detect_test.go index d6f05384297..0e411d016ac 100644 --- a/cli/azd/internal/runcontext/agentdetect/detect_test.go +++ b/cli/azd/internal/runcontext/agentdetect/detect_test.go @@ -5,6 +5,7 @@ package agentdetect import ( "os" + "strings" "testing" "github.com/azure/azure-dev/cli/azd/internal" @@ -251,16 +252,15 @@ func TestDetectFromUserAgent(t *testing.T) { func TestMatchProcessToAgent(t *testing.T) { tests := []struct { - name string - processInfo parentProcessInfo - expectedAgent AgentType - detected bool + name string + processInfo parentProcessInfo + expectedAgent AgentType + expectedDetails string }{ { name: "Empty process info", processInfo: parentProcessInfo{}, expectedAgent: AgentTypeUnknown, - detected: false, }, { name: "Claude process name", @@ -268,7 +268,6 @@ func TestMatchProcessToAgent(t *testing.T) { Name: "claude", }, expectedAgent: AgentTypeClaudeCode, - detected: true, }, { name: "Claude Code process name", @@ -276,15 +275,27 @@ func TestMatchProcessToAgent(t *testing.T) { Name: "claude-code", }, expectedAgent: AgentTypeClaudeCode, - detected: true, }, { - name: "GitHub Copilot CLI", + name: "GitHub Copilot CLI process name", processInfo: parentProcessInfo{ Name: "gh-copilot", }, expectedAgent: AgentTypeGitHubCopilotCLI, - detected: true, + }, + { + name: "GitHub Copilot CLI Windows executable", + processInfo: parentProcessInfo{ + Executable: `C:\Users\example\AppData\Local\Programs\GitHub CLI\gh-copilot.exe`, + }, + expectedAgent: AgentTypeGitHubCopilotCLI, + }, + { + name: "GitHub Copilot CLI executable variants", + processInfo: parentProcessInfo{ + Executable: "/usr/local/bin/github-copilot-cli", + }, + expectedAgent: AgentTypeGitHubCopilotCLI, }, { name: "Gemini process", @@ -292,7 +303,6 @@ func TestMatchProcessToAgent(t *testing.T) { Name: "gemini", }, expectedAgent: AgentTypeGemini, - detected: true, }, { name: "OpenCode process", @@ -300,7 +310,6 @@ func TestMatchProcessToAgent(t *testing.T) { Name: "opencode", }, expectedAgent: AgentTypeOpenCode, - detected: true, }, { name: "Unknown process", @@ -309,7 +318,52 @@ func TestMatchProcessToAgent(t *testing.T) { Executable: "/bin/bash", }, expectedAgent: AgentTypeUnknown, - detected: false, + }, + { + name: "GitHub Copilot desktop app is not Copilot CLI", + processInfo: parentProcessInfo{ + Name: "GitHub Copilot.exe", + Executable: `C:\Users\example\AppData\Local\GitHub Copilot\GitHub Copilot.exe`, + }, + expectedAgent: AgentTypeUnknown, + }, + { + name: "Copilot name in host path does not match", + processInfo: parentProcessInfo{ + Name: "pwsh.exe", + Executable: `C:\Users\example\copilot-workspace\pwsh.exe`, + }, + expectedAgent: AgentTypeUnknown, + }, + { + name: "Copilot name substring does not match", + processInfo: parentProcessInfo{ + Name: "my-copilot-wrapper", + }, + expectedAgent: AgentTypeUnknown, + }, + { + name: "Claude wrapper remains supported", + processInfo: parentProcessInfo{ + Name: "my-claude-wrapper", + }, + expectedAgent: AgentTypeClaudeCode, + }, + { + name: "Gemini installation path remains supported", + processInfo: parentProcessInfo{ + Name: "node", + Executable: "/usr/local/lib/google-gemini/bin/node", + }, + expectedAgent: AgentTypeGemini, + expectedDetails: "/usr/local/lib/google-gemini/bin/node", + }, + { + name: "OpenCode versioned executable remains supported", + processInfo: parentProcessInfo{ + Name: "opencode-v1.2.3", + }, + expectedAgent: AgentTypeOpenCode, }, } @@ -317,16 +371,51 @@ func TestMatchProcessToAgent(t *testing.T) { t.Run(tt.name, func(t *testing.T) { result := matchProcessToAgent(tt.processInfo) - assert.Equal(t, tt.detected, result.Detected) + assert.Equal(t, tt.expectedAgent != AgentTypeUnknown, result.Detected) assert.Equal(t, tt.expectedAgent, result.Type) - if tt.detected { + if result.Detected { assert.Equal(t, DetectionSourceParentProcess, result.Source) + if tt.expectedDetails != "" { + assert.Equal(t, tt.expectedDetails, result.Details) + } } }) } } +func TestMatchProcessToAgent_ExactExecutableNames(t *testing.T) { + tests := []struct { + processName string + expectedAgent AgentType + }{ + {"claude", AgentTypeClaudeCode}, + {"claude-code", AgentTypeClaudeCode}, + {"copilot", AgentTypeGitHubCopilotCLI}, + {"copilot-cli", AgentTypeGitHubCopilotCLI}, + {"gh-copilot", AgentTypeGitHubCopilotCLI}, + {"github-copilot", AgentTypeGitHubCopilotCLI}, + {"github-copilot-cli", AgentTypeGitHubCopilotCLI}, + {"gemini", AgentTypeGemini}, + {"gemini-code", AgentTypeGemini}, + {"google-gemini", AgentTypeGemini}, + {"opencode", AgentTypeOpenCode}, + } + + for _, tt := range tests { + t.Run(tt.processName, func(t *testing.T) { + result := matchProcessToAgent(parentProcessInfo{ + Name: strings.ToUpper(tt.processName) + ".EXE", + Executable: `C:\tools\` + tt.processName + ".exe", + }) + + assert.True(t, result.Detected) + assert.Equal(t, tt.expectedAgent, result.Type) + assert.Equal(t, DetectionSourceParentProcess, result.Source) + }) + } +} + func TestGetCallingAgent_Caching(t *testing.T) { clearAgentEnvVars(t) ResetDetection()