mirror of
https://github.com/openai/codex.git
synced 2026-10-18 17:18:52 +00:00
Expose plugin eligibility metadata in app-server summaries (#35837)
## What changed - Add nullable `disabledReason` and `eligiblePlanTypes` fields to v2 `PluginSummary` responses and generated schemas. - Preserve the remote catalog values across discovered, installed, and cached plugin summary paths while returning `null` for local plugins and older remote responses. - Treat unrecognized disabled reasons as `unknown` for forward compatibility. ## Testing - Cover protocol round trips, remote summary propagation, unknown disabled reasons, and app-server responses for admin-disabled and plan-ineligible plugins. GitOrigin-RevId: 657a7ea6f838a6ff8bd1769ebcca0f3432684437
This commit is contained in:
+32
@@ -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"
|
||||
},
|
||||
|
||||
+32
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
@@ -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<string>, };
|
||||
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<string> | null, interface: PluginInterface | null, keywords: Array<string>, };
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<PluginDisabledReason>,
|
||||
/// Raw plugin-service plan identifiers eligible to install the plugin.
|
||||
#[serde(default)]
|
||||
pub eligible_plan_types: Option<Vec<String>>,
|
||||
pub interface: Option<PluginInterface>,
|
||||
#[serde(default)]
|
||||
pub keywords: Vec<String>,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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<Vec<String>>,
|
||||
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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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<bool>,
|
||||
pub auth_policy: PluginAuthPolicy,
|
||||
pub availability: PluginAvailability,
|
||||
pub disabled_reason: Option<PluginDisabledReason>,
|
||||
pub eligible_plan_types: Option<Vec<String>>,
|
||||
pub interface: Option<PluginInterface>,
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
@@ -232,6 +235,8 @@ pub struct RemotePluginSummary {
|
||||
pub must_show_installation_interstitial: Option<bool>,
|
||||
pub auth_policy: PluginAuthPolicy,
|
||||
pub availability: PluginAvailability,
|
||||
pub disabled_reason: Option<PluginDisabledReason>,
|
||||
pub eligible_plan_types: Option<Vec<String>>,
|
||||
pub interface: Option<PluginInterface>,
|
||||
pub keywords: Vec<String>,
|
||||
}
|
||||
@@ -638,6 +643,10 @@ struct RemotePluginDirectoryItem {
|
||||
authentication_policy: PluginAuthPolicy,
|
||||
#[serde(rename = "status", default)]
|
||||
availability: PluginAvailability,
|
||||
#[serde(default)]
|
||||
disabled_reason: Option<PluginDisabledReason>,
|
||||
#[serde(default)]
|
||||
eligible_plan_types: Option<Vec<String>>,
|
||||
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(),
|
||||
})
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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::<Vec<_>>(),
|
||||
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::<Vec<_>>(),
|
||||
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(),
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user