-
Notifications
You must be signed in to change notification settings - Fork 8.6k
Record agentic invocations in User-Agent header #13023
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| } | ||
|
Comment on lines
+52
to
+55
|
||
|
|
||
| headers := map[string]string{ | ||
| userAgent: fmt.Sprintf("GitHub CLI %s", opts.AppVersion), | ||
| userAgent: ua, | ||
| apiVersion: apiVersionValue, | ||
| } | ||
| clientOpts.Headers = headers | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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_-]+$`) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe also dots, slashes (/ and ), and brackets? As some agent may decide to add versions (in brackets).
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Purely based on what we've seen so far out in the wild, I'm inclined to leave this alone until we see something otherwise. |
||
|
|
||
| // 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 "" | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Was unused.