fix(auth): migrate OAuth token cache from legacy sprout-agent path

The sprout-agent → buzz-agent rename (d99ad131f) moved the OAuth cache
directory without migrating existing tokens. Users on the latest main
get a browser auth prompt on every agent spawn because the new path is
empty.

When the primary cache path has no token, check the equivalent path
under ~/.config/sprout-agent/oauth/ and copy it to the new location.
The copy makes the migration one-time; subsequent starts find the token
at the primary path.

Co-authored-by: Will Pfleger <wpfleger@block.xyz>
Signed-off-by: Will Pfleger <wpfleger@block.xyz>
This commit is contained in:
Will Pfleger
2026-06-11 14:10:51 -04:00
parent 63738139ba
commit 2282ec1dcd
+83 -1
View File
@@ -120,7 +120,17 @@ impl PkceOAuthTokenSource {
fs::create_dir_all(parent)
.map_err(|e| AgentError::Llm(format!("oauth cache dir {parent:?}: {e}")))?;
}
let initial = read_cache(&cache_path);
let initial = read_cache(&cache_path).or_else(|| {
// One-time migration: check the legacy sprout-agent cache path.
let legacy = legacy_cache_path(&cache_path)?;
let token = read_cache(&legacy)?;
// Copy to the new location so subsequent starts skip this fallback.
if let Ok(body) = fs::read(&legacy) {
let _ = fs::write(&cache_path, &body);
}
tracing::info!("migrated oauth cache from legacy path {legacy:?}");
Some(token)
});
Ok(Arc::new(Self {
cfg,
http: Client::new(),
@@ -303,6 +313,19 @@ fn read_cache(path: &PathBuf) -> Option<CachedToken> {
serde_json::from_slice(&body).ok()
}
/// Compute the legacy cache path under `~/.config/sprout-agent/oauth/` for
/// a given primary path under `~/.config/buzz-agent/oauth/`. Returns `None`
/// if the path doesn't contain the expected `buzz-agent/oauth` segment.
fn legacy_cache_path(primary: &std::path::Path) -> Option<PathBuf> {
let s = primary.to_str()?;
let marker = "/buzz-agent/oauth/";
if s.contains(marker) {
Some(PathBuf::from(s.replacen(marker, "/sprout-agent/oauth/", 1)))
} else {
None
}
}
/// Parse a token-endpoint JSON response. Fails loudly when `access_token`
/// is missing or empty — without this, a malformed server response would
/// be cached and `bearer()` would silently return `""` until the entry
@@ -549,4 +572,63 @@ mod tests {
let v: Value = serde_json::from_str(r#"{"access_token":""}"#).unwrap();
assert!(token_from_response(&v, None).is_err());
}
#[test]
fn legacy_cache_path_rewrites_buzz_agent_to_sprout_agent() {
let primary = PathBuf::from("/home/user/.config/buzz-agent/oauth/databricks/abc123.json");
let legacy = legacy_cache_path(&primary).unwrap();
assert_eq!(
legacy,
PathBuf::from("/home/user/.config/sprout-agent/oauth/databricks/abc123.json")
);
}
#[test]
fn legacy_cache_path_returns_none_for_override_path() {
// cache_dir_override paths won't contain the buzz-agent segment
let primary = PathBuf::from("/tmp/test-cache/databricks/abc123.json");
assert!(legacy_cache_path(&primary).is_none());
}
#[test]
fn legacy_cache_migration_copies_token() {
let tmp = tempfile::tempdir().unwrap();
// Set up the legacy path with a cached token
let legacy_dir = tmp.path().join("sprout-agent/oauth/databricks");
fs::create_dir_all(&legacy_dir).unwrap();
let token = CachedToken {
access_token: "migrated-token".into(),
refresh_token: Some("migrated-refresh".into()),
expires_at: Some(9999999999),
};
let legacy_file = legacy_dir.join("hash.json");
fs::write(&legacy_file, serde_json::to_vec(&token).unwrap()).unwrap();
// Primary path does not exist yet
let primary_dir = tmp.path().join("buzz-agent/oauth/databricks");
fs::create_dir_all(&primary_dir).unwrap();
let primary_file = primary_dir.join("hash.json");
assert!(!primary_file.exists());
// Simulate the migration: read_cache on primary fails, legacy_cache_path
// rewrites, read_cache on legacy succeeds, file is copied.
let initial = read_cache(&primary_file).or_else(|| {
let legacy = legacy_cache_path(&primary_file)?;
let tok = read_cache(&legacy)?;
if let Ok(body) = fs::read(&legacy) {
let _ = fs::write(&primary_file, &body);
}
Some(tok)
});
let loaded = initial.unwrap();
assert_eq!(loaded.access_token, "migrated-token");
assert_eq!(loaded.refresh_token.as_deref(), Some("migrated-refresh"));
// Verify the file was copied to the new location
assert!(primary_file.exists());
let copied: CachedToken =
serde_json::from_slice(&fs::read(&primary_file).unwrap()).unwrap();
assert_eq!(copied.access_token, "migrated-token");
}
}