[codex] Remove legacy remote plugin startup sync (#25936)

## Summary

- Remove the legacy startup remote plugin sync path that called
`/plugins/list` and reconciled curated plugin cache/config.
- Remove the `sync_plugins_from_remote` API, its result/error types,
startup marker task, and tests that expected the legacy request.
- Keep the current remote installed bundle sync and remote catalog flows
(`/ps/plugins/installed` and `/ps/plugins/list`) intact.

## Validation

- `just fmt`
- `git diff --check`
- `env HOME=/private/tmp/codex-xin-build-home
USERPROFILE=/private/tmp/codex-xin-build-home just test -p
codex-core-plugins`
- Searched for legacy `/plugins/list` sync references; remaining matches
are `/ps/plugins/list` catalog tests/code.

## Notes

- `just test -p codex-app-server plugin_list` is currently blocked
before running filtered tests by an unrelated compile error in
`app-server/tests/suite/v2/image_generation.rs`:
`app_test_support::McpProcess` is not exported.
This commit is contained in:
xl-openai
2026-06-05 16:33:01 -07:00
committed by GitHub
Unverified
parent c62d79259d
commit 4f655bc3b7
8 changed files with 3 additions and 1130 deletions
@@ -38,7 +38,6 @@ use wiremock::matchers::query_param;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1";
const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str =
"CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS";
const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json";
@@ -1460,94 +1459,6 @@ enabled = true
Ok(())
}
#[tokio::test]
async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_plugin_sync_config(codex_home.path(), &format!("{}/backend-api/", server.uri()))?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("account-123")
.chatgpt_user_id("user-123")
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
write_openai_curated_marketplace(codex_home.path(), &["linear"])?;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/list"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"[
{"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}
]"#,
))
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/featured"))
.and(query_param("platform", "codex"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(r#"["linear@openai-curated"]"#))
.mount(&server)
.await;
let marker_path = codex_home
.path()
.join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE);
{
let mut mcp = TestAppServer::new_with_plugin_startup_tasks(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
wait_for_path_exists(&marker_path).await?;
wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1)
.await?;
let request_id = mcp
.send_plugin_list_request(PluginListParams {
cwds: None,
marketplace_kinds: None,
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
let curated_marketplace = response
.marketplaces
.into_iter()
.find(|marketplace| marketplace.name == "openai-curated")
.expect("expected openai-curated marketplace entry");
assert_eq!(
curated_marketplace
.plugins
.into_iter()
.map(|plugin| (plugin.id, plugin.installed, plugin.enabled))
.collect::<Vec<_>>(),
vec![("linear@openai-curated".to_string(), true, true)]
);
wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1)
.await?;
}
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
assert!(config.contains(r#"[plugins."linear@openai-curated"]"#));
{
let mut mcp = TestAppServer::new_with_plugin_startup_tasks(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
}
tokio::time::sleep(Duration::from_millis(250)).await;
wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1).await?;
Ok(())
}
#[tokio::test]
async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> Result<()> {
let codex_home = TempDir::new()?;
-3
View File
@@ -11,7 +11,6 @@ mod plugin_bundle_archive;
pub mod remote;
pub mod remote_bundle;
pub mod remote_legacy;
pub(crate) mod startup_remote_sync;
pub mod startup_sync;
pub mod store;
#[cfg(test)]
@@ -37,10 +36,8 @@ pub use manager::PluginInstallOutcome;
pub use manager::PluginInstallRequest;
pub use manager::PluginReadOutcome;
pub use manager::PluginReadRequest;
pub use manager::PluginRemoteSyncError;
pub use manager::PluginUninstallError;
pub use manager::PluginsConfigInput;
pub use manager::PluginsManager;
pub use manager::RemotePluginSyncResult;
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError;
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome;
-333
View File
@@ -1,5 +1,4 @@
use super::PluginLoadOutcome;
use super::startup_remote_sync::start_startup_remote_plugin_sync_once;
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack;
use crate::loader::PluginHookLoadOutcome;
@@ -32,7 +31,6 @@ use crate::marketplace::ResolvedMarketplacePlugin;
use crate::marketplace::find_installable_marketplace_plugin;
use crate::marketplace::find_marketplace_plugin;
use crate::marketplace::list_marketplaces;
use crate::marketplace::load_marketplace;
use crate::marketplace::plugin_interface_with_marketplace_category;
use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeError;
use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome;
@@ -52,8 +50,6 @@ use crate::store::PluginStore;
use crate::store::PluginStoreError;
use codex_analytics::AnalyticsEventsClient;
use codex_config::ConfigLayerStack;
use codex_config::PluginConfigEdit;
use codex_config::apply_user_plugin_config_edits;
use codex_config::clear_user_plugin;
use codex_config::set_user_plugin_enabled;
use codex_config::types::PluginConfig;
@@ -81,7 +77,6 @@ use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::time::Instant;
use tokio::sync::Semaphore;
use tracing::info;
use tracing::warn;
static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
@@ -297,107 +292,6 @@ impl From<PluginDetail> for PluginCapabilitySummary {
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct RemotePluginSyncResult {
/// Plugin ids newly installed into the local plugin cache.
pub installed_plugin_ids: Vec<String>,
/// Plugin ids whose local config was changed to enabled.
pub enabled_plugin_ids: Vec<String>,
/// Plugin ids whose local config was changed to disabled.
/// This is not populated by `sync_plugins_from_remote`.
pub disabled_plugin_ids: Vec<String>,
/// Plugin ids removed from local cache or plugin config.
pub uninstalled_plugin_ids: Vec<String>,
}
#[derive(Debug, thiserror::Error)]
pub enum PluginRemoteSyncError {
#[error("chatgpt authentication required to sync remote plugins")]
AuthRequired,
#[error(
"chatgpt authentication required to sync remote plugins; api key auth is not supported"
)]
UnsupportedAuthMode,
#[error("failed to read auth token for remote plugin sync: {0}")]
AuthToken(#[source] std::io::Error),
#[error("failed to send remote plugin sync request to {url}: {source}")]
Request {
url: String,
#[source]
source: reqwest::Error,
},
#[error("remote plugin sync request to {url} failed with status {status}: {body}")]
UnexpectedStatus {
url: String,
status: reqwest::StatusCode,
body: String,
},
#[error("failed to parse remote plugin sync response from {url}: {source}")]
Decode {
url: String,
#[source]
source: serde_json::Error,
},
#[error("local curated marketplace is not available")]
LocalMarketplaceNotFound,
#[error("remote marketplace `{marketplace_name}` is not available locally")]
UnknownRemoteMarketplace { marketplace_name: String },
#[error("duplicate remote plugin `{plugin_name}` in sync response")]
DuplicateRemotePlugin { plugin_name: String },
#[error(
"remote plugin `{plugin_name}` was not found in local marketplace `{marketplace_name}`"
)]
UnknownRemotePlugin {
plugin_name: String,
marketplace_name: String,
},
#[error("{0}")]
InvalidPluginId(#[from] PluginIdError),
#[error("{0}")]
Marketplace(#[from] MarketplaceError),
#[error("{0}")]
Store(#[from] PluginStoreError),
#[error("{0}")]
Config(#[from] anyhow::Error),
#[error("failed to join remote plugin sync task: {0}")]
Join(#[from] tokio::task::JoinError),
}
impl PluginRemoteSyncError {
fn join(source: tokio::task::JoinError) -> Self {
Self::Join(source)
}
}
impl From<RemotePluginFetchError> for PluginRemoteSyncError {
fn from(value: RemotePluginFetchError) -> Self {
match value {
RemotePluginFetchError::AuthRequired => Self::AuthRequired,
RemotePluginFetchError::UnsupportedAuthMode => Self::UnsupportedAuthMode,
RemotePluginFetchError::AuthToken(source) => Self::AuthToken(source),
RemotePluginFetchError::Request { url, source } => Self::Request { url, source },
RemotePluginFetchError::UnexpectedStatus { url, status, body } => {
Self::UnexpectedStatus { url, status, body }
}
RemotePluginFetchError::Decode { url, source } => Self::Decode { url, source },
}
}
}
pub struct PluginsManager {
codex_home: PathBuf,
store: PluginStore,
@@ -408,7 +302,6 @@ pub struct PluginsManager {
enabled_outcome_load_semaphore: Semaphore,
remote_installed_plugins_cache: RwLock<Option<Vec<RemoteInstalledPlugin>>>,
remote_installed_plugins_cache_refresh_state: RwLock<RemoteInstalledPluginsCacheRefreshState>,
remote_sync_lock: Semaphore,
restriction_product: Option<Product>,
analytics_events_client: RwLock<Option<AnalyticsEventsClient>>,
}
@@ -462,7 +355,6 @@ impl PluginsManager {
remote_installed_plugins_cache_refresh_state: RwLock::new(
RemoteInstalledPluginsCacheRefreshState::default(),
),
remote_sync_lock: Semaphore::new(/*permits*/ 1),
restriction_product,
analytics_events_client: RwLock::new(None),
}
@@ -1051,224 +943,6 @@ impl PluginsManager {
Ok(())
}
pub async fn sync_plugins_from_remote(
&self,
config: &PluginsConfigInput,
auth: Option<&CodexAuth>,
additive_only: bool,
) -> Result<RemotePluginSyncResult, PluginRemoteSyncError> {
let _remote_sync_guard = self.remote_sync_lock.acquire().await.map_err(|_| {
PluginRemoteSyncError::Config(anyhow::anyhow!("remote plugin sync semaphore closed"))
})?;
if !config.plugins_enabled {
return Ok(RemotePluginSyncResult::default());
}
info!("starting remote plugin sync");
let remote_plugins = crate::remote_legacy::fetch_remote_plugin_status(
&remote_plugin_service_config(config),
auth,
)
.await
.map_err(PluginRemoteSyncError::from)?;
let configured_plugins = configured_plugins_from_stack(&config.config_layer_stack);
let curated_marketplace_root = curated_plugins_repo_path(self.codex_home.as_path());
let curated_marketplace_path = AbsolutePathBuf::try_from(
curated_marketplace_root.join(".agents/plugins/marketplace.json"),
)
.map_err(|_| PluginRemoteSyncError::LocalMarketplaceNotFound)?;
let curated_marketplace = match load_marketplace(&curated_marketplace_path) {
Ok(marketplace) => marketplace,
Err(MarketplaceError::MarketplaceNotFound { .. }) => {
return Err(PluginRemoteSyncError::LocalMarketplaceNotFound);
}
Err(err) => return Err(err.into()),
};
let marketplace_name = curated_marketplace.name.clone();
let curated_plugin_version = read_curated_plugins_sha(self.codex_home.as_path())
.ok_or_else(|| {
PluginStoreError::Invalid(
"local curated marketplace sha is not available".to_string(),
)
})?;
let cache_plugin_version = curated_plugin_cache_version(&curated_plugin_version);
let mut local_plugins = Vec::<(
String,
PluginId,
AbsolutePathBuf,
Option<bool>,
Option<String>,
bool,
)>::new();
let mut local_plugin_names = HashSet::new();
for plugin in curated_marketplace.plugins {
let plugin_name = plugin.name;
if !local_plugin_names.insert(plugin_name.clone()) {
warn!(
plugin = plugin_name,
marketplace = %marketplace_name,
"ignoring duplicate local plugin entry during remote sync"
);
continue;
}
let plugin_id = PluginId::new(plugin_name.clone(), marketplace_name.clone())?;
let plugin_key = plugin_id.as_key();
let source_path = match plugin.source {
MarketplacePluginSource::Local { path } => path,
MarketplacePluginSource::Git { .. } => {
warn!(
plugin = plugin_name,
marketplace = %marketplace_name,
"skipping remote plugin source during remote sync"
);
continue;
}
};
let current_enabled = configured_plugins
.get(&plugin_key)
.map(|plugin| plugin.enabled);
let installed_version = self.store.active_plugin_version(&plugin_id);
let product_allowed =
self.restriction_product_matches(plugin.policy.products.as_deref());
local_plugins.push((
plugin_name,
plugin_id,
source_path,
current_enabled,
installed_version,
product_allowed,
));
}
let mut missing_remote_plugins = Vec::<String>::new();
let mut remote_installed_plugin_names = HashSet::<String>::new();
for plugin in remote_plugins {
if plugin.marketplace_name != marketplace_name {
return Err(PluginRemoteSyncError::UnknownRemoteMarketplace {
marketplace_name: plugin.marketplace_name,
});
}
if !local_plugin_names.contains(&plugin.name) {
missing_remote_plugins.push(plugin.name);
continue;
}
// For now, sync treats remote `enabled = false` as uninstall rather than a distinct
// disabled state.
// TODO: Switch sync to `plugins/installed` so install and enable states stay distinct.
if !plugin.enabled {
continue;
}
if !remote_installed_plugin_names.insert(plugin.name.clone()) {
return Err(PluginRemoteSyncError::DuplicateRemotePlugin {
plugin_name: plugin.name,
});
}
}
let mut config_edits = Vec::new();
let mut installs = Vec::new();
let mut uninstalls = Vec::new();
let mut result = RemotePluginSyncResult::default();
let remote_plugin_count = remote_installed_plugin_names.len();
let local_plugin_count = local_plugins.len();
if !missing_remote_plugins.is_empty() {
let sample_missing_plugins = missing_remote_plugins
.iter()
.take(10)
.cloned()
.collect::<Vec<_>>();
warn!(
marketplace = %marketplace_name,
missing_remote_plugin_count = missing_remote_plugins.len(),
missing_remote_plugin_examples = ?sample_missing_plugins,
"ignoring remote plugins missing from local marketplace during sync"
);
}
for (
plugin_name,
plugin_id,
source_path,
current_enabled,
installed_version,
product_allowed,
) in local_plugins
{
let plugin_key = plugin_id.as_key();
let is_installed = installed_version.is_some();
if !product_allowed {
continue;
}
if remote_installed_plugin_names.contains(&plugin_name) {
if !is_installed {
installs.push((source_path, plugin_id.clone(), cache_plugin_version.clone()));
}
if !is_installed {
result.installed_plugin_ids.push(plugin_key.clone());
}
if current_enabled != Some(true) {
result.enabled_plugin_ids.push(plugin_key.clone());
config_edits.push(PluginConfigEdit::SetEnabled {
plugin_key,
enabled: true,
});
}
} else if !additive_only {
if is_installed {
uninstalls.push(plugin_id);
}
if is_installed || current_enabled.is_some() {
result.uninstalled_plugin_ids.push(plugin_key.clone());
}
if current_enabled.is_some() {
config_edits.push(PluginConfigEdit::Clear { plugin_key });
}
}
}
let store = self.store.clone();
let store_result = tokio::task::spawn_blocking(move || {
for (source_path, plugin_id, plugin_version) in installs {
store.install_with_version(source_path, plugin_id, plugin_version)?;
}
for plugin_id in uninstalls {
store.uninstall(&plugin_id)?;
}
Ok::<(), PluginStoreError>(())
})
.await
.map_err(PluginRemoteSyncError::join)?;
if let Err(err) = store_result {
self.clear_cache();
return Err(err.into());
}
let config_result = if config_edits.is_empty() {
Ok(())
} else {
apply_user_plugin_config_edits(&self.codex_home, config_edits).await
};
self.clear_cache();
config_result.map_err(anyhow::Error::from)?;
info!(
marketplace = %marketplace_name,
remote_plugin_count,
local_plugin_count,
installed_plugin_ids = ?result.installed_plugin_ids,
enabled_plugin_ids = ?result.enabled_plugin_ids,
disabled_plugin_ids = ?result.disabled_plugin_ids,
uninstalled_plugin_ids = ?result.uninstalled_plugin_ids,
"completed remote plugin sync"
);
Ok(result)
}
pub fn list_marketplaces_for_config(
&self,
config: &PluginsConfigInput,
@@ -1619,13 +1293,6 @@ impl PluginsManager {
warn!("failed to start configured marketplace auto-upgrade task: {err}");
}
}
start_startup_remote_plugin_sync_once(
Arc::clone(self),
self.codex_home.clone(),
config.clone(),
auth_manager.clone(),
);
let config_for_remote_sync = config.clone();
let manager = Arc::clone(self);
let auth_manager_for_remote_sync = auth_manager.clone();
-444
View File
@@ -2416,25 +2416,6 @@ enabled = true
);
}
#[tokio::test]
async fn sync_plugins_from_remote_returns_default_when_feature_disabled() {
let tmp = tempfile::tempdir().unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = false
"#,
);
let config = load_config(tmp.path(), tmp.path()).await;
let outcome = PluginsManager::new(tmp.path().to_path_buf())
.sync_plugins_from_remote(&config, /*auth*/ None, /*additive_only*/ false)
.await
.unwrap();
assert_eq!(outcome, RemotePluginSyncResult::default());
}
#[tokio::test]
async fn list_marketplaces_includes_curated_repo_marketplace() {
let tmp = tempfile::tempdir().unwrap();
@@ -2925,431 +2906,6 @@ enabled = true
);
}
#[tokio::test]
async fn sync_plugins_from_remote_reconciles_cache_and_config() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["linear", "gmail", "calendar"]);
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
write_plugin(
&tmp.path().join("plugins/cache/openai-curated"),
"linear/local",
"linear",
);
write_plugin(
&tmp.path().join("plugins/cache/openai-curated"),
"gmail/local",
"gmail",
);
write_plugin(
&tmp.path().join("plugins/cache/openai-curated"),
"calendar/local",
"calendar",
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."linear@openai-curated"]
enabled = false
[plugins."gmail@openai-curated"]
enabled = false
[plugins."calendar@openai-curated"]
enabled = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/list"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"[
{"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true},
{"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false}
]"#,
))
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = PluginsManager::new(tmp.path().to_path_buf());
let result = manager
.sync_plugins_from_remote(
&config,
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
/*additive_only*/ false,
)
.await
.unwrap();
assert_eq!(
result,
RemotePluginSyncResult {
installed_plugin_ids: Vec::new(),
enabled_plugin_ids: vec!["linear@openai-curated".to_string()],
disabled_plugin_ids: Vec::new(),
uninstalled_plugin_ids: vec![
"gmail@openai-curated".to_string(),
"calendar@openai-curated".to_string(),
],
}
);
assert!(
tmp.path()
.join("plugins/cache/openai-curated/linear/local")
.is_dir()
);
assert!(
!tmp.path()
.join("plugins/cache/openai-curated/gmail")
.exists()
);
assert!(
!tmp.path()
.join("plugins/cache/openai-curated/calendar")
.exists()
);
let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap();
assert!(config.contains(r#"[plugins."linear@openai-curated"]"#));
assert!(config.contains("enabled = true"));
assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#));
assert!(!config.contains(r#"[plugins."calendar@openai-curated"]"#));
let synced_config = load_config(tmp.path(), tmp.path()).await;
let curated_marketplace = manager
.list_marketplaces_for_config(&synced_config, &[])
.unwrap()
.marketplaces
.into_iter()
.find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
.unwrap();
assert_eq!(
curated_marketplace
.plugins
.into_iter()
.map(|plugin| (plugin.id, plugin.installed, plugin.enabled))
.collect::<Vec<_>>(),
vec![
("linear@openai-curated".to_string(), true, true),
("gmail@openai-curated".to_string(), false, false),
("calendar@openai-curated".to_string(), false, false),
]
);
}
#[tokio::test]
async fn sync_plugins_from_remote_additive_only_keeps_existing_plugins() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["linear", "gmail", "calendar"]);
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
write_plugin(
&tmp.path().join("plugins/cache/openai-curated"),
"linear/local",
"linear",
);
write_plugin(
&tmp.path().join("plugins/cache/openai-curated"),
"gmail/local",
"gmail",
);
write_plugin(
&tmp.path().join("plugins/cache/openai-curated"),
"calendar/local",
"calendar",
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."linear@openai-curated"]
enabled = false
[plugins."gmail@openai-curated"]
enabled = false
[plugins."calendar@openai-curated"]
enabled = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/list"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"[
{"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true},
{"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false}
]"#,
))
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = PluginsManager::new(tmp.path().to_path_buf());
let result = manager
.sync_plugins_from_remote(
&config,
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
/*additive_only*/ true,
)
.await
.unwrap();
assert_eq!(
result,
RemotePluginSyncResult {
installed_plugin_ids: Vec::new(),
enabled_plugin_ids: vec!["linear@openai-curated".to_string()],
disabled_plugin_ids: Vec::new(),
uninstalled_plugin_ids: Vec::new(),
}
);
assert!(
tmp.path()
.join("plugins/cache/openai-curated/linear/local")
.is_dir()
);
assert!(
tmp.path()
.join("plugins/cache/openai-curated/gmail/local")
.is_dir()
);
assert!(
tmp.path()
.join("plugins/cache/openai-curated/calendar/local")
.is_dir()
);
let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap();
assert!(config.contains(r#"[plugins."linear@openai-curated"]"#));
assert!(config.contains(r#"[plugins."gmail@openai-curated"]"#));
assert!(config.contains(r#"[plugins."calendar@openai-curated"]"#));
assert!(config.contains("enabled = true"));
}
#[tokio::test]
async fn sync_plugins_from_remote_ignores_unknown_remote_plugins() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["linear"]);
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."linear@openai-curated"]
enabled = false
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/list"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"[
{"id":"1","name":"plugin-one","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}
]"#,
))
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = PluginsManager::new(tmp.path().to_path_buf());
let result = manager
.sync_plugins_from_remote(
&config,
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
/*additive_only*/ false,
)
.await
.unwrap();
assert_eq!(
result,
RemotePluginSyncResult {
installed_plugin_ids: Vec::new(),
enabled_plugin_ids: Vec::new(),
disabled_plugin_ids: Vec::new(),
uninstalled_plugin_ids: vec!["linear@openai-curated".to_string()],
}
);
let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap();
assert!(!config.contains(r#"[plugins."linear@openai-curated"]"#));
assert!(
!tmp.path()
.join("plugins/cache/openai-curated/linear")
.exists()
);
}
#[tokio::test]
async fn sync_plugins_from_remote_keeps_existing_plugins_when_install_fails() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["linear", "gmail"]);
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
fs::remove_dir_all(curated_root.join("plugins/gmail")).unwrap();
write_plugin(
&tmp.path().join("plugins/cache/openai-curated"),
"linear/local",
"linear",
);
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."linear@openai-curated"]
enabled = false
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/list"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"[
{"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}
]"#,
))
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = PluginsManager::new(tmp.path().to_path_buf());
let err = manager
.sync_plugins_from_remote(
&config,
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
/*additive_only*/ false,
)
.await
.unwrap_err();
assert!(matches!(
err,
PluginRemoteSyncError::Store(PluginStoreError::Invalid(ref message))
if message.contains("plugin source path is not a directory")
));
assert!(
tmp.path()
.join("plugins/cache/openai-curated/linear/local")
.is_dir()
);
assert!(
!tmp.path()
.join("plugins/cache/openai-curated/gmail")
.exists()
);
let config = fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).unwrap();
assert!(config.contains(r#"[plugins."linear@openai-curated"]"#));
assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#));
assert!(config.contains("enabled = false"));
}
#[tokio::test]
async fn sync_plugins_from_remote_uses_first_duplicate_local_plugin_entry() {
let tmp = tempfile::tempdir().unwrap();
let curated_root = curated_plugins_repo_path(tmp.path());
write_curated_plugin_sha(tmp.path(), TEST_CURATED_PLUGIN_SHA);
fs::create_dir_all(curated_root.join(".agents/plugins")).unwrap();
fs::write(
curated_root.join(".agents/plugins/marketplace.json"),
r#"{
"name": "openai-curated",
"plugins": [
{
"name": "gmail",
"source": {
"source": "local",
"path": "./plugins/gmail-first"
}
},
{
"name": "gmail",
"source": {
"source": "local",
"path": "./plugins/gmail-second"
}
}
]
}"#,
)
.unwrap();
write_plugin(&curated_root, "plugins/gmail-first", "gmail");
write_plugin(&curated_root, "plugins/gmail-second", "gmail");
fs::write(curated_root.join("plugins/gmail-first/marker.txt"), "first").unwrap();
fs::write(
curated_root.join("plugins/gmail-second/marker.txt"),
"second",
)
.unwrap();
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/list"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"[
{"id":"1","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}
]"#,
))
.mount(&server)
.await;
let mut config = load_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = PluginsManager::new(tmp.path().to_path_buf());
let result = manager
.sync_plugins_from_remote(
&config,
Some(&CodexAuth::create_dummy_chatgpt_auth_for_testing()),
/*additive_only*/ false,
)
.await
.unwrap();
assert_eq!(
result,
RemotePluginSyncResult {
installed_plugin_ids: vec!["gmail@openai-curated".to_string()],
enabled_plugin_ids: vec!["gmail@openai-curated".to_string()],
disabled_plugin_ids: Vec::new(),
uninstalled_plugin_ids: Vec::new(),
}
);
assert_eq!(
fs::read_to_string(tmp.path().join(format!(
"plugins/cache/openai-curated/gmail/{TEST_CURATED_PLUGIN_CACHE_VERSION}/marker.txt"
)))
.unwrap(),
"first"
);
}
#[tokio::test]
async fn featured_plugin_ids_for_config_uses_restriction_product_query_param() {
let tmp = tempfile::tempdir().unwrap();
+3 -66
View File
@@ -6,19 +6,9 @@ use serde::Deserialize;
use std::time::Duration;
use url::Url;
const DEFAULT_REMOTE_MARKETPLACE_NAME: &str = "openai-curated";
const REMOTE_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(30);
const REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(10);
const REMOTE_PLUGIN_MUTATION_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct RemotePluginStatusSummary {
pub name: String,
#[serde(default = "default_remote_marketplace_name")]
pub marketplace_name: String,
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RemotePluginMutationResponse {
@@ -83,32 +73,21 @@ pub enum RemotePluginMutationError {
#[derive(Debug, thiserror::Error)]
pub enum RemotePluginFetchError {
#[error("chatgpt authentication required to sync remote plugins")]
AuthRequired,
#[error(
"chatgpt authentication required to sync remote plugins; api key auth is not supported"
)]
UnsupportedAuthMode,
#[error("failed to read auth token for remote plugin sync: {0}")]
AuthToken(#[source] std::io::Error),
#[error("failed to send remote plugin sync request to {url}: {source}")]
#[error("failed to send remote featured plugin request to {url}: {source}")]
Request {
url: String,
#[source]
source: reqwest::Error,
},
#[error("remote plugin sync request to {url} failed with status {status}: {body}")]
#[error("remote featured plugin request to {url} failed with status {status}: {body}")]
UnexpectedStatus {
url: String,
status: reqwest::StatusCode,
body: String,
},
#[error("failed to parse remote plugin sync response from {url}: {source}")]
#[error("failed to parse remote featured plugin response from {url}: {source}")]
Decode {
url: String,
#[source]
@@ -116,44 +95,6 @@ pub enum RemotePluginFetchError {
},
}
pub async fn fetch_remote_plugin_status(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
) -> Result<Vec<RemotePluginStatusSummary>, RemotePluginFetchError> {
let Some(auth) = auth else {
return Err(RemotePluginFetchError::AuthRequired);
};
if !auth.uses_codex_backend() {
return Err(RemotePluginFetchError::UnsupportedAuthMode);
}
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/plugins/list");
let client = build_reqwest_client();
let request = client
.get(&url)
.timeout(REMOTE_PLUGIN_FETCH_TIMEOUT)
.headers(codex_model_provider::auth_provider_from_auth(auth).to_auth_headers());
let response = request
.send()
.await
.map_err(|source| RemotePluginFetchError::Request {
url: url.clone(),
source,
})?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body });
}
serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode {
url: url.clone(),
source,
})
}
pub async fn fetch_remote_featured_plugin_ids(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
@@ -224,10 +165,6 @@ fn ensure_codex_backend_auth(
Ok(auth)
}
fn default_remote_marketplace_name() -> String {
DEFAULT_REMOTE_MARKETPLACE_NAME.to_string()
}
async fn post_remote_plugin_mutation(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
@@ -1,100 +0,0 @@
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use crate::manager::PluginsConfigInput;
use crate::manager::PluginsManager;
use crate::startup_sync::has_local_curated_plugins_snapshot;
use codex_login::AuthManager;
use tracing::info;
use tracing::warn;
const STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE: &str = ".tmp/app-server-remote-plugin-sync-v1";
const STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT: Duration = Duration::from_secs(10);
pub(crate) fn start_startup_remote_plugin_sync_once(
manager: Arc<PluginsManager>,
codex_home: PathBuf,
config: PluginsConfigInput,
auth_manager: Arc<AuthManager>,
) {
let marker_path = startup_remote_plugin_sync_marker_path(codex_home.as_path());
if marker_path.is_file() {
return;
}
tokio::spawn(async move {
if marker_path.is_file() {
return;
}
if !wait_for_startup_remote_plugin_sync_prerequisites(codex_home.as_path()).await {
warn!(
codex_home = %codex_home.display(),
"skipping startup remote plugin sync because curated marketplace is not ready"
);
return;
}
let auth = auth_manager.auth().await;
match manager
.sync_plugins_from_remote(&config, auth.as_ref(), /*additive_only*/ true)
.await
{
Ok(sync_result) => {
info!(
installed_plugin_ids = ?sync_result.installed_plugin_ids,
enabled_plugin_ids = ?sync_result.enabled_plugin_ids,
disabled_plugin_ids = ?sync_result.disabled_plugin_ids,
uninstalled_plugin_ids = ?sync_result.uninstalled_plugin_ids,
"completed startup remote plugin sync"
);
if let Err(err) =
write_startup_remote_plugin_sync_marker(codex_home.as_path()).await
{
warn!(
error = %err,
path = %marker_path.display(),
"failed to persist startup remote plugin sync marker"
);
}
}
Err(err) => {
warn!(
error = %err,
"startup remote plugin sync failed; will retry on next app-server start"
);
}
}
});
}
fn startup_remote_plugin_sync_marker_path(codex_home: &Path) -> PathBuf {
codex_home.join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE)
}
async fn wait_for_startup_remote_plugin_sync_prerequisites(codex_home: &Path) -> bool {
let deadline = tokio::time::Instant::now() + STARTUP_REMOTE_PLUGIN_SYNC_PREREQUISITE_TIMEOUT;
loop {
if has_local_curated_plugins_snapshot(codex_home) {
return true;
}
if tokio::time::Instant::now() >= deadline {
return false;
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
async fn write_startup_remote_plugin_sync_marker(codex_home: &Path) -> std::io::Result<()> {
let marker_path = startup_remote_plugin_sync_marker_path(codex_home);
if let Some(parent) = marker_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(marker_path, b"ok\n").await
}
#[cfg(test)]
#[path = "startup_remote_sync_tests.rs"]
mod tests;
@@ -1,91 +0,0 @@
use super::*;
use crate::PluginsManager;
use crate::startup_sync::curated_plugins_repo_path;
use crate::test_support::TEST_CURATED_PLUGIN_CACHE_VERSION;
use crate::test_support::load_plugins_config;
use crate::test_support::write_curated_plugin_sha;
use crate::test_support::write_file;
use crate::test_support::write_openai_curated_marketplace;
use codex_config::CONFIG_TOML_FILE;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use pretty_assertions::assert_eq;
use std::sync::Arc;
use std::time::Duration;
use tempfile::tempdir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
#[tokio::test]
async fn startup_remote_plugin_sync_writes_marker_and_reconciles_state() {
let tmp = tempdir().expect("tempdir");
let curated_root = curated_plugins_repo_path(tmp.path());
write_openai_curated_marketplace(&curated_root, &["linear"]);
write_curated_plugin_sha(tmp.path());
write_file(
&tmp.path().join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
[plugins."linear@openai-curated"]
enabled = false
"#,
);
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/plugins/list"))
.and(header("authorization", "Bearer Access Token"))
.and(header("chatgpt-account-id", "account_id"))
.respond_with(ResponseTemplate::new(200).set_body_string(
r#"[
{"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true}
]"#,
))
.mount(&server)
.await;
let mut config = load_plugins_config(tmp.path(), tmp.path()).await;
config.chatgpt_base_url = format!("{}/backend-api/", server.uri());
let manager = Arc::new(PluginsManager::new(tmp.path().to_path_buf()));
let auth_manager =
AuthManager::from_auth_for_testing(CodexAuth::create_dummy_chatgpt_auth_for_testing());
start_startup_remote_plugin_sync_once(
Arc::clone(&manager),
tmp.path().to_path_buf(),
config,
auth_manager,
);
let marker_path = tmp.path().join(STARTUP_REMOTE_PLUGIN_SYNC_MARKER_FILE);
tokio::time::timeout(Duration::from_secs(5), async {
loop {
if marker_path.is_file() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("marker should be written");
assert!(
tmp.path()
.join(format!(
"plugins/cache/openai-curated/linear/{TEST_CURATED_PLUGIN_CACHE_VERSION}"
))
.is_dir()
);
let config =
std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("config should exist");
assert!(config.contains(r#"[plugins."linear@openai-curated"]"#));
assert!(config.contains("enabled = true"));
let marker_contents = std::fs::read_to_string(marker_path).expect("marker should be readable");
assert_eq!(marker_contents, "ok\n");
}
@@ -88,10 +88,6 @@ pub(crate) fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str
}
}
pub(crate) fn write_curated_plugin_sha(codex_home: &Path) {
write_curated_plugin_sha_with(codex_home, TEST_CURATED_PLUGIN_SHA);
}
pub(crate) fn write_curated_plugin_sha_with(codex_home: &Path, sha: &str) {
write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n"));
}