mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
feat: Add vertical remote plugin collection support (#23584)
- Adds an explicit vertical marketplace kind for plugin/list that fail-open fetches collection=vertical only when full remote plugins are disabled. - Renames the global remote marketplace/cache identity to openai-curated-remote and materializes remote installs with backend release versions and app manifests.
This commit is contained in:
committed by
GitHub
Unverified
parent
9dda71dbae
commit
dc255b0d8a
@@ -1632,6 +1632,7 @@
|
||||
"PluginListMarketplaceKind": {
|
||||
"enum": [
|
||||
"local",
|
||||
"vertical",
|
||||
"workspace-directory",
|
||||
"shared-with-me"
|
||||
],
|
||||
|
||||
+1
@@ -12193,6 +12193,7 @@
|
||||
"PluginListMarketplaceKind": {
|
||||
"enum": [
|
||||
"local",
|
||||
"vertical",
|
||||
"workspace-directory",
|
||||
"shared-with-me"
|
||||
],
|
||||
|
||||
+1
@@ -8742,6 +8742,7 @@
|
||||
"PluginListMarketplaceKind": {
|
||||
"enum": [
|
||||
"local",
|
||||
"vertical",
|
||||
"workspace-directory",
|
||||
"shared-with-me"
|
||||
],
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
"PluginListMarketplaceKind": {
|
||||
"enum": [
|
||||
"local",
|
||||
"vertical",
|
||||
"workspace-directory",
|
||||
"shared-with-me"
|
||||
],
|
||||
|
||||
+1
-1
@@ -2,4 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PluginListMarketplaceKind = "local" | "workspace-directory" | "shared-with-me";
|
||||
export type PluginListMarketplaceKind = "local" | "vertical" | "workspace-directory" | "shared-with-me";
|
||||
|
||||
@@ -144,6 +144,9 @@ pub enum PluginListMarketplaceKind {
|
||||
#[serde(rename = "local")]
|
||||
#[ts(rename = "local")]
|
||||
Local,
|
||||
#[serde(rename = "vertical")]
|
||||
#[ts(rename = "vertical")]
|
||||
Vertical,
|
||||
#[serde(rename = "workspace-directory")]
|
||||
#[ts(rename = "workspace-directory")]
|
||||
WorkspaceDirectory,
|
||||
|
||||
@@ -2708,14 +2708,14 @@ fn marketplace_upgrade_params_serialization_uses_optional_marketplace_name() {
|
||||
fn plugin_marketplace_entry_serializes_remote_only_path_as_null() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginMarketplaceEntry {
|
||||
name: "openai-curated".to_string(),
|
||||
name: "openai-curated-remote".to_string(),
|
||||
path: None,
|
||||
interface: None,
|
||||
plugins: Vec::new(),
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"name": "openai-curated",
|
||||
"name": "openai-curated-remote",
|
||||
"path": null,
|
||||
"interface": null,
|
||||
"plugins": [],
|
||||
@@ -2799,6 +2799,7 @@ fn plugin_list_params_serializes_marketplace_kind_filter() {
|
||||
cwds: None,
|
||||
marketplace_kinds: Some(vec![
|
||||
PluginListMarketplaceKind::Local,
|
||||
PluginListMarketplaceKind::Vertical,
|
||||
PluginListMarketplaceKind::WorkspaceDirectory,
|
||||
PluginListMarketplaceKind::SharedWithMe,
|
||||
]),
|
||||
@@ -2808,6 +2809,7 @@ fn plugin_list_params_serializes_marketplace_kind_filter() {
|
||||
"cwds": null,
|
||||
"marketplaceKinds": [
|
||||
"local",
|
||||
"vertical",
|
||||
"workspace-directory",
|
||||
"shared-with-me",
|
||||
],
|
||||
@@ -2875,13 +2877,13 @@ fn plugin_read_params_serialization_uses_install_source_fields() {
|
||||
|
||||
assert_eq!(
|
||||
serde_json::from_value::<PluginReadParams>(json!({
|
||||
"remoteMarketplaceName": "openai-curated",
|
||||
"remoteMarketplaceName": "openai-curated-remote",
|
||||
"pluginName": "gmail",
|
||||
}))
|
||||
.unwrap(),
|
||||
PluginReadParams {
|
||||
marketplace_path: None,
|
||||
remote_marketplace_name: Some("openai-curated".to_string()),
|
||||
remote_marketplace_name: Some("openai-curated-remote".to_string()),
|
||||
plugin_name: "gmail".to_string(),
|
||||
},
|
||||
);
|
||||
@@ -2926,14 +2928,14 @@ fn plugin_install_params_serialization_omits_force_remote_sync() {
|
||||
|
||||
assert_eq!(
|
||||
serde_json::from_value::<PluginInstallParams>(json!({
|
||||
"remoteMarketplaceName": "openai-curated",
|
||||
"remoteMarketplaceName": "openai-curated-remote",
|
||||
"pluginName": "gmail",
|
||||
"forceRemoteSync": true,
|
||||
}))
|
||||
.unwrap(),
|
||||
PluginInstallParams {
|
||||
marketplace_path: None,
|
||||
remote_marketplace_name: Some("openai-curated".to_string()),
|
||||
remote_marketplace_name: Some("openai-curated-remote".to_string()),
|
||||
plugin_name: "gmail".to_string(),
|
||||
},
|
||||
);
|
||||
@@ -2943,13 +2945,13 @@ fn plugin_install_params_serialization_omits_force_remote_sync() {
|
||||
fn plugin_skill_read_params_serialization_uses_remote_plugin_id() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginSkillReadParams {
|
||||
remote_marketplace_name: "chatgpt-global".to_string(),
|
||||
remote_marketplace_name: "openai-curated-remote".to_string(),
|
||||
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
skill_name: "plan-work".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"remoteMarketplaceName": "chatgpt-global",
|
||||
"remoteMarketplaceName": "openai-curated-remote",
|
||||
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"skillName": "plan-work",
|
||||
}),
|
||||
@@ -3144,7 +3146,7 @@ fn plugin_share_list_response_serializes_share_items() {
|
||||
serde_json::to_value(PluginShareListResponse {
|
||||
data: vec![PluginShareListItem {
|
||||
plugin: PluginSummary {
|
||||
id: "gmail@chatgpt-global".to_string(),
|
||||
id: "gmail@openai-curated-remote".to_string(),
|
||||
remote_plugin_id: Some(
|
||||
"plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
),
|
||||
@@ -3167,7 +3169,7 @@ fn plugin_share_list_response_serializes_share_items() {
|
||||
json!({
|
||||
"data": [{
|
||||
"plugin": {
|
||||
"id": "gmail@chatgpt-global",
|
||||
"id": "gmail@openai-curated-remote",
|
||||
"remotePluginId": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"localVersion": null,
|
||||
"name": "gmail",
|
||||
|
||||
@@ -475,6 +475,7 @@ impl PluginRequestProcessor {
|
||||
let marketplace_kinds =
|
||||
marketplace_kinds.unwrap_or_else(|| vec![PluginListMarketplaceKind::Local]);
|
||||
let include_local = marketplace_kinds.contains(&PluginListMarketplaceKind::Local);
|
||||
let include_vertical = marketplace_kinds.contains(&PluginListMarketplaceKind::Vertical);
|
||||
|
||||
let config = self.load_latest_config(/*fallback_cwd*/ None).await?;
|
||||
let empty_response = || PluginListResponse {
|
||||
@@ -564,6 +565,35 @@ impl PluginRequestProcessor {
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
|
||||
// TODO(remote plugins): Remove this once remote plugins are ready and vertical plugins are
|
||||
// served directly from the normal remote catalog.
|
||||
if include_vertical && !config.features.enabled(Feature::RemotePlugin) {
|
||||
let remote_plugin_service_config = RemotePluginServiceConfig {
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
};
|
||||
match codex_core_plugins::remote::fetch_openai_curated_remote_collection_marketplace(
|
||||
&remote_plugin_service_config,
|
||||
auth.as_ref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(Some(remote_marketplace)) => {
|
||||
data.push(remote_marketplace_to_info(remote_marketplace));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(
|
||||
RemotePluginCatalogError::AuthRequired
|
||||
| RemotePluginCatalogError::UnsupportedAuthMode,
|
||||
) => {}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
error = %err,
|
||||
"plugin/list openai-curated-remote collection fetch failed; returning local marketplaces only"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut remote_sources = Vec::new();
|
||||
if !explicit_marketplace_kinds && config.features.enabled(Feature::RemotePlugin) {
|
||||
remote_sources.push(RemoteMarketplaceSource::Global);
|
||||
@@ -592,14 +622,7 @@ impl PluginRequestProcessor {
|
||||
.into_iter()
|
||||
.map(remote_marketplace_to_info)
|
||||
{
|
||||
if let Some(existing) = data
|
||||
.iter_mut()
|
||||
.find(|marketplace| marketplace.name == remote_marketplace.name)
|
||||
{
|
||||
*existing = remote_marketplace;
|
||||
} else {
|
||||
data.push(remote_marketplace);
|
||||
}
|
||||
data.push(remote_marketplace);
|
||||
}
|
||||
}
|
||||
Err(
|
||||
@@ -1407,6 +1430,7 @@ impl PluginRequestProcessor {
|
||||
&remote_detail.summary.name,
|
||||
remote_detail.release_version.as_deref(),
|
||||
remote_detail.bundle_download_url.as_deref(),
|
||||
remote_detail.app_manifest.clone(),
|
||||
)
|
||||
.map_err(remote_plugin_bundle_install_error_to_jsonrpc)?;
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ async fn plugin_install_rejects_multiple_install_sources() -> Result<()> {
|
||||
marketplace_path: Some(AbsolutePathBuf::try_from(
|
||||
codex_home.path().join("marketplace.json"),
|
||||
)?),
|
||||
remote_marketplace_name: Some("openai-curated".to_string()),
|
||||
remote_marketplace_name: Some("openai-curated-remote".to_string()),
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
@@ -167,7 +167,7 @@ plugins = false
|
||||
let request_id = mcp
|
||||
.send_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path: None,
|
||||
remote_marketplace_name: Some("chatgpt-global".to_string()),
|
||||
remote_marketplace_name: Some("openai-curated-remote".to_string()),
|
||||
plugin_name: "plugins~Plugin_22222222222222222222222222222222".to_string(),
|
||||
})
|
||||
.await?;
|
||||
@@ -193,15 +193,32 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()>
|
||||
let server = MockServer::start().await;
|
||||
let installed_path = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear/1.2.3");
|
||||
.join("plugins/cache/openai-curated-remote/linear/1.2.3");
|
||||
let remote_app_manifest = json!({
|
||||
"apps": {
|
||||
"linear-remote": {
|
||||
"id": "remote-linear-app"
|
||||
}
|
||||
}
|
||||
});
|
||||
let bundle_url = mount_remote_plugin_bundle(
|
||||
&server,
|
||||
/*status_code*/ 200,
|
||||
remote_plugin_bundle_tar_gz_bytes("linear")?,
|
||||
remote_plugin_bundle_tar_gz_bytes_with_contents(
|
||||
r#"{"name":"linear","version":"0.0.1"}"#,
|
||||
Some(r#"{"apps":{"linear-bundled":{"id":"bundled-linear-app"}}}"#),
|
||||
)?,
|
||||
)
|
||||
.await;
|
||||
configure_remote_plugin_test(codex_home.path(), &server)?;
|
||||
mount_remote_plugin_detail(&server, REMOTE_PLUGIN_ID, "1.2.3", Some(&bundle_url)).await;
|
||||
mount_remote_plugin_detail_with_app_manifest(
|
||||
&server,
|
||||
REMOTE_PLUGIN_ID,
|
||||
"1.2.3",
|
||||
Some(&bundle_url),
|
||||
remote_app_manifest.clone(),
|
||||
)
|
||||
.await;
|
||||
mount_empty_remote_installed_plugins(&server).await;
|
||||
mount_remote_plugin_install_after_cache_write(
|
||||
&server,
|
||||
@@ -247,12 +264,20 @@ async fn plugin_install_writes_remote_plugin_to_cloud_and_cache() -> Result<()>
|
||||
)
|
||||
.await?;
|
||||
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
|
||||
let installed_plugin_manifest: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(installed_path.join(".codex-plugin/plugin.json"))?,
|
||||
)?;
|
||||
assert_eq!(installed_plugin_manifest["name"], json!("linear"));
|
||||
assert_eq!(installed_plugin_manifest["version"], json!("1.2.3"));
|
||||
let installed_app_manifest: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(installed_path.join(".app.json"))?)?;
|
||||
assert_eq!(installed_app_manifest, remote_app_manifest);
|
||||
assert!(installed_path.join("skills/plan-work/SKILL.md").is_file());
|
||||
assert!(
|
||||
!codex_home
|
||||
.path()
|
||||
.join(format!(
|
||||
"plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}/1.2.3"
|
||||
"plugins/cache/openai-curated-remote/{REMOTE_PLUGIN_ID}/1.2.3"
|
||||
))
|
||||
.exists()
|
||||
);
|
||||
@@ -299,7 +324,7 @@ async fn plugin_install_rejects_missing_remote_bundle_url() -> Result<()> {
|
||||
assert!(
|
||||
!codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear")
|
||||
.join("plugins/cache/openai-curated-remote/linear")
|
||||
.exists()
|
||||
);
|
||||
Ok(())
|
||||
@@ -340,7 +365,7 @@ async fn plugin_install_rejects_plain_http_remote_bundle_url() -> Result<()> {
|
||||
assert!(
|
||||
!codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear")
|
||||
.join("plugins/cache/openai-curated-remote/linear")
|
||||
.exists()
|
||||
);
|
||||
Ok(())
|
||||
@@ -382,7 +407,7 @@ async fn plugin_install_rejects_invalid_remote_release_version() -> Result<()> {
|
||||
assert!(
|
||||
!codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear")
|
||||
.join("plugins/cache/openai-curated-remote/linear")
|
||||
.exists()
|
||||
);
|
||||
Ok(())
|
||||
@@ -398,7 +423,7 @@ async fn plugin_install_rejects_invalid_remote_plugin_name() -> Result<()> {
|
||||
let request_id = mcp
|
||||
.send_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path: None,
|
||||
remote_marketplace_name: Some("chatgpt-global".to_string()),
|
||||
remote_marketplace_name: Some("openai-curated-remote".to_string()),
|
||||
plugin_name: "linear/../../oops".to_string(),
|
||||
})
|
||||
.await?;
|
||||
@@ -468,7 +493,7 @@ async fn plugin_install_rejects_remote_plugin_disabled_by_admin_before_download(
|
||||
assert!(
|
||||
!codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear")
|
||||
.join("plugins/cache/openai-curated-remote/linear")
|
||||
.exists()
|
||||
);
|
||||
Ok(())
|
||||
@@ -764,7 +789,7 @@ async fn plugin_install_tracks_remote_plugin_analytics_event() -> Result<()> {
|
||||
"event_params": {
|
||||
"plugin_id": REMOTE_PLUGIN_ID,
|
||||
"plugin_name": "linear",
|
||||
"marketplace_name": "chatgpt-global",
|
||||
"marketplace_name": "openai-curated-remote",
|
||||
"has_skills": true,
|
||||
"mcp_server_count": 0,
|
||||
"connector_ids": [],
|
||||
@@ -824,7 +849,7 @@ async fn plugin_install_errors_when_remote_bundle_download_fails() -> Result<()>
|
||||
assert!(
|
||||
!codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear")
|
||||
.join("plugins/cache/openai-curated-remote/linear")
|
||||
.exists()
|
||||
);
|
||||
Ok(())
|
||||
@@ -1349,12 +1374,49 @@ async fn mount_remote_plugin_detail(
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mount_remote_plugin_detail_with_app_manifest(
|
||||
server: &MockServer,
|
||||
remote_plugin_id: &str,
|
||||
release_version: &str,
|
||||
bundle_download_url: Option<&str>,
|
||||
app_manifest: serde_json::Value,
|
||||
) {
|
||||
mount_remote_plugin_detail_with_status_and_app_manifest(
|
||||
server,
|
||||
remote_plugin_id,
|
||||
release_version,
|
||||
bundle_download_url,
|
||||
PluginAvailability::Available,
|
||||
Some(app_manifest),
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mount_remote_plugin_detail_with_status(
|
||||
server: &MockServer,
|
||||
remote_plugin_id: &str,
|
||||
release_version: &str,
|
||||
bundle_download_url: Option<&str>,
|
||||
status: PluginAvailability,
|
||||
) {
|
||||
mount_remote_plugin_detail_with_status_and_app_manifest(
|
||||
server,
|
||||
remote_plugin_id,
|
||||
release_version,
|
||||
bundle_download_url,
|
||||
status,
|
||||
/*app_manifest*/ None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mount_remote_plugin_detail_with_status_and_app_manifest(
|
||||
server: &MockServer,
|
||||
remote_plugin_id: &str,
|
||||
release_version: &str,
|
||||
bundle_download_url: Option<&str>,
|
||||
status: PluginAvailability,
|
||||
app_manifest: Option<serde_json::Value>,
|
||||
) {
|
||||
let status = match status {
|
||||
PluginAvailability::Available => "ENABLED",
|
||||
@@ -1363,6 +1425,9 @@ async fn mount_remote_plugin_detail_with_status(
|
||||
let bundle_download_url_field = bundle_download_url
|
||||
.map(|url| format!(r#" "bundle_download_url": "{url}","#))
|
||||
.unwrap_or_default();
|
||||
let app_manifest_field = app_manifest
|
||||
.map(|manifest| format!(r#" "app_manifest": {manifest},"#))
|
||||
.unwrap_or_default();
|
||||
let detail_body = format!(
|
||||
r#"{{
|
||||
"id": "{remote_plugin_id}",
|
||||
@@ -1377,6 +1442,7 @@ async fn mount_remote_plugin_detail_with_status(
|
||||
"display_name": "Linear",
|
||||
"description": "Track work in Linear",
|
||||
"app_ids": [],
|
||||
{app_manifest_field}
|
||||
"interface": {{
|
||||
"short_description": "Plan and track work"
|
||||
}},
|
||||
@@ -1576,13 +1642,20 @@ fn write_plugin_source(
|
||||
|
||||
fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result<Vec<u8>> {
|
||||
let manifest = format!(r#"{{"name":"{plugin_name}"}}"#);
|
||||
remote_plugin_bundle_tar_gz_bytes_with_contents(&manifest, /*app_manifest*/ None)
|
||||
}
|
||||
|
||||
fn remote_plugin_bundle_tar_gz_bytes_with_contents(
|
||||
plugin_manifest: &str,
|
||||
app_manifest: Option<&str>,
|
||||
) -> Result<Vec<u8>> {
|
||||
let skill = "# Plan Work\n\nTrack work in Linear.\n";
|
||||
let encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
let mut tar = tar::Builder::new(encoder);
|
||||
for (path, contents, mode) in [
|
||||
let mut entries = vec![
|
||||
(
|
||||
".codex-plugin/plugin.json",
|
||||
manifest.as_bytes(),
|
||||
plugin_manifest.as_bytes(),
|
||||
/*mode*/ 0o644,
|
||||
),
|
||||
(
|
||||
@@ -1590,7 +1663,11 @@ fn remote_plugin_bundle_tar_gz_bytes(plugin_name: &str) -> Result<Vec<u8>> {
|
||||
skill.as_bytes(),
|
||||
/*mode*/ 0o644,
|
||||
),
|
||||
] {
|
||||
];
|
||||
if let Some(app_manifest) = app_manifest {
|
||||
entries.push((".app.json", app_manifest.as_bytes(), /*mode*/ 0o644));
|
||||
}
|
||||
for (path, contents, mode) in entries {
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(contents.len() as u64);
|
||||
header.set_mode(mode);
|
||||
|
||||
@@ -1470,15 +1470,26 @@ async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() ->
|
||||
remote_plugin_bundle_tar_gz_bytes("linear")?,
|
||||
)
|
||||
.await;
|
||||
let global_installed_body =
|
||||
remote_installed_plugin_body(&bundle_url, "1.2.3", /*enabled*/ true);
|
||||
let remote_app_manifest = serde_json::json!({
|
||||
"apps": {
|
||||
"linear-remote": {
|
||||
"id": "remote-linear-app"
|
||||
}
|
||||
}
|
||||
});
|
||||
let global_installed_body = remote_installed_plugin_body_with_app_manifest(
|
||||
&bundle_url,
|
||||
"1.2.3",
|
||||
/*enabled*/ true,
|
||||
remote_app_manifest.clone(),
|
||||
);
|
||||
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
|
||||
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
||||
.await;
|
||||
|
||||
let installed_path = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear/1.2.3");
|
||||
.join("plugins/cache/openai-curated-remote/linear/1.2.3");
|
||||
let mut mcp = McpProcess::new_with_env_and_plugin_startup_tasks(
|
||||
codex_home.path(),
|
||||
&[(TEST_ALLOW_HTTP_REMOTE_PLUGIN_BUNDLE_DOWNLOADS, Some("1"))],
|
||||
@@ -1487,9 +1498,19 @@ async fn app_server_startup_sync_downloads_remote_installed_plugin_bundles() ->
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
wait_for_path_exists(&installed_path.join(".codex-plugin/plugin.json")).await?;
|
||||
let installed_plugin_manifest: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(installed_path.join(".codex-plugin/plugin.json"))?,
|
||||
)?;
|
||||
assert_eq!(
|
||||
installed_plugin_manifest["version"],
|
||||
serde_json::json!("1.2.3")
|
||||
);
|
||||
let installed_app_manifest: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(installed_path.join(".app.json"))?)?;
|
||||
assert_eq!(installed_app_manifest, remote_app_manifest);
|
||||
assert!(installed_path.join("skills/plan-work/SKILL.md").is_file());
|
||||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||||
assert!(!config.contains("linear@chatgpt-global"));
|
||||
assert!(!config.contains("linear@openai-curated-remote"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1509,8 +1530,8 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles()
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
write_installed_plugin_with_version(&codex_home, "chatgpt-global", "linear", "1.0.0")?;
|
||||
write_installed_plugin_with_version(&codex_home, "chatgpt-global", "stale", "1.0.0")?;
|
||||
write_installed_plugin_with_version(&codex_home, "openai-curated-remote", "linear", "1.0.0")?;
|
||||
write_installed_plugin_with_version(&codex_home, "openai-curated-remote", "stale", "1.0.0")?;
|
||||
|
||||
let bundle_url = mount_remote_plugin_bundle(
|
||||
&server,
|
||||
@@ -1518,8 +1539,19 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles()
|
||||
remote_plugin_bundle_tar_gz_bytes("linear")?,
|
||||
)
|
||||
.await;
|
||||
let global_installed_body =
|
||||
remote_installed_plugin_body(&bundle_url, "1.2.3", /*enabled*/ true);
|
||||
let remote_app_manifest = serde_json::json!({
|
||||
"apps": {
|
||||
"linear-remote": {
|
||||
"id": "remote-linear-app"
|
||||
}
|
||||
}
|
||||
});
|
||||
let global_installed_body = remote_installed_plugin_body_with_app_manifest(
|
||||
&bundle_url,
|
||||
"1.2.3",
|
||||
/*enabled*/ true,
|
||||
remote_app_manifest.clone(),
|
||||
);
|
||||
mount_remote_plugin_list(&server, "GLOBAL", &global_installed_body).await;
|
||||
mount_remote_plugin_list(&server, "WORKSPACE", empty_remote_installed_plugins_body()).await;
|
||||
mount_remote_installed_plugins(&server, "GLOBAL", &global_installed_body).await;
|
||||
@@ -1528,11 +1560,13 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles()
|
||||
|
||||
let old_path = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear/1.0.0");
|
||||
.join("plugins/cache/openai-curated-remote/linear/1.0.0");
|
||||
let new_path = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear/1.2.3");
|
||||
let stale_path = codex_home.path().join("plugins/cache/chatgpt-global/stale");
|
||||
.join("plugins/cache/openai-curated-remote/linear/1.2.3");
|
||||
let stale_path = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/openai-curated-remote/stale");
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
@@ -1556,22 +1590,32 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles()
|
||||
let remote_marketplace = response
|
||||
.marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| marketplace.name == "chatgpt-global")
|
||||
.expect("expected chatgpt-global marketplace entry");
|
||||
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
||||
.expect("expected openai-curated-remote marketplace entry");
|
||||
assert_eq!(
|
||||
remote_marketplace
|
||||
.plugins
|
||||
.into_iter()
|
||||
.map(|plugin| (plugin.id, plugin.installed, plugin.enabled))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![("linear@chatgpt-global".to_string(), true, true)]
|
||||
vec![("linear@openai-curated-remote".to_string(), true, true)]
|
||||
);
|
||||
|
||||
wait_for_path_exists(&new_path.join(".codex-plugin/plugin.json")).await?;
|
||||
let installed_plugin_manifest: serde_json::Value = serde_json::from_str(
|
||||
&std::fs::read_to_string(new_path.join(".codex-plugin/plugin.json"))?,
|
||||
)?;
|
||||
assert_eq!(
|
||||
installed_plugin_manifest["version"],
|
||||
serde_json::json!("1.2.3")
|
||||
);
|
||||
let installed_app_manifest: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(new_path.join(".app.json"))?)?;
|
||||
assert_eq!(installed_app_manifest, remote_app_manifest);
|
||||
wait_for_path_missing(&old_path).await?;
|
||||
wait_for_path_missing(&stale_path).await?;
|
||||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||||
assert!(!config.contains("linear@chatgpt-global"));
|
||||
assert!(!config.contains("linear@openai-curated-remote"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1714,18 +1758,21 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
|
||||
let remote_marketplace = response
|
||||
.marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| marketplace.name == "chatgpt-global")
|
||||
.expect("expected ChatGPT remote marketplace");
|
||||
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
||||
.expect("expected openai-curated remote marketplace");
|
||||
assert_eq!(remote_marketplace.path, None);
|
||||
assert_eq!(
|
||||
remote_marketplace
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.display_name.as_deref()),
|
||||
Some("ChatGPT Plugins")
|
||||
Some("OpenAI Curated Remote")
|
||||
);
|
||||
assert_eq!(remote_marketplace.plugins.len(), 1);
|
||||
assert_eq!(remote_marketplace.plugins[0].id, "linear@chatgpt-global");
|
||||
assert_eq!(
|
||||
remote_marketplace.plugins[0].id,
|
||||
"linear@openai-curated-remote"
|
||||
);
|
||||
assert_eq!(
|
||||
remote_marketplace.plugins[0].remote_plugin_id.as_deref(),
|
||||
Some("plugins~Plugin_00000000000000000000000000000000")
|
||||
@@ -1753,6 +1800,283 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() -
|
||||
]
|
||||
);
|
||||
assert_eq!(response.featured_plugin_ids, Vec::<String>::new());
|
||||
assert!(
|
||||
!server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("wiremock should record requests")
|
||||
.iter()
|
||||
.any(|request| request
|
||||
.url
|
||||
.query_pairs()
|
||||
.any(|(name, value)| name == "collection" && value == "vertical"))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_includes_openai_curated_remote_collection_when_requested() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_plugins_enabled_config_with_base_url(
|
||||
codex_home.path(),
|
||||
&format!("{}/backend-api/", server.uri()),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let collection_body = r#"{
|
||||
"plugins": [
|
||||
{
|
||||
"id": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"name": "linear",
|
||||
"scope": "GLOBAL",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"status": "ENABLED",
|
||||
"release": {
|
||||
"display_name": "Linear",
|
||||
"description": "Track work in Linear",
|
||||
"app_ids": [],
|
||||
"interface": {
|
||||
"short_description": "Plan and track work",
|
||||
"capabilities": ["Read", "Write"]
|
||||
},
|
||||
"skills": []
|
||||
}
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"limit": 50,
|
||||
"next_page_token": null
|
||||
}
|
||||
}"#;
|
||||
mount_openai_curated_remote_collection_plugin_list(&server, collection_body).await;
|
||||
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
||||
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
||||
.await;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: None,
|
||||
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]),
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
|
||||
let remote_marketplace = response
|
||||
.marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
||||
.expect("expected openai-curated remote marketplace");
|
||||
assert_eq!(remote_marketplace.path, None);
|
||||
assert_eq!(
|
||||
remote_marketplace
|
||||
.interface
|
||||
.as_ref()
|
||||
.and_then(|interface| interface.display_name.as_deref()),
|
||||
Some("OpenAI Curated Remote")
|
||||
);
|
||||
assert_eq!(remote_marketplace.plugins.len(), 1);
|
||||
let plugin = &remote_marketplace.plugins[0];
|
||||
assert_eq!(plugin.id, "linear@openai-curated-remote");
|
||||
assert_eq!(
|
||||
plugin.remote_plugin_id.as_deref(),
|
||||
Some("plugins~Plugin_00000000000000000000000000000000")
|
||||
);
|
||||
assert_eq!(plugin.name, "linear");
|
||||
assert_eq!(plugin.source, PluginSource::Remote);
|
||||
assert_eq!(plugin.installed, false);
|
||||
assert_eq!(plugin.enabled, false);
|
||||
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("wiremock should record requests");
|
||||
assert!(requests.iter().any(|request| {
|
||||
request.method == "GET"
|
||||
&& request.url.path().ends_with("/ps/plugins/list")
|
||||
&& request
|
||||
.url
|
||||
.query_pairs()
|
||||
.any(|(name, value)| name == "collection" && value == "vertical")
|
||||
}));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_fail_opens_openai_curated_remote_collection_errors() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_plugins_enabled_config_with_base_url(
|
||||
codex_home.path(),
|
||||
&format!("{}/backend-api/", server.uri()),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/list"))
|
||||
.and(query_param("scope", "GLOBAL"))
|
||||
.and(query_param("limit", "200"))
|
||||
.and(query_param("collection", "vertical"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(500).set_body_string("temporary failure"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
mount_remote_installed_plugins(&server, "GLOBAL", empty_remote_installed_plugins_body()).await;
|
||||
mount_remote_installed_plugins(&server, "WORKSPACE", empty_remote_installed_plugins_body())
|
||||
.await;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: None,
|
||||
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]),
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
|
||||
assert!(
|
||||
response
|
||||
.marketplaces
|
||||
.iter()
|
||||
.all(|marketplace| marketplace.name != "openai-curated-remote")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_does_not_query_openai_curated_remote_collection_by_default() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_plugins_enabled_config_with_base_url(
|
||||
codex_home.path(),
|
||||
&format!("{}/backend-api/", server.uri()),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: None,
|
||||
marketplace_kinds: None,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
|
||||
assert!(
|
||||
response
|
||||
.marketplaces
|
||||
.iter()
|
||||
.all(|marketplace| marketplace.name != "openai-curated-remote")
|
||||
);
|
||||
assert!(
|
||||
server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("wiremock should record requests")
|
||||
.iter()
|
||||
.all(|request| !request
|
||||
.url
|
||||
.query_pairs()
|
||||
.any(|(name, value)| name == "collection" && value == "vertical"))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_vertical_kind_noops_when_remote_plugin_enabled() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_remote_plugin_catalog_config(
|
||||
codex_home.path(),
|
||||
&format!("{}/backend-api/", server.uri()),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: None,
|
||||
marketplace_kinds: Some(vec![PluginListMarketplaceKind::Vertical]),
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
|
||||
assert!(
|
||||
response
|
||||
.marketplaces
|
||||
.iter()
|
||||
.all(|marketplace| marketplace.name != "openai-curated-remote")
|
||||
);
|
||||
assert!(
|
||||
server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("wiremock should record requests")
|
||||
.iter()
|
||||
.all(|request| !request
|
||||
.url
|
||||
.query_pairs()
|
||||
.any(|(name, value)| name == "collection" && value == "vertical"))
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1795,7 +2119,7 @@ async fn plugin_list_does_not_append_global_remote_when_marketplace_kinds_are_ex
|
||||
response
|
||||
.marketplaces
|
||||
.iter()
|
||||
.all(|marketplace| marketplace.name != "chatgpt-global")
|
||||
.all(|marketplace| marketplace.name != "openai-curated-remote")
|
||||
);
|
||||
wait_for_remote_plugin_request_count(&server, "/ps/plugins/list", /*expected_count*/ 0).await?;
|
||||
Ok(())
|
||||
@@ -1962,18 +2286,18 @@ plugin_sharing = false
|
||||
)?;
|
||||
|
||||
assert_eq!(response.marketplaces.len(), 1);
|
||||
assert_eq!(response.marketplaces[0].name, "chatgpt-global");
|
||||
assert_eq!(response.marketplaces[0].name, "openai-curated-remote");
|
||||
assert_eq!(
|
||||
response.marketplaces[0]
|
||||
.plugins
|
||||
.iter()
|
||||
.map(|plugin| (plugin.id.clone(), plugin.installed, plugin.enabled))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![("linear@chatgpt-global".to_string(), true, true)]
|
||||
vec![("linear@openai-curated-remote".to_string(), true, true)]
|
||||
);
|
||||
let installed_path = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear/1.2.3/.codex-plugin/plugin.json");
|
||||
.join("plugins/cache/openai-curated-remote/linear/1.2.3/.codex-plugin/plugin.json");
|
||||
wait_for_path_exists(&installed_path).await?;
|
||||
wait_for_remote_installed_scope_request(&server, "GLOBAL").await?;
|
||||
wait_for_remote_installed_scope_request(&server, "WORKSPACE").await?;
|
||||
@@ -2442,7 +2766,7 @@ async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> {
|
||||
let remote_marketplace = response
|
||||
.marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| marketplace.name == "chatgpt-global")
|
||||
.find(|marketplace| marketplace.name == "openai-curated-remote")
|
||||
.expect("expected ChatGPT remote marketplace");
|
||||
let plugin = remote_marketplace
|
||||
.plugins
|
||||
@@ -2457,133 +2781,6 @@ async fn plugin_list_marks_remote_plugin_disabled_by_admin() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_remote_marketplace_replaces_local_marketplace_with_same_name() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_remote_plugin_catalog_config(
|
||||
codex_home.path(),
|
||||
&format!("{}/backend-api/", server.uri()),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
let local_plugin_root = codex_home
|
||||
.path()
|
||||
.join(".agents/plugins/plugins/local-linear/.codex-plugin");
|
||||
std::fs::create_dir_all(&local_plugin_root)?;
|
||||
std::fs::write(
|
||||
codex_home.path().join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "chatgpt-global",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "local-linear",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/local-linear"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)?;
|
||||
std::fs::write(
|
||||
local_plugin_root.join("plugin.json"),
|
||||
r#"{"name":"local-linear"}"#,
|
||||
)?;
|
||||
|
||||
let global_directory_body = r#"{
|
||||
"plugins": [
|
||||
{
|
||||
"id": "plugins~Plugin_00000000000000000000000000000000",
|
||||
"name": "linear",
|
||||
"scope": "GLOBAL",
|
||||
"installation_policy": "AVAILABLE",
|
||||
"authentication_policy": "ON_USE",
|
||||
"release": {
|
||||
"display_name": "Linear",
|
||||
"description": "Track work in Linear",
|
||||
"app_ids": [],
|
||||
"interface": {},
|
||||
"skills": []
|
||||
}
|
||||
}
|
||||
],
|
||||
"pagination": {
|
||||
"limit": 50,
|
||||
"next_page_token": null
|
||||
}
|
||||
}"#;
|
||||
let empty_page_body = r#"{
|
||||
"plugins": [],
|
||||
"pagination": {
|
||||
"limit": 50,
|
||||
"next_page_token": null
|
||||
}
|
||||
}"#;
|
||||
for (scope, body) in [
|
||||
("GLOBAL", global_directory_body),
|
||||
("WORKSPACE", empty_page_body),
|
||||
] {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/list"))
|
||||
.and(query_param("scope", scope))
|
||||
.and(query_param("limit", "200"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
for scope in ["GLOBAL", "WORKSPACE"] {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/installed"))
|
||||
.and(query_param("scope", scope))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(empty_page_body))
|
||||
.mount(&server)
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: None,
|
||||
marketplace_kinds: None,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
let matching_marketplaces = response
|
||||
.marketplaces
|
||||
.iter()
|
||||
.filter(|marketplace| marketplace.name == "chatgpt-global")
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
assert_eq!(matching_marketplaces.len(), 1);
|
||||
assert_eq!(matching_marketplaces[0].path, None);
|
||||
assert_eq!(matching_marketplaces[0].plugins.len(), 1);
|
||||
assert_eq!(
|
||||
matching_marketplaces[0].plugins[0].source,
|
||||
PluginSource::Remote
|
||||
);
|
||||
assert_eq!(matching_marketplaces[0].plugins[0].name, "linear");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_does_not_fetch_remote_marketplaces_when_plugins_disabled() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -2817,6 +3014,19 @@ async fn mount_remote_plugin_list(server: &MockServer, scope: &str, body: &str)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mount_openai_curated_remote_collection_plugin_list(server: &MockServer, body: &str) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/list"))
|
||||
.and(query_param("scope", "GLOBAL"))
|
||||
.and(query_param("limit", "200"))
|
||||
.and(query_param("collection", "vertical"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(body))
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn mount_shared_workspace_plugins(server: &MockServer, body: &str) {
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/ps/plugins/workspace/shared"))
|
||||
@@ -2910,6 +3120,37 @@ fn remote_installed_plugin_body(
|
||||
release_version: &str,
|
||||
enabled: bool,
|
||||
) -> String {
|
||||
remote_installed_plugin_body_with_optional_app_manifest(
|
||||
bundle_download_url,
|
||||
release_version,
|
||||
enabled,
|
||||
/*app_manifest*/ None,
|
||||
)
|
||||
}
|
||||
|
||||
fn remote_installed_plugin_body_with_app_manifest(
|
||||
bundle_download_url: &str,
|
||||
release_version: &str,
|
||||
enabled: bool,
|
||||
app_manifest: serde_json::Value,
|
||||
) -> String {
|
||||
remote_installed_plugin_body_with_optional_app_manifest(
|
||||
bundle_download_url,
|
||||
release_version,
|
||||
enabled,
|
||||
Some(app_manifest),
|
||||
)
|
||||
}
|
||||
|
||||
fn remote_installed_plugin_body_with_optional_app_manifest(
|
||||
bundle_download_url: &str,
|
||||
release_version: &str,
|
||||
enabled: bool,
|
||||
app_manifest: Option<serde_json::Value>,
|
||||
) -> String {
|
||||
let app_manifest_field = app_manifest
|
||||
.map(|manifest| format!(r#" "app_manifest": {manifest},"#))
|
||||
.unwrap_or_default();
|
||||
format!(
|
||||
r#"{{
|
||||
"plugins": [
|
||||
@@ -2925,6 +3166,7 @@ fn remote_installed_plugin_body(
|
||||
"description": "Track work in Linear",
|
||||
"bundle_download_url": "{bundle_download_url}",
|
||||
"app_ids": [],
|
||||
{app_manifest_field}
|
||||
"interface": {{}},
|
||||
"skills": []
|
||||
}},
|
||||
|
||||
@@ -101,7 +101,7 @@ async fn plugin_read_rejects_multiple_read_sources() -> Result<()> {
|
||||
marketplace_path: Some(AbsolutePathBuf::try_from(
|
||||
codex_home.path().join("marketplace.json"),
|
||||
)?),
|
||||
remote_marketplace_name: Some("openai-curated".to_string()),
|
||||
remote_marketplace_name: Some("openai-curated-remote".to_string()),
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
@@ -196,7 +196,7 @@ plugins = true
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: None,
|
||||
remote_marketplace_name: Some("chatgpt-global".to_string()),
|
||||
remote_marketplace_name: Some("openai-curated-remote".to_string()),
|
||||
plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
})
|
||||
.await?;
|
||||
@@ -208,8 +208,8 @@ plugins = true
|
||||
.await??;
|
||||
let response: PluginReadResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(response.plugin.marketplace_name, "chatgpt-global");
|
||||
assert_eq!(response.plugin.summary.id, "linear@chatgpt-global");
|
||||
assert_eq!(response.plugin.marketplace_name, "openai-curated-remote");
|
||||
assert_eq!(response.plugin.summary.id, "linear@openai-curated-remote");
|
||||
assert_eq!(
|
||||
response.plugin.summary.remote_plugin_id.as_deref(),
|
||||
Some("plugins~Plugin_00000000000000000000000000000000")
|
||||
@@ -484,7 +484,7 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: None,
|
||||
remote_marketplace_name: Some("chatgpt-global".to_string()),
|
||||
remote_marketplace_name: Some("openai-curated-remote".to_string()),
|
||||
plugin_name: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
})
|
||||
.await?;
|
||||
@@ -496,10 +496,10 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() ->
|
||||
.await??;
|
||||
let response: PluginReadResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(response.plugin.marketplace_name, "chatgpt-global");
|
||||
assert_eq!(response.plugin.marketplace_name, "openai-curated-remote");
|
||||
assert_eq!(response.plugin.marketplace_path, None);
|
||||
assert_eq!(response.plugin.summary.source, PluginSource::Remote);
|
||||
assert_eq!(response.plugin.summary.id, "linear@chatgpt-global");
|
||||
assert_eq!(response.plugin.summary.id, "linear@openai-curated-remote");
|
||||
assert_eq!(
|
||||
response.plugin.summary.remote_plugin_id.as_deref(),
|
||||
Some("plugins~Plugin_00000000000000000000000000000000")
|
||||
@@ -568,7 +568,7 @@ async fn plugin_skill_read_reads_remote_skill_contents_when_remote_plugin_enable
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_skill_read_request(PluginSkillReadParams {
|
||||
remote_marketplace_name: "chatgpt-global".to_string(),
|
||||
remote_marketplace_name: "openai-curated-remote".to_string(),
|
||||
remote_plugin_id: "plugins~Plugin_00000000000000000000000000000000".to_string(),
|
||||
skill_name: "plan-work".to_string(),
|
||||
})
|
||||
@@ -621,7 +621,7 @@ async fn plugin_read_maps_missing_remote_plugin_to_invalid_request() -> Result<(
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: None,
|
||||
remote_marketplace_name: Some("chatgpt-global".to_string()),
|
||||
remote_marketplace_name: Some("openai-curated-remote".to_string()),
|
||||
plugin_name: "plugins~Plugin_missing".to_string(),
|
||||
})
|
||||
.await?;
|
||||
@@ -673,7 +673,7 @@ remote_plugin = true
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: None,
|
||||
remote_marketplace_name: Some("chatgpt-global".to_string()),
|
||||
remote_marketplace_name: Some("openai-curated-remote".to_string()),
|
||||
plugin_name: "linear".to_string(),
|
||||
})
|
||||
.await?;
|
||||
@@ -703,7 +703,7 @@ async fn plugin_read_rejects_invalid_remote_plugin_name() -> Result<()> {
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: None,
|
||||
remote_marketplace_name: Some("chatgpt-global".to_string()),
|
||||
remote_marketplace_name: Some("openai-curated-remote".to_string()),
|
||||
plugin_name: "linear/../../oops".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -219,15 +219,15 @@ async fn plugin_uninstall_writes_remote_plugin_to_cloud_when_remote_plugin_enabl
|
||||
|
||||
let remote_plugin_cache_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear");
|
||||
.join("plugins/cache/openai-curated-remote/linear");
|
||||
std::fs::create_dir_all(remote_plugin_cache_root.join("1.0.0/.codex-plugin"))?;
|
||||
std::fs::write(
|
||||
remote_plugin_cache_root.join("1.0.0/.codex-plugin/plugin.json"),
|
||||
r#"{"name":"linear","version":"1.0.0"}"#,
|
||||
)?;
|
||||
let legacy_remote_plugin_cache_root = codex_home
|
||||
.path()
|
||||
.join(format!("plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}"));
|
||||
let legacy_remote_plugin_cache_root = codex_home.path().join(format!(
|
||||
"plugins/cache/openai-curated-remote/{REMOTE_PLUGIN_ID}"
|
||||
));
|
||||
std::fs::create_dir_all(legacy_remote_plugin_cache_root.join("local/.codex-plugin"))?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
@@ -299,7 +299,7 @@ async fn plugin_uninstall_uses_detail_scope_for_cache_namespace() -> Result<()>
|
||||
)?;
|
||||
let global_cache_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache/chatgpt-global/linear");
|
||||
.join("plugins/cache/openai-curated-remote/linear");
|
||||
std::fs::create_dir_all(global_cache_root.join("1.0.0/.codex-plugin"))?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
@@ -420,9 +420,9 @@ async fn plugin_uninstall_rejects_before_post_when_remote_detail_fetch_fails() -
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let legacy_remote_plugin_cache_root = codex_home
|
||||
.path()
|
||||
.join(format!("plugins/cache/chatgpt-global/{REMOTE_PLUGIN_ID}"));
|
||||
let legacy_remote_plugin_cache_root = codex_home.path().join(format!(
|
||||
"plugins/cache/openai-curated-remote/{REMOTE_PLUGIN_ID}"
|
||||
));
|
||||
std::fs::create_dir_all(legacy_remote_plugin_cache_root.join("local/.codex-plugin"))?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
|
||||
@@ -117,7 +117,7 @@ fn write_plugin_with_skill(
|
||||
fn write_cached_remote_plugin_with_skill(
|
||||
codex_home: &std::path::Path,
|
||||
) -> Result<std::path::PathBuf> {
|
||||
let plugin_root = codex_home.join("plugins/cache/chatgpt-global/linear/local");
|
||||
let plugin_root = codex_home.join("plugins/cache/openai-curated-remote/linear/local");
|
||||
std::fs::create_dir_all(plugin_root.join(".codex-plugin"))?;
|
||||
std::fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
|
||||
@@ -150,7 +150,7 @@ async fn load_config(codex_home: &Path, cwd: &Path) -> PluginsConfigInput {
|
||||
|
||||
fn remote_installed_linear_plugin() -> RemoteInstalledPlugin {
|
||||
RemoteInstalledPlugin {
|
||||
marketplace_name: "chatgpt-global".to_string(),
|
||||
marketplace_name: "openai-curated-remote".to_string(),
|
||||
id: "plugins~Plugin_linear".to_string(),
|
||||
name: "linear".to_string(),
|
||||
enabled: true,
|
||||
@@ -401,11 +401,11 @@ async fn build_remote_installed_plugin_marketplaces_from_cache_uses_remote_metad
|
||||
.build_remote_installed_plugin_marketplaces_from_cache(&[RemotePluginScope::Global])
|
||||
.expect("remote installed cache should be present");
|
||||
assert_eq!(marketplaces.len(), 1);
|
||||
assert_eq!(marketplaces[0].name, "chatgpt-global");
|
||||
assert_eq!(marketplaces[0].display_name, "ChatGPT Plugins");
|
||||
assert_eq!(marketplaces[0].name, "openai-curated-remote");
|
||||
assert_eq!(marketplaces[0].display_name, "OpenAI Curated Remote");
|
||||
assert_eq!(marketplaces[0].plugins.len(), 1);
|
||||
let plugin = &marketplaces[0].plugins[0];
|
||||
assert_eq!(plugin.id, "linear@chatgpt-global");
|
||||
assert_eq!(plugin.id, "linear@openai-curated-remote");
|
||||
assert_eq!(plugin.remote_plugin_id, "plugins~Plugin_linear");
|
||||
assert_eq!(plugin.name, "linear");
|
||||
assert_eq!(plugin.installed, true);
|
||||
|
||||
@@ -12,6 +12,7 @@ use codex_plugin::PluginId;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use reqwest::RequestBuilder;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashSet;
|
||||
@@ -46,19 +47,20 @@ pub use share::load_plugin_share_remote_ids_by_local_path;
|
||||
pub use share::save_remote_plugin_share;
|
||||
pub use share::update_remote_plugin_share_targets;
|
||||
|
||||
pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global";
|
||||
pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "openai-curated-remote";
|
||||
pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "workspace-directory";
|
||||
pub const REMOTE_WORKSPACE_SHARED_WITH_ME_MARKETPLACE_NAME: &str = "workspace-shared-with-me";
|
||||
pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_NAME: &str =
|
||||
"workspace-shared-with-me-private";
|
||||
pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_NAME: &str =
|
||||
"workspace-shared-with-me-unlisted";
|
||||
pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Plugins";
|
||||
pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "OpenAI Curated Remote";
|
||||
pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "Workspace Directory";
|
||||
pub const REMOTE_WORKSPACE_SHARED_WITH_ME_PRIVATE_MARKETPLACE_DISPLAY_NAME: &str = "Shared with me";
|
||||
pub const REMOTE_WORKSPACE_SHARED_WITH_ME_UNLISTED_MARKETPLACE_DISPLAY_NAME: &str =
|
||||
"Shared with me (unlisted)";
|
||||
|
||||
const OPENAI_CURATED_REMOTE_COLLECTION_KEY: &str = "vertical";
|
||||
const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const REMOTE_PLUGIN_LIST_PAGE_LIMIT: u32 = 200;
|
||||
const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128;
|
||||
@@ -158,6 +160,7 @@ pub struct RemotePluginDetail {
|
||||
pub description: Option<String>,
|
||||
pub release_version: Option<String>,
|
||||
pub bundle_download_url: Option<String>,
|
||||
pub app_manifest: Option<JsonValue>,
|
||||
pub skills: Vec<RemotePluginSkill>,
|
||||
pub app_ids: Vec<String>,
|
||||
}
|
||||
@@ -391,6 +394,8 @@ struct RemotePluginReleaseResponse {
|
||||
#[serde(default)]
|
||||
app_ids: Vec<String>,
|
||||
#[serde(default)]
|
||||
app_manifest: Option<JsonValue>,
|
||||
#[serde(default)]
|
||||
keywords: Vec<String>,
|
||||
interface: RemotePluginReleaseInterfaceResponse,
|
||||
#[serde(default)]
|
||||
@@ -595,6 +600,31 @@ pub async fn fetch_remote_marketplaces(
|
||||
Ok(marketplaces)
|
||||
}
|
||||
|
||||
pub async fn fetch_openai_curated_remote_collection_marketplace(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> Result<Option<RemoteMarketplace>, RemotePluginCatalogError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
let scope = RemotePluginScope::Global;
|
||||
let (directory_plugins, installed_plugins) = tokio::try_join!(
|
||||
fetch_directory_plugins_for_scope_with_collection(
|
||||
config,
|
||||
auth,
|
||||
scope,
|
||||
OPENAI_CURATED_REMOTE_COLLECTION_KEY,
|
||||
),
|
||||
fetch_installed_plugins_for_scope(config, auth, scope),
|
||||
)?;
|
||||
|
||||
build_remote_marketplace(
|
||||
REMOTE_GLOBAL_MARKETPLACE_NAME,
|
||||
REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME,
|
||||
directory_plugins,
|
||||
installed_plugins,
|
||||
/*include_installed_only*/ false,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_remote_marketplace(
|
||||
name: &str,
|
||||
display_name: &str,
|
||||
@@ -866,6 +896,7 @@ async fn build_remote_plugin_detail(
|
||||
description: non_empty_string(Some(&plugin.release.description)),
|
||||
release_version: plugin.release.version,
|
||||
bundle_download_url: plugin.release.bundle_download_url,
|
||||
app_manifest: plugin.release.app_manifest,
|
||||
skills,
|
||||
app_ids: plugin.release.app_ids,
|
||||
})
|
||||
@@ -1174,12 +1205,40 @@ async fn fetch_directory_plugins_for_scope(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: &CodexAuth,
|
||||
scope: RemotePluginScope,
|
||||
) -> Result<Vec<RemotePluginDirectoryItem>, RemotePluginCatalogError> {
|
||||
fetch_directory_plugins_for_scope_with_optional_collection(
|
||||
config, auth, scope, /*collection*/ None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_directory_plugins_for_scope_with_collection(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: &CodexAuth,
|
||||
scope: RemotePluginScope,
|
||||
collection: &str,
|
||||
) -> Result<Vec<RemotePluginDirectoryItem>, RemotePluginCatalogError> {
|
||||
fetch_directory_plugins_for_scope_with_optional_collection(
|
||||
config,
|
||||
auth,
|
||||
scope,
|
||||
Some(collection),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_directory_plugins_for_scope_with_optional_collection(
|
||||
config: &RemotePluginServiceConfig,
|
||||
auth: &CodexAuth,
|
||||
scope: RemotePluginScope,
|
||||
collection: Option<&str>,
|
||||
) -> Result<Vec<RemotePluginDirectoryItem>, RemotePluginCatalogError> {
|
||||
let mut plugins = Vec::new();
|
||||
let mut page_token = None;
|
||||
loop {
|
||||
let response =
|
||||
get_remote_plugin_list_page(config, auth, scope, page_token.as_deref()).await?;
|
||||
get_remote_plugin_list_page(config, auth, scope, page_token.as_deref(), collection)
|
||||
.await?;
|
||||
plugins.extend(response.plugins);
|
||||
let Some(next_page_token) = response.pagination.next_page_token else {
|
||||
break;
|
||||
@@ -1249,6 +1308,7 @@ async fn get_remote_plugin_list_page(
|
||||
auth: &CodexAuth,
|
||||
scope: RemotePluginScope,
|
||||
page_token: Option<&str>,
|
||||
collection: Option<&str>,
|
||||
) -> Result<RemotePluginListResponse, RemotePluginCatalogError> {
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/ps/plugins/list");
|
||||
@@ -1256,6 +1316,9 @@ async fn get_remote_plugin_list_page(
|
||||
let mut request = authenticated_request(client.get(&url), auth)?;
|
||||
request = request.query(&[("scope", scope.api_value())]);
|
||||
request = request.query(&[("limit", REMOTE_PLUGIN_LIST_PAGE_LIMIT)]);
|
||||
if let Some(collection) = collection {
|
||||
request = request.query(&[("collection", collection)]);
|
||||
}
|
||||
if let Some(page_token) = page_token {
|
||||
request = request.query(&[("pageToken", page_token)]);
|
||||
}
|
||||
|
||||
@@ -208,6 +208,7 @@ pub async fn sync_remote_installed_plugin_bundles_once(
|
||||
&plugin.name,
|
||||
release_version,
|
||||
plugin.release.bundle_download_url.as_deref(),
|
||||
plugin.release.app_manifest.clone(),
|
||||
) {
|
||||
Ok(bundle) => bundle,
|
||||
Err(err) => {
|
||||
@@ -511,7 +512,7 @@ mod tests {
|
||||
&installed_plugin_names_by_marketplace,
|
||||
)
|
||||
.expect("cleanup after install guard is dropped");
|
||||
assert_eq!(removed, vec!["linear@chatgpt-global".to_string()]);
|
||||
assert_eq!(removed, vec!["linear@openai-curated-remote".to_string()]);
|
||||
assert!(!cached_manifest.exists());
|
||||
}
|
||||
|
||||
|
||||
@@ -85,6 +85,7 @@ pub async fn checkout_remote_plugin_share(
|
||||
&plugin_name,
|
||||
detail.release_version.as_deref(),
|
||||
detail.bundle_download_url.as_deref(),
|
||||
/*app_manifest*/ None,
|
||||
)
|
||||
.map_err(|err| {
|
||||
RemotePluginCatalogError::UnexpectedResponse(format!(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::remote::REMOTE_GLOBAL_MARKETPLACE_NAME;
|
||||
use crate::store::PluginInstallResult;
|
||||
use crate::store::PluginStore;
|
||||
use crate::store::PluginStoreError;
|
||||
@@ -10,6 +11,7 @@ use codex_utils_plugins::find_plugin_manifest_path;
|
||||
use flate2::read::GzDecoder;
|
||||
use reqwest::Response;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::io::Read;
|
||||
@@ -33,6 +35,7 @@ const TEST_ALLOW_LOOPBACK_HTTP_REMOTE_PLUGIN_BUNDLES_ENV: &str =
|
||||
pub struct ValidatedRemotePluginBundle {
|
||||
pub plugin_id: PluginId,
|
||||
pub plugin_version: String,
|
||||
app_manifest: Option<JsonValue>,
|
||||
bundle_download_url: String,
|
||||
}
|
||||
|
||||
@@ -137,6 +140,7 @@ pub fn validate_remote_plugin_bundle(
|
||||
plugin_name: &str,
|
||||
release_version: Option<&str>,
|
||||
bundle_download_url: Option<&str>,
|
||||
app_manifest: Option<JsonValue>,
|
||||
) -> Result<ValidatedRemotePluginBundle, RemotePluginBundleInstallError> {
|
||||
let plugin_id = PluginId::new(plugin_name.to_string(), remote_marketplace_name.to_string())
|
||||
.map_err(|source| RemotePluginBundleInstallError::InvalidPluginId {
|
||||
@@ -187,6 +191,7 @@ pub fn validate_remote_plugin_bundle(
|
||||
Ok(ValidatedRemotePluginBundle {
|
||||
plugin_id,
|
||||
plugin_version,
|
||||
app_manifest,
|
||||
bundle_download_url,
|
||||
})
|
||||
}
|
||||
@@ -367,6 +372,7 @@ fn install_remote_plugin_bundle(
|
||||
|
||||
extract_plugin_bundle_tar_gz(&bundle_bytes, extract_dir.path())?;
|
||||
let plugin_root = find_extracted_plugin_root(extract_dir.path())?;
|
||||
prepare_extracted_remote_plugin_root(&plugin_root, &bundle)?;
|
||||
let plugin_root = AbsolutePathBuf::try_from(plugin_root).map_err(|err| {
|
||||
RemotePluginBundleInstallError::InvalidBundle(format!(
|
||||
"failed to resolve extracted remote plugin bundle root: {err}"
|
||||
@@ -436,6 +442,90 @@ fn extract_remote_plugin_bundle_to_path(
|
||||
Ok(destination)
|
||||
}
|
||||
|
||||
fn prepare_extracted_remote_plugin_root(
|
||||
plugin_root: &Path,
|
||||
bundle: &ValidatedRemotePluginBundle,
|
||||
) -> Result<(), RemotePluginBundleInstallError> {
|
||||
if bundle.plugin_id.marketplace_name != REMOTE_GLOBAL_MARKETPLACE_NAME {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
overwrite_plugin_manifest_version(plugin_root, &bundle.plugin_version)?;
|
||||
if let Some(app_manifest) = &bundle.app_manifest {
|
||||
overwrite_plugin_app_manifest(plugin_root, app_manifest)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn overwrite_plugin_manifest_version(
|
||||
plugin_root: &Path,
|
||||
plugin_version: &str,
|
||||
) -> Result<(), RemotePluginBundleInstallError> {
|
||||
let manifest_path = find_plugin_manifest_path(plugin_root).ok_or_else(|| {
|
||||
RemotePluginBundleInstallError::InvalidBundle(
|
||||
"remote plugin bundle did not contain a valid plugin.json".to_string(),
|
||||
)
|
||||
})?;
|
||||
let contents = fs::read_to_string(&manifest_path).map_err(|source| {
|
||||
RemotePluginBundleInstallError::io("failed to read remote plugin manifest", source)
|
||||
})?;
|
||||
let mut manifest: JsonValue = serde_json::from_str(&contents).map_err(|err| {
|
||||
RemotePluginBundleInstallError::InvalidBundle(format!(
|
||||
"failed to parse remote plugin manifest: {err}"
|
||||
))
|
||||
})?;
|
||||
let Some(manifest_object) = manifest.as_object_mut() else {
|
||||
return Err(RemotePluginBundleInstallError::InvalidBundle(
|
||||
"remote plugin manifest must be a JSON object".to_string(),
|
||||
));
|
||||
};
|
||||
manifest_object.insert(
|
||||
"version".to_string(),
|
||||
JsonValue::String(plugin_version.to_string()),
|
||||
);
|
||||
write_json_file(
|
||||
&manifest_path,
|
||||
&manifest,
|
||||
"failed to write remote plugin manifest",
|
||||
)
|
||||
}
|
||||
|
||||
fn overwrite_plugin_app_manifest(
|
||||
plugin_root: &Path,
|
||||
app_manifest: &JsonValue,
|
||||
) -> Result<(), RemotePluginBundleInstallError> {
|
||||
let app_manifest_path = crate::manifest::load_plugin_manifest(plugin_root)
|
||||
.and_then(|manifest| manifest.paths.apps.map(|path| path.to_path_buf()))
|
||||
.unwrap_or_else(|| plugin_root.join(".app.json"));
|
||||
write_json_file(
|
||||
&app_manifest_path,
|
||||
app_manifest,
|
||||
"failed to write remote plugin app manifest",
|
||||
)
|
||||
}
|
||||
|
||||
fn write_json_file(
|
||||
path: &Path,
|
||||
value: &JsonValue,
|
||||
context: &'static str,
|
||||
) -> Result<(), RemotePluginBundleInstallError> {
|
||||
let parent = path.parent().ok_or_else(|| {
|
||||
RemotePluginBundleInstallError::InvalidBundle(format!(
|
||||
"remote plugin output path has no parent: {}",
|
||||
path.display()
|
||||
))
|
||||
})?;
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|source| RemotePluginBundleInstallError::io(context, source))?;
|
||||
let mut contents = serde_json::to_vec_pretty(value).map_err(|err| {
|
||||
RemotePluginBundleInstallError::InvalidBundle(format!(
|
||||
"failed to serialize remote plugin JSON override: {err}"
|
||||
))
|
||||
})?;
|
||||
contents.push(b'\n');
|
||||
fs::write(path, contents).map_err(|source| RemotePluginBundleInstallError::io(context, source))
|
||||
}
|
||||
|
||||
fn extract_plugin_bundle_tar_gz(
|
||||
bytes: &[u8],
|
||||
destination: &Path,
|
||||
@@ -624,15 +714,16 @@ mod tests {
|
||||
fn validate_remote_plugin_bundle_uses_detail_name_for_local_plugin_id() {
|
||||
let bundle = validate_remote_plugin_bundle(
|
||||
REMOTE_PLUGIN_ID,
|
||||
"chatgpt-global",
|
||||
"openai-curated-remote",
|
||||
"linear",
|
||||
Some("1.2.3"),
|
||||
Some("https://example.com/linear.tar.gz"),
|
||||
/*app_manifest*/ None,
|
||||
)
|
||||
.expect("valid install plan");
|
||||
|
||||
assert_eq!(bundle.plugin_id.plugin_name, "linear");
|
||||
assert_eq!(bundle.plugin_id.marketplace_name, "chatgpt-global");
|
||||
assert_eq!(bundle.plugin_id.marketplace_name, "openai-curated-remote");
|
||||
assert_eq!(bundle.plugin_version, "1.2.3");
|
||||
assert_eq!(
|
||||
bundle.bundle_download_url.as_str(),
|
||||
@@ -644,10 +735,11 @@ mod tests {
|
||||
fn validate_remote_plugin_bundle_rejects_missing_release_version() {
|
||||
let err = validate_remote_plugin_bundle(
|
||||
REMOTE_PLUGIN_ID,
|
||||
"chatgpt-global",
|
||||
"openai-curated-remote",
|
||||
"linear",
|
||||
/*release_version*/ None,
|
||||
Some("https://example.com/linear.tar.gz"),
|
||||
/*app_manifest*/ None,
|
||||
)
|
||||
.expect_err("missing release version should be rejected");
|
||||
|
||||
@@ -661,10 +753,11 @@ mod tests {
|
||||
fn validate_remote_plugin_bundle_rejects_invalid_release_version() {
|
||||
let err = validate_remote_plugin_bundle(
|
||||
REMOTE_PLUGIN_ID,
|
||||
"chatgpt-global",
|
||||
"openai-curated-remote",
|
||||
"linear",
|
||||
Some("../1.2.3"),
|
||||
Some("https://example.com/linear.tar.gz"),
|
||||
/*app_manifest*/ None,
|
||||
)
|
||||
.expect_err("invalid release version should be rejected");
|
||||
|
||||
@@ -678,10 +771,11 @@ mod tests {
|
||||
fn validate_remote_plugin_bundle_rejects_missing_download_url() {
|
||||
let err = validate_remote_plugin_bundle(
|
||||
REMOTE_PLUGIN_ID,
|
||||
"chatgpt-global",
|
||||
"openai-curated-remote",
|
||||
"linear",
|
||||
Some("1.2.3"),
|
||||
/*bundle_download_url*/ None,
|
||||
/*app_manifest*/ None,
|
||||
)
|
||||
.expect_err("missing bundle download URL should be rejected");
|
||||
|
||||
@@ -695,10 +789,11 @@ mod tests {
|
||||
fn validate_remote_plugin_bundle_rejects_unsupported_download_url_scheme() {
|
||||
let err = validate_remote_plugin_bundle(
|
||||
REMOTE_PLUGIN_ID,
|
||||
"chatgpt-global",
|
||||
"openai-curated-remote",
|
||||
"linear",
|
||||
Some("1.2.3"),
|
||||
Some("http://example.com/linear.tar.gz"),
|
||||
/*app_manifest*/ None,
|
||||
)
|
||||
.expect_err("plain HTTP URLs should be rejected before cloud install");
|
||||
|
||||
@@ -755,6 +850,78 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_preserves_non_global_bundle_manifest_metadata() {
|
||||
let codex_home = tempdir().expect("tempdir");
|
||||
let bundle = validate_remote_plugin_bundle(
|
||||
REMOTE_PLUGIN_ID,
|
||||
"workspace-shared-with-me",
|
||||
"linear",
|
||||
Some("backend-version"),
|
||||
Some("https://example.com/linear.tar.gz"),
|
||||
Some(serde_json::json!({
|
||||
"apps": {
|
||||
"remote": {
|
||||
"id": "remote-app"
|
||||
}
|
||||
}
|
||||
})),
|
||||
)
|
||||
.expect("valid install plan");
|
||||
|
||||
let result = install_remote_plugin_bundle(
|
||||
codex_home.path().to_path_buf(),
|
||||
bundle,
|
||||
tar_gz_bytes(&[
|
||||
(
|
||||
".codex-plugin/plugin.json",
|
||||
br#"{"name":"linear","version":"bundle-version"}"#,
|
||||
/*mode*/ 0o644,
|
||||
),
|
||||
(
|
||||
".app.json",
|
||||
br#"{"apps":{"bundled":{"id":"bundled-app"}}}"#,
|
||||
/*mode*/ 0o644,
|
||||
),
|
||||
]),
|
||||
)
|
||||
.expect("install bundle");
|
||||
|
||||
assert_eq!(result.plugin_version, "backend-version");
|
||||
let installed_manifest: JsonValue = serde_json::from_str(
|
||||
&std::fs::read_to_string(
|
||||
result
|
||||
.installed_path
|
||||
.join(".codex-plugin/plugin.json")
|
||||
.as_path(),
|
||||
)
|
||||
.expect("read installed plugin manifest"),
|
||||
)
|
||||
.expect("parse installed plugin manifest");
|
||||
assert_eq!(
|
||||
installed_manifest,
|
||||
serde_json::json!({
|
||||
"name": "linear",
|
||||
"version": "bundle-version",
|
||||
})
|
||||
);
|
||||
let installed_app_manifest: JsonValue = serde_json::from_str(
|
||||
&std::fs::read_to_string(result.installed_path.join(".app.json").as_path())
|
||||
.expect("read installed app manifest"),
|
||||
)
|
||||
.expect("parse installed app manifest");
|
||||
assert_eq!(
|
||||
installed_app_manifest,
|
||||
serde_json::json!({
|
||||
"apps": {
|
||||
"bundled": {
|
||||
"id": "bundled-app",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_extracted_plugin_root_uses_local_manifest_discovery() {
|
||||
let extraction_root = tempdir().expect("tempdir");
|
||||
@@ -866,10 +1033,11 @@ mod tests {
|
||||
fn valid_remote_plugin_bundle() -> ValidatedRemotePluginBundle {
|
||||
validate_remote_plugin_bundle(
|
||||
REMOTE_PLUGIN_ID,
|
||||
"chatgpt-global",
|
||||
"openai-curated-remote",
|
||||
"linear",
|
||||
Some("1.2.3"),
|
||||
Some("https://example.com/linear.tar.gz"),
|
||||
/*app_manifest*/ None,
|
||||
)
|
||||
.expect("valid install plan")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user