-
Notifications
You must be signed in to change notification settings - Fork 95
RTECO-1017 - Detect agent + CI invocation context, enrich metrics wit… #1555
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
base: master
Are you sure you want to change the base?
Changes from all commits
aaa4fda
89ba093
60e0bf5
6f29b6a
036d919
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 |
|---|---|---|
| @@ -0,0 +1,93 @@ | ||
| package commands | ||
|
|
||
| import ( | ||
| "os" | ||
| "sync" | ||
|
|
||
| clientlog "github.com/jfrog/jfrog-client-go/utils/log" | ||
| ) | ||
|
|
||
| // AgentUnknown is returned when a generic AGENT env var is set but its value | ||
| // does not match any known agent. We don't propagate the raw value to keep | ||
| // metric cardinality bounded. | ||
| const AgentUnknown = "unknown" | ||
|
|
||
| // ExecutionContext describes how a CLI invocation was launched. | ||
| type ExecutionContext struct { | ||
| Agent string // e.g. "claude", "cursor", "gemini", "unknown" or "" if none | ||
| IsAgent bool | ||
| IsInteractive bool // stdout is a TTY | ||
| TraceID string // propagated trace ID (e.g. CURSOR_TRACE_ID), empty if none | ||
| } | ||
|
|
||
| // agentDetector maps an agent name to env vars whose presence proves the agent | ||
| // invoked the CLI. | ||
| type agentDetector struct { | ||
| name string | ||
| envs []string | ||
| } | ||
|
|
||
| // agentEnvDetectors is the agent detection table. First match wins. | ||
| var agentEnvDetectors = []agentDetector{ | ||
| {"claude", []string{"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"}}, | ||
| {"gemini", []string{"GEMINI_CLI"}}, | ||
| {"goose", []string{"GOOSE_TERMINAL"}}, | ||
| {"cursor", []string{"CURSOR_AGENT", "CURSOR_CLI", "CURSOR_TRACE_ID"}}, | ||
| {"copilot", []string{"COPILOT_CLI"}}, | ||
| {"kilocode", []string{"KILO_IPC_SOCKET_PATH", "KILO_SERVER_PASSWORD"}}, | ||
|
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. Also in description table I see replit, windsurf, and aider entries but here I am not seeing them implemented
Contributor
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. yeah needed to drop it, no official documentation regarding any env from them |
||
| {"roo_code", []string{"ROO_CODE_IPC_SOCKET_PATH"}}, | ||
| {"codex", []string{"CODEX_CI"}}, | ||
| } | ||
|
|
||
| // DetectExecutionContext captures signals about who executed the CLI. | ||
| // Memoized for the process lifetime so independent call sites (metrics | ||
| // collector, trace-ID setup, User-Agent enrichment) cannot diverge if a | ||
| // later caller mutates the environment. | ||
| func DetectExecutionContext() ExecutionContext { | ||
| executionContextOnce.Do(func() { | ||
| cachedExecutionContext = computeExecutionContext() | ||
| }) | ||
| return cachedExecutionContext | ||
| } | ||
|
|
||
| var ( | ||
| executionContextOnce sync.Once | ||
| cachedExecutionContext ExecutionContext | ||
| ) | ||
|
|
||
| func computeExecutionContext() ExecutionContext { | ||
| ec := ExecutionContext{ | ||
| IsInteractive: clientlog.IsStdOutTerminal(), | ||
| } | ||
| ec.Agent = detectAgent() | ||
| ec.IsAgent = ec.Agent != "" | ||
| ec.TraceID = detectAgentTraceID(ec.Agent) | ||
| return ec | ||
| } | ||
|
|
||
| func detectAgent() string { | ||
| for _, d := range agentEnvDetectors { | ||
| for _, e := range d.envs { | ||
| if os.Getenv(e) != "" { | ||
| return d.name | ||
| } | ||
| } | ||
| } | ||
| // Generic AGENT env var (goose convention, codex pending). Don't propagate the | ||
| // raw value into metrics — collapse to "unknown" to keep cardinality bounded. | ||
| if os.Getenv("AGENT") != "" { | ||
| return AgentUnknown | ||
| } | ||
| return "" | ||
| } | ||
|
|
||
| // detectAgentTraceID returns a trace ID propagated by the parent agent, if any. | ||
| // Gated on agent identity to prevent stale values leaked from an outer shell | ||
| // (e.g. CURSOR_TRACE_ID present while the actual invoker is Claude Code). | ||
| // Empty result means the CLI should generate its own trace ID. | ||
| func detectAgentTraceID(agent string) string { | ||
| if agent == "cursor" { | ||
| return os.Getenv("CURSOR_TRACE_ID") | ||
| } | ||
| return "" | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,99 @@ | ||
| package commands | ||
|
|
||
| import ( | ||
| "sync" | ||
| "testing" | ||
|
|
||
| "github.com/stretchr/testify/assert" | ||
| ) | ||
|
|
||
| func TestDetectAgent_FromTable(t *testing.T) { | ||
| for _, d := range agentEnvDetectors { | ||
| for _, env := range d.envs { | ||
| t.Run(env, func(t *testing.T) { | ||
| clearAgentEnvVars(t) | ||
| t.Setenv(env, "1") | ||
| assert.Equal(t, d.name, detectAgent()) | ||
| }) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestDetectAgent_GenericAgentEnvCollapsesToUnknown(t *testing.T) { | ||
| clearAgentEnvVars(t) | ||
| t.Setenv("AGENT", "some_random_value") | ||
| assert.Equal(t, AgentUnknown, detectAgent()) | ||
| } | ||
|
|
||
| func TestDetectAgent_None(t *testing.T) { | ||
| clearAgentEnvVars(t) | ||
| assert.Equal(t, "", detectAgent()) | ||
| } | ||
|
|
||
| func TestDetectAgentTraceID(t *testing.T) { | ||
| t.Setenv("CURSOR_TRACE_ID", "trace-abc") | ||
| assert.Equal(t, "trace-abc", detectAgentTraceID("cursor")) | ||
| // Trace ID gated on agent identity: a leaked CURSOR_TRACE_ID from an outer | ||
| // shell must not be reused when the real invoker is a different agent. | ||
| assert.Equal(t, "", detectAgentTraceID("claude")) | ||
| assert.Equal(t, "", detectAgentTraceID("")) | ||
| } | ||
|
|
||
| func TestDetectExecutionContext_Agent(t *testing.T) { | ||
| resetExecutionContextForTest(t) | ||
| clearAgentEnvVars(t) | ||
| t.Setenv("CLAUDECODE", "1") | ||
|
|
||
| ec := DetectExecutionContext() | ||
| assert.True(t, ec.IsAgent) | ||
| assert.Equal(t, "claude", ec.Agent) | ||
| } | ||
|
|
||
| func TestDetectExecutionContext_NoEnv(t *testing.T) { | ||
| resetExecutionContextForTest(t) | ||
| clearAgentEnvVars(t) | ||
|
|
||
| ec := DetectExecutionContext() | ||
| assert.False(t, ec.IsAgent) | ||
| assert.Equal(t, "", ec.Agent) | ||
| assert.Equal(t, "", ec.TraceID) | ||
| } | ||
|
|
||
| func TestDetectExecutionContext_IsMemoized(t *testing.T) { | ||
| resetExecutionContextForTest(t) | ||
| clearAgentEnvVars(t) | ||
| t.Setenv("CLAUDECODE", "1") | ||
| first := DetectExecutionContext() | ||
|
|
||
| // Mutate env after first call; result must not change without reset. | ||
| t.Setenv("CLAUDECODE", "") | ||
| t.Setenv("CURSOR_AGENT", "1") | ||
| second := DetectExecutionContext() | ||
|
|
||
| assert.Equal(t, first, second) | ||
| assert.Equal(t, "claude", second.Agent) | ||
| } | ||
|
|
||
| // resetExecutionContextForTest forces the next DetectExecutionContext call to | ||
| // re-evaluate env vars. Restores the memoization state after the test. | ||
| func resetExecutionContextForTest(t *testing.T) { | ||
| t.Helper() | ||
| prevOnce, prevCache := executionContextOnce, cachedExecutionContext | ||
| executionContextOnce = sync.Once{} | ||
| cachedExecutionContext = ExecutionContext{} | ||
| t.Cleanup(func() { | ||
| executionContextOnce, cachedExecutionContext = prevOnce, prevCache | ||
| }) | ||
| } | ||
|
|
||
|
|
||
| func clearAgentEnvVars(t *testing.T) { | ||
| t.Helper() | ||
| for _, d := range agentEnvDetectors { | ||
| for _, e := range d.envs { | ||
| t.Setenv(e, "") | ||
| } | ||
| } | ||
| t.Setenv("AGENT", "") | ||
| t.Setenv("CURSOR_TRACE_ID", "") | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.