[codex] expose remote plugin share URL (#27890)

## Summary

- expose the remote plugin detail endpoint's `share_url` as nullable
`PluginDetail.shareUrl`
- preserve existing `PluginSummary.shareContext` behavior for local and
workspace sharing flows
- regenerate the app-server TypeScript and JSON schema fixtures

## Why

The remote plugin detail response already includes a canonical
`share_url`, but that value was not surfaced by `plugin/read` for global
plugins. Global plugins intentionally have no `shareContext`, so using
that model for the URL would change the semantics consumed by the
existing share modal.

## User impact

Codex clients can use `PluginDetail.shareUrl` for a remote plugin's
copy-link action, including when the plugin is disabled by an
administrator, without changing existing share-modal or ownership
behavior.

## Validation

- `cargo test -p codex-app-server
plugin_read_includes_share_url_for_admin_disabled_remote_plugin`
- `cargo test -p codex-app-server-protocol
typescript_schema_fixtures_match_generated`
- `cargo test -p codex-app-server-protocol
json_schema_fixtures_match_generated`
- `cargo fmt --all`
This commit is contained in:
Eric Ning
2026-06-12 11:53:55 -07:00
committed by GitHub
Unverified
parent 52a50aec70
commit 9600afb1a1
11 changed files with 86 additions and 47 deletions
@@ -12546,6 +12546,12 @@
},
"type": "array"
},
"shareUrl": {
"type": [
"string",
"null"
]
},
"skills": {
"items": {
"$ref": "#/definitions/v2/SkillSummary"
@@ -9019,6 +9019,12 @@
},
"type": "array"
},
"shareUrl": {
"type": [
"string",
"null"
]
},
"skills": {
"items": {
"$ref": "#/definitions/SkillSummary"
@@ -192,6 +192,12 @@
},
"type": "array"
},
"shareUrl": {
"type": [
"string",
"null"
]
},
"skills": {
"items": {
"$ref": "#/definitions/SkillSummary"
@@ -8,4 +8,4 @@ import type { PluginHookSummary } from "./PluginHookSummary";
import type { PluginSummary } from "./PluginSummary";
import type { SkillSummary } from "./SkillSummary";
export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, description: string | null, skills: Array<SkillSummary>, hooks: Array<PluginHookSummary>, apps: Array<AppSummary>, appTemplates: Array<AppTemplateSummary>, mcpServers: Array<string>, };
export type PluginDetail = { marketplaceName: string, marketplacePath: AbsolutePathBuf | null, summary: PluginSummary, shareUrl: string | null, description: string | null, skills: Array<SkillSummary>, hooks: Array<PluginHookSummary>, apps: Array<AppSummary>, appTemplates: Array<AppTemplateSummary>, mcpServers: Array<string>, };
@@ -638,6 +638,7 @@ pub struct PluginDetail {
pub marketplace_name: String,
pub marketplace_path: Option<AbsolutePathBuf>,
pub summary: PluginSummary,
pub share_url: Option<String>,
pub description: Option<String>,
pub skills: Vec<SkillSummary>,
pub hooks: Vec<PluginHookSummary>,
+1 -1
View File
@@ -206,7 +206,7 @@ Example with notification opt-out:
- `marketplace/upgrade` — upgrade all configured Git plugin marketplaces, or one named marketplace when `marketplaceName` is provided. Returns selected marketplace names, upgraded roots, and per-marketplace errors.
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, plugin `availability` (`AVAILABLE` by default or `DISABLED_BY_ADMIN` for remote plugins blocked upstream), fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
- `plugin/installed` — list installed plugin rows plus any explicitly requested local install-suggestion plugin names, without fetching the broader remote catalog. Mention surfaces can use this narrower view when they need plugin mention payloads rather than plugin-page discovery data (**under development; do not call from production clients yet**).
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. 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/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/hooks/apps/MCP server names. Remote plugin details expose the canonical `shareUrl` supplied by the remote catalog when available; it is `null` for local plugins or when the catalog omits it. This field is separate from `summary.shareContext`, which continues to describe user and workspace sharing state. Returned plugin skills include their current `enabled` state after local config filtering; bundled hooks are returned as lightweight declaration summaries keyed for correlation with `hooks/list`. Use `plugin/install`'s `appsNeedingAuth` to drive post-install authentication and `app/list`'s `isAccessible` to determine current connector accessibility (**under development; do not call from production clients yet**).
- `plugin/skill/read` — read remote plugin skill markdown on demand by `remoteMarketplaceName`, `remotePluginId`, and `skillName`. This lets clients preview uninstalled remote plugin skills without downloading the plugin bundle.
- `skills/changed` — notification emitted when watched local skill files change.
- `app/list` — list available apps.
@@ -1070,6 +1070,7 @@ impl PluginRequestProcessor {
interface: outcome.plugin.interface.map(local_plugin_interface_to_info),
keywords: outcome.plugin.keywords,
},
share_url: None,
description: outcome.plugin.description,
skills: plugin_skills_to_info(
&visible_skills,
@@ -2081,6 +2082,7 @@ fn remote_plugin_detail_to_info(
marketplace_name: detail.marketplace_name,
marketplace_path: None,
summary: remote_plugin_summary_to_info(detail.summary),
share_url: detail.share_url,
description: detail.description,
skills: detail
.skills
@@ -23,6 +23,7 @@ use codex_app_server_protocol::HookEventName;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginAvailability;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
@@ -392,7 +393,7 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result<
}
#[tokio::test]
async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> Result<()> {
async fn plugin_read_includes_share_url_for_admin_disabled_remote_plugin() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_remote_plugin_catalog_config(
@@ -410,29 +411,31 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
let detail_body = r#"{
"id": "plugins~Plugin_00000000000000000000000000000000",
"name": "linear",
"name": "example-plugin",
"scope": "GLOBAL",
"share_url": "https://chatgpt.example/plugins/share/example-plugin",
"status": "DISABLED_BY_ADMIN",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"display_name": "Linear",
"description": "Track work in Linear",
"display_name": "Example Plugin",
"description": "Exercise example workflows",
"app_ids": [],
"app_templates": [
{
"template_id": "templated_apps_GitHubEnterprise",
"name": "GitHub Enterprise",
"description": "Connect GitHub Enterprise",
"template_id": "templated_apps_SourceControlEnterprise",
"name": "Source Control Enterprise",
"description": "Connect source control",
"category": "Developer Tools",
"canonical_connector_id": "github_enterprise",
"logo_url": "https://example.com/ghe-light.png",
"logo_url_dark": "https://example.com/ghe-dark.png",
"materialized_app_ids": ["asdk_app_ghe"],
"canonical_connector_id": "source_control_enterprise",
"logo_url": "https://example.com/source-control-light.png",
"logo_url_dark": "https://example.com/source-control-dark.png",
"materialized_app_ids": ["asdk_app_source_control"],
"reason": null
},
{
"template_id": "templated_apps_Databricks",
"name": "Databricks",
"template_id": "templated_apps_DataWarehouse",
"name": "Data Warehouse",
"description": null,
"canonical_connector_id": null,
"logo_url": null,
@@ -441,19 +444,19 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
"reason": "NOT_CONFIGURED_FOR_WORKSPACE"
}
],
"keywords": ["issue-tracking", "project management"],
"keywords": ["workflow", "example"],
"interface": {
"short_description": "Plan and track work",
"short_description": "Run example workflows",
"capabilities": ["Read", "Write"],
"default_prompt": "Use the legacy Linear prompt",
"default_prompts": ["Create a Linear issue", "Review my Linear projects"],
"logo_url": "https://example.com/linear.png",
"screenshot_urls": ["https://example.com/linear-shot.png"]
"default_prompt": "Use the legacy example prompt",
"default_prompts": ["Create an example item", "Review example projects"],
"logo_url": "https://example.com/example-plugin.png",
"screenshot_urls": ["https://example.com/example-plugin-shot.png"]
},
"skills": [
{
"name": "plan-work",
"description": "Plan work from Linear issues",
"description": "Plan example work",
"plugin_release_skill_id": "skill-1",
"interface": {
"display_name": "Plan Work",
@@ -467,24 +470,24 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
"plugins": [
{
"id": "plugins~Plugin_00000000000000000000000000000000",
"name": "linear",
"name": "example-plugin",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"display_name": "Linear",
"description": "Track work in Linear",
"display_name": "Example Plugin",
"description": "Exercise example workflows",
"app_ids": [],
"interface": {
"short_description": "Plan and track work",
"short_description": "Run example workflows",
"capabilities": ["Read", "Write"],
"logo_url": "https://example.com/linear.png",
"screenshot_urls": ["https://example.com/linear-shot.png"]
"logo_url": "https://example.com/example-plugin.png",
"screenshot_urls": ["https://example.com/example-plugin-shot.png"]
},
"skills": [
{
"name": "plan-work",
"description": "Plan work from Linear issues",
"description": "Plan example work",
"plugin_release_skill_id": "skill-1",
"interface": {
"display_name": "Plan Work",
@@ -542,24 +545,33 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
assert_eq!(response.plugin.marketplace_name, "openai-curated-remote");
assert_eq!(response.plugin.marketplace_path, None);
assert_eq!(response.plugin.summary.source, PluginSource::Remote);
assert_eq!(response.plugin.summary.id, "linear@openai-curated-remote");
assert_eq!(
response.plugin.summary.id,
"example-plugin@openai-curated-remote"
);
assert_eq!(
response.plugin.summary.remote_plugin_id.as_deref(),
Some("plugins~Plugin_00000000000000000000000000000000")
);
assert_eq!(response.plugin.summary.name, "linear");
assert_eq!(response.plugin.summary.name, "example-plugin");
assert_eq!(response.plugin.summary.installed, true);
assert_eq!(response.plugin.summary.enabled, false);
assert_eq!(
response.plugin.summary.availability,
PluginAvailability::DisabledByAdmin
);
assert_eq!(response.plugin.summary.share_context, None);
assert_eq!(
response.plugin.share_url.as_deref(),
Some("https://chatgpt.example/plugins/share/example-plugin")
);
assert_eq!(
response.plugin.description.as_deref(),
Some("Track work in Linear")
Some("Exercise example workflows")
);
assert_eq!(
response.plugin.summary.keywords,
vec![
"issue-tracking".to_string(),
"project management".to_string()
]
vec!["workflow".to_string(), "example".to_string()]
);
assert_eq!(
response
@@ -569,8 +581,8 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
.as_ref()
.and_then(|interface| interface.default_prompt.clone()),
Some(vec![
"Create a Linear issue".to_string(),
"Review my Linear projects".to_string(),
"Create an example item".to_string(),
"Review example projects".to_string(),
])
);
assert_eq!(response.plugin.skills.len(), 1);
@@ -582,19 +594,19 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
response.plugin.app_templates,
vec![
AppTemplateSummary {
template_id: "templated_apps_GitHubEnterprise".to_string(),
name: "GitHub Enterprise".to_string(),
description: Some("Connect GitHub Enterprise".to_string()),
template_id: "templated_apps_SourceControlEnterprise".to_string(),
name: "Source Control Enterprise".to_string(),
description: Some("Connect source control".to_string()),
category: Some("Developer Tools".to_string()),
canonical_connector_id: Some("github_enterprise".to_string()),
logo_url: Some("https://example.com/ghe-light.png".to_string()),
logo_url_dark: Some("https://example.com/ghe-dark.png".to_string()),
materialized_app_ids: vec!["asdk_app_ghe".to_string()],
canonical_connector_id: Some("source_control_enterprise".to_string()),
logo_url: Some("https://example.com/source-control-light.png".to_string()),
logo_url_dark: Some("https://example.com/source-control-dark.png".to_string()),
materialized_app_ids: vec!["asdk_app_source_control".to_string()],
reason: None,
},
AppTemplateSummary {
template_id: "templated_apps_Databricks".to_string(),
name: "Databricks".to_string(),
template_id: "templated_apps_DataWarehouse".to_string(),
name: "Data Warehouse".to_string(),
description: None,
category: None,
canonical_connector_id: None,
+2
View File
@@ -163,6 +163,7 @@ pub struct RemotePluginDetail {
pub marketplace_name: String,
pub marketplace_display_name: String,
pub summary: RemotePluginSummary,
pub share_url: Option<String>,
pub description: Option<String>,
pub release_version: Option<String>,
pub bundle_download_url: Option<String>,
@@ -1048,6 +1049,7 @@ async fn build_remote_plugin_detail(
marketplace_name,
marketplace_display_name: scope.marketplace_display_name().to_string(),
summary: build_remote_plugin_summary(&plugin, installed_plugin.as_ref())?,
share_url: plugin.share_url,
description: non_empty_string(Some(&plugin.release.description)),
release_version: plugin.release.version,
bundle_download_url: plugin.release.bundle_download_url,
@@ -1421,6 +1421,7 @@ pub(super) fn plugins_test_detail(
marketplace_name: "ChatGPT Marketplace".to_string(),
marketplace_path: Some(plugins_test_absolute_path("marketplaces/chatgpt")),
summary,
share_url: None,
description: description.map(str::to_string),
skills: skills
.iter()
@@ -803,6 +803,7 @@ async fn plugin_detail_remote_install_uses_remote_location() {
marketplace_name: "workspace-shared-with-me-private".to_string(),
marketplace_path: None,
summary,
share_url: None,
description: Some("Install shared Linear plugin.".to_string()),
skills: Vec::new(),
hooks: Vec::new(),
@@ -877,6 +878,7 @@ async fn plugin_detail_remote_uninstall_uses_remote_plugin_id() {
marketplace_name: "workspace-shared-with-me-private".to_string(),
marketplace_path: None,
summary,
share_url: None,
description: Some("Installed shared Linear plugin.".to_string()),
skills: Vec::new(),
hooks: Vec::new(),
@@ -949,6 +951,7 @@ async fn plugin_detail_remote_without_remote_id_disables_uninstall_action() {
marketplace_name: "workspace-shared-with-me-private".to_string(),
marketplace_path: None,
summary,
share_url: None,
description: Some("Installed shared Linear plugin.".to_string()),
skills: Vec::new(),
hooks: Vec::new(),