Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 28 additions & 27 deletions cli/azd/internal/runcontext/agentdetect/detect_process.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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
{
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand All @@ -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")
}
117 changes: 103 additions & 14 deletions cli/azd/internal/runcontext/agentdetect/detect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package agentdetect

import (
"os"
"strings"
"testing"

"github.com/azure/azure-dev/cli/azd/internal"
Expand Down Expand Up @@ -251,56 +252,64 @@ 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",
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",
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",
processInfo: parentProcessInfo{
Name: "gemini",
},
expectedAgent: AgentTypeGemini,
detected: true,
},
{
name: "OpenCode process",
processInfo: parentProcessInfo{
Name: "opencode",
},
expectedAgent: AgentTypeOpenCode,
detected: true,
},
{
name: "Unknown process",
Expand All @@ -309,24 +318,104 @@ 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,
},
}

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 != 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()
Expand Down
Loading