mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
[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:
1 parent
c62d79259d
commit
4f655bc3b7
8 files changed
+3
-1130
No files matched your search
@@ -38,7 +38,6 @@ use wiremock::matchers::query_param;
|
|||||||
|
|
||||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30);
|
||||||
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
|
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 =
|
const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str =
|
||||||
"CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS";
|
"CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS";
|
||||||
const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json";
|
const ALTERNATE_MARKETPLACE_RELATIVE_PATH: &str = ".claude-plugin/marketplace.json";
|
||||||
@@ -1460,94 +1459,6 @@ enabled = true
|
|||||||
Ok(())
|
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]
|
#[tokio::test]
|
||||||
async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> Result<()> {
|
async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> Result<()> {
|
||||||
let codex_home = TempDir::new()?;
|
let codex_home = TempDir::new()?;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ mod plugin_bundle_archive;
|
|||||||
pub mod remote;
|
pub mod remote;
|
||||||
pub mod remote_bundle;
|
pub mod remote_bundle;
|
||||||
pub mod remote_legacy;
|
pub mod remote_legacy;
|
||||||
pub(crate) mod startup_remote_sync;
|
|
||||||
pub mod startup_sync;
|
pub mod startup_sync;
|
||||||
pub mod store;
|
pub mod store;
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -37,10 +36,8 @@ pub use manager::PluginInstallOutcome;
|
|||||||
pub use manager::PluginInstallRequest;
|
pub use manager::PluginInstallRequest;
|
||||||
pub use manager::PluginReadOutcome;
|
pub use manager::PluginReadOutcome;
|
||||||
pub use manager::PluginReadRequest;
|
pub use manager::PluginReadRequest;
|
||||||
pub use manager::PluginRemoteSyncError;
|
|
||||||
pub use manager::PluginUninstallError;
|
pub use manager::PluginUninstallError;
|
||||||
pub use manager::PluginsConfigInput;
|
pub use manager::PluginsConfigInput;
|
||||||
pub use manager::PluginsManager;
|
pub use manager::PluginsManager;
|
||||||
pub use manager::RemotePluginSyncResult;
|
|
||||||
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError;
|
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError;
|
||||||
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome;
|
pub use marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome;
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
use super::PluginLoadOutcome;
|
use super::PluginLoadOutcome;
|
||||||
use super::startup_remote_sync::start_startup_remote_plugin_sync_once;
|
|
||||||
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
|
use crate::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||||
use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack;
|
use crate::installed_marketplaces::installed_marketplace_roots_from_layer_stack;
|
||||||
use crate::loader::PluginHookLoadOutcome;
|
use crate::loader::PluginHookLoadOutcome;
|
||||||
@@ -32,7 +31,6 @@ use crate::marketplace::ResolvedMarketplacePlugin;
|
|||||||
use crate::marketplace::find_installable_marketplace_plugin;
|
use crate::marketplace::find_installable_marketplace_plugin;
|
||||||
use crate::marketplace::find_marketplace_plugin;
|
use crate::marketplace::find_marketplace_plugin;
|
||||||
use crate::marketplace::list_marketplaces;
|
use crate::marketplace::list_marketplaces;
|
||||||
use crate::marketplace::load_marketplace;
|
|
||||||
use crate::marketplace::plugin_interface_with_marketplace_category;
|
use crate::marketplace::plugin_interface_with_marketplace_category;
|
||||||
use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeError;
|
use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeError;
|
||||||
use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome;
|
use crate::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome;
|
||||||
@@ -52,8 +50,6 @@ use crate::store::PluginStore;
|
|||||||
use crate::store::PluginStoreError;
|
use crate::store::PluginStoreError;
|
||||||
use codex_analytics::AnalyticsEventsClient;
|
use codex_analytics::AnalyticsEventsClient;
|
||||||
use codex_config::ConfigLayerStack;
|
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::clear_user_plugin;
|
||||||
use codex_config::set_user_plugin_enabled;
|
use codex_config::set_user_plugin_enabled;
|
||||||
use codex_config::types::PluginConfig;
|
use codex_config::types::PluginConfig;
|
||||||
@@ -81,7 +77,6 @@ use std::sync::atomic::AtomicBool;
|
|||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
use tokio::sync::Semaphore;
|
use tokio::sync::Semaphore;
|
||||||
use tracing::info;
|
|
||||||
use tracing::warn;
|
use tracing::warn;
|
||||||
|
|
||||||
static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
|
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 {
|
pub struct PluginsManager {
|
||||||
codex_home: PathBuf,
|
codex_home: PathBuf,
|
||||||
store: PluginStore,
|
store: PluginStore,
|
||||||
@@ -408,7 +302,6 @@ pub struct PluginsManager {
|
|||||||
enabled_outcome_load_semaphore: Semaphore,
|
enabled_outcome_load_semaphore: Semaphore,
|
||||||
remote_installed_plugins_cache: RwLock<Option<Vec<RemoteInstalledPlugin>>>,
|
remote_installed_plugins_cache: RwLock<Option<Vec<RemoteInstalledPlugin>>>,
|
||||||
remote_installed_plugins_cache_refresh_state: RwLock<RemoteInstalledPluginsCacheRefreshState>,
|
remote_installed_plugins_cache_refresh_state: RwLock<RemoteInstalledPluginsCacheRefreshState>,
|
||||||
remote_sync_lock: Semaphore,
|
|
||||||
restriction_product: Option<Product>,
|
restriction_product: Option<Product>,
|
||||||
analytics_events_client: RwLock<Option<AnalyticsEventsClient>>,
|
analytics_events_client: RwLock<Option<AnalyticsEventsClient>>,
|
||||||
}
|
}
|
||||||
@@ -462,7 +355,6 @@ impl PluginsManager {
|
|||||||
remote_installed_plugins_cache_refresh_state: RwLock::new(
|
remote_installed_plugins_cache_refresh_state: RwLock::new(
|
||||||
RemoteInstalledPluginsCacheRefreshState::default(),
|
RemoteInstalledPluginsCacheRefreshState::default(),
|
||||||
),
|
),
|
||||||
remote_sync_lock: Semaphore::new(/*permits*/ 1),
|
|
||||||
restriction_product,
|
restriction_product,
|
||||||
analytics_events_client: RwLock::new(None),
|
analytics_events_client: RwLock::new(None),
|
||||||
}
|
}
|
||||||
@@ -1051,224 +943,6 @@ impl PluginsManager {
|
|||||||
Ok(())
|
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(
|
pub fn list_marketplaces_for_config(
|
||||||
&self,
|
&self,
|
||||||
config: &PluginsConfigInput,
|
config: &PluginsConfigInput,
|
||||||
@@ -1619,13 +1293,6 @@ impl PluginsManager {
|
|||||||
warn!("failed to start configured marketplace auto-upgrade task: {err}");
|
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 config_for_remote_sync = config.clone();
|
||||||
let manager = Arc::clone(self);
|
let manager = Arc::clone(self);
|
||||||
let auth_manager_for_remote_sync = auth_manager.clone();
|
let auth_manager_for_remote_sync = auth_manager.clone();
|
||||||
|
|||||||
@@ -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]
|
#[tokio::test]
|
||||||
async fn list_marketplaces_includes_curated_repo_marketplace() {
|
async fn list_marketplaces_includes_curated_repo_marketplace() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
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]
|
#[tokio::test]
|
||||||
async fn featured_plugin_ids_for_config_uses_restriction_product_query_param() {
|
async fn featured_plugin_ids_for_config_uses_restriction_product_query_param() {
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -6,19 +6,9 @@ use serde::Deserialize;
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use url::Url;
|
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_FEATURED_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
const REMOTE_PLUGIN_MUTATION_TIMEOUT: Duration = Duration::from_secs(30);
|
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)]
|
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct RemotePluginMutationResponse {
|
struct RemotePluginMutationResponse {
|
||||||
@@ -83,32 +73,21 @@ pub enum RemotePluginMutationError {
|
|||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
pub enum RemotePluginFetchError {
|
pub enum RemotePluginFetchError {
|
||||||
#[error("chatgpt authentication required to sync remote plugins")]
|
#[error("failed to send remote featured plugin request to {url}: {source}")]
|
||||||
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 {
|
Request {
|
||||||
url: String,
|
url: String,
|
||||||
#[source]
|
#[source]
|
||||||
source: reqwest::Error,
|
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 {
|
UnexpectedStatus {
|
||||||
url: String,
|
url: String,
|
||||||
status: reqwest::StatusCode,
|
status: reqwest::StatusCode,
|
||||||
body: String,
|
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 {
|
Decode {
|
||||||
url: String,
|
url: String,
|
||||||
#[source]
|
#[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(
|
pub async fn fetch_remote_featured_plugin_ids(
|
||||||
config: &RemotePluginServiceConfig,
|
config: &RemotePluginServiceConfig,
|
||||||
auth: Option<&CodexAuth>,
|
auth: Option<&CodexAuth>,
|
||||||
@@ -224,10 +165,6 @@ fn ensure_codex_backend_auth(
|
|||||||
Ok(auth)
|
Ok(auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_remote_marketplace_name() -> String {
|
|
||||||
DEFAULT_REMOTE_MARKETPLACE_NAME.to_string()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn post_remote_plugin_mutation(
|
async fn post_remote_plugin_mutation(
|
||||||
config: &RemotePluginServiceConfig,
|
config: &RemotePluginServiceConfig,
|
||||||
auth: Option<&CodexAuth>,
|
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) {
|
pub(crate) fn write_curated_plugin_sha_with(codex_home: &Path, sha: &str) {
|
||||||
write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n"));
|
write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n"));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user