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>
149 lines
3.8 KiB
Go
149 lines
3.8 KiB
Go
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)
|
|
})
|
|
}
|
|
}
|