@@ -210,6 +210,14 @@ fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> {
210210 Ok ( ( ) )
211211}
212212
213+ /// Parse the `expiresAt` timestamp from a Claude Code credential JSON string.
214+ /// Returns `None` if the JSON is malformed or the field is missing/wrong type.
215+ #[ cfg( any( target_os = "macos" , test) ) ]
216+ fn parse_credential_expires_at ( content : & str ) -> Option < u64 > {
217+ let value: serde_json:: Value = serde_json:: from_str ( content) . ok ( ) ?;
218+ value. get ( "claudeAiOauth" ) ?. get ( "expiresAt" ) ?. as_u64 ( )
219+ }
220+
213221/// Extract credentials from the macOS Keychain and write to a file.
214222/// Returns Ok(true) if credentials were written, Ok(false) if not available.
215223#[ cfg( target_os = "macos" ) ]
@@ -265,6 +273,35 @@ fn extract_keychain_credential(service: &str, dest: &Path) -> Result<bool> {
265273 return Ok ( false ) ;
266274 }
267275
276+ // Compare expiresAt timestamps: only overwrite if keychain credential is fresher
277+ // than the existing sandbox credential, or if either is unparseable.
278+ if dest. exists ( ) {
279+ if let Ok ( existing_content) = std:: fs:: read_to_string ( dest) {
280+ let existing_exp = parse_credential_expires_at ( & existing_content) ;
281+ let keychain_exp = parse_credential_expires_at ( trimmed) ;
282+
283+ if let ( Some ( existing) , Some ( keychain) ) = ( existing_exp, keychain_exp) {
284+ if keychain <= existing {
285+ tracing:: debug!(
286+ "Keychain credential for '{}' is not fresher (keychain={}, sandbox={}), keeping sandbox" ,
287+ service,
288+ keychain,
289+ existing
290+ ) ;
291+ return Ok ( false ) ;
292+ }
293+ } else if existing_exp. is_some ( ) && keychain_exp. is_none ( ) {
294+ // Sandbox is parseable but keychain is not -- keep sandbox.
295+ tracing:: debug!(
296+ "Keychain credential for '{}' is unparseable but sandbox is valid, keeping sandbox" ,
297+ service
298+ ) ;
299+ return Ok ( false ) ;
300+ }
301+ // All other cases (both unparseable, or only keychain parseable): overwrite.
302+ }
303+ }
304+
268305 std:: fs:: write ( dest, trimmed) ?;
269306 tracing:: debug!(
270307 "Extracted keychain credential for '{}' -> {}" ,
@@ -1187,4 +1224,86 @@ mod tests {
11871224 r#"{"token":"abc"}"#
11881225 ) ;
11891226 }
1227+
1228+ // --- credential freshness tests ---
1229+
1230+ #[ test]
1231+ fn test_parse_credential_expires_at_valid ( ) {
1232+ let json = r#"{"claudeAiOauth":{"expiresAt":1700000000}}"# ;
1233+ assert_eq ! ( parse_credential_expires_at( json) , Some ( 1700000000 ) ) ;
1234+ }
1235+
1236+ #[ test]
1237+ fn test_parse_credential_expires_at_missing_key ( ) {
1238+ // Missing claudeAiOauth entirely.
1239+ assert_eq ! ( parse_credential_expires_at( r#"{"other":"data"}"# ) , None ) ;
1240+ // Missing expiresAt inside claudeAiOauth.
1241+ assert_eq ! (
1242+ parse_credential_expires_at( r#"{"claudeAiOauth":{"token":"abc"}}"# ) ,
1243+ None
1244+ ) ;
1245+ }
1246+
1247+ #[ test]
1248+ fn test_parse_credential_expires_at_invalid_json ( ) {
1249+ assert_eq ! ( parse_credential_expires_at( "not json at all" ) , None ) ;
1250+ assert_eq ! ( parse_credential_expires_at( "" ) , None ) ;
1251+ }
1252+
1253+ #[ test]
1254+ fn test_parse_credential_expires_at_wrong_type ( ) {
1255+ // expiresAt is a string instead of a number.
1256+ let json = r#"{"claudeAiOauth":{"expiresAt":"1700000000"}}"# ;
1257+ assert_eq ! ( parse_credential_expires_at( json) , None ) ;
1258+ }
1259+
1260+ #[ test]
1261+ fn test_credential_freshness_comparison ( ) {
1262+ let dir = TempDir :: new ( ) . unwrap ( ) ;
1263+ let dest = dir. path ( ) . join ( ".credentials.json" ) ;
1264+
1265+ let stale = r#"{"claudeAiOauth":{"expiresAt":1000}}"# ;
1266+ let fresh = r#"{"claudeAiOauth":{"expiresAt":2000}}"# ;
1267+
1268+ // Sandbox has fresh credential, keychain has stale: sandbox should be kept.
1269+ fs:: write ( & dest, fresh) . unwrap ( ) ;
1270+ let existing_exp = parse_credential_expires_at ( fresh) ;
1271+ let keychain_exp = parse_credential_expires_at ( stale) ;
1272+ assert ! ( existing_exp. is_some( ) ) ;
1273+ assert ! ( keychain_exp. is_some( ) ) ;
1274+ assert ! ( keychain_exp. unwrap( ) <= existing_exp. unwrap( ) ) ;
1275+
1276+ // Sandbox has stale credential, keychain has fresh: keychain should win.
1277+ fs:: write ( & dest, stale) . unwrap ( ) ;
1278+ let existing_exp = parse_credential_expires_at ( stale) ;
1279+ let keychain_exp = parse_credential_expires_at ( fresh) ;
1280+ assert ! ( keychain_exp. unwrap( ) > existing_exp. unwrap( ) ) ;
1281+ }
1282+
1283+ #[ test]
1284+ fn test_credential_comparison_with_unparseable_files ( ) {
1285+ // When sandbox is parseable but keychain is not, sandbox should be kept.
1286+ let valid = r#"{"claudeAiOauth":{"expiresAt":1000}}"# ;
1287+ let invalid = "not-json" ;
1288+
1289+ let existing_exp = parse_credential_expires_at ( valid) ;
1290+ let keychain_exp = parse_credential_expires_at ( invalid) ;
1291+ assert ! ( existing_exp. is_some( ) ) ;
1292+ assert ! ( keychain_exp. is_none( ) ) ;
1293+ // Decision: keep sandbox (existing is parseable, keychain is not).
1294+
1295+ // When both are unparseable, keychain wins (fallback to current behavior).
1296+ let existing_exp = parse_credential_expires_at ( invalid) ;
1297+ let keychain_exp = parse_credential_expires_at ( "also-not-json" ) ;
1298+ assert ! ( existing_exp. is_none( ) ) ;
1299+ assert ! ( keychain_exp. is_none( ) ) ;
1300+ // Decision: overwrite (both unparseable).
1301+
1302+ // When only keychain is parseable, keychain wins.
1303+ let existing_exp = parse_credential_expires_at ( invalid) ;
1304+ let keychain_exp = parse_credential_expires_at ( valid) ;
1305+ assert ! ( existing_exp. is_none( ) ) ;
1306+ assert ! ( keychain_exp. is_some( ) ) ;
1307+ // Decision: overwrite (sandbox is unparseable).
1308+ }
11901309}
0 commit comments