diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index a644549325a..104a727f3ce 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -8557,6 +8557,21 @@ }, "type": "object" }, + "MarketplaceLoadErrorInfo": { + "properties": { + "marketplacePath": { + "$ref": "#/definitions/v2/AbsolutePathBuf" + }, + "message": { + "type": "string" + } + }, + "required": [ + "marketplacePath", + "message" + ], + "type": "object" + }, "McpAuthStatus": { "enum": [ "unsupported", @@ -9515,6 +9530,13 @@ }, "type": "array" }, + "marketplaceLoadErrors": { + "default": [], + "items": { + "$ref": "#/definitions/v2/MarketplaceLoadErrorInfo" + }, + "type": "array" + }, "marketplaces": { "items": { "$ref": "#/definitions/v2/PluginMarketplaceEntry" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index 71e60345296..8f4f962167a 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -5305,6 +5305,21 @@ }, "type": "object" }, + "MarketplaceLoadErrorInfo": { + "properties": { + "marketplacePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "message": { + "type": "string" + } + }, + "required": [ + "marketplacePath", + "message" + ], + "type": "object" + }, "McpAuthStatus": { "enum": [ "unsupported", @@ -6263,6 +6278,13 @@ }, "type": "array" }, + "marketplaceLoadErrors": { + "default": [], + "items": { + "$ref": "#/definitions/MarketplaceLoadErrorInfo" + }, + "type": "array" + }, "marketplaces": { "items": { "$ref": "#/definitions/PluginMarketplaceEntry" diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index 580ee37a185..a9a5960638b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -16,6 +16,21 @@ }, "type": "object" }, + "MarketplaceLoadErrorInfo": { + "properties": { + "marketplacePath": { + "$ref": "#/definitions/AbsolutePathBuf" + }, + "message": { + "type": "string" + } + }, + "required": [ + "marketplacePath", + "message" + ], + "type": "object" + }, "PluginAuthPolicy": { "enum": [ "ON_INSTALL", @@ -246,6 +261,13 @@ }, "type": "array" }, + "marketplaceLoadErrors": { + "default": [], + "items": { + "$ref": "#/definitions/MarketplaceLoadErrorInfo" + }, + "type": "array" + }, "marketplaces": { "items": { "$ref": "#/definitions/PluginMarketplaceEntry" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceLoadErrorInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceLoadErrorInfo.ts new file mode 100644 index 00000000000..3e60e2149e8 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/MarketplaceLoadErrorInfo.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AbsolutePathBuf } from "../AbsolutePathBuf"; + +export type MarketplaceLoadErrorInfo = { marketplacePath: AbsolutePathBuf, message: string, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts index 4ca9b8a7147..7ae5f8e5056 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListResponse.ts @@ -1,6 +1,7 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo"; import type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry"; -export type PluginListResponse = { marketplaces: Array, remoteSyncError: string | null, featuredPluginIds: Array, }; +export type PluginListResponse = { marketplaces: Array, marketplaceLoadErrors: Array, remoteSyncError: string | null, featuredPluginIds: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index c649aec06af..1178f7c2eea 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -141,6 +141,7 @@ export type { LoginAccountParams } from "./LoginAccountParams"; export type { LoginAccountResponse } from "./LoginAccountResponse"; export type { LogoutAccountResponse } from "./LogoutAccountResponse"; export type { MarketplaceInterface } from "./MarketplaceInterface"; +export type { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo"; export type { McpAuthStatus } from "./McpAuthStatus"; export type { McpElicitationArrayType } from "./McpElicitationArrayType"; export type { McpElicitationBooleanSchema } from "./McpElicitationBooleanSchema"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 3e30f813237..09647e05cc1 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -3146,11 +3146,21 @@ pub struct PluginListParams { #[ts(export_to = "v2/")] pub struct PluginListResponse { pub marketplaces: Vec, + #[serde(default)] + pub marketplace_load_errors: Vec, pub remote_sync_error: Option, #[serde(default)] pub featured_plugin_ids: Vec, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct MarketplaceLoadErrorInfo { + pub marketplace_path: AbsolutePathBuf, + pub message: String, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a536e5c68ba..c238cc73e36 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -163,7 +163,7 @@ Example with notification opt-out: - `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`. - `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly. - `skills/list` — list skills for one or more `cwd` values (optional `forceReload`). -- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**). +- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**). - `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**). - `skills/changed` — notification emitted when watched local skill files change. - `app/list` — list available apps. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 37ff0ca4f20..b4e5da87bc5 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -5524,11 +5524,18 @@ impl CodexMessageProcessor { let config_for_marketplace_listing = config.clone(); let plugins_manager_for_marketplace_listing = plugins_manager.clone(); - let data = match tokio::task::spawn_blocking(move || { - let marketplaces = plugins_manager_for_marketplace_listing + let (data, marketplace_load_errors) = match tokio::task::spawn_blocking(move || { + let outcome = plugins_manager_for_marketplace_listing .list_marketplaces_for_config(&config_for_marketplace_listing, &roots)?; - Ok::, MarketplaceError>( - marketplaces + Ok::< + ( + Vec, + Vec, + ), + MarketplaceError, + >(( + outcome + .marketplaces .into_iter() .map(|marketplace| PluginMarketplaceEntry { name: marketplace.name, @@ -5552,11 +5559,19 @@ impl CodexMessageProcessor { .collect(), }) .collect(), - ) + outcome + .errors + .into_iter() + .map(|err| codex_app_server_protocol::MarketplaceLoadErrorInfo { + marketplace_path: err.path, + message: err.message, + }) + .collect(), + )) }) .await { - Ok(Ok(data)) => data, + Ok(Ok(outcome)) => outcome, Ok(Err(err)) => { self.send_marketplace_error(request_id, err, "list marketplace plugins") .await; @@ -5598,6 +5613,7 @@ impl CodexMessageProcessor { request_id, PluginListResponse { marketplaces: data, + marketplace_load_errors, remote_sync_error, featured_plugin_ids, }, 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 89b1090b847..ce3d4f52869 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -11,6 +11,9 @@ use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginListParams; use codex_app_server_protocol::PluginListResponse; +use codex_app_server_protocol::PluginMarketplaceEntry; +use codex_app_server_protocol::PluginSource; +use codex_app_server_protocol::PluginSummary; use codex_app_server_protocol::RequestId; use codex_core::auth::AuthCredentialsStoreMode; use codex_core::config::set_project_trust_level; @@ -41,16 +44,15 @@ plugins = true } #[tokio::test] -async fn plugin_list_skips_invalid_marketplace_file() -> Result<()> { +async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Result<()> { let codex_home = TempDir::new()?; let repo_root = TempDir::new()?; std::fs::create_dir_all(repo_root.path().join(".git"))?; std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?; write_plugins_enabled_config(codex_home.path())?; - std::fs::write( - repo_root.path().join(".agents/plugins/marketplace.json"), - "{not json", - )?; + let marketplace_path = + AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?; + std::fs::write(marketplace_path.as_path(), "{not json")?; let home = codex_home.path().to_string_lossy().into_owned(); let mut mcp = McpProcess::new_with_env( @@ -78,15 +80,24 @@ async fn plugin_list_skips_invalid_marketplace_file() -> Result<()> { let response: PluginListResponse = to_response(response)?; assert!( - response.marketplaces.iter().all(|marketplace| { - marketplace.path - != AbsolutePathBuf::try_from( - repo_root.path().join(".agents/plugins/marketplace.json"), - ) - .expect("absolute marketplace path") - }), + response + .marketplaces + .iter() + .all(|marketplace| { marketplace.path != marketplace_path }), "invalid marketplace should be skipped" ); + assert_eq!(response.marketplace_load_errors.len(), 1); + assert_eq!( + response.marketplace_load_errors[0].marketplace_path, + marketplace_path + ); + assert!( + response.marketplace_load_errors[0] + .message + .contains("invalid marketplace file"), + "unexpected error: {:?}", + response.marketplace_load_errors + ); Ok(()) } @@ -116,6 +127,124 @@ async fn plugin_list_rejects_relative_cwds() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_load() -> Result<()> +{ + let codex_home = TempDir::new()?; + let valid_repo_root = TempDir::new()?; + let invalid_repo_root = TempDir::new()?; + std::fs::create_dir_all(valid_repo_root.path().join(".git"))?; + std::fs::create_dir_all(valid_repo_root.path().join(".agents/plugins"))?; + std::fs::create_dir_all( + valid_repo_root + .path() + .join("plugins/valid-plugin/.codex-plugin"), + )?; + std::fs::create_dir_all(invalid_repo_root.path().join(".git"))?; + std::fs::create_dir_all(invalid_repo_root.path().join(".agents/plugins"))?; + write_plugins_enabled_config(codex_home.path())?; + + let valid_marketplace_path = AbsolutePathBuf::try_from( + valid_repo_root + .path() + .join(".agents/plugins/marketplace.json"), + )?; + let invalid_marketplace_path = AbsolutePathBuf::try_from( + invalid_repo_root + .path() + .join(".agents/plugins/marketplace.json"), + )?; + let valid_plugin_path = + AbsolutePathBuf::try_from(valid_repo_root.path().join("plugins/valid-plugin"))?; + + std::fs::write( + valid_marketplace_path.as_path(), + r#"{ + "name": "valid-marketplace", + "plugins": [ + { + "name": "valid-plugin", + "source": { + "source": "local", + "path": "./plugins/valid-plugin" + } + } + ] +}"#, + )?; + std::fs::write( + valid_repo_root + .path() + .join("plugins/valid-plugin/.codex-plugin/plugin.json"), + r#"{"name":"valid-plugin"}"#, + )?; + std::fs::write(invalid_marketplace_path.as_path(), "{not json")?; + + let home = codex_home.path().to_string_lossy().into_owned(); + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[ + ("HOME", Some(home.as_str())), + ("USERPROFILE", Some(home.as_str())), + ], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: Some(vec![ + AbsolutePathBuf::try_from(valid_repo_root.path())?, + AbsolutePathBuf::try_from(invalid_repo_root.path())?, + ]), + force_remote_sync: false, + }) + .await?; + + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginListResponse = to_response(response)?; + + assert_eq!( + response.marketplaces, + vec![PluginMarketplaceEntry { + name: "valid-marketplace".to_string(), + path: valid_marketplace_path, + interface: None, + plugins: vec![PluginSummary { + id: "valid-plugin@valid-marketplace".to_string(), + name: "valid-plugin".to_string(), + source: PluginSource::Local { + path: valid_plugin_path, + }, + installed: false, + enabled: false, + install_policy: PluginInstallPolicy::Available, + auth_policy: PluginAuthPolicy::OnInstall, + interface: None, + }], + }] + ); + assert_eq!(response.marketplace_load_errors.len(), 1); + assert_eq!( + response.marketplace_load_errors[0].marketplace_path, + invalid_marketplace_path + ); + assert!( + response.marketplace_load_errors[0] + .message + .contains("invalid marketplace file"), + "unexpected error: {:?}", + response.marketplace_load_errors + ); + assert_eq!(response.remote_sync_error, None); + assert!(response.featured_plugin_ids.is_empty()); + Ok(()) +} + #[tokio::test] async fn plugin_list_accepts_omitted_cwds() -> Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/plugins/discoverable.rs b/codex-rs/core/src/plugins/discoverable.rs index 5d054c2ff2e..00efd58026b 100644 --- a/codex-rs/core/src/plugins/discoverable.rs +++ b/codex-rs/core/src/plugins/discoverable.rs @@ -39,7 +39,8 @@ pub(crate) fn list_tool_suggest_discoverable_plugins( .collect::>(); let marketplaces = plugins_manager .list_marketplaces_for_config(config, &[]) - .context("failed to list plugin marketplaces for tool suggestions")?; + .context("failed to list plugin marketplaces for tool suggestions")? + .marketplaces; let Some(curated_marketplace) = marketplaces .into_iter() .find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) diff --git a/codex-rs/core/src/plugins/manager.rs b/codex-rs/core/src/plugins/manager.rs index 571535b5f06..fe4165719ed 100644 --- a/codex-rs/core/src/plugins/manager.rs +++ b/codex-rs/core/src/plugins/manager.rs @@ -4,6 +4,7 @@ use super::load_plugin_manifest; use super::manifest::PluginManifestInterface; use super::marketplace::MarketplaceError; use super::marketplace::MarketplaceInterface; +use super::marketplace::MarketplaceListError; use super::marketplace::MarketplacePluginAuthPolicy; use super::marketplace::MarketplacePluginPolicy; use super::marketplace::MarketplacePluginSource; @@ -178,6 +179,12 @@ pub struct ConfiguredMarketplacePlugin { pub enabled: bool, } +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ConfiguredMarketplaceListOutcome { + pub marketplaces: Vec, + pub errors: Vec, +} + #[derive(Debug, Clone, PartialEq)] pub struct LoadedPlugin { pub config_name: String, @@ -996,16 +1003,16 @@ impl PluginsManager { &self, config: &Config, additional_roots: &[AbsolutePathBuf], - ) -> Result, MarketplaceError> { + ) -> Result { if !config.features.enabled(Feature::Plugins) { - return Ok(Vec::new()); + return Ok(ConfiguredMarketplaceListOutcome::default()); } let (installed_plugins, enabled_plugins) = self.configured_plugin_states(config); - let marketplaces = list_marketplaces(&self.marketplace_roots(additional_roots))?; + let marketplace_outcome = list_marketplaces(&self.marketplace_roots(additional_roots))?; let mut seen_plugin_keys = HashSet::new(); - - Ok(marketplaces + let marketplaces = marketplace_outcome + .marketplaces .into_iter() .filter_map(|marketplace| { let marketplace_name = marketplace.name.clone(); @@ -1043,7 +1050,12 @@ impl PluginsManager { plugins, }) }) - .collect()) + .collect(); + + Ok(ConfiguredMarketplaceListOutcome { + marketplaces, + errors: marketplace_outcome.errors, + }) } pub fn read_plugin_for_config( diff --git a/codex-rs/core/src/plugins/manager_tests.rs b/codex-rs/core/src/plugins/manager_tests.rs index 630808dc0eb..3d61c393759 100644 --- a/codex-rs/core/src/plugins/manager_tests.rs +++ b/codex-rs/core/src/plugins/manager_tests.rs @@ -1035,7 +1035,8 @@ enabled = false 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(); + .unwrap() + .marketplaces; let marketplace = marketplaces .into_iter() @@ -1130,7 +1131,8 @@ 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(); + .unwrap() + .marketplaces; assert_eq!(marketplaces, Vec::new()); } @@ -1177,7 +1179,8 @@ plugins = 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(); + .unwrap() + .marketplaces; let marketplace = marketplaces .into_iter() @@ -1382,7 +1385,8 @@ plugins = true let config = load_config(tmp.path(), tmp.path()).await; let marketplaces = PluginsManager::new(tmp.path().to_path_buf()) .list_marketplaces_for_config(&config, &[]) - .unwrap(); + .unwrap() + .marketplaces; let curated_marketplace = marketplaces .into_iter() @@ -1487,7 +1491,8 @@ enabled = false AbsolutePathBuf::try_from(repo_b_root).unwrap(), ], ) - .unwrap(); + .unwrap() + .marketplaces; let repo_a_marketplace = marketplaces .iter() @@ -1590,7 +1595,8 @@ 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(); + .unwrap() + .marketplaces; let marketplace = marketplaces .into_iter() @@ -1733,6 +1739,7 @@ enabled = true let curated_marketplace = manager .list_marketplaces_for_config(&synced_config, &[]) .unwrap() + .marketplaces .into_iter() .find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME) .unwrap(); diff --git a/codex-rs/core/src/plugins/marketplace.rs b/codex-rs/core/src/plugins/marketplace.rs index 17b37f8cc9f..325b810f5f7 100644 --- a/codex-rs/core/src/plugins/marketplace.rs +++ b/codex-rs/core/src/plugins/marketplace.rs @@ -33,6 +33,18 @@ pub struct Marketplace { pub plugins: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MarketplaceListError { + pub path: AbsolutePathBuf, + pub message: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct MarketplaceListOutcome { + pub marketplaces: Vec, + pub errors: Vec, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct MarketplaceInterface { pub display_name: Option, @@ -195,7 +207,7 @@ pub fn resolve_marketplace_plugin( pub fn list_marketplaces( additional_roots: &[AbsolutePathBuf], -) -> Result, MarketplaceError> { +) -> Result { list_marketplaces_with_home(additional_roots, home_dir().as_deref()) } @@ -246,23 +258,27 @@ pub(crate) fn load_marketplace(path: &AbsolutePathBuf) -> Result, -) -> Result, MarketplaceError> { - let mut marketplaces = Vec::new(); +) -> Result { + let mut outcome = MarketplaceListOutcome::default(); for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) { match load_marketplace(&marketplace_path) { - Ok(marketplace) => marketplaces.push(marketplace), + Ok(marketplace) => outcome.marketplaces.push(marketplace), Err(err) => { warn!( path = %marketplace_path.display(), error = %err, "skipping marketplace that failed to load" ); + outcome.errors.push(MarketplaceListError { + path: marketplace_path, + message: err.to_string(), + }); } } } - Ok(marketplaces) + Ok(outcome) } fn discover_marketplace_paths_from_roots( diff --git a/codex-rs/core/src/plugins/marketplace_tests.rs b/codex-rs/core/src/plugins/marketplace_tests.rs index faf60250a04..0c5e72e6413 100644 --- a/codex-rs/core/src/plugins/marketplace_tests.rs +++ b/codex-rs/core/src/plugins/marketplace_tests.rs @@ -130,7 +130,8 @@ fn list_marketplaces_returns_home_and_repo_marketplaces() { &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], Some(&home_root), ) - .unwrap(); + .unwrap() + .marketplaces; assert_eq!( marketplaces, @@ -254,7 +255,8 @@ fn list_marketplaces_keeps_distinct_entries_for_same_name() { &[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()], Some(&home_root), ) - .unwrap(); + .unwrap() + .marketplaces; assert_eq!( marketplaces, @@ -342,7 +344,8 @@ fn list_marketplaces_dedupes_multiple_roots_in_same_repo() { ], None, ) - .unwrap(); + .unwrap() + .marketplaces; assert_eq!( marketplaces, @@ -396,7 +399,8 @@ fn list_marketplaces_reads_marketplace_display_name() { let marketplaces = list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None) - .unwrap(); + .unwrap() + .marketplaces; assert_eq!( marketplaces[0].interface, @@ -456,12 +460,66 @@ fn list_marketplaces_skips_marketplaces_that_fail_to_load() { ], None, ) - .unwrap(); + .unwrap() + .marketplaces; assert_eq!(marketplaces.len(), 1); assert_eq!(marketplaces[0].name, "valid-marketplace"); } +#[test] +fn list_marketplaces_reports_marketplace_load_errors() { + let tmp = tempdir().unwrap(); + let valid_repo_root = tmp.path().join("valid-repo"); + let invalid_repo_root = tmp.path().join("invalid-repo"); + + fs::create_dir_all(valid_repo_root.join(".git")).unwrap(); + fs::create_dir_all(valid_repo_root.join(".agents/plugins")).unwrap(); + fs::create_dir_all(invalid_repo_root.join(".git")).unwrap(); + fs::create_dir_all(invalid_repo_root.join(".agents/plugins")).unwrap(); + fs::write( + valid_repo_root.join(".agents/plugins/marketplace.json"), + r#"{ + "name": "valid-marketplace", + "plugins": [ + { + "name": "valid-plugin", + "source": { + "source": "local", + "path": "./plugin" + } + } + ] +}"#, + ) + .unwrap(); + let invalid_marketplace_path = + AbsolutePathBuf::try_from(invalid_repo_root.join(".agents/plugins/marketplace.json")) + .unwrap(); + fs::write(invalid_marketplace_path.as_path(), "{not json").unwrap(); + + let outcome = list_marketplaces_with_home( + &[ + AbsolutePathBuf::try_from(valid_repo_root).unwrap(), + AbsolutePathBuf::try_from(invalid_repo_root).unwrap(), + ], + None, + ) + .unwrap(); + + assert_eq!(outcome.marketplaces.len(), 1); + assert_eq!(outcome.marketplaces[0].name, "valid-marketplace"); + assert_eq!(outcome.errors.len(), 1); + assert_eq!(outcome.errors[0].path, invalid_marketplace_path); + assert!( + outcome.errors[0] + .message + .contains("invalid marketplace file"), + "unexpected errors: {:?}", + outcome.errors + ); +} + #[test] fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() { let tmp = tempdir().unwrap(); @@ -510,7 +568,8 @@ fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() { let marketplaces = list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None) - .unwrap(); + .unwrap() + .marketplaces; assert_eq!( marketplaces[0].plugins[0].policy.installation, @@ -577,7 +636,8 @@ fn list_marketplaces_ignores_legacy_top_level_policy_fields() { let marketplaces = list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None) - .unwrap(); + .unwrap() + .marketplaces; assert_eq!( marketplaces[0].plugins[0].policy.installation, @@ -632,7 +692,8 @@ fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() { let marketplaces = list_marketplaces_with_home(&[AbsolutePathBuf::try_from(repo_root).unwrap()], None) - .unwrap(); + .unwrap() + .marketplaces; assert_eq!( marketplaces[0].plugins[0].interface, diff --git a/codex-rs/core/src/plugins/mod.rs b/codex-rs/core/src/plugins/mod.rs index 3e1e6db28d3..34403f4d1f7 100644 --- a/codex-rs/core/src/plugins/mod.rs +++ b/codex-rs/core/src/plugins/mod.rs @@ -15,6 +15,7 @@ pub(crate) use discoverable::list_tool_suggest_discoverable_plugins; pub(crate) use injection::build_plugin_injections; pub use manager::AppConnectorId; pub use manager::ConfiguredMarketplace; +pub use manager::ConfiguredMarketplaceListOutcome; pub use manager::ConfiguredMarketplacePlugin; pub use manager::LoadedPlugin; pub use manager::OPENAI_CURATED_MARKETPLACE_NAME; @@ -40,6 +41,7 @@ pub use manifest::PluginManifestInterface; pub(crate) use manifest::PluginManifestPaths; pub(crate) use manifest::load_plugin_manifest; pub use marketplace::MarketplaceError; +pub use marketplace::MarketplaceListError; pub use marketplace::MarketplacePluginAuthPolicy; pub use marketplace::MarketplacePluginInstallPolicy; pub use marketplace::MarketplacePluginPolicy; diff --git a/codex-rs/core/src/tools/handlers/tool_suggest.rs b/codex-rs/core/src/tools/handlers/tool_suggest.rs index 533f12c4f1a..8a98bd2b03d 100644 --- a/codex-rs/core/src/tools/handlers/tool_suggest.rs +++ b/codex-rs/core/src/tools/handlers/tool_suggest.rs @@ -310,7 +310,7 @@ fn verified_plugin_suggestion_completed( .list_marketplaces_for_config(config, &[]) .ok() .into_iter() - .flatten() + .flat_map(|outcome| outcome.marketplaces) .flat_map(|marketplace| marketplace.plugins.into_iter()) .any(|plugin| plugin.id == tool_id && plugin.installed) } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index a31b4d6862b..f47f5da7c1a 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -7162,6 +7162,7 @@ fn plugins_test_repo_marketplace(plugins: Vec) -> PluginMarketpla fn plugins_test_response(marketplaces: Vec) -> PluginListResponse { PluginListResponse { marketplaces, + marketplace_load_errors: Vec::new(), remote_sync_error: None, featured_plugin_ids: Vec::new(), } diff --git a/codex-rs/tui_app_server/src/chatwidget/tests.rs b/codex-rs/tui_app_server/src/chatwidget/tests.rs index 8a383f21027..a6619eefd25 100644 --- a/codex-rs/tui_app_server/src/chatwidget/tests.rs +++ b/codex-rs/tui_app_server/src/chatwidget/tests.rs @@ -7759,6 +7759,7 @@ fn plugins_test_repo_marketplace(plugins: Vec) -> PluginMarketpla fn plugins_test_response(marketplaces: Vec) -> PluginListResponse { PluginListResponse { marketplaces, + marketplace_load_errors: Vec::new(), remote_sync_error: None, featured_plugin_ids: Vec::new(), }