From c51769c977da4a049a9609685f862834528fdbf3 Mon Sep 17 00:00:00 2001 From: William Martin Date: Tue, 24 Mar 2026 17:05:34 +0100 Subject: [PATCH] Record agentic invocations in User-Agent header Detect which AI coding agent is invoking gh by checking well-known environment variables and include the agent name in the User-Agent header sent to GitHub APIs. Supported agents: Codex, Gemini CLI, Copilot CLI, OpenCode, Claude Code, and Amp. Generic AI_AGENT env var is also supported with validation to prevent header injection. Fixes github/cli#1111 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- api/client.go | 1 - api/http_client.go | 8 +- api/http_client_test.go | 14 ++ internal/agents/detect.go | 99 ++++++++++++ internal/agents/detect_test.go | 149 ++++++++++++++++++ internal/ghcmd/cmd.go | 3 +- .../verify/verify_integration_test.go | 8 +- pkg/cmd/factory/default.go | 42 ++--- pkg/cmd/factory/default_test.go | 26 +-- pkg/cmd/search/shared/shared_test.go | 2 +- 10 files changed, 311 insertions(+), 41 deletions(-) create mode 100644 internal/agents/detect.go create mode 100644 internal/agents/detect_test.go diff --git a/api/client.go b/api/client.go index 207fd86d3..8ee525df5 100644 --- a/api/client.go +++ b/api/client.go @@ -15,7 +15,6 @@ import ( ) const ( - accept = "Accept" apiVersion = "X-GitHub-Api-Version" apiVersionValue = "2022-11-28" authorization = "Authorization" diff --git a/api/http_client.go b/api/http_client.go index 9957f6bc5..532f79c7f 100644 --- a/api/http_client.go +++ b/api/http_client.go @@ -18,6 +18,7 @@ type tokenGetter interface { type HTTPClientOptions struct { AppVersion string + InvokingAgent string CacheTTL time.Duration Config tokenGetter EnableCache bool @@ -48,8 +49,13 @@ func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) { clientOpts.LogVerboseHTTP = opts.LogVerboseHTTP } + ua := fmt.Sprintf("GitHub CLI %s", opts.AppVersion) + if opts.InvokingAgent != "" { + ua = fmt.Sprintf("%s Agent/%s", ua, opts.InvokingAgent) + } + headers := map[string]string{ - userAgent: fmt.Sprintf("GitHub CLI %s", opts.AppVersion), + userAgent: ua, apiVersion: apiVersionValue, } clientOpts.Headers = headers diff --git a/api/http_client_test.go b/api/http_client_test.go index 824bc0f1b..1c81b4aa7 100644 --- a/api/http_client_test.go +++ b/api/http_client_test.go @@ -20,6 +20,7 @@ func TestNewHTTPClient(t *testing.T) { type args struct { config tokenGetter appVersion string + invokingAgent string logVerboseHTTP bool skipDefaultHeaders bool } @@ -155,6 +156,18 @@ func TestNewHTTPClient(t *testing.T) { * Request took `), }, + { + name: "includes invoking agent in user-agent header", + args: args{ + appVersion: "v1.2.3", + invokingAgent: "copilot-cli", + }, + host: "github.com", + wantHeader: map[string][]string{ + "user-agent": {"GitHub CLI v1.2.3 Agent/copilot-cli"}, + }, + wantStderr: "", + }, } var gotReq *http.Request @@ -169,6 +182,7 @@ func TestNewHTTPClient(t *testing.T) { ios, _, _, stderr := iostreams.Test() client, err := NewHTTPClient(HTTPClientOptions{ AppVersion: tt.args.appVersion, + InvokingAgent: tt.args.invokingAgent, Config: tt.args.config, Log: ios.ErrOut, LogVerboseHTTP: tt.args.logVerboseHTTP, diff --git a/internal/agents/detect.go b/internal/agents/detect.go new file mode 100644 index 000000000..fd330ed67 --- /dev/null +++ b/internal/agents/detect.go @@ -0,0 +1,99 @@ +package agents + +import ( + "fmt" + "os" + "regexp" +) + +// AgentName is a validated agent identifier safe for use in HTTP headers. +type AgentName string + +const ( + agentAmp AgentName = "amp" + agentClaudeCode AgentName = "claude-code" + agentCodex AgentName = "codex" + agentCopilotCLI AgentName = "copilot-cli" + agentGeminiCLI AgentName = "gemini-cli" + agentOpencode AgentName = "opencode" +) + +var validAgentName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) + +// parseAgentName validates and returns an AgentName from a raw string. +// Only alphanumeric characters, hyphens, and underscores are allowed. +func parseAgentName(s string) (AgentName, error) { + if !validAgentName.MatchString(s) { + return "", fmt.Errorf("invalid agent name %q: must match [a-zA-Z0-9_-]+", s) + } + return AgentName(s), nil +} + +// Detect returns the name of the AI coding agent driving the CLI, +// or an empty AgentName if none is detected. +func Detect() AgentName { + return detectWith(os.LookupEnv) +} + +func detectWith(lookup func(string) (string, bool)) AgentName { + isSet := func(key string) bool { + v, ok := lookup(key) + return ok && v != "" + } + + valueOf := func(key string) string { + v, _ := lookup(key) + return v + } + + // Generic agent identifiers — checked first because they are the most specific signal. + if v, ok := lookup("AI_AGENT"); ok && v != "" { + if name, err := parseAgentName(v); err == nil { + return name + } + } + + // Tool-specific variables. + + // Check AGENT=amp before the more generic CLAUDECODE=1 since Amp sets both. + if valueOf("AGENT") == "amp" { + return agentAmp + } + + // OpenAI Codex CLI — https://github.com/openai/codex + // CODEX_SANDBOX: https://github.com/openai/codex/blob/95e1d5993985019ce0ce0d10689caf1375f95120/codex-rs/core/src/spawn.rs#L25 + // CODEX_THREAD_ID: https://github.com/openai/codex/blob/95e1d5993985019ce0ce0d10689caf1375f95120/codex-rs/core/src/exec_env.rs#L8 + // CODEX_CI: https://github.com/openai/codex/blob/95e1d5993985019ce0ce0d10689caf1375f95120/codex-rs/core/src/unified_exec/process_manager.rs#L64 + if isSet("CODEX_SANDBOX") || isSet("CODEX_CI") || isSet("CODEX_THREAD_ID") { + return agentCodex + } + + // Google Gemini CLI — https://github.com/google-gemini/gemini-cli + // GEMINI_CLI: https://github.com/google-gemini/gemini-cli/blob/46fd7b4864111032a1c7dfa1821b2000fc7531da/docs/tools/shell.md#L96-L97 + if isSet("GEMINI_CLI") { + return agentGeminiCLI + } + + // GitHub Copilot CLI + // No first-party docs + if isSet("COPILOT_CLI") { + return agentCopilotCLI + } + + // OpenCode — https://github.com/anomalyco/opencode + // OPENCODE: https://github.com/anomalyco/opencode/blob/fde201c286a83ff32dda9b41d61d734a4449fe70/packages/opencode/src/index.ts#L78-L80 + if isSet("OPENCODE") { + return agentOpencode + } + + // Anthropic Claude Code — https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview + // CLAUDECODE: https://code.claude.com/docs/en/env-vars (CLAUDECODE section) + // Checked last because other agents (e.g. Amp) set CLAUDECODE=1 alongside their own vars. + if isSet("CLAUDECODE") { + // There is a CLAUDE_CODE_ENTRYPOINT env var that is set to `cli` or `desktop` etc, but it's not documented + // so we don't want to rely on it too heavily. We'll just return a generic claude-code agent name. + return agentClaudeCode + } + + return "" +} diff --git a/internal/agents/detect_test.go b/internal/agents/detect_test.go new file mode 100644 index 000000000..6cb4f4611 --- /dev/null +++ b/internal/agents/detect_test.go @@ -0,0 +1,149 @@ +package agents + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func lookup(vars map[string]string) func(string) (string, bool) { + return func(key string) (string, bool) { + v, ok := vars[key] + return v, ok + } +} + +func TestParseAgentName(t *testing.T) { + tests := []struct { + name string + input string + want AgentName + wantErr bool + }{ + {name: "valid lowercase", input: "my-agent", want: "my-agent"}, + {name: "valid with underscore", input: "my_agent_v2", want: "my_agent_v2"}, + {name: "valid uppercase", input: "MyAgent", want: "MyAgent"}, + {name: "valid numbers", input: "agent123", want: "agent123"}, + {name: "spaces rejected", input: "my agent", wantErr: true}, + {name: "newline rejected", input: "my\nagent", wantErr: true}, + {name: "carriage return rejected", input: "my\ragent", wantErr: true}, + {name: "null byte rejected", input: "my\x00agent", wantErr: true}, + {name: "dot rejected", input: "my.agent", wantErr: true}, + {name: "slash rejected", input: "my/agent", wantErr: true}, + {name: "empty rejected", input: "", wantErr: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseAgentName(tt.input) + if tt.wantErr { + require.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, tt.want, got) + } + }) + } +} + +func TestDetectWith(t *testing.T) { + tests := []struct { + name string + env map[string]string + wantAgent AgentName + }{ + { + name: "clean environment", + env: map[string]string{}, + wantAgent: "", + }, + { + name: "empty var is not detected", + env: map[string]string{"GEMINI_CLI": ""}, + wantAgent: "", + }, + { + name: "AGENT=amp detected as amp", + env: map[string]string{"AGENT": "amp"}, + wantAgent: "amp", + }, + { + name: "AGENT with non-amp value is ignored", + env: map[string]string{"AGENT": "other"}, + wantAgent: "", + }, + { + name: "AI_AGENT returns value as agent name", + env: map[string]string{"AI_AGENT": "some-agent"}, + wantAgent: "some-agent", + }, + { + name: "AI_AGENT with invalid characters is ignored", + env: map[string]string{"AI_AGENT": "bad\nagent"}, + wantAgent: "", + }, + { + name: "AI_AGENT with spaces is ignored", + env: map[string]string{"AI_AGENT": "bad agent"}, + wantAgent: "", + }, + { + name: "AI_AGENT takes priority over AGENT", + env: map[string]string{"AGENT": "amp", "AI_AGENT": "other"}, + wantAgent: "other", + }, + { + name: "CODEX_SANDBOX", + env: map[string]string{"CODEX_SANDBOX": "seatbelt"}, + wantAgent: "codex", + }, + { + name: "CODEX_CI", + env: map[string]string{"CODEX_CI": "1"}, + wantAgent: "codex", + }, + { + name: "CODEX_THREAD_ID", + env: map[string]string{"CODEX_THREAD_ID": "abc"}, + wantAgent: "codex", + }, + { + name: "GEMINI_CLI", + env: map[string]string{"GEMINI_CLI": "1"}, + wantAgent: "gemini-cli", + }, + { + name: "COPILOT_CLI", + env: map[string]string{"COPILOT_CLI": "1"}, + wantAgent: "copilot-cli", + }, + { + name: "OPENCODE", + env: map[string]string{"OPENCODE": "1"}, + wantAgent: "opencode", + }, + { + name: "CLAUDECODE", + env: map[string]string{"CLAUDECODE": "1"}, + wantAgent: "claude-code", + }, + { + name: "AGENT=amp takes priority over CLAUDECODE", + env: map[string]string{"AGENT": "amp", "CLAUDECODE": "1"}, + wantAgent: "amp", + }, + { + name: "invalid AI_AGENT falls through to tool-specific detection", + env: map[string]string{"AI_AGENT": "bad agent", "GEMINI_CLI": "1"}, + wantAgent: "gemini-cli", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := detectWith(lookup(tt.env)) + assert.Equal(t, tt.wantAgent, got) + }) + } +} diff --git a/internal/ghcmd/cmd.go b/internal/ghcmd/cmd.go index 9fc5bcefe..8690078c6 100644 --- a/internal/ghcmd/cmd.go +++ b/internal/ghcmd/cmd.go @@ -15,6 +15,7 @@ import ( surveyCore "github.com/AlecAivazis/survey/v2/core" "github.com/AlecAivazis/survey/v2/terminal" "github.com/cli/cli/v2/api" + "github.com/cli/cli/v2/internal/agents" "github.com/cli/cli/v2/internal/build" "github.com/cli/cli/v2/internal/config" "github.com/cli/cli/v2/internal/config/migration" @@ -44,7 +45,7 @@ func Main() exitCode { buildVersion := build.Version hasDebug, _ := utils.IsDebugEnabled() - cmdFactory := factory.New(buildVersion) + cmdFactory := factory.New(buildVersion, string(agents.Detect())) stderr := cmdFactory.IOStreams.ErrOut ctx := context.Background() diff --git a/pkg/cmd/attestation/verify/verify_integration_test.go b/pkg/cmd/attestation/verify/verify_integration_test.go index d77f21f70..ec64cefa7 100644 --- a/pkg/cmd/attestation/verify/verify_integration_test.go +++ b/pkg/cmd/attestation/verify/verify_integration_test.go @@ -26,7 +26,7 @@ func TestVerifyIntegration(t *testing.T) { TUFMetadataDir: o.Some(t.TempDir()), } - cmdFactory := factory.New("test") + cmdFactory := factory.New("test", "") hc, err := cmdFactory.HttpClient() if err != nil { @@ -143,7 +143,7 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) { TUFMetadataDir: o.Some(t.TempDir()), } - cmdFactory := factory.New("test") + cmdFactory := factory.New("test", "") hc, err := cmdFactory.HttpClient() if err != nil { @@ -217,7 +217,7 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) { TUFMetadataDir: o.Some(t.TempDir()), } - cmdFactory := factory.New("test") + cmdFactory := factory.New("test", "") hc, err := cmdFactory.HttpClient() if err != nil { @@ -310,7 +310,7 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) { TUFMetadataDir: o.Some(t.TempDir()), } - cmdFactory := factory.New("test") + cmdFactory := factory.New("test", "") hc, err := cmdFactory.HttpClient() if err != nil { diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index b48840387..48ec0c8fe 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -26,23 +26,23 @@ import ( var ssoHeader string var ssoURLRE = regexp.MustCompile(`\burl=([^;]+)`) -func New(appVersion string) *cmdutil.Factory { +func New(appVersion string, invokingAgent string) *cmdutil.Factory { f := &cmdutil.Factory{ AppVersion: appVersion, Config: configFunc(), // No factory dependencies ExecutableName: "gh", } - f.IOStreams = ioStreams(f) // Depends on Config - f.HttpClient = httpClientFunc(f, appVersion) // Depends on Config, IOStreams, and appVersion - f.PlainHttpClient = plainHttpClientFunc(f, appVersion) // Depends on IOStreams, and appVersion - f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable - f.Remotes = remotesFunc(f) // Depends on Config, and GitClient - f.BaseRepo = BaseRepoFunc(f) // Depends on Remotes - f.Prompter = newPrompter(f) // Depends on Config and IOStreams - f.Browser = newBrowser(f) // Depends on Config, and IOStreams - f.ExtensionManager = extensionManager(f) // Depends on Config, HttpClient, and IOStreams - f.Branch = branchFunc(f) // Depends on GitClient + f.IOStreams = ioStreams(f) // Depends on Config + f.HttpClient = httpClientFunc(f, appVersion, invokingAgent) // Depends on Config, IOStreams, appVersion, and invokingAgent + f.PlainHttpClient = plainHttpClientFunc(f, appVersion, invokingAgent) // Depends on IOStreams, appVersion, and invokingAgent + f.GitClient = newGitClient(f) // Depends on IOStreams, and Executable + f.Remotes = remotesFunc(f) // Depends on Config, and GitClient + f.BaseRepo = BaseRepoFunc(f) // Depends on Remotes + f.Prompter = newPrompter(f) // Depends on Config and IOStreams + f.Browser = newBrowser(f) // Depends on Config, and IOStreams + f.ExtensionManager = extensionManager(f) // Depends on Config, HttpClient, and IOStreams + f.Branch = branchFunc(f) // Depends on GitClient return f } @@ -186,7 +186,7 @@ func remotesFunc(f *cmdutil.Factory) func() (ghContext.Remotes, error) { return rr.Resolver() } -func httpClientFunc(f *cmdutil.Factory, appVersion string) func() (*http.Client, error) { +func httpClientFunc(f *cmdutil.Factory, appVersion string, invokingAgent string) func() (*http.Client, error) { return func() (*http.Client, error) { io := f.IOStreams cfg, err := f.Config() @@ -194,10 +194,11 @@ func httpClientFunc(f *cmdutil.Factory, appVersion string) func() (*http.Client, return nil, err } opts := api.HTTPClientOptions{ - Config: cfg.Authentication(), - Log: io.ErrOut, - LogColorize: io.ColorEnabled(), - AppVersion: appVersion, + Config: cfg.Authentication(), + Log: io.ErrOut, + LogColorize: io.ColorEnabled(), + AppVersion: appVersion, + InvokingAgent: invokingAgent, } client, err := api.NewHTTPClient(opts) if err != nil { @@ -208,13 +209,14 @@ func httpClientFunc(f *cmdutil.Factory, appVersion string) func() (*http.Client, } } -func plainHttpClientFunc(f *cmdutil.Factory, appVersion string) func() (*http.Client, error) { +func plainHttpClientFunc(f *cmdutil.Factory, appVersion string, invokingAgent string) func() (*http.Client, error) { return func() (*http.Client, error) { io := f.IOStreams opts := api.HTTPClientOptions{ - Log: io.ErrOut, - LogColorize: io.ColorEnabled(), - AppVersion: appVersion, + Log: io.ErrOut, + LogColorize: io.ColorEnabled(), + AppVersion: appVersion, + InvokingAgent: invokingAgent, // This is required to prevent automatic setting of auth and other headers. SkipDefaultHeaders: true, } diff --git a/pkg/cmd/factory/default_test.go b/pkg/cmd/factory/default_test.go index 7e0ed4af6..7d84caa8f 100644 --- a/pkg/cmd/factory/default_test.go +++ b/pkg/cmd/factory/default_test.go @@ -66,7 +66,7 @@ func Test_BaseRepo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - f := New("1") + f := New("1", "") rr := &remoteResolver{ readRemotes: func() (git.RemoteSet, error) { return tt.remotes, nil @@ -204,7 +204,7 @@ func Test_SmartBaseRepo(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - f := New("1") + f := New("1", "") rr := &remoteResolver{ readRemotes: func() (git.RemoteSet, error) { return tt.remotes, nil @@ -297,7 +297,7 @@ func Test_OverrideBaseRepo(t *testing.T) { if tt.envOverride != "" { t.Setenv("GH_REPO", tt.envOverride) } - f := New("1") + f := New("1", "") rr := &remoteResolver{ readRemotes: func() (git.RemoteSet, error) { return tt.remotes, nil @@ -375,7 +375,7 @@ func Test_ioStreams_pager(t *testing.T) { t.Setenv(k, v) } } - f := New("1") + f := New("1", "") f.Config = func() (gh.Config, error) { if tt.config == nil { return config.NewBlankConfig(), nil @@ -418,7 +418,7 @@ func Test_ioStreams_prompt(t *testing.T) { t.Setenv(k, v) } } - f := New("1") + f := New("1", "") f.Config = func() (gh.Config, error) { if tt.config == nil { return config.NewBlankConfig(), nil @@ -496,7 +496,7 @@ func Test_ioStreams_spinnerDisabled(t *testing.T) { for k, v := range tt.env { t.Setenv(k, v) } - f := New("1") + f := New("1", "") f.Config = func() (gh.Config, error) { if tt.config == nil { return config.NewBlankConfig(), nil @@ -564,7 +564,7 @@ func Test_ioStreams_accessiblePrompterEnabled(t *testing.T) { for k, v := range tt.env { t.Setenv(k, v) } - f := New("1") + f := New("1", "") f.Config = func() (gh.Config, error) { if tt.config == nil { return config.NewBlankConfig(), nil @@ -642,7 +642,7 @@ func Test_ioStreams_colorLabels(t *testing.T) { t.Setenv(k, v) } } - f := New("1") + f := New("1", "") f.Config = func() (gh.Config, error) { if tt.config == nil { return config.NewBlankConfig(), nil @@ -683,13 +683,13 @@ func TestSSOURL(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - f := New("1") + f := New("1", "") f.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } ios, _, _, stderr := iostreams.Test() f.IOStreams = ios - client, err := httpClientFunc(f, "v1.2.3")() + client, err := httpClientFunc(f, "v1.2.3", "")() require.NoError(t, err) req, err := http.NewRequest("GET", ts.URL, nil) if tt.sso != "" { @@ -718,13 +718,13 @@ func TestPlainHttpClient(t *testing.T) { })) defer ts.Close() - f := New("1") + f := New("1", "") f.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil } ios, _, _, _ := iostreams.Test() f.IOStreams = ios - client, err := plainHttpClientFunc(f, "v1.2.3")() + client, err := plainHttpClientFunc(f, "v1.2.3", "")() require.NoError(t, err) req, err := http.NewRequest("GET", ts.URL, nil) @@ -759,7 +759,7 @@ func TestNewGitClient(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - f := New("1") + f := New("1", "") f.Config = func() (gh.Config, error) { if tt.config == nil { return config.NewBlankConfig(), nil diff --git a/pkg/cmd/search/shared/shared_test.go b/pkg/cmd/search/shared/shared_test.go index 0700a688e..c66e0908f 100644 --- a/pkg/cmd/search/shared/shared_test.go +++ b/pkg/cmd/search/shared/shared_test.go @@ -15,7 +15,7 @@ import ( ) func TestSearcher(t *testing.T) { - f := factory.New("1") + f := factory.New("1", "") f.Config = func() (gh.Config, error) { return config.NewBlankConfig(), nil }