mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat: Cache remote plugin bundles on install (#19914)
Remote installs now fetch, validate, download, and cache the plugin bundle locally
This commit is contained in:
committed by
GitHub
Unverified
parent
b985768dc1
commit
7d72fc8f53
Generated
+1
@@ -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\"]}}",
|
||||
|
||||
Generated
+14
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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::<Vec<_>>();
|
||||
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}"))
|
||||
}
|
||||
|
||||
@@ -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<u8>,
|
||||
) -> 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<i64> {
|
||||
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<Vec<u8>> {
|
||||
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()?)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -49,6 +49,8 @@ pub struct RemotePluginDetail {
|
||||
pub marketplace_display_name: String,
|
||||
pub summary: RemotePluginSummary,
|
||||
pub description: Option<String>,
|
||||
pub release_version: Option<String>,
|
||||
pub bundle_download_url: Option<String>,
|
||||
pub skills: Vec<RemotePluginSkill>,
|
||||
pub app_ids: Vec<String>,
|
||||
}
|
||||
@@ -168,23 +170,16 @@ impl RemotePluginScope {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
struct RemotePluginPagination {
|
||||
#[serde(alias = "nextPageToken")]
|
||||
next_page_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
struct RemotePluginSkillInterfaceResponse {
|
||||
#[serde(alias = "displayName")]
|
||||
display_name: Option<String>,
|
||||
#[serde(alias = "shortDescription")]
|
||||
short_description: Option<String>,
|
||||
#[serde(alias = "brandColor")]
|
||||
brand_color: Option<String>,
|
||||
#[serde(alias = "defaultPrompt")]
|
||||
default_prompt: Option<String>,
|
||||
#[serde(alias = "iconSmallUrl")]
|
||||
icon_small_url: Option<String>,
|
||||
#[serde(alias = "iconLargeUrl")]
|
||||
icon_large_url: Option<String>,
|
||||
}
|
||||
|
||||
@@ -197,41 +192,32 @@ struct RemotePluginSkillResponse {
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
struct RemotePluginReleaseInterfaceResponse {
|
||||
#[serde(alias = "shortDescription")]
|
||||
short_description: Option<String>,
|
||||
#[serde(alias = "longDescription")]
|
||||
long_description: Option<String>,
|
||||
#[serde(alias = "developerName")]
|
||||
developer_name: Option<String>,
|
||||
category: Option<String>,
|
||||
#[serde(default)]
|
||||
capabilities: Vec<String>,
|
||||
#[serde(alias = "websiteUrl")]
|
||||
website_url: Option<String>,
|
||||
#[serde(alias = "privacyPolicyUrl")]
|
||||
privacy_policy_url: Option<String>,
|
||||
#[serde(alias = "termsOfServiceUrl")]
|
||||
terms_of_service_url: Option<String>,
|
||||
#[serde(alias = "brandColor")]
|
||||
brand_color: Option<String>,
|
||||
#[serde(alias = "defaultPrompt")]
|
||||
default_prompt: Option<String>,
|
||||
#[serde(alias = "composerIconUrl")]
|
||||
composer_icon_url: Option<String>,
|
||||
#[serde(alias = "logoUrl")]
|
||||
logo_url: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(alias = "screenshotUrls")]
|
||||
screenshot_urls: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
struct RemotePluginReleaseResponse {
|
||||
#[serde(alias = "displayName")]
|
||||
#[serde(default)]
|
||||
version: Option<String>,
|
||||
display_name: String,
|
||||
description: String,
|
||||
#[serde(default)]
|
||||
#[serde(alias = "appIds")]
|
||||
bundle_download_url: Option<String>,
|
||||
#[serde(default)]
|
||||
app_ids: Vec<String>,
|
||||
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<String>,
|
||||
}
|
||||
|
||||
@@ -381,6 +364,39 @@ pub async fn fetch_remote_plugin_detail(
|
||||
auth: Option<&CodexAuth>,
|
||||
marketplace_name: &str,
|
||||
plugin_id: &str,
|
||||
) -> Result<RemotePluginDetail, RemotePluginCatalogError> {
|
||||
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<RemotePluginDetail, RemotePluginCatalogError> {
|
||||
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<RemotePluginDetail, RemotePluginCatalogError> {
|
||||
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<RemotePluginDirectoryItem, RemotePluginCatalogError> {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<ValidatedRemotePluginBundle, RemotePluginBundleInstallError> {
|
||||
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<PluginInstallResult, RemotePluginBundleInstallError> {
|
||||
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<Vec<u8>, 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<Vec<u8>, 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<u8>,
|
||||
) -> Result<PluginInstallResult, RemotePluginBundleInstallError> {
|
||||
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<R: Read>(
|
||||
archive: &mut Archive<R>,
|
||||
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<PathBuf, RemotePluginBundleInstallError> {
|
||||
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<PathBuf, RemotePluginBundleInstallError> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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<W: std::io::Write>(
|
||||
tar: &mut tar::Builder<W>,
|
||||
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<GzEncoder<Vec<u8>>>) -> Vec<u8> {
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -160,7 +160,7 @@ pub fn plugin_version_for_source(source_path: &Path) -> Result<String, PluginSto
|
||||
Ok(plugin_version)
|
||||
}
|
||||
|
||||
fn validate_plugin_version_segment(plugin_version: &str) -> 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());
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user