diff --git a/common/commands/execution_context.go b/common/commands/execution_context.go new file mode 100644 index 000000000..b12b8bfea --- /dev/null +++ b/common/commands/execution_context.go @@ -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"}}, + {"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 "" +} diff --git a/common/commands/execution_context_test.go b/common/commands/execution_context_test.go new file mode 100644 index 000000000..c889367ae --- /dev/null +++ b/common/commands/execution_context_test.go @@ -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", "") +} diff --git a/common/commands/metrics_collector.go b/common/commands/metrics_collector.go index d39a3357d..0a46b5584 100644 --- a/common/commands/metrics_collector.go +++ b/common/commands/metrics_collector.go @@ -27,32 +27,31 @@ var globalMetricsCollector = &metricsCollector{ // CollectMetrics stores enhanced metrics information for a command execution. // Collects system information, CI environment details, and container detection. func CollectMetrics(commandName string, flags []string) { + // Compute detection outside the lock; these are pure env reads and don't + // touch shared state. Keeps the critical section minimal. + ec := DetectExecutionContext() + ciSystem := detectCISystem() + isContainer := isRunningInContainer() + globalMetricsCollector.mu.Lock() defer globalMetricsCollector.mu.Unlock() - ciSystem := detectCISystem() - isCI := ciSystem != "" - pkgAliasTool := globalMetricsCollector.packageAliasContext globalMetricsCollector.packageAliasContext = "" - metricsData := &MetricsData{ - Flags: flags, - Platform: runtime.GOOS, - Architecture: runtime.GOARCH, - IsCI: isCI, - CISystem: func() string { - if isCI { - return ciSystem - } - return "" - }(), - IsContainer: isRunningInContainer(), + globalMetricsCollector.metricsData[commandName] = &MetricsData{ + Flags: flags, + Platform: runtime.GOOS, + Architecture: runtime.GOARCH, + IsCI: ciSystem != "", + CISystem: ciSystem, + IsContainer: isContainer, + IsAgent: ec.IsAgent, + Agent: ec.Agent, + IsInteractive: ec.IsInteractive, PackageAlias: pkgAliasTool != "", PackageManager: pkgAliasTool, } - - globalMetricsCollector.metricsData[commandName] = metricsData } // GetCollectedMetrics retrieves collected metrics for a command. @@ -73,6 +72,9 @@ func GetCollectedMetrics(commandName string) *MetricsData { IsCI: metrics.IsCI, CISystem: metrics.CISystem, IsContainer: metrics.IsContainer, + IsAgent: metrics.IsAgent, + Agent: metrics.Agent, + IsInteractive: metrics.IsInteractive, PackageAlias: metrics.PackageAlias, PackageManager: metrics.PackageManager, } @@ -93,6 +95,7 @@ func detectCISystem() string { "DRONE": "drone", "BITBUCKET_BUILD_NUMBER": "bitbucket", "CODEBUILD_BUILD_ID": "aws_codebuild", + "HARNESS_BUILD_ID": "harness", } for envVar, system := range ciEnvVars { diff --git a/utils/metrics/metrics.go b/utils/metrics/metrics.go index e4326d4ce..58e2824f1 100644 --- a/utils/metrics/metrics.go +++ b/utils/metrics/metrics.go @@ -9,6 +9,9 @@ type MetricsData struct { IsCI bool `json:"is_ci,omitempty"` CISystem string `json:"ci_system,omitempty"` IsContainer bool `json:"is_container,omitempty"` + IsAgent bool `json:"is_agent,omitempty"` + Agent string `json:"agent,omitempty"` + IsInteractive bool `json:"is_interactive,omitempty"` PackageAlias bool `json:"package_alias,omitempty"` PackageManager string `json:"package_manager,omitempty"` }