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`
This commit is contained in:
xli-oai
2026-04-30 20:00:07 -07:00
committed by GitHub
parent c39824c2fd
commit bb60b78c46
17 changed files with 435 additions and 2 deletions
+1 -1
View File
@@ -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.
@@ -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,
}
}
@@ -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}
@@ -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()?;
@@ -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()),
}],
}