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
@@ -1632,6 +1632,7 @@
"PluginListMarketplaceKind": {
"enum": [
"local",
"vertical",
"workspace-directory",
"shared-with-me"
],
@@ -12193,6 +12193,7 @@
"PluginListMarketplaceKind": {
"enum": [
"local",
"vertical",
"workspace-directory",
"shared-with-me"
],
@@ -8742,6 +8742,7 @@
"PluginListMarketplaceKind": {
"enum": [
"local",
"vertical",
"workspace-directory",
"shared-with-me"
],
@@ -8,6 +8,7 @@
"PluginListMarketplaceKind": {
"enum": [
"local",
"vertical",
"workspace-directory",
"shared-with-me"
],
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type PluginListMarketplaceKind = "local" | "workspace-directory" | "shared-with-me";
export type PluginListMarketplaceKind = "local" | "vertical" | "workspace-directory" | "shared-with-me";
@@ -144,6 +144,9 @@ pub enum PluginListMarketplaceKind {
#[serde(rename = "local")]
#[ts(rename = "local")]
Local,
#[serde(rename = "vertical")]
#[ts(rename = "vertical")]
Vertical,
#[serde(rename = "workspace-directory")]
#[ts(rename = "workspace-directory")]
WorkspaceDirectory,
@@ -2708,14 +2708,14 @@ fn marketplace_upgrade_params_serialization_uses_optional_marketplace_name() {
fn plugin_marketplace_entry_serializes_remote_only_path_as_null() {
assert_eq!(
serde_json::to_value(PluginMarketplaceEntry {
name: "openai-curated".to_string(),
name: "openai-curated-remote".to_string(),
path: None,
interface: None,
plugins: Vec::new(),
})
.unwrap(),
json!({
"name": "openai-curated",
"name": "openai-curated-remote",
"path": null,
"interface": null,
"plugins": [],
@@ -2799,6 +2799,7 @@ fn plugin_list_params_serializes_marketplace_kind_filter() {
cwds: None,
marketplace_kinds: Some(vec![
PluginListMarketplaceKind::Local,
PluginListMarketplaceKind::Vertical,
PluginListMarketplaceKind::WorkspaceDirectory,
PluginListMarketplaceKind::SharedWithMe,
]),
@@ -2808,6 +2809,7 @@ fn plugin_list_params_serializes_marketplace_kind_filter() {
"cwds": null,
"marketplaceKinds": [
"local",
"vertical",
"workspace-directory",
"shared-with-me",
],
@@ -2875,13 +2877,13 @@ fn plugin_read_params_serialization_uses_install_source_fields() {
assert_eq!(
serde_json::from_value::<PluginReadParams>(json!({
"remoteMarketplaceName": "openai-curated",
"remoteMarketplaceName": "openai-curated-remote",
"pluginName": "gmail",
}))
.unwrap(),
PluginReadParams {
marketplace_path: None,
remote_marketplace_name: Some("openai-curated".to_string()),
remote_marketplace_name: Some("openai-curated-remote".to_string()),
plugin_name: "gmail".to_string(),
},
);
@@ -2926,14 +2928,14 @@ fn plugin_install_params_serialization_omits_force_remote_sync() {
assert_eq!(
serde_json::from_value::<PluginInstallParams>(json!({
"remoteMarketplaceName": "openai-curated",
"remoteMarketplaceName": "openai-curated-remote",
"pluginName": "gmail",
"forceRemoteSync": true,
}))
.unwrap(),
PluginInstallParams {
marketplace_path: None,
remote_marketplace_name: Some("openai-curated".to_string()),
remote_marketplace_name: Some("openai-curated-remote".to_string()),
plugin_name: "gmail".to_string(),
},
);
@@ -2943,13 +2945,13 @@ fn plugin_install_params_serialization_omits_force_remote_sync() {
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_marketplace_name: "openai-curated-remote".to_string(),
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
skill_name: "plan-work".to_string(),
})
.unwrap(),
json!({
"remoteMarketplaceName": "chatgpt-global",
"remoteMarketplaceName": "openai-curated-remote",
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
"skillName": "plan-work",
}),
@@ -3144,7 +3146,7 @@ fn plugin_share_list_response_serializes_share_items() {
serde_json::to_value(PluginShareListResponse {
data: vec![PluginShareListItem {
plugin: PluginSummary {
id: "gmail@chatgpt-global".to_string(),
id: "gmail@openai-curated-remote".to_string(),
remote_plugin_id: Some(
"plugins~Plugin_00000000000000000000000000000000".to_string(),
),
@@ -3167,7 +3169,7 @@ fn plugin_share_list_response_serializes_share_items() {
json!({
"data": [{
"plugin": {
"id": "gmail@chatgpt-global",
"id": "gmail@openai-curated-remote",
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
"localVersion": null,
"name": "gmail",
@@ -475,6 +475,7 @@ impl PluginRequestProcessor {
let marketplace_kinds =
marketplace_kinds.unwrap_or_else(|| vec![PluginListMarketplaceKind::Local]);
let include_local = marketplace_kinds.contains(&PluginListMarketplaceKind::Local);
let include_vertical = marketplace_kinds.contains(&PluginListMarketplaceKind::Vertical);
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
let empty_response = || PluginListResponse {
@@ -564,6 +565,35 @@ impl PluginRequestProcessor {
(Vec::new(), Vec::new())
};
// TODO(remote plugins): Remove this once remote plugins are ready and vertical plugins are
// served directly from the normal remote catalog.
if include_vertical && !config.features.enabled(Feature::RemotePlugin) {
let remote_plugin_service_config = RemotePluginServiceConfig {
chatgpt_base_url: config.chatgpt_base_url.clone(),
};
match codex_core_plugins::remote::fetch_openai_curated_remote_collection_marketplace(
&remote_plugin_service_config,
auth.as_ref(),
)
.await
{
Ok(Some(remote_marketplace)) => {
data.push(remote_marketplace_to_info(remote_marketplace));
}
Ok(None) => {}
Err(
RemotePluginCatalogError::AuthRequired
| RemotePluginCatalogError::UnsupportedAuthMode,
) => {}
Err(err) => {
warn!(
error = %err,
"plugin/list openai-curated-remote collection fetch failed; returning local marketplaces only"
);
}
}
}
let mut remote_sources = Vec::new();
if !explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin) {
remote_sources.push(RemoteMarketplaceSource::Global);
@@ -592,14 +622,7 @@ impl PluginRequestProcessor {
.into_iter()
.map(remote_marketplace_to_info)
{
if let Some(existing) = data
.iter_mut()
.find(|marketplace| marketplace.name == remote_marketplace.name)
{
*existing = remote_marketplace;
} else {
data.push(remote_marketplace);
}
data.push(remote_marketplace);
}
}
Err(
@@ -1407,6 +1430,7 @@ impl PluginRequestProcessor {
&remote_detail.summary.name,
remote_detail.release_version.as_deref(),
remote_detail.bundle_download_url.as_deref(),
remote_detail.app_manifest.clone(),
)
.map_err(remote_plugin_bundle_install_error_to_jsonrpc)?;
@@ -132,7 +132,7 @@ async fn plugin_install_rejects_multiple_install_sources() -> Result<()> {
marketplace_path: Some(AbsolutePathBuf::try_from(
codex_home.path().join("marketplace.json"),
)?),
remote_marketplace_name: Some("openai-curated".to_string()),
remote_marketplace_name: Some("openai-curated-remote".to_string()),
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -167,7 +167,7 @@ plugins = false
let request_id = mcp
.send_plugin_install_request(PluginInstallParams {
marketplace_path: None,
remote_marketplace_name: Some("chatgpt-global".to_string()),
remote_marketplace_name: Some("openai-curated-remote".to_string()),
plugin_name: "plugins~Plugin_22222222222222222222222222222222".to_string(),
})
.await?;
@@ -193,15 +193,32 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()>
let server = MockServer::start().await;
let installed_path = codex_home
.path()
.join("plugins/cache/chatgpt-global/linear/1.2.3");
.join("plugins/cache/openai-curated-remote/linear/1.2.3");
let remote_app_manifest = json!({
"apps": {
"linear-remote": {
"id": "remote-linear-app"
}
}
});
let bundle_url = mount_remote_plugin_bundle(
&server,
/*status_code*/ 200,
remote_plugin_bundle_tar_gz_bytes("linear")?,
remote_plugin_bundle_tar_gz_bytes_with_contents(
r#"{"name":"linear","version":"0.0.1"}"#,
Some(r#"{"apps":{"linear-bundled":{"id":"bundled-linear-app"}}}"#),
)?,
)
.await;
configure_remote_plugin_test(codex_home.path(), &server)?;
mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await;
mount_remote_plugin_detail_with_app_manifest(
&server,
REMOTE_PLUGIN_ID,
"1.2.3",
Some(&bundle_url),
remote_app_manifest.clone(),
)
.await;
mount_empty_remote_installed_plugins(&server).await;
mount_remote_plugin_install_after_cache_write(
&server,
@@ -247,12 +264,20 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()>
)
.await?;
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
let installed_plugin_manifest: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(installed_path.join(".codex-plugin/plugin.json"))?,
)?;
assert_eq!(installed_plugin_manifest["name"], json!("linear"));
assert_eq!(installed_plugin_manifest["version"], json!("1.2.3"));
let installed_app_manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(installed_path.join(".app.json"))?)?;
assert_eq!(installed_app_manifest, remote_app_manifest);
assert!(installed_path.join("skills/plan-work/SKILL.md").is_file());
assert!(
!codex_home
.path()
.join(format!(
"plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}/1.2.3"
"plugins/cache/openai-curated-remote/{REMOTE_PLUGIN_ID}/1.2.3"
))
.exists()
);
@@ -299,7 +324,7 @@ async fn plugin_install_rejects_missing_remote_bundle_url() -> Result<()> {
assert!(
!codex_home
.path()
.join("plugins/cache/chatgpt-global/linear")
.join("plugins/cache/openai-curated-remote/linear")
.exists()
);
Ok(())
@@ -340,7 +365,7 @@ async fn plugin_install_rejects_plain_http_remote_bundle_url() -> Result<()> {
assert!(
!codex_home
.path()
.join("plugins/cache/chatgpt-global/linear")
.join("plugins/cache/openai-curated-remote/linear")
.exists()
);
Ok(())
@@ -382,7 +407,7 @@ async fn plugin_install_rejects_invalid_remote_release_version() -> Result<()> {
assert!(
!codex_home
.path()
.join("plugins/cache/chatgpt-global/linear")
.join("plugins/cache/openai-curated-remote/linear")
.exists()
);
Ok(())
@@ -398,7 +423,7 @@ async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> {
let request_id = mcp
.send_plugin_install_request(PluginInstallParams {
marketplace_path: None,
remote_marketplace_name: Some("chatgpt-global".to_string()),
remote_marketplace_name: Some("openai-curated-remote".to_string()),
plugin_name: "linear/../../oops".to_string(),
})
.await?;
@@ -468,7 +493,7 @@ async fn plugin_install_rejects_remote_plugin_disabled_by_admin_before_download(
assert!(
!codex_home
.path()
.join("plugins/cache/chatgpt-global/linear")
.join("plugins/cache/openai-curated-remote/linear")
.exists()
);
Ok(())
@@ -764,7 +789,7 @@ async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> {
"event_params": {
"plugin_id": REMOTE_PLUGIN_ID,
"plugin_name": "linear",
"marketplace_name": "chatgpt-global",
"marketplace_name": "openai-curated-remote",
"has_skills": true,
"mcp_server_count": 0,
"connector_ids": [],
@@ -824,7 +849,7 @@ async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()>
assert!(
!codex_home
.path()
.join("plugins/cache/chatgpt-global/linear")
.join("plugins/cache/openai-curated-remote/linear")
.exists()
);
Ok(())
@@ -1349,12 +1374,49 @@ async fn mount_remote_plugin_detail(
.await;
}
async fn mount_remote_plugin_detail_with_app_manifest(
server: &MockServer,
remote_plugin_id: &str,
release_version: &str,
bundle_download_url: Option<&str>,
app_manifest: serde_json::Value,
) {
mount_remote_plugin_detail_with_status_and_app_manifest(
server,
remote_plugin_id,
release_version,
bundle_download_url,
PluginAvailability::Available,
Some(app_manifest),
)
.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,
) {
mount_remote_plugin_detail_with_status_and_app_manifest(
server,
remote_plugin_id,
release_version,
bundle_download_url,
status,
/*app_manifest*/ None,
)
.await;
}
async fn mount_remote_plugin_detail_with_status_and_app_manifest(
server: &MockServer,
remote_plugin_id: &str,
release_version: &str,
bundle_download_url: Option<&str>,
status: PluginAvailability,
app_manifest: Option<serde_json::Value>,
) {
let status = match status {
PluginAvailability::Available => "ENABLED",
@@ -1363,6 +1425,9 @@ async fn mount_remote_plugin_detail_with_status(
let bundle_download_url_field = bundle_download_url
.map(|url| format!(r#" "bundle_download_url": "{url}","#))
.unwrap_or_default();
let app_manifest_field = app_manifest
.map(|manifest| format!(r#" "app_manifest": {manifest},"#))
.unwrap_or_default();
let detail_body = format!(
r#"{{
"id": "{remote_plugin_id}",
@@ -1377,6 +1442,7 @@ async fn mount_remote_plugin_detail_with_status(
"display_name": "Linear",
"description": "Track work in Linear",
"app_ids": [],
{app_manifest_field}
"interface": {{
"short_description": "Plan and track work"
}},
@@ -1576,13 +1642,20 @@ fn write_plugin_source(
fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result<Vec<u8>> {
let manifest = format!(r#"{{"name":"{plugin_name}"}}"#);
remote_plugin_bundle_tar_gz_bytes_with_contents(&manifest, /*app_manifest*/ None)
}
fn remote_plugin_bundle_tar_gz_bytes_with_contents(
plugin_manifest: &str,
app_manifest: Option<&str>,
) -> Result<Vec<u8>> {
let skill = "# Plan Work\n\nTrack work in Linear.\n";
let encoder = GzEncoder::new(Vec::new(), Compression::default());
let mut tar = tar::Builder::new(encoder);
for (path, contents, mode) in [
let mut entries = vec![
(
".codex-plugin/plugin.json",
manifest.as_bytes(),
plugin_manifest.as_bytes(),
/*mode*/ 0o644,
),
(
@@ -1590,7 +1663,11 @@ fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result<Vec<u8>> {
skill.as_bytes(),
/*mode*/ 0o644,
),
] {
];
if let Some(app_manifest) = app_manifest {
entries.push((".app.json", app_manifest.as_bytes(), /*mode*/ 0o644));
}
for (path, contents, mode) in entries {
let mut header = tar::Header::new_gnu();
header.set_size(contents.len() as u64);
header.set_mode(mode);
+393 -151
View File
@@ -1470,15 +1470,26 @@ async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() ->
remote_plugin_bundle_tar_gz_bytes("linear")?,
)
.await;
let global_installed_body =
remote_installed_plugin_body(&bundle_url, "1.2.3", /*enabled*/ true);
let remote_app_manifest = serde_json::json!({
"apps": {
"linear-remote": {
"id": "remote-linear-app"
}
}
});
let global_installed_body = remote_installed_plugin_body_with_app_manifest(
&bundle_url,
"1.2.3",
/*enabled*/ true,
remote_app_manifest.clone(),
);
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.await;
let installed_path = codex_home
.path()
.join("plugins/cache/chatgpt-global/linear/1.2.3");
.join("plugins/cache/openai-curated-remote/linear/1.2.3");
let mut mcp = McpProcess::new_with_env_and_plugin_startup_tasks(
codex_home.path(),
&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))],
@@ -1487,9 +1498,19 @@ async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() ->
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
wait_for_path_exists(&installed_path.join(".codex-plugin/plugin.json")).await?;
let installed_plugin_manifest: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(installed_path.join(".codex-plugin/plugin.json"))?,
)?;
assert_eq!(
installed_plugin_manifest["version"],
serde_json::json!("1.2.3")
);
let installed_app_manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(installed_path.join(".app.json"))?)?;
assert_eq!(installed_app_manifest, remote_app_manifest);
assert!(installed_path.join("skills/plan-work/SKILL.md").is_file());
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
assert!(!config.contains("linear@chatgpt-global"));
assert!(!config.contains("linear@openai-curated-remote"));
Ok(())
}
@@ -1509,8 +1530,8 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles()
.chatgpt_account_id("account-123"),
AuthCredentialsStoreMode::File,
)?;
write_installed_plugin_with_version(&codex_home, "chatgpt-global", "linear", "1.0.0")?;
write_installed_plugin_with_version(&codex_home, "chatgpt-global", "stale", "1.0.0")?;
write_installed_plugin_with_version(&codex_home, "openai-curated-remote", "linear", "1.0.0")?;
write_installed_plugin_with_version(&codex_home, "openai-curated-remote", "stale", "1.0.0")?;
let bundle_url = mount_remote_plugin_bundle(
&server,
@@ -1518,8 +1539,19 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles()
remote_plugin_bundle_tar_gz_bytes("linear")?,
)
.await;
let global_installed_body =
remote_installed_plugin_body(&bundle_url, "1.2.3", /*enabled*/ true);
let remote_app_manifest = serde_json::json!({
"apps": {
"linear-remote": {
"id": "remote-linear-app"
}
}
});
let global_installed_body = remote_installed_plugin_body_with_app_manifest(
&bundle_url,
"1.2.3",
/*enabled*/ true,
remote_app_manifest.clone(),
);
mount_remote_plugin_list(&server, "GLOBAL", &global_installed_body).await;
mount_remote_plugin_list(&server, "WORKSPACE", empty_remote_installed_plugins_body()).await;
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
@@ -1528,11 +1560,13 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles()
let old_path = codex_home
.path()
.join("plugins/cache/chatgpt-global/linear/1.0.0");
.join("plugins/cache/openai-curated-remote/linear/1.0.0");
let new_path = codex_home
.path()
.join("plugins/cache/chatgpt-global/linear/1.2.3");
let stale_path = codex_home.path().join("plugins/cache/chatgpt-global/stale");
.join("plugins/cache/openai-curated-remote/linear/1.2.3");
let stale_path = codex_home
.path()
.join("plugins/cache/openai-curated-remote/stale");
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
@@ -1556,22 +1590,32 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles()
let remote_marketplace = response
.marketplaces
.into_iter()
.find(|marketplace| marketplace.name == "chatgpt-global")
.expect("expected chatgpt-global marketplace entry");
.find(|marketplace| marketplace.name == "openai-curated-remote")
.expect("expected openai-curated-remote marketplace entry");
assert_eq!(
remote_marketplace
.plugins
.into_iter()
.map(|plugin| (plugin.id, plugin.installed, plugin.enabled))
.collect::<Vec<_>>(),
vec![("linear@chatgpt-global".to_string(), true, true)]
vec![("linear@openai-curated-remote".to_string(), true, true)]
);
wait_for_path_exists(&new_path.join(".codex-plugin/plugin.json")).await?;
let installed_plugin_manifest: serde_json::Value = serde_json::from_str(
&std::fs::read_to_string(new_path.join(".codex-plugin/plugin.json"))?,
)?;
assert_eq!(
installed_plugin_manifest["version"],
serde_json::json!("1.2.3")
);
let installed_app_manifest: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(new_path.join(".app.json"))?)?;
assert_eq!(installed_app_manifest, remote_app_manifest);
wait_for_path_missing(&old_path).await?;
wait_for_path_missing(&stale_path).await?;
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
assert!(!config.contains("linear@chatgpt-global"));
assert!(!config.contains("linear@openai-curated-remote"));
Ok(())
}
@@ -1714,18 +1758,21 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
let remote_marketplace = response
.marketplaces
.into_iter()
.find(|marketplace| marketplace.name == "chatgpt-global")
.expect("expected ChatGPT remote marketplace");
.find(|marketplace| marketplace.name == "openai-curated-remote")
.expect("expected openai-curated remote marketplace");
assert_eq!(remote_marketplace.path, None);
assert_eq!(
remote_marketplace
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref()),
Some("ChatGPT Plugins")
Some("OpenAI Curated Remote")
);
assert_eq!(remote_marketplace.plugins.len(), 1);
assert_eq!(remote_marketplace.plugins[0].id, "linear@chatgpt-global");
assert_eq!(
remote_marketplace.plugins[0].id,
"linear@openai-curated-remote"
);
assert_eq!(
remote_marketplace.plugins[0].remote_plugin_id.as_deref(),
Some("plugins~Plugin_00000000000000000000000000000000")
@@ -1753,6 +1800,283 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
]
);
assert_eq!(response.featured_plugin_ids, Vec::<String>::new());
assert!(
!server
.received_requests()
.await
.expect("wiremock should record requests")
.iter()
.any(|request| request
.url
.query_pairs()
.any(|(name, value)| name == "collection" && value == "vertical"))
);
Ok(())
}
#[tokio::test]
async fn plugin_list_includes_openai_curated_remote_collection_when_requested() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_plugins_enabled_config_with_base_url(
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 collection_body = r#"{
"plugins": [
{
"id": "plugins~Plugin_00000000000000000000000000000000",
"name": "linear",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"status": "ENABLED",
"release": {
"display_name": "Linear",
"description": "Track work in Linear",
"app_ids": [],
"interface": {
"short_description": "Plan and track work",
"capabilities": ["Read", "Write"]
},
"skills": []
}
}
],
"pagination": {
"limit": 50,
"next_page_token": null
}
}"#;
mount_openai_curated_remote_collection_plugin_list(&server, collection_body).await;
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.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,
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]),
})
.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 == "openai-curated-remote")
.expect("expected openai-curated remote marketplace");
assert_eq!(remote_marketplace.path, None);
assert_eq!(
remote_marketplace
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref()),
Some("OpenAI Curated Remote")
);
assert_eq!(remote_marketplace.plugins.len(), 1);
let plugin = &remote_marketplace.plugins[0];
assert_eq!(plugin.id, "linear@openai-curated-remote");
assert_eq!(
plugin.remote_plugin_id.as_deref(),
Some("plugins~Plugin_00000000000000000000000000000000")
);
assert_eq!(plugin.name, "linear");
assert_eq!(plugin.source, PluginSource::Remote);
assert_eq!(plugin.installed, false);
assert_eq!(plugin.enabled, false);
let requests = server
.received_requests()
.await
.expect("wiremock should record requests");
assert!(requests.iter().any(|request| {
request.method == "GET"
&& request.url.path().ends_with("/ps/plugins/list")
&& request
.url
.query_pairs()
.any(|(name, value)| name == "collection" && value == "vertical")
}));
Ok(())
}
#[tokio::test]
async fn plugin_list_fail_opens_openai_curated_remote_collection_errors() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_plugins_enabled_config_with_base_url(
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,
)?;
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/list"))
.and(query_param("scope", "GLOBAL"))
.and(query_param("limit", "200"))
.and(query_param("collection", "vertical"))
.and(header("authorization", "Bearer chatgpt-token"))
.and(header("chatgpt-account-id", "account-123"))
.respond_with(ResponseTemplate::new(500).set_body_string("temporary failure"))
.mount(&server)
.await;
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
.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,
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
assert!(
response
.marketplaces
.iter()
.all(|marketplace| marketplace.name != "openai-curated-remote")
);
Ok(())
}
#[tokio::test]
async fn plugin_list_does_not_query_openai_curated_remote_collection_by_default() -> Result<()> {
let codex_home = TempDir::new()?;
let server = MockServer::start().await;
write_plugins_enabled_config_with_base_url(
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 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,
marketplace_kinds: 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)?;
assert!(
response
.marketplaces
.iter()
.all(|marketplace| marketplace.name != "openai-curated-remote")
);
assert!(
server
.received_requests()
.await
.expect("wiremock should record requests")
.iter()
.all(|request| !request
.url
.query_pairs()
.any(|(name, value)| name == "collection" && value == "vertical"))
);
Ok(())
}
#[tokio::test]
async fn plugin_list_vertical_kind_noops_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 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,
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]),
})
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let response: PluginListResponse = to_response(response)?;
assert!(
response
.marketplaces
.iter()
.all(|marketplace| marketplace.name != "openai-curated-remote")
);
assert!(
server
.received_requests()
.await
.expect("wiremock should record requests")
.iter()
.all(|request| !request
.url
.query_pairs()
.any(|(name, value)| name == "collection" && value == "vertical"))
);
Ok(())
}
@@ -1795,7 +2119,7 @@ async fn plugin_list_does_not_append_global_remote_when_marketplace_kinds_are_ex
response
.marketplaces
.iter()
.all(|marketplace| marketplace.name != "chatgpt-global")
.all(|marketplace| marketplace.name != "openai-curated-remote")
);
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
Ok(())
@@ -1962,18 +2286,18 @@ plugin_sharing = false
)?;
assert_eq!(response.marketplaces.len(), 1);
assert_eq!(response.marketplaces[0].name, "chatgpt-global");
assert_eq!(response.marketplaces[0].name, "openai-curated-remote");
assert_eq!(
response.marketplaces[0]
.plugins
.iter()
.map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled))
.collect::<Vec<_>>(),
vec![("linear@chatgpt-global".to_string(), true, true)]
vec![("linear@openai-curated-remote".to_string(), true, true)]
);
let installed_path = codex_home
.path()
.join("plugins/cache/chatgpt-global/linear/1.2.3/.codex-plugin/plugin.json");
.join("plugins/cache/openai-curated-remote/linear/1.2.3/.codex-plugin/plugin.json");
wait_for_path_exists(&installed_path).await?;
wait_for_remote_installed_scope_request(&server, "GLOBAL").await?;
wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?;
@@ -2442,7 +2766,7 @@ async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> {
let remote_marketplace = response
.marketplaces
.into_iter()
.find(|marketplace| marketplace.name == "chatgpt-global")
.find(|marketplace| marketplace.name == "openai-curated-remote")
.expect("expected ChatGPT remote marketplace");
let plugin = remote_marketplace
.plugins
@@ -2457,133 +2781,6 @@ async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn plugin_list_remote_marketplace_replaces_local_marketplace_with_same_name() -> 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 local_plugin_root = codex_home
.path()
.join(".agents/plugins/plugins/local-linear/.codex-plugin");
std::fs::create_dir_all(&local_plugin_root)?;
std::fs::write(
codex_home.path().join(".agents/plugins/marketplace.json"),
r#"{
"name": "chatgpt-global",
"plugins": [
{
"name": "local-linear",
"source": {
"source": "local",
"path": "./plugins/local-linear"
}
}
]
}"#,
)?;
std::fs::write(
local_plugin_root.join("plugin.json"),
r#"{"name":"local-linear"}"#,
)?;
let global_directory_body = r#"{
"plugins": [
{
"id": "plugins~Plugin_00000000000000000000000000000000",
"name": "linear",
"scope": "GLOBAL",
"installation_policy": "AVAILABLE",
"authentication_policy": "ON_USE",
"release": {
"display_name": "Linear",
"description": "Track work in Linear",
"app_ids": [],
"interface": {},
"skills": []
}
}
],
"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 in ["GLOBAL", "WORKSPACE"] {
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(empty_page_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,
marketplace_kinds: 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 matching_marketplaces = response
.marketplaces
.iter()
.filter(|marketplace| marketplace.name == "chatgpt-global")
.collect::<Vec<_>>();
assert_eq!(matching_marketplaces.len(), 1);
assert_eq!(matching_marketplaces[0].path, None);
assert_eq!(matching_marketplaces[0].plugins.len(), 1);
assert_eq!(
matching_marketplaces[0].plugins[0].source,
PluginSource::Remote
);
assert_eq!(matching_marketplaces[0].plugins[0].name, "linear");
Ok(())
}
#[tokio::test]
async fn plugin_list_does_not_fetch_remote_marketplaces_when_plugins_disabled() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -2817,6 +3014,19 @@ async fn mount_remote_plugin_list(server: &MockServer, scope: &str, body: &str)
.await;
}
async fn mount_openai_curated_remote_collection_plugin_list(server: &MockServer, body: &str) {
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/list"))
.and(query_param("scope", "GLOBAL"))
.and(query_param("limit", "200"))
.and(query_param("collection", "vertical"))
.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;
}
async fn mount_shared_workspace_plugins(server: &MockServer, body: &str) {
Mock::given(method("GET"))
.and(path("/backend-api/ps/plugins/workspace/shared"))
@@ -2910,6 +3120,37 @@ fn remote_installed_plugin_body(
release_version: &str,
enabled: bool,
) -> String {
remote_installed_plugin_body_with_optional_app_manifest(
bundle_download_url,
release_version,
enabled,
/*app_manifest*/ None,
)
}
fn remote_installed_plugin_body_with_app_manifest(
bundle_download_url: &str,
release_version: &str,
enabled: bool,
app_manifest: serde_json::Value,
) -> String {
remote_installed_plugin_body_with_optional_app_manifest(
bundle_download_url,
release_version,
enabled,
Some(app_manifest),
)
}
fn remote_installed_plugin_body_with_optional_app_manifest(
bundle_download_url: &str,
release_version: &str,
enabled: bool,
app_manifest: Option<serde_json::Value>,
) -> String {
let app_manifest_field = app_manifest
.map(|manifest| format!(r#" "app_manifest": {manifest},"#))
.unwrap_or_default();
format!(
r#"{{
"plugins": [
@@ -2925,6 +3166,7 @@ fn remote_installed_plugin_body(
"description": "Track work in Linear",
"bundle_download_url": "{bundle_download_url}",
"app_ids": [],
{app_manifest_field}
"interface": {{}},
"skills": []
}},
@@ -101,7 +101,7 @@ async fn plugin_read_rejects_multiple_read_sources() -> Result<()> {
marketplace_path: Some(AbsolutePathBuf::try_from(
codex_home.path().join("marketplace.json"),
)?),
remote_marketplace_name: Some("openai-curated".to_string()),
remote_marketplace_name: Some("openai-curated-remote".to_string()),
plugin_name: "sample-plugin".to_string(),
})
.await?;
@@ -196,7 +196,7 @@ plugins = true
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: None,
remote_marketplace_name: Some("chatgpt-global".to_string()),
remote_marketplace_name: Some("openai-curated-remote".to_string()),
plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(),
})
.await?;
@@ -208,8 +208,8 @@ plugins = true
.await??;
let response: PluginReadResponse = to_response(response)?;
assert_eq!(response.plugin.marketplace_name, "chatgpt-global");
assert_eq!(response.plugin.summary.id, "linear@chatgpt-global");
assert_eq!(response.plugin.marketplace_name, "openai-curated-remote");
assert_eq!(response.plugin.summary.id, "linear@openai-curated-remote");
assert_eq!(
response.plugin.summary.remote_plugin_id.as_deref(),
Some("plugins~Plugin_00000000000000000000000000000000")
@@ -484,7 +484,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("chatgpt-global".to_string()),
remote_marketplace_name: Some("openai-curated-remote".to_string()),
plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(),
})
.await?;
@@ -496,10 +496,10 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
.await??;
let response: PluginReadResponse = to_response(response)?;
assert_eq!(response.plugin.marketplace_name, "chatgpt-global");
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@chatgpt-global");
assert_eq!(response.plugin.summary.id, "linear@openai-curated-remote");
assert_eq!(
response.plugin.summary.remote_plugin_id.as_deref(),
Some("plugins~Plugin_00000000000000000000000000000000")
@@ -568,7 +568,7 @@ async fn plugin_skill_read_reads_remote_skill_contents_when_remote_plugin_enable
let request_id = mcp
.send_plugin_skill_read_request(PluginSkillReadParams {
remote_marketplace_name: "chatgpt-global".to_string(),
remote_marketplace_name: "openai-curated-remote".to_string(),
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
skill_name: "plan-work".to_string(),
})
@@ -621,7 +621,7 @@ async fn plugin_read_maps_missing_remote_plugin_to_invalid_request() -> Result<(
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: None,
remote_marketplace_name: Some("chatgpt-global".to_string()),
remote_marketplace_name: Some("openai-curated-remote".to_string()),
plugin_name: "plugins~Plugin_missing".to_string(),
})
.await?;
@@ -673,7 +673,7 @@ remote_plugin = true
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: None,
remote_marketplace_name: Some("chatgpt-global".to_string()),
remote_marketplace_name: Some("openai-curated-remote".to_string()),
plugin_name: "linear".to_string(),
})
.await?;
@@ -703,7 +703,7 @@ async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> {
let request_id = mcp
.send_plugin_read_request(PluginReadParams {
marketplace_path: None,
remote_marketplace_name: Some("chatgpt-global".to_string()),
remote_marketplace_name: Some("openai-curated-remote".to_string()),
plugin_name: "linear/../../oops".to_string(),
})
.await?;
@@ -219,15 +219,15 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl
let remote_plugin_cache_root = codex_home
.path()
.join("plugins/cache/chatgpt-global/linear");
.join("plugins/cache/openai-curated-remote/linear");
std::fs::create_dir_all(remote_plugin_cache_root.join("1.0.0/.codex-plugin"))?;
std::fs::write(
remote_plugin_cache_root.join("1.0.0/.codex-plugin/plugin.json"),
r#"{"name":"linear","version":"1.0.0"}"#,
)?;
let legacy_remote_plugin_cache_root = codex_home
.path()
.join(format!("plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}"));
let legacy_remote_plugin_cache_root = codex_home.path().join(format!(
"plugins/cache/openai-curated-remote/{REMOTE_PLUGIN_ID}"
));
std::fs::create_dir_all(legacy_remote_plugin_cache_root.join("local/.codex-plugin"))?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
@@ -299,7 +299,7 @@ async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()>
)?;
let global_cache_root = codex_home
.path()
.join("plugins/cache/chatgpt-global/linear");
.join("plugins/cache/openai-curated-remote/linear");
std::fs::create_dir_all(global_cache_root.join("1.0.0/.codex-plugin"))?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
@@ -420,9 +420,9 @@ async fn plugin_uninstall_rejects_before_post_when_remote_detail_fetch_fails() -
AuthCredentialsStoreMode::File,
)?;
let legacy_remote_plugin_cache_root = codex_home
.path()
.join(format!("plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}"));
let legacy_remote_plugin_cache_root = codex_home.path().join(format!(
"plugins/cache/openai-curated-remote/{REMOTE_PLUGIN_ID}"
));
std::fs::create_dir_all(legacy_remote_plugin_cache_root.join("local/.codex-plugin"))?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
@@ -117,7 +117,7 @@ fn write_plugin_with_skill(
fn write_cached_remote_plugin_with_skill(
codex_home: &std::path::Path,
) -> Result<std::path::PathBuf> {
let plugin_root = codex_home.join("plugins/cache/chatgpt-global/linear/local");
let plugin_root = codex_home.join("plugins/cache/openai-curated-remote/linear/local");
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
std::fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
+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")
}