From 2abdeb34d5b7a0bbdf082ce8be1d5dae6c645ffd Mon Sep 17 00:00:00 2001 From: xli-oai Date: Sun, 10 May 2026 16:59:57 -0700 Subject: [PATCH] Read cached metadata for installed Git plugins (#20825) ## Summary - Populate `plugin/list` interface metadata for installed Git-sourced marketplace plugins from the active cached plugin bundle. - Preserve marketplace category precedence so list behavior matches `plugin/read`. - Keep existing fallback behavior when the cache or manifest is missing or invalid. ## Test Plan - `cd codex-rs && just fmt` - `cd codex-rs && cargo test -p codex-core-plugins list_marketplaces_installed_git_source_reads_metadata_from_cache_without_cloning` - `cd codex-rs && cargo test -p codex-app-server plugin_list_returns_installed_git_source_interface_from_cache` - `cd codex-rs && just fix -p codex-core-plugins` - `cd codex-rs && just fix -p codex-app-server` - `git diff --check` Server-truth check: OpenAI monorepo app-server generated types already expose `PluginSummary.interface`, and the webview consumes it for plugin cards. This PR keeps the protocol/schema unchanged and fills the existing field from the cached installed bundle for Git-backed cross-repo plugins. --- .../app-server/tests/suite/v2/plugin_list.rs | 118 ++++++++++++++++++ codex-rs/core-plugins/src/manager.rs | 26 +++- codex-rs/core-plugins/src/manager_tests.rs | 110 ++++++++++++++++ 3 files changed, 250 insertions(+), 4 deletions(-) diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index a0205cb059..8b8e104a03 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -1141,6 +1141,124 @@ async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_list_returns_installed_git_source_interface_from_cache() -> Result<()> { + let codex_home = TempDir::new()?; + let repo_root = TempDir::new()?; + let missing_remote_repo = repo_root.path().join("missing-remote-plugin-repo"); + let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo) + .unwrap() + .to_string(); + std::fs::create_dir_all(repo_root.path().join(".git"))?; + std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; + std::fs::write( + repo_root.path().join(".agents/plugins/marketplace.json"), + format!( + r#"{{ + "name": "debug", + "plugins": [ + {{ + "name": "toolkit", + "source": {{ + "source": "git-subdir", + "url": "{missing_remote_repo_url}", + "path": "plugins/toolkit" + }}, + "category": "Developer Tools" + }} + ] +}}"# + ), + )?; + let cached_plugin_root = codex_home.path().join("plugins/cache/debug/toolkit/local"); + std::fs::create_dir_all(cached_plugin_root.join(".codex-plugin"))?; + std::fs::write( + cached_plugin_root.join(".codex-plugin/plugin.json"), + r##"{ + "name": "toolkit", + "interface": { + "displayName": "Toolkit", + "shortDescription": "Search cached data", + "category": "Cached Category", + "brandColor": "#3B82F6", + "composerIcon": "./assets/icon.png", + "logo": "./assets/logo.png" + } +}"##, + )?; + std::fs::write( + codex_home.path().join("config.toml"), + r#"[features] +plugins = true + +[plugins."toolkit@debug"] +enabled = true +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]), + 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 plugin = response + .marketplaces + .iter() + .flat_map(|marketplace| marketplace.plugins.iter()) + .find(|plugin| plugin.name == "toolkit") + .expect("expected toolkit entry"); + + assert_eq!(plugin.id, "toolkit@debug"); + assert_eq!(plugin.installed, true); + assert_eq!(plugin.enabled, true); + assert_eq!( + plugin.source, + PluginSource::Git { + url: missing_remote_repo_url, + path: Some("plugins/toolkit".to_string()), + ref_name: None, + sha: None, + } + ); + let interface = plugin + .interface + .as_ref() + .expect("expected cached plugin interface"); + assert_eq!(interface.display_name.as_deref(), Some("Toolkit")); + assert_eq!( + interface.short_description.as_deref(), + Some("Search cached data") + ); + assert_eq!(interface.category.as_deref(), Some("Developer Tools")); + assert_eq!(interface.brand_color.as_deref(), Some("#3B82F6")); + let canonical_cached_plugin_root = std::fs::canonicalize(&cached_plugin_root)?; + assert_eq!( + interface.composer_icon, + Some(AbsolutePathBuf::try_from( + canonical_cached_plugin_root.join("assets/icon.png") + )?) + ); + assert_eq!( + interface.logo, + Some(AbsolutePathBuf::try_from( + canonical_cached_plugin_root.join("assets/logo.png") + )?) + ); + Ok(()) +} + #[tokio::test] async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core-plugins/src/manager.rs b/codex-rs/core-plugins/src/manager.rs index adb8084cdc..42f5ac73db 100644 --- a/codex-rs/core-plugins/src/manager.rs +++ b/codex-rs/core-plugins/src/manager.rs @@ -1196,19 +1196,37 @@ impl PluginsManager { if !self.restriction_product_matches(plugin.policy.products.as_deref()) { return None; } + let installed = installed_plugins.contains(&plugin_key); + let enabled = enabled_plugins.contains(&plugin_key); + let mut interface = plugin.interface; + if installed + && matches!(&plugin.source, MarketplacePluginSource::Git { .. }) + && let Ok(plugin_id) = + PluginId::new(plugin.name.clone(), marketplace_name.clone()) + && let Some(plugin_root) = self.store.active_plugin_root(&plugin_id) + && let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) + { + let marketplace_category = interface + .as_ref() + .and_then(|interface| interface.category.clone()); + interface = plugin_interface_with_marketplace_category( + manifest.interface, + marketplace_category, + ); + } Some(ConfiguredMarketplacePlugin { // Enabled state is keyed by `@`, so duplicate // plugin entries from duplicate marketplace files intentionally // resolve to the first discovered source. - id: plugin_key.clone(), - installed: installed_plugins.contains(&plugin_key), - enabled: enabled_plugins.contains(&plugin_key), + id: plugin_key, + installed, + enabled, name: plugin.name, source: plugin.source, policy: plugin.policy, - interface: plugin.interface, keywords: plugin.keywords, + interface, }) }) .collect::>(); diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 06736a853c..e1c0f1b121 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -2039,6 +2039,116 @@ enabled = false ); } +#[tokio::test] +async fn list_marketplaces_installed_git_source_reads_metadata_from_cache_without_cloning() { + let tmp = tempfile::tempdir().unwrap(); + let repo_root = tmp.path().join("repo"); + let missing_remote_repo = tmp.path().join("missing-remote-plugin-repo"); + let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo) + .unwrap() + .to_string(); + fs::create_dir_all(repo_root.join(".git")).unwrap(); + write_file( + &repo_root.join(".agents/plugins/marketplace.json"), + &format!( + r#"{{ + "name": "debug", + "plugins": [ + {{ + "name": "toolkit", + "source": {{ + "source": "git-subdir", + "url": "{missing_remote_repo_url}", + "path": "plugins/toolkit" + }}, + "category": "Developer Tools" + }} + ] +}}"# + ), + ); + let cached_plugin_root = tmp.path().join("plugins/cache/debug/toolkit/local"); + write_file( + &cached_plugin_root.join(".codex-plugin/plugin.json"), + r##"{ + "name": "toolkit", + "interface": { + "displayName": "Toolkit", + "shortDescription": "Search cached data", + "category": "Cached Category", + "brandColor": "#3B82F6", + "composerIcon": "./assets/icon.png", + "logo": "./assets/logo.png", + "screenshots": ["./assets/screenshot.png"] + } +}"##, + ); + write_file( + &tmp.path().join(CONFIG_TOML_FILE), + r#"[features] +plugins = true + +[plugins."toolkit@debug"] +enabled = true +"#, + ); + + let config = load_config(tmp.path(), &repo_root).await; + let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) + .list_marketplaces_for_config(&config, &[AbsolutePathBuf::try_from(repo_root).unwrap()]) + .unwrap() + .marketplaces; + + let marketplace = marketplaces + .into_iter() + .find(|marketplace| marketplace.name == "debug") + .expect("debug marketplace should be listed"); + + assert_eq!( + marketplace.plugins, + vec![ConfiguredMarketplacePlugin { + id: "toolkit@debug".to_string(), + name: "toolkit".to_string(), + source: MarketplacePluginSource::Git { + url: missing_remote_repo_url, + path: Some("plugins/toolkit".to_string()), + ref_name: None, + sha: None, + }, + policy: MarketplacePluginPolicy { + installation: MarketplacePluginInstallPolicy::Available, + authentication: MarketplacePluginAuthPolicy::OnInstall, + products: None, + }, + interface: Some(PluginManifestInterface { + display_name: Some("Toolkit".to_string()), + short_description: Some("Search cached data".to_string()), + category: Some("Developer Tools".to_string()), + brand_color: Some("#3B82F6".to_string()), + composer_icon: Some( + AbsolutePathBuf::try_from(cached_plugin_root.join("assets/icon.png")).unwrap(), + ), + logo: Some( + AbsolutePathBuf::try_from(cached_plugin_root.join("assets/logo.png")).unwrap(), + ), + screenshots: vec![ + AbsolutePathBuf::try_from(cached_plugin_root.join("assets/screenshot.png")) + .unwrap(), + ], + ..Default::default() + }), + keywords: Vec::new(), + installed: true, + enabled: true, + }] + ); + assert!( + !tmp.path() + .join("plugins/.marketplace-plugin-source-staging") + .exists() + ); +} + #[tokio::test] async fn sync_plugins_from_remote_returns_default_when_feature_disabled() { let tmp = tempfile::tempdir().unwrap();