From e20ff63394179405de9058f5c552d96f13cc47f5 Mon Sep 17 00:00:00 2001 From: Alex Shovlin Date: Tue, 14 Jul 2026 14:46:36 -0400 Subject: [PATCH] Improve User-Agent attribution for agentic callers --- .../enhancement-UserAgent-78213.json | 5 +++ awscli/botocore/useragent.py | 44 +++++++++++++++++++ tests/functional/botocore/test_useragent.py | 37 ++++++++++++++++ tests/unit/botocore/test_useragent.py | 25 ++++++++++- 4 files changed, 110 insertions(+), 1 deletion(-) create mode 100644 .changes/next-release/enhancement-UserAgent-78213.json diff --git a/.changes/next-release/enhancement-UserAgent-78213.json b/.changes/next-release/enhancement-UserAgent-78213.json new file mode 100644 index 000000000000..09e6145ae353 --- /dev/null +++ b/.changes/next-release/enhancement-UserAgent-78213.json @@ -0,0 +1,5 @@ +{ + "type": "enhancement", + "category": "User Agent", + "description": "Improve User-Agent attribution for agentic callers." +} diff --git a/awscli/botocore/useragent.py b/awscli/botocore/useragent.py index e1537381fc37..e3618150e322 100644 --- a/awscli/botocore/useragent.py +++ b/awscli/botocore/useragent.py @@ -104,7 +104,29 @@ 'FLEXIBLE_CHECKSUMS_REQ_XXHASH3': 'AG', 'FLEXIBLE_CHECKSUMS_REQ_XXHASH64': 'AH', 'FLEXIBLE_CHECKSUMS_REQ_XXHASH128': 'AI', + 'AGENTIC_CALLER_CLAUDE_CODE': 'AN', + 'AGENTIC_CALLER_GEMINI_CLI': 'AO', + 'AGENTIC_CALLER_CODEX': 'AP', + 'AGENTIC_CALLER_KIRO': 'AQ', + 'AGENTIC_CALLER_OPENCODE': 'AR', + 'AGENTIC_CALLER_ANTIGRAVITY': 'AS', + 'AGENTIC_CALLER_AMP': 'AT', + 'AGENTIC_CALLER_PI': 'AU', + 'AGENTIC_CALLER_COPILOT_CLI': 'AV', + 'AGENTIC_CALLER_CURSOR': 'AW', } +_USERAGENT_AGENTIC_CALLER_ENV_VAR_MAPPINGS = ( + ('CLAUDECODE', 'AGENTIC_CALLER_CLAUDE_CODE', '1'), + ('GEMINI_CLI', 'AGENTIC_CALLER_GEMINI_CLI', '1'), + ('CODEX_THREAD_ID', 'AGENTIC_CALLER_CODEX', None), + ('KIRO_SESSION_ID', 'AGENTIC_CALLER_KIRO', None), + ('OPENCODE', 'AGENTIC_CALLER_OPENCODE', None), + ('ANTIGRAVITY_AGENT', 'AGENTIC_CALLER_ANTIGRAVITY', None), + ('AMP_CURRENT_THREAD_ID', 'AGENTIC_CALLER_AMP', None), + ('PI_CODING_AGENT', 'AGENTIC_CALLER_PI', 'true'), + ('COPILOT_CLI', 'AGENTIC_CALLER_COPILOT_CLI', '1'), + ('CURSOR_AGENT', 'AGENTIC_CALLER_CURSOR', '1'), +) def register_feature_id(feature_id): @@ -136,6 +158,27 @@ def register_feature_ids(feature_ids): register_feature_id(feature_id) +def _agentic_env_var_is_set(env_var, expected_value): + """ + Returns true if an environment variable is set, + optionally with specific value + """ + value = os.environ.get(env_var) + if expected_value is None: + return value not in (None, '') + return value == expected_value + + +def _register_agentic_caller_env_features(): + for ( + env_var, + feature_id, + expected_value, + ) in _USERAGENT_AGENTIC_CALLER_ENV_VAR_MAPPINGS: + if _agentic_env_var_is_set(env_var, expected_value): + register_feature_id(feature_id) + + def sanitize_user_agent_string_component(raw_str, allow_hash): """Replaces all not allowed characters in the string with a dash ("-"). @@ -549,6 +592,7 @@ def _build_feature_metadata(self): Returns a single component with prefix "m" followed by a list of comma-separated metric values. """ + _register_agentic_caller_env_features() ctx = get_context() context_features = set() if ctx is None else ctx.features client_features = self._client_features or set() diff --git a/tests/functional/botocore/test_useragent.py b/tests/functional/botocore/test_useragent.py index 9857d7760ea0..e642e21a201d 100644 --- a/tests/functional/botocore/test_useragent.py +++ b/tests/functional/botocore/test_useragent.py @@ -57,6 +57,27 @@ def captured_ua_string(self): return None +_AGENTIC_CALLER_ENV_FEATURE_MAPPINGS = ( + ('CLAUDECODE', '1', 'AN'), + ('GEMINI_CLI', '1', 'AO'), + ('CODEX_THREAD_ID', 'thread-id', 'AP'), + ('KIRO_SESSION_ID', 'session-id', 'AQ'), + ('OPENCODE', '1', 'AR'), + ('ANTIGRAVITY_AGENT', '1', 'AS'), + ('AMP_CURRENT_THREAD_ID', 'thread-id', 'AT'), + ('PI_CODING_AGENT', 'true', 'AU'), + ('COPILOT_CLI', '1', 'AV'), + ('CURSOR_AGENT', '1', 'AW'), +) + + +@pytest.fixture(autouse=True) +def clear_agentic_caller_env(monkeypatch): + """Unset agentic tool env vars that could affect user-agent tests.""" + for env_var, _, _ in _AGENTIC_CALLER_ENV_FEATURE_MAPPINGS: + monkeypatch.delenv(env_var, raising=False) + + @pytest.mark.parametrize( 'sess_name, sess_version, sess_extra, cfg_extra, cfg_appid', # Produce every combination of User-Agent related config settings other @@ -229,6 +250,22 @@ def test_user_agent_has_registered_feature_id(patched_session): assert 'C' in feature_list +@pytest.mark.parametrize( + 'env_var, env_value, expected_feature', + _AGENTIC_CALLER_ENV_FEATURE_MAPPINGS, +) +def test_user_agent_has_agentic_caller_feature_id( + patched_session, monkeypatch, env_var, env_value, expected_feature +): + monkeypatch.setenv(env_var, env_value) + client_s3 = patched_session.create_client('s3') + with UACapHTTPStubber(client_s3) as stub_client: + client_s3.list_buckets() + + feature_list = parse_registered_feature_ids(stub_client.captured_ua_string) + assert expected_feature in feature_list + + def test_registered_feature_ids_dont_bleed_between_requests(patched_session): client_s3 = patched_session.create_client('s3') with UACapHTTPStubber(client_s3) as stub_client: diff --git a/tests/unit/botocore/test_useragent.py b/tests/unit/botocore/test_useragent.py index b6a238d90c1d..53f884647a1e 100644 --- a/tests/unit/botocore/test_useragent.py +++ b/tests/unit/botocore/test_useragent.py @@ -26,6 +26,29 @@ sanitize_user_agent_string_component, ) +_AGENTIC_CALLER_ENV_VARS = ( + 'CLAUDECODE', + 'GEMINI_CLI', + 'CODEX_THREAD_ID', + 'KIRO_SESSION_ID', + 'OPENCODE', + 'ANTIGRAVITY_AGENT', + 'AMP_CURRENT_THREAD_ID', + 'PI_CODING_AGENT', + 'COPILOT_CLI', + 'CURSOR_AGENT', +) + + +@pytest.fixture(autouse=True) +def clear_agentic_caller_env(monkeypatch): + """ + Unset any env vars from agentic tools running the user-agent + tests which could influence the user-agent + """ + for env_var in _AGENTIC_CALLER_ENV_VARS: + monkeypatch.delenv(env_var, raising=False) + @pytest.mark.parametrize( 'raw_str, allow_hash, expected_str', @@ -259,4 +282,4 @@ def test_hash_in_user_agent_appid(): 'exec-env/AWS_Lambda_python3.8 ' 'app/fooapp#1.0.0' ) - assert actual == expected \ No newline at end of file + assert actual == expected