diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 506358d25..ee68faf00 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1632,6 +1632,7 @@ "PluginListMarketplaceKind": { "enum": [ "local", + "vertical", "workspace-directory", "shared-with-me" ], diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 73e4335bf..3db4a87ef 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -12193,6 +12193,7 @@ "PluginListMarketplaceKind": { "enum": [ "local", + "vertical", "workspace-directory", "shared-with-me" ], diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index ea8655d0f..7b62d6d4b 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -8742,6 +8742,7 @@ "PluginListMarketplaceKind": { "enum": [ "local", + "vertical", "workspace-directory", "shared-with-me" ], diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json index 65b1b4e88..9c15ed5a7 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListParams.json @@ -8,6 +8,7 @@ "PluginListMarketplaceKind": { "enum": [ "local", + "vertical", "workspace-directory", "shared-with-me" ], diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts index 6ff6161f3..1be75e6f0 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginListMarketplaceKind.ts @@ -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"; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index 33c267007..70d283994 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -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, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 2ee583f99..09a8b609b 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -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::(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::(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", diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 199ddf119..61fd363b2 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -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)?; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_install.rs b/codex-rs/app-server/tests/suite/v2/plugin_install.rs index 6adcd9219..9d17e8747 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -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, ) { 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> { 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> { 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> { 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); diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index d439a4585..93dbfb477 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -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![("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::::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![("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::>(); - - 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, +) -> 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": [] }}, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 812f884a5..3ec2366f5 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -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?; diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs index 8fdf149a0..4feede934 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -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?; diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index db982bc5d..74100bc19 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -117,7 +117,7 @@ fn write_plugin_with_skill( fn write_cached_remote_plugin_with_skill( codex_home: &std::path::Path, ) -> Result { - 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"), diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 791a143c4..53f72ed50 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -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); diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index 56427473a..16ae51293 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -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, pub release_version: Option, pub bundle_download_url: Option, + pub app_manifest: Option, pub skills: Vec, pub app_ids: Vec, } @@ -391,6 +394,8 @@ struct RemotePluginReleaseResponse { #[serde(default)] app_ids: Vec, #[serde(default)] + app_manifest: Option, + #[serde(default)] keywords: Vec, 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, 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, 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, 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, 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 { 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)]); } diff --git a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs index 69cbfe256..fe3a597b0 100644 --- a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs +++ b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs @@ -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()); } diff --git a/codex-rs/core-plugins/src/remote/share/checkout.rs b/codex-rs/core-plugins/src/remote/share/checkout.rs index 8aabccff8..6b12d2588 100644 --- a/codex-rs/core-plugins/src/remote/share/checkout.rs +++ b/codex-rs/core-plugins/src/remote/share/checkout.rs @@ -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!( diff --git a/codex-rs/core-plugins/src/remote_bundle.rs b/codex-rs/core-plugins/src/remote_bundle.rs index ed7cd56a8..7862573e4 100644 --- a/codex-rs/core-plugins/src/remote_bundle.rs +++ b/codex-rs/core-plugins/src/remote_bundle.rs @@ -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, 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, ) -> Result { 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") }