From aaa4fda381f72c8303a1b650c94a6eada75e3372 Mon Sep 17 00:00:00 2001 From: Kanishk Date: Mon, 4 May 2026 16:03:16 +0530 Subject: [PATCH 1/4] RTECO-1017 - Detect agent + CI invocation context, enrich metrics with agent/is_agent/is_interactive --- common/commands/execution_context.go | 80 ++++++++++++++++ common/commands/execution_context_test.go | 112 ++++++++++++++++++++++ common/commands/metrics_collector.go | 24 ++--- utils/metrics/metrics.go | 3 + 4 files changed, 207 insertions(+), 12 deletions(-) create mode 100644 common/commands/execution_context.go create mode 100644 common/commands/execution_context_test.go diff --git a/common/commands/execution_context.go b/common/commands/execution_context.go new file mode 100644 index 000000000..10accbc8d --- /dev/null +++ b/common/commands/execution_context.go @@ -0,0 +1,80 @@ +package commands + +import ( + "os" + + "golang.org/x/term" +) + +// ExecutionContext describes how a CLI invocation was launched. +// Dimensions are independent: an agent may run inside CI; both will be reported. +type ExecutionContext struct { + Agent string // e.g. "claude", "cursor", "gemini", "" if none + CISystem string // e.g. "github_actions", "" if not CI + IsCI bool + IsAgent bool + IsInteractive bool // stdin is a TTY + TraceID string // propagated trace ID (e.g. CURSOR_TRACEID), empty if none +} + +// agent env var detection table. First match wins. +var agentEnvDetectors = []struct { + name string + envs []string +}{ + {"claude", []string{"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT"}}, + {"gemini", []string{"GEMINI_CLI"}}, + {"goose", []string{"GOOSE_TERMINAL"}}, + {"cursor", []string{"CURSOR_AGENT", "CURSOR_TRACEID"}}, + {"copilot", []string{"COPILOT_CLI"}}, + {"kilocode", []string{"KILO_IPC_SOCKET_PATH", "KILO_SERVER_PASSWORD"}}, + {"roo_code", []string{"ROO_CODE_IPC_SOCKET_PATH"}}, + {"replit", []string{"REPLIT_AGENT"}}, + {"windsurf", []string{"WINDSURF_SESSION_ID"}}, + {"aider", []string{"AIDER_MODEL"}}, + {"codex", []string{"CODEX_HOME"}}, +} + +// DetectExecutionContext captures all signals about who executed the CLI. +func DetectExecutionContext() ExecutionContext { + ec := ExecutionContext{ + IsInteractive: term.IsTerminal(int(os.Stdin.Fd())), + } + + ec.Agent = detectAgent() + ec.IsAgent = ec.Agent != "" + ec.TraceID = detectAgentTraceID(ec.Agent) + + ec.CISystem = detectCISystem() + ec.IsCI = ec.CISystem != "" + + return ec +} + +func detectAgent() string { + for _, d := range agentEnvDetectors { + for _, e := range d.envs { + if os.Getenv(e) != "" { + return d.name + } + } + } + // Fallback: generic AGENT env var (goose convention, codex pending). + if v := os.Getenv("AGENT"); v != "" { + return v + } + return "" +} + +// detectAgentTraceID returns a trace ID propagated by the parent agent, if any. +// Only used when the detected agent itself owns the trace ID env var, so we don't +// reuse a stale ID leaked from an outer shell (e.g. CURSOR_TRACEID present while +// the actual invoker is Claude Code). Empty result means the CLI should generate +// its own trace ID. +func detectAgentTraceID(agent string) string { + switch agent { + case "cursor": + return os.Getenv("CURSOR_TRACEID") + } + return "" +} diff --git a/common/commands/execution_context_test.go b/common/commands/execution_context_test.go new file mode 100644 index 000000000..eb24148fa --- /dev/null +++ b/common/commands/execution_context_test.go @@ -0,0 +1,112 @@ +package commands + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDetectAgent_EnvVar(t *testing.T) { + cases := []struct { + envVar string + want string + }{ + {"CLAUDECODE", "claude"}, + {"CLAUDE_CODE_ENTRYPOINT", "claude"}, + {"GEMINI_CLI", "gemini"}, + {"GOOSE_TERMINAL", "goose"}, + {"CURSOR_AGENT", "cursor"}, + {"CURSOR_TRACEID", "cursor"}, + {"COPILOT_CLI", "copilot"}, + {"KILO_IPC_SOCKET_PATH", "kilocode"}, + {"ROO_CODE_IPC_SOCKET_PATH", "roo_code"}, + {"REPLIT_AGENT", "replit"}, + {"WINDSURF_SESSION_ID", "windsurf"}, + {"AIDER_MODEL", "aider"}, + {"CODEX_HOME", "codex"}, + } + for _, c := range cases { + t.Run(c.envVar, func(t *testing.T) { + clearAgentEnvVars(t) + t.Setenv(c.envVar, "1") + assert.Equal(t, c.want, detectAgent()) + }) + } +} + +func TestDetectAgent_GenericFallback(t *testing.T) { + clearAgentEnvVars(t) + t.Setenv("AGENT", "custom_bot") + assert.Equal(t, "custom_bot", detectAgent()) +} + +func TestDetectAgent_None(t *testing.T) { + clearAgentEnvVars(t) + assert.Equal(t, "", detectAgent()) +} + +func TestDetectAgentTraceID(t *testing.T) { + t.Setenv("CURSOR_TRACEID", "trace-abc") + assert.Equal(t, "trace-abc", detectAgentTraceID("cursor")) + // Trace ID gated on agent identity: a leaked CURSOR_TRACEID 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_AgentAndCI(t *testing.T) { + clearAgentEnvVars(t) + clearCIEnvVars(t) + t.Setenv("CLAUDECODE", "1") + t.Setenv("GITHUB_ACTIONS", "true") + + inv := DetectExecutionContext() + assert.True(t, inv.IsAgent) + assert.True(t, inv.IsCI) + assert.Equal(t, "claude", inv.Agent) + assert.Equal(t, "github_actions", inv.CISystem) +} + +func TestDetectExecutionContext_CIOnly(t *testing.T) { + clearAgentEnvVars(t) + clearCIEnvVars(t) + t.Setenv("GITHUB_ACTIONS", "true") + + inv := DetectExecutionContext() + assert.False(t, inv.IsAgent) + assert.True(t, inv.IsCI) + assert.Equal(t, "github_actions", inv.CISystem) +} + +func TestDetectExecutionContext_NoEnv(t *testing.T) { + clearAgentEnvVars(t) + clearCIEnvVars(t) + + inv := DetectExecutionContext() + assert.False(t, inv.IsAgent) + assert.False(t, inv.IsCI) + assert.Equal(t, "", inv.Agent) + assert.Equal(t, "", inv.CISystem) +} + +func clearAgentEnvVars(t *testing.T) { + t.Helper() + for _, d := range agentEnvDetectors { + for _, e := range d.envs { + t.Setenv(e, "") + } + } + t.Setenv("AGENT", "") +} + +func clearCIEnvVars(t *testing.T) { + t.Helper() + for _, e := range []string{ + "JENKINS_URL", "TRAVIS", "CIRCLECI", "GITHUB_ACTIONS", "GITLAB_CI", + "BUILDKITE", "BAMBOO_BUILD_KEY", "TF_BUILD", "TEAMCITY_VERSION", + "DRONE", "BITBUCKET_BUILD_NUMBER", "CODEBUILD_BUILD_ID", + "CI", "CONTINUOUS_INTEGRATION", "BUILD_ID", "BUILD_NUMBER", + } { + t.Setenv(e, "") + } +} diff --git a/common/commands/metrics_collector.go b/common/commands/metrics_collector.go index d39a3357d..dbea1baa2 100644 --- a/common/commands/metrics_collector.go +++ b/common/commands/metrics_collector.go @@ -30,24 +30,21 @@ func CollectMetrics(commandName string, flags []string) { globalMetricsCollector.mu.Lock() defer globalMetricsCollector.mu.Unlock() - ciSystem := detectCISystem() - isCI := ciSystem != "" + ec := DetectExecutionContext() 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 "" - }(), + Flags: flags, + Platform: runtime.GOOS, + Architecture: runtime.GOARCH, + IsCI: ec.IsCI, + CISystem: ec.CISystem, IsContainer: isRunningInContainer(), + IsAgent: ec.IsAgent, + Agent: ec.Agent, + IsInteractive: ec.IsInteractive, PackageAlias: pkgAliasTool != "", PackageManager: pkgAliasTool, } @@ -73,6 +70,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, } 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"` } From 89ba093adf6ba3a9f3fdc43f5acae8902e4a6bc3 Mon Sep 17 00:00:00 2001 From: Kanishk Date: Thu, 7 May 2026 17:04:22 +0530 Subject: [PATCH 2/4] fixed comments --- common/commands/execution_context.go | 79 ++++++++------ common/commands/execution_context_test.go | 124 +++++++++++----------- common/commands/metrics_collector.go | 19 ++-- 3 files changed, 120 insertions(+), 102 deletions(-) diff --git a/common/commands/execution_context.go b/common/commands/execution_context.go index 10accbc8d..d06839910 100644 --- a/common/commands/execution_context.go +++ b/common/commands/execution_context.go @@ -1,53 +1,52 @@ package commands import ( + "fmt" "os" - "golang.org/x/term" + 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. -// Dimensions are independent: an agent may run inside CI; both will be reported. type ExecutionContext struct { - Agent string // e.g. "claude", "cursor", "gemini", "" if none - CISystem string // e.g. "github_actions", "" if not CI - IsCI bool + Agent string // e.g. "claude", "cursor", "gemini", "unknown" or "" if none IsAgent bool - IsInteractive bool // stdin is a TTY - TraceID string // propagated trace ID (e.g. CURSOR_TRACEID), empty if none + IsInteractive bool // stdout is a TTY + TraceID string // propagated trace ID (e.g. CURSOR_TRACE_ID), empty if none } -// agent env var detection table. First match wins. -var agentEnvDetectors = []struct { +// 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_TRACEID"}}, + {"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"}}, - {"replit", []string{"REPLIT_AGENT"}}, - {"windsurf", []string{"WINDSURF_SESSION_ID"}}, - {"aider", []string{"AIDER_MODEL"}}, - {"codex", []string{"CODEX_HOME"}}, + {"codex", []string{"CODEX_CI"}}, } -// DetectExecutionContext captures all signals about who executed the CLI. +// DetectExecutionContext captures signals about who executed the CLI. func DetectExecutionContext() ExecutionContext { ec := ExecutionContext{ - IsInteractive: term.IsTerminal(int(os.Stdin.Fd())), + IsInteractive: clientlog.IsStdOutTerminal(), } - ec.Agent = detectAgent() ec.IsAgent = ec.Agent != "" ec.TraceID = detectAgentTraceID(ec.Agent) - - ec.CISystem = detectCISystem() - ec.IsCI = ec.CISystem != "" - return ec } @@ -59,22 +58,38 @@ func detectAgent() string { } } } - // Fallback: generic AGENT env var (goose convention, codex pending). - if v := os.Getenv("AGENT"); v != "" { - return v + // 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. -// Only used when the detected agent itself owns the trace ID env var, so we don't -// reuse a stale ID leaked from an outer shell (e.g. CURSOR_TRACEID present while -// the actual invoker is Claude Code). Empty result means the CLI should generate -// its own trace ID. +// 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 { - switch agent { - case "cursor": - return os.Getenv("CURSOR_TRACEID") + if agent == "cursor" { + return os.Getenv("CURSOR_TRACE_ID") } return "" } + +// EnrichUserAgent appends invoker context (agent and/or CI provider) to a base +// User-Agent string. Returns base unchanged when neither is detected. +// Examples: "jfrog-cli-go/2.x (claude)", "jfrog-cli-go/2.x (cursor; ci=github_actions)". +func EnrichUserAgent(base string) string { + ec := DetectExecutionContext() + ciSystem := detectCISystem() + switch { + case ec.Agent != "" && ciSystem != "": + return fmt.Sprintf("%s (%s; ci=%s)", base, ec.Agent, ciSystem) + case ec.Agent != "": + return fmt.Sprintf("%s (%s)", base, ec.Agent) + case ciSystem != "": + return fmt.Sprintf("%s (ci=%s)", base, ciSystem) + } + return base +} diff --git a/common/commands/execution_context_test.go b/common/commands/execution_context_test.go index eb24148fa..83812d04c 100644 --- a/common/commands/execution_context_test.go +++ b/common/commands/execution_context_test.go @@ -6,38 +6,22 @@ import ( "github.com/stretchr/testify/assert" ) -func TestDetectAgent_EnvVar(t *testing.T) { - cases := []struct { - envVar string - want string - }{ - {"CLAUDECODE", "claude"}, - {"CLAUDE_CODE_ENTRYPOINT", "claude"}, - {"GEMINI_CLI", "gemini"}, - {"GOOSE_TERMINAL", "goose"}, - {"CURSOR_AGENT", "cursor"}, - {"CURSOR_TRACEID", "cursor"}, - {"COPILOT_CLI", "copilot"}, - {"KILO_IPC_SOCKET_PATH", "kilocode"}, - {"ROO_CODE_IPC_SOCKET_PATH", "roo_code"}, - {"REPLIT_AGENT", "replit"}, - {"WINDSURF_SESSION_ID", "windsurf"}, - {"AIDER_MODEL", "aider"}, - {"CODEX_HOME", "codex"}, - } - for _, c := range cases { - t.Run(c.envVar, func(t *testing.T) { - clearAgentEnvVars(t) - t.Setenv(c.envVar, "1") - assert.Equal(t, c.want, detectAgent()) - }) +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_GenericFallback(t *testing.T) { +func TestDetectAgent_GenericAgentEnvCollapsesToUnknown(t *testing.T) { clearAgentEnvVars(t) - t.Setenv("AGENT", "custom_bot") - assert.Equal(t, "custom_bot", detectAgent()) + t.Setenv("AGENT", "some_random_value") + assert.Equal(t, AgentUnknown, detectAgent()) } func TestDetectAgent_None(t *testing.T) { @@ -46,57 +30,62 @@ func TestDetectAgent_None(t *testing.T) { } func TestDetectAgentTraceID(t *testing.T) { - t.Setenv("CURSOR_TRACEID", "trace-abc") + t.Setenv("CURSOR_TRACE_ID", "trace-abc") assert.Equal(t, "trace-abc", detectAgentTraceID("cursor")) - // Trace ID gated on agent identity: a leaked CURSOR_TRACEID from an outer + // 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_AgentAndCI(t *testing.T) { +func TestDetectExecutionContext_Agent(t *testing.T) { clearAgentEnvVars(t) - clearCIEnvVars(t) t.Setenv("CLAUDECODE", "1") - t.Setenv("GITHUB_ACTIONS", "true") - inv := DetectExecutionContext() - assert.True(t, inv.IsAgent) - assert.True(t, inv.IsCI) - assert.Equal(t, "claude", inv.Agent) - assert.Equal(t, "github_actions", inv.CISystem) + ec := DetectExecutionContext() + assert.True(t, ec.IsAgent) + assert.Equal(t, "claude", ec.Agent) } -func TestDetectExecutionContext_CIOnly(t *testing.T) { +func TestDetectExecutionContext_NoEnv(t *testing.T) { clearAgentEnvVars(t) - clearCIEnvVars(t) - t.Setenv("GITHUB_ACTIONS", "true") - inv := DetectExecutionContext() - assert.False(t, inv.IsAgent) - assert.True(t, inv.IsCI) - assert.Equal(t, "github_actions", inv.CISystem) + ec := DetectExecutionContext() + assert.False(t, ec.IsAgent) + assert.Equal(t, "", ec.Agent) + assert.Equal(t, "", ec.TraceID) } -func TestDetectExecutionContext_NoEnv(t *testing.T) { - clearAgentEnvVars(t) - clearCIEnvVars(t) +func TestEnrichUserAgent(t *testing.T) { + base := "jfrog-cli-go/2.103.0" - inv := DetectExecutionContext() - assert.False(t, inv.IsAgent) - assert.False(t, inv.IsCI) - assert.Equal(t, "", inv.Agent) - assert.Equal(t, "", inv.CISystem) -} + t.Run("none", func(t *testing.T) { + clearAgentEnvVars(t) + clearCIEnvVars(t) + assert.Equal(t, base, EnrichUserAgent(base)) + }) -func clearAgentEnvVars(t *testing.T) { - t.Helper() - for _, d := range agentEnvDetectors { - for _, e := range d.envs { - t.Setenv(e, "") - } - } - t.Setenv("AGENT", "") + t.Run("agent only", func(t *testing.T) { + clearAgentEnvVars(t) + clearCIEnvVars(t) + t.Setenv("CLAUDECODE", "1") + assert.Equal(t, base+" (claude)", EnrichUserAgent(base)) + }) + + t.Run("ci only", func(t *testing.T) { + clearAgentEnvVars(t) + clearCIEnvVars(t) + t.Setenv("GITHUB_ACTIONS", "true") + assert.Equal(t, base+" (ci=github_actions)", EnrichUserAgent(base)) + }) + + t.Run("agent and ci", func(t *testing.T) { + clearAgentEnvVars(t) + clearCIEnvVars(t) + t.Setenv("CURSOR_AGENT", "1") + t.Setenv("GITHUB_ACTIONS", "true") + assert.Equal(t, base+" (cursor; ci=github_actions)", EnrichUserAgent(base)) + }) } func clearCIEnvVars(t *testing.T) { @@ -110,3 +99,14 @@ func clearCIEnvVars(t *testing.T) { t.Setenv(e, "") } } + +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 dbea1baa2..0a46b5584 100644 --- a/common/commands/metrics_collector.go +++ b/common/commands/metrics_collector.go @@ -27,29 +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() - ec := DetectExecutionContext() - pkgAliasTool := globalMetricsCollector.packageAliasContext globalMetricsCollector.packageAliasContext = "" - metricsData := &MetricsData{ + globalMetricsCollector.metricsData[commandName] = &MetricsData{ Flags: flags, Platform: runtime.GOOS, Architecture: runtime.GOARCH, - IsCI: ec.IsCI, - CISystem: ec.CISystem, - IsContainer: isRunningInContainer(), + 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. @@ -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 { From 60e0bf5280bf122bb026490951a2f2c5f2a5d2aa Mon Sep 17 00:00:00 2001 From: Kanishk Date: Wed, 13 May 2026 14:29:40 +0530 Subject: [PATCH 3/4] Enhance execution context handling in tests and implementation. Added memoization for execution context to prevent divergence when environment variables change. Introduced resetExecutionContextForTest helper to facilitate isolated tests. Updated relevant test cases to utilize the new helper for consistent state management. --- common/commands/execution_context.go | 16 +++++++++++ common/commands/execution_context_test.go | 34 +++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/common/commands/execution_context.go b/common/commands/execution_context.go index d06839910..fa8563670 100644 --- a/common/commands/execution_context.go +++ b/common/commands/execution_context.go @@ -3,6 +3,7 @@ package commands import ( "fmt" "os" + "sync" clientlog "github.com/jfrog/jfrog-client-go/utils/log" ) @@ -40,7 +41,22 @@ var agentEnvDetectors = []agentDetector{ } // 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(), } diff --git a/common/commands/execution_context_test.go b/common/commands/execution_context_test.go index 83812d04c..527d10a99 100644 --- a/common/commands/execution_context_test.go +++ b/common/commands/execution_context_test.go @@ -1,6 +1,7 @@ package commands import ( + "sync" "testing" "github.com/stretchr/testify/assert" @@ -39,6 +40,7 @@ func TestDetectAgentTraceID(t *testing.T) { } func TestDetectExecutionContext_Agent(t *testing.T) { + resetExecutionContextForTest(t) clearAgentEnvVars(t) t.Setenv("CLAUDECODE", "1") @@ -48,6 +50,7 @@ func TestDetectExecutionContext_Agent(t *testing.T) { } func TestDetectExecutionContext_NoEnv(t *testing.T) { + resetExecutionContextForTest(t) clearAgentEnvVars(t) ec := DetectExecutionContext() @@ -56,16 +59,45 @@ func TestDetectExecutionContext_NoEnv(t *testing.T) { 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 TestEnrichUserAgent(t *testing.T) { base := "jfrog-cli-go/2.103.0" t.Run("none", func(t *testing.T) { + resetExecutionContextForTest(t) clearAgentEnvVars(t) clearCIEnvVars(t) assert.Equal(t, base, EnrichUserAgent(base)) }) t.Run("agent only", func(t *testing.T) { + resetExecutionContextForTest(t) clearAgentEnvVars(t) clearCIEnvVars(t) t.Setenv("CLAUDECODE", "1") @@ -73,6 +105,7 @@ func TestEnrichUserAgent(t *testing.T) { }) t.Run("ci only", func(t *testing.T) { + resetExecutionContextForTest(t) clearAgentEnvVars(t) clearCIEnvVars(t) t.Setenv("GITHUB_ACTIONS", "true") @@ -80,6 +113,7 @@ func TestEnrichUserAgent(t *testing.T) { }) t.Run("agent and ci", func(t *testing.T) { + resetExecutionContextForTest(t) clearAgentEnvVars(t) clearCIEnvVars(t) t.Setenv("CURSOR_AGENT", "1") From 036d9195c4e9c0a27d713388a464ba7422732fc6 Mon Sep 17 00:00:00 2001 From: Kanishk Date: Mon, 18 May 2026 18:01:44 +0530 Subject: [PATCH 4/4] Remove EnrichUserAgent function and related tests to streamline execution context handling. This change simplifies the codebase by eliminating unused functionality and focuses on the core execution context logic. --- common/commands/execution_context.go | 18 --------- common/commands/execution_context_test.go | 47 ----------------------- 2 files changed, 65 deletions(-) diff --git a/common/commands/execution_context.go b/common/commands/execution_context.go index fa8563670..b12b8bfea 100644 --- a/common/commands/execution_context.go +++ b/common/commands/execution_context.go @@ -1,7 +1,6 @@ package commands import ( - "fmt" "os" "sync" @@ -92,20 +91,3 @@ func detectAgentTraceID(agent string) string { } return "" } - -// EnrichUserAgent appends invoker context (agent and/or CI provider) to a base -// User-Agent string. Returns base unchanged when neither is detected. -// Examples: "jfrog-cli-go/2.x (claude)", "jfrog-cli-go/2.x (cursor; ci=github_actions)". -func EnrichUserAgent(base string) string { - ec := DetectExecutionContext() - ciSystem := detectCISystem() - switch { - case ec.Agent != "" && ciSystem != "": - return fmt.Sprintf("%s (%s; ci=%s)", base, ec.Agent, ciSystem) - case ec.Agent != "": - return fmt.Sprintf("%s (%s)", base, ec.Agent) - case ciSystem != "": - return fmt.Sprintf("%s (ci=%s)", base, ciSystem) - } - return base -} diff --git a/common/commands/execution_context_test.go b/common/commands/execution_context_test.go index 527d10a99..c889367ae 100644 --- a/common/commands/execution_context_test.go +++ b/common/commands/execution_context_test.go @@ -86,53 +86,6 @@ func resetExecutionContextForTest(t *testing.T) { }) } -func TestEnrichUserAgent(t *testing.T) { - base := "jfrog-cli-go/2.103.0" - - t.Run("none", func(t *testing.T) { - resetExecutionContextForTest(t) - clearAgentEnvVars(t) - clearCIEnvVars(t) - assert.Equal(t, base, EnrichUserAgent(base)) - }) - - t.Run("agent only", func(t *testing.T) { - resetExecutionContextForTest(t) - clearAgentEnvVars(t) - clearCIEnvVars(t) - t.Setenv("CLAUDECODE", "1") - assert.Equal(t, base+" (claude)", EnrichUserAgent(base)) - }) - - t.Run("ci only", func(t *testing.T) { - resetExecutionContextForTest(t) - clearAgentEnvVars(t) - clearCIEnvVars(t) - t.Setenv("GITHUB_ACTIONS", "true") - assert.Equal(t, base+" (ci=github_actions)", EnrichUserAgent(base)) - }) - - t.Run("agent and ci", func(t *testing.T) { - resetExecutionContextForTest(t) - clearAgentEnvVars(t) - clearCIEnvVars(t) - t.Setenv("CURSOR_AGENT", "1") - t.Setenv("GITHUB_ACTIONS", "true") - assert.Equal(t, base+" (cursor; ci=github_actions)", EnrichUserAgent(base)) - }) -} - -func clearCIEnvVars(t *testing.T) { - t.Helper() - for _, e := range []string{ - "JENKINS_URL", "TRAVIS", "CIRCLECI", "GITHUB_ACTIONS", "GITLAB_CI", - "BUILDKITE", "BAMBOO_BUILD_KEY", "TF_BUILD", "TEAMCITY_VERSION", - "DRONE", "BITBUCKET_BUILD_NUMBER", "CODEBUILD_BUILD_ID", - "CI", "CONTINUOUS_INTEGRATION", "BUILD_ID", "BUILD_NUMBER", - } { - t.Setenv(e, "") - } -} func clearAgentEnvVars(t *testing.T) { t.Helper()