Skip to content

Commit 1ea2952

Browse files
Merge pull request cli#13023 from cli/agent-in-header
Record agentic invocations in User-Agent header
2 parents b046d20 + c51769c commit 1ea2952

10 files changed

Lines changed: 311 additions & 41 deletions

File tree

api/client.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ import (
1515
)
1616

1717
const (
18-
accept = "Accept"
1918
apiVersion = "X-GitHub-Api-Version"
2019
apiVersionValue = "2022-11-28"
2120
authorization = "Authorization"

api/http_client.go

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type tokenGetter interface {
1818

1919
type HTTPClientOptions struct {
2020
AppVersion string
21+
InvokingAgent string
2122
CacheTTL time.Duration
2223
Config tokenGetter
2324
EnableCache bool
@@ -48,8 +49,13 @@ func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) {
4849
clientOpts.LogVerboseHTTP = opts.LogVerboseHTTP
4950
}
5051

52+
ua := fmt.Sprintf("GitHub CLI %s", opts.AppVersion)
53+
if opts.InvokingAgent != "" {
54+
ua = fmt.Sprintf("%s Agent/%s", ua, opts.InvokingAgent)
55+
}
56+
5157
headers := map[string]string{
52-
userAgent: fmt.Sprintf("GitHub CLI %s", opts.AppVersion),
58+
userAgent: ua,
5359
apiVersion: apiVersionValue,
5460
}
5561
clientOpts.Headers = headers

api/http_client_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ func TestNewHTTPClient(t *testing.T) {
2020
type args struct {
2121
config tokenGetter
2222
appVersion string
23+
invokingAgent string
2324
logVerboseHTTP bool
2425
skipDefaultHeaders bool
2526
}
@@ -155,6 +156,18 @@ func TestNewHTTPClient(t *testing.T) {
155156
* Request took <duration>
156157
`),
157158
},
159+
{
160+
name: "includes invoking agent in user-agent header",
161+
args: args{
162+
appVersion: "v1.2.3",
163+
invokingAgent: "copilot-cli",
164+
},
165+
host: "github.com",
166+
wantHeader: map[string][]string{
167+
"user-agent": {"GitHub CLI v1.2.3 Agent/copilot-cli"},
168+
},
169+
wantStderr: "",
170+
},
158171
}
159172

160173
var gotReq *http.Request
@@ -169,6 +182,7 @@ func TestNewHTTPClient(t *testing.T) {
169182
ios, _, _, stderr := iostreams.Test()
170183
client, err := NewHTTPClient(HTTPClientOptions{
171184
AppVersion: tt.args.appVersion,
185+
InvokingAgent: tt.args.invokingAgent,
172186
Config: tt.args.config,
173187
Log: ios.ErrOut,
174188
LogVerboseHTTP: tt.args.logVerboseHTTP,

internal/agents/detect.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package agents
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"regexp"
7+
)
8+
9+
// AgentName is a validated agent identifier safe for use in HTTP headers.
10+
type AgentName string
11+
12+
const (
13+
agentAmp AgentName = "amp"
14+
agentClaudeCode AgentName = "claude-code"
15+
agentCodex AgentName = "codex"
16+
agentCopilotCLI AgentName = "copilot-cli"
17+
agentGeminiCLI AgentName = "gemini-cli"
18+
agentOpencode AgentName = "opencode"
19+
)
20+
21+
var validAgentName = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
22+
23+
// parseAgentName validates and returns an AgentName from a raw string.
24+
// Only alphanumeric characters, hyphens, and underscores are allowed.
25+
func parseAgentName(s string) (AgentName, error) {
26+
if !validAgentName.MatchString(s) {
27+
return "", fmt.Errorf("invalid agent name %q: must match [a-zA-Z0-9_-]+", s)
28+
}
29+
return AgentName(s), nil
30+
}
31+
32+
// Detect returns the name of the AI coding agent driving the CLI,
33+
// or an empty AgentName if none is detected.
34+
func Detect() AgentName {
35+
return detectWith(os.LookupEnv)
36+
}
37+
38+
func detectWith(lookup func(string) (string, bool)) AgentName {
39+
isSet := func(key string) bool {
40+
v, ok := lookup(key)
41+
return ok && v != ""
42+
}
43+
44+
valueOf := func(key string) string {
45+
v, _ := lookup(key)
46+
return v
47+
}
48+
49+
// Generic agent identifiers — checked first because they are the most specific signal.
50+
if v, ok := lookup("AI_AGENT"); ok && v != "" {
51+
if name, err := parseAgentName(v); err == nil {
52+
return name
53+
}
54+
}
55+
56+
// Tool-specific variables.
57+
58+
// Check AGENT=amp before the more generic CLAUDECODE=1 since Amp sets both.
59+
if valueOf("AGENT") == "amp" {
60+
return agentAmp
61+
}
62+
63+
// OpenAI Codex CLI — https://github.com/openai/codex
64+
// CODEX_SANDBOX: https://github.com/openai/codex/blob/95e1d5993985019ce0ce0d10689caf1375f95120/codex-rs/core/src/spawn.rs#L25
65+
// CODEX_THREAD_ID: https://github.com/openai/codex/blob/95e1d5993985019ce0ce0d10689caf1375f95120/codex-rs/core/src/exec_env.rs#L8
66+
// CODEX_CI: https://github.com/openai/codex/blob/95e1d5993985019ce0ce0d10689caf1375f95120/codex-rs/core/src/unified_exec/process_manager.rs#L64
67+
if isSet("CODEX_SANDBOX") || isSet("CODEX_CI") || isSet("CODEX_THREAD_ID") {
68+
return agentCodex
69+
}
70+
71+
// Google Gemini CLI — https://github.com/google-gemini/gemini-cli
72+
// GEMINI_CLI: https://github.com/google-gemini/gemini-cli/blob/46fd7b4864111032a1c7dfa1821b2000fc7531da/docs/tools/shell.md#L96-L97
73+
if isSet("GEMINI_CLI") {
74+
return agentGeminiCLI
75+
}
76+
77+
// GitHub Copilot CLI
78+
// No first-party docs
79+
if isSet("COPILOT_CLI") {
80+
return agentCopilotCLI
81+
}
82+
83+
// OpenCode — https://github.com/anomalyco/opencode
84+
// OPENCODE: https://github.com/anomalyco/opencode/blob/fde201c286a83ff32dda9b41d61d734a4449fe70/packages/opencode/src/index.ts#L78-L80
85+
if isSet("OPENCODE") {
86+
return agentOpencode
87+
}
88+
89+
// Anthropic Claude Code — https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview
90+
// CLAUDECODE: https://code.claude.com/docs/en/env-vars (CLAUDECODE section)
91+
// Checked last because other agents (e.g. Amp) set CLAUDECODE=1 alongside their own vars.
92+
if isSet("CLAUDECODE") {
93+
// There is a CLAUDE_CODE_ENTRYPOINT env var that is set to `cli` or `desktop` etc, but it's not documented
94+
// so we don't want to rely on it too heavily. We'll just return a generic claude-code agent name.
95+
return agentClaudeCode
96+
}
97+
98+
return ""
99+
}

internal/agents/detect_test.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
package agents
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
"github.com/stretchr/testify/require"
8+
)
9+
10+
func lookup(vars map[string]string) func(string) (string, bool) {
11+
return func(key string) (string, bool) {
12+
v, ok := vars[key]
13+
return v, ok
14+
}
15+
}
16+
17+
func TestParseAgentName(t *testing.T) {
18+
tests := []struct {
19+
name string
20+
input string
21+
want AgentName
22+
wantErr bool
23+
}{
24+
{name: "valid lowercase", input: "my-agent", want: "my-agent"},
25+
{name: "valid with underscore", input: "my_agent_v2", want: "my_agent_v2"},
26+
{name: "valid uppercase", input: "MyAgent", want: "MyAgent"},
27+
{name: "valid numbers", input: "agent123", want: "agent123"},
28+
{name: "spaces rejected", input: "my agent", wantErr: true},
29+
{name: "newline rejected", input: "my\nagent", wantErr: true},
30+
{name: "carriage return rejected", input: "my\ragent", wantErr: true},
31+
{name: "null byte rejected", input: "my\x00agent", wantErr: true},
32+
{name: "dot rejected", input: "my.agent", wantErr: true},
33+
{name: "slash rejected", input: "my/agent", wantErr: true},
34+
{name: "empty rejected", input: "", wantErr: true},
35+
}
36+
37+
for _, tt := range tests {
38+
t.Run(tt.name, func(t *testing.T) {
39+
got, err := parseAgentName(tt.input)
40+
if tt.wantErr {
41+
require.Error(t, err)
42+
} else {
43+
require.NoError(t, err)
44+
assert.Equal(t, tt.want, got)
45+
}
46+
})
47+
}
48+
}
49+
50+
func TestDetectWith(t *testing.T) {
51+
tests := []struct {
52+
name string
53+
env map[string]string
54+
wantAgent AgentName
55+
}{
56+
{
57+
name: "clean environment",
58+
env: map[string]string{},
59+
wantAgent: "",
60+
},
61+
{
62+
name: "empty var is not detected",
63+
env: map[string]string{"GEMINI_CLI": ""},
64+
wantAgent: "",
65+
},
66+
{
67+
name: "AGENT=amp detected as amp",
68+
env: map[string]string{"AGENT": "amp"},
69+
wantAgent: "amp",
70+
},
71+
{
72+
name: "AGENT with non-amp value is ignored",
73+
env: map[string]string{"AGENT": "other"},
74+
wantAgent: "",
75+
},
76+
{
77+
name: "AI_AGENT returns value as agent name",
78+
env: map[string]string{"AI_AGENT": "some-agent"},
79+
wantAgent: "some-agent",
80+
},
81+
{
82+
name: "AI_AGENT with invalid characters is ignored",
83+
env: map[string]string{"AI_AGENT": "bad\nagent"},
84+
wantAgent: "",
85+
},
86+
{
87+
name: "AI_AGENT with spaces is ignored",
88+
env: map[string]string{"AI_AGENT": "bad agent"},
89+
wantAgent: "",
90+
},
91+
{
92+
name: "AI_AGENT takes priority over AGENT",
93+
env: map[string]string{"AGENT": "amp", "AI_AGENT": "other"},
94+
wantAgent: "other",
95+
},
96+
{
97+
name: "CODEX_SANDBOX",
98+
env: map[string]string{"CODEX_SANDBOX": "seatbelt"},
99+
wantAgent: "codex",
100+
},
101+
{
102+
name: "CODEX_CI",
103+
env: map[string]string{"CODEX_CI": "1"},
104+
wantAgent: "codex",
105+
},
106+
{
107+
name: "CODEX_THREAD_ID",
108+
env: map[string]string{"CODEX_THREAD_ID": "abc"},
109+
wantAgent: "codex",
110+
},
111+
{
112+
name: "GEMINI_CLI",
113+
env: map[string]string{"GEMINI_CLI": "1"},
114+
wantAgent: "gemini-cli",
115+
},
116+
{
117+
name: "COPILOT_CLI",
118+
env: map[string]string{"COPILOT_CLI": "1"},
119+
wantAgent: "copilot-cli",
120+
},
121+
{
122+
name: "OPENCODE",
123+
env: map[string]string{"OPENCODE": "1"},
124+
wantAgent: "opencode",
125+
},
126+
{
127+
name: "CLAUDECODE",
128+
env: map[string]string{"CLAUDECODE": "1"},
129+
wantAgent: "claude-code",
130+
},
131+
{
132+
name: "AGENT=amp takes priority over CLAUDECODE",
133+
env: map[string]string{"AGENT": "amp", "CLAUDECODE": "1"},
134+
wantAgent: "amp",
135+
},
136+
{
137+
name: "invalid AI_AGENT falls through to tool-specific detection",
138+
env: map[string]string{"AI_AGENT": "bad agent", "GEMINI_CLI": "1"},
139+
wantAgent: "gemini-cli",
140+
},
141+
}
142+
143+
for _, tt := range tests {
144+
t.Run(tt.name, func(t *testing.T) {
145+
got := detectWith(lookup(tt.env))
146+
assert.Equal(t, tt.wantAgent, got)
147+
})
148+
}
149+
}

internal/ghcmd/cmd.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
surveyCore "github.com/AlecAivazis/survey/v2/core"
1616
"github.com/AlecAivazis/survey/v2/terminal"
1717
"github.com/cli/cli/v2/api"
18+
"github.com/cli/cli/v2/internal/agents"
1819
"github.com/cli/cli/v2/internal/build"
1920
"github.com/cli/cli/v2/internal/config"
2021
"github.com/cli/cli/v2/internal/config/migration"
@@ -44,7 +45,7 @@ func Main() exitCode {
4445
buildVersion := build.Version
4546
hasDebug, _ := utils.IsDebugEnabled()
4647

47-
cmdFactory := factory.New(buildVersion)
48+
cmdFactory := factory.New(buildVersion, string(agents.Detect()))
4849
stderr := cmdFactory.IOStreams.ErrOut
4950

5051
ctx := context.Background()

pkg/cmd/attestation/verify/verify_integration_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ func TestVerifyIntegration(t *testing.T) {
2626
TUFMetadataDir: o.Some(t.TempDir()),
2727
}
2828

29-
cmdFactory := factory.New("test")
29+
cmdFactory := factory.New("test", "")
3030

3131
hc, err := cmdFactory.HttpClient()
3232
if err != nil {
@@ -143,7 +143,7 @@ func TestVerifyIntegrationCustomIssuer(t *testing.T) {
143143
TUFMetadataDir: o.Some(t.TempDir()),
144144
}
145145

146-
cmdFactory := factory.New("test")
146+
cmdFactory := factory.New("test", "")
147147

148148
hc, err := cmdFactory.HttpClient()
149149
if err != nil {
@@ -217,7 +217,7 @@ func TestVerifyIntegrationReusableWorkflow(t *testing.T) {
217217
TUFMetadataDir: o.Some(t.TempDir()),
218218
}
219219

220-
cmdFactory := factory.New("test")
220+
cmdFactory := factory.New("test", "")
221221

222222
hc, err := cmdFactory.HttpClient()
223223
if err != nil {
@@ -310,7 +310,7 @@ func TestVerifyIntegrationReusableWorkflowSignerWorkflow(t *testing.T) {
310310
TUFMetadataDir: o.Some(t.TempDir()),
311311
}
312312

313-
cmdFactory := factory.New("test")
313+
cmdFactory := factory.New("test", "")
314314

315315
hc, err := cmdFactory.HttpClient()
316316
if err != nil {

0 commit comments

Comments
 (0)