From 8480b95191da6cfcd700bc851bc0d5c14547e859 Mon Sep 17 00:00:00 2001 From: aemous Date: Thu, 11 Jun 2026 09:56:53 -0400 Subject: [PATCH 1/5] Add Session IDs to User-Agent header even when cache does not exist. --- .changes/next-release/bugfix-UserAgent-47668.json | 5 +++++ awscli/telemetry.py | 9 +++++---- tests/functional/test_telemetry.py | 14 ++++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) create mode 100644 .changes/next-release/bugfix-UserAgent-47668.json diff --git a/.changes/next-release/bugfix-UserAgent-47668.json b/.changes/next-release/bugfix-UserAgent-47668.json new file mode 100644 index 000000000000..f5256313ec29 --- /dev/null +++ b/.changes/next-release/bugfix-UserAgent-47668.json @@ -0,0 +1,5 @@ +{ + "type": "bugfix", + "category": "User Agent", + "description": "Ensure that session IDs are added to the User-Agent HTTP header even when the local AWS CLI cache does not exist." +} diff --git a/awscli/telemetry.py b/awscli/telemetry.py index 3cf44e83ace0..cbe68cd2124c 100644 --- a/awscli/telemetry.py +++ b/awscli/telemetry.py @@ -76,13 +76,14 @@ class CLISessionDatabaseConnection: """ _ENABLE_WAL = 'PRAGMA journal_mode=WAL' - def __init__(self, connection=None): + def __init__(self, connection=None, cache_dir=None): + self._cache_dir = cache_dir or _CACHE_DIR + self._ensure_cache_dir() self._connection = connection or sqlite3.connect( - _CACHE_DIR / _DATABASE_FILENAME, + self._cache_dir / _DATABASE_FILENAME, check_same_thread=False, isolation_level=None, ) - self._ensure_cache_dir() self._ensure_database_setup() def execute(self, query, *parameters): @@ -95,7 +96,7 @@ def execute(self, query, *parameters): return sqlite3.Cursor(self._connection) def _ensure_cache_dir(self): - _CACHE_DIR.mkdir(parents=True, exist_ok=True) + self._cache_dir.mkdir(parents=True, exist_ok=True) def _ensure_database_setup(self): self._create_session_table() diff --git a/tests/functional/test_telemetry.py b/tests/functional/test_telemetry.py index 698b639d0329..dd7b146c34dd 100644 --- a/tests/functional/test_telemetry.py +++ b/tests/functional/test_telemetry.py @@ -107,6 +107,20 @@ def test_ensure_database_setup(self, session_conn): ) assert cursor.fetchall() == [('session',), ('host_id',)] + def test_creates_database_when_cache_dir_does_not_exist(self, tmp_path): + # When the cache directory doesn't exist, the connection should still + # be established successfully. + nonexistent_dir = tmp_path / 'nonexistent' / 'nested' / 'cache' + assert not nonexistent_dir.exists() + conn = CLISessionDatabaseConnection(cache_dir=nonexistent_dir) + assert nonexistent_dir.exists() + assert (nonexistent_dir / 'session.db').exists() + # Verify the database is functional. + writer = CLISessionDatabaseWriter(conn) + reader = CLISessionDatabaseReader(conn) + writer.write(CLISessionData('key', 'sid', 1000000000)) + assert reader.read('key').session_id == 'sid' + def test_timeout_does_not_raise_exception(self, session_conn): test_query = """ SELECT name From 0f52d8bf7766ccad996345ce71eace8698eb53e2 Mon Sep 17 00:00:00 2001 From: aemous Date: Sun, 14 Jun 2026 00:55:17 -0400 Subject: [PATCH 2/5] Remove integration test that verified cache is not created for the aws --version. --- tests/integration/test_cli.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index 1c156887cfb9..0b249db1e63c 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -310,15 +310,6 @@ def test_version(self): ) self.assertTrue(re.fullmatch(user_agent_regex, version_output)) - def test_version_does_not_create_cache_directory(self): - # Regression test: --version should not create any files/directories. - with tempfile.TemporaryDirectory() as tmpdir: - env = os.environ.copy() - env['HOME'] = tmpdir - aws('--version', env_vars=env) - aws_dir = os.path.join(tmpdir, '.aws') - self.assertFalse(os.path.exists(aws_dir)) - def test_version_with_exec_env(self): base_env_vars = os.environ.copy() base_env_vars['AWS_EXECUTION_ENV'] = 'an_execution_env' From 4682581633010f0f1b98a106e461923f034fe82a Mon Sep 17 00:00:00 2001 From: aemous Date: Wed, 17 Jun 2026 12:31:46 -0400 Subject: [PATCH 3/5] Add back test that verifies no telemetry during aws-cli/2.35.2 Python/3.12.6 Darwin/25.5.0 source/arm64, and modify code so that the test passes. --- awscli/clidriver.py | 4 ++-- awscli/telemetry.py | 34 ++++++++++++++++++------------ tests/functional/test_telemetry.py | 33 ++++++++++++++++++++++------- tests/integration/test_cli.py | 9 ++++++++ tests/unit/test_clidriver.py | 14 +----------- 5 files changed, 57 insertions(+), 37 deletions(-) diff --git a/awscli/clidriver.py b/awscli/clidriver.py index 206925cf6877..1f4abae88087 100644 --- a/awscli/clidriver.py +++ b/awscli/clidriver.py @@ -76,7 +76,7 @@ set_stream_logger, ) from awscli.plugin import load_plugins -from awscli.telemetry import add_session_id_component_to_user_agent_extra +from awscli.telemetry import register_session_id_event from awscli.utils import ( IMDSRegionProvider, OutputStreamFactory, @@ -211,7 +211,6 @@ def _set_user_agent_for_session(session): session.user_agent_version = __version__ _add_distribution_source_to_user_agent(session) _add_linux_distribution_to_user_agent(session) - add_session_id_component_to_user_agent_extra(session) def no_pager_handler(session, parsed_args, **kwargs): @@ -283,6 +282,7 @@ def __init__(self, session=None, error_handler=None, debug=False): _set_user_agent_for_session(self.session) else: self.session = session + register_session_id_event(self.session) self._error_handler = error_handler if self._error_handler is None: self._error_handler = construct_cli_error_handlers_chain( diff --git a/awscli/telemetry.py b/awscli/telemetry.py index cbe68cd2124c..39afb8d262ed 100644 --- a/awscli/telemetry.py +++ b/awscli/telemetry.py @@ -296,17 +296,23 @@ def _get_cli_session_orchestrator(): ) -def add_session_id_component_to_user_agent_extra(session, orchestrator=None): - try: - cli_session_orchestrator = ( - orchestrator or _get_cli_session_orchestrator() - ) - add_component_to_user_agent_extra( - session, - UserAgentComponent("sid", cli_session_orchestrator.session_id), - ) - except Exception: - # Ideally, the AWS CLI should never throw if the session id - # can't be generated since it's not critical for users. Issues - # with session data should instead be caught server-side. - pass +def register_session_id_event(session, orchestrator_factory=None): + if orchestrator_factory is None: + orchestrator_factory = _get_cli_session_orchestrator + event_emitter = session.get_component('event_emitter') + + def _inject_session_id(params, **kwargs): + try: + orchestrator = orchestrator_factory() + add_component_to_user_agent_extra( + session, + UserAgentComponent("sid", orchestrator.session_id), + ) + except Exception: + # Ideally, the AWS CLI should never throw if the session id + # can't be generated since it's not critical for users. Issues + # with session data should instead be caught server-side. + pass + event_emitter.unregister('before-call', _inject_session_id) + + event_emitter.register('before-call', _inject_session_id) diff --git a/tests/functional/test_telemetry.py b/tests/functional/test_telemetry.py index dd7b146c34dd..94affd5f2e3b 100644 --- a/tests/functional/test_telemetry.py +++ b/tests/functional/test_telemetry.py @@ -11,7 +11,7 @@ # ANY KIND, either express or implied. See the License for the specific # language governing permissions and limitations under the License. import sqlite3 -from unittest.mock import MagicMock, PropertyMock, patch +from unittest.mock import MagicMock, patch import pytest from botocore.exceptions import MD5UnavailableError @@ -25,7 +25,7 @@ CLISessionDatabaseWriter, CLISessionGenerator, CLISessionOrchestrator, - add_session_id_component_to_user_agent_extra, + register_session_id_event, ) from tests.markers import skip_if_windows @@ -322,17 +322,34 @@ def test_cached_session_id_not_updated_if_valid( assert session_data_2.timestamp != session_data_1.timestamp -def test_add_session_id_component_to_user_agent_extra(): +def test_register_session_id_event_injects_sid_on_before_call(): session = MagicMock(Session) session.user_agent_extra = '' + event_emitter = MagicMock() + session.get_component.return_value = event_emitter orchestrator = MagicMock(CLISessionOrchestrator) orchestrator.session_id = 'my-session-id' - add_session_id_component_to_user_agent_extra(session, orchestrator) + + def fake_orchestrator_factory(): + return orchestrator + + register_session_id_event( + session, orchestrator_factory=fake_orchestrator_factory + ) + handler = event_emitter.register.call_args[0][1] + handler(params={}) assert session.user_agent_extra == 'sid/my-session-id' + event_emitter.unregister.assert_called_once_with('before-call', handler) -def test_entrypoint_catches_bare_exceptions(): - mock_orchestrator = MagicMock(CLISessionOrchestrator) - type(mock_orchestrator).session_id = PropertyMock(side_effect=Exception) +def test_register_session_id_event_catches_bare_exceptions(): session = MagicMock(Session) - add_session_id_component_to_user_agent_extra(session, mock_orchestrator) + session.user_agent_extra = '' + event_emitter = MagicMock() + session.get_component.return_value = event_emitter + register_session_id_event( + session, orchestrator_factory=MagicMock(side_effect=Exception) + ) + handler = event_emitter.register.call_args[0][1] + handler(params={}) + assert session.user_agent_extra == '' diff --git a/tests/integration/test_cli.py b/tests/integration/test_cli.py index 0b249db1e63c..1c156887cfb9 100644 --- a/tests/integration/test_cli.py +++ b/tests/integration/test_cli.py @@ -310,6 +310,15 @@ def test_version(self): ) self.assertTrue(re.fullmatch(user_agent_regex, version_output)) + def test_version_does_not_create_cache_directory(self): + # Regression test: --version should not create any files/directories. + with tempfile.TemporaryDirectory() as tmpdir: + env = os.environ.copy() + env['HOME'] = tmpdir + aws('--version', env_vars=env) + aws_dir = os.path.join(tmpdir, '.aws') + self.assertFalse(os.path.exists(aws_dir)) + def test_version_with_exec_env(self): base_env_vars = os.environ.copy() base_env_vars['AWS_EXECUTION_ENV'] = 'an_execution_env' diff --git a/tests/unit/test_clidriver.py b/tests/unit/test_clidriver.py index cd5315212728..80cc600a12b2 100644 --- a/tests/unit/test_clidriver.py +++ b/tests/unit/test_clidriver.py @@ -315,12 +315,6 @@ def _run_main(self, args, parsed_globals): return 0 -class FakeCLISessionOrchestrator: - @property - def session_id(self): - return 'mysessionid' - - class TestCliDriver: def setup_method(self): self.session = FakeSession() @@ -846,19 +840,13 @@ def test_idempotency_token_is_not_required_in_help_text(self): self.assertEqual(rc, 252) self.assertNotIn('--idempotency-token', self.stderr.getvalue()) - @mock.patch( - 'awscli.telemetry._get_cli_session_orchestrator', - return_value=FakeCLISessionOrchestrator(), - ) @mock.patch('awscli.clidriver.platform.system', return_value='Linux') @mock.patch('awscli.clidriver.platform.machine', return_value='x86_64') @mock.patch('awscli.clidriver.distro.id', return_value='amzn') @mock.patch('awscli.clidriver.distro.major_version', return_value='1') def test_user_agent_for_linux(self, *args): driver = create_clidriver() - expected_user_agent = ( - 'md/installer#source md/distrib#amzn.1 sid/mysessionid' - ) + expected_user_agent = 'md/installer#source md/distrib#amzn.1' self.assertEqual(expected_user_agent, driver.session.user_agent_extra) def test_user_agent(self, *args): From 84875f353369977e41a5df816a30252b723c75c9 Mon Sep 17 00:00:00 2001 From: aemous Date: Wed, 17 Jun 2026 13:22:38 -0400 Subject: [PATCH 4/5] Fix regression where ordering in User-Agent header was changed. --- awscli/telemetry.py | 15 ++++++++++++--- tests/functional/test_telemetry.py | 13 +++++++++++-- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/awscli/telemetry.py b/awscli/telemetry.py index 39afb8d262ed..8518a46a45f7 100644 --- a/awscli/telemetry.py +++ b/awscli/telemetry.py @@ -304,9 +304,18 @@ def register_session_id_event(session, orchestrator_factory=None): def _inject_session_id(params, **kwargs): try: orchestrator = orchestrator_factory() - add_component_to_user_agent_extra( - session, - UserAgentComponent("sid", orchestrator.session_id), + sid_component = UserAgentComponent( + "sid", orchestrator.session_id + ).to_string() + extra = session.user_agent_extra + # Insert sid after md/installer to preserve original + # user-agent component ordering. + idx = extra.find('md/installer') + end = extra.find(' ', idx) + if end == -1: + end = len(extra) + session.user_agent_extra = ( + extra[:end] + f' {sid_component}' + extra[end:] ) except Exception: # Ideally, the AWS CLI should never throw if the session id diff --git a/tests/functional/test_telemetry.py b/tests/functional/test_telemetry.py index 94affd5f2e3b..ddac917c6aa9 100644 --- a/tests/functional/test_telemetry.py +++ b/tests/functional/test_telemetry.py @@ -17,6 +17,7 @@ from botocore.exceptions import MD5UnavailableError from botocore.session import Session +from awscli.clidriver import create_clidriver from awscli.telemetry import ( CLISessionData, CLISessionDatabaseConnection, @@ -324,7 +325,7 @@ def test_cached_session_id_not_updated_if_valid( def test_register_session_id_event_injects_sid_on_before_call(): session = MagicMock(Session) - session.user_agent_extra = '' + session.user_agent_extra = 'md/installer#source' event_emitter = MagicMock() session.get_component.return_value = event_emitter orchestrator = MagicMock(CLISessionOrchestrator) @@ -338,7 +339,7 @@ def fake_orchestrator_factory(): ) handler = event_emitter.register.call_args[0][1] handler(params={}) - assert session.user_agent_extra == 'sid/my-session-id' + assert session.user_agent_extra == 'md/installer#source sid/my-session-id' event_emitter.unregister.assert_called_once_with('before-call', handler) @@ -353,3 +354,11 @@ def test_register_session_id_event_catches_bare_exceptions(): handler = event_emitter.register.call_args[0][1] handler(params={}) assert session.user_agent_extra == '' + + +def test_user_agent_extra_contains_installer_component(): + # register_session_id_event depends on md/installer being present + # in user_agent_extra to insert sid at the correct position. This + # test ensures that invariant holds after driver creation. + driver = create_clidriver() + assert 'md/installer#' in driver.session.user_agent_extra From cf1b86585f002d9fc28d83998094a53096678d47 Mon Sep 17 00:00:00 2001 From: aemous Date: Wed, 17 Jun 2026 13:34:16 -0400 Subject: [PATCH 5/5] Fix bug where sid was not being injcted into User-Agent header due to snapshot of the header value at client creation time. --- awscli/telemetry.py | 8 ++++---- tests/functional/test_telemetry.py | 10 ++++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/awscli/telemetry.py b/awscli/telemetry.py index 8518a46a45f7..aa2e01eab84f 100644 --- a/awscli/telemetry.py +++ b/awscli/telemetry.py @@ -301,15 +301,15 @@ def register_session_id_event(session, orchestrator_factory=None): orchestrator_factory = _get_cli_session_orchestrator event_emitter = session.get_component('event_emitter') - def _inject_session_id(params, **kwargs): + def _inject_session_id(**kwargs): try: orchestrator = orchestrator_factory() sid_component = UserAgentComponent( "sid", orchestrator.session_id ).to_string() - extra = session.user_agent_extra # Insert sid after md/installer to preserve original # user-agent component ordering. + extra = session.user_agent_extra idx = extra.find('md/installer') end = extra.find(' ', idx) if end == -1: @@ -322,6 +322,6 @@ def _inject_session_id(params, **kwargs): # can't be generated since it's not critical for users. Issues # with session data should instead be caught server-side. pass - event_emitter.unregister('before-call', _inject_session_id) + event_emitter.unregister('before-create-client', _inject_session_id) - event_emitter.register('before-call', _inject_session_id) + event_emitter.register('before-create-client', _inject_session_id) diff --git a/tests/functional/test_telemetry.py b/tests/functional/test_telemetry.py index ddac917c6aa9..5e4c73f5e32e 100644 --- a/tests/functional/test_telemetry.py +++ b/tests/functional/test_telemetry.py @@ -323,7 +323,7 @@ def test_cached_session_id_not_updated_if_valid( assert session_data_2.timestamp != session_data_1.timestamp -def test_register_session_id_event_injects_sid_on_before_call(): +def test_register_session_id_event_injects_sid_on_before_create_client(): session = MagicMock(Session) session.user_agent_extra = 'md/installer#source' event_emitter = MagicMock() @@ -338,9 +338,11 @@ def fake_orchestrator_factory(): session, orchestrator_factory=fake_orchestrator_factory ) handler = event_emitter.register.call_args[0][1] - handler(params={}) + handler() assert session.user_agent_extra == 'md/installer#source sid/my-session-id' - event_emitter.unregister.assert_called_once_with('before-call', handler) + event_emitter.unregister.assert_called_once_with( + 'before-create-client', handler + ) def test_register_session_id_event_catches_bare_exceptions(): @@ -352,7 +354,7 @@ def test_register_session_id_event_catches_bare_exceptions(): session, orchestrator_factory=MagicMock(side_effect=Exception) ) handler = event_emitter.register.call_args[0][1] - handler(params={}) + handler() assert session.user_agent_extra == ''