Skip to content

Commit bd91bf1

Browse files
committed
docker config
1 parent 3db658f commit bd91bf1

1 file changed

Lines changed: 119 additions & 0 deletions

File tree

src/session/container_config.rs

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)