Skip to content

Commit 6de39fb

Browse files
committed
Merge branch 'main' into dependabot/go_modules/github.com/auth0/go-auth0/v2-2.13.0
2 parents e5aa60d + a06950e commit 6de39fb

9 files changed

Lines changed: 941 additions & 54 deletions

File tree

internal/analytics/analytics.go

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import (
1212
"sync"
1313
"time"
1414

15-
"github.com/spf13/cobra"
1615
"golang.org/x/text/cases"
1716
"golang.org/x/text/language"
1817

@@ -46,15 +45,15 @@ func NewTracker() *Tracker {
4645

4746
func (t *Tracker) TrackFirstLogin(id string, loginType string) {
4847
eventName := fmt.Sprintf("%s - Auth0 - First Login", eventNamePrefix)
49-
t.track(eventName, id)
48+
t.track(eventName, id, nil)
5049

5150
eventName = fmt.Sprintf("%s - Auth0 - First Login - %s", eventNamePrefix, loginType)
52-
t.track(eventName, id)
51+
t.track(eventName, id, nil)
5352
}
5453

55-
func (t *Tracker) TrackCommandRun(cmd *cobra.Command, id string) {
56-
eventName := generateRunEventName(cmd.CommandPath())
57-
t.track(eventName, id)
54+
func (t *Tracker) TrackCommandRun(commandPath string, id string, properties map[string]string) {
55+
eventName := generateRunEventName(commandPath)
56+
t.track(eventName, id, properties)
5857
}
5958

6059
func (t *Tracker) Wait(ctx context.Context) {
@@ -73,12 +72,12 @@ func (t *Tracker) Wait(ctx context.Context) {
7372
}
7473
}
7574

76-
func (t *Tracker) track(eventName string, id string) {
75+
func (t *Tracker) track(eventName string, id string, properties map[string]string) {
7776
if !shouldTrack() {
7877
return
7978
}
8079

81-
event := newEvent(eventName, id)
80+
event := newEvent(eventName, id, properties)
8281

8382
t.wg.Add(1)
8483
go t.sendEvent(event)
@@ -111,17 +110,23 @@ func (t *Tracker) sendEvent(event *event) {
111110
}()
112111
}
113112

114-
func newEvent(eventName string, id string) *event {
113+
func newEvent(eventName string, id string, properties map[string]string) *event {
114+
eventProperties := map[string]string{
115+
versionKey: buildinfo.Version,
116+
osKey: runtime.GOOS,
117+
archKey: runtime.GOARCH,
118+
}
119+
120+
for k, v := range properties {
121+
eventProperties[k] = v
122+
}
123+
115124
return &event{
116-
App: appID,
117-
ID: id,
118-
Event: eventName,
119-
Timestamp: timestamp(),
120-
Properties: map[string]string{
121-
versionKey: buildinfo.Version,
122-
osKey: runtime.GOOS,
123-
archKey: runtime.GOARCH,
124-
},
125+
App: appID,
126+
ID: id,
127+
Event: eventName,
128+
Timestamp: timestamp(),
129+
Properties: eventProperties,
125130
}
126131
}
127132

internal/analytics/analytics_test.go

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -35,34 +35,28 @@ func TestGenerateEventName(t *testing.T) {
3535
}
3636

3737
func TestGenerateRunEventName(t *testing.T) {
38-
t.Run("generates from root command run", func(t *testing.T) {
38+
t.Run("generates from root command", func(t *testing.T) {
3939
want := "CLI - Auth0 - Run"
4040
got := generateRunEventName("auth0")
4141
assert.Equal(t, want, got)
4242
})
4343

44-
t.Run("generates from top-level command run", func(t *testing.T) {
44+
t.Run("generates from top-level command", func(t *testing.T) {
4545
want := "CLI - Auth0 - Apps - Run"
4646
got := generateRunEventName("auth0 apps")
4747
assert.Equal(t, want, got)
4848
})
4949

50-
t.Run("generates from subcommand run", func(t *testing.T) {
50+
t.Run("generates from subcommand", func(t *testing.T) {
5151
want := "CLI - Apps - List - Run"
5252
got := generateRunEventName("auth0 apps list")
5353
assert.Equal(t, want, got)
5454
})
55-
56-
t.Run("generates from deep subcommand run", func(t *testing.T) {
57-
want := "CLI - Apis - Scopes List - Run"
58-
got := generateRunEventName("auth0 apis scopes list")
59-
assert.Equal(t, want, got)
60-
})
6155
}
6256

6357
func TestNewEvent(t *testing.T) {
6458
t.Run("creates a new event instance", func(t *testing.T) {
65-
event := newEvent("event", "id")
59+
event := newEvent("event", "id", nil)
6660
// Assert that the interval between the event timestamp and now is within 1 second.
6761
assert.WithinDuration(t, time.Now(), time.Unix(0, event.Timestamp*int64(1000000)), 1*time.Second)
6862
assert.Equal(t, event.App, appID)
@@ -71,4 +65,11 @@ func TestNewEvent(t *testing.T) {
7165
assert.Equal(t, event.Properties[osKey], runtime.GOOS)
7266
assert.Equal(t, event.Properties[archKey], runtime.GOARCH)
7367
})
68+
69+
t.Run("merges extra properties", func(t *testing.T) {
70+
event := newEvent("event", "id", map[string]string{"success": "false", "error_class": "auth"})
71+
assert.Equal(t, "false", event.Properties["success"])
72+
assert.Equal(t, "auth", event.Properties["error_class"])
73+
assert.Equal(t, runtime.GOOS, event.Properties[osKey])
74+
})
7475
}

internal/cli/agent_detection.go

Lines changed: 238 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,238 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"runtime"
8+
"strings"
9+
"sync"
10+
)
11+
12+
// agentEnvEntry maps an env var to a canonical agent_client name.
13+
// The requiredPrefix field restricts matching to values with that prefix (case-insensitive).
14+
type agentEnvEntry struct {
15+
envVar string
16+
requiredPrefix string
17+
agentName string
18+
}
19+
20+
// agentEnvTable is the ordered allow-list of agent env signals. First match wins.
21+
var agentEnvTable = []agentEnvEntry{
22+
// Claude Code.
23+
{envVar: "CLAUDECODE", agentName: "claude-code"},
24+
{envVar: "CLAUDE_CODE_SESSION_ID", agentName: "claude-code"},
25+
{envVar: "CLAUDE_CODE_ENTRYPOINT", agentName: "claude-code"},
26+
{envVar: "AI_AGENT", requiredPrefix: "claude-code", agentName: "claude-code"},
27+
// Cursor.
28+
{envVar: "CURSOR_AGENT", agentName: "cursor"},
29+
{envVar: "CURSOR_TRACE_ID", agentName: "cursor"},
30+
{envVar: "CURSOR_CONVERSATION_ID", agentName: "cursor"},
31+
// Codex.
32+
{envVar: "CODEX_THREAD_ID", agentName: "codex"},
33+
// Gemini-cli.
34+
{envVar: "GEMINI_CLI_VERSION", agentName: "gemini"},
35+
// AntiGravity.
36+
{envVar: "ANTIGRAVITY_CLI_ALIAS", agentName: "antigravity"},
37+
{envVar: "ANTIGRAVITY_CONVERSATION_ID", agentName: "antigravity"},
38+
// AI_AGENT catch-all (must be last).
39+
{envVar: "AI_AGENT", agentName: "unknown-agent"},
40+
}
41+
42+
// agentProcessNames maps parent process names (partial, lower-cased) to agent names.
43+
// Covers both AI agent binaries and CLI surfaces that spawn auth0-cli as a subprocess.
44+
var agentProcessNames = map[string]string{
45+
// AI agents.
46+
"claude": "claude-code",
47+
"cursor": "cursor",
48+
"copilot": "github-copilot",
49+
"codex": "codex",
50+
"gemini": "gemini",
51+
"agy": "antigravity",
52+
// Auth0 first-party CLI surfaces.
53+
"auth0-mcp-server": "mcp-server",
54+
}
55+
56+
// detectAgent resolves agent_client via a waterfall:
57+
// Tier 1 AUTH0_CLI_CLIENT handshake, Tier 2 env allow-list, Tier 3 parent-process walk, Tier 4 fallback.
58+
func detectAgent(interactive bool) string {
59+
return detectAgentWithEnv(os.Getenv, os.Environ, os.Getppid, getProcInfo, interactive)
60+
}
61+
62+
// agentEnvSuffixes are naming conventions shared across agent CLIs. Matching any of
63+
// these on an env var key signals an agent we don't yet have a named entry for.
64+
var agentEnvSuffixes = []string{
65+
"_CONVERSATION_ID",
66+
"_THREAD_ID",
67+
"_AGENT_SESSION_ID",
68+
}
69+
70+
// detectAgentWithEnv is the testable form, accepting injected env/process readers.
71+
func detectAgentWithEnv(
72+
getEnv func(string) string,
73+
environ func() []string,
74+
getppid func() int,
75+
procInfo func(int) (string, int),
76+
interactive bool,
77+
) string {
78+
// Tier 1: Handshake — AUTH0_CLI_CLIENT set by our own surfaces.
79+
if client := strings.TrimSpace(getEnv("AUTH0_CLI_CLIENT")); client != "" {
80+
return sanitizeAgentName(client)
81+
}
82+
83+
// Tier 2: Env allow-list.
84+
for _, entry := range agentEnvTable {
85+
raw := strings.TrimSpace(getEnv(entry.envVar))
86+
if raw == "" {
87+
continue
88+
}
89+
90+
if entry.requiredPrefix != "" {
91+
if !strings.HasPrefix(strings.ToLower(raw), strings.ToLower(entry.requiredPrefix)) {
92+
continue
93+
}
94+
}
95+
96+
return entry.agentName
97+
}
98+
99+
// Tier 2b: Wildcard sweep for unknown future agents. Catches the shared
100+
// naming conventions (*_CONVERSATION_ID / *_THREAD_ID / *_AGENT_SESSION_ID)
101+
// without a per-agent code change. Returns the generic "unknown-agent".
102+
for _, kv := range environ() {
103+
key, val, ok := strings.Cut(kv, "=")
104+
if !ok || strings.TrimSpace(val) == "" {
105+
continue
106+
}
107+
108+
upperKey := strings.ToUpper(key)
109+
// Unlisted CURSOR_* infra vars share generic agent suffixes; skip them here
110+
// so they don't false-positive as unknown-agent. Named CURSOR_* entries are
111+
// matched in Tier 2 above.
112+
if strings.HasPrefix(upperKey, "CURSOR_") {
113+
continue
114+
}
115+
for _, suffix := range agentEnvSuffixes {
116+
if strings.HasSuffix(upperKey, suffix) {
117+
return "unknown-agent"
118+
}
119+
}
120+
}
121+
122+
// Tier 3: Parent-process walk (up to 3 levels).
123+
// Note: Tier 2 may return "unknown-agent" (env matched but no specific agent),
124+
// which is distinct from Tier 4 fallback "unknown" (no signal found).
125+
pid := getppid()
126+
for depth := 0; depth < 3 && pid > 1; depth++ {
127+
rawName, nextPPID := procInfo(pid)
128+
name := strings.ToLower(strings.TrimSpace(rawName))
129+
if name == "" {
130+
break
131+
}
132+
133+
for fragment, agentName := range agentProcessNames {
134+
if strings.Contains(name, fragment) {
135+
return agentName
136+
}
137+
}
138+
139+
if nextPPID <= 1 {
140+
break
141+
}
142+
143+
pid = nextPPID
144+
}
145+
146+
// Tier 4: Fallback.
147+
if !interactive {
148+
return "unknown"
149+
}
150+
151+
return "human"
152+
}
153+
154+
type procInfo struct {
155+
ppid int
156+
name string
157+
}
158+
159+
var (
160+
procCache = make(map[int]procInfo)
161+
procCacheMu sync.Mutex
162+
)
163+
164+
// getProcInfo returns the process name and parent PID for a PID, cached to avoid
165+
// repeat lookups. Linux and macOS only; elsewhere returns ("", 0).
166+
func getProcInfo(pid int) (string, int) {
167+
procCacheMu.Lock()
168+
defer procCacheMu.Unlock()
169+
170+
if info, ok := procCache[pid]; ok {
171+
return info.name, info.ppid
172+
}
173+
174+
var name string
175+
var ppid int
176+
177+
switch runtime.GOOS {
178+
case "linux":
179+
commData, err := os.ReadFile(fmt.Sprintf("/proc/%d/comm", pid))
180+
if err == nil {
181+
name = strings.TrimSpace(string(commData))
182+
}
183+
184+
statusData, err := os.ReadFile(fmt.Sprintf("/proc/%d/status", pid))
185+
if err == nil {
186+
for _, line := range strings.Split(string(statusData), "\n") {
187+
if strings.HasPrefix(line, "PPid:") {
188+
var parsedPPID int
189+
if _, err := fmt.Sscanf(strings.TrimPrefix(line, "PPid:"), "%d", &parsedPPID); err == nil {
190+
ppid = parsedPPID
191+
break
192+
}
193+
}
194+
}
195+
}
196+
case "darwin":
197+
// On macOS, query PPID and command name in a single ps invocation to avoid an extra process spawn.
198+
out, err := exec.Command("ps", "-p", fmt.Sprintf("%d", pid), "-o", "ppid=", "-o", "comm=").Output()
199+
if err == nil {
200+
fields := strings.Fields(strings.TrimSpace(string(out)))
201+
if len(fields) >= 2 {
202+
var parsedPPID int
203+
if _, err := fmt.Sscanf(fields[0], "%d", &parsedPPID); err == nil {
204+
ppid = parsedPPID
205+
}
206+
name = strings.Join(fields[1:], " ")
207+
}
208+
}
209+
}
210+
211+
procCache[pid] = procInfo{name: name, ppid: ppid}
212+
return name, ppid
213+
}
214+
215+
// knownAgentClients is the allow-list for AUTH0_CLI_CLIENT. Extend when adding new surfaces.
216+
var knownAgentClients = []string{
217+
// Auth0 first-party surfaces.
218+
"mcp-server",
219+
"claude-code",
220+
"cursor",
221+
"github-copilot",
222+
"codex",
223+
"gemini",
224+
"antigravity",
225+
}
226+
227+
// sanitizeAgentName restricts AUTH0_CLI_CLIENT to the allow-list; unknown values are prefixed with "client-".
228+
func sanitizeAgentName(raw string) string {
229+
lower := strings.ToLower(strings.TrimSpace(raw))
230+
231+
for _, name := range knownAgentClients {
232+
if lower == name {
233+
return name
234+
}
235+
}
236+
237+
return "client-" + lower
238+
}

0 commit comments

Comments
 (0)