Skip to content

Commit ec300e4

Browse files
Add AI_AGENT fallback and sanitized passthrough for agent detection
Mirror databricks-sdk-go PR #1683 in the User-Agent agent detection. Add AI_AGENT (the Vercel @vercel/detect-agent convention) as a secondary fallback env var, consulted only when AGENT (the agents.md standard) is unset or empty. AGENT takes precedence when both are non-empty. Explicit product-specific env vars (CLAUDECODE, CURSOR_AGENT, etc.) still win over both. Change the fallback behavior so an unrecognized value is passed through rather than coerced to "unknown". The raw value is sanitized to the User-Agent allowlist (disallowed characters become "-") and capped at 64 characters. This applies to both AGENT and AI_AGENT. Co-authored-by: Isaac
1 parent 6235813 commit ec300e4

3 files changed

Lines changed: 147 additions & 20 deletions

File tree

NEXT_CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
### New Features and Improvements
66

7+
* Honor the Vercel `AI_AGENT=<name>` env var as a secondary fallback for AI agent detection in the User-Agent header (after the agents.md `AGENT=<name>` standard). Unrecognized fallback values now pass through the User-Agent sanitized and length-capped at 64 chars instead of being coerced to `agent/unknown`, so versioned variants such as `claude-code_2-1-141_agent` surface as-is.
8+
79
### Security
810

911
### Bug Fixes

databricks/sdk/useragent.py

Lines changed: 33 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@
2323
# Precompiled regex patterns
2424
alphanum_pattern = re.compile(r"^[a-zA-Z0-9_.+-]+$")
2525

26+
# Matches any single character not allowed in a User-Agent token. Used to
27+
# sanitize free-form values (e.g. the AGENT/AI_AGENT fallback) by replacing
28+
# disallowed characters with a hyphen.
29+
alphanum_inverse_pattern = re.compile(r"[^a-zA-Z0-9_.+-]")
30+
2631
# official https://semver.org/ recommendation: https://regex101.com/r/Ly7O1x/
2732
# with addition of "x" wildcards for minor/patch versions. Also, patch version may be omitted.
2833
semver_pattern = re.compile(
@@ -133,6 +138,11 @@ def _sanitize_header_value(value: str) -> str:
133138
return value
134139

135140

141+
def _sanitize_agent_value(value: str) -> str:
142+
"""Replace any character not allowed in a User-Agent token with a hyphen."""
143+
return alphanum_inverse_pattern.sub("-", value)
144+
145+
136146
def to_string(
137147
alternate_product_info: Optional[Tuple[str, str]] = None,
138148
other_info: Optional[List[Tuple[str, str]]] = None,
@@ -226,7 +236,8 @@ def cicd_provider() -> str:
226236

227237

228238
# Canonical list of known AI coding agents. Alphabetical by product name.
229-
# Keep this list in sync with databricks-sdk-go and databricks-sdk-java.
239+
# Keep this list, and the AGENT / AI_AGENT fallback handling in
240+
# _agent_env_fallback, in sync with databricks-sdk-go and databricks-sdk-java.
230241
#
231242
# Each record has a single env var that identifies the product by presence
232243
# (the env var just needs to be set, even to an empty string).
@@ -236,6 +247,12 @@ class _AgentRecord:
236247
product: str
237248

238249

250+
# Caps fallback values to keep the User-Agent bounded. Explicit-matcher
251+
# products are short by construction; only the fallback path can carry
252+
# arbitrary lengths.
253+
_MAX_AGENT_FALLBACK_LEN = 64
254+
255+
239256
_KNOWN_AGENTS: List[_AgentRecord] = [
240257
_AgentRecord("AMP_CURRENT_THREAD_ID", "amp"), # https://ampcode.com/ (also sets AGENT=amp, handled centrally)
241258
_AgentRecord("ANTIGRAVITY_AGENT", "antigravity"), # Closed source (Google)
@@ -274,13 +291,12 @@ def agent_provider() -> str:
274291
every enclosing layer).
275292
276293
Explicit agent env vars (e.g. CLAUDECODE, GOOSE_TERMINAL) always take
277-
precedence. The agents.md-standard AGENT=<name> env var is only consulted
278-
as a fallback when no explicit matcher fired:
279-
- If AGENT matches a known product name, return that product.
280-
- Otherwise return "unknown".
294+
precedence. The agents.md-standard AGENT=<name> env var and the Vercel
295+
AI_AGENT=<name> convention are only consulted as a fallback when no
296+
explicit matcher fired (see _agent_env_fallback).
281297
282-
This means AGENT=<name> never contributes to the multi-agent signal: if
283-
any explicit matcher fires, AGENT is ignored entirely, even when it names
298+
This means AGENT/AI_AGENT never contribute to the multi-agent signal: if
299+
any explicit matcher fires, they are ignored entirely, even when they name
284300
a different known product.
285301
286302
Result is cached after first call.
@@ -301,14 +317,18 @@ def agent_provider() -> str:
301317

302318

303319
def _agent_env_fallback() -> str:
304-
"""Honor the agents.md AGENT=<name> standard.
320+
"""Return a sanitized, length-capped name from AGENT or AI_AGENT.
305321
306-
Returns the value if it matches a known product name, "unknown" if AGENT
307-
is set to any other non-empty value, and "" if AGENT is unset or empty.
322+
AGENT (the agents.md standard) is preferred; AI_AGENT (the Vercel
323+
@vercel/detect-agent convention) is consulted only when AGENT is unset or
324+
empty. The value is passed through rather than categorized so that new
325+
names are propagated without updating the list of known agents. Returns ""
326+
if both are unset or empty.
308327
"""
309328
v = os.environ.get("AGENT", "")
329+
if not v:
330+
v = os.environ.get("AI_AGENT", "")
310331
if not v:
311332
return ""
312-
if v in {a.product for a in _KNOWN_AGENTS}:
313-
return v
314-
return "unknown"
333+
v = _sanitize_agent_value(v)
334+
return v[:_MAX_AGENT_FALLBACK_LEN]

tests/test_user_agent.py

Lines changed: 112 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -256,25 +256,50 @@ def test_agent_provider_windsurf(clean_useragent_env):
256256
assert useragent.agent_provider() == "windsurf"
257257

258258

259-
def test_agent_provider_unknown_agent_fallback(clean_useragent_env):
260-
# AGENT set to a value that doesn't match any known agent
261-
# should fall back to "unknown".
259+
def test_agent_provider_unknown_agent_passthrough(clean_useragent_env):
260+
# AGENT set to a value that doesn't match any known agent now passes
261+
# through (sanitized) rather than being coerced to "unknown".
262262
os.environ["AGENT"] = "someweirdthing"
263263
from databricks.sdk import useragent
264264

265-
assert useragent.agent_provider() == "unknown"
265+
assert useragent.agent_provider() == "someweirdthing"
266266

267267

268268
def test_agent_provider_agent_known_product_name_fallback(clean_useragent_env):
269-
# AGENT=<known product name> with no other matchers set should resolve
270-
# to the matching product (e.g. cursor is only identified by CURSOR_AGENT;
271-
# AGENT=cursor is a reasonable implicit signal to attribute it).
269+
# AGENT=<known product name> with no other matchers set passes through
270+
# unchanged (cursor is only identified by CURSOR_AGENT; AGENT=cursor is a
271+
# reasonable implicit signal to attribute it).
272272
os.environ["AGENT"] = "cursor"
273273
from databricks.sdk import useragent
274274

275275
assert useragent.agent_provider() == "cursor"
276276

277277

278+
def test_agent_provider_agent_versioned_variant_passthrough(clean_useragent_env):
279+
# A versioned variant passes through unchanged since every character is
280+
# already in the allowlist.
281+
os.environ["AGENT"] = "claude-code_2-1-141_agent"
282+
from databricks.sdk import useragent
283+
284+
assert useragent.agent_provider() == "claude-code_2-1-141_agent"
285+
286+
287+
def test_agent_provider_agent_disallowed_chars_sanitized(clean_useragent_env):
288+
# Characters outside the User-Agent allowlist are replaced with hyphens.
289+
os.environ["AGENT"] = "claude code/agent"
290+
from databricks.sdk import useragent
291+
292+
assert useragent.agent_provider() == "claude-code-agent"
293+
294+
295+
def test_agent_provider_agent_over_cap_truncated(clean_useragent_env):
296+
# Values longer than the cap are truncated to 64 characters.
297+
os.environ["AGENT"] = "a" * 100
298+
from databricks.sdk import useragent
299+
300+
assert useragent.agent_provider() == "a" * 64
301+
302+
278303
def test_agent_provider_known_matcher_wins_over_agent_fallback(clean_useragent_env):
279304
# When a known matcher fires, it wins even if AGENT is set to an
280305
# unrelated value. The AGENT fallback only applies when nothing else hits.
@@ -293,6 +318,86 @@ def test_agent_provider_agent_empty_string(clean_useragent_env):
293318
assert useragent.agent_provider() == ""
294319

295320

321+
def test_agent_provider_ai_agent_fallback(clean_useragent_env):
322+
# AI_AGENT (Vercel @vercel/detect-agent convention) is consulted as a
323+
# secondary fallback when AGENT is unset.
324+
os.environ["AI_AGENT"] = "cursor"
325+
from databricks.sdk import useragent
326+
327+
assert useragent.agent_provider() == "cursor"
328+
329+
330+
def test_agent_provider_ai_agent_empty_string(clean_useragent_env):
331+
# AI_AGENT="" (empty) should NOT trigger the fallback.
332+
os.environ["AI_AGENT"] = ""
333+
from databricks.sdk import useragent
334+
335+
assert useragent.agent_provider() == ""
336+
337+
338+
def test_agent_provider_known_matcher_wins_over_ai_agent_fallback(clean_useragent_env):
339+
# An explicit matcher wins over the AI_AGENT fallback.
340+
os.environ["AI_AGENT"] = "somethingunknown"
341+
os.environ["CLAUDECODE"] = "1"
342+
from databricks.sdk import useragent
343+
344+
assert useragent.agent_provider() == "claude-code"
345+
346+
347+
def test_agent_provider_agent_wins_over_ai_agent(clean_useragent_env):
348+
# AGENT takes precedence over AI_AGENT when both are non-empty.
349+
os.environ["AGENT"] = "claude-code"
350+
os.environ["AI_AGENT"] = "cursor"
351+
from databricks.sdk import useragent
352+
353+
assert useragent.agent_provider() == "claude-code"
354+
355+
356+
def test_agent_provider_agent_unrecognized_wins_over_ai_agent(clean_useragent_env):
357+
# A non-empty AGENT wins over AI_AGENT even when it is unrecognized.
358+
os.environ["AGENT"] = "somethingunknown"
359+
os.environ["AI_AGENT"] = "cursor"
360+
from databricks.sdk import useragent
361+
362+
assert useragent.agent_provider() == "somethingunknown"
363+
364+
365+
def test_agent_provider_agent_set_ai_agent_empty(clean_useragent_env):
366+
# AGENT set, AI_AGENT empty: AGENT value is used.
367+
os.environ["AGENT"] = "cursor"
368+
os.environ["AI_AGENT"] = ""
369+
from databricks.sdk import useragent
370+
371+
assert useragent.agent_provider() == "cursor"
372+
373+
374+
def test_agent_provider_empty_agent_falls_through_to_ai_agent(clean_useragent_env):
375+
# An empty AGENT falls through to AI_AGENT.
376+
os.environ["AGENT"] = ""
377+
os.environ["AI_AGENT"] = "cursor"
378+
from databricks.sdk import useragent
379+
380+
assert useragent.agent_provider() == "cursor"
381+
382+
383+
def test_agent_provider_both_agent_and_ai_agent_empty(clean_useragent_env):
384+
# Both AGENT and AI_AGENT empty returns no agent.
385+
os.environ["AGENT"] = ""
386+
os.environ["AI_AGENT"] = ""
387+
from databricks.sdk import useragent
388+
389+
assert useragent.agent_provider() == ""
390+
391+
392+
def test_agent_provider_explicit_wins_over_ai_agent(clean_useragent_env):
393+
# An explicit env var wins over AI_AGENT naming a different product.
394+
os.environ["AI_AGENT"] = "cursor"
395+
os.environ["CLAUDECODE"] = "1"
396+
from databricks.sdk import useragent
397+
398+
assert useragent.agent_provider() == "claude-code"
399+
400+
296401
def test_agent_provider_multiple_agents(clean_useragent_env):
297402
# Nested agents (e.g. Claude Code spawning a Cursor CLI subagent) set
298403
# multiple explicit matchers on the same process.

0 commit comments

Comments
 (0)