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>
This commit is contained in:
William Martin 2026-03-24 17:05:34 +01:00
parent 8723e3bb52
commit c51769c977
10 changed files with 311 additions and 41 deletions

View file

@ -15,7 +15,6 @@ import (
)
const (
accept = "Accept"
apiVersion = "X-GitHub-Api-Version"
apiVersionValue = "2022-11-28"
authorization = "Authorization"

View file

@ -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

View file

@ -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 <duration>
`),
},
{
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,

99
internal/agents/detect.go Normal file
View file

@ -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 ""
}

View file

@ -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)
})
}
}

View file

@ -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()

View file

@ -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 {

View file

@ -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,
}

View file

@ -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

View file

@ -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
}