diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index c7ec2c15cf..f565130f14 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -212,10 +212,24 @@ 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. 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/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. Pass `forceRefetch: true` for user-initiated refreshes that should bypass app/tool caches and schedule a fresh remote installed-plugin cache load after external app authentication changes. `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. + +Example `plugin/list` refresh request after external app authentication changes: + +```json +{ + "method": "plugin/list", + "id": 41, + "params": { + "marketplaceKinds": ["workspace-directory"], + "forceRefetch": true + } +} +``` + - `skills/changed` — notification emitted when watched local skill files change. - `app/list` — list available apps. - `remoteControl/enable` — experimental; enable remote control for the current app-server process and return the current remote-control status snapshot. By default, any missing enrollment is completed before the response and the preference is persisted for the current app-server client scope. Pass `ephemeral: true` to enable remote control only for the current process without changing the persisted preference. diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index b8bdb168d5..679bd031bb 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -738,11 +738,8 @@ impl PluginRequestProcessor { } } } - if include_local - || include_created_by_me_remote - || include_shared_with_me - || include_global_remote - { + let include_remote_sources = !remote_sources.is_empty(); + if include_local || include_remote_sources { let on_effective_plugins_changed = Some(self.effective_plugins_changed_callback()); plugins_manager.maybe_start_plugin_list_background_tasks_for_config( &plugins_input, 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 5fdbd55a79..55873d5f4a 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -2855,6 +2855,68 @@ async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag Ok(()) } +#[tokio::test] +async fn plugin_list_force_refetch_refreshes_installed_cache_for_workspace_directory() -> Result<()> +{ + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_plugins_enabled_config_with_base_url( + codex_home.path(), + &format!("{}/backend-api/", 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 workspace_plugin_body = workspace_remote_plugin_page_body( + "plugins~Plugin_11111111111111111111111111111111", + "workspace-linear", + "Workspace Linear", + "LISTED", + /*enabled*/ None, + ); + let workspace_installed_body = workspace_remote_plugin_page_body( + "plugins~Plugin_11111111111111111111111111111111", + "workspace-linear", + "Workspace Linear", + "LISTED", + /*enabled*/ Some(true), + ); + mount_remote_plugin_list(&server, "WORKSPACE", &workspace_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??; + + let request_id = mcp + .send_plugin_list_request(PluginListParams { + cwds: None, + marketplace_kinds: Some(vec![PluginListMarketplaceKind::WorkspaceDirectory]), + force_refetch: true, + }) + .await?; + + let response: PluginListResponse = to_response( + timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??, + )?; + assert_eq!(response.marketplaces.len(), 1); + assert_eq!(response.marketplaces[0].name, "workspace-directory"); + + wait_for_remote_installed_scope_request_count_at_least(&server, "WORKSPACE", 2).await?; + Ok(()) +} + #[tokio::test] async fn plugin_list_fetches_user_plugins_in_created_by_me_remote_marketplace() -> Result<()> { let codex_home = TempDir::new()?; @@ -3640,6 +3702,37 @@ async fn wait_for_remote_installed_scope_request(server: &MockServer, scope: &st Ok(()) } +async fn wait_for_remote_installed_scope_request_count_at_least( + server: &MockServer, + scope: &str, + expected_count: usize, +) -> Result<()> { + timeout(DEFAULT_TIMEOUT, async { + loop { + let Some(requests) = server.received_requests().await else { + bail!("wiremock did not record requests"); + }; + let request_count = requests + .iter() + .filter(|request| { + request.method == "GET" + && request.url.path().ends_with("/ps/plugins/installed") + && request + .url + .query_pairs() + .any(|(name, value)| name == "scope" && value == scope) + }) + .count(); + if request_count >= expected_count { + return Ok::<(), anyhow::Error>(()); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await??; + Ok(()) +} + async fn wait_for_cached_remote_catalog_plugin_ids( codex_home: &std::path::Path, expected_plugin_ids: &[&str],