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") +}