From bb60b78c46c53be0c3a9351f0fc06a95bfea9cab Mon Sep 17 00:00:00 2001 From: xli-oai Date: Thu, 30 Apr 2026 20:00:07 -0700 Subject: [PATCH] Surface admin-disabled remote plugin status (#20298) ## Summary Remote plugin-service returns plugin availability separately from a user's installed/enabled state. This adds `PluginAvailabilityStatus` to the app-server protocol, propagates remote catalog `status` into `PluginSummary`, and rejects install attempts for remote plugins marked `DISABLED_BY_ADMIN` before downloading or caching the bundle. This is the `openai/codex` half of the change. The companion `openai/openai` webview PR is https://github.com/openai/openai/pull/873269. ## Validation - `cargo run -p codex-app-server-protocol --bin write_schema_fixtures` - `cargo test -p codex-app-server --test all plugin_list_marks_remote_plugin_disabled_by_admin` - `cargo test -p codex-app-server --test all plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled` - `cargo test -p codex-app-server --test all plugin_install_rejects_remote_plugin_disabled_by_admin_before_download` - `cargo test -p codex-app-server-protocol schema_fixtures` --- .../codex_app_server_protocol.schemas.json | 26 ++++ .../codex_app_server_protocol.v2.schemas.json | 26 ++++ .../schema/json/v2/PluginListResponse.json | 26 ++++ .../schema/json/v2/PluginReadResponse.json | 26 ++++ .../json/v2/PluginShareListResponse.json | 26 ++++ .../typescript/v2/PluginAvailability.ts | 5 + .../schema/typescript/v2/PluginSummary.ts | 7 +- .../schema/typescript/v2/index.ts | 1 + .../app-server-protocol/src/protocol/v2.rs | 48 ++++++ codex-rs/app-server/README.md | 2 +- .../src/codex_message_processor/plugins.rs | 10 ++ .../tests/suite/v2/plugin_install.rs | 83 +++++++++++ .../app-server/tests/suite/v2/plugin_list.rs | 141 ++++++++++++++++++ .../app-server/tests/suite/v2/plugin_share.rs | 1 + codex-rs/core-plugins/src/remote.rs | 5 + .../core-plugins/src/remote/share/tests.rs | 2 + codex-rs/tui/src/chatwidget/tests/helpers.rs | 2 + 17 files changed, 435 insertions(+), 2 deletions(-) create mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PluginAvailability.ts 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 fdd975c4e..d579dfe06 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 @@ -11932,6 +11932,23 @@ ], "type": "string" }, + "PluginAvailability": { + "oneOf": [ + { + "enum": [ + "DISABLED_BY_ADMIN" + ], + "type": "string" + }, + { + "description": "Plugin-service currently sends `\"ENABLED\"` for available remote plugins. Codex app-server exposes `\"AVAILABLE\"` in its API; the alias keeps decoding compatible with that upstream response.", + "enum": [ + "AVAILABLE" + ], + "type": "string" + } + ] + }, "PluginDetail": { "properties": { "apps": { @@ -12458,6 +12475,15 @@ "authPolicy": { "$ref": "#/definitions/v2/PluginAuthPolicy" }, + "availability": { + "allOf": [ + { + "$ref": "#/definitions/v2/PluginAvailability" + } + ], + "default": "AVAILABLE", + "description": "Availability state for installing and using the plugin." + }, "enabled": { "type": "boolean" }, 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 ab4b8bf65..f9e1f879c 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 @@ -8585,6 +8585,23 @@ ], "type": "string" }, + "PluginAvailability": { + "oneOf": [ + { + "enum": [ + "DISABLED_BY_ADMIN" + ], + "type": "string" + }, + { + "description": "Plugin-service currently sends `\"ENABLED\"` for available remote plugins. Codex app-server exposes `\"AVAILABLE\"` in its API; the alias keeps decoding compatible with that upstream response.", + "enum": [ + "AVAILABLE" + ], + "type": "string" + } + ] + }, "PluginDetail": { "properties": { "apps": { @@ -9111,6 +9128,15 @@ "authPolicy": { "$ref": "#/definitions/PluginAuthPolicy" }, + "availability": { + "allOf": [ + { + "$ref": "#/definitions/PluginAvailability" + } + ], + "default": "AVAILABLE", + "description": "Availability state for installing and using the plugin." + }, "enabled": { "type": "boolean" }, 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 72c941c45..dc383608f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -38,6 +38,23 @@ ], "type": "string" }, + "PluginAvailability": { + "oneOf": [ + { + "enum": [ + "DISABLED_BY_ADMIN" + ], + "type": "string" + }, + { + "description": "Plugin-service currently sends `\"ENABLED\"` for available remote plugins. Codex app-server exposes `\"AVAILABLE\"` in its API; the alias keeps decoding compatible with that upstream response.", + "enum": [ + "AVAILABLE" + ], + "type": "string" + } + ] + }, "PluginInstallPolicy": { "enum": [ "NOT_AVAILABLE", @@ -299,6 +316,15 @@ "authPolicy": { "$ref": "#/definitions/PluginAuthPolicy" }, + "availability": { + "allOf": [ + { + "$ref": "#/definitions/PluginAvailability" + } + ], + "default": "AVAILABLE", + "description": "Availability state for installing and using the plugin." + }, "enabled": { "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index 6e04ef74b..2762807c7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -44,6 +44,23 @@ ], "type": "string" }, + "PluginAvailability": { + "oneOf": [ + { + "enum": [ + "DISABLED_BY_ADMIN" + ], + "type": "string" + }, + { + "description": "Plugin-service currently sends `\"ENABLED\"` for available remote plugins. Codex app-server exposes `\"AVAILABLE\"` in its API; the alias keeps decoding compatible with that upstream response.", + "enum": [ + "AVAILABLE" + ], + "type": "string" + } + ] + }, "PluginDetail": { "properties": { "apps": { @@ -318,6 +335,15 @@ "authPolicy": { "$ref": "#/definitions/PluginAuthPolicy" }, + "availability": { + "allOf": [ + { + "$ref": "#/definitions/PluginAvailability" + } + ], + "default": "AVAILABLE", + "description": "Availability state for installing and using the plugin." + }, "enabled": { "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json index eb33b3ce1..6753db3d2 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json @@ -12,6 +12,23 @@ ], "type": "string" }, + "PluginAvailability": { + "oneOf": [ + { + "enum": [ + "DISABLED_BY_ADMIN" + ], + "type": "string" + }, + { + "description": "Plugin-service currently sends `\"ENABLED\"` for available remote plugins. Codex app-server exposes `\"AVAILABLE\"` in its API; the alias keeps decoding compatible with that upstream response.", + "enum": [ + "AVAILABLE" + ], + "type": "string" + } + ] + }, "PluginInstallPolicy": { "enum": [ "NOT_AVAILABLE", @@ -234,6 +251,15 @@ "authPolicy": { "$ref": "#/definitions/PluginAuthPolicy" }, + "availability": { + "allOf": [ + { + "$ref": "#/definitions/PluginAvailability" + } + ], + "default": "AVAILABLE", + "description": "Availability state for installing and using the plugin." + }, "enabled": { "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginAvailability.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginAvailability.ts new file mode 100644 index 000000000..bec0b88cc --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginAvailability.ts @@ -0,0 +1,5 @@ +// 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. + +export type PluginAvailability = "AVAILABLE" | "DISABLED_BY_ADMIN"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts index 1eb443c59..fe9e63703 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts @@ -2,8 +2,13 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { PluginAuthPolicy } from "./PluginAuthPolicy"; +import type { PluginAvailability } from "./PluginAvailability"; import type { PluginInstallPolicy } from "./PluginInstallPolicy"; import type { PluginInterface } from "./PluginInterface"; import type { PluginSource } from "./PluginSource"; -export type PluginSummary = { id: string, name: string, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, authPolicy: PluginAuthPolicy, interface: PluginInterface | null, }; +export type PluginSummary = { id: string, name: string, source: PluginSource, installed: boolean, enabled: boolean, installPolicy: PluginInstallPolicy, authPolicy: PluginAuthPolicy, +/** + * Availability state for installing and using the plugin. + */ +availability: PluginAvailability, interface: PluginInterface | null, }; 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 b19a3e0d1..893cc202e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -270,6 +270,7 @@ export type { PermissionsRequestApprovalParams } from "./PermissionsRequestAppro export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; export type { PluginAuthPolicy } from "./PluginAuthPolicy"; +export type { PluginAvailability } from "./PluginAvailability"; export type { PluginDetail } from "./PluginDetail"; export type { PluginInstallParams } from "./PluginInstallParams"; export type { PluginInstallPolicy } from "./PluginInstallPolicy"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 9114e64f8..960f3e968 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -4827,6 +4827,21 @@ pub enum PluginAuthPolicy { OnUse, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Default, JsonSchema, TS)] +#[ts(export_to = "v2/")] +pub enum PluginAvailability { + /// Plugin-service currently sends `"ENABLED"` for available remote plugins. + /// Codex app-server exposes `"AVAILABLE"` in its API; the alias keeps + /// decoding compatible with that upstream response. + #[serde(rename = "AVAILABLE", alias = "ENABLED")] + #[ts(rename = "AVAILABLE")] + #[default] + Available, + #[serde(rename = "DISABLED_BY_ADMIN")] + #[ts(rename = "DISABLED_BY_ADMIN")] + DisabledByAdmin, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -4838,6 +4853,9 @@ pub struct PluginSummary { pub enabled: bool, pub install_policy: PluginInstallPolicy, pub auth_policy: PluginAuthPolicy, + /// Availability state for installing and using the plugin. + #[serde(default)] + pub availability: PluginAvailability, pub interface: Option, } @@ -10725,6 +10743,7 @@ mod tests { enabled: false, install_policy: PluginInstallPolicy::Available, auth_policy: PluginAuthPolicy::OnUse, + availability: PluginAvailability::Available, interface: None, }], }) @@ -10738,12 +10757,41 @@ mod tests { "enabled": false, "installPolicy": "AVAILABLE", "authPolicy": "ON_USE", + "availability": "AVAILABLE", "interface": null, }], }), ); } + #[test] + fn plugin_summary_defaults_missing_availability_to_available() { + let summary: PluginSummary = serde_json::from_value(json!({ + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "gmail", + "source": { "type": "remote" }, + "installed": false, + "enabled": false, + "installPolicy": "AVAILABLE", + "authPolicy": "ON_USE", + "interface": null, + })) + .unwrap(); + + assert_eq!(summary.availability, PluginAvailability::Available); + } + + #[test] + fn plugin_availability_deserializes_enabled_alias() { + let availability: PluginAvailability = serde_json::from_value(json!("ENABLED")).unwrap(); + + assert_eq!(availability, PluginAvailability::Available); + assert_eq!( + serde_json::to_value(availability).unwrap(), + json!("AVAILABLE") + ); + } + #[test] fn plugin_uninstall_params_serialization_omits_force_remote_sync() { assert_eq!( diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a8ff0c47f..093a4640b 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -201,7 +201,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, 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. `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/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/plugins.rs b/codex-rs/app-server/src/codex_message_processor/plugins.rs index 2f26c1a95..3101091b0 100644 --- a/codex-rs/app-server/src/codex_message_processor/plugins.rs +++ b/codex-rs/app-server/src/codex_message_processor/plugins.rs @@ -1,6 +1,7 @@ use super::*; use crate::error_code::internal_error; use crate::error_code::invalid_request; +use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginInstallPolicy; impl CodexMessageProcessor { @@ -77,6 +78,7 @@ impl CodexMessageProcessor { source: marketplace_plugin_source_to_info(plugin.source), install_policy: plugin.policy.installation.into(), auth_policy: plugin.policy.authentication.into(), + availability: PluginAvailability::Available, interface: plugin.interface.map(local_plugin_interface_to_info), }) .collect(), @@ -243,6 +245,7 @@ impl CodexMessageProcessor { enabled: outcome.plugin.enabled, install_policy: outcome.plugin.policy.installation.into(), auth_policy: outcome.plugin.policy.authentication.into(), + availability: PluginAvailability::Available, interface: outcome.plugin.interface.map(local_plugin_interface_to_info), }, description: outcome.plugin.description, @@ -537,6 +540,12 @@ impl CodexMessageProcessor { "read remote plugin details before install", ) })?; + if remote_detail.summary.availability == PluginAvailability::DisabledByAdmin { + let remote_plugin_id = &remote_detail.summary.id; + return Err(invalid_request(format!( + "remote plugin {remote_plugin_id} is disabled by admin" + ))); + } if remote_detail.summary.install_policy == PluginInstallPolicy::NotAvailable { return Err(invalid_request(format!( "remote plugin {remote_plugin_id} is not available for install" @@ -859,6 +868,7 @@ fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginS enabled: summary.enabled, install_policy: summary.install_policy, auth_policy: summary.auth_policy, + availability: summary.availability, interface: summary.interface, } } diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 84e68306d..2b2f78136 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -23,6 +23,7 @@ use codex_app_server_protocol::AppInfo; use codex_app_server_protocol::AppSummary; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::RequestId; @@ -407,6 +408,66 @@ async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_install_rejects_remote_plugin_disabled_by_admin_before_download() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + let bundle_url = mount_remote_plugin_bundle( + &server, + /*status_code*/ 200, + remote_plugin_bundle_tar_gz_bytes("linear")?, + ) + .await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail_with_status( + &server, + REMOTE_PLUGIN_ID, + "1.2.3", + Some(&bundle_url), + PluginAvailability::DisabledByAdmin, + ) + .await; + mount_empty_remote_installed_plugins(&server).await; + + let mut mcp = McpProcess::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 = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32600); + assert!(err.error.message.contains("disabled by admin")); + wait_for_remote_plugin_request_count( + &server, + "GET", + "/bundles/linear.tar.gz", + /*expected_count*/ 0, + ) + .await?; + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 0, + ) + .await?; + assert!( + !codex_home + .path() + .join("plugins/cache/chatgpt-global/linear") + .exists() + ); + Ok(()) +} + #[tokio::test] async fn plugin_install_rejects_when_workspace_codex_plugins_disabled() -> Result<()> { let codex_home = TempDir::new()?; @@ -1272,6 +1333,27 @@ async fn mount_remote_plugin_detail( release_version: &str, bundle_download_url: Option<&str>, ) { + mount_remote_plugin_detail_with_status( + server, + remote_plugin_id, + release_version, + bundle_download_url, + PluginAvailability::Available, + ) + .await; +} + +async fn mount_remote_plugin_detail_with_status( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + bundle_download_url: Option<&str>, + status: PluginAvailability, +) { + let status = match status { + PluginAvailability::Available => "ENABLED", + PluginAvailability::DisabledByAdmin => "DISABLED_BY_ADMIN", + }; let bundle_download_url_field = bundle_download_url .map(|url| format!(r#" "bundle_download_url": "{url}","#)) .unwrap_or_default(); @@ -1282,6 +1364,7 @@ async fn mount_remote_plugin_detail( "scope": "GLOBAL", "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", + "status": "{status}", "release": {{ "version": "{release_version}", {bundle_download_url_field} 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 4772e9fb5..86fb78bae 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -244,6 +244,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ enabled: false, install_policy: PluginInstallPolicy::Available, auth_policy: PluginAuthPolicy::OnInstall, + availability: codex_app_server_protocol::PluginAvailability::Available, interface: None, }], }] @@ -527,6 +528,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab enabled: false, install_policy: PluginInstallPolicy::Available, auth_policy: PluginAuthPolicy::OnInstall, + availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(codex_app_server_protocol::PluginInterface { display_name: Some("Valid Plugin".to_string()), short_description: None, @@ -559,6 +561,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab enabled: false, install_policy: PluginInstallPolicy::Available, auth_policy: PluginAuthPolicy::OnInstall, + availability: codex_app_server_protocol::PluginAvailability::Available, interface: None, }, ], @@ -1287,6 +1290,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - "scope": "GLOBAL", "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", + "status": "ENABLED", "release": { "display_name": "Linear", "description": "Track work in Linear", @@ -1321,6 +1325,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - "scope": "GLOBAL", "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", + "status": "ENABLED", "release": { "display_name": "Linear", "description": "Track work in Linear", @@ -1414,6 +1419,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - assert_eq!(remote_marketplace.plugins[0].source, PluginSource::Remote); assert_eq!(remote_marketplace.plugins[0].installed, true); assert_eq!(remote_marketplace.plugins[0].enabled, true); + assert_eq!( + remote_marketplace.plugins[0].availability, + codex_app_server_protocol::PluginAvailability::Available + ); assert_eq!( remote_marketplace.plugins[0] .interface @@ -1425,6 +1434,138 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - Ok(()) } +#[tokio::test] +async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + write_remote_plugin_catalog_config( + 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 global_directory_body = r#"{ + "plugins": [ + { + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "DISABLED_BY_ADMIN", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": {}, + "skills": [] + } + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + let global_installed_body = r#"{ + "plugins": [ + { + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "status": "DISABLED_BY_ADMIN", + "release": { + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": {}, + "skills": [] + }, + "enabled": true, + "disabled_skill_names": [] + } + ], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + let empty_page_body = r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#; + + for (scope, body) in [ + ("GLOBAL", global_directory_body), + ("WORKSPACE", empty_page_body), + ] { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/list")) + .and(query_param("scope", scope)) + .and(query_param("limit", "200")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(&server) + .await; + } + for (scope, body) in [ + ("GLOBAL", global_installed_body), + ("WORKSPACE", empty_page_body), + ] { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", scope)) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(body)) + .mount(&server) + .await; + } + + 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: 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 remote_marketplace = response + .marketplaces + .into_iter() + .find(|marketplace| marketplace.name == "chatgpt-global") + .expect("expected ChatGPT remote marketplace"); + let plugin = remote_marketplace + .plugins + .first() + .expect("expected remote plugin"); + assert_eq!(plugin.installed, true); + assert_eq!(plugin.enabled, true); + assert_eq!( + plugin.availability, + codex_app_server_protocol::PluginAvailability::DisabledByAdmin + ); + Ok(()) +} + #[tokio::test] async fn plugin_list_remote_marketplace_replaces_local_marketplace_with_same_name() -> Result<()> { let codex_home = TempDir::new()?; 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 e783f58fa..62e2ee18e 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -177,6 +177,7 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { enabled: true, install_policy: PluginInstallPolicy::Available, auth_policy: PluginAuthPolicy::OnUse, + availability: codex_app_server_protocol::PluginAvailability::Available, interface: Some(expected_plugin_interface()), }], } diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 8fc720e1b..459ada6bd 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -1,6 +1,7 @@ use crate::store::PLUGINS_CACHE_DIR; use crate::store::PluginStore; use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginAvailability; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInterface; use codex_app_server_protocol::SkillInterface; @@ -67,6 +68,7 @@ pub struct RemotePluginSummary { pub enabled: bool, pub install_policy: PluginInstallPolicy, pub auth_policy: PluginAuthPolicy, + pub availability: PluginAvailability, pub interface: Option, } @@ -265,6 +267,8 @@ struct RemotePluginDirectoryItem { scope: RemotePluginScope, installation_policy: PluginInstallPolicy, authentication_policy: PluginAuthPolicy, + #[serde(rename = "status", default)] + availability: PluginAvailability, release: RemotePluginReleaseResponse, } @@ -661,6 +665,7 @@ fn build_remote_plugin_summary( enabled: installed_plugin.is_some_and(|plugin| plugin.enabled), install_policy: plugin.installation_policy, auth_policy: plugin.authentication_policy, + availability: plugin.availability, interface: remote_plugin_interface_to_info(plugin), } } diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index 0b3d31630..afed08e0a 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -381,6 +381,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { enabled: false, install_policy: PluginInstallPolicy::Available, auth_policy: PluginAuthPolicy::OnUse, + availability: PluginAvailability::Available, interface: Some(expected_plugin_interface()), }, RemotePluginSummary { @@ -390,6 +391,7 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { enabled: true, install_policy: PluginInstallPolicy::Available, auth_policy: PluginAuthPolicy::OnUse, + availability: PluginAvailability::Available, interface: Some(expected_plugin_interface()), } ] diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index bb17359e7..6920689ef 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1,4 +1,5 @@ use super::*; +use codex_app_server_protocol::PluginAvailability; use pretty_assertions::assert_eq; pub(super) async fn test_config() -> Config { @@ -1422,6 +1423,7 @@ pub(super) fn plugins_test_summary( enabled, install_policy, auth_policy: PluginAuthPolicy::OnInstall, + availability: PluginAvailability::Available, interface: Some(plugins_test_interface( display_name, description,