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
5 changes: 3 additions & 2 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions utils/cliutils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
94 changes: 94 additions & 0 deletions utils/cliutils/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Loading