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 6bcefbb58824..0b2200a9d55b 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -1824,13 +1824,27 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { ))?; shared_plugin_body["plugins"][0]["share_principals"] = serde_json::Value::Null; let shared_plugin_body = serde_json::to_string(&shared_plugin_body)?; - let workspace_installed_body = workspace_remote_plugin_page_body( - "plugins~Plugin_22222222222222222222222222222222", - "shared-linear", - "Shared Linear", - "PRIVATE", - /*enabled*/ Some(true), - ); + let mut workspace_installed_body: serde_json::Value = + serde_json::from_str(&workspace_remote_plugin_page_body( + "plugins~Plugin_22222222222222222222222222222222", + "shared-linear", + "Shared Linear", + "PRIVATE", + /*enabled*/ Some(true), + ))?; + let unlisted_installed_body: serde_json::Value = + serde_json::from_str(&workspace_remote_plugin_page_body( + "plugins~Plugin_33333333333333333333333333333333", + "unlisted-linear", + "Unlisted Linear", + "UNLISTED", + /*enabled*/ Some(false), + ))?; + workspace_installed_body["plugins"] + .as_array_mut() + .expect("installed plugins should be an array") + .push(unlisted_installed_body["plugins"][0].clone()); + let workspace_installed_body = serde_json::to_string(&workspace_installed_body)?; mount_shared_workspace_plugins(&server, &shared_plugin_body).await; mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; @@ -1851,9 +1865,12 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { .await??; let response: PluginListResponse = to_response(response)?; - assert_eq!(response.marketplaces.len(), 1); - let marketplace = &response.marketplaces[0]; - assert_eq!(marketplace.name, "shared-with-me"); + assert_eq!(response.marketplaces.len(), 2); + let marketplace = response + .marketplaces + .iter() + .find(|marketplace| marketplace.name == "workspace-shared-with-me-private") + .expect("expected private shared-with-me marketplace"); assert_eq!( marketplace .interface @@ -1862,7 +1879,10 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { Some("Shared with me") ); assert_eq!(marketplace.plugins.len(), 1); - assert_eq!(marketplace.plugins[0].id, "shared-linear@shared-with-me"); + assert_eq!( + marketplace.plugins[0].id, + "shared-linear@workspace-shared-with-me-private" + ); assert_eq!( marketplace.plugins[0].remote_plugin_id.as_deref(), Some("plugins~Plugin_22222222222222222222222222222222") @@ -1893,6 +1913,44 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { Some("https://chatgpt.example/plugins/share/share-key-1") ); assert_eq!(share_context.share_principals, None); + + let marketplace = response + .marketplaces + .iter() + .find(|marketplace| marketplace.name == "workspace-shared-with-me-unlisted") + .expect("expected unlisted shared-with-me marketplace"); + assert_eq!( + marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Shared with me (unlisted)") + ); + assert_eq!(marketplace.plugins.len(), 1); + assert_eq!( + marketplace.plugins[0].id, + "unlisted-linear@workspace-shared-with-me-unlisted" + ); + assert_eq!( + marketplace.plugins[0].remote_plugin_id.as_deref(), + Some("plugins~Plugin_33333333333333333333333333333333") + ); + assert_eq!(marketplace.plugins[0].name, "unlisted-linear"); + assert_eq!(marketplace.plugins[0].installed, true); + assert_eq!(marketplace.plugins[0].enabled, false); + let share_context = marketplace.plugins[0] + .share_context + .as_ref() + .expect("expected share context"); + assert_eq!( + share_context.remote_plugin_id, + "plugins~Plugin_33333333333333333333333333333333" + ); + assert_eq!(share_context.remote_version.as_deref(), Some("1.2.3")); + assert_eq!( + share_context.discoverability, + Some(PluginShareDiscoverability::Unlisted) + ); wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 7c93ac5d8adf..525a4234aa20 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -303,7 +303,7 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result< let request_id = mcp .send_plugin_read_request(PluginReadParams { marketplace_path: None, - remote_marketplace_name: Some("shared-with-me".to_string()), + remote_marketplace_name: Some("workspace-shared-with-me-private".to_string()), plugin_name: "plugins~Plugin_11111111111111111111111111111111".to_string(), }) .await?; @@ -315,8 +315,14 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result< .await??; let response: PluginReadResponse = to_response(response)?; - assert_eq!(response.plugin.marketplace_name, "shared-with-me"); - assert_eq!(response.plugin.summary.id, "shared-linear@shared-with-me"); + assert_eq!( + response.plugin.marketplace_name, + "workspace-shared-with-me-private" + ); + assert_eq!( + response.plugin.summary.id, + "shared-linear@workspace-shared-with-me-private" + ); assert_eq!( response.plugin.summary.remote_plugin_id.as_deref(), Some("plugins~Plugin_11111111111111111111111111111111") diff --git a/codex-rs/app-server/tests/suite/v2/plugin_share.rs b/codex-rs/app-server/tests/suite/v2/plugin_share.rs index 299c1eff253b..8614e83f8d04 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -162,7 +162,7 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "demo-plugin@shared-with-me".to_string(), + id: "demo-plugin@workspace-shared-with-me-private".to_string(), remote_plugin_id: Some("plugins_123".to_string()), local_version: None, name: "demo-plugin".to_string(), @@ -566,7 +566,7 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "demo-plugin@shared-with-me".to_string(), + id: "demo-plugin@workspace-shared-with-me-private".to_string(), remote_plugin_id: Some("plugins_123".to_string()), local_version: None, name: "demo-plugin".to_string(), @@ -787,7 +787,7 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "demo-plugin@shared-with-me".to_string(), + id: "demo-plugin@workspace-shared-with-me-private".to_string(), remote_plugin_id: Some("plugins_123".to_string()), local_version: None, name: "demo-plugin".to_string(), diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 6822e6e19785..47563b95b800 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -47,10 +47,15 @@ pub use share::update_remote_plugin_share_targets; pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global"; pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "workspace-directory"; -pub const REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME: &str = "shared-with-me"; +pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME: &str = + "workspace-shared-with-me-private"; +pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME: &str = + "workspace-shared-with-me-unlisted"; pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Plugins"; pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "Workspace Directory"; -pub const REMOTE_SHARED_WITH_ME_MARKETPLACE_DISPLAY_NAME: &str = "Shared with me"; +pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME: &str = "Shared with me"; +pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME: &str = + "Shared with me (unlisted)"; const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30); const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200; @@ -286,9 +291,9 @@ impl RemotePluginScope { fn from_marketplace_name(name: &str) -> Option { match name { REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global), - REMOTE_WORKSPACE_MARKETPLACE_NAME | REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME => { - Some(Self::Workspace) - } + REMOTE_WORKSPACE_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME + | REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME => Some(Self::Workspace), _ => None, } } @@ -388,9 +393,11 @@ fn remote_plugin_canonical_marketplace_name( RemotePluginScope::Global => Ok(REMOTE_GLOBAL_MARKETPLACE_NAME), RemotePluginScope::Workspace => match workspace_plugin_discoverability(plugin)? { RemotePluginShareDiscoverability::Listed => Ok(REMOTE_WORKSPACE_MARKETPLACE_NAME), - RemotePluginShareDiscoverability::Unlisted - | RemotePluginShareDiscoverability::Private => { - Ok(REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME) + RemotePluginShareDiscoverability::Unlisted => { + Ok(REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME) + } + RemotePluginShareDiscoverability::Private => { + Ok(REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME) } }, } @@ -462,43 +469,81 @@ pub async fn fetch_remote_marketplaces( }; for source in sources { - let marketplace = match source { + match source { RemoteMarketplaceSource::Global => { let scope = RemotePluginScope::Global; let (directory_plugins, installed_plugins) = tokio::try_join!( fetch_directory_plugins_for_scope(config, auth, scope), fetch_installed_plugins_for_scope(config, auth, scope), )?; - build_remote_marketplace( + if let Some(marketplace) = build_remote_marketplace( scope.marketplace_name(), scope.marketplace_display_name(), directory_plugins, installed_plugins, /*include_installed_only*/ true, - )? + )? { + marketplaces.push(marketplace); + } } RemoteMarketplaceSource::WorkspaceDirectory => { let scope = RemotePluginScope::Workspace; let directory_plugins = fetch_directory_plugins_for_scope(config, auth, scope).await?; - build_remote_marketplace( + if let Some(marketplace) = build_remote_marketplace( scope.marketplace_name(), scope.marketplace_display_name(), directory_plugins, workspace_installed_plugins.clone().unwrap_or_default(), /*include_installed_only*/ false, - )? + )? { + marketplaces.push(marketplace); + } + } + RemoteMarketplaceSource::SharedWithMe => { + let private_plugins = fetch_shared_workspace_plugins(config, auth) + .await? + .into_iter() + .filter_map(|plugin| match workspace_plugin_discoverability(&plugin) { + Ok(RemotePluginShareDiscoverability::Private) => Some(Ok(plugin)), + Ok(RemotePluginShareDiscoverability::Listed) + | Ok(RemotePluginShareDiscoverability::Unlisted) => None, + Err(err) => Some(Err(err)), + }) + .collect::, _>>()?; + if let Some(marketplace) = build_remote_marketplace( + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME, + private_plugins, + workspace_installed_plugins.clone().unwrap_or_default(), + /*include_installed_only*/ false, + )? { + marketplaces.push(marketplace); + } + + let unlisted_installed_plugins = workspace_installed_plugins + .clone() + .unwrap_or_default() + .into_iter() + .filter_map( + |plugin| match workspace_plugin_discoverability(&plugin.plugin) { + Ok(RemotePluginShareDiscoverability::Unlisted) => Some(Ok(plugin)), + Ok(RemotePluginShareDiscoverability::Listed) + | Ok(RemotePluginShareDiscoverability::Private) => None, + Err(err) => Some(Err(err)), + }, + ) + .collect::, _>>()?; + if let Some(marketplace) = build_remote_marketplace( + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME, + Vec::new(), + unlisted_installed_plugins, + /*include_installed_only*/ true, + )? { + marketplaces.push(marketplace); + } } - RemoteMarketplaceSource::SharedWithMe => build_remote_marketplace( - REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME, - REMOTE_SHARED_WITH_ME_MARKETPLACE_DISPLAY_NAME, - fetch_shared_workspace_plugins(config, auth).await?, - workspace_installed_plugins.clone().unwrap_or_default(), - /*include_installed_only*/ false, - )?, - }; - if let Some(marketplace) = marketplace { - marketplaces.push(marketplace); } } diff --git a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs index 26cdce343ace..c94eba428e50 100644 --- a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs +++ b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs @@ -1,6 +1,7 @@ use super::REMOTE_GLOBAL_MARKETPLACE_NAME; -use super::REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME; use super::REMOTE_WORKSPACE_MARKETPLACE_NAME; +use super::REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME; +use super::REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME; use super::RemotePluginCatalogError; use super::RemotePluginScope; use super::RemotePluginServiceConfig; @@ -153,7 +154,11 @@ pub async fn sync_remote_installed_plugin_bundles_once( BTreeSet::new(), ), ( - REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME.to_string(), + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME.to_string(), BTreeSet::new(), ), ]); @@ -298,7 +303,8 @@ fn remove_stale_remote_plugin_caches( for marketplace_name in [ REMOTE_GLOBAL_MARKETPLACE_NAME, REMOTE_WORKSPACE_MARKETPLACE_NAME, - REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME, + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME, ] { let marketplace_root = codex_home.join(PLUGINS_CACHE_DIR).join(marketplace_name); if !marketplace_root.exists() { @@ -457,7 +463,11 @@ mod tests { BTreeSet::new(), ), ( - REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME.to_string(), + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME.to_string(), BTreeSet::new(), ), ]); @@ -500,12 +510,12 @@ mod tests { } #[test] - fn stale_remote_plugin_cleanup_removes_shared_with_me_cache() { + fn stale_remote_plugin_cleanup_removes_private_shared_with_me_cache() { let codex_home = tempfile::tempdir().expect("create codex home"); let cached_manifest = codex_home .path() .join(PLUGINS_CACHE_DIR) - .join(REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME) + .join(REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME) .join("private-plugin") .join("1.2.3") .join(".codex-plugin") @@ -522,7 +532,11 @@ mod tests { BTreeSet::new(), ), ( - REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME.to_string(), + REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), + ( + REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME.to_string(), BTreeSet::new(), ), ]); @@ -531,9 +545,12 @@ mod tests { codex_home.path(), &installed_plugin_names_by_marketplace, ) - .expect("cleanup shared-with-me cache"); + .expect("cleanup private shared-with-me cache"); - assert_eq!(removed, vec!["private-plugin@shared-with-me".to_string()]); + assert_eq!( + removed, + vec!["private-plugin@workspace-shared-with-me-private".to_string()] + ); assert!(!cached_manifest.exists()); } } diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index 23bcfbfb06a0..0b8f8f2fb56f 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -586,7 +586,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { vec![ RemotePluginShareSummary { summary: RemotePluginSummary { - id: "demo-plugin@shared-with-me".to_string(), + id: "demo-plugin@workspace-shared-with-me-private".to_string(), remote_plugin_id: "plugins_123".to_string(), name: "demo-plugin".to_string(), share_context: Some(RemotePluginShareContext { @@ -625,7 +625,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { }, RemotePluginShareSummary { summary: RemotePluginSummary { - id: "demo-plugin@shared-with-me".to_string(), + id: "demo-plugin@workspace-shared-with-me-private".to_string(), remote_plugin_id: "plugins_456".to_string(), name: "demo-plugin".to_string(), share_context: Some(RemotePluginShareContext {