mirror of
https://github.com/openai/codex.git
synced 2026-11-05 20:10:33 +00:00
plugins: reconcile same-name installations
This commit is contained in:
@@ -1627,13 +1627,16 @@ impl PluginRequestProcessor {
|
||||
remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin")
|
||||
})?;
|
||||
|
||||
self.thread_manager
|
||||
.plugins_manager()
|
||||
.maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
|
||||
&config.plugins_config_input(),
|
||||
auth.clone(),
|
||||
Some(self.effective_plugins_changed_callback()),
|
||||
);
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
plugins_manager
|
||||
.reconcile_remote_plugin_install(&config.plugins_config_input(), &result.plugin_id)
|
||||
.await
|
||||
.map_err(Self::plugin_install_error)?;
|
||||
plugins_manager.maybe_start_remote_installed_plugins_cache_refresh_after_mutation(
|
||||
&config.plugins_config_input(),
|
||||
auth.clone(),
|
||||
Some(self.effective_plugins_changed_callback()),
|
||||
);
|
||||
|
||||
let plugin_metadata = self
|
||||
.thread_manager
|
||||
|
||||
@@ -30,6 +30,8 @@ use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginAvailability;
|
||||
use codex_app_server_protocol::PluginInstallParams;
|
||||
use codex_app_server_protocol::PluginInstallResponse;
|
||||
use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginReadResponse;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -215,6 +217,16 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()>
|
||||
)
|
||||
.await;
|
||||
configure_remote_plugin_test(codex_home.path(), &server)?;
|
||||
let config_path = codex_home.path().join("config.toml");
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
format!(
|
||||
"{}\n[plugins.\"linear@debug\"]\nenabled = true\n",
|
||||
std::fs::read_to_string(&config_path)?
|
||||
),
|
||||
)?;
|
||||
let stale_root = codex_home.path().join("plugins/cache/debug/linear");
|
||||
std::fs::create_dir_all(stale_root.join("local"))?;
|
||||
mount_remote_plugin_detail_with_app_manifest(
|
||||
&server,
|
||||
REMOTE_PLUGIN_ID,
|
||||
@@ -277,6 +289,8 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()>
|
||||
serde_json::from_str(&std::fs::read_to_string(installed_path.join(".app.json"))?)?;
|
||||
assert_eq!(installed_app_manifest, remote_app_manifest);
|
||||
assert!(installed_path.join("skills/plan-work/SKILL.md").is_file());
|
||||
assert!(!stale_root.exists());
|
||||
assert!(!std::fs::read_to_string(config_path)?.contains("linear@debug"));
|
||||
assert!(
|
||||
!codex_home
|
||||
.path()
|
||||
@@ -1680,31 +1694,35 @@ async fn plugin_install_includes_formerly_disallowed_apps_needing_auth() -> Resu
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests() -> Result<()> {
|
||||
async fn plugin_install_reconciles_config_and_exposes_one_server_and_skill() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
"[features]\nplugins = true\n",
|
||||
"[features]\nplugins = true\n[plugins.\"structure-viewer@openai-internal-testing\"]\nenabled = true\n",
|
||||
)?;
|
||||
let stale_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/openai-internal-testing/structure-viewer");
|
||||
std::fs::create_dir_all(stale_root.join("local"))?;
|
||||
let repo_root = TempDir::new()?;
|
||||
write_plugin_marketplace(
|
||||
repo_root.path(),
|
||||
"debug",
|
||||
"sample-plugin",
|
||||
"./sample-plugin",
|
||||
"openai-monorepo",
|
||||
"structure-viewer",
|
||||
"./structure-viewer",
|
||||
/*install_policy*/ None,
|
||||
/*auth_policy*/ None,
|
||||
)?;
|
||||
write_plugin_source(repo_root.path(), "sample-plugin", &[])?;
|
||||
write_plugin_source(repo_root.path(), "structure-viewer", &[])?;
|
||||
let plugin_root = repo_root.path().join("structure-viewer");
|
||||
std::fs::create_dir_all(plugin_root.join("skills/structure-viewer"))?;
|
||||
std::fs::write(
|
||||
repo_root.path().join("sample-plugin/.mcp.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"sample-mcp": {
|
||||
"command": "echo"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
plugin_root.join("skills/structure-viewer/SKILL.md"),
|
||||
"---\nname: structure-viewer\ndescription: View structures\n---\n",
|
||||
)?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".mcp.json"),
|
||||
r#"{"mcpServers":{"structure":{"command":"echo"}}}"#,
|
||||
)?;
|
||||
let marketplace_path =
|
||||
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
||||
@@ -1714,9 +1732,9 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path: Some(marketplace_path),
|
||||
marketplace_path: Some(marketplace_path.clone()),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
plugin_name: "structure-viewer".to_string(),
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
@@ -1727,14 +1745,22 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests
|
||||
let response: PluginInstallResponse = to_response(response)?;
|
||||
assert_eq!(response.apps_needing_auth, Vec::<AppSummary>::new());
|
||||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||||
assert!(!config.contains("[mcp_servers.sample-mcp]"));
|
||||
assert!(!config.contains("[mcp_servers.structure]"));
|
||||
assert!(!config.contains("command = \"echo\""));
|
||||
let config: toml::Value = toml::from_str(&config)?;
|
||||
let plugins = config["plugins"].as_table().expect("plugins table");
|
||||
assert_eq!(plugins.len(), 1);
|
||||
assert_eq!(
|
||||
plugins.keys().next().map(String::as_str),
|
||||
Some("structure-viewer@openai-monorepo")
|
||||
);
|
||||
assert!(!stale_root.exists());
|
||||
|
||||
let request_id = mcp
|
||||
.send_raw_request(
|
||||
"mcpServer/oauth/login",
|
||||
Some(json!({
|
||||
"name": "sample-mcp",
|
||||
"name": "structure",
|
||||
})),
|
||||
)
|
||||
.await?;
|
||||
@@ -1749,6 +1775,25 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests
|
||||
err.error.message,
|
||||
"OAuth login is only supported for streamable HTTP servers."
|
||||
);
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: Some(marketplace_path),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "structure-viewer".to_string(),
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginReadResponse = to_response(response)?;
|
||||
assert_eq!(response.plugin.skills.len(), 1);
|
||||
assert_eq!(
|
||||
response.plugin.skills[0].name,
|
||||
"structure-viewer:structure-viewer"
|
||||
);
|
||||
assert_eq!(response.plugin.mcp_servers, vec!["structure"]);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -127,6 +127,7 @@ pub use overrides::build_cli_overrides_layer;
|
||||
pub use plugin_edit::PluginConfigEdit;
|
||||
pub use plugin_edit::apply_user_plugin_config_edits;
|
||||
pub use plugin_edit::clear_user_plugin;
|
||||
pub use plugin_edit::reconcile_user_plugin_registrations;
|
||||
pub use plugin_edit::set_user_plugin_enabled;
|
||||
pub use project_root_markers::default_project_root_markers;
|
||||
pub use project_root_markers::project_root_markers_from_config;
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_utils_path::resolve_symlink_write_paths;
|
||||
use codex_utils_path::write_atomically;
|
||||
use tokio::task;
|
||||
@@ -11,6 +13,8 @@ use toml_edit::Table as TomlTable;
|
||||
use toml_edit::value;
|
||||
|
||||
use crate::CONFIG_TOML_FILE;
|
||||
use crate::ConfigLayerStack;
|
||||
use crate::ConfigLayerStackOrdering;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PluginConfigEdit {
|
||||
@@ -37,6 +41,36 @@ pub async fn clear_user_plugin(codex_home: &Path, plugin_key: String) -> std::io
|
||||
apply_user_plugin_config_edits(codex_home, vec![PluginConfigEdit::Clear { plugin_key }]).await
|
||||
}
|
||||
|
||||
pub async fn reconcile_user_plugin_registrations(
|
||||
codex_home: &Path,
|
||||
config_layer_stack: Option<&ConfigLayerStack>,
|
||||
plugin_key: String,
|
||||
plugin_name: String,
|
||||
enable_canonical: bool,
|
||||
) -> std::io::Result<()> {
|
||||
let target_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let mut paths = BTreeSet::new();
|
||||
if let Some(config_layer_stack) = config_layer_stack {
|
||||
for layer in config_layer_stack.get_user_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ false,
|
||||
) {
|
||||
if let ConfigLayerSource::User { file, .. } = &layer.name {
|
||||
paths.insert(file.as_path().to_path_buf());
|
||||
}
|
||||
}
|
||||
}
|
||||
paths.remove(&target_path);
|
||||
task::spawn_blocking(move || {
|
||||
for path in paths {
|
||||
reconcile_plugin_config_path(&path, &plugin_key, &plugin_name, /*enable*/ false)?;
|
||||
}
|
||||
reconcile_plugin_config_path(&target_path, &plugin_key, &plugin_name, enable_canonical)
|
||||
})
|
||||
.await
|
||||
.map_err(|err| std::io::Error::other(format!("config persistence task panicked: {err}")))?
|
||||
}
|
||||
|
||||
pub async fn apply_user_plugin_config_edits(
|
||||
codex_home: &Path,
|
||||
edits: Vec<PluginConfigEdit>,
|
||||
@@ -74,6 +108,25 @@ fn apply_user_plugin_config_edits_blocking(
|
||||
write_atomically(&write_paths.write_path, &doc.to_string())
|
||||
}
|
||||
|
||||
fn reconcile_plugin_config_path(
|
||||
config_path: &Path,
|
||||
plugin_key: &str,
|
||||
plugin_name: &str,
|
||||
enable: bool,
|
||||
) -> std::io::Result<()> {
|
||||
let write_paths = resolve_symlink_write_paths(config_path)?;
|
||||
let mut doc = read_or_create_document(write_paths.read_path.as_deref())?;
|
||||
let mutated = if enable {
|
||||
set_plugin_enabled_exclusively(&mut doc, plugin_key, plugin_name)
|
||||
} else {
|
||||
remove_other_plugin_registrations(&mut doc, plugin_key, plugin_name)
|
||||
};
|
||||
if mutated {
|
||||
write_atomically(&write_paths.write_path, &doc.to_string())?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn read_or_create_document(config_path: Option<&Path>) -> std::io::Result<DocumentMut> {
|
||||
let Some(config_path) = config_path else {
|
||||
return Ok(DocumentMut::new());
|
||||
@@ -102,6 +155,45 @@ fn set_plugin_enabled(doc: &mut DocumentMut, plugin_key: &str, enabled: bool) ->
|
||||
true
|
||||
}
|
||||
|
||||
fn set_plugin_enabled_exclusively(
|
||||
doc: &mut DocumentMut,
|
||||
plugin_key: &str,
|
||||
plugin_name: &str,
|
||||
) -> bool {
|
||||
let removed_duplicate = remove_other_plugin_registrations(doc, plugin_key, plugin_name);
|
||||
set_plugin_enabled(doc, plugin_key, /*enabled*/ true) || removed_duplicate
|
||||
}
|
||||
|
||||
fn remove_other_plugin_registrations(
|
||||
doc: &mut DocumentMut,
|
||||
plugin_key: &str,
|
||||
plugin_name: &str,
|
||||
) -> bool {
|
||||
let Some(plugins) = doc
|
||||
.as_table_mut()
|
||||
.get_mut("plugins")
|
||||
.and_then(ensure_table_for_read)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let duplicate_keys = plugins
|
||||
.iter()
|
||||
.map(|(key, _)| key)
|
||||
.filter(|key| {
|
||||
*key != plugin_key
|
||||
&& key.rsplit_once('@').is_some_and(|(name, marketplace)| {
|
||||
name == plugin_name && !marketplace.is_empty()
|
||||
})
|
||||
})
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
let mut removed = false;
|
||||
for key in duplicate_keys {
|
||||
removed |= plugins.remove(&key).is_some();
|
||||
}
|
||||
removed
|
||||
}
|
||||
|
||||
fn clear_plugin(doc: &mut DocumentMut, plugin_key: &str) -> bool {
|
||||
let root = doc.as_table_mut();
|
||||
let Some(plugins_item) = root.get_mut("plugins") else {
|
||||
@@ -180,6 +272,10 @@ fn preserve_decor(existing: &TomlItem, replacement: &mut TomlItem) {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::ConfigLayerEntry;
|
||||
use crate::config_requirements::ConfigRequirements;
|
||||
use crate::config_requirements::ConfigRequirementsToml;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
@@ -239,6 +335,58 @@ source = "/tmp/plugin"
|
||||
assert_eq!(config, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exclusive_registration_reconciles_active_profile_layers() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let base_path = codex_home.path().join(CONFIG_TOML_FILE);
|
||||
let profile_path = codex_home.path().join("work.config.toml");
|
||||
let base_config = "[plugins.\"structure-viewer@openai-internal-testing\"]\nenabled = true\n[plugins.\"sequence-viewer@openai-internal-testing\"]\nenabled = true\n";
|
||||
let profile_config = "[plugins.\"structure-viewer@debug\"]\nenabled = true\n";
|
||||
fs::write(&base_path, base_config).unwrap();
|
||||
fs::write(&profile_path, profile_config).unwrap();
|
||||
let layer = |file: &Path, profile: Option<&str>, config: &str| {
|
||||
ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User {
|
||||
file: AbsolutePathBuf::try_from(file.to_path_buf()).unwrap(),
|
||||
profile: profile.map(str::to_string),
|
||||
},
|
||||
toml::from_str(config).unwrap(),
|
||||
)
|
||||
};
|
||||
let stack = ConfigLayerStack::new(
|
||||
vec![
|
||||
layer(&base_path, None, base_config),
|
||||
layer(&profile_path, Some("work"), profile_config),
|
||||
],
|
||||
ConfigRequirements::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.unwrap();
|
||||
reconcile_user_plugin_registrations(
|
||||
codex_home.path(),
|
||||
Some(&stack),
|
||||
"structure-viewer@openai-monorepo".to_string(),
|
||||
"structure-viewer".to_string(),
|
||||
/*enable_canonical*/ true,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let parse = |path: &Path| {
|
||||
toml::from_str::<toml::Value>(&fs::read_to_string(path).unwrap()).unwrap()
|
||||
};
|
||||
assert_eq!(
|
||||
parse(&base_path),
|
||||
toml::from_str::<toml::Value>(
|
||||
"[plugins.\"sequence-viewer@openai-internal-testing\"]\nenabled = true\n[plugins.\"structure-viewer@openai-monorepo\"]\nenabled = true\n"
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&profile_path),
|
||||
toml::from_str::<toml::Value>("").unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn clear_user_plugin_removes_empty_plugins_table() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
|
||||
@@ -56,7 +56,7 @@ use crate::tool_suggest_metadata::ToolSuggestMetadataCache;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::clear_user_plugin;
|
||||
use codex_config::set_user_plugin_enabled;
|
||||
use codex_config::reconcile_user_plugin_registrations;
|
||||
use codex_config::types::PluginConfig;
|
||||
use codex_config::types::ToolSuggestDisabledTool;
|
||||
use codex_config::types::ToolSuggestDiscoverableType;
|
||||
@@ -365,6 +365,7 @@ pub struct PluginsManager {
|
||||
restriction_product: Option<Product>,
|
||||
auth_mode: RwLock<Option<AuthMode>>,
|
||||
analytics_events_client: RwLock<Option<AnalyticsEventsClient>>,
|
||||
plugin_mutation_lock: Semaphore,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -437,6 +438,7 @@ impl PluginsManager {
|
||||
restriction_product,
|
||||
auth_mode: RwLock::new(auth_mode),
|
||||
analytics_events_client: RwLock::new(None),
|
||||
plugin_mutation_lock: Semaphore::new(/*permits*/ 1),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1258,7 +1260,10 @@ impl PluginsManager {
|
||||
) -> Result<PluginInstallOutcome, PluginInstallError> {
|
||||
let resolved = self.resolve_installable_plugin(config_layer_stack, &request)?;
|
||||
let plugin_id = resolved.plugin_id.clone();
|
||||
match self.install_resolved_plugin(resolved).await {
|
||||
match self
|
||||
.install_resolved_plugin(resolved, Some(config_layer_stack))
|
||||
.await
|
||||
{
|
||||
Ok(outcome) => Ok(outcome),
|
||||
Err(err) => {
|
||||
self.track_plugin_install_failed(
|
||||
@@ -1331,7 +1336,10 @@ impl PluginsManager {
|
||||
return Err(err);
|
||||
}
|
||||
let plugin_id = resolved.plugin_id.clone();
|
||||
match self.install_resolved_plugin(resolved).await {
|
||||
match self
|
||||
.install_resolved_plugin(resolved, Some(&config.config_layer_stack))
|
||||
.await
|
||||
{
|
||||
Ok(outcome) => Ok(outcome),
|
||||
Err(err) => {
|
||||
self.track_plugin_install_failed(
|
||||
@@ -1402,7 +1410,13 @@ impl PluginsManager {
|
||||
async fn install_resolved_plugin(
|
||||
&self,
|
||||
resolved: ResolvedMarketplacePlugin,
|
||||
config_layer_stack: Option<&ConfigLayerStack>,
|
||||
) -> Result<PluginInstallOutcome, PluginInstallError> {
|
||||
let _mutation_guard = self
|
||||
.plugin_mutation_lock
|
||||
.acquire()
|
||||
.await
|
||||
.unwrap_or_else(|_| unreachable!("plugin mutation lock should stay open"));
|
||||
let auth_policy = resolved.policy.authentication;
|
||||
let plugin_version =
|
||||
if is_openai_curated_marketplace_name(&resolved.plugin_id.marketplace_name) {
|
||||
@@ -1449,13 +1463,12 @@ impl PluginsManager {
|
||||
.await
|
||||
.map_err(PluginInstallError::join)??;
|
||||
|
||||
set_user_plugin_enabled(
|
||||
&self.codex_home,
|
||||
result.plugin_id.as_key(),
|
||||
/*enabled*/ true,
|
||||
self.reconcile_plugin_install_locked(
|
||||
&result.plugin_id,
|
||||
config_layer_stack,
|
||||
/*enable_canonical*/ true,
|
||||
)
|
||||
.await
|
||||
.map_err(anyhow::Error::from)?;
|
||||
.await?;
|
||||
|
||||
let analytics_events_client = match self.analytics_events_client.read() {
|
||||
Ok(client) => client.clone(),
|
||||
@@ -1476,6 +1489,60 @@ impl PluginsManager {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn reconcile_remote_plugin_install(
|
||||
&self,
|
||||
config: &PluginsConfigInput,
|
||||
plugin_id: &PluginId,
|
||||
) -> Result<(), PluginInstallError> {
|
||||
let _mutation_guard = self
|
||||
.plugin_mutation_lock
|
||||
.acquire()
|
||||
.await
|
||||
.unwrap_or_else(|_| unreachable!("plugin mutation lock should stay open"));
|
||||
self.reconcile_plugin_install_locked(
|
||||
plugin_id,
|
||||
Some(&config.config_layer_stack),
|
||||
/*enable_canonical*/ false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn reconcile_plugin_install_locked(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
config_layer_stack: Option<&ConfigLayerStack>,
|
||||
enable_canonical: bool,
|
||||
) -> Result<(), PluginInstallError> {
|
||||
reconcile_user_plugin_registrations(
|
||||
&self.codex_home,
|
||||
config_layer_stack,
|
||||
plugin_id.as_key(),
|
||||
plugin_id.plugin_name.clone(),
|
||||
enable_canonical,
|
||||
)
|
||||
.await
|
||||
.map_err(anyhow::Error::from)?;
|
||||
let store = self.store.clone();
|
||||
let canonical_plugin_id = plugin_id.clone();
|
||||
let removed_plugin_ids = tokio::task::spawn_blocking(move || {
|
||||
let removed = store.other_sources(&canonical_plugin_id, /*active_only*/ false)?;
|
||||
removed
|
||||
.iter()
|
||||
.try_for_each(|plugin_id| store.uninstall(plugin_id))?;
|
||||
Ok::<_, PluginStoreError>(removed)
|
||||
})
|
||||
.await
|
||||
.map_err(PluginInstallError::join)??;
|
||||
for removed_plugin_id in removed_plugin_ids {
|
||||
tracing::info!(
|
||||
canonical_plugin = %plugin_id.as_key(),
|
||||
removed_plugin = %removed_plugin_id.as_key(),
|
||||
"removed stale duplicate plugin installation"
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn uninstall_plugin(&self, plugin_id: String) -> Result<(), PluginUninstallError> {
|
||||
let plugin_id = PluginId::parse(&plugin_id)?;
|
||||
self.uninstall_plugin_id(plugin_id).await
|
||||
@@ -1503,6 +1570,11 @@ impl PluginsManager {
|
||||
}
|
||||
|
||||
async fn uninstall_plugin_id(&self, plugin_id: PluginId) -> Result<(), PluginUninstallError> {
|
||||
let _mutation_guard = self
|
||||
.plugin_mutation_lock
|
||||
.acquire()
|
||||
.await
|
||||
.unwrap_or_else(|_| unreachable!("plugin mutation lock should stay open"));
|
||||
let plugin_telemetry = if self.store.active_plugin_root(&plugin_id).is_some() {
|
||||
Some(
|
||||
self.telemetry_metadata_for_installed_plugin(&plugin_id)
|
||||
|
||||
@@ -187,10 +187,6 @@ pub async fn sync_remote_installed_plugin_bundles_once(
|
||||
for installed_plugin in installed_plugins {
|
||||
let plugin = installed_plugin.plugin;
|
||||
let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string();
|
||||
installed_plugin_names_by_marketplace
|
||||
.entry(marketplace_name.clone())
|
||||
.or_default()
|
||||
.insert(plugin.name.clone());
|
||||
let plugin_id = match PluginId::new(plugin.name.clone(), marketplace_name.clone()) {
|
||||
Ok(plugin_id) => plugin_id,
|
||||
Err(err) => {
|
||||
@@ -205,6 +201,13 @@ pub async fn sync_remote_installed_plugin_bundles_once(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if has_active_local_competitor(&store, &plugin_id)? {
|
||||
continue;
|
||||
}
|
||||
installed_plugin_names_by_marketplace
|
||||
.entry(marketplace_name.clone())
|
||||
.or_default()
|
||||
.insert(plugin.name.clone());
|
||||
let release_version = plugin
|
||||
.release
|
||||
.version
|
||||
@@ -254,7 +257,11 @@ pub async fn sync_remote_installed_plugin_bundles_once(
|
||||
.await
|
||||
{
|
||||
Ok(result) => {
|
||||
installed_plugin_ids.insert(result.plugin_id.as_key());
|
||||
if !has_active_local_competitor(&store, &result.plugin_id)? {
|
||||
installed_plugin_ids.insert(result.plugin_id.as_key());
|
||||
} else {
|
||||
store.uninstall(&result.plugin_id)?;
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
@@ -286,6 +293,15 @@ pub async fn sync_remote_installed_plugin_bundles_once(
|
||||
})
|
||||
}
|
||||
|
||||
fn has_active_local_competitor(
|
||||
store: &PluginStore,
|
||||
plugin_id: &PluginId,
|
||||
) -> Result<bool, PluginStoreError> {
|
||||
Ok(store
|
||||
.other_sources(plugin_id, /*active_only*/ true)?
|
||||
.iter()
|
||||
.any(|other| RemotePluginScope::from_marketplace_name(&other.marketplace_name).is_none()))
|
||||
}
|
||||
pub fn mark_remote_plugin_cache_mutation_in_flight(
|
||||
codex_home: &Path,
|
||||
marketplace_name: &str,
|
||||
@@ -479,7 +495,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sync_backfills_remote_plugin_install_metadata_for_current_bundle() {
|
||||
async fn sync_backfills_metadata_and_keeps_local_same_name_install_canonical() {
|
||||
let server = MockServer::start().await;
|
||||
let codex_home = tempfile::tempdir().expect("create codex home");
|
||||
let cached_manifest = codex_home
|
||||
@@ -494,6 +510,11 @@ mod tests {
|
||||
.expect("create cached plugin manifest parent");
|
||||
std::fs::write(&cached_manifest, r#"{"name":"linear","version":"1.2.3"}"#)
|
||||
.expect("write cached plugin manifest");
|
||||
assert!(fs::create_dir_all(codex_home.path().join("plugins/cache/debug/linear")).is_ok());
|
||||
let stale_remote = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/workspace-shared-with-me-private/linear/local");
|
||||
assert!(fs::create_dir_all(&stale_remote).is_ok());
|
||||
let remote_plugin_id = "plugins~Plugin_linear";
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
@@ -517,7 +538,7 @@ mod tests {
|
||||
}],
|
||||
"pagination": {"next_page_token": null},
|
||||
})))
|
||||
.expect(1)
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
@@ -528,7 +549,7 @@ mod tests {
|
||||
"plugins": [],
|
||||
"pagination": {"next_page_token": null},
|
||||
})))
|
||||
.expect(1)
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
@@ -539,7 +560,7 @@ mod tests {
|
||||
"plugins": [],
|
||||
"pagination": {"next_page_token": null},
|
||||
})))
|
||||
.expect(1)
|
||||
.expect(2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let config = RemotePluginServiceConfig {
|
||||
@@ -555,18 +576,23 @@ mod tests {
|
||||
.await
|
||||
.expect("sync current remote plugin bundle");
|
||||
|
||||
assert_eq!(outcome, RemoteInstalledPluginBundleSyncOutcome::default());
|
||||
assert_eq!(
|
||||
outcome.removed_cache_plugin_ids,
|
||||
vec!["linear@workspace-shared-with-me-private".to_string()]
|
||||
);
|
||||
assert!(!stale_remote.exists());
|
||||
let plugin_id = PluginId::new(
|
||||
"linear".to_string(),
|
||||
REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(),
|
||||
)
|
||||
.expect("valid plugin id");
|
||||
let metadata_path = PluginStore::new(codex_home.path().to_path_buf())
|
||||
let store = PluginStore::new(codex_home.path().to_path_buf());
|
||||
let metadata = store
|
||||
.plugin_base_root(&plugin_id)
|
||||
.join(".codex-remote-plugin-install.json");
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(
|
||||
&std::fs::read_to_string(metadata_path.as_path())
|
||||
&std::fs::read_to_string(metadata.as_path())
|
||||
.expect("read remote plugin install metadata")
|
||||
)
|
||||
.expect("parse remote plugin install metadata"),
|
||||
@@ -575,6 +601,23 @@ mod tests {
|
||||
"remote_plugin_id": remote_plugin_id,
|
||||
})
|
||||
);
|
||||
let local_manifest = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/debug/linear/local/.codex-plugin/plugin.json");
|
||||
std::fs::create_dir_all(local_manifest.parent().expect("manifest parent"))
|
||||
.expect("create local plugin manifest parent");
|
||||
std::fs::write(&local_manifest, r#"{"name":"linear"}"#)
|
||||
.expect("write local plugin manifest");
|
||||
store.uninstall(&plugin_id).expect("remove remote cache");
|
||||
let outcome = sync_remote_installed_plugin_bundles_once(
|
||||
codex_home.path().to_path_buf(),
|
||||
&config,
|
||||
Some(&auth),
|
||||
)
|
||||
.await
|
||||
.expect("sync after local plugin becomes canonical");
|
||||
assert_eq!(outcome, RemoteInstalledPluginBundleSyncOutcome::default());
|
||||
assert!(local_manifest.is_file() && !cached_manifest.exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user