Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ require (
github.com/jfrog/gofrog v1.7.6
github.com/jfrog/jfrog-cli-application v1.0.2-0.20260723152309-34eeb81e2847
github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260804124646-1a5e6a2d3caf
github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260804120604-edaa34435a80
github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260806165013-a9fb8a640f22
github.com/jfrog/jfrog-cli-evidence v0.9.5
github.com/jfrog/jfrog-cli-platform-services v1.10.1-0.20260618062042-6053ab368cab
github.com/jfrog/jfrog-cli-security v1.33.0
Expand Down Expand Up @@ -246,8 +246,8 @@ require (

//replace github.com/ktrysmt/go-bitbucket => github.com/ktrysmt/go-bitbucket v0.9.80

// replace github.com/jfrog/jfrog-cli-core/v2 => github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260729061834-1c527b8abaa6

// Temporary pin to jfrog-cli-core PR #1602 tip — drop after that merges and re-bump require.
// replace github.com/jfrog/jfrog-cli-core/v2 => github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260806165013-a9fb8a640f22
//replace github.com/jfrog/jfrog-client-go => github.com/jfrog/jfrog-client-go v1.54.2-0.20251007084958-5eeaa42c31a6

// replace github.com/jfrog/jfrog-cli-artifactory => github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260723100012-d9e9c3412cb2
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -404,8 +404,8 @@ github.com/jfrog/jfrog-cli-application v1.0.2-0.20260723152309-34eeb81e2847 h1:w
github.com/jfrog/jfrog-cli-application v1.0.2-0.20260723152309-34eeb81e2847/go.mod h1:p8yLtbmCxxQucIbLZKnWu0F+EDtj6NLXbRQCEK/nb6o=
github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260804124646-1a5e6a2d3caf h1:HJob3Bsj6FtQ3nq72GGzBWXJ7ZXvUz6rKSGpYGAXwKI=
github.com/jfrog/jfrog-cli-artifactory v0.8.1-0.20260804124646-1a5e6a2d3caf/go.mod h1:UkVDiTbSgtk+7N2ePOsPvjPsgO8r8rJtUchjcnAk08w=
github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260804120604-edaa34435a80 h1:V8wTPQAO/9MMxYFMM5qD08E8QRCmV3EtS8Gh+7SmJzU=
github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260804120604-edaa34435a80/go.mod h1:MygQx8pekgPCXyXnejIAVG9S4ImGcDFmcfRPUug/0d0=
github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260806165013-a9fb8a640f22 h1:uTz6aqLmc8lgQsLOQC7daCiqq2ACSy8g8HmrnuoPato=
github.com/jfrog/jfrog-cli-core/v2 v2.60.1-0.20260806165013-a9fb8a640f22/go.mod h1:MygQx8pekgPCXyXnejIAVG9S4ImGcDFmcfRPUug/0d0=
github.com/jfrog/jfrog-cli-evidence v0.9.5 h1:YzkoYZtqChStPOxEj1odF7satpv1YPl1Zb/IZ/wZ9kc=
github.com/jfrog/jfrog-cli-evidence v0.9.5/go.mod h1:xTtHBeiVg3gbJ7jcx48sMlcWlCsRnvqlPKpbGJt22k0=
github.com/jfrog/jfrog-cli-platform-services v1.10.1-0.20260618062042-6053ab368cab h1:Zn/qB8LYhSu82YDtbqXwErN1RPHTHe/a3gQY6Ti/OBE=
Expand Down
22 changes: 14 additions & 8 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -462,15 +462,21 @@ func TestDockerScanHelp(t *testing.T) {
// survey-visibility assertions are deterministic regardless of the shell running
// `go test` (e.g. running inside Claude Code, Cursor, etc.).
var agentDetectorEnvVars = []string{
"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_CHILD_SESSION",
"CLAUDECODE", "CLAUDE_CODE", "CLAUDE_CODE_ENTRYPOINT",
"GEMINI_CLI",
"GOOSE_TERMINAL",
"CURSOR_AGENT", "CURSOR_CLI", "CURSOR_TRACE_ID",
"COPILOT_CLI",
"KILO_IPC_SOCKET_PATH", "KILO_SERVER_PASSWORD",
"ROO_CODE_IPC_SOCKET_PATH",
"CODEX_CI",
"AGENT",
"CURSOR_AGENT", "CURSOR_TRACE_ID", "CURSOR_EXTENSION_HOST_ROLE", "CURSOR_CLI",
"COPILOT_CLI", "COPILOT_AGENT_SESSION_ID", "COPILOT_MODEL", "COPILOT_ALLOW_ALL",
"KILOCODE_FEATURE", "KILO_PID", "KILO_IPC_SOCKET_PATH", "KILO_SERVER_PASSWORD",
"ROO_ACTIVE", "ROO_CLI_RUNTIME", "ROO_CODE_IPC_SOCKET_PATH",
"CODEX_CI", "CODEX_THREAD_ID", "CODEX_SANDBOX",
"WINDSURF_CASCADE_TERMINAL",
"CLINE_ACTIVE", "OPENCODE", "OPENCODE_CLIENT",
"AMP_CURRENT_THREAD_ID", "AUGMENT_AGENT", "QWEN_CODE",
"ANTIGRAVITY_AGENT", "CRUSH", "IFLOW_CLI", "TRAE_AI_SHELL_ID",
"AI_AGENT", "AGENT",
"TERM_PROGRAM", "JFROG_CLI_AI_MODEL",
}

func clearAgentEnvVarsForTest(t *testing.T) {
Expand Down Expand Up @@ -502,7 +508,7 @@ func TestSurvey_NotDisplayedOnHelpCI(t *testing.T) {
func TestSurvey_NotDisplayedOnHelpAgent(t *testing.T) {
t.Setenv("CI", "false")
clearAgentEnvVarsForTest(t)
t.Setenv("CLAUDECODE", "true")
t.Setenv("CLAUDE_CODE_CHILD_SESSION", "true")
commands.ResetExecutionContextForTest()

jfrogCli := coreTests.NewJfrogCli(execMain, "jfrog", "")
Expand Down
1 change: 1 addition & 0 deletions metrics_visibility_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ func TestVisibility_NoAgent_E2E(t *testing.T) {
corecommands.ResetExecutionContextForTest()
t.Cleanup(corecommands.ResetExecutionContextForTest)
t.Setenv("CURSOR_AGENT", "")
t.Setenv("CLAUDE_CODE_CHILD_SESSION", "")
t.Setenv("CLAUDECODE", "")
t.Setenv("AGENT", "")

Expand Down
60 changes: 34 additions & 26 deletions utils/cliutils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,41 +74,49 @@ func splitAgentNameAndVersion(fullAgentName string) (string, string) {
return agentName, agentVersion
}

// AgentUserAgentSuffixFormat renders the detected AI agent as an additional RFC 9110
// User-Agent product token, e.g. "jfrog-cli-go/2.117.0 ai-agent/claude".
// User-Agent product-token formats for the Client → Agent → Model axes
// (e.g. "jfrog-cli-go/2.117.0 ai-agent/claude ai-client/vscode ai-model/opus-4.7").
//
// A product token rather than a comment, for two reasons. It is the native User-Agent
// shape (compare "Mozilla/5.0 … Chrome/120 Safari/537.36"), so anything that splits on
// whitespace and reads name/version pairs surfaces it as a structured component instead
// of discarding it as comment text — and being parsed is the whole point of a census
// signal. And "ai-agent" is unambiguous, where a bare "agent" would collide with this
// codebase's existing use of the word for the CLI itself (see SetCliUserAgentName and
// build-info's agent name).
//
// The product-version slot deliberately carries the harness NAME, not a version: the
// execution-context detector exposes no harness version. Should one ever be wanted, it
// belongs in its own product token rather than crammed in here.
const AgentUserAgentSuffixFormat = " ai-agent/%s"
// Product tokens rather than comments so UA parsers that split on whitespace and
// read name/version pairs keep the census signal. "ai-agent" avoids colliding with
// this codebase's use of "agent" for the CLI itself (SetCliUserAgentName / build-info).
// The version slot of each token carries the axis value (harness/app/model slug), not
// a software version.
const (
aiAgentUserAgentFormat = " ai-agent/%s"
aiClientUserAgentFormat = " ai-client/%s"
aiModelUserAgentFormat = " ai-model/%s"
)

// GetCliUserAgentWithAgent returns the CLI user-agent, enriched with the AI agent that
// invoked the CLI when one was detected (AGW-86). Without this the agent identity never
// GetCliUserAgentWithAgent returns the CLI user-agent, enriched with Client → Agent →
// Model tokens when an AI agent is detected (AGW-86). Without this the identity never
// leaves the machine on the request itself — it reaches the platform only as a label on
// a separate telemetry call — so an agent and a human running the same command are
// byte-identical on the wire.
//
// The value is attribution metadata, NOT a credential: it derives from harness
// environment variables the client sets and can trivially unset or forge. Consumers must
// treat it as a routing/census hint only.
// Attribution metadata, not a credential: harness env vars can be unset or forged.
// Consumers must treat it as a routing/census hint only.
//
// Injection-safe by construction: DetectExecutionContext returns a name from a fixed
// table, or the literal "unknown" for the generic AGENT variable — a raw environment
// value is never propagated.
// Wire-safe by construction: Agent is a fixed table name (or "unknown"); Client and
// Model are sanitizeToken'd ([a-z0-9._-], capped) in DetectExecutionContext before use.
func GetCliUserAgentWithAgent() string {
userAgent := coreutils.GetCliUserAgent()
if executionContext := commonCommands.DetectExecutionContext(); executionContext.IsAgent {
userAgent += fmt.Sprintf(AgentUserAgentSuffixFormat, executionContext.Agent)
return coreutils.GetCliUserAgent() + agentUserAgentSuffix(commonCommands.DetectExecutionContext())
}

// agentUserAgentSuffix appends ai-agent / ai-client / ai-model tokens for
// agent runs; empty for humans. Never logs or fails the command.
func agentUserAgentSuffix(executionContext commonCommands.ExecutionContext) string {
if !executionContext.IsAgent {
return ""
}
suffix := fmt.Sprintf(aiAgentUserAgentFormat, executionContext.Agent)
if executionContext.Client != "" {
suffix += fmt.Sprintf(aiClientUserAgentFormat, executionContext.Client)
}
if executionContext.Model != "" {
suffix += fmt.Sprintf(aiModelUserAgentFormat, executionContext.Model)
}
return userAgent
return suffix
}

func GetCliError(err error, success, failed int, failNoOp bool) error {
Expand Down
81 changes: 59 additions & 22 deletions utils/cliutils/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -391,15 +391,25 @@ func (t *redirectingTransport) RoundTrip(req *http.Request) (*http.Response, err
// ShouldHideSurveyLink's agent check is deterministic regardless of the shell
// running `go test` (e.g. running inside Claude Code, Cursor, etc.).
var agentDetectorEnvVars = []string{
"CLAUDECODE", "CLAUDE_CODE_ENTRYPOINT",
"CLAUDE_CODE_CHILD_SESSION",
// Cleared even though no longer detectors — leftover process env must not
// bleed into human / strong-signal assertions.
"CLAUDECODE", "CLAUDE_CODE", "CLAUDE_CODE_ENTRYPOINT",
"GEMINI_CLI",
"GOOSE_TERMINAL",
"CURSOR_AGENT", "CURSOR_CLI", "CURSOR_TRACE_ID",
"COPILOT_CLI",
"KILO_IPC_SOCKET_PATH", "KILO_SERVER_PASSWORD",
"ROO_CODE_IPC_SOCKET_PATH",
"CODEX_CI",
"AGENT",
"CURSOR_AGENT", "CURSOR_TRACE_ID", "CURSOR_EXTENSION_HOST_ROLE", "CURSOR_CLI",
"COPILOT_CLI", "COPILOT_AGENT_SESSION_ID", "COPILOT_MODEL", "COPILOT_ALLOW_ALL",
"KILOCODE_FEATURE", "KILO_PID", "KILO_IPC_SOCKET_PATH", "KILO_SERVER_PASSWORD",
"ROO_ACTIVE", "ROO_CLI_RUNTIME", "ROO_CODE_IPC_SOCKET_PATH",
"CODEX_CI", "CODEX_THREAD_ID", "CODEX_SANDBOX",
"WINDSURF_CASCADE_TERMINAL",
"CLINE_ACTIVE", "OPENCODE", "OPENCODE_CLIENT",
"AMP_CURRENT_THREAD_ID", "AUGMENT_AGENT", "QWEN_CODE",
"ANTIGRAVITY_AGENT", "CRUSH", "IFLOW_CLI", "TRAE_AI_SHELL_ID",
"AI_AGENT", "AGENT",
// Host editor and model axes — cleared so the wire format is deterministic
// regardless of the shell running `go test`.
"TERM_PROGRAM", "JFROG_CLI_AI_MODEL",
}

func clearAgentEnvVarsForTest(t *testing.T) {
Expand Down Expand Up @@ -461,7 +471,7 @@ func TestSurveyHiddenForAgent(t *testing.T) {
t.Setenv(coreutils.CI, "")
t.Setenv(JfrogCliHideSurvey, "")
clearAgentEnvVarsForTest(t)
t.Setenv("CLAUDECODE", "true")
t.Setenv("CLAUDE_CODE_CHILD_SESSION", "true")
corecommands.ResetExecutionContextForTest()

assert.True(t, ShouldHideSurveyLink(), "Expected survey to be hidden when invoked by an agent")
Expand Down Expand Up @@ -526,25 +536,29 @@ func TestGetCliUserAgentWithAgentPerDetector(t *testing.T) {
testCases := []struct {
name string
envVar string
envValue string // empty → "1"
wantAgent string
}{
{"claude code", "CLAUDECODE", "claude"},
{"claude code entrypoint", "CLAUDE_CODE_ENTRYPOINT", "claude"},
{"gemini", "GEMINI_CLI", "gemini"},
{"goose", "GOOSE_TERMINAL", "goose"},
{"cursor agent", "CURSOR_AGENT", "cursor"},
{"cursor cli", "CURSOR_CLI", "cursor"},
{"copilot", "COPILOT_CLI", "copilot"},
{"kilocode", "KILO_IPC_SOCKET_PATH", "kilocode"},
{"roo code", "ROO_CODE_IPC_SOCKET_PATH", "roo_code"},
{"codex", "CODEX_CI", "codex"},
{"generic agent collapses to unknown", "AGENT", "unknown"},
{"claude child session", "CLAUDE_CODE_CHILD_SESSION", "", "claude"},
{"gemini", "GEMINI_CLI", "", "gemini"},
{"goose", "GOOSE_TERMINAL", "", "goose"},
{"cursor agent", "CURSOR_AGENT", "", "cursor"},
{"cursor extension host", "CURSOR_EXTENSION_HOST_ROLE", "agent-exec", "cursor"},
{"copilot", "COPILOT_CLI", "", "copilot"},
{"kilocode", "KILO_PID", "", "kilocode"},
{"roo code", "ROO_ACTIVE", "", "roo_code"},
{"codex", "CODEX_CI", "", "codex"},
{"generic agent collapses to unknown", "AGENT", "", "unknown"},
}
for _, testCase := range testCases {
t.Run(testCase.name, func(t *testing.T) {
clearAgentEnvVarsForTest(t)
withCliUserAgent(t, "jfrog-cli-go", "2.117.0")
t.Setenv(testCase.envVar, "1")
val := testCase.envValue
if val == "" {
val = "1"
}
t.Setenv(testCase.envVar, val)
corecommands.ResetExecutionContextForTest()

assert.Equal(t, "jfrog-cli-go/2.117.0 ai-agent/"+testCase.wantAgent, GetCliUserAgentWithAgent())
Expand All @@ -557,7 +571,7 @@ func TestGetCliUserAgentWithAgentPreservesCustomUserAgent(t *testing.T) {
// marker must be appended to whatever that resolves to, never replace it.
clearAgentEnvVarsForTest(t)
withCliUserAgent(t, "my-wrapper", "9.9.9")
t.Setenv("CLAUDECODE", "true")
t.Setenv("CLAUDE_CODE_CHILD_SESSION", "true")
corecommands.ResetExecutionContextForTest()

assert.Equal(t, "my-wrapper/9.9.9 ai-agent/claude", GetCliUserAgentWithAgent())
Expand All @@ -567,12 +581,35 @@ func TestGetCliUserAgentWithAgentNoVersion(t *testing.T) {
// GetCliUserAgent omits the slash when no version is set; the marker still appends.
clearAgentEnvVarsForTest(t)
withCliUserAgent(t, "jfrog-cli-go", "")
t.Setenv("CLAUDECODE", "true")
t.Setenv("CLAUDE_CODE_CHILD_SESSION", "true")
corecommands.ResetExecutionContextForTest()

assert.Equal(t, "jfrog-cli-go ai-agent/claude", GetCliUserAgentWithAgent())
}

func TestGetCliUserAgentWithAgentAppendsHostAndModel(t *testing.T) {
clearAgentEnvVarsForTest(t)
withCliUserAgent(t, "jfrog-cli-go", "2.117.0")
t.Setenv("CURSOR_AGENT", "1")
t.Setenv("TERM_PROGRAM", "vscode")
t.Setenv("JFROG_CLI_AI_MODEL", "opus-4.7")
corecommands.ResetExecutionContextForTest()

assert.Equal(t, "jfrog-cli-go/2.117.0 ai-agent/cursor ai-client/vscode ai-model/opus-4.7",
GetCliUserAgentWithAgent())
}

func TestGetCliUserAgentWithAgentOmitsAbsentAxes(t *testing.T) {
// Host and model are optional: with neither advertised, the suffix is just
// the agent token — byte-identical to the pre-host/model behaviour.
clearAgentEnvVarsForTest(t)
withCliUserAgent(t, "jfrog-cli-go", "2.117.0")
t.Setenv("CLAUDE_CODE_CHILD_SESSION", "1")
corecommands.ResetExecutionContextForTest()

assert.Equal(t, "jfrog-cli-go/2.117.0 ai-agent/claude", GetCliUserAgentWithAgent())
}

func TestGetCliUserAgentWithAgentMarkerIsWellFormed(t *testing.T) {
clearAgentEnvVarsForTest(t)
withCliUserAgent(t, "jfrog-cli-go", "2.117.0")
Expand Down
Loading