From 7d72fc8f5323175d9d323222a5b98b4bb8a361d5 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Tue, 28 Apr 2026 00:53:27 -0700 Subject: [PATCH] feat: Cache remote plugin bundles on install (#19914) Remote installs now fetch, validate, download, and cache the plugin bundle locally --- MODULE.bazel.lock | 1 + codex-rs/Cargo.lock | 14 + codex-rs/Cargo.toml | 2 + codex-rs/app-server/Cargo.toml | 2 + .../src/codex_message_processor/plugins.rs | 71 +- .../tests/suite/v2/plugin_install.rs | 481 ++++++++-- .../app-server/tests/suite/v2/plugin_list.rs | 11 +- .../app-server/tests/suite/v2/plugin_read.rs | 15 +- codex-rs/core-plugins/Cargo.toml | 2 + codex-rs/core-plugins/src/lib.rs | 1 + codex-rs/core-plugins/src/remote.rs | 72 +- codex-rs/core-plugins/src/remote_bundle.rs | 848 ++++++++++++++++++ codex-rs/core-plugins/src/store.rs | 2 +- 13 files changed, 1396 insertions(+), 126 deletions(-) create mode 100644 codex-rs/core-plugins/src/remote_bundle.rs diff --git a/MODULE.bazel.lock b/MODULE.bazel.lock index 7c0b30feb..e079e3af0 100644 --- a/MODULE.bazel.lock +++ b/MODULE.bazel.lock @@ -1560,6 +1560,7 @@ "system-deps_7.0.7": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"assert_matches\",\"req\":\"^1.5\"},{\"features\":[\"targets\"],\"name\":\"cfg-expr\",\"req\":\">=0.17, <0.21\"},{\"name\":\"heck\",\"req\":\"^0.5\"},{\"kind\":\"dev\",\"name\":\"itertools\",\"req\":\"^0.14\"},{\"kind\":\"dev\",\"name\":\"lazy_static\",\"req\":\"^1\"},{\"name\":\"pkg-config\",\"req\":\"^0.3.25\"},{\"default_features\":false,\"features\":[\"parse\",\"std\"],\"name\":\"toml\",\"req\":\"^0.9\"},{\"name\":\"version-compare\",\"req\":\"^0.2\"}],\"features\":{}}", "tagptr_0.2.0": "{\"dependencies\":[],\"features\":{}}", "tar_0.4.44": "{\"dependencies\":[{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}", + "tar_0.4.45": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"astral-tokio-tar\",\"req\":\"^0.5\"},{\"name\":\"filetime\",\"req\":\"^0.2.8\"},{\"name\":\"libc\",\"req\":\"^0.2\",\"target\":\"cfg(unix)\"},{\"features\":[\"small_rng\"],\"kind\":\"dev\",\"name\":\"rand\",\"req\":\"^0.8\"},{\"kind\":\"dev\",\"name\":\"tempfile\",\"req\":\"^3\"},{\"features\":[\"macros\",\"rt\"],\"kind\":\"dev\",\"name\":\"tokio\",\"req\":\"^1\"},{\"kind\":\"dev\",\"name\":\"tokio-stream\",\"req\":\"^0.1\"},{\"name\":\"xattr\",\"optional\":true,\"req\":\"^1.1.3\",\"target\":\"cfg(unix)\"}],\"features\":{\"default\":[\"xattr\"]}}", "target-lexicon_0.13.3": "{\"dependencies\":[{\"name\":\"serde\",\"optional\":true,\"req\":\"^1.0\"},{\"kind\":\"dev\",\"name\":\"serde_json\",\"req\":\"^1.0\"}],\"features\":{\"arch_z80\":[],\"arch_zkasm\":[],\"default\":[],\"serde_support\":[\"serde\",\"std\"],\"std\":[]}}", "tempfile_3.27.0": "{\"dependencies\":[{\"kind\":\"dev\",\"name\":\"doc-comment\",\"req\":\"^0.3\"},{\"name\":\"fastrand\",\"req\":\"^2.1.1\"},{\"default_features\":false,\"name\":\"getrandom\",\"optional\":true,\"req\":\">=0.3.0, <0.5\",\"target\":\"cfg(any(unix, windows, target_os = \\\"wasi\\\"))\"},{\"default_features\":false,\"features\":[\"std\"],\"name\":\"once_cell\",\"req\":\"^1.19.0\"},{\"features\":[\"fs\"],\"name\":\"rustix\",\"req\":\"^1.1.4\",\"target\":\"cfg(any(unix, target_os = \\\"wasi\\\"))\"},{\"features\":[\"Win32_Storage_FileSystem\",\"Win32_Foundation\"],\"name\":\"windows-sys\",\"req\":\">=0.52, <0.62\",\"target\":\"cfg(windows)\"}],\"features\":{\"default\":[\"getrandom\"],\"nightly\":[]}}", "temporal_capi_0.1.2": "{\"dependencies\":[{\"default_features\":false,\"name\":\"diplomat\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"name\":\"diplomat-runtime\",\"req\":\"^0.14.0\"},{\"default_features\":false,\"features\":[\"unstable\"],\"name\":\"icu_calendar\",\"req\":\"^2.1.0\"},{\"name\":\"icu_locale\",\"req\":\"^2.1.0\"},{\"default_features\":false,\"name\":\"num-traits\",\"req\":\"^0.2.19\"},{\"default_features\":false,\"name\":\"temporal_rs\",\"req\":\"^0.1.2\"},{\"name\":\"timezone_provider\",\"req\":\"^0.1.2\"},{\"name\":\"writeable\",\"req\":\"^0.6.0\"},{\"name\":\"zoneinfo64\",\"optional\":true,\"req\":\"^0.2.0\"}],\"features\":{\"compiled_data\":[\"temporal_rs/compiled_data\"],\"zoneinfo64\":[\"dep:zoneinfo64\",\"timezone_provider/zoneinfo64\"]}}", diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index a00873ee4..de11d1c15 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1880,6 +1880,7 @@ dependencies = [ "codex-utils-rustls-provider", "constant_time_eq 0.3.1", "core_test_support", + "flate2", "futures", "gethostname", "hmac", @@ -1895,6 +1896,7 @@ dependencies = [ "serial_test", "sha2", "shlex", + "tar", "tempfile", "thiserror 2.0.18", "time", @@ -2479,11 +2481,13 @@ dependencies = [ "codex-utils-absolute-path", "codex-utils-plugins", "dirs", + "flate2", "libc", "pretty_assertions", "reqwest", "serde", "serde_json", + "tar", "tempfile", "thiserror 2.0.18", "tokio", @@ -12278,6 +12282,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "target-lexicon" version = "0.13.3" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 1bbb13834..d39651fed 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -260,6 +260,7 @@ encoding_rs = "0.8.35" env-flags = "0.1.1" env_logger = "0.11.9" eventsource-stream = "0.2.3" +flate2 = "1.1.8" futures = { version = "0.3", default-features = false } gethostname = "1.1.0" gix = { version = "0.81.0", default-features = false, features = ["sha1"] } @@ -352,6 +353,7 @@ strum_macros = "0.28.0" supports-color = "3.0.2" syntect = "5" sys-locale = "0.3.2" +tar = { version = "=0.4.45", default-features = false } tempfile = "3.23.0" test-log = "0.2.19" textwrap = "0.16.2" diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 06ed624c3..d78486f38 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -105,6 +105,7 @@ axum = { workspace = true, default-features = false, features = [ core_test_support = { workspace = true } codex-model-provider-info = { workspace = true } codex-utils-cargo-bin = { workspace = true } +flate2 = { workspace = true } opentelemetry = { workspace = true } opentelemetry_sdk = { workspace = true } pretty_assertions = { workspace = true } @@ -115,6 +116,7 @@ rmcp = { workspace = true, default-features = false, features = [ "transport-streamable-http-server", ] } serial_test = { workspace = true } +tar = { workspace = true } tokio-tungstenite = { workspace = true } tracing-opentelemetry = { workspace = true } wiremock = { workspace = true } diff --git a/codex-rs/app-server/src/codex_message_processor/plugins.rs b/codex-rs/app-server/src/codex_message_processor/plugins.rs index e1d0fffad..11709d8e3 100644 --- a/codex-rs/app-server/src/codex_message_processor/plugins.rs +++ b/codex-rs/app-server/src/codex_message_processor/plugins.rs @@ -421,22 +421,44 @@ impl CodexMessageProcessor { let remote_plugin_service_config = RemotePluginServiceConfig { chatgpt_base_url: config.chatgpt_base_url.clone(), }; - let remote_detail = codex_core_plugins::remote::fetch_remote_plugin_detail( - &remote_plugin_service_config, - auth.as_ref(), - &remote_marketplace_name, - &plugin_name, - ) - .await - .map_err(|err| { - remote_plugin_catalog_error_to_jsonrpc(err, "read remote plugin details before install") - })?; + let remote_detail = + codex_core_plugins::remote::fetch_remote_plugin_detail_with_download_urls( + &remote_plugin_service_config, + auth.as_ref(), + &remote_marketplace_name, + &plugin_name, + ) + .await + .map_err(|err| { + remote_plugin_catalog_error_to_jsonrpc( + err, + "read remote plugin details before install", + ) + })?; if remote_detail.summary.install_policy == PluginInstallPolicy::NotAvailable { return Err(invalid_request(format!( "remote plugin {plugin_name} is not available for install" ))); } + let validated_bundle = codex_core_plugins::remote_bundle::validate_remote_plugin_bundle( + &plugin_name, + &remote_marketplace_name, + &remote_detail.summary.name, + remote_detail.release_version.as_deref(), + remote_detail.bundle_download_url.as_deref(), + ) + .map_err(remote_plugin_bundle_install_error_to_jsonrpc)?; + let result = codex_core_plugins::remote_bundle::download_and_install_remote_plugin_bundle( + config.codex_home.to_path_buf(), + validated_bundle, + ) + .await + .map_err(remote_plugin_bundle_install_error_to_jsonrpc)?; + + // Cache first so a backend install cannot succeed when local materialization fails. + // If this backend call fails, the cache entry is harmless because remote installed state + // is still backend-gated. codex_core_plugins::remote::install_remote_plugin( &remote_plugin_service_config, auth.as_ref(), @@ -446,18 +468,29 @@ impl CodexMessageProcessor { .await .map_err(|err| remote_plugin_catalog_error_to_jsonrpc(err, "install remote plugin"))?; + // TODO(remote plugins): remote marketplaces do not yet have a local + // marketplace/read-path sync, so this install path reads MCP/apps directly + // from the just-cached bundle. self.clear_plugin_related_caches(); - let plugin_apps = remote_detail - .app_ids - .into_iter() - .map(codex_core::plugins::AppConnectorId) - .collect::>(); + let plugin_mcp_servers = load_plugin_mcp_servers(result.installed_path.as_path()).await; + if !plugin_mcp_servers.is_empty() { + if let Err(err) = self.queue_mcp_server_refresh_for_config(&config).await { + warn!( + plugin = result.plugin_id.as_key(), + "failed to queue MCP refresh after remote plugin install: {err:?}" + ); + } + self.start_plugin_mcp_oauth_logins(&config, plugin_mcp_servers) + .await; + } + + let plugin_apps = load_plugin_apps(result.installed_path.as_path()).await; let apps_needing_auth = self .plugin_apps_needing_auth_for_install( &config, auth.as_ref().is_some_and(CodexAuth::is_chatgpt_auth), - &plugin_name, + &result.plugin_id.as_key(), &plugin_apps, ) .await; @@ -708,3 +741,9 @@ fn remote_plugin_catalog_error_to_jsonrpc( }, } } + +fn remote_plugin_bundle_install_error_to_jsonrpc( + err: codex_core_plugins::remote_bundle::RemotePluginBundleInstallError, +) -> JSONRPCErrorError { + internal_error(format!("install remote plugin bundle: {err}")) +} 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 88403d891..a115c8307 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_install.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_install.rs @@ -28,6 +28,8 @@ use codex_app_server_protocol::PluginInstallResponse; use codex_app_server_protocol::RequestId; use codex_config::types::AuthCredentialsStoreMode; use codex_utils_absolute_path::AbsolutePathBuf; +use flate2::Compression; +use flate2::write::GzEncoder; use pretty_assertions::assert_eq; use rmcp::handler::server::ServerHandler; use rmcp::model::JsonObject; @@ -45,8 +47,10 @@ use tempfile::TempDir; use tokio::net::TcpListener; use tokio::task::JoinHandle; use tokio::time::timeout; +use wiremock::Match; use wiremock::Mock; use wiremock::MockServer; +use wiremock::Request; use wiremock::ResponseTemplate; use wiremock::matchers::header; use wiremock::matchers::method; @@ -56,6 +60,9 @@ use wiremock::matchers::query_param; // Plugin install tests wait on connector discovery after the install response path // starts, which is noticeably slower on Windows CI. const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); +const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_00000000000000000000000000000000"; +const TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS: &str = + "CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS"; #[tokio::test] async fn plugin_install_rejects_relative_marketplace_paths() -> Result<()> { @@ -154,7 +161,7 @@ async fn plugin_install_rejects_remote_marketplace_when_remote_plugin_is_disable .send_plugin_install_request(PluginInstallParams { marketplace_path: None, remote_marketplace_name: Some("chatgpt-global".to_string()), - plugin_name: "plugins~Plugin_sample".to_string(), + plugin_name: "plugins~Plugin_22222222222222222222222222222222".to_string(), }) .await?; @@ -175,84 +182,36 @@ async fn plugin_install_rejects_remote_marketplace_when_remote_plugin_is_disable } #[tokio::test] -async fn plugin_install_writes_remote_plugin_to_cloud_when_remote_plugin_enabled() -> Result<()> { +async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()> { let codex_home = TempDir::new()?; let server = MockServer::start().await; - write_remote_plugin_catalog_config( + let installed_path = codex_home + .path() + .join("plugins/cache/chatgpt-global/linear/1.2.3"); + let bundle_url = mount_remote_plugin_bundle( + &server, + /*status_code*/ 200, + remote_plugin_bundle_tar_gz_bytes("linear")?, + ) + .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_empty_remote_installed_plugins(&server).await; + mount_remote_plugin_install_after_cache_write( + &server, + REMOTE_PLUGIN_ID, + installed_path.join(".codex-plugin/plugin.json"), + ) + .await; + + let mut mcp = McpProcess::new_with_env( 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 detail_body = r#"{ - "id": "plugins~Plugin_linear", - "name": "linear", - "scope": "GLOBAL", - "installation_policy": "AVAILABLE", - "authentication_policy": "ON_USE", - "release": { - "display_name": "Linear", - "description": "Track work in Linear", - "app_ids": [], - "interface": { - "short_description": "Plan and track work" - }, - "skills": [] - } -}"#; - let empty_installed_body = r#"{ - "plugins": [], - "pagination": { - "limit": 50, - "next_page_token": null - } -}"#; - - Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/plugins~Plugin_linear")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string(detail_body)) - .mount(&server) - .await; - Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/installed")) - .and(query_param("scope", "GLOBAL")) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with(ResponseTemplate::new(200).set_body_string(empty_installed_body)) - .mount(&server) - .await; - Mock::given(method("POST")) - .and(path( - "/backend-api/ps/plugins/plugins~Plugin_linear/install", - )) - .and(header("authorization", "Bearer chatgpt-token")) - .and(header("chatgpt-account-id", "account-123")) - .respond_with( - ResponseTemplate::new(200) - .set_body_string(r#"{"id":"plugins~Plugin_linear","enabled":true}"#), - ) - .mount(&server) - .await; - - let mut mcp = McpProcess::new(codex_home.path()).await?; + &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], + ) + .await?; timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; - let request_id = mcp - .send_plugin_install_request(PluginInstallParams { - marketplace_path: None, - remote_marketplace_name: Some("chatgpt-global".to_string()), - plugin_name: "plugins~Plugin_linear".to_string(), - }) - .await?; + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; let response: JSONRPCResponse = timeout( DEFAULT_TIMEOUT, mcp.read_stream_until_response_message(RequestId::Integer(request_id)), @@ -270,10 +229,156 @@ async fn plugin_install_writes_remote_plugin_to_cloud_when_remote_plugin_enabled wait_for_remote_plugin_request_count( &server, "POST", - "/ps/plugins/plugins~Plugin_linear/install", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), /*expected_count*/ 1, ) .await?; + wait_for_remote_plugin_request_count( + &server, + "GET", + "/bundles/linear.tar.gz", + /*expected_count*/ 1, + ) + .await?; + assert!(installed_path.join(".codex-plugin/plugin.json").is_file()); + 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" + )) + .exists() + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_missing_remote_bundle_url() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail( + &server, + REMOTE_PLUGIN_ID, + "1.2.3", + /*bundle_download_url*/ None, + ) + .await; + mount_empty_remote_installed_plugins(&server).await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32603); + assert!( + err.error + .message + .contains("backend did not return a download URL") + ); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 0, + ) + .await?; + assert!( + !codex_home + .path() + .join("plugins/cache/chatgpt-global/linear") + .exists() + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_plain_http_remote_bundle_url() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + let bundle_url = format!("{}/bundles/linear.tar.gz", server.uri()); + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await; + mount_empty_remote_installed_plugins(&server).await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32603); + assert!( + err.error + .message + .contains("unsupported download URL scheme") + ); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 0, + ) + .await?; + assert!( + !codex_home + .path() + .join("plugins/cache/chatgpt-global/linear") + .exists() + ); + Ok(()) +} + +#[tokio::test] +async fn plugin_install_rejects_invalid_remote_release_version() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + configure_remote_plugin_test(codex_home.path(), &server)?; + mount_remote_plugin_detail( + &server, + REMOTE_PLUGIN_ID, + "../1.2.3", + Some("https://127.0.0.1:1/bundles/linear.tar.gz"), + ) + .await; + mount_empty_remote_installed_plugins(&server).await; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32603); + assert!(err.error.message.contains("invalid release version")); + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 0, + ) + .await?; + assert!( + !codex_home + .path() + .join("plugins/cache/chatgpt-global/linear") + .exists() + ); Ok(()) } @@ -571,6 +676,60 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> { Ok(()) } +#[tokio::test] +async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + let bundle_url = mount_remote_plugin_bundle( + &server, + /*status_code*/ 503, + b"bundle temporarily unavailable".to_vec(), + ) + .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_empty_remote_installed_plugins(&server).await; + mount_remote_plugin_install(&server, REMOTE_PLUGIN_ID).await; + + let mut mcp = McpProcess::new_with_env( + codex_home.path(), + &[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))], + ) + .await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + + let request_id = send_remote_plugin_install_request(&mut mcp, REMOTE_PLUGIN_ID).await?; + let err = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(err.error.code, -32603); + assert!(err.error.message.contains("failed with status 503")); + wait_for_remote_plugin_request_count( + &server, + "GET", + "/bundles/linear.tar.gz", + /*expected_count*/ 1, + ) + .await?; + wait_for_remote_plugin_request_count( + &server, + "POST", + &format!("/ps/plugins/{REMOTE_PLUGIN_ID}/install"), + /*expected_count*/ 0, + ) + .await?; + assert!( + !codex_home + .path() + .join("plugins/cache/chatgpt-global/linear") + .exists() + ); + Ok(()) +} + #[tokio::test] async fn plugin_install_returns_apps_needing_auth() -> Result<()> { let connectors = vec![ @@ -1014,6 +1173,152 @@ remote_plugin = true ) } +fn configure_remote_plugin_test(codex_home: &std::path::Path, server: &MockServer) -> Result<()> { + write_remote_plugin_catalog_config(codex_home, &format!("{}/backend-api/", server.uri()))?; + write_chatgpt_auth( + codex_home, + ChatGptAuthFixture::new("chatgpt-token") + .account_id("account-123") + .chatgpt_user_id("user-123") + .chatgpt_account_id("account-123"), + AuthCredentialsStoreMode::File, + ) +} + +async fn mount_remote_plugin_bundle( + server: &MockServer, + status_code: u16, + body: Vec, +) -> String { + Mock::given(method("GET")) + .and(path("/bundles/linear.tar.gz")) + .respond_with( + ResponseTemplate::new(status_code) + .insert_header("content-type", "application/gzip") + .set_body_bytes(body), + ) + .mount(server) + .await; + format!("{}/bundles/linear.tar.gz", server.uri()) +} + +async fn mount_remote_plugin_detail( + server: &MockServer, + remote_plugin_id: &str, + release_version: &str, + bundle_download_url: Option<&str>, +) { + let bundle_download_url_field = bundle_download_url + .map(|url| format!(r#" "bundle_download_url": "{url}","#)) + .unwrap_or_default(); + let detail_body = format!( + r#"{{ + "id": "{remote_plugin_id}", + "name": "linear", + "scope": "GLOBAL", + "installation_policy": "AVAILABLE", + "authentication_policy": "ON_USE", + "release": {{ + "version": "{release_version}", +{bundle_download_url_field} + "display_name": "Linear", + "description": "Track work in Linear", + "app_ids": [], + "interface": {{ + "short_description": "Plan and track work" + }}, + "skills": [] + }} +}}"# + ); + + Mock::given(method("GET")) + .and(path(format!("/backend-api/ps/plugins/{remote_plugin_id}"))) + .and(query_param("includeDownloadUrls", "true")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string(detail_body)) + .mount(server) + .await; +} + +async fn mount_empty_remote_installed_plugins(server: &MockServer) { + Mock::given(method("GET")) + .and(path("/backend-api/ps/plugins/installed")) + .and(query_param("scope", "GLOBAL")) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with(ResponseTemplate::new(200).set_body_string( + r#"{ + "plugins": [], + "pagination": { + "limit": 50, + "next_page_token": null + } +}"#, + )) + .mount(server) + .await; +} + +async fn mount_remote_plugin_install(server: &MockServer, remote_plugin_id: &str) { + Mock::given(method("POST")) + .and(path(format!( + "/backend-api/ps/plugins/{remote_plugin_id}/install" + ))) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!(r#"{{"id":"{remote_plugin_id}","enabled":true}}"#)), + ) + .mount(server) + .await; +} + +#[derive(Debug, Clone)] +struct CacheManifestExists { + manifest_path: std::path::PathBuf, +} + +impl Match for CacheManifestExists { + fn matches(&self, _request: &Request) -> bool { + self.manifest_path.is_file() + } +} + +async fn mount_remote_plugin_install_after_cache_write( + server: &MockServer, + remote_plugin_id: &str, + manifest_path: std::path::PathBuf, +) { + Mock::given(method("POST")) + .and(path(format!( + "/backend-api/ps/plugins/{remote_plugin_id}/install" + ))) + .and(header("authorization", "Bearer chatgpt-token")) + .and(header("chatgpt-account-id", "account-123")) + .and(CacheManifestExists { manifest_path }) + .respond_with( + ResponseTemplate::new(200) + .set_body_string(format!(r#"{{"id":"{remote_plugin_id}","enabled":true}}"#)), + ) + .mount(server) + .await; +} + +async fn send_remote_plugin_install_request( + mcp: &mut McpProcess, + remote_plugin_id: &str, +) -> Result { + mcp.send_plugin_install_request(PluginInstallParams { + marketplace_path: None, + remote_marketplace_name: Some("chatgpt-global".to_string()), + plugin_name: remote_plugin_id.to_string(), + }) + .await +} + async fn wait_for_remote_plugin_request_count( server: &MockServer, method_name: &str, @@ -1115,3 +1420,29 @@ fn write_plugin_source( )?; Ok(()) } + +fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result> { + let manifest = format!(r#"{{"name":"{plugin_name}"}}"#); + 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 [ + ( + ".codex-plugin/plugin.json", + manifest.as_bytes(), + /*mode*/ 0o644, + ), + ( + "skills/plan-work/SKILL.md", + skill.as_bytes(), + /*mode*/ 0o644, + ), + ] { + let mut header = tar::Header::new_gnu(); + header.set_size(contents.len() as u64); + header.set_mode(mode); + header.set_cksum(); + tar.append_data(&mut header, path, contents)?; + } + Ok(tar.into_inner()?.finish()?) +} 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 8735c20ff..23f6e5496 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -1131,7 +1131,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - let global_directory_body = r#"{ "plugins": [ { - "id": "plugins~Plugin_linear", + "id": "plugins~Plugin_00000000000000000000000000000000", "name": "linear", "scope": "GLOBAL", "installation_policy": "AVAILABLE", @@ -1165,7 +1165,7 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - let global_installed_body = r#"{ "plugins": [ { - "id": "plugins~Plugin_linear", + "id": "plugins~Plugin_00000000000000000000000000000000", "name": "linear", "scope": "GLOBAL", "installation_policy": "AVAILABLE", @@ -1255,7 +1255,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - Some("ChatGPT Plugins") ); assert_eq!(remote_marketplace.plugins.len(), 1); - assert_eq!(remote_marketplace.plugins[0].id, "plugins~Plugin_linear"); + assert_eq!( + remote_marketplace.plugins[0].id, + "plugins~Plugin_00000000000000000000000000000000" + ); assert_eq!(remote_marketplace.plugins[0].name, "linear"); assert_eq!(remote_marketplace.plugins[0].source, PluginSource::Remote); assert_eq!(remote_marketplace.plugins[0].installed, true); @@ -1314,7 +1317,7 @@ async fn plugin_list_remote_marketplace_replaces_local_marketplace_with_same_nam let global_directory_body = r#"{ "plugins": [ { - "id": "plugins~Plugin_linear", + "id": "plugins~Plugin_00000000000000000000000000000000", "name": "linear", "scope": "GLOBAL", "installation_policy": "AVAILABLE", 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 5360c381d..753468345 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -161,7 +161,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> )?; let detail_body = r#"{ - "id": "plugins~Plugin_linear", + "id": "plugins~Plugin_00000000000000000000000000000000", "name": "linear", "scope": "GLOBAL", "installation_policy": "AVAILABLE", @@ -192,7 +192,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> let installed_body = r#"{ "plugins": [ { - "id": "plugins~Plugin_linear", + "id": "plugins~Plugin_00000000000000000000000000000000", "name": "linear", "scope": "GLOBAL", "installation_policy": "AVAILABLE", @@ -230,7 +230,9 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> }"#; Mock::given(method("GET")) - .and(path("/backend-api/ps/plugins/plugins~Plugin_linear")) + .and(path( + "/backend-api/ps/plugins/plugins~Plugin_00000000000000000000000000000000", + )) .and(header("authorization", "Bearer chatgpt-token")) .and(header("chatgpt-account-id", "account-123")) .respond_with(ResponseTemplate::new(200).set_body_string(detail_body)) @@ -252,7 +254,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> .send_plugin_read_request(PluginReadParams { marketplace_path: None, remote_marketplace_name: Some("chatgpt-global".to_string()), - plugin_name: "plugins~Plugin_linear".to_string(), + plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(), }) .await?; @@ -266,7 +268,10 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> assert_eq!(response.plugin.marketplace_name, "chatgpt-global"); assert_eq!(response.plugin.marketplace_path, None); assert_eq!(response.plugin.summary.source, PluginSource::Remote); - assert_eq!(response.plugin.summary.id, "plugins~Plugin_linear"); + assert_eq!( + response.plugin.summary.id, + "plugins~Plugin_00000000000000000000000000000000" + ); assert_eq!(response.plugin.summary.name, "linear"); assert_eq!(response.plugin.summary.installed, true); assert_eq!(response.plugin.summary.enabled, false); diff --git a/codex-rs/core-plugins/Cargo.toml b/codex-rs/core-plugins/Cargo.toml index 8a0e4f772..ee477a381 100644 --- a/codex-rs/core-plugins/Cargo.toml +++ b/codex-rs/core-plugins/Cargo.toml @@ -27,9 +27,11 @@ codex-utils-absolute-path = { workspace = true } codex-utils-plugins = { workspace = true } chrono = { workspace = true } dirs = { workspace = true } +flate2 = { workspace = true } reqwest = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +tar = { workspace = true } tempfile = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true, features = ["fs", "macros", "rt", "time"] } diff --git a/codex-rs/core-plugins/src/lib.rs b/codex-rs/core-plugins/src/lib.rs index 61e5ef372..45ce39107 100644 --- a/codex-rs/core-plugins/src/lib.rs +++ b/codex-rs/core-plugins/src/lib.rs @@ -6,6 +6,7 @@ pub mod marketplace_add; pub mod marketplace_remove; pub mod marketplace_upgrade; pub mod remote; +pub mod remote_bundle; pub mod remote_legacy; pub mod startup_sync; pub mod store; diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index e453c52f9..8c447a1ed 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -49,6 +49,8 @@ pub struct RemotePluginDetail { pub marketplace_display_name: String, pub summary: RemotePluginSummary, pub description: Option, + pub release_version: Option, + pub bundle_download_url: Option, pub skills: Vec, pub app_ids: Vec, } @@ -168,23 +170,16 @@ impl RemotePluginScope { #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginPagination { - #[serde(alias = "nextPageToken")] next_page_token: Option, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginSkillInterfaceResponse { - #[serde(alias = "displayName")] display_name: Option, - #[serde(alias = "shortDescription")] short_description: Option, - #[serde(alias = "brandColor")] brand_color: Option, - #[serde(alias = "defaultPrompt")] default_prompt: Option, - #[serde(alias = "iconSmallUrl")] icon_small_url: Option, - #[serde(alias = "iconLargeUrl")] icon_large_url: Option, } @@ -197,41 +192,32 @@ struct RemotePluginSkillResponse { #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginReleaseInterfaceResponse { - #[serde(alias = "shortDescription")] short_description: Option, - #[serde(alias = "longDescription")] long_description: Option, - #[serde(alias = "developerName")] developer_name: Option, category: Option, #[serde(default)] capabilities: Vec, - #[serde(alias = "websiteUrl")] website_url: Option, - #[serde(alias = "privacyPolicyUrl")] privacy_policy_url: Option, - #[serde(alias = "termsOfServiceUrl")] terms_of_service_url: Option, - #[serde(alias = "brandColor")] brand_color: Option, - #[serde(alias = "defaultPrompt")] default_prompt: Option, - #[serde(alias = "composerIconUrl")] composer_icon_url: Option, - #[serde(alias = "logoUrl")] logo_url: Option, #[serde(default)] - #[serde(alias = "screenshotUrls")] screenshot_urls: Vec, } #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginReleaseResponse { - #[serde(alias = "displayName")] + #[serde(default)] + version: Option, display_name: String, description: String, #[serde(default)] - #[serde(alias = "appIds")] + bundle_download_url: Option, + #[serde(default)] app_ids: Vec, interface: RemotePluginReleaseInterfaceResponse, #[serde(default)] @@ -243,9 +229,7 @@ struct RemotePluginDirectoryItem { id: String, name: String, scope: RemotePluginScope, - #[serde(alias = "installationPolicy")] installation_policy: PluginInstallPolicy, - #[serde(alias = "authenticationPolicy")] authentication_policy: PluginAuthPolicy, release: RemotePluginReleaseResponse, } @@ -256,7 +240,6 @@ struct RemotePluginInstalledItem { plugin: RemotePluginDirectoryItem, enabled: bool, #[serde(default)] - #[serde(alias = "disabledSkillNames")] disabled_skill_names: Vec, } @@ -381,6 +364,39 @@ pub async fn fetch_remote_plugin_detail( auth: Option<&CodexAuth>, marketplace_name: &str, plugin_id: &str, +) -> Result { + fetch_remote_plugin_detail_with_download_url_option( + config, + auth, + marketplace_name, + plugin_id, + /*include_download_urls*/ false, + ) + .await +} + +pub async fn fetch_remote_plugin_detail_with_download_urls( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + marketplace_name: &str, + plugin_id: &str, +) -> Result { + fetch_remote_plugin_detail_with_download_url_option( + config, + auth, + marketplace_name, + plugin_id, + /*include_download_urls*/ true, + ) + .await +} + +async fn fetch_remote_plugin_detail_with_download_url_option( + config: &RemotePluginServiceConfig, + auth: Option<&CodexAuth>, + marketplace_name: &str, + plugin_id: &str, + include_download_urls: bool, ) -> Result { let auth = ensure_chatgpt_auth(auth)?; let scope = RemotePluginScope::from_marketplace_name(marketplace_name).ok_or_else(|| { @@ -388,7 +404,7 @@ pub async fn fetch_remote_plugin_detail( marketplace_name: marketplace_name.to_string(), } })?; - let plugin = fetch_plugin_detail(config, auth, plugin_id).await?; + let plugin = fetch_plugin_detail(config, auth, plugin_id, include_download_urls).await?; let actual_marketplace_name = plugin.scope.marketplace_name(); if actual_marketplace_name != marketplace_name { return Err(RemotePluginCatalogError::MarketplaceMismatch { @@ -433,6 +449,8 @@ pub async fn fetch_remote_plugin_detail( marketplace_display_name: scope.marketplace_display_name().to_string(), summary: build_remote_plugin_summary(&plugin, installed_plugin.as_ref()), description: non_empty_string(Some(&plugin.release.description)), + release_version: plugin.release.version, + bundle_download_url: plugin.release.bundle_download_url, skills, app_ids: plugin.release.app_ids, }) @@ -651,11 +669,15 @@ async fn fetch_plugin_detail( config: &RemotePluginServiceConfig, auth: &CodexAuth, plugin_id: &str, + include_download_urls: bool, ) -> Result { let base_url = config.chatgpt_base_url.trim_end_matches('/'); let url = format!("{base_url}/ps/plugins/{plugin_id}"); let client = build_reqwest_client(); - let request = authenticated_request(client.get(&url), auth)?; + let mut request = authenticated_request(client.get(&url), auth)?; + if include_download_urls { + request = request.query(&[("includeDownloadUrls", true)]); + } send_and_decode(request, &url).await } diff --git a/codex-rs/core-plugins/src/remote_bundle.rs b/codex-rs/core-plugins/src/remote_bundle.rs new file mode 100644 index 000000000..92d561384 --- /dev/null +++ b/codex-rs/core-plugins/src/remote_bundle.rs @@ -0,0 +1,848 @@ +use crate::store::PluginInstallResult; +use crate::store::PluginStore; +use crate::store::PluginStoreError; +use crate::store::validate_plugin_version_segment; +use codex_login::default_client::build_reqwest_client; +use codex_plugin::PluginId; +use codex_plugin::PluginIdError; +use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_plugins::find_plugin_manifest_path; +use flate2::read::GzDecoder; +use reqwest::Response; +use reqwest::StatusCode; +use std::fs; +use std::io; +use std::io::Read; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; +use tar::Archive; +use url::Host; +use url::Url; + +const REMOTE_PLUGIN_BUNDLE_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60); +const REMOTE_PLUGIN_BUNDLE_MAX_DOWNLOAD_BYTES: u64 = 50 * 1024 * 1024; +const REMOTE_PLUGIN_BUNDLE_ERROR_BODY_MAX_BYTES: u64 = 8 * 1024; +const REMOTE_PLUGIN_BUNDLE_MAX_EXTRACTED_BYTES: u64 = 250 * 1024 * 1024; +const REMOTE_PLUGIN_INSTALL_STAGING_DIR: &str = "plugins/.remote-plugin-install-staging"; +#[cfg(debug_assertions)] +const TEST_ALLOW_LOOPBACK_HTTP_REMOTE_PLUGIN_BUNDLES_ENV: &str = + "CODEX_TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS"; + +#[derive(Debug, Clone)] +pub struct ValidatedRemotePluginBundle { + pub plugin_id: PluginId, + pub plugin_version: String, + bundle_download_url: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum RemotePluginBundleInstallError { + #[error("backend did not return a release version for remote plugin `{remote_plugin_id}`")] + MissingReleaseVersion { remote_plugin_id: String }, + + #[error( + "backend returned an invalid release version for remote plugin `{remote_plugin_id}`: {message}" + )] + InvalidReleaseVersion { + remote_plugin_id: String, + message: String, + }, + + #[error("backend did not return a download URL for remote plugin `{remote_plugin_id}`")] + MissingBundleDownloadUrl { remote_plugin_id: String }, + + #[error( + "backend returned an invalid download URL for remote plugin `{remote_plugin_id}`: {url}" + )] + InvalidBundleDownloadUrl { + remote_plugin_id: String, + url: String, + #[source] + source: url::ParseError, + }, + + #[error( + "backend returned an unsupported download URL scheme for remote plugin `{remote_plugin_id}`: {scheme}" + )] + UnsupportedBundleDownloadUrlScheme { + remote_plugin_id: String, + scheme: String, + }, + + #[error( + "backend returned an invalid local plugin id for remote plugin `{remote_plugin_id}`: {source}" + )] + InvalidPluginId { + remote_plugin_id: String, + #[source] + source: PluginIdError, + }, + + #[error("failed to send remote plugin bundle download request to {url}: {source}")] + DownloadRequest { + url: String, + #[source] + source: reqwest::Error, + }, + + #[error("remote plugin bundle download from {url} failed with status {status}: {body}")] + DownloadStatus { + url: String, + status: StatusCode, + body: String, + }, + + #[error("failed to read remote plugin bundle download response from {url}: {source}")] + DownloadBody { + url: String, + #[source] + source: reqwest::Error, + }, + + #[error("remote plugin bundle download from {url} exceeded maximum size of {max_bytes} bytes")] + DownloadTooLarge { url: String, max_bytes: u64 }, + + #[error("remote plugin bundle download from {url} redirected to unsupported URL {final_url}")] + UnsupportedBundleDownloadFinalUrl { url: String, final_url: String }, + + #[error( + "remote plugin bundle extracted size would be {bytes} bytes, exceeding the maximum total size of {max_bytes} bytes" + )] + ExtractedBundleTooLarge { bytes: u64, max_bytes: u64 }, + + #[error("{context}: {source}")] + Io { + context: &'static str, + #[source] + source: io::Error, + }, + + #[error("{0}")] + InvalidBundle(String), + + #[error("{0}")] + Store(#[from] PluginStoreError), +} + +impl RemotePluginBundleInstallError { + fn io(context: &'static str, source: io::Error) -> Self { + Self::Io { context, source } + } +} + +pub fn validate_remote_plugin_bundle( + remote_plugin_id: &str, + remote_marketplace_name: &str, + plugin_name: &str, + release_version: Option<&str>, + bundle_download_url: Option<&str>, +) -> Result { + let plugin_id = PluginId::new(plugin_name.to_string(), remote_marketplace_name.to_string()) + .map_err(|source| RemotePluginBundleInstallError::InvalidPluginId { + remote_plugin_id: remote_plugin_id.to_string(), + source, + })?; + let plugin_version = release_version + .map(str::trim) + .filter(|version| !version.is_empty()) + .ok_or_else(|| RemotePluginBundleInstallError::MissingReleaseVersion { + remote_plugin_id: remote_plugin_id.to_string(), + })? + .to_string(); + validate_plugin_version_segment(&plugin_version).map_err(|message| { + RemotePluginBundleInstallError::InvalidReleaseVersion { + remote_plugin_id: remote_plugin_id.to_string(), + message, + } + })?; + let bundle_download_url = bundle_download_url + .map(str::trim) + .filter(|url| !url.is_empty()) + .ok_or_else( + || RemotePluginBundleInstallError::MissingBundleDownloadUrl { + remote_plugin_id: remote_plugin_id.to_string(), + }, + )? + .to_string(); + let parsed_bundle_url = Url::parse(&bundle_download_url).map_err(|source| { + RemotePluginBundleInstallError::InvalidBundleDownloadUrl { + remote_plugin_id: remote_plugin_id.to_string(), + url: bundle_download_url.clone(), + source, + } + })?; + if !is_allowed_bundle_download_url( + &parsed_bundle_url, + allow_test_loopback_http_bundle_downloads(), + ) { + return Err( + RemotePluginBundleInstallError::UnsupportedBundleDownloadUrlScheme { + remote_plugin_id: remote_plugin_id.to_string(), + scheme: parsed_bundle_url.scheme().to_string(), + }, + ); + } + + Ok(ValidatedRemotePluginBundle { + plugin_id, + plugin_version, + bundle_download_url, + }) +} + +fn allow_test_loopback_http_bundle_downloads() -> bool { + #[cfg(debug_assertions)] + { + if let Ok(value) = std::env::var(TEST_ALLOW_LOOPBACK_HTTP_REMOTE_PLUGIN_BUNDLES_ENV) { + return value == "1"; + } + } + + false +} + +fn is_allowed_bundle_download_url(url: &Url, allow_loopback_http: bool) -> bool { + match url.scheme() { + "https" => true, + "http" => allow_loopback_http && is_loopback_url(url), + _ => false, + } +} + +fn is_loopback_url(url: &Url) -> bool { + match url.host() { + Some(Host::Ipv4(addr)) => addr.is_loopback(), + Some(Host::Ipv6(addr)) => addr.is_loopback(), + Some(Host::Domain(host)) => host.eq_ignore_ascii_case("localhost"), + None => false, + } +} + +pub async fn download_and_install_remote_plugin_bundle( + codex_home: PathBuf, + bundle: ValidatedRemotePluginBundle, +) -> Result { + let bundle_bytes = download_remote_plugin_bundle_with_limit( + &bundle.bundle_download_url, + /*max_bytes*/ REMOTE_PLUGIN_BUNDLE_MAX_DOWNLOAD_BYTES, + ) + .await?; + tokio::task::spawn_blocking(move || { + install_remote_plugin_bundle(codex_home, bundle, bundle_bytes) + }) + .await + .map_err(|err| { + RemotePluginBundleInstallError::InvalidBundle(format!( + "failed to join remote plugin bundle install task: {err}" + )) + })? +} + +async fn download_remote_plugin_bundle_with_limit( + bundle_download_url: &str, + max_bytes: u64, +) -> Result, RemotePluginBundleInstallError> { + let client = build_reqwest_client(); + let response = client + .get(bundle_download_url) + .timeout(REMOTE_PLUGIN_BUNDLE_DOWNLOAD_TIMEOUT) + .send() + .await + .map_err(|source| RemotePluginBundleInstallError::DownloadRequest { + url: bundle_download_url.to_string(), + source, + })?; + + let final_url = response.url().clone(); + // reqwest may already have followed redirects here. For backend-issued bundle URLs, keep the + // shared client policy and fail unsupported final schemes before caching. + if !is_allowed_bundle_download_url(&final_url, allow_test_loopback_http_bundle_downloads()) { + return Err( + RemotePluginBundleInstallError::UnsupportedBundleDownloadFinalUrl { + url: bundle_download_url.to_string(), + final_url: final_url.to_string(), + }, + ); + } + + let url = final_url.to_string(); + let status = response.status(); + if !status.is_success() { + let body = read_response_body_with_limit( + response, + &url, + /*max_bytes*/ REMOTE_PLUGIN_BUNDLE_ERROR_BODY_MAX_BYTES, + ) + .await?; + let body = String::from_utf8_lossy(&body).to_string(); + return Err(RemotePluginBundleInstallError::DownloadStatus { url, status, body }); + } + + read_response_body_with_limit(response, &url, max_bytes).await +} + +async fn read_response_body_with_limit( + mut response: Response, + url: &str, + max_bytes: u64, +) -> Result, RemotePluginBundleInstallError> { + if let Some(content_length) = response.content_length() { + enforce_download_size_limit(url, content_length, max_bytes)?; + } + + let mut body = Vec::new(); + while let Some(chunk) = + response + .chunk() + .await + .map_err(|source| RemotePluginBundleInstallError::DownloadBody { + url: url.to_string(), + source, + })? + { + let next_len = body.len() as u64 + chunk.len() as u64; + enforce_download_size_limit(url, next_len, max_bytes)?; + body.extend_from_slice(&chunk); + } + + Ok(body) +} + +fn enforce_download_size_limit( + url: &str, + bytes: u64, + max_bytes: u64, +) -> Result<(), RemotePluginBundleInstallError> { + if bytes > max_bytes { + return Err(RemotePluginBundleInstallError::DownloadTooLarge { + url: url.to_string(), + max_bytes, + }); + } + Ok(()) +} + +fn install_remote_plugin_bundle( + codex_home: PathBuf, + bundle: ValidatedRemotePluginBundle, + bundle_bytes: Vec, +) -> Result { + let staging_root = codex_home.join(REMOTE_PLUGIN_INSTALL_STAGING_DIR); + fs::create_dir_all(&staging_root).map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to create remote plugin bundle staging directory", + source, + ) + })?; + let extract_dir = tempfile::Builder::new() + .prefix("remote-plugin-bundle-") + .tempdir_in(&staging_root) + .map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to create remote plugin bundle extraction directory", + source, + ) + })?; + + extract_plugin_bundle_tar_gz(&bundle_bytes, extract_dir.path())?; + let plugin_root = find_extracted_plugin_root(extract_dir.path())?; + let plugin_root = AbsolutePathBuf::try_from(plugin_root).map_err(|err| { + RemotePluginBundleInstallError::InvalidBundle(format!( + "failed to resolve extracted remote plugin bundle root: {err}" + )) + })?; + + let store = PluginStore::try_new(codex_home)?; + store + .install_with_version(plugin_root, bundle.plugin_id, bundle.plugin_version) + .map_err(RemotePluginBundleInstallError::from) +} + +fn extract_plugin_bundle_tar_gz( + bytes: &[u8], + destination: &Path, +) -> Result<(), RemotePluginBundleInstallError> { + extract_plugin_bundle_tar_gz_with_limits( + bytes, + destination, + REMOTE_PLUGIN_BUNDLE_MAX_EXTRACTED_BYTES, + ) +} + +fn extract_plugin_bundle_tar_gz_with_limits( + bytes: &[u8], + destination: &Path, + max_total_bytes: u64, +) -> Result<(), RemotePluginBundleInstallError> { + fs::create_dir_all(destination).map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to create remote plugin bundle extraction directory", + source, + ) + })?; + + let archive = GzDecoder::new(std::io::Cursor::new(bytes)); + let mut archive = Archive::new(archive); + extract_plugin_bundle_tar(&mut archive, destination, max_total_bytes) +} + +fn extract_plugin_bundle_tar( + archive: &mut Archive, + destination: &Path, + max_total_bytes: u64, +) -> Result<(), RemotePluginBundleInstallError> { + let mut extracted_bytes = 0u64; + let entries = archive.entries().map_err(|source| { + RemotePluginBundleInstallError::io("failed to read remote plugin bundle tar", source) + })?; + let entries = entries.raw(true); + for entry in entries { + let mut entry = entry.map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to read remote plugin bundle tar entry", + source, + ) + })?; + let entry_type = entry.header().entry_type(); + let entry_size = entry.size(); + let entry_path = entry.path().map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to read remote plugin bundle tar entry path", + source, + ) + })?; + let entry_path = entry_path.into_owned(); + let output_path = checked_tar_output_path(destination, &entry_path)?; + + if entry_type.is_dir() { + fs::create_dir_all(&output_path).map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to create remote plugin bundle directory", + source, + ) + })?; + continue; + } + + if entry_type.is_file() { + enforce_total_extracted_size(entry_size, &mut extracted_bytes, max_total_bytes)?; + let Some(parent) = output_path.parent() else { + return Err(RemotePluginBundleInstallError::InvalidBundle(format!( + "remote plugin bundle output path has no parent: {}", + output_path.display() + ))); + }; + fs::create_dir_all(parent).map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to create remote plugin bundle directory", + source, + ) + })?; + entry.unpack(&output_path).map_err(|source| { + RemotePluginBundleInstallError::io( + "failed to unpack remote plugin bundle entry", + source, + ) + })?; + continue; + } + + if entry_type.is_hard_link() || entry_type.is_symlink() { + return Err(RemotePluginBundleInstallError::InvalidBundle(format!( + "remote plugin bundle tar entry `{}` is a link", + entry_path.display() + ))); + } + + return Err(RemotePluginBundleInstallError::InvalidBundle(format!( + "remote plugin bundle tar entry `{}` has unsupported type {:?}", + entry_path.display(), + entry_type + ))); + } + + Ok(()) +} + +fn checked_tar_output_path( + destination: &Path, + entry_name: &Path, +) -> Result { + let mut output_path = destination.to_path_buf(); + let mut has_component = false; + for component in entry_name.components() { + match component { + std::path::Component::Normal(component) => { + has_component = true; + output_path.push(component); + } + std::path::Component::CurDir => {} + std::path::Component::ParentDir + | std::path::Component::RootDir + | std::path::Component::Prefix(_) => { + return Err(RemotePluginBundleInstallError::InvalidBundle(format!( + "remote plugin bundle tar entry `{}` escapes extraction root", + entry_name.display() + ))); + } + } + } + if !has_component { + return Err(RemotePluginBundleInstallError::InvalidBundle( + "remote plugin bundle tar entry has an empty path".to_string(), + )); + } + Ok(output_path) +} + +fn enforce_total_extracted_size( + entry_size: u64, + extracted_bytes: &mut u64, + max_total_bytes: u64, +) -> Result<(), RemotePluginBundleInstallError> { + let next_total = extracted_bytes.checked_add(entry_size).ok_or( + RemotePluginBundleInstallError::ExtractedBundleTooLarge { + bytes: u64::MAX, + max_bytes: max_total_bytes, + }, + )?; + if next_total > max_total_bytes { + return Err(RemotePluginBundleInstallError::ExtractedBundleTooLarge { + bytes: next_total, + max_bytes: max_total_bytes, + }); + } + *extracted_bytes = next_total; + Ok(()) +} + +fn find_extracted_plugin_root( + extraction_root: &Path, +) -> Result { + if is_standard_plugin_root(extraction_root) { + return Ok(extraction_root.to_path_buf()); + } + + Err(RemotePluginBundleInstallError::InvalidBundle( + "remote plugin bundle did not contain a standard plugin root with plugin.json".to_string(), + )) +} + +fn is_standard_plugin_root(path: &Path) -> bool { + find_plugin_manifest_path(path).is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::Compression; + use flate2::write::GzEncoder; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + const REMOTE_PLUGIN_ID: &str = "plugins~Plugin_00000000000000000000000000000000"; + + #[test] + fn validate_remote_plugin_bundle_uses_detail_name_for_local_plugin_id() { + let bundle = validate_remote_plugin_bundle( + REMOTE_PLUGIN_ID, + "chatgpt-global", + "linear", + Some("1.2.3"), + Some("https://example.com/linear.tar.gz"), + ) + .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_version, "1.2.3"); + assert_eq!( + bundle.bundle_download_url.as_str(), + "https://example.com/linear.tar.gz" + ); + } + + #[test] + fn validate_remote_plugin_bundle_rejects_missing_release_version() { + let err = validate_remote_plugin_bundle( + REMOTE_PLUGIN_ID, + "chatgpt-global", + "linear", + /*release_version*/ None, + Some("https://example.com/linear.tar.gz"), + ) + .expect_err("missing release version should be rejected"); + + assert!(matches!( + err, + RemotePluginBundleInstallError::MissingReleaseVersion { .. } + )); + } + + #[test] + fn validate_remote_plugin_bundle_rejects_invalid_release_version() { + let err = validate_remote_plugin_bundle( + REMOTE_PLUGIN_ID, + "chatgpt-global", + "linear", + Some("../1.2.3"), + Some("https://example.com/linear.tar.gz"), + ) + .expect_err("invalid release version should be rejected"); + + assert!(matches!( + err, + RemotePluginBundleInstallError::InvalidReleaseVersion { .. } + )); + } + + #[test] + fn validate_remote_plugin_bundle_rejects_missing_download_url() { + let err = validate_remote_plugin_bundle( + REMOTE_PLUGIN_ID, + "chatgpt-global", + "linear", + Some("1.2.3"), + /*bundle_download_url*/ None, + ) + .expect_err("missing bundle download URL should be rejected"); + + assert!(matches!( + err, + RemotePluginBundleInstallError::MissingBundleDownloadUrl { .. } + )); + } + + #[test] + fn validate_remote_plugin_bundle_rejects_unsupported_download_url_scheme() { + let err = validate_remote_plugin_bundle( + REMOTE_PLUGIN_ID, + "chatgpt-global", + "linear", + Some("1.2.3"), + Some("http://example.com/linear.tar.gz"), + ) + .expect_err("plain HTTP URLs should be rejected before cloud install"); + + assert!(matches!( + err, + RemotePluginBundleInstallError::UnsupportedBundleDownloadUrlScheme { .. } + )); + } + + #[test] + fn download_size_limit_rejects_oversized_bundle() { + let err = enforce_download_size_limit( + "https://example.com/linear.tar.gz", + /*bytes*/ 5, + /*max_bytes*/ 4, + ) + .expect_err("oversized bundle download should fail"); + + assert!(matches!( + err, + RemotePluginBundleInstallError::DownloadTooLarge { .. } + )); + } + + #[test] + fn install_rejects_invalid_tar_gz_bundle() { + let codex_home = tempdir().expect("tempdir"); + let bundle = valid_remote_plugin_bundle(); + + let err = install_remote_plugin_bundle( + codex_home.path().to_path_buf(), + bundle, + b"not a tar.gz".to_vec(), + ) + .expect_err("invalid tar.gz should be rejected"); + + assert!(format!("{err}").contains("failed to read remote plugin bundle tar")); + } + + #[test] + fn install_rejects_bundle_without_standard_plugin_root() { + let codex_home = tempdir().expect("tempdir"); + let bundle = valid_remote_plugin_bundle(); + + let err = install_remote_plugin_bundle( + codex_home.path().to_path_buf(), + bundle, + tar_gz_bytes(&[("README.md", b"missing plugin manifest", /*mode*/ 0o644)]), + ) + .expect_err("bundle without plugin root should be rejected"); + + assert!( + format!("{err}").contains("did not contain a standard plugin root with plugin.json") + ); + } + + #[test] + fn find_extracted_plugin_root_uses_local_manifest_discovery() { + let extraction_root = tempdir().expect("tempdir"); + std::fs::create_dir_all(extraction_root.path().join(".codex-plugin")) + .expect("create manifest dir"); + std::fs::write( + extraction_root.path().join(".codex-plugin/plugin.json"), + r#"{"name":"linear"}"#, + ) + .expect("write manifest"); + + assert_eq!( + find_extracted_plugin_root(extraction_root.path()).expect("plugin root"), + extraction_root.path() + ); + } + + #[test] + fn find_extracted_plugin_root_rejects_nested_plugin_root() { + let extraction_root = tempdir().expect("tempdir"); + let plugin_root = extraction_root.path().join("linear"); + std::fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir"); + std::fs::write( + plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"linear"}"#, + ) + .expect("write manifest"); + + let err = find_extracted_plugin_root(extraction_root.path()) + .expect_err("nested plugin root should be rejected"); + + assert!( + format!("{err}").contains("did not contain a standard plugin root with plugin.json") + ); + } + + #[test] + fn extraction_rejects_tar_path_traversal() { + let destination = tempdir().expect("tempdir"); + let err = checked_tar_output_path(destination.path(), Path::new("../evil.txt")) + .expect_err("tar path traversal should be rejected"); + + assert!(format!("{err}").contains("escapes extraction root")); + } + + #[test] + fn extraction_rejects_total_size_over_limit() { + let destination = tempdir().expect("tempdir"); + let err = extract_plugin_bundle_tar_gz_with_limits( + &tar_gz_bytes(&[ + ("a.txt", b"1234", /*mode*/ 0o644), + ("b.txt", b"5678", /*mode*/ 0o644), + ]), + destination.path(), + /*max_total_bytes*/ 6, + ) + .expect_err("oversized extracted bundle should be rejected"); + + assert!(matches!( + err, + RemotePluginBundleInstallError::ExtractedBundleTooLarge { .. } + )); + } + + #[test] + fn extraction_rejects_pax_metadata_entries() { + let destination = tempdir().expect("tempdir"); + let err = extract_plugin_bundle_tar_gz( + &tar_gz_bytes_with_entry_type( + tar::EntryType::XHeader, + "PaxHeaders.0/linear", + b"18 path=linear\n", + /*mode*/ 0o644, + ), + destination.path(), + ) + .expect_err("pax metadata entries should be rejected"); + + assert!(format!("{err}").contains("unsupported type")); + } + + #[cfg(unix)] + #[test] + fn extraction_preserves_executable_permissions() { + use std::os::unix::fs::PermissionsExt; + + let destination = tempdir().expect("tempdir"); + extract_plugin_bundle_tar_gz( + &tar_gz_bytes(&[ + ( + ".codex-plugin/plugin.json", + b"{\"name\":\"linear\"}", + /*mode*/ 0o644, + ), + ("bin/helper", b"#!/bin/sh\n", /*mode*/ 0o755), + ]), + destination.path(), + ) + .expect("extract bundle"); + + let mode = std::fs::metadata(destination.path().join("bin/helper")) + .expect("helper metadata") + .permissions() + .mode() + & 0o777; + assert_eq!(mode, 0o755); + } + + fn valid_remote_plugin_bundle() -> ValidatedRemotePluginBundle { + validate_remote_plugin_bundle( + REMOTE_PLUGIN_ID, + "chatgpt-global", + "linear", + Some("1.2.3"), + Some("https://example.com/linear.tar.gz"), + ) + .expect("valid install plan") + } + + fn tar_gz_bytes(entries: &[(&str, &[u8], u32)]) -> Vec { + let encoder = GzEncoder::new(Vec::new(), Compression::default()); + let mut tar = tar::Builder::new(encoder); + for (path, contents, mode) in entries { + append_tar_entry(&mut tar, tar::EntryType::Regular, path, contents, *mode); + } + finish_tar_gz(tar) + } + + fn tar_gz_bytes_with_entry_type( + entry_type: tar::EntryType, + path: &str, + contents: &[u8], + mode: u32, + ) -> Vec { + let encoder = GzEncoder::new(Vec::new(), Compression::default()); + let mut tar = tar::Builder::new(encoder); + append_tar_entry(&mut tar, entry_type, path, contents, mode); + finish_tar_gz(tar) + } + + fn append_tar_entry( + tar: &mut tar::Builder, + entry_type: tar::EntryType, + path: &str, + contents: &[u8], + mode: u32, + ) { + let mut header = tar::Header::new_gnu(); + header.set_entry_type(entry_type); + header.set_size(contents.len() as u64); + header.set_mode(mode); + header.set_cksum(); + if let Err(error) = tar.append_data(&mut header, path, contents) { + panic!("failed to append tar test data: {error}"); + } + } + + fn finish_tar_gz(tar: tar::Builder>>) -> Vec { + let encoder = match tar.into_inner() { + Ok(encoder) => encoder, + Err(error) => panic!("failed to finish tar test data: {error}"), + }; + match encoder.finish() { + Ok(bytes) => bytes, + Err(error) => panic!("failed to finish gzip test data: {error}"), + } + } +} diff --git a/codex-rs/core-plugins/src/store.rs b/codex-rs/core-plugins/src/store.rs index 757aec8bc..2ffead0fb 100644 --- a/codex-rs/core-plugins/src/store.rs +++ b/codex-rs/core-plugins/src/store.rs @@ -160,7 +160,7 @@ pub fn plugin_version_for_source(source_path: &Path) -> Result Result<(), String> { +pub fn validate_plugin_version_segment(plugin_version: &str) -> Result<(), String> { if plugin_version.is_empty() { return Err("invalid plugin version: must not be empty".to_string()); }