Add remote plugin skill read API (#20150)

## Summary

Adds an app-server `plugin/skill/read` method for remote plugin skill
markdown. The new method calls the plugin-service skill detail endpoint
and returns `skill_md_contents`, so clients can preview skills for
remote plugins before the bundle is installed locally.

## Why

Uninstalled remote plugin skills do not have local `SKILL.md` files.
Without an on-demand remote read, the desktop plugin details UI cannot
render the skill details modal for those skills.

## Validation

- `just write-app-server-schema`
- `just fmt`
- `cargo test -p codex-app-server-protocol`
- `cargo test -p codex-app-server --test all --
suite::v2::plugin_read::plugin_skill_read_reads_remote_skill_contents_when_remote_plugin_enabled
--exact`
- `just fix -p codex-app-server-protocol -p codex-core-plugins -p
codex-app-server`
This commit is contained in:
xli-oai
2026-05-01 00:16:25 -07:00
committed by GitHub
parent a62b52f826
commit 96d2ea9058
21 changed files with 1212 additions and 529 deletions
+115
View File
@@ -1,5 +1,6 @@
use crate::store::PLUGINS_CACHE_DIR;
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::PluginInstallPolicy;
@@ -16,6 +17,7 @@ use std::collections::HashSet;
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use url::Url;
mod remote_installed_plugin_sync;
mod share;
@@ -39,6 +41,7 @@ pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Workspace P
const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30);
const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200;
const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128;
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemotePluginServiceConfig {
@@ -93,6 +96,32 @@ pub struct RemotePluginSkill {
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RemotePluginSkillDetail {
pub contents: Option<String>,
}
pub fn is_valid_remote_plugin_id(plugin_id: &str) -> bool {
!plugin_id.is_empty()
&& plugin_id
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~')
}
pub fn validate_remote_plugin_id(plugin_id: &str) -> Result<(), JSONRPCErrorError> {
if !is_valid_remote_plugin_id(plugin_id) {
return Err(JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message:
"invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed"
.to_string(),
data: None,
});
}
Ok(())
}
#[derive(Debug, thiserror::Error)]
pub enum RemotePluginCatalogError {
#[error("chatgpt authentication required for remote plugin catalog")]
@@ -127,11 +156,25 @@ pub enum RemotePluginCatalogError {
source: serde_json::Error,
},
#[error("invalid remote plugin catalog base URL: {0}")]
InvalidBaseUrl(#[source] url::ParseError),
#[error("invalid remote plugin catalog base URL path")]
InvalidBaseUrlPath,
#[error("remote marketplace `{marketplace_name}` is not supported")]
UnknownMarketplace { marketplace_name: String },
#[error(
"remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`"
)]
UnexpectedPluginId { expected: String, actual: String },
#[error(
"remote plugin skill response returned unexpected skill name: expected `{expected}`, got `{actual}`"
)]
UnexpectedSkillName { expected: String, actual: String },
#[error(
"remote plugin mutation returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}"
)]
@@ -202,6 +245,14 @@ impl RemotePluginScope {
Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME,
}
}
fn from_marketplace_name(name: &str) -> Option<Self> {
match name {
REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global),
REMOTE_WORKSPACE_MARKETPLACE_NAME => Some(Self::Workspace),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
@@ -226,6 +277,13 @@ struct RemotePluginSkillResponse {
interface: Option<RemotePluginSkillInterfaceResponse>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemotePluginSkillDetailResponse {
plugin_id: String,
name: String,
skill_md_contents: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemotePluginReleaseInterfaceResponse {
short_description: Option<String>,
@@ -462,6 +520,42 @@ pub async fn fetch_remote_plugin_detail_with_download_urls(
.await
}
pub async fn fetch_remote_plugin_skill_detail(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
marketplace_name: &str,
plugin_id: &str,
skill_name: &str,
) -> Result<RemotePluginSkillDetail, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
if RemotePluginScope::from_marketplace_name(marketplace_name).is_none() {
return Err(RemotePluginCatalogError::UnknownMarketplace {
marketplace_name: marketplace_name.to_string(),
});
}
let url = remote_plugin_skill_detail_url(config, plugin_id, skill_name)?;
let client = build_reqwest_client();
let request = authenticated_request(client.get(&url), auth)?;
let response: RemotePluginSkillDetailResponse = send_and_decode(request, &url).await?;
if response.plugin_id != plugin_id {
return Err(RemotePluginCatalogError::UnexpectedPluginId {
expected: plugin_id.to_string(),
actual: response.plugin_id,
});
}
if response.name != skill_name {
return Err(RemotePluginCatalogError::UnexpectedSkillName {
expected: skill_name.to_string(),
actual: response.name,
});
}
Ok(RemotePluginSkillDetail {
contents: response.skill_md_contents,
})
}
async fn fetch_remote_plugin_detail_with_download_url_option(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
@@ -883,6 +977,27 @@ async fn fetch_plugin_detail(
send_and_decode(request, &url).await
}
fn remote_plugin_skill_detail_url(
config: &RemotePluginServiceConfig,
plugin_id: &str,
skill_name: &str,
) -> Result<String, RemotePluginCatalogError> {
let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/'))
.map_err(RemotePluginCatalogError::InvalidBaseUrl)?;
{
let mut segments = url
.path_segments_mut()
.map_err(|()| RemotePluginCatalogError::InvalidBaseUrlPath)?;
segments.pop_if_empty();
segments.push("ps");
segments.push("plugins");
segments.push(plugin_id);
segments.push("skills");
segments.push(skill_name);
}
Ok(url.to_string())
}
fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginCatalogError> {
let Some(auth) = auth else {
return Err(RemotePluginCatalogError::AuthRequired);