From 7772063eeba9fa0f36d34c9875d5c5b14c5a921d Mon Sep 17 00:00:00 2001 From: Chad Chervitz Date: Sun, 15 Feb 2026 10:53:41 -0600 Subject: [PATCH 1/4] fix: honor ssh_key_path config in SessionKeyManager and config.local.json --- .../configmanager/file_config_manager.py | 4 +- src/claudesync/session_key_manager.py | 61 ++++++++++++++++--- 2 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/claudesync/configmanager/file_config_manager.py b/src/claudesync/configmanager/file_config_manager.py index 2ed9c845f..196daa768 100644 --- a/src/claudesync/configmanager/file_config_manager.py +++ b/src/claudesync/configmanager/file_config_manager.py @@ -187,7 +187,7 @@ def set_session_key(self, provider, session_key, expiry): expiry (datetime): The expiry datetime for the session key. """ try: - session_key_manager = SessionKeyManager() + session_key_manager = SessionKeyManager(self.get("ssh_key_path")) encrypted_session_key, encryption_method = ( session_key_manager.encrypt_session_key(provider, session_key) ) @@ -236,7 +236,7 @@ def get_session_key(self, provider): return None, None try: - session_key_manager = SessionKeyManager() + session_key_manager = SessionKeyManager(self.get("ssh_key_path")) session_key = session_key_manager.decrypt_session_key( provider, encryption_method, encrypted_key ) diff --git a/src/claudesync/session_key_manager.py b/src/claudesync/session_key_manager.py index 48f2bf083..dc1a46a28 100644 --- a/src/claudesync/session_key_manager.py +++ b/src/claudesync/session_key_manager.py @@ -8,22 +8,65 @@ class SessionKeyManager: - def __init__(self): - self.ssh_key_path = self._find_ssh_key() + def __init__(self, ssh_key_path=None): + # Allow config-provided ssh_key_path to guide key discovery + self.ssh_key_path = self._find_ssh_key(ssh_key_path) self.logger = logging.getLogger(__name__) - def _find_ssh_key(self): - ssh_dir = Path.home() / ".ssh" + def _find_ssh_key(self, configured_path=None): + """ + Locate an SSH private key for session encryption. + + Priority: + 1. If configured_path points to a specific file, check it first + 2. If configured_path is a directory, search it alongside ~/.ssh + 3. Fall back to ~/.ssh with default key names + 4. Prompt the user as a last resort + """ + default_ssh_dir = Path.home() / ".ssh" key_names = ["id_ed25519", "id_ecdsa"] - for key_name in key_names: - key_path = ssh_dir / key_name - if key_path.exists(): - return str(key_path) + search_dirs = [default_ssh_dir] + + if configured_path: + configured = Path(configured_path) + + if configured.is_file(): + # Config points directly to a key file — use it immediately + return str(configured) + + if configured.exists() and not configured.is_dir(): + # Path exists but isn't a regular file or dir — still try it + return str(configured) + + if configured.is_dir(): + # Config is a directory — search it in addition to ~/.ssh + if configured != default_ssh_dir: + search_dirs.insert(0, configured) + else: + # Path doesn't exist yet, but has a filename component — + # treat it as a full file path the user intends to create + if configured.suffix or configured.name.startswith("id_"): + print(f"* Configured ssh_key_path not found: {configured_path}") + else: + # Looks like a directory that doesn't exist + print(f"* Configured ssh_key_path directory not found: {configured_path}") + + # Search all candidate directories for supported key names + for search_dir in search_dirs: + for key_name in key_names: + key_path = search_dir / key_name + if key_path.exists(): + return str(key_path) # If no supported key is found, prompt the user to generate an Ed25519 key + # No supported key found — prompt user print("* No supported SSH key found. RSA keys are no longer supported.") print("* Please generate an Ed25519 key using the following command:") - print('ssh-keygen -t ed25519 -C "your_email@example.com"') + print(' ssh-keygen -t ed25519 -C "your_email@example.com"') + print("* If you don't specify a custom ssh_key_path in config,") + print("* have created a key, and are still seeing this message,") + print(" be sure to name your key 'id_ed25519' or 'id_ecdsa' so it's found automatically.") + print("* Or set ssh_key_path with the full key name in your .claudesync/config.local.json") return input("Enter the full path to your new Ed25519 private key: ") def _get_key_type(self): From ecfae35fb42e90e43d5a33792eb95cdad85871bf Mon Sep 17 00:00:00 2001 From: Chad Chervitz Date: Thu, 26 Feb 2026 16:46:15 -0600 Subject: [PATCH 2/4] fix: remove trailing whitespace on blank line (flake8 W293) --- src/claudesync/session_key_manager.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/claudesync/session_key_manager.py b/src/claudesync/session_key_manager.py index dc1a46a28..7c67fda17 100644 --- a/src/claudesync/session_key_manager.py +++ b/src/claudesync/session_key_manager.py @@ -16,7 +16,6 @@ def __init__(self, ssh_key_path=None): def _find_ssh_key(self, configured_path=None): """ Locate an SSH private key for session encryption. - Priority: 1. If configured_path points to a specific file, check it first 2. If configured_path is a directory, search it alongside ~/.ssh From b48ac134033168bb9e9e302995c6847bf5a7edd5 Mon Sep 17 00:00:00 2001 From: Chad Chervitz Date: Thu, 26 Feb 2026 16:59:16 -0600 Subject: [PATCH 3/4] fix: resolve flake8 W293 and Black formatting --- src/claudesync/session_key_manager.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/claudesync/session_key_manager.py b/src/claudesync/session_key_manager.py index 7c67fda17..7397eb198 100644 --- a/src/claudesync/session_key_manager.py +++ b/src/claudesync/session_key_manager.py @@ -48,7 +48,9 @@ def _find_ssh_key(self, configured_path=None): print(f"* Configured ssh_key_path not found: {configured_path}") else: # Looks like a directory that doesn't exist - print(f"* Configured ssh_key_path directory not found: {configured_path}") + print( + f"* Configured ssh_key_path directory not found: {configured_path}" + ) # Search all candidate directories for supported key names for search_dir in search_dirs: @@ -64,8 +66,12 @@ def _find_ssh_key(self, configured_path=None): print(' ssh-keygen -t ed25519 -C "your_email@example.com"') print("* If you don't specify a custom ssh_key_path in config,") print("* have created a key, and are still seeing this message,") - print(" be sure to name your key 'id_ed25519' or 'id_ecdsa' so it's found automatically.") - print("* Or set ssh_key_path with the full key name in your .claudesync/config.local.json") + print( + " be sure to name your key 'id_ed25519' or 'id_ecdsa' so it's found automatically." + ) + print( + "* Or set ssh_key_path with the full key name in your .claudesync/config.local.json" + ) return input("Enter the full path to your new Ed25519 private key: ") def _get_key_type(self): From 308601420a608eee7de96b049442baea7a7d0e14 Mon Sep 17 00:00:00 2001 From: Chad Chervitz Date: Fri, 27 Feb 2026 22:32:40 -0600 Subject: [PATCH 4/4] fix: add tests, use logger instead of print, simplify path validation --- src/claudesync/session_key_manager.py | 40 ++++++++-------- tests/test_session_key_manager.py | 66 +++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 22 deletions(-) create mode 100644 tests/test_session_key_manager.py diff --git a/src/claudesync/session_key_manager.py b/src/claudesync/session_key_manager.py index 7397eb198..082b8349b 100644 --- a/src/claudesync/session_key_manager.py +++ b/src/claudesync/session_key_manager.py @@ -9,9 +9,9 @@ class SessionKeyManager: def __init__(self, ssh_key_path=None): + self.logger = logging.getLogger(__name__) # Allow config-provided ssh_key_path to guide key discovery self.ssh_key_path = self._find_ssh_key(ssh_key_path) - self.logger = logging.getLogger(__name__) def _find_ssh_key(self, configured_path=None): """ @@ -33,24 +33,15 @@ def _find_ssh_key(self, configured_path=None): # Config points directly to a key file — use it immediately return str(configured) - if configured.exists() and not configured.is_dir(): - # Path exists but isn't a regular file or dir — still try it - return str(configured) - if configured.is_dir(): # Config is a directory — search it in addition to ~/.ssh if configured != default_ssh_dir: search_dirs.insert(0, configured) else: - # Path doesn't exist yet, but has a filename component — - # treat it as a full file path the user intends to create - if configured.suffix or configured.name.startswith("id_"): - print(f"* Configured ssh_key_path not found: {configured_path}") - else: - # Looks like a directory that doesn't exist - print( - f"* Configured ssh_key_path directory not found: {configured_path}" - ) + # Path doesn't exist — warn and fall through to defaults + self.logger.warning( + "Configured ssh_key_path not found: %s", configured_path + ) # Search all candidate directories for supported key names for search_dir in search_dirs: @@ -60,16 +51,21 @@ def _find_ssh_key(self, configured_path=None): return str(key_path) # If no supported key is found, prompt the user to generate an Ed25519 key - # No supported key found — prompt user - print("* No supported SSH key found. RSA keys are no longer supported.") - print("* Please generate an Ed25519 key using the following command:") - print(' ssh-keygen -t ed25519 -C "your_email@example.com"') - print("* If you don't specify a custom ssh_key_path in config,") - print("* have created a key, and are still seeing this message,") - print( + self.logger.warning( + "* No supported SSH key found. RSA keys are no longer supported." + ) + self.logger.warning( + "* Please generate an Ed25519 key using the following command:" + ) + self.logger.warning(' ssh-keygen -t ed25519 -C "your_email@example.com"') + self.logger.warning( + "* If you have NOT specified a custom ssh_key_path in config," + ) + self.logger.warning("* have created a key, and are still seeing this message,") + self.logger.warning( " be sure to name your key 'id_ed25519' or 'id_ecdsa' so it's found automatically." ) - print( + self.logger.warning( "* Or set ssh_key_path with the full key name in your .claudesync/config.local.json" ) return input("Enter the full path to your new Ed25519 private key: ") diff --git a/tests/test_session_key_manager.py b/tests/test_session_key_manager.py new file mode 100644 index 000000000..bb47bda0b --- /dev/null +++ b/tests/test_session_key_manager.py @@ -0,0 +1,66 @@ +from pathlib import Path +from unittest.mock import patch +from claudesync.session_key_manager import SessionKeyManager + + +class TestFindSshKey: + """Tests for SessionKeyManager._find_ssh_key key discovery logic.""" + + @patch("claudesync.session_key_manager.input", return_value="/fake/key") + def test_configured_file_path(self, mock_input, tmp_path): + """When ssh_key_path points to an existing file, use it directly.""" + key_file = tmp_path / "my_custom_key" + key_file.write_text("fake-key-content") + + mgr = SessionKeyManager(ssh_key_path=str(key_file)) + assert mgr.ssh_key_path == str(key_file) + mock_input.assert_not_called() + + @patch("claudesync.session_key_manager.input", return_value="/fake/key") + def test_configured_directory(self, mock_input, tmp_path): + """When ssh_key_path is a directory, search it for standard key names.""" + key_file = tmp_path / "id_ed25519" + key_file.write_text("fake-key-content") + + mgr = SessionKeyManager(ssh_key_path=str(tmp_path)) + assert mgr.ssh_key_path == str(key_file) + mock_input.assert_not_called() + + @patch("claudesync.session_key_manager.input", return_value="/fake/key") + def test_default_fallback(self, mock_input, tmp_path): + """When no config provided, search ~/.ssh for standard key names.""" + fake_ssh = tmp_path / ".ssh" + fake_ssh.mkdir() + key_file = fake_ssh / "id_ed25519" + key_file.write_text("fake-key-content") + + with patch.object(Path, "home", return_value=tmp_path): + mgr = SessionKeyManager() + assert mgr.ssh_key_path == str(key_file) + mock_input.assert_not_called() + + @patch("claudesync.session_key_manager.input", return_value="/user/entered/key") + def test_no_key_found_prompts_user(self, mock_input, tmp_path): + """When no key exists anywhere, prompt the user.""" + fake_ssh = tmp_path / ".ssh" + fake_ssh.mkdir() + # No key files created + + with patch.object(Path, "home", return_value=tmp_path): + mgr = SessionKeyManager() + assert mgr.ssh_key_path == "/user/entered/key" + mock_input.assert_called_once() + + @patch("claudesync.session_key_manager.input", return_value="/fake/key") + def test_nonexistent_configured_path_warns(self, mock_input, tmp_path): + """When ssh_key_path doesn't exist, log warning and fall through.""" + fake_ssh = tmp_path / ".ssh" + fake_ssh.mkdir() + key_file = fake_ssh / "id_ed25519" + key_file.write_text("fake-key-content") + + with patch.object(Path, "home", return_value=tmp_path): + mgr = SessionKeyManager(ssh_key_path="/nonexistent/path") + # Should fall through to default and find the key + assert mgr.ssh_key_path == str(key_file) + mock_input.assert_not_called()