diff --git a/pkg/services/api/ai_agent_provider.go b/pkg/services/api/ai_agent_provider.go new file mode 100644 index 00000000..2aa66597 --- /dev/null +++ b/pkg/services/api/ai_agent_provider.go @@ -0,0 +1,136 @@ +package api + +import "strings" + +// AI agents are detected separately from automation (CI) environments because the two are +// independent dimensions: an agent commonly runs inside a CI system or on an Octopus worker, +// and both should be reported rather than one masking the other. + +// aiAgentEnvVar is the emerging cross-vendor convention for an agent to identify itself. +// https://github.com/vercel/vercel/tree/main/packages/detect-agent +const aiAgentEnvVar = "AI_AGENT" + +type aiAgentRule struct { + variable string + // value, when set, must match the variable's value (case-insensitive). When empty, any + // non-empty value matches. + value string +} + +type aiAgent struct { + name string + rules []aiAgentRule +} + +// knownAIAgents is ordered: the first agent with a matching rule wins, so the result is +// deterministic when several agents' variables are present (e.g. one agent running another). +// +// Variables that are commonly present without an agent driving the process are deliberately +// excluded: CURSOR_TRACE_ID (any Cursor terminal), COPILOT_GITHUB_TOKEN and GOOSE_PROVIDER +// (user configuration), REPL_ID (any Replit workspace) and ZED_TERM (any Zed terminal). +// +// References: +// https://code.claude.com/docs/en/env-vars +// https://source.dot.net/aspire/Telemetry/CodingAgentDetector.cs.html +// https://docs.rs/is-ai-agent +var knownAIAgents = []aiAgent{ + {"ClaudeCode", []aiAgentRule{{variable: "CLAUDECODE"}, {variable: "CLAUDE_CODE_ENTRYPOINT"}}}, + {"Cursor", []aiAgentRule{{variable: "CURSOR_AGENT"}, {variable: "CURSOR_EXTENSION_HOST_ROLE", value: "agent-exec"}}}, + {"Codex", []aiAgentRule{{variable: "CODEX_SANDBOX"}, {variable: "CODEX_THREAD_ID"}, {variable: "CODEX_CI"}}}, + {"GeminiCLI", []aiAgentRule{{variable: "GEMINI_CLI"}}}, + {"GitHubCopilot", []aiAgentRule{{variable: "COPILOT_CLI"}, {variable: "COPILOT_AGENT"}, {variable: "GITHUB_COPILOT_CLI_MODE"}}}, + {"OpenCode", []aiAgentRule{{variable: "OPENCODE", value: "1"}, {variable: "OPENCODE_CLIENT"}, {variable: "OPENCODE_AI"}}}, + {"Antigravity", []aiAgentRule{{variable: "ANTIGRAVITY_AGENT"}}}, + {"Goose", []aiAgentRule{{variable: "GOOSE_TERMINAL"}, {variable: "AGENT", value: "goose"}}}, + {"Amp", []aiAgentRule{{variable: "AGENT", value: "amp"}}}, + {"Cline", []aiAgentRule{{variable: "CLINE_ACTIVE"}, {variable: "CLINE_TASK_ID"}}}, + {"RooCode", []aiAgentRule{{variable: "ROO_CODE_TASK_ID"}}}, + {"Augment", []aiAgentRule{{variable: "AUGMENT_AGENT"}}}, + {"QwenCode", []aiAgentRule{{variable: "QWEN_CODE"}}}, + {"FactoryDroid", []aiAgentRule{{variable: "DROID_CLI"}}}, + {"KimiCLI", []aiAgentRule{{variable: "KIMI_CLI"}}}, + {"Trae", []aiAgentRule{{variable: "TRAE_AI_SHELL_ID"}}}, + {"Aider", []aiAgentRule{{variable: "OR_APP_NAME", value: "Aider"}}}, + {"OpenHands", []aiAgentRule{{variable: "OR_APP_NAME", value: "OpenHands"}}}, +} + +// aiAgentValuePrefixes maps the start of a normalised AI_AGENT value to a known agent name. +// Vendors don't agree on a format (e.g. "claude-code_2-1-322_agent", "github_copilot_vscode_agent", +// "devin@1"), so values are lowercased and runs of underscores and whitespace replaced with a single hyphen +// before matching. +var aiAgentValuePrefixes = []struct { + prefix string + name string +}{ + {"claude-code", "ClaudeCode"}, + {"cursor", "Cursor"}, + {"codex", "Codex"}, + {"gemini", "GeminiCLI"}, + {"github-copilot", "GitHubCopilot"}, + {"opencode", "OpenCode"}, + {"antigravity", "Antigravity"}, + {"goose", "Goose"}, + {"amp", "Amp"}, + {"cline", "Cline"}, + {"augment", "Augment"}, + {"devin", "Devin"}, + {"replit", "Replit"}, + {"v0", "V0"}, +} + +// GetAIAgent returns a user agent token naming the AI agent driving this process, or an empty +// string when none is detected. Only a normalised name is reported: the raw AI_AGENT value is +// free-form and user-settable, so it is used for matching and never included in the output. +// Detection is based on self-reported environment variables, so it is suitable for analytics but +// must not be relied on for security decisions. +// +// Examples: +// +// AIAgent/ClaudeCode AI_AGENT=claude-code_2-1-322_agent, or CLAUDECODE=1 +// AIAgent/Cursor CURSOR_AGENT=1 +// AIAgent/Other AI_AGENT set to a value that isn't recognised +func GetAIAgent(e Environment) string { + aiAgentValue := strings.TrimSpace(e.Getenv(aiAgentEnvVar)) + + name := nameFromAIAgentValue(aiAgentValue) + if name == "" { + // Also covers an agent that sets AI_AGENT in a format that isn't recognised. + name = nameFromVendorVariables(e) + } + if name == "" && aiAgentValue != "" { + name = "Other" + } + if name == "" { + return "" + } + + return "AIAgent/" + name +} + +func nameFromVendorVariables(e Environment) string { + for _, agent := range knownAIAgents { + for _, rule := range agent.rules { + value := e.Getenv(rule.variable) + if value == "" { + continue + } + if rule.value == "" || strings.EqualFold(value, rule.value) { + return agent.name + } + } + } + return "" +} + +func nameFromAIAgentValue(value string) string { + if value == "" { + return "" + } + normalised := strings.Join(strings.Fields(strings.ReplaceAll(strings.ToLower(value), "_", " ")), "-") + for _, p := range aiAgentValuePrefixes { + if strings.HasPrefix(normalised, p.prefix) { + return p.name + } + } + return "" +} diff --git a/pkg/services/api/ai_agent_provider_test.go b/pkg/services/api/ai_agent_provider_test.go new file mode 100644 index 00000000..c6d59350 --- /dev/null +++ b/pkg/services/api/ai_agent_provider_test.go @@ -0,0 +1,149 @@ +package api_test + +import ( + "strings" + "testing" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/services/api" + "github.com/stretchr/testify/require" +) + +func TestGetAIAgent_None(t *testing.T) { + e := api.NewMockEnvironment() + require.Equal(t, "", api.GetAIAgent(e)) +} + +func TestGetAIAgent_VendorVariables(t *testing.T) { + cases := []struct { + variable string + value string + expected string + }{ + {"CLAUDECODE", "1", "AIAgent/ClaudeCode"}, + {"CLAUDE_CODE_ENTRYPOINT", "cli", "AIAgent/ClaudeCode"}, + {"CURSOR_AGENT", "1", "AIAgent/Cursor"}, + {"CURSOR_EXTENSION_HOST_ROLE", "agent-exec", "AIAgent/Cursor"}, + {"CODEX_SANDBOX", "seatbelt", "AIAgent/Codex"}, + {"CODEX_THREAD_ID", "abc", "AIAgent/Codex"}, + {"GEMINI_CLI", "1", "AIAgent/GeminiCLI"}, + {"COPILOT_CLI", "1", "AIAgent/GitHubCopilot"}, + {"OPENCODE", "1", "AIAgent/OpenCode"}, + {"OPENCODE_CLIENT", "cli", "AIAgent/OpenCode"}, + {"ANTIGRAVITY_AGENT", "1", "AIAgent/Antigravity"}, + {"GOOSE_TERMINAL", "1", "AIAgent/Goose"}, + {"AGENT", "goose", "AIAgent/Goose"}, + {"AGENT", "amp", "AIAgent/Amp"}, + {"CLINE_ACTIVE", "true", "AIAgent/Cline"}, + {"ROO_CODE_TASK_ID", "abc", "AIAgent/RooCode"}, + {"AUGMENT_AGENT", "1", "AIAgent/Augment"}, + {"QWEN_CODE", "1", "AIAgent/QwenCode"}, + {"DROID_CLI", "1", "AIAgent/FactoryDroid"}, + {"KIMI_CLI", "1", "AIAgent/KimiCLI"}, + {"TRAE_AI_SHELL_ID", "abc", "AIAgent/Trae"}, + {"OR_APP_NAME", "aider", "AIAgent/Aider"}, + {"OR_APP_NAME", "OpenHands", "AIAgent/OpenHands"}, + } + + for _, c := range cases { + t.Run(c.variable+"="+c.value, func(t *testing.T) { + e := api.NewMockEnvironment() + e.Setenv(c.variable, c.value) + require.Equal(t, c.expected, api.GetAIAgent(e)) + }) + } +} + +func TestGetAIAgent_ValueRulesRequireMatch(t *testing.T) { + cases := map[string]string{ + "CURSOR_EXTENSION_HOST_ROLE": "user", + "OPENCODE": "0", + "AGENT": "teamcity-agent-01", + "OR_APP_NAME": "SomethingElse", + } + + for variable, value := range cases { + t.Run(variable, func(t *testing.T) { + e := api.NewMockEnvironment() + e.Setenv(variable, value) + require.Equal(t, "", api.GetAIAgent(e)) + }) + } +} + +func TestGetAIAgent_ExcludesNoisyVariables(t *testing.T) { + for _, variable := range []string{"CURSOR_TRACE_ID", "COPILOT_GITHUB_TOKEN", "GOOSE_PROVIDER", "REPL_ID", "ZED_TERM"} { + t.Run(variable, func(t *testing.T) { + e := api.NewMockEnvironment() + e.Setenv(variable, "1") + require.Equal(t, "", api.GetAIAgent(e)) + }) + } +} + +func TestGetAIAgent_IsDeterministicWhenAgentsAreNested(t *testing.T) { + e := api.NewMockEnvironment() + e.Setenv("CURSOR_AGENT", "1") + e.Setenv("CLAUDECODE", "1") + for i := 0; i < 50; i++ { + require.Equal(t, "AIAgent/ClaudeCode", api.GetAIAgent(e)) + } +} + +func TestGetAIAgent_AIAgentVariable(t *testing.T) { + cases := []struct { + value string + expected string + }{ + {"claude-code_2-1-322_agent", "AIAgent/ClaudeCode"}, + {"claude-code", "AIAgent/ClaudeCode"}, + {"Claude-Code", "AIAgent/ClaudeCode"}, + {"Claude Code", "AIAgent/ClaudeCode"}, + {"cursor-cli", "AIAgent/Cursor"}, + {"github_copilot_vscode_agent", "AIAgent/GitHubCopilot"}, + {"github-copilot-cli", "AIAgent/GitHubCopilot"}, + {"devin@1", "AIAgent/Devin"}, + {"custom-agent@2.0", "AIAgent/Other"}, + } + + for _, c := range cases { + t.Run(c.value, func(t *testing.T) { + e := api.NewMockEnvironment() + e.Setenv("AI_AGENT", c.value) + require.Equal(t, c.expected, api.GetAIAgent(e)) + }) + } +} + +func TestGetAIAgent_AIAgentTakesPrecedenceOverVendorVariables(t *testing.T) { + e := api.NewMockEnvironment() + e.Setenv("CURSOR_AGENT", "1") + e.Setenv("AI_AGENT", "claude-code_2-1-322_agent") + require.Equal(t, "AIAgent/ClaudeCode", api.GetAIAgent(e)) +} + +func TestGetAIAgent_UnrecognisedAIAgentFallsBackToVendorName(t *testing.T) { + e := api.NewMockEnvironment() + e.Setenv("GEMINI_CLI", "1") + e.Setenv("AI_AGENT", "gcli") + require.Equal(t, "AIAgent/GeminiCLI", api.GetAIAgent(e)) +} + +func TestGetAIAgent_NeverIncludesRawAIAgentValue(t *testing.T) { + e := api.NewMockEnvironment() + e.Setenv("AI_AGENT", " evil) agent\r\nX-Injected: 1 ") + require.Equal(t, "AIAgent/Other", api.GetAIAgent(e)) + + e.Setenv("AI_AGENT", strings.Repeat("a", 200)) + require.Equal(t, "AIAgent/Other", api.GetAIAgent(e)) + + e.Setenv("AI_AGENT", " \t ") + require.Equal(t, "", api.GetAIAgent(e)) +} + +func TestGetAutomationEnvironment_IgnoresAIAgents(t *testing.T) { + e := api.NewMockEnvironment() + e.Setenv("CLAUDECODE", "1") + e.Setenv("GITHUB_ACTIONS", "true") + require.Equal(t, "GitHubActions", api.GetAutomationEnvironment(e)) + require.Equal(t, "AIAgent/ClaudeCode", api.GetAIAgent(e)) +} diff --git a/pkg/services/api/api.go b/pkg/services/api/api.go index 856369b8..1e58a058 100644 --- a/pkg/services/api/api.go +++ b/pkg/services/api/api.go @@ -63,7 +63,11 @@ func GetUserAgentString(requestingTool string) string { } } - automationEnvironment := GetAutomationEnvironment(&OsEnvironment{}) + environment := &OsEnvironment{} + automationEnvironment := GetAutomationEnvironment(environment) + if aiAgent := GetAIAgent(environment); aiAgent != "" { + automationEnvironment = automationEnvironment + " " + aiAgent + } return strings.TrimSpace(fmt.Sprintf("%s/%s (%s; %s) go/%s %s %s", "go-octopusdeploy", version, runtime.GOOS, runtime.GOARCH, runtime.Version(), automationEnvironment, requestingTool)) }