11"""
22Configuration manager with keyring integration for secure credential storage.
3+
4+ Supports fallback to file-based storage when system keyring is unavailable
5+ (e.g. headless containers, RHEL without Secret Service). Set the environment
6+ variable CODEWIKI_NO_KEYRING=1 to force file-based storage.
37"""
48
59import json
10+ import os
11+ import logging
612from pathlib import Path
713from typing import Optional
814import keyring
1218from codewiki .cli .utils .errors import ConfigurationError , FileSystemError
1319from codewiki .cli .utils .fs import ensure_directory , safe_write , safe_read
1420
21+ logger = logging .getLogger (__name__ )
1522
1623# Keyring configuration
1724KEYRING_SERVICE = "codewiki"
2027# Configuration file location
2128CONFIG_DIR = Path .home () / ".codewiki"
2229CONFIG_FILE = CONFIG_DIR / "config.json"
30+ CREDENTIALS_FILE = CONFIG_DIR / "credentials.json"
2331CONFIG_VERSION = "1.0"
2432
2533
2634class ConfigManager :
2735 """
2836 Manages CodeWiki configuration with secure keyring storage for API keys.
29-
37+
3038 Storage:
31- - API key: System keychain via keyring (macOS Keychain, Windows Credential Manager,
39+ - API key: System keychain via keyring (macOS Keychain, Windows Credential Manager,
3240 Linux Secret Service)
41+ - Fallback: ~/.codewiki/credentials.json when keyring is unavailable
3342 - Other settings: ~/.codewiki/config.json
43+
44+ Set CODEWIKI_NO_KEYRING=1 to skip keyring and use file-based storage.
3445 """
35-
46+
3647 def __init__ (self ):
3748 """Initialize the configuration manager."""
3849 self ._api_key : Optional [str ] = None
3950 self ._config : Optional [Configuration ] = None
51+ self ._force_no_keyring = os .environ .get ("CODEWIKI_NO_KEYRING" , "" ).strip () in ("1" , "true" , "yes" )
4052 self ._keyring_available = self ._check_keyring_available ()
41-
53+
4254 def _check_keyring_available (self ) -> bool :
4355 """Check if system keyring is available."""
56+ if self ._force_no_keyring :
57+ logger .debug ("Keyring disabled via CODEWIKI_NO_KEYRING" )
58+ return False
4459 try :
4560 # Try to get/set a test value
4661 keyring .get_password (KEYRING_SERVICE , "__test__" )
4762 return True
48- except KeyringError :
63+ except ( KeyringError , Exception ) :
4964 return False
65+
66+ def _load_api_key_from_file (self ) -> Optional [str ]:
67+ """Load API key from fallback credentials file."""
68+ if not CREDENTIALS_FILE .exists ():
69+ return None
70+ try :
71+ content = safe_read (CREDENTIALS_FILE )
72+ data = json .loads (content )
73+ return data .get ("api_key" )
74+ except (json .JSONDecodeError , FileSystemError ):
75+ return None
76+
77+ def _save_api_key_to_file (self , api_key : str ):
78+ """Save API key to fallback credentials file (plaintext)."""
79+ ensure_directory (CONFIG_DIR )
80+ data = {"api_key" : api_key }
81+ safe_write (CREDENTIALS_FILE , json .dumps (data , indent = 2 ))
82+ # Restrict file permissions (owner read/write only)
83+ try :
84+ CREDENTIALS_FILE .chmod (0o600 )
85+ except OSError :
86+ pass
5087
5188 def load (self ) -> bool :
5289 """
@@ -70,12 +107,14 @@ def load(self) -> bool:
70107
71108 self ._config = Configuration .from_dict (data )
72109
73- # Load API key from keyring
74- try :
75- self ._api_key = keyring .get_password (KEYRING_SERVICE , KEYRING_API_KEY_ACCOUNT )
76- except KeyringError :
77- # Keyring unavailable, API key will be None
78- pass
110+ # Load API key from keyring, falling back to file
111+ if self ._keyring_available :
112+ try :
113+ self ._api_key = keyring .get_password (KEYRING_SERVICE , KEYRING_API_KEY_ACCOUNT )
114+ except (KeyringError , Exception ):
115+ pass
116+ if self ._api_key is None :
117+ self ._api_key = self ._load_api_key_from_file ()
79118
80119 return True
81120 except (json .JSONDecodeError , FileSystemError ) as e :
@@ -154,17 +193,23 @@ def save(
154193 if self ._config .base_url and self ._config .main_model and self ._config .cluster_model :
155194 self ._config .validate ()
156195
157- # Save API key to keyring
196+ # Save API key to keyring, falling back to file
158197 if api_key is not None :
159198 self ._api_key = api_key
160- try :
161- keyring .set_password (KEYRING_SERVICE , KEYRING_API_KEY_ACCOUNT , api_key )
162- except KeyringError as e :
163- # Fallback: warn about keyring unavailability
164- raise ConfigurationError (
165- f"System keychain unavailable: { e } \n "
166- f"Please ensure your system keychain is properly configured."
167- )
199+ if self ._keyring_available :
200+ try :
201+ keyring .set_password (KEYRING_SERVICE , KEYRING_API_KEY_ACCOUNT , api_key )
202+ except (KeyringError , Exception ):
203+ # Keyring failed at runtime — fall back to file
204+ self ._keyring_available = False
205+ self ._save_api_key_to_file (api_key )
206+ logger .warning (
207+ "System keychain unavailable. API key stored in %s "
208+ "(plaintext). Set CODEWIKI_NO_KEYRING=1 to suppress this warning." ,
209+ CREDENTIALS_FILE
210+ )
211+ else :
212+ self ._save_api_key_to_file (api_key )
168213
169214 # Save non-sensitive config to JSON
170215 config_data = {
@@ -179,17 +224,20 @@ def save(
179224
180225 def get_api_key (self ) -> Optional [str ]:
181226 """
182- Get API key from keyring.
183-
227+ Get API key from keyring or fallback file .
228+
184229 Returns:
185230 API key or None if not set
186231 """
187232 if self ._api_key is None :
188- try :
189- self ._api_key = keyring .get_password (KEYRING_SERVICE , KEYRING_API_KEY_ACCOUNT )
190- except KeyringError :
191- pass
192-
233+ if self ._keyring_available :
234+ try :
235+ self ._api_key = keyring .get_password (KEYRING_SERVICE , KEYRING_API_KEY_ACCOUNT )
236+ except (KeyringError , Exception ):
237+ pass
238+ if self ._api_key is None :
239+ self ._api_key = self ._load_api_key_from_file ()
240+
193241 return self ._api_key
194242
195243 def get_config (self ) -> Optional [Configuration ]:
@@ -219,12 +267,19 @@ def is_configured(self) -> bool:
219267 return self ._config .is_complete ()
220268
221269 def delete_api_key (self ):
222- """Delete API key from keyring."""
223- try :
224- keyring .delete_password (KEYRING_SERVICE , KEYRING_API_KEY_ACCOUNT )
225- self ._api_key = None
226- except KeyringError :
227- pass
270+ """Delete API key from keyring and fallback file."""
271+ if self ._keyring_available :
272+ try :
273+ keyring .delete_password (KEYRING_SERVICE , KEYRING_API_KEY_ACCOUNT )
274+ except (KeyringError , Exception ):
275+ pass
276+ # Also remove fallback credentials file
277+ if CREDENTIALS_FILE .exists ():
278+ try :
279+ CREDENTIALS_FILE .unlink ()
280+ except OSError :
281+ pass
282+ self ._api_key = None
228283
229284 def clear (self ):
230285 """Clear all configuration (file and keyring)."""
0 commit comments