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
Unverified
parent a62b52f826
commit 96d2ea9058
21 changed files with 1212 additions and 529 deletions
@@ -2217,6 +2217,25 @@
],
"type": "object"
},
"PluginSkillReadParams": {
"properties": {
"remoteMarketplaceName": {
"type": "string"
},
"remotePluginId": {
"type": "string"
},
"skillName": {
"type": "string"
}
},
"required": [
"remoteMarketplaceName",
"remotePluginId",
"skillName"
],
"type": "object"
},
"PluginUninstallParams": {
"properties": {
"pluginId": {
@@ -5036,6 +5055,30 @@
"title": "Plugin/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/skill/read"
],
"title": "Plugin/skill/readRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginSkillReadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/skill/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -762,6 +762,30 @@
"title": "Plugin/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"plugin/skill/read"
],
"title": "Plugin/skill/readRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/PluginSkillReadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/skill/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -12391,6 +12415,40 @@
"title": "PluginShareSaveResponse",
"type": "object"
},
"PluginSkillReadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remoteMarketplaceName": {
"type": "string"
},
"remotePluginId": {
"type": "string"
},
"skillName": {
"type": "string"
}
},
"required": [
"remoteMarketplaceName",
"remotePluginId",
"skillName"
],
"title": "PluginSkillReadParams",
"type": "object"
},
"PluginSkillReadResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"contents": {
"type": [
"string",
"null"
]
}
},
"title": "PluginSkillReadResponse",
"type": "object"
},
"PluginSource": {
"oneOf": [
{
@@ -1521,6 +1521,30 @@
"title": "Plugin/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"plugin/skill/read"
],
"title": "Plugin/skill/readRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/PluginSkillReadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Plugin/skill/readRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -9044,6 +9068,40 @@
"title": "PluginShareSaveResponse",
"type": "object"
},
"PluginSkillReadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remoteMarketplaceName": {
"type": "string"
},
"remotePluginId": {
"type": "string"
},
"skillName": {
"type": "string"
}
},
"required": [
"remoteMarketplaceName",
"remotePluginId",
"skillName"
],
"title": "PluginSkillReadParams",
"type": "object"
},
"PluginSkillReadResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"contents": {
"type": [
"string",
"null"
]
}
},
"title": "PluginSkillReadResponse",
"type": "object"
},
"PluginSource": {
"oneOf": [
{
@@ -0,0 +1,21 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"remoteMarketplaceName": {
"type": "string"
},
"remotePluginId": {
"type": "string"
},
"skillName": {
"type": "string"
}
},
"required": [
"remoteMarketplaceName",
"remotePluginId",
"skillName"
],
"title": "PluginSkillReadParams",
"type": "object"
}
@@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"contents": {
"type": [
"string",
"null"
]
}
},
"title": "PluginSkillReadResponse",
"type": "object"
}
File diff suppressed because one or more lines are too long
@@ -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 PluginSkillReadParams = { remoteMarketplaceName: string, remotePluginId: string, skillName: string, };
@@ -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 PluginSkillReadResponse = { contents: string | null, };
@@ -287,6 +287,8 @@ export type { PluginShareListParams } from "./PluginShareListParams";
export type { PluginShareListResponse } from "./PluginShareListResponse";
export type { PluginShareSaveParams } from "./PluginShareSaveParams";
export type { PluginShareSaveResponse } from "./PluginShareSaveResponse";
export type { PluginSkillReadParams } from "./PluginSkillReadParams";
export type { PluginSkillReadResponse } from "./PluginSkillReadResponse";
export type { PluginSource } from "./PluginSource";
export type { PluginSummary } from "./PluginSummary";
export type { PluginUninstallParams } from "./PluginUninstallParams";
@@ -612,6 +612,11 @@ client_request_definitions! {
serialization: global("config"),
response: v2::PluginReadResponse,
},
PluginSkillRead => "plugin/skill/read" {
params: v2::PluginSkillReadParams,
serialization: global("config"),
response: v2::PluginSkillReadResponse,
},
PluginShareSave => "plugin/share/save" {
params: v2::PluginShareSaveParams,
serialization: global("config"),
@@ -4609,6 +4609,22 @@ pub struct PluginReadResponse {
pub plugin: PluginDetail,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginSkillReadParams {
pub remote_marketplace_name: String,
pub remote_plugin_id: String,
pub skill_name: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct PluginSkillReadResponse {
pub contents: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -10667,6 +10683,23 @@ mod tests {
);
}
#[test]
fn plugin_skill_read_params_serialization_uses_remote_plugin_id() {
assert_eq!(
serde_json::to_value(PluginSkillReadParams {
remote_marketplace_name: "chatgpt-global".to_string(),
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
skill_name: "plan-work".to_string(),
})
.unwrap(),
json!({
"remoteMarketplaceName": "chatgpt-global",
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
"skillName": "plan-work",
}),
);
}
#[test]
fn plugin_share_params_and_response_serialization_use_camel_case_fields() {
let plugin_path = if cfg!(windows) {
+1
View File
@@ -203,6 +203,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/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**).
- `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.
- `device/key/create` — create or load a controller-local device signing key for an account/client binding. This local-key API is available only over local transports such as stdio and in-process; remote transports reject it. Hardware-backed providers are the target protection class; an OS-protected non-extractable fallback is allowed only with `protectionPolicy: "allow_os_protected_nonextractable"` and returns the reported `protectionClass`.
@@ -126,6 +126,8 @@ use codex_app_server_protocol::PluginShareListParams;
use codex_app_server_protocol::PluginShareListResponse;
use codex_app_server_protocol::PluginShareSaveParams;
use codex_app_server_protocol::PluginShareSaveResponse;
use codex_app_server_protocol::PluginSkillReadParams;
use codex_app_server_protocol::PluginSkillReadResponse;
use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::PluginUninstallParams;
@@ -1147,6 +1149,10 @@ impl CodexMessageProcessor {
self.plugin_read(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::PluginSkillRead { request_id, params } => {
self.plugin_skill_read(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::PluginShareSave { request_id, params } => {
self.plugin_share_save(to_connection_request_id(request_id), params)
.await;
@@ -3,6 +3,8 @@ use crate::error_code::internal_error;
use crate::error_code::invalid_request;
use codex_app_server_protocol::PluginAvailability;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_core_plugins::remote::is_valid_remote_plugin_id;
use codex_core_plugins::remote::validate_remote_plugin_id;
impl CodexMessageProcessor {
pub(super) async fn plugin_list(
@@ -261,15 +263,15 @@ impl CodexMessageProcessor {
if !config.features.enabled(Feature::Plugins)
|| !config.features.enabled(Feature::RemotePlugin)
{
return Err(invalid_request("remote plugin read is not enabled"));
return Err(invalid_request(format!(
"remote plugin read is not enabled for marketplace {remote_marketplace_name}"
)));
}
let auth = self.auth_manager.auth().await;
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
if plugin_name.is_empty() || !is_valid_remote_plugin_id(&plugin_name) {
return Err(invalid_request("invalid remote plugin id"));
}
validate_remote_plugin_id(&plugin_name)?;
let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail(
&remote_plugin_service_config,
auth.as_ref(),
@@ -300,6 +302,61 @@ impl CodexMessageProcessor {
Ok(PluginReadResponse { plugin })
}
pub(super) async fn plugin_skill_read(
&self,
request_id: ConnectionRequestId,
params: PluginSkillReadParams,
) {
let result = self.plugin_skill_read_response(params).await;
self.outgoing.send_result(request_id, result).await;
}
async fn plugin_skill_read_response(
&self,
params: PluginSkillReadParams,
) -> Result<PluginSkillReadResponse, JSONRPCErrorError> {
let PluginSkillReadParams {
remote_marketplace_name,
remote_plugin_id,
skill_name,
} = params;
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
if !config.features.enabled(Feature::Plugins)
|| !config.features.enabled(Feature::RemotePlugin)
{
return Err(invalid_request(format!(
"remote plugin skill read is not enabled for marketplace {remote_marketplace_name}"
)));
}
validate_remote_plugin_id(&remote_plugin_id)?;
if skill_name.is_empty() {
return Err(invalid_request(
"invalid remote plugin skill name: cannot be empty",
));
}
let auth = self.auth_manager.auth().await;
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
let remote_skill_detail = codex_core_plugins::remote::fetch_remote_plugin_skill_detail(
&remote_plugin_service_config,
auth.as_ref(),
&remote_marketplace_name,
&remote_plugin_id,
&skill_name,
)
.await
.map_err(|err| {
remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin skill details")
})?;
Ok(PluginSkillReadResponse {
contents: remote_skill_detail.contents,
})
}
pub(super) async fn plugin_share_save(
&self,
request_id: ConnectionRequestId,
@@ -514,13 +571,11 @@ impl CodexMessageProcessor {
if !config.features.enabled(Feature::Plugins)
|| !config.features.enabled(Feature::RemotePlugin)
{
return Err(invalid_request("remote plugin install is not enabled"));
}
if remote_plugin_id.is_empty() || !is_valid_remote_plugin_id(&remote_plugin_id) {
return Err(invalid_request(
"invalid remote plugin id: only ASCII letters, digits, `_`, `-`, and `~` are allowed",
));
return Err(invalid_request(format!(
"remote plugin install is not enabled for marketplace {remote_marketplace_name}"
)));
}
validate_remote_plugin_id(&remote_plugin_id)?;
let auth = self.auth_manager.auth().await;
let remote_plugin_service_config = RemotePluginServiceConfig {
@@ -703,13 +758,13 @@ impl CodexMessageProcessor {
) -> Result<PluginUninstallResponse, JSONRPCErrorError> {
let PluginUninstallParams { plugin_id } = params;
if codex_plugin::PluginId::parse(&plugin_id).is_err()
&& (plugin_id.is_empty() || !is_valid_remote_plugin_id(&plugin_id))
&& !is_valid_remote_uninstall_plugin_id(&plugin_id)
{
return Err(invalid_request(
"invalid plugin id: expected a local plugin id or remote plugin id",
"invalid plugin id: expected a local plugin id in the form `plugin@marketplace` or a remote plugin id starting with `plugins~`, `plugins_`, `app_`, `asdk_app_`, or `connector_`",
));
}
if !plugin_id.is_empty() && is_valid_remote_plugin_id(&plugin_id) {
if is_valid_remote_uninstall_plugin_id(&plugin_id) {
return self.remote_plugin_uninstall_response(plugin_id).await;
}
let plugins_manager = self.thread_manager.plugins_manager();
@@ -800,9 +855,7 @@ impl CodexMessageProcessor {
{
return Err(invalid_request("remote plugin uninstall is not enabled"));
}
if plugin_id.is_empty() || !is_valid_remote_plugin_id(&plugin_id) {
return Err(invalid_request("invalid remote plugin id"));
}
validate_remote_plugin_id(&plugin_id)?;
let auth = self.auth_manager.auth().await;
let remote_plugin_service_config = RemotePluginServiceConfig {
@@ -838,10 +891,13 @@ impl CodexMessageProcessor {
}
}
fn is_valid_remote_plugin_id(plugin_name: &str) -> bool {
plugin_name
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '~')
fn is_valid_remote_uninstall_plugin_id(plugin_name: &str) -> bool {
is_valid_remote_plugin_id(plugin_name)
&& (plugin_name.starts_with("plugins~")
|| plugin_name.starts_with("plugins_")
|| plugin_name.starts_with("app_")
|| plugin_name.starts_with("asdk_app_")
|| plugin_name.starts_with("connector_"))
}
fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketplaceEntry {
@@ -919,7 +975,8 @@ fn remote_plugin_catalog_error_to_jsonrpc(
}
}
RemotePluginCatalogError::InvalidPluginPath { .. }
| RemotePluginCatalogError::ArchiveTooLarge { .. } => JSONRPCErrorError {
| RemotePluginCatalogError::ArchiveTooLarge { .. }
| RemotePluginCatalogError::UnknownMarketplace { .. } => JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!("{context}: {err}"),
data: None,
@@ -928,7 +985,10 @@ fn remote_plugin_catalog_error_to_jsonrpc(
| RemotePluginCatalogError::Request { .. }
| RemotePluginCatalogError::UnexpectedStatus { .. }
| RemotePluginCatalogError::Decode { .. }
| RemotePluginCatalogError::InvalidBaseUrl(_)
| RemotePluginCatalogError::InvalidBaseUrlPath
| RemotePluginCatalogError::UnexpectedPluginId { .. }
| RemotePluginCatalogError::UnexpectedSkillName { .. }
| RemotePluginCatalogError::UnexpectedEnabledState { .. }
| RemotePluginCatalogError::Archive { .. }
| RemotePluginCatalogError::ArchiveJoin(_)
@@ -59,6 +59,7 @@ use codex_app_server_protocol::ModelProviderCapabilitiesReadParams;
use codex_app_server_protocol::PluginInstallParams;
use codex_app_server_protocol::PluginListParams;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginSkillReadParams;
use codex_app_server_protocol::PluginUninstallParams;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewStartParams;
@@ -660,6 +661,15 @@ impl McpProcess {
self.send_request("plugin/read", params).await
}
/// Send a `plugin/skill/read` JSON-RPC request.
pub async fn send_plugin_skill_read_request(
&mut self,
params: PluginSkillReadParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("plugin/skill/read", params).await
}
/// Send an `mcpServerStatus/list` JSON-RPC request.
pub async fn send_list_mcp_server_status_request(
&mut self,
@@ -22,6 +22,8 @@ use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginReadParams;
use codex_app_server_protocol::PluginReadResponse;
use codex_app_server_protocol::PluginSkillReadParams;
use codex_app_server_protocol::PluginSkillReadResponse;
use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::RequestId;
use codex_config::types::AuthCredentialsStoreMode;
@@ -139,6 +141,7 @@ async fn plugin_read_rejects_remote_marketplace_when_remote_plugin_is_disabled()
.message
.contains("remote plugin read is not enabled")
);
assert!(err.error.message.contains("chatgpt-global"));
Ok(())
}
@@ -252,7 +255,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: None,
remote_marketplace_name: Some("caller-marketplace-is-ignored".to_string()),
remote_marketplace_name: Some("chatgpt-global".to_string()),
plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(),
})
.await?;
@@ -286,6 +289,70 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
Ok(())
}
#[tokio::test]
async fn plugin_skill_read_reads_remote_skill_contents_when_remote_plugin_enabled() -> 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 skill_body = r##"{
"plugin_id": "plugins~Plugin_00000000000000000000000000000000",
"status": "ENABLED",
"plugin_release_id": "release-1",
"name": "plan-work",
"description": "Plan work from Linear issues",
"plugin_release_skill_id": "skill-1",
"skill_md_contents": "# Plan Work\n\nUse Linear issues to create a plan."
}"##;
Mock::given(method("GET"))
.and(path(
"/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000/skills/plan-work",
))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(200).set_body_string(skill_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_skill_read_request(PluginSkillReadParams {
remote_marketplace_name: "chatgpt-global".to_string(),
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
skill_name: "plan-work".to_string(),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginSkillReadResponse = to_response(response)?;
assert_eq!(
response,
PluginSkillReadResponse {
contents: Some("# Plan Work\n\nUse Linear issues to create a plan.".to_string()),
}
);
Ok(())
}
#[tokio::test]
async fn plugin_read_maps_missing_remote_plugin_to_invalid_request() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -412,6 +479,11 @@ async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> {
assert_eq!(err.error.code, -32600);
assert!(err.error.message.contains("invalid remote plugin id"));
assert!(
err.error
.message
.contains("only ASCII letters, digits, `_`, `-`, and `~` are allowed")
);
Ok(())
}
+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);
-29
View File
@@ -10,7 +10,6 @@ from .generated.v2_all import (
ApprovalsReviewer,
AskForApproval,
ModelListResponse,
PermissionProfile,
Personality,
ReasoningEffort,
ReasoningSummary,
@@ -150,7 +149,6 @@ class Codex:
ephemeral: bool | None = None,
model: str | None = None,
model_provider: str | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox: SandboxMode | None = None,
service_name: str | None = None,
@@ -167,7 +165,6 @@ class Codex:
ephemeral=ephemeral,
model=model,
model_provider=model_provider,
permission_profile=permission_profile,
personality=personality,
sandbox=sandbox,
service_name=service_name,
@@ -215,10 +212,8 @@ class Codex:
config: JsonObject | None = None,
cwd: str | None = None,
developer_instructions: str | None = None,
exclude_turns: bool | None = None,
model: str | None = None,
model_provider: str | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox: SandboxMode | None = None,
service_tier: ServiceTier | None = None,
@@ -231,10 +226,8 @@ class Codex:
config=config,
cwd=cwd,
developer_instructions=developer_instructions,
exclude_turns=exclude_turns,
model=model,
model_provider=model_provider,
permission_profile=permission_profile,
personality=personality,
sandbox=sandbox,
service_tier=service_tier,
@@ -253,10 +246,8 @@ class Codex:
cwd: str | None = None,
developer_instructions: str | None = None,
ephemeral: bool | None = None,
exclude_turns: bool | None = None,
model: str | None = None,
model_provider: str | None = None,
permission_profile: PermissionProfile | None = None,
sandbox: SandboxMode | None = None,
service_tier: ServiceTier | None = None,
) -> Thread:
@@ -269,10 +260,8 @@ class Codex:
cwd=cwd,
developer_instructions=developer_instructions,
ephemeral=ephemeral,
exclude_turns=exclude_turns,
model=model,
model_provider=model_provider,
permission_profile=permission_profile,
sandbox=sandbox,
service_tier=service_tier,
)
@@ -356,7 +345,6 @@ class AsyncCodex:
ephemeral: bool | None = None,
model: str | None = None,
model_provider: str | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox: SandboxMode | None = None,
service_name: str | None = None,
@@ -374,7 +362,6 @@ class AsyncCodex:
ephemeral=ephemeral,
model=model,
model_provider=model_provider,
permission_profile=permission_profile,
personality=personality,
sandbox=sandbox,
service_name=service_name,
@@ -423,10 +410,8 @@ class AsyncCodex:
config: JsonObject | None = None,
cwd: str | None = None,
developer_instructions: str | None = None,
exclude_turns: bool | None = None,
model: str | None = None,
model_provider: str | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox: SandboxMode | None = None,
service_tier: ServiceTier | None = None,
@@ -440,10 +425,8 @@ class AsyncCodex:
config=config,
cwd=cwd,
developer_instructions=developer_instructions,
exclude_turns=exclude_turns,
model=model,
model_provider=model_provider,
permission_profile=permission_profile,
personality=personality,
sandbox=sandbox,
service_tier=service_tier,
@@ -462,10 +445,8 @@ class AsyncCodex:
cwd: str | None = None,
developer_instructions: str | None = None,
ephemeral: bool | None = None,
exclude_turns: bool | None = None,
model: str | None = None,
model_provider: str | None = None,
permission_profile: PermissionProfile | None = None,
sandbox: SandboxMode | None = None,
service_tier: ServiceTier | None = None,
) -> AsyncThread:
@@ -479,10 +460,8 @@ class AsyncCodex:
cwd=cwd,
developer_instructions=developer_instructions,
ephemeral=ephemeral,
exclude_turns=exclude_turns,
model=model,
model_provider=model_provider,
permission_profile=permission_profile,
sandbox=sandbox,
service_tier=service_tier,
)
@@ -519,7 +498,6 @@ class Thread:
effort: ReasoningEffort | None = None,
model: str | None = None,
output_schema: JsonObject | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox_policy: SandboxPolicy | None = None,
service_tier: ServiceTier | None = None,
@@ -533,7 +511,6 @@ class Thread:
effort=effort,
model=model,
output_schema=output_schema,
permission_profile=permission_profile,
personality=personality,
sandbox_policy=sandbox_policy,
service_tier=service_tier,
@@ -556,7 +533,6 @@ class Thread:
effort: ReasoningEffort | None = None,
model: str | None = None,
output_schema: JsonObject | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox_policy: SandboxPolicy | None = None,
service_tier: ServiceTier | None = None,
@@ -572,7 +548,6 @@ class Thread:
effort=effort,
model=model,
output_schema=output_schema,
permission_profile=permission_profile,
personality=personality,
sandbox_policy=sandbox_policy,
service_tier=service_tier,
@@ -607,7 +582,6 @@ class AsyncThread:
effort: ReasoningEffort | None = None,
model: str | None = None,
output_schema: JsonObject | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox_policy: SandboxPolicy | None = None,
service_tier: ServiceTier | None = None,
@@ -621,7 +595,6 @@ class AsyncThread:
effort=effort,
model=model,
output_schema=output_schema,
permission_profile=permission_profile,
personality=personality,
sandbox_policy=sandbox_policy,
service_tier=service_tier,
@@ -644,7 +617,6 @@ class AsyncThread:
effort: ReasoningEffort | None = None,
model: str | None = None,
output_schema: JsonObject | None = None,
permission_profile: PermissionProfile | None = None,
personality: Personality | None = None,
sandbox_policy: SandboxPolicy | None = None,
service_tier: ServiceTier | None = None,
@@ -661,7 +633,6 @@ class AsyncThread:
effort=effort,
model=model,
output_schema=output_schema,
permission_profile=permission_profile,
personality=personality,
sandbox_policy=sandbox_policy,
service_tier=service_tier,
@@ -38,6 +38,7 @@ from .v2_all import PlanDeltaNotification
from .v2_all import ReasoningSummaryPartAddedNotification
from .v2_all import ReasoningSummaryTextDeltaNotification
from .v2_all import ReasoningTextDeltaNotification
from .v2_all import RemoteControlStatusChangedNotification
from .v2_all import ServerRequestResolvedNotification
from .v2_all import SkillsChangedNotification
from .v2_all import TerminalInteractionNotification
@@ -100,6 +101,7 @@ NOTIFICATION_MODELS: dict[str, type[BaseModel]] = {
"mcpServer/startupStatus/updated": McpServerStatusUpdatedNotification,
"model/rerouted": ModelReroutedNotification,
"model/verification": ModelVerificationNotification,
"remoteControl/status/changed": RemoteControlStatusChangedNotification,
"serverRequest/resolved": ServerRequestResolvedNotification,
"skills/changed": SkillsChangedNotification,
"thread/archived": ThreadArchivedNotification,
File diff suppressed because it is too large Load Diff
@@ -65,7 +65,6 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"ephemeral",
"model",
"model_provider",
"permission_profile",
"personality",
"sandbox",
"service_name",
@@ -91,10 +90,8 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"config",
"cwd",
"developer_instructions",
"exclude_turns",
"model",
"model_provider",
"permission_profile",
"personality",
"sandbox",
"service_tier",
@@ -107,10 +104,8 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"cwd",
"developer_instructions",
"ephemeral",
"exclude_turns",
"model",
"model_provider",
"permission_profile",
"sandbox",
"service_tier",
],
@@ -121,7 +116,6 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"effort",
"model",
"output_schema",
"permission_profile",
"personality",
"sandbox_policy",
"service_tier",
@@ -134,7 +128,6 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"effort",
"model",
"output_schema",
"permission_profile",
"personality",
"sandbox_policy",
"service_tier",
@@ -150,7 +143,6 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"ephemeral",
"model",
"model_provider",
"permission_profile",
"personality",
"sandbox",
"service_name",
@@ -176,10 +168,8 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"config",
"cwd",
"developer_instructions",
"exclude_turns",
"model",
"model_provider",
"permission_profile",
"personality",
"sandbox",
"service_tier",
@@ -192,10 +182,8 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"cwd",
"developer_instructions",
"ephemeral",
"exclude_turns",
"model",
"model_provider",
"permission_profile",
"sandbox",
"service_tier",
],
@@ -206,7 +194,6 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"effort",
"model",
"output_schema",
"permission_profile",
"personality",
"sandbox_policy",
"service_tier",
@@ -219,7 +206,6 @@ def test_generated_public_signatures_are_snake_case_and_typed() -> None:
"effort",
"model",
"output_schema",
"permission_profile",
"personality",
"sandbox_policy",
"service_tier",