From 950a4d0430c96ba9e607f11facd6c04c72d1d675 Mon Sep 17 00:00:00 2001 From: Noam Shemesh Date: Fri, 31 Jul 2026 09:27:15 -0400 Subject: [PATCH] Surface the invoking AI agent on the CLI's User-Agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit jfrog-cli-core already detects which AI harness invoked the CLI, from a table of harness environment variables (Claude Code, Gemini, Goose, Cursor, Copilot, Kilocode, Roo Code, Codex, plus a generic AGENT fallback collapsed to "unknown"). That answer never reached the wire: it fed telemetry labels, prompt suppression, AI-formatted help and the survey link, but not the request. So an agent running "jf rt download" and a human running it were byte identical to any server in the path. GetCliUserAgentWithAgent appends the detected agent as an additional product token, so the wire value becomes: jfrog-cli-go/2.117.0 ai-agent/claude A product token rather than a comment: 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. "ai-agent" rather than "agent", because a bare "agent" would collide with this codebase's existing use of the word for the CLI itself (SetCliUserAgentName, 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. If one is ever wanted it belongs in its own token rather than crammed in here. The leading product token is unchanged and stays first, so anything parsing only that is unaffected, and a non-agent invocation is byte identical to today. JFROG_CLI_USER_AGENT overrides are preserved — the marker is appended to whatever the override resolves to, never replacing it. Injection-safe by construction: DetectExecutionContext returns a name from a fixed table or the literal "unknown", so a raw environment value is never propagated. Note that execution_context.go already lists "User-Agent enrichment" among its call sites in a comment, but no such call site existed; this appears to be the intent that never landed. The value is attribution metadata, not a credential: harness environment variables are client-set and trivially forged or unset, so consumers must treat it as a routing or census hint only. Co-Authored-By: Claude Opus 5 --- main.go | 5 +- utils/cliutils/utils.go | 37 ++++++++++++++ utils/cliutils/utils_test.go | 94 ++++++++++++++++++++++++++++++++++++ 3 files changed, 134 insertions(+), 2 deletions(-) diff --git a/main.go b/main.go index 74c8786da..7bd0da14c 100644 --- a/main.go +++ b/main.go @@ -113,8 +113,9 @@ func execMain() error { } } - // Set JFrog CLI's user-agent on the jfrog-client-go. - clientutils.SetUserAgent(coreutils.GetCliUserAgent()) + // Set JFrog CLI's user-agent on the jfrog-client-go, enriched with the AI agent + // that invoked us when one is detected (AGW-86). + clientutils.SetUserAgent(cliutils.GetCliUserAgentWithAgent()) app := cli.NewApp() app.Name = jfrogAppName diff --git a/utils/cliutils/utils.go b/utils/cliutils/utils.go index 3525d21fd..3dd079863 100644 --- a/utils/cliutils/utils.go +++ b/utils/cliutils/utils.go @@ -74,6 +74,43 @@ 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". +// +// 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" + +// 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 +// 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. +// +// 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. +func GetCliUserAgentWithAgent() string { + userAgent := coreutils.GetCliUserAgent() + if executionContext := commonCommands.DetectExecutionContext(); executionContext.IsAgent { + userAgent += fmt.Sprintf(AgentUserAgentSuffixFormat, executionContext.Agent) + } + return userAgent +} + func GetCliError(err error, success, failed int, failNoOp bool) error { switch coreutils.GetExitCode(err, success, failed, failNoOp) { case coreutils.ExitCodeError: diff --git a/utils/cliutils/utils_test.go b/utils/cliutils/utils_test.go index eeb69821e..3215a566f 100644 --- a/utils/cliutils/utils_test.go +++ b/utils/cliutils/utils_test.go @@ -494,3 +494,97 @@ func TestTransferFilesTimestampFilterFlags(t *testing.T) { assert.Contains(t, usageByName[CreatedAfter], "YYYY-MM-DDTHH:mm:ss.sssZ") assert.Contains(t, usageByName[DownloadedAfter], "YYYY-MM-DDTHH:mm:ss.sssZ") } + +// --- AGW-86: User-Agent enrichment with the detected AI agent --- + +// withCliUserAgent pins the CLI user-agent name/version for one test. The real values are +// set once in init() from JFROG_CLI_USER_AGENT, so tests drive the setters directly rather +// than trying to re-run init. +func withCliUserAgent(t *testing.T, name, version string) { + t.Helper() + prevName, prevVersion := coreutils.GetCliUserAgentName(), coreutils.GetCliUserAgentVersion() + coreutils.SetCliUserAgentName(name) + coreutils.SetCliUserAgentVersion(version) + t.Cleanup(func() { + coreutils.SetCliUserAgentName(prevName) + coreutils.SetCliUserAgentVersion(prevVersion) + }) +} + +func TestGetCliUserAgentWithAgentNoAgentDetected(t *testing.T) { + clearAgentEnvVarsForTest(t) + withCliUserAgent(t, "jfrog-cli-go", "2.117.0") + corecommands.ResetExecutionContextForTest() + + assert.Equal(t, "jfrog-cli-go/2.117.0", GetCliUserAgentWithAgent(), + "a human invocation must stay byte-identical to today's behaviour") +} + +func TestGetCliUserAgentWithAgentPerDetector(t *testing.T) { + // One case per row of jfrog-cli-core's agentEnvDetectors table, plus the generic + // AGENT fallback that is deliberately collapsed to "unknown". + testCases := []struct { + name string + envVar string + 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"}, + } + 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") + corecommands.ResetExecutionContextForTest() + + assert.Equal(t, "jfrog-cli-go/2.117.0 ai-agent/"+testCase.wantAgent, GetCliUserAgentWithAgent()) + }) + } +} + +func TestGetCliUserAgentWithAgentPreservesCustomUserAgent(t *testing.T) { + // JFROG_CLI_USER_AGENT lets an operator replace the product token entirely. The agent + // 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") + corecommands.ResetExecutionContextForTest() + + assert.Equal(t, "my-wrapper/9.9.9 ai-agent/claude", GetCliUserAgentWithAgent()) +} + +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") + corecommands.ResetExecutionContextForTest() + + assert.Equal(t, "jfrog-cli-go ai-agent/claude", GetCliUserAgentWithAgent()) +} + +func TestGetCliUserAgentWithAgentMarkerIsWellFormed(t *testing.T) { + clearAgentEnvVarsForTest(t) + withCliUserAgent(t, "jfrog-cli-go", "2.117.0") + t.Setenv("CURSOR_AGENT", "1") + corecommands.ResetExecutionContextForTest() + + userAgent := GetCliUserAgentWithAgent() + // The product token stays first, so parsers that read only it are unaffected. + assert.True(t, strings.HasPrefix(userAgent, "jfrog-cli-go/2.117.0"), "got %q", userAgent) + assert.True(t, strings.HasSuffix(userAgent, "ai-agent/cursor"), "got %q", userAgent) + // The detector only ever returns fixed table names, so no raw env value — and + // therefore no header-splitting sequence — can reach the wire. + assert.NotContains(t, userAgent, "\n") + assert.NotContains(t, userAgent, "\r") +}