diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index b275969a9..169ed7e13 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1845,7 +1845,8 @@ "local", "vertical", "workspace-directory", - "shared-with-me" + "shared-with-me", + "created-by-me-remote" ], "type": "string" }, 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 7752b7b1a..46c3f0720 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 @@ -13135,7 +13135,8 @@ "local", "vertical", "workspace-directory", - "shared-with-me" + "shared-with-me", + "created-by-me-remote" ], "type": "string" }, 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 49e926755..e95d4c98a 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 @@ -9608,7 +9608,8 @@ "local", "vertical", "workspace-directory", - "shared-with-me" + "shared-with-me", + "created-by-me-remote" ], "type": "string" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json index 9c15ed5a7..997c83dc7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json @@ -10,7 +10,8 @@ "local", "vertical", "workspace-directory", - "shared-with-me" + "shared-with-me", + "created-by-me-remote" ], "type": "string" } diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts index 1be75e6f0..8e1867d8f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts @@ -2,4 +2,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type PluginListMarketplaceKind = "local" | "vertical" | "workspace-directory" | "shared-with-me"; +export type PluginListMarketplaceKind = "local" | "vertical" | "workspace-directory" | "shared-with-me" | "created-by-me-remote"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 2bf187210..128425d1c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -165,6 +165,9 @@ pub enum PluginListMarketplaceKind { #[serde(rename = "shared-with-me")] #[ts(rename = "shared-with-me")] SharedWithMe, + #[serde(rename = "created-by-me-remote")] + #[ts(rename = "created-by-me-remote")] + CreatedByMeRemote, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index e891fb546..7b2b45c7e 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2907,6 +2907,7 @@ fn plugin_list_params_serializes_marketplace_kind_filter() { PluginListMarketplaceKind::Vertical, PluginListMarketplaceKind::WorkspaceDirectory, PluginListMarketplaceKind::SharedWithMe, + PluginListMarketplaceKind::CreatedByMeRemote, ]), }) .unwrap(), @@ -2917,6 +2918,7 @@ fn plugin_list_params_serializes_marketplace_kind_filter() { "vertical", "workspace-directory", "shared-with-me", + "created-by-me-remote", ], }), ); diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index cc251c3a5..8ad2ba7b6 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -204,7 +204,7 @@ Example with notification opt-out: - `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present. - `marketplace/remove` — remove a configured marketplace by name from the user marketplace config, and delete its installed marketplace root when one exists. - `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors. -- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), 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 (**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, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**). - `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**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/hooks/apps/MCP server names. Remote plugin details expose the canonical `shareUrl` supplied by the remote catalog when available; it is `null` for local plugins or when the catalog omits it. This field is separate from `summary.shareContext`, which continues to describe user and workspace sharing state. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Use `plugin/install`'s `appsNeedingAuth` to drive post-install authentication and `app/list`'s `isAccessible` to determine current connector accessibility (**under development; do not call from production clients yet**). - `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle. diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 8e124694c..2efda3319 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -8,6 +8,7 @@ use codex_app_server_protocol::PluginShareTargetRole; use codex_config::types::McpServerConfig; use codex_core_plugins::OPENAI_CURATED_MARKETPLACE_NAME; use codex_core_plugins::PluginListBackgroundTaskOptions; +use codex_core_plugins::remote::REMOTE_CREATED_BY_ME_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_GLOBAL_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_MARKETPLACE_NAME; use codex_core_plugins::remote::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME; @@ -156,6 +157,7 @@ fn remote_installed_plugin_visible_marketplaces(config: &Config) -> Vec<&'static let mut marketplaces = Vec::new(); if config.features.enabled(Feature::RemotePlugin) { marketplaces.push(REMOTE_GLOBAL_MARKETPLACE_NAME); + marketplaces.push(REMOTE_CREATED_BY_ME_MARKETPLACE_NAME); } marketplaces.push(REMOTE_WORKSPACE_MARKETPLACE_NAME); if config.features.enabled(Feature::PluginSharing) { @@ -552,6 +554,9 @@ impl PluginRequestProcessor { let plugins_input = config.plugins_config_input(); let include_shared_with_me = marketplace_kinds.contains(&PluginListMarketplaceKind::SharedWithMe); + let include_created_by_me_remote = marketplace_kinds + .contains(&PluginListMarketplaceKind::CreatedByMeRemote) + && config.features.enabled(Feature::RemotePlugin); let include_global_remote = !explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin); let remote_plugin_service_config = RemotePluginServiceConfig { @@ -667,6 +672,9 @@ impl PluginRequestProcessor { if include_global_remote { remote_sources.push(RemoteMarketplaceSource::Global); } + if include_created_by_me_remote { + remote_sources.push(RemoteMarketplaceSource::CreatedByMeRemote); + } if marketplace_kinds.contains(&PluginListMarketplaceKind::WorkspaceDirectory) { remote_sources.push(RemoteMarketplaceSource::WorkspaceDirectory); } @@ -717,7 +725,11 @@ impl PluginRequestProcessor { } } } - if include_local || include_shared_with_me || include_global_remote { + if include_local + || include_created_by_me_remote + || include_shared_with_me + || include_global_remote + { plugins_manager.maybe_start_plugin_list_background_tasks_for_config( &plugins_input, auth.clone(), 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 811f87216..1f1f47bd0 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -35,6 +35,7 @@ use wiremock::matchers::header; use wiremock::matchers::method; use wiremock::matchers::path; use wiremock::matchers::query_param; +use wiremock::matchers::query_param_is_missing; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567"; @@ -229,6 +230,7 @@ enabled = true mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await; mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) .await; + mount_empty_user_installed_plugins(&server).await; let mut app_server = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, app_server.initialize()).await??; @@ -1498,6 +1500,7 @@ async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() -> mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await; mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) .await; + mount_empty_user_installed_plugins(&server).await; let installed_path = codex_home .path() @@ -1569,6 +1572,7 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await; mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) .await; + mount_empty_user_installed_plugins(&server).await; let old_path = codex_home .path() @@ -1894,6 +1898,7 @@ async fn plugin_list_uses_cached_global_remote_catalog_and_refreshes_it() -> Res mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await; mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) .await; + mount_empty_user_installed_plugins(&server).await; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -1929,6 +1934,7 @@ async fn plugin_list_uses_cached_global_remote_catalog_and_refreshes_it() -> Res mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await; mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) .await; + mount_empty_user_installed_plugins(&server).await; let request_id = mcp .send_plugin_list_request(PluginListParams { @@ -2006,6 +2012,7 @@ async fn plugin_list_includes_openai_curated_remote_collection_when_requested() mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await; mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) .await; + mount_empty_user_installed_plugins(&server).await; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -2093,6 +2100,7 @@ async fn plugin_list_propagates_explicit_openai_curated_remote_collection_errors mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await; mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) .await; + mount_empty_user_installed_plugins(&server).await; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -2318,6 +2326,7 @@ plugin_sharing = true let global_installed_body = remote_installed_plugin_body("", "1.2.3", /*enabled*/ true); mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await; mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; + mount_empty_user_installed_plugins(&server).await; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -2418,6 +2427,7 @@ plugin_sharing = false let workspace_installed_body = serde_json::to_string(&workspace_installed_body)?; mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await; mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; + mount_empty_user_installed_plugins(&server).await; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -2456,6 +2466,97 @@ plugin_sharing = false Ok(()) } +#[tokio::test] +async fn plugin_installed_includes_created_by_me_when_remote_plugins_enabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +remote_plugin = true +plugin_sharing = false +"#, + 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, + )?; + mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await; + mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) + .await; + let bundle_url = mount_remote_plugin_bundle( + &server, + "private-linear", + remote_plugin_bundle_tar_gz_bytes("private-linear")?, + ) + .await; + let mut user_installed_body: serde_json::Value = + serde_json::from_str(&user_remote_plugin_page_body( + "plugins~Plugin_55555555555555555555555555555555", + "private-linear", + "Private Linear", + "PRIVATE", + /*enabled*/ Some(true), + ))?; + user_installed_body["plugins"][0]["release"]["bundle_download_url"] = + serde_json::json!(bundle_url); + mount_remote_installed_plugins( + &server, + "USER", + &serde_json::to_string(&user_installed_body)?, + ) + .await; + + let mut mcp = TestAppServer::new_with_env( + codex_home.path(), + &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_installed_request(PluginInstalledParams { + cwds: None, + install_suggestion_plugin_names: None, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let response: PluginInstalledResponse = to_response(response)?; + + assert_eq!(response.marketplaces.len(), 1); + assert_eq!(response.marketplaces[0].name, "created-by-me-remote"); + assert_eq!( + response.marketplaces[0] + .plugins + .iter() + .map(|plugin| (plugin.id.as_str(), plugin.installed, plugin.enabled)) + .collect::>(), + vec![("private-linear@created-by-me-remote", true, true)] + ); + wait_for_path_exists( + &codex_home.path().join( + "plugins/cache/created-by-me-remote/private-linear/1.2.3/.codex-plugin/plugin.json", + ), + ) + .await?; + wait_for_remote_installed_scope_request(&server, "USER").await?; + Ok(()) +} + #[tokio::test] async fn plugin_installed_starts_remote_installed_bundle_sync() -> Result<()> { let codex_home = TempDir::new()?; @@ -2493,6 +2594,7 @@ plugin_sharing = false mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await; mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) .await; + mount_empty_user_installed_plugins(&server).await; let mut mcp = TestAppServer::new_with_env( codex_home.path(), @@ -2567,6 +2669,7 @@ async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag ); mount_remote_plugin_list(&server, "WORKSPACE", &workspace_plugin_body).await; mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; + mount_empty_user_installed_plugins(&server).await; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -2621,6 +2724,142 @@ async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag Ok(()) } +#[tokio::test] +async fn plugin_list_fetches_user_plugins_in_created_by_me_remote_marketplace() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +remote_plugin = true +plugin_sharing = false +"#, + 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, + )?; + + let mut private_page: serde_json::Value = serde_json::from_str(&user_remote_plugin_page_body( + "plugins~Plugin_55555555555555555555555555555555", + "private-linear", + "Private Linear", + "PRIVATE", + /*enabled*/ None, + ))?; + private_page["pagination"]["next_page_token"] = serde_json::json!("page-2"); + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/list")) + .and(query_param("scope", "USER")) + .and(query_param("limit", "200")) + .and(query_param_is_missing("pageToken")) + .respond_with(ResponseTemplate::new(200).set_body_json(private_page)) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/list")) + .and(query_param("scope", "USER")) + .and(query_param("limit", "200")) + .and(query_param("pageToken", "page-2")) + .respond_with( + ResponseTemplate::new(200).set_body_string(user_remote_plugin_page_body( + "plugins~Plugin_66666666666666666666666666666666", + "second-private-linear", + "Second Private Linear", + "PRIVATE", + /*enabled*/ None, + )), + ) + .mount(&server) + .await; + mount_remote_installed_plugins( + &server, + "USER", + &user_remote_plugin_page_body( + "plugins~Plugin_55555555555555555555555555555555", + "private-linear", + "Private Linear", + "PRIVATE", + /*enabled*/ Some(true), + ), + ) + .await; + mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await; + mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body()) + .await; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![PluginListMarketplaceKind::CreatedByMeRemote]), + }) + .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.len(), 1); + let marketplace = &response.marketplaces[0]; + assert_eq!(marketplace.name, "created-by-me-remote"); + assert_eq!( + marketplace + .interface + .as_ref() + .and_then(|interface| interface.display_name.as_deref()), + Some("Created by me") + ); + assert_eq!(marketplace.plugins.len(), 2); + assert_eq!( + marketplace.plugins[0].id, + "private-linear@created-by-me-remote" + ); + assert_eq!( + marketplace.plugins[0].remote_plugin_id.as_deref(), + Some("plugins~Plugin_55555555555555555555555555555555") + ); + assert_eq!(marketplace.plugins[0].installed, true); + assert_eq!(marketplace.plugins[0].enabled, true); + assert_eq!(marketplace.plugins[0].share_context, None); + assert_eq!( + marketplace.plugins[1].id, + "second-private-linear@created-by-me-remote" + ); + assert_eq!(marketplace.plugins[1].installed, false); + assert_eq!(marketplace.plugins[1].enabled, false); + assert!( + !server + .received_requests() + .await + .expect("wiremock should record requests") + .iter() + .any(|request| { + request.url.path().ends_with("/ps/plugins/list") + && request + .url + .query_pairs() + .any(|(key, value)| key == "scope" && value != "USER") + }) + ); + Ok(()) +} + #[tokio::test] async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { let codex_home = TempDir::new()?; @@ -2684,6 +2923,7 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { mount_shared_workspace_plugins(&server, &shared_plugin_body).await; mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await; mount_remote_installed_plugins(&server, "WORKSPACE", &workspace_installed_body).await; + mount_empty_user_installed_plugins(&server).await; let mut mcp = TestAppServer::new(codex_home.path()).await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; @@ -2876,6 +3116,61 @@ plugin_sharing = false Ok(()) } +#[tokio::test] +async fn plugin_list_omits_created_by_me_when_remote_plugins_disabled() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + std::fs::write( + codex_home.path().join("config.toml"), + format!( + r#"chatgpt_base_url = "{}/backend-api/" + +[features] +plugins = true +remote_plugin = false +plugin_sharing = true +"#, + 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, + )?; + + let mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![PluginListMarketplaceKind::CreatedByMeRemote]), + }) + .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, + PluginListResponse { + marketplaces: Vec::new(), + marketplace_load_errors: Vec::new(), + featured_plugin_ids: Vec::new(), + } + ); + wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?; + Ok(()) +} + #[tokio::test] async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { let codex_home = TempDir::new()?; @@ -3362,6 +3657,10 @@ async fn mount_remote_installed_plugins(server: &MockServer, scope: &str, body: .await; } +async fn mount_empty_user_installed_plugins(server: &MockServer) { + mount_remote_installed_plugins(server, "USER", empty_remote_installed_plugins_body()).await; +} + fn empty_remote_installed_plugins_body() -> &'static str { r#"{ "plugins": [], @@ -3428,6 +3727,23 @@ fn workspace_remote_plugin_page_body( ) } +fn user_remote_plugin_page_body( + remote_plugin_id: &str, + plugin_name: &str, + display_name: &str, + discoverability: &str, + enabled: Option, +) -> String { + workspace_remote_plugin_page_body( + remote_plugin_id, + plugin_name, + display_name, + discoverability, + enabled, + ) + .replacen(r#""scope": "WORKSPACE""#, r#""scope": "USER""#, 1) +} + fn remote_installed_plugin_body( bundle_download_url: &str, release_version: &str, diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 147d40c47..491c06085 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -268,6 +268,7 @@ async fn skills_list_loads_remote_installed_plugin_skills_from_cache() -> Result for (scope, body) in [ ("GLOBAL", global_installed_body), + ("USER", empty_page_body), ("WORKSPACE", empty_page_body), ] { Mock::given(method("GET")) diff --git a/codex-rs/core-plugins/src/discoverable_tests.rs b/codex-rs/core-plugins/src/discoverable_tests.rs index 4fa03d94b..26f6a5653 100644 --- a/codex-rs/core-plugins/src/discoverable_tests.rs +++ b/codex-rs/core-plugins/src/discoverable_tests.rs @@ -628,7 +628,7 @@ remote_plugin = true .await .expect("remote plugin catalog cache should write"); - for scope in ["GLOBAL", "WORKSPACE"] { + for scope in ["GLOBAL", "USER", "WORKSPACE"] { Mock::given(method("GET")) .and(path("/backend-api/ps/plugins/installed")) .and(query_param("scope", scope)) diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index a17dc2586..a2286199e 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -51,6 +51,7 @@ pub use share::save_remote_plugin_share; pub use share::update_remote_plugin_share_targets; pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "openai-curated-remote"; +pub const REMOTE_CREATED_BY_ME_MARKETPLACE_NAME: &str = "created-by-me-remote"; pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "workspace-directory"; pub const REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME: &str = "workspace-shared-with-me"; pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME: &str = @@ -58,6 +59,7 @@ pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME: &str = pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME: &str = "workspace-shared-with-me-unlisted"; pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "OpenAI Curated Remote"; +pub const REMOTE_CREATED_BY_ME_MARKETPLACE_DISPLAY_NAME: &str = "Created by me"; pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "Workspace Directory"; 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 = @@ -71,11 +73,15 @@ const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200; const MAX_REMOTE_DEFAULT_PROMPT_COUNT: usize = 3; const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128; const INVALID_REQUEST_ERROR_CODE: i64 = -32600; -const REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER: [(&str, &str); 5] = [ +const REMOTE_INSTALLED_MARKETPLACE_DISPLAY_ORDER: [(&str, &str); 6] = [ ( REMOTE_GLOBAL_MARKETPLACE_NAME, REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, ), + ( + REMOTE_CREATED_BY_ME_MARKETPLACE_NAME, + REMOTE_CREATED_BY_ME_MARKETPLACE_DISPLAY_NAME, + ), ( REMOTE_WORKSPACE_MARKETPLACE_NAME, REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME, @@ -109,6 +115,7 @@ pub struct RemoteMarketplace { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RemoteMarketplaceSource { Global, + CreatedByMeRemote, WorkspaceDirectory, SharedWithMe, } @@ -338,6 +345,8 @@ pub enum RemotePluginCatalogError { pub enum RemotePluginScope { #[serde(rename = "GLOBAL")] Global, + #[serde(rename = "USER")] + User, #[serde(rename = "WORKSPACE")] Workspace, } @@ -346,6 +355,7 @@ impl RemotePluginScope { fn api_value(self) -> &'static str { match self { Self::Global => "GLOBAL", + Self::User => "USER", Self::Workspace => "WORKSPACE", } } @@ -353,6 +363,7 @@ impl RemotePluginScope { fn marketplace_name(self) -> &'static str { match self { Self::Global => REMOTE_GLOBAL_MARKETPLACE_NAME, + Self::User => REMOTE_CREATED_BY_ME_MARKETPLACE_NAME, Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_NAME, } } @@ -360,6 +371,7 @@ impl RemotePluginScope { fn marketplace_display_name(self) -> &'static str { match self { Self::Global => REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, + Self::User => REMOTE_CREATED_BY_ME_MARKETPLACE_DISPLAY_NAME, Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME, } } @@ -367,6 +379,7 @@ impl RemotePluginScope { fn from_marketplace_name(name: &str) -> Option { match name { REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global), + REMOTE_CREATED_BY_ME_MARKETPLACE_NAME => Some(Self::User), REMOTE_WORKSPACE_MARKETPLACE_NAME | REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME | REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME @@ -500,6 +513,7 @@ fn remote_plugin_canonical_marketplace_name( ) -> Result<&'static str, RemotePluginCatalogError> { match plugin.scope { RemotePluginScope::Global => Ok(REMOTE_GLOBAL_MARKETPLACE_NAME), + RemotePluginScope::User => Ok(REMOTE_CREATED_BY_ME_MARKETPLACE_NAME), RemotePluginScope::Workspace => match workspace_plugin_discoverability(plugin)? { RemotePluginShareDiscoverability::Listed => Ok(REMOTE_WORKSPACE_MARKETPLACE_NAME), RemotePluginShareDiscoverability::Private @@ -631,6 +645,22 @@ pub async fn fetch_remote_marketplaces( ); } } + RemoteMarketplaceSource::CreatedByMeRemote => { + let scope = RemotePluginScope::User; + let (directory_plugins, installed_plugins) = tokio::try_join!( + fetch_directory_plugins_for_scope(config, auth, scope), + fetch_installed_plugins_for_scope(config, auth, scope), + )?; + if let Some(marketplace) = build_remote_marketplace( + scope.marketplace_name(), + scope.marketplace_display_name(), + directory_plugins, + installed_plugins, + /*include_installed_only*/ false, + )? { + marketplaces.push(marketplace); + } + } RemoteMarketplaceSource::WorkspaceDirectory => { let scope = RemotePluginScope::Workspace; let directory_plugins = @@ -838,9 +868,14 @@ pub(crate) async fn fetch_remote_installed_plugins( let installed_plugins = fetch_installed_plugins_for_scope(config, auth, scope).await?; Ok::<_, RemotePluginCatalogError>((scope, installed_plugins)) }; + let user = async { + let scope = RemotePluginScope::User; + let installed_plugins = fetch_installed_plugins_for_scope(config, auth, scope).await?; + Ok::<_, RemotePluginCatalogError>((scope, installed_plugins)) + }; - let (global, workspace) = tokio::try_join!(global, workspace)?; - let mut installed_plugins = [global, workspace] + let (global, workspace, user) = tokio::try_join!(global, workspace, user)?; + let mut installed_plugins = [global, workspace, user] .into_iter() .flat_map(|(_scope, plugins)| plugins) .map(|plugin| remote_installed_plugin_to_cache_entry(&plugin)) @@ -1266,7 +1301,7 @@ fn remote_plugin_share_context( plugin: &RemotePluginDirectoryItem, ) -> Result, RemotePluginCatalogError> { match plugin.scope { - RemotePluginScope::Global => Ok(None), + RemotePluginScope::Global | RemotePluginScope::User => Ok(None), RemotePluginScope::Workspace => { let discoverability = workspace_plugin_discoverability(plugin)?; Ok(Some(RemotePluginShareContext { 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 fe3a597b0..8041d444b 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,3 +1,4 @@ +use super::REMOTE_CREATED_BY_ME_MARKETPLACE_NAME; use super::REMOTE_GLOBAL_MARKETPLACE_NAME; use super::REMOTE_WORKSPACE_MARKETPLACE_NAME; use super::REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME; @@ -144,12 +145,24 @@ pub async fn sync_remote_installed_plugin_bundles_once( .await?; Ok::<_, RemotePluginCatalogError>((scope, installed_plugins)) }; + let user = async { + let scope = RemotePluginScope::User; + let installed_plugins = fetch_installed_plugins_for_scope_with_download_url( + config, auth, scope, /*include_download_urls*/ true, + ) + .await?; + Ok::<_, RemotePluginCatalogError>((scope, installed_plugins)) + }; - let (global, workspace) = tokio::try_join!(global, workspace)?; + let (global, workspace, user) = tokio::try_join!(global, workspace, user)?; let store = PluginStore::try_new(codex_home.clone())?; let mut installed_plugin_names_by_marketplace = BTreeMap::>::from_iter([ (REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), BTreeSet::new()), + ( + REMOTE_CREATED_BY_ME_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), ( REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(), BTreeSet::new(), @@ -170,7 +183,7 @@ pub async fn sync_remote_installed_plugin_bundles_once( let mut installed_plugin_ids = BTreeSet::new(); let mut failed_remote_plugin_ids = BTreeSet::new(); - for (_scope, installed_plugins) in [global, workspace] { + for (_scope, installed_plugins) in [global, workspace, user] { for installed_plugin in installed_plugins { let plugin = installed_plugin.plugin; let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string(); @@ -308,6 +321,7 @@ fn remove_stale_remote_plugin_caches( let mut removed_cache_plugin_ids = Vec::new(); for marketplace_name in [ REMOTE_GLOBAL_MARKETPLACE_NAME, + REMOTE_CREATED_BY_ME_MARKETPLACE_NAME, REMOTE_WORKSPACE_MARKETPLACE_NAME, REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME, REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME, @@ -517,8 +531,27 @@ mod tests { } #[test] - fn stale_remote_plugin_cleanup_removes_old_shared_with_me_cache_and_keeps_canonical_cache() { + fn stale_remote_plugin_cleanup_removes_stale_marketplace_caches_and_keeps_canonical_cache() { let codex_home = tempfile::tempdir().expect("create codex home"); + let created_by_me_cached_manifest = codex_home + .path() + .join(PLUGINS_CACHE_DIR) + .join(REMOTE_CREATED_BY_ME_MARKETPLACE_NAME) + .join("created-by-me-plugin") + .join("1.2.3") + .join(".codex-plugin") + .join("plugin.json"); + std::fs::create_dir_all( + created_by_me_cached_manifest + .parent() + .expect("manifest parent"), + ) + .expect("create cached plugin manifest parent"); + std::fs::write( + &created_by_me_cached_manifest, + r#"{"name":"created-by-me-plugin"}"#, + ) + .expect("write cached plugin manifest"); let cached_manifest = codex_home .path() .join(PLUGINS_CACHE_DIR) @@ -546,6 +579,10 @@ mod tests { let installed_plugin_names_by_marketplace = BTreeMap::>::from_iter([ (REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), BTreeSet::new()), + ( + REMOTE_CREATED_BY_ME_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), ( REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(), BTreeSet::new(), @@ -572,8 +609,12 @@ mod tests { assert_eq!( removed, - vec!["private-plugin@workspace-shared-with-me-private".to_string()] + vec![ + "created-by-me-plugin@created-by-me-remote".to_string(), + "private-plugin@workspace-shared-with-me-private".to_string(), + ] ); + assert!(!created_by_me_cached_manifest.exists()); assert!(!cached_manifest.exists()); assert!(canonical_cached_manifest.is_file()); } diff --git a/codex-rs/core/src/plugins/discoverable_tests.rs b/codex-rs/core/src/plugins/discoverable_tests.rs index a3b47929d..2d92e7096 100644 --- a/codex-rs/core/src/plugins/discoverable_tests.rs +++ b/codex-rs/core/src/plugins/discoverable_tests.rs @@ -244,7 +244,7 @@ remote_plugin = true .all(|plugin| plugin.id != "github@openai-curated-remote") ); - for scope in ["GLOBAL", "WORKSPACE"] { + for scope in ["GLOBAL", "USER", "WORKSPACE"] { Mock::given(method("GET")) .and(path("/backend-api/ps/plugins/installed")) .and(query_param("scope", scope))