Cache remote plugin catalogs by scope (#34849)

## What changed

- Cache global, user, and workspace remote plugin catalogs on disk with a three-hour TTL and scope- and account-specific keys.
- Serve cached catalogs from `plugin/list`, refresh stale entries in the background, and warm existing caches at app-server startup without blocking list requests.
- Make `forceRefetch: true` bypass cached catalog data and replace an entry only after a successful fetch.
- Invalidate user and workspace catalog caches after remote plugin sharing changes.

## Testing

- Cover cache freshness, scope isolation, legacy global cache compatibility, identity requirements for private catalogs, forced refetches, and non-blocking startup refreshes.

GitOrigin-RevId: 7844e9dfb72e43b26d749b4afd16fd9c1793caba
This commit is contained in:
brisebois-oai
2026-07-23 00:52:55 +00:00
committed by copyberry
parent 9d82334302
commit 83ff1c2f80
9 changed files with 1223 additions and 146 deletions
+100 -20
View File
@@ -1,14 +1,21 @@
use super::RemotePluginDirectoryItem;
use super::RemotePluginScope;
use super::RemotePluginServiceConfig;
use chrono::DateTime;
use chrono::Utc;
use codex_login::CodexAuth;
use serde::Deserialize;
use serde::Serialize;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use tracing::warn;
// `plugin/list` and other callers that assume all plugins are available locally will be
// deprecated soon. Remove this catalog cache when those callers migrate to on-demand fetching.
const REMOTE_PLUGIN_CATALOG_DISK_CACHE_SCHEMA_VERSION: u8 = 1;
const REMOTE_PLUGIN_CATALOG_DISK_CACHE_DIR: &str = "cache/remote_plugin_catalog";
const REMOTE_PLUGIN_CATALOG_DISK_CACHE_TTL: Duration = Duration::from_secs(60 * 60 * 3);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
struct RemotePluginCatalogCacheKey {
@@ -16,34 +23,65 @@ struct RemotePluginCatalogCacheKey {
account_id: Option<String>,
chatgpt_user_id: Option<String>,
is_workspace_account: bool,
// Global catalogs predate scoped cache keys and must keep their existing filenames.
#[serde(skip_serializing_if = "Option::is_none")]
scope: Option<RemotePluginScope>,
}
impl RemotePluginCatalogCacheKey {
fn global(config: &RemotePluginServiceConfig, auth: &CodexAuth) -> Self {
Self {
fn new(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
scope: RemotePluginScope,
) -> Option<Self> {
let cache_key = Self {
chatgpt_base_url: config.chatgpt_base_url.clone(),
account_id: auth.get_account_id(),
chatgpt_user_id: auth.get_chatgpt_user_id(),
is_workspace_account: auth.is_workspace_account(),
scope: (scope != RemotePluginScope::Global).then_some(scope),
};
// Preserve global catalog caching for existing header-auth clients, but never share
// user or workspace catalogs when the auth mode cannot identify their owner.
if !matches!(scope, RemotePluginScope::Global)
&& cache_key.account_id.is_none()
&& cache_key.chatgpt_user_id.is_none()
{
return None;
}
Some(cache_key)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct RemotePluginCatalogDiskCache {
schema_version: u8,
#[serde(default, skip_serializing_if = "Option::is_none")]
fetched_at: Option<DateTime<Utc>>,
plugins: Vec<RemotePluginDirectoryItem>,
}
pub(crate) fn load_cached_global_directory_plugins(
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum RemotePluginCatalogCacheFreshness {
Fresh,
Stale,
}
#[derive(Debug, Clone)]
pub(super) struct CachedDirectoryPlugins {
pub plugins: Vec<RemotePluginDirectoryItem>,
pub freshness: RemotePluginCatalogCacheFreshness,
}
pub(crate) fn load_cached_directory_plugins(
codex_home: &Path,
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
) -> Option<Vec<RemotePluginDirectoryItem>> {
let cache_path = cache_path(
codex_home,
&RemotePluginCatalogCacheKey::global(config, auth),
);
scope: RemotePluginScope,
) -> Option<CachedDirectoryPlugins> {
let cache_key = RemotePluginCatalogCacheKey::new(config, auth, scope)?;
let cache_path = cache_path(codex_home, &cache_key);
let bytes = match std::fs::read(&cache_path) {
Ok(bytes) => bytes,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return None,
@@ -71,31 +109,58 @@ pub(crate) fn load_cached_global_directory_plugins(
return None;
}
Some(cache.plugins)
let freshness = if is_fresh(cache.fetched_at, Utc::now()) {
RemotePluginCatalogCacheFreshness::Fresh
} else {
RemotePluginCatalogCacheFreshness::Stale
};
Some(CachedDirectoryPlugins {
plugins: cache.plugins,
freshness,
})
}
pub(crate) fn write_cached_global_directory_plugins(
pub(crate) fn write_cached_directory_plugins(
codex_home: &Path,
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
scope: RemotePluginScope,
plugins: &[RemotePluginDirectoryItem],
) {
let cache_path = cache_path(
codex_home,
&RemotePluginCatalogCacheKey::global(config, auth),
);
if let Some(parent) = cache_path.parent()
&& std::fs::create_dir_all(parent).is_err()
{
let Some(cache_key) = RemotePluginCatalogCacheKey::new(config, auth, scope) else {
return;
}
let Ok(bytes) = serde_json::to_vec_pretty(&RemotePluginCatalogDiskCache {
};
let cache_path = cache_path(codex_home, &cache_key);
let Ok(contents) = serde_json::to_string_pretty(&RemotePluginCatalogDiskCache {
schema_version: REMOTE_PLUGIN_CATALOG_DISK_CACHE_SCHEMA_VERSION,
fetched_at: Some(Utc::now()),
plugins: plugins.to_vec(),
}) else {
return;
};
let _ = std::fs::write(cache_path, bytes);
let _ = codex_utils_path::write_atomically(&cache_path, &contents);
}
pub(crate) fn remove_cached_directory_plugins(
codex_home: &Path,
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
scope: RemotePluginScope,
) {
let Some(cache_key) = RemotePluginCatalogCacheKey::new(config, auth, scope) else {
return;
};
let cache_path = cache_path(codex_home, &cache_key);
match std::fs::remove_file(&cache_path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => {
warn!(
cache_path = %cache_path.display(),
"failed to remove remote plugin catalog disk cache: {err}"
);
}
}
}
fn cache_path(codex_home: &Path, cache_key: &RemotePluginCatalogCacheKey) -> PathBuf {
@@ -109,3 +174,18 @@ fn cache_path(codex_home: &Path, cache_key: &RemotePluginCatalogCacheKey) -> Pat
.join(REMOTE_PLUGIN_CATALOG_DISK_CACHE_DIR)
.join(format!("{cache_key_hash:016x}.json"))
}
fn is_fresh(fetched_at: Option<DateTime<Utc>>, now: DateTime<Utc>) -> bool {
let Some(fetched_at) = fetched_at else {
return false;
};
let Ok(ttl) = chrono::Duration::from_std(REMOTE_PLUGIN_CATALOG_DISK_CACHE_TTL) else {
return false;
};
let age = now.signed_duration_since(fetched_at);
age >= chrono::Duration::zero() && age <= ttl
}
#[cfg(test)]
#[path = "catalog_cache_tests.rs"]
mod tests;
@@ -0,0 +1,127 @@
use super::*;
use chrono::TimeDelta;
use codex_login::AuthHeaders;
use pretty_assertions::assert_eq;
#[test]
fn catalog_cache_freshness_honors_ttl() {
let now = Utc::now();
assert!(!is_fresh(/*fetched_at*/ None, now));
assert!(is_fresh(Some(now), now));
assert!(is_fresh(Some(now - TimeDelta::hours(3)), now,));
assert!(!is_fresh(
Some(now - TimeDelta::hours(3) - TimeDelta::milliseconds(1)),
now,
));
assert!(!is_fresh(Some(now + TimeDelta::milliseconds(1)), now));
}
#[test]
fn catalog_cache_paths_are_isolated_by_scope() {
let codex_home = Path::new("/tmp/codex-home");
let cache_key_for_scope = |scope| RemotePluginCatalogCacheKey {
chatgpt_base_url: "https://chatgpt.com/backend-api".to_string(),
account_id: Some("account-id".to_string()),
chatgpt_user_id: Some("user-id".to_string()),
is_workspace_account: true,
scope: (scope != RemotePluginScope::Global).then_some(scope),
};
let paths = [
RemotePluginScope::Global,
RemotePluginScope::User,
RemotePluginScope::Workspace,
]
.map(|scope| cache_path(codex_home, &cache_key_for_scope(scope)));
assert_ne!(paths[0], paths[1]);
assert_ne!(paths[0], paths[2]);
assert_ne!(paths[1], paths[2]);
}
#[test]
fn global_catalog_cache_reuses_legacy_cache_file() {
let codex_home = tempfile::tempdir().expect("create codex home");
let config = RemotePluginServiceConfig::new(
"https://chatgpt.com/backend-api".to_string(),
crate::test_support::test_http_client_factory(),
);
let auth = CodexAuth::Headers(AuthHeaders::new(http::HeaderMap::new()));
let legacy_cache_path = codex_home
.path()
.join(REMOTE_PLUGIN_CATALOG_DISK_CACHE_DIR)
.join("f22564d6f8ca89f6.json");
let legacy_cache = serde_json::json!({
"schema_version": REMOTE_PLUGIN_CATALOG_DISK_CACHE_SCHEMA_VERSION,
"plugins": [],
});
let contents = serde_json::to_string_pretty(&legacy_cache).expect("serialize legacy cache");
codex_utils_path::write_atomically(&legacy_cache_path, &contents).expect("write legacy cache");
let cached =
load_cached_directory_plugins(codex_home.path(), &config, &auth, RemotePluginScope::Global)
.expect("load legacy global cache");
assert!(cached.plugins.is_empty());
assert_eq!(cached.freshness, RemotePluginCatalogCacheFreshness::Stale);
write_cached_directory_plugins(
codex_home.path(),
&config,
&auth,
RemotePluginScope::Global,
&[],
);
let refreshed_cache: RemotePluginCatalogDiskCache = serde_json::from_slice(
&std::fs::read(&legacy_cache_path).expect("read refreshed legacy cache"),
)
.expect("parse refreshed legacy cache");
assert!(refreshed_cache.fetched_at.is_some());
}
#[test]
fn header_auth_does_not_cache_private_catalogs_without_a_stable_identity() {
let codex_home = tempfile::tempdir().expect("create codex home");
let config = RemotePluginServiceConfig::new(
"https://chatgpt.com/backend-api".to_string(),
crate::test_support::test_http_client_factory(),
);
let auth = CodexAuth::Headers(AuthHeaders::new(http::HeaderMap::new()));
write_cached_directory_plugins(
codex_home.path(),
&config,
&auth,
RemotePluginScope::Global,
&[],
);
assert!(
load_cached_directory_plugins(codex_home.path(), &config, &auth, RemotePluginScope::Global)
.is_some()
);
for scope in [RemotePluginScope::User, RemotePluginScope::Workspace] {
let insecure_cache_key = RemotePluginCatalogCacheKey {
chatgpt_base_url: config.chatgpt_base_url.clone(),
account_id: None,
chatgpt_user_id: None,
is_workspace_account: false,
scope: Some(scope),
};
let insecure_cache_path = cache_path(codex_home.path(), &insecure_cache_key);
write_cached_directory_plugins(codex_home.path(), &config, &auth, scope, &[]);
assert!(!insecure_cache_path.exists());
let insecure_cache = RemotePluginCatalogDiskCache {
schema_version: REMOTE_PLUGIN_CATALOG_DISK_CACHE_SCHEMA_VERSION,
fetched_at: Some(Utc::now()),
plugins: Vec::new(),
};
let contents = serde_json::to_string_pretty(&insecure_cache).expect("serialize cache");
codex_utils_path::write_atomically(&insecure_cache_path, &contents)
.expect("write insecure cache");
assert!(load_cached_directory_plugins(codex_home.path(), &config, &auth, scope).is_none());
}
}