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 058fb96814..8d6146328a 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 @@ -14137,6 +14137,15 @@ ], "type": "object" }, + "PluginDisabledReason": { + "enum": [ + "disabled_by_admin", + "plan_not_eligible", + "required_app_unavailable", + "unknown" + ], + "type": "string" + }, "PluginHookSummary": { "properties": { "eventName": { @@ -15089,6 +15098,29 @@ "default": "AVAILABLE", "description": "Availability state for installing and using the plugin." }, + "disabledReason": { + "anyOf": [ + { + "$ref": "#/definitions/v2/PluginDisabledReason" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Why the remote plugin is unavailable, when provided by plugin-service." + }, + "eligiblePlanTypes": { + "default": null, + "description": "Raw plugin-service plan identifiers eligible to install the plugin.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "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 2a5e2bf6cc..f1cfaa183b 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 @@ -10479,6 +10479,15 @@ ], "type": "object" }, + "PluginDisabledReason": { + "enum": [ + "disabled_by_admin", + "plan_not_eligible", + "required_app_unavailable", + "unknown" + ], + "type": "string" + }, "PluginHookSummary": { "properties": { "eventName": { @@ -11431,6 +11440,29 @@ "default": "AVAILABLE", "description": "Availability state for installing and using the plugin." }, + "disabledReason": { + "anyOf": [ + { + "$ref": "#/definitions/PluginDisabledReason" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Why the remote plugin is unavailable, when provided by plugin-service." + }, + "eligiblePlanTypes": { + "default": null, + "description": "Raw plugin-service plan identifiers eligible to install the plugin.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "enabled": { "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json index 550ae0990b..345995f382 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginInstalledResponse.json @@ -55,6 +55,15 @@ } ] }, + "PluginDisabledReason": { + "enum": [ + "disabled_by_admin", + "plan_not_eligible", + "required_app_unavailable", + "unknown" + ], + "type": "string" + }, "PluginInstallPolicy": { "enum": [ "NOT_AVAILABLE", @@ -494,6 +503,29 @@ "default": "AVAILABLE", "description": "Availability state for installing and using the plugin." }, + "disabledReason": { + "anyOf": [ + { + "$ref": "#/definitions/PluginDisabledReason" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Why the remote plugin is unavailable, when provided by plugin-service." + }, + "eligiblePlanTypes": { + "default": null, + "description": "Raw plugin-service plan identifiers eligible to install the plugin.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "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 c2cd52d80c..17e7c8aa45 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -55,6 +55,15 @@ } ] }, + "PluginDisabledReason": { + "enum": [ + "disabled_by_admin", + "plan_not_eligible", + "required_app_unavailable", + "unknown" + ], + "type": "string" + }, "PluginInstallPolicy": { "enum": [ "NOT_AVAILABLE", @@ -494,6 +503,29 @@ "default": "AVAILABLE", "description": "Availability state for installing and using the plugin." }, + "disabledReason": { + "anyOf": [ + { + "$ref": "#/definitions/PluginDisabledReason" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Why the remote plugin is unavailable, when provided by plugin-service." + }, + "eligiblePlanTypes": { + "default": null, + "description": "Raw plugin-service plan identifiers eligible to install the plugin.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "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 67c6a6b181..83e2a1ec2b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -229,6 +229,15 @@ ], "type": "object" }, + "PluginDisabledReason": { + "enum": [ + "disabled_by_admin", + "plan_not_eligible", + "required_app_unavailable", + "unknown" + ], + "type": "string" + }, "PluginHookSummary": { "properties": { "eventName": { @@ -644,6 +653,29 @@ "default": "AVAILABLE", "description": "Availability state for installing and using the plugin." }, + "disabledReason": { + "anyOf": [ + { + "$ref": "#/definitions/PluginDisabledReason" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Why the remote plugin is unavailable, when provided by plugin-service." + }, + "eligiblePlanTypes": { + "default": null, + "description": "Raw plugin-service plan identifiers eligible to install the plugin.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "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 3e43d6ff32..621d02e9bd 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json @@ -29,6 +29,15 @@ } ] }, + "PluginDisabledReason": { + "enum": [ + "disabled_by_admin", + "plan_not_eligible", + "required_app_unavailable", + "unknown" + ], + "type": "string" + }, "PluginInstallPolicy": { "enum": [ "NOT_AVAILABLE", @@ -450,6 +459,29 @@ "default": "AVAILABLE", "description": "Availability state for installing and using the plugin." }, + "disabledReason": { + "anyOf": [ + { + "$ref": "#/definitions/PluginDisabledReason" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Why the remote plugin is unavailable, when provided by plugin-service." + }, + "eligiblePlanTypes": { + "default": null, + "description": "Raw plugin-service plan identifiers eligible to install the plugin.", + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, "enabled": { "type": "boolean" }, diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginDisabledReason.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDisabledReason.ts new file mode 100644 index 0000000000..01799dde1f --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginDisabledReason.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 PluginDisabledReason = "disabled_by_admin" | "plan_not_eligible" | "required_app_unavailable" | "unknown"; 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 b02eba161b..db261af26f 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts @@ -3,6 +3,7 @@ // 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 { PluginDisabledReason } from "./PluginDisabledReason"; import type { PluginInstallPolicy } from "./PluginInstallPolicy"; import type { PluginInstallPolicySource } from "./PluginInstallPolicySource"; import type { PluginInterface } from "./PluginInterface"; @@ -29,4 +30,12 @@ shareContext: PluginShareContext | null, source: PluginSource, installed: boolea /** * Availability state for installing and using the plugin. */ -availability: PluginAvailability, interface: PluginInterface | null, keywords: Array, }; +availability: PluginAvailability, +/** + * Why the remote plugin is unavailable, when provided by plugin-service. + */ +disabledReason: PluginDisabledReason | null, +/** + * Raw plugin-service plan identifiers eligible to install the plugin. + */ +eligiblePlanTypes: Array | null, interface: PluginInterface | null, keywords: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index edbda8a022..5ee103a587 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -306,6 +306,7 @@ export type { PlanDeltaNotification } from "./PlanDeltaNotification"; export type { PluginAuthPolicy } from "./PluginAuthPolicy"; export type { PluginAvailability } from "./PluginAvailability"; export type { PluginDetail } from "./PluginDetail"; +export type { PluginDisabledReason } from "./PluginDisabledReason"; export type { PluginHookSummary } from "./PluginHookSummary"; export type { PluginInstallParams } from "./PluginInstallParams"; export type { PluginInstallPolicy } from "./PluginInstallPolicy"; 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 27cd6b0786..126e50ed59 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -619,6 +619,17 @@ pub enum PluginAvailability { DisabledByAdmin, } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(export_to = "v2/", rename_all = "snake_case")] +pub enum PluginDisabledReason { + DisabledByAdmin, + PlanNotEligible, + RequiredAppUnavailable, + #[serde(other)] + Unknown, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -646,6 +657,12 @@ pub struct PluginSummary { /// Availability state for installing and using the plugin. #[serde(default)] pub availability: PluginAvailability, + /// Why the remote plugin is unavailable, when provided by plugin-service. + #[serde(default)] + pub disabled_reason: Option, + /// Raw plugin-service plan identifiers eligible to install the plugin. + #[serde(default)] + pub eligible_plan_types: Option>, pub interface: Option, #[serde(default)] pub keywords: Vec, 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 3c8e13369d..c1dd3ffdca 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -3964,6 +3964,8 @@ fn plugin_share_list_response_serializes_share_items() { must_show_installation_interstitial: None, auth_policy: PluginAuthPolicy::OnUse, availability: PluginAvailability::Available, + disabled_reason: None, + eligible_plan_types: None, interface: None, keywords: Vec::new(), }, @@ -3988,6 +3990,8 @@ fn plugin_share_list_response_serializes_share_items() { "mustShowInstallationInterstitial": null, "authPolicy": "ON_USE", "availability": "AVAILABLE", + "disabledReason": null, + "eligiblePlanTypes": null, "interface": null, "keywords": [], }, @@ -4015,6 +4019,43 @@ fn plugin_summary_defaults_missing_availability_to_available() { assert_eq!(summary.local_version, None); assert_eq!(summary.share_context, None); assert_eq!(summary.must_show_installation_interstitial, None); + assert_eq!(summary.disabled_reason, None); + assert_eq!(summary.eligible_plan_types, None); +} + +#[test] +fn plugin_summary_round_trips_plan_eligibility_metadata() { + let value = json!({ + "id": "gmail@openai-curated-remote", + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", + "version": null, + "localVersion": null, + "name": "gmail", + "shareContext": null, + "source": { "type": "remote" }, + "installed": false, + "enabled": false, + "installPolicy": "NOT_AVAILABLE", + "installPolicySource": null, + "mustShowInstallationInterstitial": null, + "authPolicy": "ON_USE", + "availability": "DISABLED_BY_ADMIN", + "disabledReason": "plan_not_eligible", + "eligiblePlanTypes": ["plus", "pro", "enterprise_cbp_automation"], + "interface": null, + "keywords": [], + }); + let summary: PluginSummary = + serde_json::from_value(value.clone()).expect("plan metadata should deserialize"); + + assert_eq!( + summary.disabled_reason, + Some(PluginDisabledReason::PlanNotEligible) + ); + assert_eq!( + serde_json::to_value(summary).expect("plan metadata should serialize"), + value + ); } #[test] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index a79aff37b1..9a0b22ca2c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -219,7 +219,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, nullable remote install-policy provenance in `installPolicySource` (`WORKSPACE_SETTING` or `IMPLICIT_CANONICAL_APP`), the remote marketplace `version` and locally materialized `localVersion` when available, 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. Every `PluginSummary` returned by plugin list, installed, read, and share-list methods includes `mustShowInstallationInterstitial`: remote service values preserve `true` or `false`, while local plugins and remote responses that omit the policy return `null`. Clients should fail closed when the value is `null`. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. Set `forceRefetch: true` to bypass TTL-backed remote catalog caches for the requested marketplaces and wait for fresh data; cache entries are replaced only after a successful fetch. When local marketplaces are included, the request also waits for configured plugin caches to reconcile before marketplace summaries are returned. At app-server startup, existing cached catalogs remain available to `plugin/list` while they refresh in the background. `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, nullable remote install-policy provenance in `installPolicySource` (`WORKSPACE_SETTING` or `IMPLICIT_CANONICAL_APP`), the remote marketplace `version` and locally materialized `localVersion` when available, 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. Every `PluginSummary` returned by plugin list, installed, read, and share-list methods includes nullable `disabledReason` and `eligiblePlanTypes`, preserving plugin-service availability metadata and raw plan identifiers for remote plugins while returning `null` for local plugins or older remote responses. The same summaries include `mustShowInstallationInterstitial`: remote service values preserve `true` or `false`, while local plugins and remote responses that omit the policy return `null`. Clients should fail closed when the value is `null`. Clients can explicitly request the remote `workspace-directory`, `shared-with-me`, or `created-by-me-remote` marketplace kinds. Set `forceRefetch: true` to bypass TTL-backed remote catalog caches for the requested marketplaces and wait for fresh data; cache entries are replaced only after a successful fetch. When local marketplaces are included, the request also waits for configured plugin caches to reconcile before marketplace summaries are returned. At app-server startup, existing cached catalogs remain available to `plugin/list` while they refresh in the background. `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. Remote rows include nullable `installPolicySource`; local rows return `null`. 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 can include scheduled task summaries from the catalog; `scheduledTasks: null` means the metadata is unavailable, while an empty array means the catalog found no scheduled tasks. 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. For owned workspace plugins, `summary.shareContext.canPublishToWorkspace` reports whether the current user may add the plugin to the workspace directory; `plugin/share/save` returns the same capability after creating or updating a share, and clients should fail closed when either value is `null`. Remote skill interfaces expose `iconSmallUrl` and `iconLargeUrl` when the catalog supplies icon URLs. 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 1c47cba748..4e2cf9de28 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -186,6 +186,8 @@ fn convert_configured_marketplace_plugin_to_plugin_summary( must_show_installation_interstitial: None, auth_policy: plugin.policy.authentication.into(), availability: PluginAvailability::Available, + disabled_reason: None, + eligible_plan_types: None, interface: plugin.interface.map(local_plugin_interface_to_info), keywords: plugin.keywords, } @@ -1132,6 +1134,8 @@ impl PluginRequestProcessor { must_show_installation_interstitial: None, auth_policy: outcome.plugin.policy.authentication.into(), availability: PluginAvailability::Available, + disabled_reason: None, + eligible_plan_types: None, interface: outcome.plugin.interface.map(local_plugin_interface_to_info), keywords: outcome.plugin.keywords, }, @@ -2242,6 +2246,8 @@ fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginS must_show_installation_interstitial: summary.must_show_installation_interstitial, auth_policy: summary.auth_policy, availability: summary.availability, + disabled_reason: summary.disabled_reason, + eligible_plan_types: summary.eligible_plan_types, interface: summary.interface, keywords: summary.keywords, } 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 e601f265ad..7b9aaf73a1 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -15,6 +15,7 @@ use codex_app_server_protocol::HooksListResponse; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::LoginAccountResponse; use codex_app_server_protocol::PluginAuthPolicy; +use codex_app_server_protocol::PluginDisabledReason; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallPolicySource; use codex_app_server_protocol::PluginInstalledParams; @@ -591,6 +592,8 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ must_show_installation_interstitial: None, auth_policy: PluginAuthPolicy::OnInstall, availability: codex_app_server_protocol::PluginAvailability::Available, + disabled_reason: None, + eligible_plan_types: None, interface: None, keywords: vec!["api-key".to_string(), "developer tools".to_string()], }], @@ -878,6 +881,8 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab must_show_installation_interstitial: None, auth_policy: PluginAuthPolicy::OnInstall, availability: codex_app_server_protocol::PluginAvailability::Available, + disabled_reason: None, + eligible_plan_types: None, interface: Some(codex_app_server_protocol::PluginInterface { display_name: Some("Valid Plugin".to_string()), short_description: None, @@ -920,6 +925,8 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab must_show_installation_interstitial: None, auth_policy: PluginAuthPolicy::OnInstall, availability: codex_app_server_protocol::PluginAvailability::Available, + disabled_reason: None, + eligible_plan_types: None, interface: None, keywords: Vec::new(), }, @@ -4378,6 +4385,33 @@ plugin_sharing = true #[tokio::test] async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { + assert_disabled_remote_plugin_metadata( + PluginDisabledReason::DisabledByAdmin, + /*eligible_plan_types*/ None, + PluginInstallPolicy::Available, + ) + .await +} + +#[tokio::test] +async fn plugin_list_preserves_plan_ineligible_remote_plugin_metadata() -> Result<()> { + assert_disabled_remote_plugin_metadata( + PluginDisabledReason::PlanNotEligible, + Some(vec![ + "plus".to_string(), + "pro".to_string(), + "enterprise_cbp_automation".to_string(), + ]), + PluginInstallPolicy::NotAvailable, + ) + .await +} + +async fn assert_disabled_remote_plugin_metadata( + disabled_reason: PluginDisabledReason, + eligible_plan_types: Option>, + installation_policy: PluginInstallPolicy, +) -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; write_remote_plugin_catalog_config( @@ -4393,65 +4427,51 @@ async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { 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 - } -}"#; + let plugin = serde_json::json!({ + "id": "plugins~Plugin_00000000000000000000000000000000", + "name": "gmail", + "scope": "GLOBAL", + "installation_policy": installation_policy, + "authentication_policy": "ON_USE", + "status": "DISABLED_BY_ADMIN", + "disabled_reason": disabled_reason, + "eligible_plan_types": eligible_plan_types, + "release": { + "display_name": "Gmail", + "description": "Search and manage email", + "app_ids": [], + "interface": {}, + "skills": [], + }, + }); + let global_directory_body = serde_json::json!({ + "plugins": [plugin.clone()], + "pagination": { + "limit": 50, + "next_page_token": null, + }, + }); + let mut installed_plugin = plugin; + installed_plugin["enabled"] = serde_json::json!(true); + installed_plugin["disabled_skill_names"] = serde_json::json!([]); + let global_installed_body = serde_json::json!({ + "plugins": [installed_plugin], + "pagination": { + "limit": 50, + "next_page_token": null, + }, + }); + let empty_page_body = serde_json::json!({ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null, + }, + }); for (scope, body) in [ - ("GLOBAL", global_directory_body), - ("WORKSPACE", empty_page_body), + ("GLOBAL", &global_directory_body), + ("WORKSPACE", &empty_page_body), ] { Mock::given(method("GET")) .and(path("/backend-api/ps/plugins/list")) @@ -4459,20 +4479,20 @@ async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { .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)) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) .mount(&server) .await; } for (scope, body) in [ - ("GLOBAL", global_installed_body), - ("WORKSPACE", empty_page_body), + ("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)) + .respond_with(ResponseTemplate::new(200).set_body_json(body)) .mount(&server) .await; } @@ -4508,6 +4528,9 @@ async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> { plugin.availability, codex_app_server_protocol::PluginAvailability::DisabledByAdmin ); + assert_eq!(plugin.disabled_reason, Some(disabled_reason)); + assert_eq!(plugin.eligible_plan_types, eligible_plan_types); + assert_eq!(plugin.install_policy, installation_policy); Ok(()) } 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 efb196cb43..bf58e4e851 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -178,6 +178,8 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { must_show_installation_interstitial: Some(false), auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, + disabled_reason: None, + eligible_plan_types: None, interface: Some(expected_plugin_interface()), keywords: Vec::new(), }, @@ -595,6 +597,8 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { must_show_installation_interstitial: Some(false), auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, + disabled_reason: None, + eligible_plan_types: None, interface: Some(expected_plugin_interface()), keywords: Vec::new(), }, @@ -1255,6 +1259,8 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { must_show_installation_interstitial: Some(false), auth_policy: PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, + disabled_reason: None, + eligible_plan_types: None, interface: Some(expected_plugin_interface()), keywords: Vec::new(), }, diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 33127535ca..1e521264ab 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -836,6 +836,8 @@ fn remote_installed_plugin_in_marketplace( must_show_installation_interstitial: None, auth_policy: codex_app_server_protocol::PluginAuthPolicy::OnUse, availability: codex_app_server_protocol::PluginAvailability::Available, + disabled_reason: None, + eligible_plan_types: None, interface: None, keywords: Vec::new(), } diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index f913e0586b..bf7011f850 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -6,6 +6,7 @@ use crate::store::PluginStore; use codex_app_server_protocol::JSONRPCErrorError; use codex_app_server_protocol::PluginAuthPolicy; use codex_app_server_protocol::PluginAvailability; +use codex_app_server_protocol::PluginDisabledReason; use codex_app_server_protocol::PluginInstallPolicy; use codex_app_server_protocol::PluginInstallPolicySource; use codex_app_server_protocol::PluginInterface; @@ -213,6 +214,8 @@ pub struct RemoteInstalledPlugin { pub must_show_installation_interstitial: Option, pub auth_policy: PluginAuthPolicy, pub availability: PluginAvailability, + pub disabled_reason: Option, + pub eligible_plan_types: Option>, pub interface: Option, pub keywords: Vec, } @@ -232,6 +235,8 @@ pub struct RemotePluginSummary { pub must_show_installation_interstitial: Option, pub auth_policy: PluginAuthPolicy, pub availability: PluginAvailability, + pub disabled_reason: Option, + pub eligible_plan_types: Option>, pub interface: Option, pub keywords: Vec, } @@ -638,6 +643,10 @@ struct RemotePluginDirectoryItem { authentication_policy: PluginAuthPolicy, #[serde(rename = "status", default)] availability: PluginAvailability, + #[serde(default)] + disabled_reason: Option, + #[serde(default)] + eligible_plan_types: Option>, release: RemotePluginReleaseResponse, } @@ -1201,6 +1210,8 @@ pub fn group_remote_installed_plugins_by_marketplaces( must_show_installation_interstitial: plugin.must_show_installation_interstitial, auth_policy: plugin.auth_policy, availability: plugin.availability, + disabled_reason: plugin.disabled_reason, + eligible_plan_types: plugin.eligible_plan_types.clone(), interface: plugin.interface.clone(), keywords: plugin.keywords.clone(), }; @@ -1635,6 +1646,8 @@ fn build_remote_plugin_summary( must_show_installation_interstitial: plugin.must_show_installation_interstitial, auth_policy: plugin.authentication_policy, availability: plugin.availability, + disabled_reason: plugin.disabled_reason, + eligible_plan_types: plugin.eligible_plan_types.clone(), interface: remote_plugin_interface_to_info(plugin), keywords: plugin.release.keywords.clone(), }) @@ -1719,6 +1732,8 @@ fn remote_installed_plugin_to_cache_entry( must_show_installation_interstitial: plugin.must_show_installation_interstitial, auth_policy: plugin.authentication_policy, availability: plugin.availability, + disabled_reason: plugin.disabled_reason, + eligible_plan_types: plugin.eligible_plan_types.clone(), interface: remote_plugin_interface_to_info(plugin), keywords: plugin.release.keywords.clone(), }) diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index 1afc1b0843..1bcc48fb76 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -671,6 +671,8 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { must_show_installation_interstitial: None, auth_policy: PluginAuthPolicy::OnUse, availability: PluginAvailability::Available, + disabled_reason: None, + eligible_plan_types: None, interface: Some(expected_plugin_interface()), keywords: Vec::new(), }, @@ -713,6 +715,8 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { must_show_installation_interstitial: None, auth_policy: PluginAuthPolicy::OnUse, availability: PluginAvailability::Available, + disabled_reason: None, + eligible_plan_types: None, interface: Some(expected_plugin_interface()), keywords: Vec::new(), }, diff --git a/codex-rs/core-plugins/src/remote_tests.rs b/codex-rs/core-plugins/src/remote_tests.rs index 8473636f73..9ed28de7fa 100644 --- a/codex-rs/core-plugins/src/remote_tests.rs +++ b/codex-rs/core-plugins/src/remote_tests.rs @@ -150,6 +150,86 @@ fn installation_policy_source_is_preserved_across_remote_summary_paths() { ); } +#[test] +fn plan_eligibility_is_preserved_across_remote_summary_paths() { + let mut directory_plugin = directory_plugin("plugin-gmail", "gmail"); + directory_plugin.installation_policy = PluginInstallPolicy::NotAvailable; + directory_plugin.availability = PluginAvailability::DisabledByAdmin; + directory_plugin.disabled_reason = Some(PluginDisabledReason::PlanNotEligible); + directory_plugin.eligible_plan_types = Some(vec![ + "plus".to_string(), + "pro".to_string(), + "enterprise_cbp_automation".to_string(), + ]); + let installed_plugin = RemotePluginInstalledItem { + plugin: directory_plugin.clone(), + enabled: false, + disabled_skill_names: Vec::new(), + }; + + let marketplace = build_remote_marketplace( + REMOTE_GLOBAL_MARKETPLACE_NAME, + REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME, + vec![directory_plugin], + Vec::new(), + /*include_installed_only*/ false, + ) + .expect("marketplace should be valid") + .expect("marketplace should not be empty"); + let expected = vec![( + PluginAvailability::DisabledByAdmin, + Some(PluginDisabledReason::PlanNotEligible), + Some(vec![ + "plus".to_string(), + "pro".to_string(), + "enterprise_cbp_automation".to_string(), + ]), + )]; + assert_eq!( + marketplace + .plugins + .into_iter() + .map(|plugin| ( + plugin.availability, + plugin.disabled_reason, + plugin.eligible_plan_types, + )) + .collect::>(), + expected + ); + + let installed_plugin = remote_installed_plugin_to_cache_entry(&installed_plugin) + .expect("installed plugin should be valid"); + let marketplaces = group_remote_installed_plugins_by_marketplaces( + &[installed_plugin], + &[REMOTE_GLOBAL_MARKETPLACE_NAME], + ); + assert_eq!( + marketplaces + .into_iter() + .flat_map(|marketplace| marketplace.plugins) + .map(|plugin| ( + plugin.availability, + plugin.disabled_reason, + plugin.eligible_plan_types, + )) + .collect::>(), + expected + ); +} + +#[test] +fn unknown_plugin_disabled_reason_preserves_remote_catalog_compatibility() { + let plugin = directory_plugin("plugin-gmail", "gmail"); + let mut plugin_json = serde_json::to_value(plugin).expect("plugin should serialize"); + plugin_json["disabled_reason"] = serde_json::json!("future_disabled_reason"); + + let plugin: RemotePluginDirectoryItem = + serde_json::from_value(plugin_json).expect("unknown reason should deserialize"); + + assert_eq!(plugin.disabled_reason, Some(PluginDisabledReason::Unknown)); +} + #[test] fn installation_interstitial_requirement_is_preserved_across_remote_summary_paths() { let mut directory_plugin = directory_plugin("plugin-linear", "linear"); @@ -271,6 +351,8 @@ fn directory_plugin(id: &str, name: &str) -> RemotePluginDirectoryItem { must_show_installation_interstitial: None, authentication_policy: PluginAuthPolicy::OnUse, availability: PluginAvailability::Available, + disabled_reason: None, + eligible_plan_types: None, release: RemotePluginReleaseResponse { version: None, display_name: name.to_string(), diff --git a/codex-rs/tui/src/app/plugin_mentions.rs b/codex-rs/tui/src/app/plugin_mentions.rs index e56c2cfa43..597c5b543c 100644 --- a/codex-rs/tui/src/app/plugin_mentions.rs +++ b/codex-rs/tui/src/app/plugin_mentions.rs @@ -178,6 +178,8 @@ mod tests { must_show_installation_interstitial: None, auth_policy: PluginAuthPolicy::OnInstall, availability: PluginAvailability::Available, + disabled_reason: None, + eligible_plan_types: None, interface: None, keywords: Vec::new(), } diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index f4669781b5..3c71c0bcb8 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1360,6 +1360,8 @@ pub(super) fn plugins_test_summary( must_show_installation_interstitial: None, auth_policy: PluginAuthPolicy::OnInstall, availability: PluginAvailability::Available, + disabled_reason: None, + eligible_plan_types: None, interface: Some(plugins_test_interface( display_name, description, @@ -1391,6 +1393,8 @@ pub(super) fn plugins_test_remote_summary( must_show_installation_interstitial: None, auth_policy: PluginAuthPolicy::OnInstall, availability: PluginAvailability::Available, + disabled_reason: None, + eligible_plan_types: None, interface: Some(plugins_test_interface( display_name, description,