feat: Add vertical remote plugin collection support (#23584)

- Adds an explicit vertical marketplace kind for plugin/list that
fail-open fetches collection=vertical only when full remote plugins are
disabled.

- Renames the global remote marketplace/cache identity to
openai-curated-remote and materializes remote installs with backend
release versions and app manifests.
This commit is contained in:
xl-openai
2026-05-19 22:03:08 -07:00
committed by GitHub
Unverified
parent 9dda71dbae
commit dc255b0d8a
18 changed files with 806 additions and 221 deletions
+4 -4
View File
@@ -150,7 +150,7 @@ async fn load_config(codex_home: &Path, cwd: &Path) -> PluginsConfigInput {
fn remote_installed_linear_plugin() -> RemoteInstalledPlugin {
RemoteInstalledPlugin {
marketplace_name: "chatgpt-global".to_string(),
marketplace_name: "openai-curated-remote".to_string(),
id: "plugins~Plugin_linear".to_string(),
name: "linear".to_string(),
enabled: true,
@@ -401,11 +401,11 @@ async fn build_remote_installed_plugin_marketplaces_from_cache_uses_remote_metad
.build_remote_installed_plugin_marketplaces_from_cache(&[RemotePluginScope::Global])
.expect("remote installed cache should be present");
assert_eq!(marketplaces.len(), 1);
assert_eq!(marketplaces[0].name, "chatgpt-global");
assert_eq!(marketplaces[0].display_name, "ChatGPT Plugins");
assert_eq!(marketplaces[0].name, "openai-curated-remote");
assert_eq!(marketplaces[0].display_name, "OpenAI Curated Remote");
assert_eq!(marketplaces[0].plugins.len(), 1);
let plugin = &marketplaces[0].plugins[0];
assert_eq!(plugin.id, "linear@chatgpt-global");
assert_eq!(plugin.id, "linear@openai-curated-remote");
assert_eq!(plugin.remote_plugin_id, "plugins~Plugin_linear");
assert_eq!(plugin.name, "linear");
assert_eq!(plugin.installed, true);
+66 -3
View File
@@ -12,6 +12,7 @@ use codex_plugin::PluginId;
use codex_utils_absolute_path::AbsolutePathBuf;
use reqwest::RequestBuilder;
use serde::Deserialize;
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashSet;
@@ -46,19 +47,20 @@ pub use share::load_plugin_share_remote_ids_by_local_path;
pub use share::save_remote_plugin_share;
pub use share::update_remote_plugin_share_targets;
pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global";
pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "openai-curated-remote";
pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "workspace-directory";
pub const REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME: &str = "workspace-shared-with-me";
pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME: &str =
"workspace-shared-with-me-private";
pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME: &str =
"workspace-shared-with-me-unlisted";
pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Plugins";
pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "OpenAI Curated Remote";
pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "Workspace Directory";
pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME: &str = "Shared with me";
pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME: &str =
"Shared with me (unlisted)";
const OPENAI_CURATED_REMOTE_COLLECTION_KEY: &str = "vertical";
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;
@@ -158,6 +160,7 @@ pub struct RemotePluginDetail {
pub description: Option<String>,
pub release_version: Option<String>,
pub bundle_download_url: Option<String>,
pub app_manifest: Option<JsonValue>,
pub skills: Vec<RemotePluginSkill>,
pub app_ids: Vec<String>,
}
@@ -391,6 +394,8 @@ struct RemotePluginReleaseResponse {
#[serde(default)]
app_ids: Vec<String>,
#[serde(default)]
app_manifest: Option<JsonValue>,
#[serde(default)]
keywords: Vec<String>,
interface: RemotePluginReleaseInterfaceResponse,
#[serde(default)]
@@ -595,6 +600,31 @@ pub async fn fetch_remote_marketplaces(
Ok(marketplaces)
}
pub async fn fetch_openai_curated_remote_collection_marketplace(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
) -> Result<Option<RemoteMarketplace>, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let scope = RemotePluginScope::Global;
let (directory_plugins, installed_plugins) = tokio::try_join!(
fetch_directory_plugins_for_scope_with_collection(
config,
auth,
scope,
OPENAI_CURATED_REMOTE_COLLECTION_KEY,
),
fetch_installed_plugins_for_scope(config, auth, scope),
)?;
build_remote_marketplace(
REMOTE_GLOBAL_MARKETPLACE_NAME,
REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME,
directory_plugins,
installed_plugins,
/*include_installed_only*/ false,
)
}
fn build_remote_marketplace(
name: &str,
display_name: &str,
@@ -866,6 +896,7 @@ async fn build_remote_plugin_detail(
description: non_empty_string(Some(&plugin.release.description)),
release_version: plugin.release.version,
bundle_download_url: plugin.release.bundle_download_url,
app_manifest: plugin.release.app_manifest,
skills,
app_ids: plugin.release.app_ids,
})
@@ -1174,12 +1205,40 @@ async fn fetch_directory_plugins_for_scope(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
scope: RemotePluginScope,
) -> Result<Vec<RemotePluginDirectoryItem>, RemotePluginCatalogError> {
fetch_directory_plugins_for_scope_with_optional_collection(
config, auth, scope, /*collection*/ None,
)
.await
}
async fn fetch_directory_plugins_for_scope_with_collection(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
scope: RemotePluginScope,
collection: &str,
) -> Result<Vec<RemotePluginDirectoryItem>, RemotePluginCatalogError> {
fetch_directory_plugins_for_scope_with_optional_collection(
config,
auth,
scope,
Some(collection),
)
.await
}
async fn fetch_directory_plugins_for_scope_with_optional_collection(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
scope: RemotePluginScope,
collection: Option<&str>,
) -> Result<Vec<RemotePluginDirectoryItem>, RemotePluginCatalogError> {
let mut plugins = Vec::new();
let mut page_token = None;
loop {
let response =
get_remote_plugin_list_page(config, auth, scope, page_token.as_deref()).await?;
get_remote_plugin_list_page(config, auth, scope, page_token.as_deref(), collection)
.await?;
plugins.extend(response.plugins);
let Some(next_page_token) = response.pagination.next_page_token else {
break;
@@ -1249,6 +1308,7 @@ async fn get_remote_plugin_list_page(
auth: &CodexAuth,
scope: RemotePluginScope,
page_token: Option<&str>,
collection: Option<&str>,
) -> Result<RemotePluginListResponse, RemotePluginCatalogError> {
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/ps/plugins/list");
@@ -1256,6 +1316,9 @@ async fn get_remote_plugin_list_page(
let mut request = authenticated_request(client.get(&url), auth)?;
request = request.query(&[("scope", scope.api_value())]);
request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]);
if let Some(collection) = collection {
request = request.query(&[("collection", collection)]);
}
if let Some(page_token) = page_token {
request = request.query(&[("pageToken", page_token)]);
}
@@ -208,6 +208,7 @@ pub async fn sync_remote_installed_plugin_bundles_once(
&plugin.name,
release_version,
plugin.release.bundle_download_url.as_deref(),
plugin.release.app_manifest.clone(),
) {
Ok(bundle) => bundle,
Err(err) => {
@@ -511,7 +512,7 @@ mod tests {
&installed_plugin_names_by_marketplace,
)
.expect("cleanup after install guard is dropped");
assert_eq!(removed, vec!["linear@chatgpt-global".to_string()]);
assert_eq!(removed, vec!["linear@openai-curated-remote".to_string()]);
assert!(!cached_manifest.exists());
}
@@ -85,6 +85,7 @@ pub async fn checkout_remote_plugin_share(
&plugin_name,
detail.release_version.as_deref(),
detail.bundle_download_url.as_deref(),
/*app_manifest*/ None,
)
.map_err(|err| {
RemotePluginCatalogError::UnexpectedResponse(format!(
+175 -7
View File
@@ -1,3 +1,4 @@
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
use crate::store::PluginInstallResult;
use crate::store::PluginStore;
use crate::store::PluginStoreError;
@@ -10,6 +11,7 @@ use codex_utils_plugins::find_plugin_manifest_path;
use flate2::read::GzDecoder;
use reqwest::Response;
use reqwest::StatusCode;
use serde_json::Value as JsonValue;
use std::fs;
use std::io;
use std::io::Read;
@@ -33,6 +35,7 @@ const TEST_ALLOW_LOOPBACK_HTTP_REMOTE_PLUGIN_BUNDLES_ENV: &str =
pub struct ValidatedRemotePluginBundle {
pub plugin_id: PluginId,
pub plugin_version: String,
app_manifest: Option<JsonValue>,
bundle_download_url: String,
}
@@ -137,6 +140,7 @@ pub fn validate_remote_plugin_bundle(
plugin_name: &str,
release_version: Option<&str>,
bundle_download_url: Option<&str>,
app_manifest: Option<JsonValue>,
) -> Result<ValidatedRemotePluginBundle, RemotePluginBundleInstallError> {
let plugin_id = PluginId::new(plugin_name.to_string(), remote_marketplace_name.to_string())
.map_err(|source| RemotePluginBundleInstallError::InvalidPluginId {
@@ -187,6 +191,7 @@ pub fn validate_remote_plugin_bundle(
Ok(ValidatedRemotePluginBundle {
plugin_id,
plugin_version,
app_manifest,
bundle_download_url,
})
}
@@ -367,6 +372,7 @@ fn install_remote_plugin_bundle(
extract_plugin_bundle_tar_gz(&bundle_bytes, extract_dir.path())?;
let plugin_root = find_extracted_plugin_root(extract_dir.path())?;
prepare_extracted_remote_plugin_root(&plugin_root, &bundle)?;
let plugin_root = AbsolutePathBuf::try_from(plugin_root).map_err(|err| {
RemotePluginBundleInstallError::InvalidBundle(format!(
"failed to resolve extracted remote plugin bundle root: {err}"
@@ -436,6 +442,90 @@ fn extract_remote_plugin_bundle_to_path(
Ok(destination)
}
fn prepare_extracted_remote_plugin_root(
plugin_root: &Path,
bundle: &ValidatedRemotePluginBundle,
) -> Result<(), RemotePluginBundleInstallError> {
if bundle.plugin_id.marketplace_name != REMOTE_GLOBAL_MARKETPLACE_NAME {
return Ok(());
}
overwrite_plugin_manifest_version(plugin_root, &bundle.plugin_version)?;
if let Some(app_manifest) = &bundle.app_manifest {
overwrite_plugin_app_manifest(plugin_root, app_manifest)?;
}
Ok(())
}
fn overwrite_plugin_manifest_version(
plugin_root: &Path,
plugin_version: &str,
) -> Result<(), RemotePluginBundleInstallError> {
let manifest_path = find_plugin_manifest_path(plugin_root).ok_or_else(|| {
RemotePluginBundleInstallError::InvalidBundle(
"remote plugin bundle did not contain a valid plugin.json".to_string(),
)
})?;
let contents = fs::read_to_string(&manifest_path).map_err(|source| {
RemotePluginBundleInstallError::io("failed to read remote plugin manifest", source)
})?;
let mut manifest: JsonValue = serde_json::from_str(&contents).map_err(|err| {
RemotePluginBundleInstallError::InvalidBundle(format!(
"failed to parse remote plugin manifest: {err}"
))
})?;
let Some(manifest_object) = manifest.as_object_mut() else {
return Err(RemotePluginBundleInstallError::InvalidBundle(
"remote plugin manifest must be a JSON object".to_string(),
));
};
manifest_object.insert(
"version".to_string(),
JsonValue::String(plugin_version.to_string()),
);
write_json_file(
&manifest_path,
&manifest,
"failed to write remote plugin manifest",
)
}
fn overwrite_plugin_app_manifest(
plugin_root: &Path,
app_manifest: &JsonValue,
) -> Result<(), RemotePluginBundleInstallError> {
let app_manifest_path = crate::manifest::load_plugin_manifest(plugin_root)
.and_then(|manifest| manifest.paths.apps.map(|path| path.to_path_buf()))
.unwrap_or_else(|| plugin_root.join(".app.json"));
write_json_file(
&app_manifest_path,
app_manifest,
"failed to write remote plugin app manifest",
)
}
fn write_json_file(
path: &Path,
value: &JsonValue,
context: &'static str,
) -> Result<(), RemotePluginBundleInstallError> {
let parent = path.parent().ok_or_else(|| {
RemotePluginBundleInstallError::InvalidBundle(format!(
"remote plugin output path has no parent: {}",
path.display()
))
})?;
fs::create_dir_all(parent)
.map_err(|source| RemotePluginBundleInstallError::io(context, source))?;
let mut contents = serde_json::to_vec_pretty(value).map_err(|err| {
RemotePluginBundleInstallError::InvalidBundle(format!(
"failed to serialize remote plugin JSON override: {err}"
))
})?;
contents.push(b'\n');
fs::write(path, contents).map_err(|source| RemotePluginBundleInstallError::io(context, source))
}
fn extract_plugin_bundle_tar_gz(
bytes: &[u8],
destination: &Path,
@@ -624,15 +714,16 @@ mod tests {
fn validate_remote_plugin_bundle_uses_detail_name_for_local_plugin_id() {
let bundle = validate_remote_plugin_bundle(
REMOTE_PLUGIN_ID,
"chatgpt-global",
"openai-curated-remote",
"linear",
Some("1.2.3"),
Some("https://example.com/linear.tar.gz"),
/*app_manifest*/ None,
)
.expect("valid install plan");
assert_eq!(bundle.plugin_id.plugin_name, "linear");
assert_eq!(bundle.plugin_id.marketplace_name, "chatgpt-global");
assert_eq!(bundle.plugin_id.marketplace_name, "openai-curated-remote");
assert_eq!(bundle.plugin_version, "1.2.3");
assert_eq!(
bundle.bundle_download_url.as_str(),
@@ -644,10 +735,11 @@ mod tests {
fn validate_remote_plugin_bundle_rejects_missing_release_version() {
let err = validate_remote_plugin_bundle(
REMOTE_PLUGIN_ID,
"chatgpt-global",
"openai-curated-remote",
"linear",
/*release_version*/ None,
Some("https://example.com/linear.tar.gz"),
/*app_manifest*/ None,
)
.expect_err("missing release version should be rejected");
@@ -661,10 +753,11 @@ mod tests {
fn validate_remote_plugin_bundle_rejects_invalid_release_version() {
let err = validate_remote_plugin_bundle(
REMOTE_PLUGIN_ID,
"chatgpt-global",
"openai-curated-remote",
"linear",
Some("../1.2.3"),
Some("https://example.com/linear.tar.gz"),
/*app_manifest*/ None,
)
.expect_err("invalid release version should be rejected");
@@ -678,10 +771,11 @@ mod tests {
fn validate_remote_plugin_bundle_rejects_missing_download_url() {
let err = validate_remote_plugin_bundle(
REMOTE_PLUGIN_ID,
"chatgpt-global",
"openai-curated-remote",
"linear",
Some("1.2.3"),
/*bundle_download_url*/ None,
/*app_manifest*/ None,
)
.expect_err("missing bundle download URL should be rejected");
@@ -695,10 +789,11 @@ mod tests {
fn validate_remote_plugin_bundle_rejects_unsupported_download_url_scheme() {
let err = validate_remote_plugin_bundle(
REMOTE_PLUGIN_ID,
"chatgpt-global",
"openai-curated-remote",
"linear",
Some("1.2.3"),
Some("http://example.com/linear.tar.gz"),
/*app_manifest*/ None,
)
.expect_err("plain HTTP URLs should be rejected before cloud install");
@@ -755,6 +850,78 @@ mod tests {
);
}
#[test]
fn install_preserves_non_global_bundle_manifest_metadata() {
let codex_home = tempdir().expect("tempdir");
let bundle = validate_remote_plugin_bundle(
REMOTE_PLUGIN_ID,
"workspace-shared-with-me",
"linear",
Some("backend-version"),
Some("https://example.com/linear.tar.gz"),
Some(serde_json::json!({
"apps": {
"remote": {
"id": "remote-app"
}
}
})),
)
.expect("valid install plan");
let result = install_remote_plugin_bundle(
codex_home.path().to_path_buf(),
bundle,
tar_gz_bytes(&[
(
".codex-plugin/plugin.json",
br#"{"name":"linear","version":"bundle-version"}"#,
/*mode*/ 0o644,
),
(
".app.json",
br#"{"apps":{"bundled":{"id":"bundled-app"}}}"#,
/*mode*/ 0o644,
),
]),
)
.expect("install bundle");
assert_eq!(result.plugin_version, "backend-version");
let installed_manifest: JsonValue = serde_json::from_str(
&std::fs::read_to_string(
result
.installed_path
.join(".codex-plugin/plugin.json")
.as_path(),
)
.expect("read installed plugin manifest"),
)
.expect("parse installed plugin manifest");
assert_eq!(
installed_manifest,
serde_json::json!({
"name": "linear",
"version": "bundle-version",
})
);
let installed_app_manifest: JsonValue = serde_json::from_str(
&std::fs::read_to_string(result.installed_path.join(".app.json").as_path())
.expect("read installed app manifest"),
)
.expect("parse installed app manifest");
assert_eq!(
installed_app_manifest,
serde_json::json!({
"apps": {
"bundled": {
"id": "bundled-app",
},
},
})
);
}
#[test]
fn find_extracted_plugin_root_uses_local_manifest_discovery() {
let extraction_root = tempdir().expect("tempdir");
@@ -866,10 +1033,11 @@ mod tests {
fn valid_remote_plugin_bundle() -> ValidatedRemotePluginBundle {
validate_remote_plugin_bundle(
REMOTE_PLUGIN_ID,
"chatgpt-global",
"openai-curated-remote",
"linear",
Some("1.2.3"),
Some("https://example.com/linear.tar.gz"),
/*app_manifest*/ None,
)
.expect("valid install plan")
}