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:
@@ -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