Delete orphaned core plugins crate

This commit is contained in:
Meta Vens 2026-05-13 16:16:31 +01:00
commit f23ef8445c
20 changed files with 11 additions and 6509 deletions

View file

@ -889,6 +889,12 @@ auth surfaces that preserve the user's Codex subscription compatibility remain
part of the keeper spine until the native OpenAI adapter can replace more of
that transport.
The orphaned `codex-core-plugins` crate is deleted too. Nothing imported
`codex_core_plugins`; it was only a workspace member/dependency entry plus its
own marketplace/store/loader code. Remaining plugin-shaped imports are narrower
telemetry and skill-namespace compatibility residues, not the old marketplace
runtime.
The Phase 6 freshness slice is landed. It exposes read-only
`thread/epiphany/freshness` from live retrieval summaries plus graph
frontier/churn state and, for loaded threads, watcher-backed invalidation

Binary file not shown.

View file

@ -316,6 +316,9 @@ current_status:
legacy protocol structs remain for orphaned plugin crates. The orphaned
`codex-chatgpt` product crate was deleted from the vendored workspace; only
ChatGPT auth originator strings remain where they serve the subscription
compatibility spine. MCP remains JSON-RPC at the edge; user-declared MCP
servers are the only runtime MCP authority.
compatibility spine. The orphaned `codex-core-plugins` crate was also
deleted from the workspace; remaining plugin references are narrower
telemetry/skill-namespace residues, not a plugin marketplace runtime. MCP
remains JSON-RPC at the edge; user-declared MCP servers are the only runtime
MCP authority.
next_action: Continue starving coordinator/runtime execution and operator surfaces off app-server JSON-RPC and out of `codex_message_processor.rs`; keep the vestigial Codex organ narrowed to subscription auth/model transport.

28
vendor/codex/codex-rs/Cargo.lock generated vendored
View file

@ -2461,34 +2461,6 @@ dependencies = [
"zstd 0.13.3",
]
[[package]]
name = "codex-core-plugins"
version = "0.0.0"
dependencies = [
"chrono",
"codex-app-server-protocol",
"codex-config",
"codex-core-skills",
"codex-exec-server",
"codex-git-utils",
"codex-login",
"codex-plugin",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-plugins",
"dirs",
"pretty_assertions",
"reqwest",
"serde",
"serde_json",
"tempfile",
"thiserror 2.0.18",
"tokio",
"toml 0.9.11+spec-1.1.0",
"tracing",
"url",
]
[[package]]
name = "codex-core-skills"
version = "0.0.0"

View file

@ -31,7 +31,6 @@ members = [
"shell-escalation",
"skills",
"core",
"core-plugins",
"core-skills",
"hooks",
"secrets",
@ -135,7 +134,6 @@ codex-code-mode = { path = "code-mode" }
codex-config = { path = "config" }
codex-connectors = { path = "connectors" }
codex-core = { path = "core" }
codex-core-plugins = { path = "core-plugins" }
codex-core-skills = { path = "core-skills" }
codex-device-key = { path = "device-key" }
codex-exec = { path = "exec" }

View file

@ -1,15 +0,0 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "core-plugins",
crate_name = "codex_core_plugins",
compile_data = glob(
include = ["**"],
exclude = [
"**/* *",
"BUILD.bazel",
"Cargo.toml",
],
allow_empty = True,
),
)

View file

@ -1,40 +0,0 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-core-plugins"
version.workspace = true
[lib]
doctest = false
name = "codex_core_plugins"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
codex-app-server-protocol = { workspace = true }
codex-config = { workspace = true }
codex-core-skills = { workspace = true }
codex-exec-server = { workspace = true }
codex-git-utils = { workspace = true }
codex-login = { workspace = true }
codex-plugin = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-plugins = { workspace = true }
chrono = { workspace = true }
dirs = { workspace = true }
reqwest = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tempfile = { workspace = true }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["fs"] }
toml = { workspace = true }
tracing = { workspace = true }
url = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }

View file

@ -1,8 +0,0 @@
pub mod loader;
pub mod manifest;
pub mod marketplace;
pub mod marketplace_upgrade;
pub mod remote;
pub mod remote_legacy;
pub mod store;
pub mod toggles;

File diff suppressed because it is too large Load diff

View file

@ -1,545 +0,0 @@
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_plugins::find_plugin_manifest_path;
use serde::Deserialize;
use serde_json::Value as JsonValue;
use std::fs;
use std::path::Component;
use std::path::Path;
const MAX_DEFAULT_PROMPT_COUNT: usize = 3;
const MAX_DEFAULT_PROMPT_LEN: usize = 128;
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawPluginManifest {
#[serde(default)]
name: String,
#[serde(default)]
version: Option<String>,
#[serde(default)]
description: Option<String>,
// Keep manifest paths as raw strings so we can validate the required `./...` syntax before
// resolving them under the plugin root.
#[serde(default)]
skills: Option<String>,
#[serde(default)]
mcp_servers: Option<String>,
#[serde(default)]
apps: Option<String>,
#[serde(default)]
interface: Option<RawPluginManifestInterface>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginManifest {
pub name: String,
pub version: Option<String>,
pub description: Option<String>,
pub paths: PluginManifestPaths,
pub interface: Option<PluginManifestInterface>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginManifestPaths {
pub skills: Option<AbsolutePathBuf>,
pub mcp_servers: Option<AbsolutePathBuf>,
pub apps: Option<AbsolutePathBuf>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct PluginManifestInterface {
pub display_name: Option<String>,
pub short_description: Option<String>,
pub long_description: Option<String>,
pub developer_name: Option<String>,
pub category: Option<String>,
pub capabilities: Vec<String>,
pub website_url: Option<String>,
pub privacy_policy_url: Option<String>,
pub terms_of_service_url: Option<String>,
pub default_prompt: Option<Vec<String>>,
pub brand_color: Option<String>,
pub composer_icon: Option<AbsolutePathBuf>,
pub logo: Option<AbsolutePathBuf>,
pub screenshots: Vec<AbsolutePathBuf>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawPluginManifestInterface {
#[serde(default)]
display_name: Option<String>,
#[serde(default)]
short_description: Option<String>,
#[serde(default)]
long_description: Option<String>,
#[serde(default)]
developer_name: Option<String>,
#[serde(default)]
category: Option<String>,
#[serde(default)]
capabilities: Vec<String>,
#[serde(default)]
#[serde(alias = "websiteURL")]
website_url: Option<String>,
#[serde(default)]
#[serde(alias = "privacyPolicyURL")]
privacy_policy_url: Option<String>,
#[serde(default)]
#[serde(alias = "termsOfServiceURL")]
terms_of_service_url: Option<String>,
#[serde(default)]
default_prompt: Option<RawPluginManifestDefaultPrompt>,
#[serde(default)]
brand_color: Option<String>,
#[serde(default)]
composer_icon: Option<String>,
#[serde(default)]
logo: Option<String>,
#[serde(default)]
screenshots: Vec<String>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RawPluginManifestDefaultPrompt {
String(String),
List(Vec<RawPluginManifestDefaultPromptEntry>),
Invalid(JsonValue),
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RawPluginManifestDefaultPromptEntry {
String(String),
Invalid(JsonValue),
}
pub fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
let manifest_path = find_plugin_manifest_path(plugin_root)?;
let contents = fs::read_to_string(&manifest_path).ok()?;
match serde_json::from_str::<RawPluginManifest>(&contents) {
Ok(manifest) => {
let RawPluginManifest {
name: raw_name,
version,
description,
skills,
mcp_servers,
apps,
interface,
} = manifest;
let name = plugin_root
.file_name()
.and_then(|entry| entry.to_str())
.filter(|_| raw_name.trim().is_empty())
.unwrap_or(&raw_name)
.to_string();
let version = version.and_then(|version| {
let version = version.trim();
(!version.is_empty()).then(|| version.to_string())
});
let interface = interface.and_then(|interface| {
let RawPluginManifestInterface {
display_name,
short_description,
long_description,
developer_name,
category,
capabilities,
website_url,
privacy_policy_url,
terms_of_service_url,
default_prompt,
brand_color,
composer_icon,
logo,
screenshots,
} = interface;
let interface = PluginManifestInterface {
display_name,
short_description,
long_description,
developer_name,
category,
capabilities,
website_url,
privacy_policy_url,
terms_of_service_url,
default_prompt: resolve_default_prompts(plugin_root, default_prompt.as_ref()),
brand_color,
composer_icon: resolve_interface_asset_path(
plugin_root,
"interface.composerIcon",
composer_icon.as_deref(),
),
logo: resolve_interface_asset_path(
plugin_root,
"interface.logo",
logo.as_deref(),
),
screenshots: screenshots
.iter()
.filter_map(|screenshot| {
resolve_interface_asset_path(
plugin_root,
"interface.screenshots",
Some(screenshot),
)
})
.collect(),
};
let has_fields = interface.display_name.is_some()
|| interface.short_description.is_some()
|| interface.long_description.is_some()
|| interface.developer_name.is_some()
|| interface.category.is_some()
|| !interface.capabilities.is_empty()
|| interface.website_url.is_some()
|| interface.privacy_policy_url.is_some()
|| interface.terms_of_service_url.is_some()
|| interface.default_prompt.is_some()
|| interface.brand_color.is_some()
|| interface.composer_icon.is_some()
|| interface.logo.is_some()
|| !interface.screenshots.is_empty();
has_fields.then_some(interface)
});
Some(PluginManifest {
name,
version,
description,
paths: PluginManifestPaths {
skills: resolve_manifest_path(plugin_root, "skills", skills.as_deref()),
mcp_servers: resolve_manifest_path(
plugin_root,
"mcpServers",
mcp_servers.as_deref(),
),
apps: resolve_manifest_path(plugin_root, "apps", apps.as_deref()),
},
interface,
})
}
Err(err) => {
tracing::warn!(
path = %manifest_path.display(),
"failed to parse plugin manifest: {err}"
);
None
}
}
}
fn resolve_interface_asset_path(
plugin_root: &Path,
field: &'static str,
path: Option<&str>,
) -> Option<AbsolutePathBuf> {
resolve_manifest_path(plugin_root, field, path)
}
fn resolve_default_prompts(
plugin_root: &Path,
value: Option<&RawPluginManifestDefaultPrompt>,
) -> Option<Vec<String>> {
match value? {
RawPluginManifestDefaultPrompt::String(prompt) => {
resolve_default_prompt_str(plugin_root, "interface.defaultPrompt", prompt)
.map(|prompt| vec![prompt])
}
RawPluginManifestDefaultPrompt::List(values) => {
let mut prompts = Vec::new();
for (index, item) in values.iter().enumerate() {
if prompts.len() >= MAX_DEFAULT_PROMPT_COUNT {
warn_invalid_default_prompt(
plugin_root,
"interface.defaultPrompt",
&format!("maximum of {MAX_DEFAULT_PROMPT_COUNT} prompts is supported"),
);
break;
}
match item {
RawPluginManifestDefaultPromptEntry::String(prompt) => {
let field = format!("interface.defaultPrompt[{index}]");
if let Some(prompt) =
resolve_default_prompt_str(plugin_root, &field, prompt)
{
prompts.push(prompt);
}
}
RawPluginManifestDefaultPromptEntry::Invalid(value) => {
let field = format!("interface.defaultPrompt[{index}]");
warn_invalid_default_prompt(
plugin_root,
&field,
&format!("expected a string, found {}", json_value_type(value)),
);
}
}
}
(!prompts.is_empty()).then_some(prompts)
}
RawPluginManifestDefaultPrompt::Invalid(value) => {
warn_invalid_default_prompt(
plugin_root,
"interface.defaultPrompt",
&format!(
"expected a string or array of strings, found {}",
json_value_type(value)
),
);
None
}
}
}
fn resolve_default_prompt_str(plugin_root: &Path, field: &str, prompt: &str) -> Option<String> {
let prompt = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
if prompt.is_empty() {
warn_invalid_default_prompt(plugin_root, field, "prompt must not be empty");
return None;
}
if prompt.chars().count() > MAX_DEFAULT_PROMPT_LEN {
warn_invalid_default_prompt(
plugin_root,
field,
&format!("prompt must be at most {MAX_DEFAULT_PROMPT_LEN} characters"),
);
return None;
}
Some(prompt)
}
fn warn_invalid_default_prompt(plugin_root: &Path, field: &str, message: &str) {
if let Some(manifest_path) = find_plugin_manifest_path(plugin_root) {
tracing::warn!(
path = %manifest_path.display(),
"ignoring {field}: {message}"
);
} else {
tracing::warn!("ignoring {field}: {message}");
}
}
fn json_value_type(value: &JsonValue) -> &'static str {
match value {
JsonValue::Null => "null",
JsonValue::Bool(_) => "boolean",
JsonValue::Number(_) => "number",
JsonValue::String(_) => "string",
JsonValue::Array(_) => "array",
JsonValue::Object(_) => "object",
}
}
fn resolve_manifest_path(
plugin_root: &Path,
field: &'static str,
path: Option<&str>,
) -> Option<AbsolutePathBuf> {
// `plugin.json` paths are required to be relative to the plugin root and we return the
// normalized absolute path to the rest of the system.
let path = path?;
if path.is_empty() {
return None;
}
let Some(relative_path) = path.strip_prefix("./") else {
tracing::warn!("ignoring {field}: path must start with `./` relative to plugin root");
return None;
};
if relative_path.is_empty() {
tracing::warn!("ignoring {field}: path must not be `./`");
return None;
}
let mut normalized = std::path::PathBuf::new();
for component in Path::new(relative_path).components() {
match component {
Component::Normal(component) => normalized.push(component),
Component::ParentDir => {
tracing::warn!("ignoring {field}: path must not contain '..'");
return None;
}
_ => {
tracing::warn!("ignoring {field}: path must stay within the plugin root");
return None;
}
}
}
AbsolutePathBuf::try_from(plugin_root.join(normalized))
.map_err(|err| {
tracing::warn!("ignoring {field}: path must resolve to an absolute path: {err}");
err
})
.ok()
}
#[cfg(test)]
mod tests {
use super::MAX_DEFAULT_PROMPT_LEN;
use super::PluginManifest;
use super::load_plugin_manifest;
use pretty_assertions::assert_eq;
use std::fs;
use std::path::Path;
use tempfile::tempdir;
const ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH: &str = ".claude-plugin/plugin.json";
fn write_manifest(plugin_root: &Path, version: Option<&str>, interface: &str) {
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir");
let version = version
.map(|version| format!(" \"version\": \"{version}\",\n"))
.unwrap_or_default();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
format!(
r#"{{
"name": "demo-plugin",
{version}
"interface": {interface}
}}"#
),
)
.expect("write manifest");
}
fn write_alternate_plugin_manifest(plugin_root: &Path, contents: &str) {
let manifest_path = plugin_root.join(ALTERNATE_PLUGIN_MANIFEST_RELATIVE_PATH);
fs::create_dir_all(manifest_path.parent().expect("manifest parent"))
.expect("create manifest dir");
fs::write(manifest_path, contents).expect("write manifest");
}
fn load_manifest(plugin_root: &Path) -> PluginManifest {
load_plugin_manifest(plugin_root).expect("load plugin manifest")
}
#[test]
fn plugin_interface_accepts_legacy_default_prompt_string() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("demo-plugin");
write_manifest(
&plugin_root,
/*version*/ None,
r#"{
"displayName": "Demo Plugin",
"defaultPrompt": " Summarize my inbox "
}"#,
);
let manifest = load_manifest(&plugin_root);
let interface = manifest.interface.expect("plugin interface");
assert_eq!(
interface.default_prompt,
Some(vec!["Summarize my inbox".to_string()])
);
}
#[test]
fn plugin_interface_normalizes_default_prompt_array() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("demo-plugin");
let too_long = "x".repeat(MAX_DEFAULT_PROMPT_LEN + 1);
write_manifest(
&plugin_root,
/*version*/ None,
&format!(
r#"{{
"displayName": "Demo Plugin",
"defaultPrompt": [
" Summarize my inbox ",
123,
"{too_long}",
" ",
"Draft the reply ",
"Find my next action",
"Archive old mail"
]
}}"#
),
);
let manifest = load_manifest(&plugin_root);
let interface = manifest.interface.expect("plugin interface");
assert_eq!(
interface.default_prompt,
Some(vec![
"Summarize my inbox".to_string(),
"Draft the reply".to_string(),
"Find my next action".to_string(),
])
);
}
#[test]
fn plugin_interface_ignores_invalid_default_prompt_shape() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("demo-plugin");
write_manifest(
&plugin_root,
/*version*/ None,
r#"{
"displayName": "Demo Plugin",
"defaultPrompt": { "text": "Summarize my inbox" }
}"#,
);
let manifest = load_manifest(&plugin_root);
let interface = manifest.interface.expect("plugin interface");
assert_eq!(interface.default_prompt, None);
}
#[test]
fn plugin_manifest_reads_trimmed_version() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("demo-plugin");
write_manifest(
&plugin_root,
Some(" 1.2.3-beta+7 "),
r#"{
"displayName": "Demo Plugin"
}"#,
);
let manifest = load_manifest(&plugin_root);
assert_eq!(manifest.version, Some("1.2.3-beta+7".to_string()));
}
#[test]
fn plugin_manifest_uses_alternate_discoverable_path() {
let tmp = tempdir().expect("tempdir");
let plugin_root = tmp.path().join("demo-plugin");
write_alternate_plugin_manifest(
&plugin_root,
r#"{
"name": "demo-plugin",
"version": " 2.0.0 ",
"interface": {
"displayName": "Fallback Plugin"
}
}"#,
);
let manifest = load_manifest(&plugin_root);
assert_eq!(manifest.version, Some("2.0.0".to_string()));
assert_eq!(
manifest
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref()),
Some("Fallback Plugin")
);
}
}

View file

@ -1,778 +0,0 @@
use crate::manifest::PluginManifestInterface;
use crate::manifest::load_plugin_manifest;
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_git_utils::get_git_repo_root;
use codex_plugin::PluginId;
use codex_plugin::PluginIdError;
use codex_protocol::protocol::Product;
use codex_utils_absolute_path::AbsolutePathBuf;
use dirs::home_dir;
use serde::Deserialize;
use serde_json::Value as JsonValue;
use std::fs;
use std::io;
use std::path::Component;
use std::path::Path;
use std::path::PathBuf;
use tracing::warn;
const MARKETPLACE_MANIFEST_RELATIVE_PATHS: &[&str] = &[
".agents/plugins/marketplace.json",
".claude-plugin/marketplace.json",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedMarketplacePlugin {
pub plugin_id: PluginId,
pub source: MarketplacePluginSource,
pub policy: MarketplacePluginPolicy,
pub interface: Option<PluginManifestInterface>,
pub manifest: Option<crate::manifest::PluginManifest>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Marketplace {
pub name: String,
pub path: AbsolutePathBuf,
pub interface: Option<MarketplaceInterface>,
pub plugins: Vec<MarketplacePlugin>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplaceListError {
pub path: AbsolutePathBuf,
pub message: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct MarketplaceListOutcome {
pub marketplaces: Vec<Marketplace>,
pub errors: Vec<MarketplaceListError>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplaceInterface {
pub display_name: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplacePlugin {
pub name: String,
pub source: MarketplacePluginSource,
pub policy: MarketplacePluginPolicy,
pub interface: Option<PluginManifestInterface>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MarketplacePluginSource {
Local {
path: AbsolutePathBuf,
},
Git {
url: String,
path: Option<String>,
ref_name: Option<String>,
sha: Option<String>,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MarketplacePluginPolicy {
pub installation: MarketplacePluginInstallPolicy,
pub authentication: MarketplacePluginAuthPolicy,
// TODO: Surface or enforce product gating at the Codex/plugin consumer boundary instead of
// only carrying it through core marketplace metadata.
pub products: Option<Vec<Product>>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
pub enum MarketplacePluginInstallPolicy {
#[serde(rename = "NOT_AVAILABLE")]
NotAvailable,
#[default]
#[serde(rename = "AVAILABLE")]
Available,
#[serde(rename = "INSTALLED_BY_DEFAULT")]
InstalledByDefault,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
pub enum MarketplacePluginAuthPolicy {
#[default]
#[serde(rename = "ON_INSTALL")]
OnInstall,
#[serde(rename = "ON_USE")]
OnUse,
}
impl From<MarketplacePluginInstallPolicy> for PluginInstallPolicy {
fn from(value: MarketplacePluginInstallPolicy) -> Self {
match value {
MarketplacePluginInstallPolicy::NotAvailable => Self::NotAvailable,
MarketplacePluginInstallPolicy::Available => Self::Available,
MarketplacePluginInstallPolicy::InstalledByDefault => Self::InstalledByDefault,
}
}
}
impl From<MarketplacePluginAuthPolicy> for PluginAuthPolicy {
fn from(value: MarketplacePluginAuthPolicy) -> Self {
match value {
MarketplacePluginAuthPolicy::OnInstall => Self::OnInstall,
MarketplacePluginAuthPolicy::OnUse => Self::OnUse,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum MarketplaceError {
#[error("{context}: {source}")]
Io {
context: &'static str,
#[source]
source: io::Error,
},
#[error("marketplace file `{path}` does not exist")]
MarketplaceNotFound { path: PathBuf },
#[error("invalid marketplace file `{path}`: {message}")]
InvalidMarketplaceFile { path: PathBuf, message: String },
#[error("plugin `{plugin_name}` was not found in marketplace `{marketplace_name}`")]
PluginNotFound {
plugin_name: String,
marketplace_name: String,
},
#[error(
"plugin `{plugin_name}` is not available for install in marketplace `{marketplace_name}`"
)]
PluginNotAvailable {
plugin_name: String,
marketplace_name: String,
},
#[error("plugins feature is disabled")]
PluginsDisabled,
#[error("{0}")]
InvalidPlugin(String),
}
impl MarketplaceError {
fn io(context: &'static str, source: io::Error) -> Self {
Self::Io { context, source }
}
}
pub fn find_marketplace_plugin(
marketplace_path: &AbsolutePathBuf,
plugin_name: &str,
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
let marketplace = load_raw_marketplace_manifest(marketplace_path)?;
let marketplace_name = marketplace.name;
let marketplace_name_for_not_found = marketplace_name.clone();
for plugin in marketplace.plugins {
if plugin.name != plugin_name {
continue;
}
if let Some(plugin) =
resolve_marketplace_plugin_entry(marketplace_path, &marketplace_name, plugin)?
{
return Ok(plugin);
}
}
Err(MarketplaceError::PluginNotFound {
plugin_name: plugin_name.to_string(),
marketplace_name: marketplace_name_for_not_found,
})
}
pub fn find_installable_marketplace_plugin(
marketplace_path: &AbsolutePathBuf,
plugin_name: &str,
restriction_product: Option<Product>,
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
let resolved = find_marketplace_plugin(marketplace_path, plugin_name)?;
let product_allowed = match resolved.policy.products.as_deref() {
None => true,
Some([]) => false,
Some(products) => {
restriction_product.is_some_and(|product| product.matches_product_restriction(products))
}
};
if resolved.policy.installation == MarketplacePluginInstallPolicy::NotAvailable
|| !product_allowed
{
return Err(MarketplaceError::PluginNotAvailable {
plugin_name: resolved.plugin_id.plugin_name,
marketplace_name: resolved.plugin_id.marketplace_name,
});
}
Ok(resolved)
}
pub fn list_marketplaces(
additional_roots: &[AbsolutePathBuf],
) -> Result<MarketplaceListOutcome, MarketplaceError> {
list_marketplaces_with_home(additional_roots, home_dir().as_deref())
}
pub fn validate_marketplace_root(root: &Path) -> Result<String, MarketplaceError> {
let Some(path) = find_marketplace_manifest_path(root) else {
return Err(MarketplaceError::InvalidMarketplaceFile {
path: root.to_path_buf(),
message: "marketplace root does not contain a supported manifest".to_string(),
});
};
let marketplace = load_marketplace(&path)?;
Ok(marketplace.name)
}
pub fn find_marketplace_manifest_path(root: &Path) -> Option<AbsolutePathBuf> {
MARKETPLACE_MANIFEST_RELATIVE_PATHS
.iter()
.find_map(|relative_path| {
let path = root.join(relative_path);
if !path.is_file() {
return None;
}
AbsolutePathBuf::try_from(path).ok()
})
}
fn invalid_marketplace_layout_error(path: &AbsolutePathBuf) -> MarketplaceError {
MarketplaceError::InvalidMarketplaceFile {
path: path.to_path_buf(),
message: "marketplace file is not in a supported location".to_string(),
}
}
fn marketplace_root_from_layout(marketplace_path: &Path, relative_path: &str) -> Option<PathBuf> {
let mut current = marketplace_path;
for component in Path::new(relative_path).components().rev() {
let expected = match component {
Component::Normal(expected) => expected,
_ => return None,
};
if current.file_name() != Some(expected) {
return None;
}
current = current.parent()?;
}
Some(current.to_path_buf())
}
pub fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, MarketplaceError> {
let marketplace = load_raw_marketplace_manifest(path)?;
let mut plugins = Vec::new();
for plugin in marketplace.plugins {
let plugin = match resolve_marketplace_plugin_entry(path, &marketplace.name, plugin) {
Ok(Some(plugin)) => plugin,
Ok(None) => continue,
Err(MarketplaceError::InvalidPlugin(message)) => {
warn!(
path = %path.display(),
marketplace = %marketplace.name,
error = %message,
"skipping invalid marketplace plugin"
);
continue;
}
Err(err) => return Err(err),
};
plugins.push(MarketplacePlugin {
name: plugin.plugin_id.plugin_name,
source: plugin.source,
policy: plugin.policy,
interface: plugin.interface,
});
}
Ok(Marketplace {
name: marketplace.name,
path: path.clone(),
interface: resolve_marketplace_interface(marketplace.interface),
plugins,
})
}
#[doc(hidden)]
pub fn list_marketplaces_with_home(
additional_roots: &[AbsolutePathBuf],
home_dir: Option<&Path>,
) -> Result<MarketplaceListOutcome, MarketplaceError> {
let mut outcome = MarketplaceListOutcome::default();
for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) {
match load_marketplace(&marketplace_path) {
Ok(marketplace) => outcome.marketplaces.push(marketplace),
Err(err) => {
warn!(
path = %marketplace_path.display(),
error = %err,
"skipping marketplace that failed to load"
);
outcome.errors.push(MarketplaceListError {
path: marketplace_path,
message: err.to_string(),
});
}
}
}
Ok(outcome)
}
fn discover_marketplace_paths_from_roots(
additional_roots: &[AbsolutePathBuf],
home_dir: Option<&Path>,
) -> Vec<AbsolutePathBuf> {
let mut paths = Vec::new();
if let Some(home) = home_dir
&& let Some(path) = find_marketplace_manifest_path(home)
{
paths.push(path);
}
for root in additional_roots {
// Curated marketplaces can now come from an HTTP-downloaded directory that is not a git
// checkout, so check the root directly before falling back to repo-root discovery.
if let Some(path) = find_marketplace_manifest_path(root.as_path())
&& !paths.contains(&path)
{
paths.push(path);
continue;
}
if let Some(repo_root) = get_git_repo_root(root.as_path())
&& let Ok(repo_root) = AbsolutePathBuf::try_from(repo_root)
&& let Some(path) = find_marketplace_manifest_path(repo_root.as_path())
&& !paths.contains(&path)
{
paths.push(path);
}
}
paths
}
fn load_raw_marketplace_manifest(
path: &AbsolutePathBuf,
) -> Result<RawMarketplaceManifest, MarketplaceError> {
let contents = fs::read_to_string(path.as_path()).map_err(|err| {
if err.kind() == io::ErrorKind::NotFound {
MarketplaceError::MarketplaceNotFound {
path: path.to_path_buf(),
}
} else {
MarketplaceError::io("failed to read marketplace file", err)
}
})?;
serde_json::from_str(&contents).map_err(|err| MarketplaceError::InvalidMarketplaceFile {
path: path.to_path_buf(),
message: err.to_string(),
})
}
fn resolve_marketplace_plugin_entry(
marketplace_path: &AbsolutePathBuf,
marketplace_name: &str,
plugin: RawMarketplaceManifestPlugin,
) -> Result<Option<ResolvedMarketplacePlugin>, MarketplaceError> {
let RawMarketplaceManifestPlugin {
name,
source,
policy,
category,
} = plugin;
let Some(source) = resolve_supported_plugin_source(marketplace_path, &name, source) else {
return Ok(None);
};
let manifest = match &source {
MarketplacePluginSource::Local { path } => load_plugin_manifest(path.as_path()),
MarketplacePluginSource::Git { .. } => None,
};
let interface = plugin_interface_with_marketplace_category(
manifest
.as_ref()
.and_then(|manifest| manifest.interface.clone()),
category,
);
Ok(Some(ResolvedMarketplacePlugin {
plugin_id: PluginId::new(name, marketplace_name.to_string()).map_err(|err| match err {
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
})?,
source,
policy: MarketplacePluginPolicy {
installation: policy.installation,
authentication: policy.authentication,
products: policy.products,
},
interface,
manifest,
}))
}
fn resolve_supported_plugin_source(
marketplace_path: &AbsolutePathBuf,
plugin_name: &str,
source: RawMarketplaceManifestPluginSource,
) -> Option<MarketplacePluginSource> {
match source {
RawMarketplaceManifestPluginSource::Unsupported(_) => {
warn!(
path = %marketplace_path.display(),
plugin = plugin_name,
"skipping marketplace plugin with unsupported source"
);
None
}
source => match resolve_plugin_source(marketplace_path, source) {
Ok(source) => Some(source),
Err(err) => {
warn!(
path = %marketplace_path.display(),
plugin = plugin_name,
error = %err,
"skipping marketplace plugin that failed to resolve"
);
None
}
},
}
}
fn resolve_plugin_source(
marketplace_path: &AbsolutePathBuf,
source: RawMarketplaceManifestPluginSource,
) -> Result<MarketplacePluginSource, MarketplaceError> {
match source {
RawMarketplaceManifestPluginSource::Path(path)
| RawMarketplaceManifestPluginSource::Object(
RawMarketplaceManifestPluginSourceObject::Local { path },
) => Ok(MarketplacePluginSource::Local {
path: resolve_local_plugin_source_path(marketplace_path, &path)?,
}),
RawMarketplaceManifestPluginSource::Object(
RawMarketplaceManifestPluginSourceObject::Url {
url,
path,
ref_name,
sha,
},
) => Ok(MarketplacePluginSource::Git {
url: normalize_git_plugin_source_url(marketplace_path, &url)?,
path: path
.as_deref()
.map(|path| normalize_remote_plugin_subdir(marketplace_path, path))
.transpose()?,
ref_name: normalize_optional_git_selector(&ref_name),
sha: normalize_optional_git_selector(&sha),
}),
RawMarketplaceManifestPluginSource::Object(
RawMarketplaceManifestPluginSourceObject::GitSubdir {
url,
path,
ref_name,
sha,
},
) => Ok(MarketplacePluginSource::Git {
url: normalize_git_plugin_source_url(marketplace_path, &url)?,
path: Some(normalize_remote_plugin_subdir(marketplace_path, &path)?),
ref_name: normalize_optional_git_selector(&ref_name),
sha: normalize_optional_git_selector(&sha),
}),
RawMarketplaceManifestPluginSource::Unsupported(_) => {
unreachable!("unsupported plugin sources should be filtered before resolution")
}
}
}
fn resolve_local_plugin_source_path(
marketplace_path: &AbsolutePathBuf,
path: &str,
) -> Result<AbsolutePathBuf, MarketplaceError> {
let Some(path) = path.strip_prefix("./") else {
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "local plugin source path must start with `./`".to_string(),
});
};
if path.is_empty() {
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "local plugin source path must not be empty".to_string(),
});
}
let relative_source_path = Path::new(path);
if relative_source_path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "local plugin source path must stay within the marketplace root".to_string(),
});
}
// `marketplace.json` lives under a supported marketplace layout beneath `<root>`,
// but local plugin paths are resolved relative to `<root>`.
Ok(marketplace_root_dir(marketplace_path)?.join(relative_source_path))
}
fn normalize_remote_plugin_subdir(
marketplace_path: &AbsolutePathBuf,
path: &str,
) -> Result<String, MarketplaceError> {
let path = path.trim();
let path = path.strip_prefix("./").unwrap_or(path);
if path.is_empty() {
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "git plugin source path must not be empty".to_string(),
});
}
let relative_path = Path::new(path);
if relative_path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "git plugin source path must stay within the repository root".to_string(),
});
}
Ok(path.to_string())
}
fn normalize_git_plugin_source_url(
marketplace_path: &AbsolutePathBuf,
url: &str,
) -> Result<String, MarketplaceError> {
let url = url.trim();
if url.is_empty() {
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "git plugin source url must not be empty".to_string(),
});
}
if url.starts_with("http://") || url.starts_with("https://") {
return Ok(normalize_github_git_url(url));
}
if url.starts_with("./")
|| url.starts_with("../")
|| url.starts_with(".\\")
|| url.starts_with("..\\")
{
return normalize_relative_git_plugin_source_url(marketplace_path, url);
}
if url.starts_with("file://") || url.starts_with('/') {
return Ok(url.to_string());
}
if url.starts_with("ssh://") || url.starts_with("git@") && url.contains(':') {
return Ok(url.to_string());
}
if let Some(url) = normalize_github_shorthand_url(url) {
return Ok(url);
}
Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: format!("invalid git plugin source url: {url}"),
})
}
fn normalize_relative_git_plugin_source_url(
marketplace_path: &AbsolutePathBuf,
url: &str,
) -> Result<String, MarketplaceError> {
let mut normalized = marketplace_root_dir(marketplace_path)?
.as_path()
.to_path_buf();
for segment in url.split(['/', '\\']) {
match segment {
"" | "." => {}
".." => {
return Err(MarketplaceError::InvalidMarketplaceFile {
path: marketplace_path.to_path_buf(),
message: "relative git plugin source url must stay within the marketplace root"
.to_string(),
});
}
segment => normalized.push(segment),
}
}
Ok(normalized.display().to_string())
}
fn normalize_optional_git_selector(value: &Option<String>) -> Option<String> {
value
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
}
fn normalize_github_git_url(url: &str) -> String {
if url.starts_with("https://github.com/") && !url.ends_with(".git") {
format!("{url}.git")
} else {
url.to_string()
}
}
fn normalize_github_shorthand_url(source: &str) -> Option<String> {
if !looks_like_github_shorthand(source) {
return None;
}
let mut segments = source.split('/');
let owner = segments.next()?;
let repo = segments.next()?;
let repo = repo.strip_suffix(".git").unwrap_or(repo);
if repo.is_empty() {
return None;
}
Some(format!("https://github.com/{owner}/{repo}.git"))
}
fn looks_like_github_shorthand(source: &str) -> bool {
let mut segments = source.split('/');
let owner = segments.next();
let repo = segments.next();
let extra = segments.next();
owner.is_some_and(is_github_shorthand_segment)
&& repo.is_some_and(is_github_shorthand_segment)
&& extra.is_none()
}
fn is_github_shorthand_segment(segment: &str) -> bool {
!segment.is_empty()
&& segment
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.'))
}
pub fn plugin_interface_with_marketplace_category(
mut interface: Option<PluginManifestInterface>,
category: Option<String>,
) -> Option<PluginManifestInterface> {
if let Some(category) = category {
// Marketplace taxonomy wins when both sources provide a category.
interface
.get_or_insert_with(PluginManifestInterface::default)
.category = Some(category);
}
interface
}
fn marketplace_root_dir(
marketplace_path: &AbsolutePathBuf,
) -> Result<AbsolutePathBuf, MarketplaceError> {
for relative_path in MARKETPLACE_MANIFEST_RELATIVE_PATHS {
if let Some(marketplace_root) =
marketplace_root_from_layout(marketplace_path.as_path(), relative_path)
{
return AbsolutePathBuf::try_from(marketplace_root)
.map_err(|_| invalid_marketplace_layout_error(marketplace_path));
}
}
Err(invalid_marketplace_layout_error(marketplace_path))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawMarketplaceManifest {
name: String,
#[serde(default)]
interface: Option<RawMarketplaceManifestInterface>,
plugins: Vec<RawMarketplaceManifestPlugin>,
}
#[derive(Debug, Default, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawMarketplaceManifestInterface {
#[serde(default)]
display_name: Option<String>,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawMarketplaceManifestPlugin {
name: String,
source: RawMarketplaceManifestPluginSource,
#[serde(default)]
policy: RawMarketplaceManifestPluginPolicy,
#[serde(default)]
category: Option<String>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawMarketplaceManifestPluginPolicy {
#[serde(default)]
installation: MarketplacePluginInstallPolicy,
#[serde(default)]
authentication: MarketplacePluginAuthPolicy,
products: Option<Vec<Product>>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RawMarketplaceManifestPluginSource {
Path(String),
Object(RawMarketplaceManifestPluginSourceObject),
#[allow(dead_code)]
Unsupported(JsonValue),
}
#[derive(Debug, Deserialize)]
#[serde(tag = "source", rename_all = "lowercase")]
enum RawMarketplaceManifestPluginSourceObject {
Local {
path: String,
},
Url {
url: String,
path: Option<String>,
#[serde(rename = "ref")]
ref_name: Option<String>,
sha: Option<String>,
},
#[serde(rename = "git-subdir")]
GitSubdir {
url: String,
path: String,
#[serde(rename = "ref")]
ref_name: Option<String>,
sha: Option<String>,
},
}
fn resolve_marketplace_interface(
interface: Option<RawMarketplaceManifestInterface>,
) -> Option<MarketplaceInterface> {
let interface = interface?;
if interface.display_name.is_some() {
Some(MarketplaceInterface {
display_name: interface.display_name,
})
} else {
None
}
}
#[cfg(test)]
#[path = "marketplace_tests.rs"]
mod tests;

File diff suppressed because it is too large Load diff

View file

@ -1,298 +0,0 @@
mod activation;
mod git;
use self::activation::activate_marketplace_root;
use self::activation::installed_marketplace_metadata_matches;
use self::activation::write_installed_marketplace_metadata;
use self::git::clone_git_source;
use self::git::git_remote_revision;
use crate::marketplace::validate_marketplace_root;
use codex_config::CONFIG_TOML_FILE;
use codex_config::ConfigLayerStack;
use codex_config::MarketplaceConfigUpdate;
use codex_config::record_user_marketplace;
use codex_config::types::MarketplaceConfig;
use codex_config::types::MarketplaceSourceType;
use codex_plugin::validate_plugin_segment;
use codex_utils_absolute_path::AbsolutePathBuf;
use std::collections::HashMap;
use std::path::Path;
use std::path::PathBuf;
use std::time::Duration;
use tracing::warn;
const INSTALLED_MARKETPLACES_DIR: &str = ".tmp/marketplaces";
const MARKETPLACE_UPGRADE_GIT_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfiguredMarketplaceUpgradeError {
pub marketplace_name: String,
pub message: String,
}
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct ConfiguredMarketplaceUpgradeOutcome {
pub selected_marketplaces: Vec<String>,
pub upgraded_roots: Vec<AbsolutePathBuf>,
pub errors: Vec<ConfiguredMarketplaceUpgradeError>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ConfiguredGitMarketplace {
name: String,
source: String,
ref_name: Option<String>,
sparse_paths: Vec<String>,
last_revision: Option<String>,
}
impl ConfiguredMarketplaceUpgradeOutcome {
pub fn all_succeeded(&self) -> bool {
self.errors.is_empty()
}
}
pub fn configured_git_marketplace_names(config_layer_stack: &ConfigLayerStack) -> Vec<String> {
let mut names = configured_git_marketplaces(config_layer_stack)
.into_iter()
.map(|marketplace| marketplace.name)
.collect::<Vec<_>>();
names.sort_unstable();
names
}
pub fn upgrade_configured_git_marketplaces(
codex_home: &Path,
config_layer_stack: &ConfigLayerStack,
marketplace_name: Option<&str>,
) -> ConfiguredMarketplaceUpgradeOutcome {
let marketplaces = configured_git_marketplaces(config_layer_stack)
.into_iter()
.filter(|marketplace| marketplace_name.is_none_or(|name| marketplace.name.as_str() == name))
.collect::<Vec<_>>();
if marketplaces.is_empty() {
return ConfiguredMarketplaceUpgradeOutcome::default();
}
let install_root = marketplace_install_root(codex_home);
let selected_marketplaces = marketplaces
.iter()
.map(|marketplace| marketplace.name.clone())
.collect();
let mut upgraded_roots = Vec::new();
let mut errors = Vec::new();
for marketplace in marketplaces {
match upgrade_configured_git_marketplace(codex_home, &install_root, &marketplace) {
Ok(Some(upgraded_root)) => upgraded_roots.push(upgraded_root),
Ok(None) => {}
Err(err) => {
errors.push(ConfiguredMarketplaceUpgradeError {
marketplace_name: marketplace.name,
message: err,
});
}
}
}
ConfiguredMarketplaceUpgradeOutcome {
selected_marketplaces,
upgraded_roots,
errors,
}
}
fn marketplace_install_root(codex_home: &Path) -> PathBuf {
codex_home.join(INSTALLED_MARKETPLACES_DIR)
}
fn configured_git_marketplaces(
config_layer_stack: &ConfigLayerStack,
) -> Vec<ConfiguredGitMarketplace> {
let Some(user_layer) = config_layer_stack.get_user_layer() else {
return Vec::new();
};
let Some(marketplaces_value) = user_layer.config.get("marketplaces") else {
return Vec::new();
};
let marketplaces = match marketplaces_value
.clone()
.try_into::<HashMap<String, MarketplaceConfig>>()
{
Ok(marketplaces) => marketplaces,
Err(err) => {
warn!("invalid marketplaces config while preparing auto-upgrade: {err}");
return Vec::new();
}
};
let mut configured = marketplaces
.into_iter()
.filter_map(|(name, marketplace)| configured_git_marketplace_from_config(name, marketplace))
.collect::<Vec<_>>();
configured.sort_unstable_by(|left, right| left.name.cmp(&right.name));
configured
}
fn configured_git_marketplace_from_config(
name: String,
marketplace: MarketplaceConfig,
) -> Option<ConfiguredGitMarketplace> {
let MarketplaceConfig {
last_updated: _,
last_revision,
source_type,
source,
ref_name,
sparse_paths,
} = marketplace;
if source_type != Some(MarketplaceSourceType::Git) {
return None;
}
let Some(source) = source else {
warn!(
marketplace = name,
"ignoring configured Git marketplace without source"
);
return None;
};
Some(ConfiguredGitMarketplace {
name,
source,
ref_name,
sparse_paths: sparse_paths.unwrap_or_default(),
last_revision,
})
}
fn upgrade_configured_git_marketplace(
codex_home: &Path,
install_root: &Path,
marketplace: &ConfiguredGitMarketplace,
) -> Result<Option<AbsolutePathBuf>, String> {
validate_plugin_segment(&marketplace.name, "marketplace name")?;
let remote_revision = git_remote_revision(
&marketplace.source,
marketplace.ref_name.as_deref(),
MARKETPLACE_UPGRADE_GIT_TIMEOUT,
)?;
let destination = install_root.join(&marketplace.name);
if destination
.join(".agents/plugins/marketplace.json")
.is_file()
&& marketplace.last_revision.as_deref() == Some(remote_revision.as_str())
&& installed_marketplace_metadata_matches(&destination, marketplace, &remote_revision)
{
return Ok(None);
}
let staging_parent = install_root.join(".staging");
std::fs::create_dir_all(&staging_parent).map_err(|err| {
format!(
"failed to create marketplace upgrade staging directory {}: {err}",
staging_parent.display()
)
})?;
let staged_dir = tempfile::Builder::new()
.prefix("marketplace-upgrade-")
.tempdir_in(&staging_parent)
.map_err(|err| {
format!(
"failed to create temporary marketplace upgrade directory in {}: {err}",
staging_parent.display()
)
})?;
let activated_revision = clone_git_source(
&marketplace.source,
marketplace.ref_name.as_deref(),
&marketplace.sparse_paths,
staged_dir.path(),
MARKETPLACE_UPGRADE_GIT_TIMEOUT,
)?;
let marketplace_name = validate_marketplace_root(staged_dir.path())
.map_err(|err| format!("failed to validate upgraded marketplace root: {err}"))?;
if marketplace_name != marketplace.name {
return Err(format!(
"upgraded marketplace name `{marketplace_name}` does not match configured marketplace `{}`",
marketplace.name
));
}
write_installed_marketplace_metadata(staged_dir.path(), marketplace, &activated_revision)?;
let last_updated = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let update = MarketplaceConfigUpdate {
last_updated: &last_updated,
last_revision: Some(&activated_revision),
source_type: "git",
source: &marketplace.source,
ref_name: marketplace.ref_name.as_deref(),
sparse_paths: &marketplace.sparse_paths,
};
activate_marketplace_root(&destination, staged_dir, || {
ensure_configured_git_marketplace_unchanged(codex_home, marketplace)?;
record_user_marketplace(codex_home, &marketplace.name, &update).map_err(|err| {
format!(
"failed to record upgraded marketplace `{}` in user config.toml: {err}",
marketplace.name
)
})
})?;
AbsolutePathBuf::try_from(destination)
.map(Some)
.map_err(|err| format!("upgraded marketplace path is not absolute: {err}"))
}
fn ensure_configured_git_marketplace_unchanged(
codex_home: &Path,
expected: &ConfiguredGitMarketplace,
) -> Result<(), String> {
let current = read_configured_git_marketplace(codex_home, &expected.name)?;
match current {
Some(current) if current == *expected => Ok(()),
Some(_) => Err(format!(
"configured marketplace `{}` changed while auto-upgrade was in flight",
expected.name
)),
None => Err(format!(
"configured marketplace `{}` was removed or is no longer a Git marketplace",
expected.name
)),
}
}
fn read_configured_git_marketplace(
codex_home: &Path,
marketplace_name: &str,
) -> Result<Option<ConfiguredGitMarketplace>, String> {
let config_path = codex_home.join(CONFIG_TOML_FILE);
let raw_config = match std::fs::read_to_string(&config_path) {
Ok(raw_config) => raw_config,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(err) => {
return Err(format!(
"failed to read user config {} while checking marketplace auto-upgrade: {err}",
config_path.display()
));
}
};
let config: toml::Value = toml::from_str(&raw_config).map_err(|err| {
format!(
"failed to parse user config {} while checking marketplace auto-upgrade: {err}",
config_path.display()
)
})?;
let Some(marketplaces_value) = config.get("marketplaces") else {
return Ok(None);
};
let mut marketplaces = marketplaces_value
.clone()
.try_into::<HashMap<String, MarketplaceConfig>>()
.map_err(|err| format!("invalid marketplaces config while checking auto-upgrade: {err}"))?;
let Some(marketplace) = marketplaces.remove(marketplace_name) else {
return Ok(None);
};
Ok(configured_git_marketplace_from_config(
marketplace_name.to_string(),
marketplace,
))
}

View file

@ -1,167 +0,0 @@
use super::ConfiguredGitMarketplace;
use codex_config::types::MarketplaceSourceType;
use serde::Deserialize;
use serde::Serialize;
use std::path::Path;
use std::path::PathBuf;
use tempfile::TempDir;
use tracing::warn;
const MARKETPLACE_INSTALL_METADATA_FILE: &str = ".codex-marketplace-install.json";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
struct InstalledMarketplaceMetadata {
source_type: MarketplaceSourceType,
source: String,
ref_name: Option<String>,
sparse_paths: Vec<String>,
revision: String,
}
pub(super) fn installed_marketplace_metadata_matches(
root: &Path,
marketplace: &ConfiguredGitMarketplace,
revision: &str,
) -> bool {
let metadata = match std::fs::read_to_string(installed_marketplace_metadata_path(root)) {
Ok(metadata) => metadata,
Err(_) => return false,
};
let metadata = match serde_json::from_str::<InstalledMarketplaceMetadata>(&metadata) {
Ok(metadata) => metadata,
Err(err) => {
warn!(
marketplace = marketplace.name,
error = %err,
"failed to parse activated marketplace metadata"
);
return false;
}
};
metadata == installed_marketplace_metadata(marketplace, revision)
}
pub(super) fn write_installed_marketplace_metadata(
root: &Path,
marketplace: &ConfiguredGitMarketplace,
revision: &str,
) -> Result<(), String> {
let metadata = installed_marketplace_metadata(marketplace, revision);
let contents = serde_json::to_string_pretty(&metadata)
.map_err(|err| format!("failed to serialize activated marketplace metadata: {err}"))?;
std::fs::write(installed_marketplace_metadata_path(root), contents)
.map_err(|err| format!("failed to write activated marketplace metadata: {err}"))
}
pub(super) fn activate_marketplace_root(
destination: &Path,
staged_dir: TempDir,
after_activate: impl FnOnce() -> Result<(), String>,
) -> Result<(), String> {
let staged_root = staged_dir.path();
let Some(parent) = destination.parent() else {
return Err(format!(
"failed to determine marketplace install parent for {}",
destination.display()
));
};
std::fs::create_dir_all(parent).map_err(|err| {
format!(
"failed to create marketplace install parent {}: {err}",
parent.display()
)
})?;
if destination.exists() {
let backup_dir = tempfile::Builder::new()
.prefix("marketplace-backup-")
.tempdir_in(parent)
.map_err(|err| {
format!(
"failed to create marketplace backup directory in {}: {err}",
parent.display()
)
})?;
let backup_root = backup_dir.path().join("root");
std::fs::rename(destination, &backup_root).map_err(|err| {
format!(
"failed to move previous marketplace root out of the way at {}: {err}",
destination.display()
)
})?;
if let Err(err) = std::fs::rename(staged_root, destination) {
let rollback_result = std::fs::rename(&backup_root, destination);
return match rollback_result {
Ok(()) => Err(format!(
"failed to activate upgraded marketplace at {}: {err}",
destination.display()
)),
Err(rollback_err) => {
let backup_path = backup_dir.keep().join("root");
Err(format!(
"failed to activate upgraded marketplace at {}: {err}; failed to restore previous marketplace root (left at {}): {rollback_err}",
destination.display(),
backup_path.display()
))
}
};
}
if let Err(err) = after_activate() {
let remove_result = std::fs::remove_dir_all(destination);
let rollback_result =
remove_result.and_then(|()| std::fs::rename(&backup_root, destination));
return match rollback_result {
Ok(()) => Err(err),
Err(rollback_err) => {
let backup_path = backup_dir.keep().join("root");
Err(format!(
"{err}; failed to restore previous marketplace root at {} (left at {}): {rollback_err}",
destination.display(),
backup_path.display()
))
}
};
}
return Ok(());
}
std::fs::rename(staged_root, destination).map_err(|err| {
format!(
"failed to activate upgraded marketplace at {}: {err}",
destination.display()
)
})?;
if let Err(err) = after_activate() {
let remove_result = std::fs::remove_dir_all(destination);
return match remove_result {
Ok(()) => Err(err),
Err(remove_err) => Err(format!(
"{err}; failed to remove newly activated marketplace root at {}: {remove_err}",
destination.display()
)),
};
}
Ok(())
}
fn installed_marketplace_metadata(
marketplace: &ConfiguredGitMarketplace,
revision: &str,
) -> InstalledMarketplaceMetadata {
InstalledMarketplaceMetadata {
source_type: MarketplaceSourceType::Git,
source: marketplace.source.clone(),
ref_name: marketplace.ref_name.clone(),
sparse_paths: marketplace.sparse_paths.clone(),
revision: revision.to_string(),
}
}
fn installed_marketplace_metadata_path(root: &Path) -> PathBuf {
root.join(MARKETPLACE_INSTALL_METADATA_FILE)
}

View file

@ -1,238 +0,0 @@
use std::path::Path;
use std::process::Command;
use std::process::Output;
use std::process::Stdio;
use std::time::Duration;
pub(super) fn git_remote_revision(
source: &str,
ref_name: Option<&str>,
timeout: Duration,
) -> Result<String, String> {
if let Some(ref_name) = ref_name
&& is_full_git_sha(ref_name)
{
return Ok(ref_name.to_string());
}
let ref_name = ref_name.unwrap_or("HEAD");
let output = run_git_command_with_timeout(
git_command().arg("ls-remote").arg(source).arg(ref_name),
"git ls-remote marketplace source",
timeout,
)?;
ensure_git_success(&output, "git ls-remote marketplace source")?;
let stdout = String::from_utf8_lossy(&output.stdout);
let Some(first_line) = stdout.lines().next() else {
return Err("git ls-remote returned empty output for marketplace source".to_string());
};
let Some((revision, _)) = first_line.split_once('\t') else {
return Err(format!(
"unexpected git ls-remote output for marketplace source: {first_line}"
));
};
let revision = revision.trim();
if revision.is_empty() {
return Err("git ls-remote returned empty revision for marketplace source".to_string());
}
Ok(revision.to_string())
}
pub(super) fn clone_git_source(
source: &str,
ref_name: Option<&str>,
sparse_paths: &[String],
destination: &Path,
timeout: Duration,
) -> Result<String, String> {
if sparse_paths.is_empty() {
let output = run_git_command_with_timeout(
git_command().arg("clone").arg(source).arg(destination),
"git clone marketplace source",
timeout,
)?;
ensure_git_success(&output, "git clone marketplace source")?;
if let Some(ref_name) = ref_name {
let output = run_git_command_with_timeout(
git_command()
.arg("-C")
.arg(destination)
.arg("checkout")
.arg(ref_name),
"git checkout marketplace ref",
timeout,
)?;
ensure_git_success(&output, "git checkout marketplace ref")?;
}
return git_worktree_revision(destination, timeout);
}
let output = run_git_command_with_timeout(
git_command()
.arg("clone")
.arg("--filter=blob:none")
.arg("--no-checkout")
.arg(source)
.arg(destination),
"git clone marketplace source",
timeout,
)?;
ensure_git_success(&output, "git clone marketplace source")?;
let mut sparse_checkout = git_command();
sparse_checkout
.arg("-C")
.arg(destination)
.arg("sparse-checkout")
.arg("set")
.args(sparse_paths);
let output = run_git_command_with_timeout(
&mut sparse_checkout,
"git sparse-checkout marketplace source",
timeout,
)?;
ensure_git_success(&output, "git sparse-checkout marketplace source")?;
let output = run_git_command_with_timeout(
git_command()
.arg("-C")
.arg(destination)
.arg("checkout")
.arg(ref_name.unwrap_or("HEAD")),
"git checkout marketplace ref",
timeout,
)?;
ensure_git_success(&output, "git checkout marketplace ref")?;
git_worktree_revision(destination, timeout)
}
fn git_worktree_revision(destination: &Path, timeout: Duration) -> Result<String, String> {
let output = run_git_command_with_timeout(
git_command()
.arg("-C")
.arg(destination)
.arg("rev-parse")
.arg("HEAD"),
"git rev-parse marketplace revision",
timeout,
)?;
ensure_git_success(&output, "git rev-parse marketplace revision")?;
let revision = String::from_utf8_lossy(&output.stdout).trim().to_string();
if revision.is_empty() {
Err("git rev-parse returned empty revision for marketplace source".to_string())
} else {
Ok(revision)
}
}
fn is_full_git_sha(value: &str) -> bool {
value.len() == 40 && value.chars().all(|ch| ch.is_ascii_hexdigit())
}
fn git_command() -> Command {
let mut command = Command::new("git");
command
.env("GIT_OPTIONAL_LOCKS", "0")
.env("GIT_TERMINAL_PROMPT", "0");
command
}
fn run_git_command_with_timeout(
command: &mut Command,
context: &str,
timeout: Duration,
) -> Result<Output, String> {
let mut child = command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|err| format!("failed to run {context}: {err}"))?;
let start = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => {
return child
.wait_with_output()
.map_err(|err| format!("failed to wait for {context}: {err}"));
}
Ok(None) => {}
Err(err) => return Err(format!("failed to poll {context}: {err}")),
}
if start.elapsed() >= timeout {
let _ = child.kill();
let output = child
.wait_with_output()
.map_err(|err| format!("failed to wait for {context} after timeout: {err}"))?;
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
return if stderr.is_empty() {
Err(format!("{context} timed out after {}s", timeout.as_secs()))
} else {
Err(format!(
"{context} timed out after {}s: {stderr}",
timeout.as_secs()
))
};
}
std::thread::sleep(Duration::from_millis(100));
}
}
fn ensure_git_success(output: &Output, context: &str) -> Result<(), String> {
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
if stderr.is_empty() {
Err(format!("{context} failed with status {}", output.status))
} else {
Err(format!(
"{context} failed with status {}: {stderr}",
output.status
))
}
}
#[cfg(test)]
mod tests {
use super::git_command;
use super::is_full_git_sha;
use std::ffi::OsStr;
#[test]
fn full_git_sha_ref_is_already_a_remote_revision() {
assert!(is_full_git_sha("0123456789abcdef0123456789abcdef01234567"));
assert!(!is_full_git_sha("main"));
assert!(!is_full_git_sha("0123456"));
}
#[test]
fn git_command_uses_path_lookup_with_stable_noninteractive_env() {
let command = git_command();
assert_eq!(command.get_program(), OsStr::new("git"));
assert_eq!(
command_env(&command, "GIT_OPTIONAL_LOCKS"),
Some(Some(OsStr::new("0")))
);
assert_eq!(
command_env(&command, "GIT_TERMINAL_PROMPT"),
Some(Some(OsStr::new("0")))
);
assert_eq!(command_env(&command, "PATH"), None);
}
fn command_env<'a>(
command: &'a std::process::Command,
name: &str,
) -> Option<Option<&'a OsStr>> {
command
.get_envs()
.find(|(key, _)| key == &OsStr::new(name))
.map(|(_, value)| value)
}
}

View file

@ -1,656 +0,0 @@
use codex_app_server_protocol::PluginAuthPolicy;
use codex_app_server_protocol::PluginInstallPolicy;
use codex_app_server_protocol::PluginInterface;
use codex_app_server_protocol::SkillInterface;
use codex_login::CodexAuth;
use codex_login::default_client::build_reqwest_client;
use reqwest::RequestBuilder;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::collections::HashSet;
use std::time::Duration;
pub const REMOTE_GLOBAL_MARKETPLACE_NAME: &str = "chatgpt-global";
pub const REMOTE_WORKSPACE_MARKETPLACE_NAME: &str = "chatgpt-workspace";
pub const REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Plugins";
pub const REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME: &str = "ChatGPT Workspace Plugins";
const REMOTE_PLUGIN_CATALOG_TIMEOUT: Duration = Duration::from_secs(30);
const MAX_REMOTE_DEFAULT_PROMPT_LEN: usize = 128;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RemotePluginServiceConfig {
pub chatgpt_base_url: String,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RemoteMarketplace {
pub name: String,
pub display_name: String,
pub plugins: Vec<RemotePluginSummary>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RemotePluginSummary {
pub id: String,
pub name: String,
pub installed: bool,
pub enabled: bool,
pub install_policy: PluginInstallPolicy,
pub auth_policy: PluginAuthPolicy,
pub interface: Option<PluginInterface>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RemotePluginDetail {
pub marketplace_name: String,
pub marketplace_display_name: String,
pub summary: RemotePluginSummary,
pub description: Option<String>,
pub skills: Vec<RemotePluginSkill>,
pub app_ids: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RemotePluginSkill {
pub name: String,
pub description: String,
pub short_description: Option<String>,
pub interface: Option<SkillInterface>,
pub enabled: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum RemotePluginCatalogError {
#[error("chatgpt authentication required for remote plugin catalog")]
AuthRequired,
#[error(
"chatgpt authentication required for remote plugin catalog; api key auth is not supported"
)]
UnsupportedAuthMode,
#[error("failed to read auth token for remote plugin catalog: {0}")]
AuthToken(#[source] std::io::Error),
#[error("failed to send remote plugin catalog request to {url}: {source}")]
Request {
url: String,
#[source]
source: reqwest::Error,
},
#[error("remote plugin catalog request to {url} failed with status {status}: {body}")]
UnexpectedStatus {
url: String,
status: reqwest::StatusCode,
body: String,
},
#[error("failed to parse remote plugin catalog response from {url}: {source}")]
Decode {
url: String,
#[source]
source: serde_json::Error,
},
#[error("remote marketplace `{marketplace_name}` is not supported")]
UnknownMarketplace { marketplace_name: String },
#[error(
"remote plugin `{plugin_id}` belongs to marketplace `{actual_marketplace_name}`, not `{expected_marketplace_name}`"
)]
MarketplaceMismatch {
plugin_id: String,
expected_marketplace_name: String,
actual_marketplace_name: String,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize)]
enum RemotePluginScope {
#[serde(rename = "GLOBAL")]
Global,
#[serde(rename = "WORKSPACE")]
Workspace,
}
impl RemotePluginScope {
fn all() -> [Self; 2] {
[Self::Global, Self::Workspace]
}
fn api_value(self) -> &'static str {
match self {
Self::Global => "GLOBAL",
Self::Workspace => "WORKSPACE",
}
}
fn marketplace_name(self) -> &'static str {
match self {
Self::Global => REMOTE_GLOBAL_MARKETPLACE_NAME,
Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_NAME,
}
}
fn marketplace_display_name(self) -> &'static str {
match self {
Self::Global => REMOTE_GLOBAL_MARKETPLACE_DISPLAY_NAME,
Self::Workspace => REMOTE_WORKSPACE_MARKETPLACE_DISPLAY_NAME,
}
}
fn from_marketplace_name(name: &str) -> Option<Self> {
match name {
REMOTE_GLOBAL_MARKETPLACE_NAME => Some(Self::Global),
REMOTE_WORKSPACE_MARKETPLACE_NAME => Some(Self::Workspace),
_ => None,
}
}
}
#[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>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemotePluginSkillResponse {
name: String,
description: String,
interface: Option<RemotePluginSkillInterfaceResponse>,
}
#[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")]
display_name: String,
description: String,
#[serde(default)]
#[serde(alias = "appIds")]
app_ids: Vec<String>,
interface: RemotePluginReleaseInterfaceResponse,
#[serde(default)]
skills: Vec<RemotePluginSkillResponse>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemotePluginDirectoryItem {
id: String,
name: String,
scope: RemotePluginScope,
#[serde(alias = "installationPolicy")]
installation_policy: PluginInstallPolicy,
#[serde(alias = "authenticationPolicy")]
authentication_policy: PluginAuthPolicy,
release: RemotePluginReleaseResponse,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemotePluginInstalledItem {
#[serde(flatten)]
plugin: RemotePluginDirectoryItem,
enabled: bool,
#[serde(default)]
#[serde(alias = "disabledSkillNames")]
disabled_skill_names: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemotePluginListResponse {
plugins: Vec<RemotePluginDirectoryItem>,
pagination: RemotePluginPagination,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct RemotePluginInstalledResponse {
plugins: Vec<RemotePluginInstalledItem>,
pagination: RemotePluginPagination,
}
pub async fn fetch_remote_marketplaces(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
) -> Result<Vec<RemoteMarketplace>, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let mut directory_by_scope =
BTreeMap::<RemotePluginScope, BTreeMap<String, RemotePluginDirectoryItem>>::new();
let mut installed_by_scope =
BTreeMap::<RemotePluginScope, BTreeMap<String, RemotePluginInstalledItem>>::new();
let global = async {
let scope = RemotePluginScope::Global;
let (directory_plugins, installed_plugins) = tokio::try_join!(
fetch_directory_plugins_for_scope(config, auth, scope),
fetch_installed_plugins_for_scope(config, auth, scope),
)?;
Ok::<_, RemotePluginCatalogError>((scope, directory_plugins, installed_plugins))
};
let workspace = async {
let scope = RemotePluginScope::Workspace;
let (directory_plugins, installed_plugins) = tokio::try_join!(
fetch_directory_plugins_for_scope(config, auth, scope),
fetch_installed_plugins_for_scope(config, auth, scope),
)?;
Ok::<_, RemotePluginCatalogError>((scope, directory_plugins, installed_plugins))
};
let (global, workspace) = tokio::try_join!(global, workspace)?;
for (scope, directory_plugins, installed_plugins) in [global, workspace] {
if !directory_plugins.is_empty() {
directory_by_scope.insert(
scope,
directory_plugins
.into_iter()
.map(|plugin| (plugin.id.clone(), plugin))
.collect(),
);
}
if !installed_plugins.is_empty() {
installed_by_scope.insert(
scope,
installed_plugins
.into_iter()
.map(|plugin| (plugin.plugin.id.clone(), plugin))
.collect(),
);
}
}
let mut marketplaces = Vec::new();
for scope in RemotePluginScope::all() {
let directory_plugins = directory_by_scope.get(&scope);
let installed_plugins = installed_by_scope.get(&scope);
let plugin_ids = directory_plugins
.into_iter()
.flat_map(|plugins| plugins.keys())
.chain(
installed_plugins
.into_iter()
.flat_map(|plugins| plugins.keys()),
)
.cloned()
.collect::<BTreeSet<_>>();
if plugin_ids.is_empty() {
continue;
}
let mut plugins = plugin_ids
.into_iter()
.filter_map(|plugin_id| {
let directory_plugin =
directory_plugins.and_then(|plugins| plugins.get(&plugin_id));
let installed_plugin =
installed_plugins.and_then(|plugins| plugins.get(&plugin_id));
directory_plugin
.or_else(|| installed_plugin.map(|plugin| &plugin.plugin))
.map(|plugin| build_remote_plugin_summary(plugin, installed_plugin))
})
.collect::<Vec<_>>();
plugins.sort_by(|left, right| {
remote_plugin_display_name(left)
.to_ascii_lowercase()
.cmp(&remote_plugin_display_name(right).to_ascii_lowercase())
.then_with(|| {
remote_plugin_display_name(left).cmp(remote_plugin_display_name(right))
})
.then_with(|| left.id.cmp(&right.id))
});
marketplaces.push(RemoteMarketplace {
name: scope.marketplace_name().to_string(),
display_name: scope.marketplace_display_name().to_string(),
plugins,
});
}
Ok(marketplaces)
}
pub async fn fetch_remote_plugin_detail(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
marketplace_name: &str,
plugin_id: &str,
) -> Result<RemotePluginDetail, RemotePluginCatalogError> {
let auth = ensure_chatgpt_auth(auth)?;
let scope = RemotePluginScope::from_marketplace_name(marketplace_name).ok_or_else(|| {
RemotePluginCatalogError::UnknownMarketplace {
marketplace_name: marketplace_name.to_string(),
}
})?;
let plugin = fetch_plugin_detail(config, auth, plugin_id).await?;
let actual_marketplace_name = plugin.scope.marketplace_name();
if actual_marketplace_name != marketplace_name {
return Err(RemotePluginCatalogError::MarketplaceMismatch {
plugin_id: plugin_id.to_string(),
expected_marketplace_name: marketplace_name.to_string(),
actual_marketplace_name: actual_marketplace_name.to_string(),
});
}
let installed_plugin = fetch_installed_plugins_for_scope(config, auth, scope)
.await?
.into_iter()
.find(|installed_plugin| installed_plugin.plugin.id == plugin_id);
let disabled_skill_names = installed_plugin
.as_ref()
.map(|plugin| {
plugin
.disabled_skill_names
.iter()
.cloned()
.collect::<HashSet<_>>()
})
.unwrap_or_default();
let skills = plugin
.release
.skills
.iter()
.map(|skill| RemotePluginSkill {
name: skill.name.clone(),
description: skill.description.clone(),
short_description: skill
.interface
.as_ref()
.and_then(|interface| interface.short_description.clone()),
interface: remote_skill_interface_to_info(skill.interface.clone()),
enabled: !disabled_skill_names.contains(&skill.name),
})
.collect();
Ok(RemotePluginDetail {
marketplace_name: marketplace_name.to_string(),
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)),
skills,
app_ids: plugin.release.app_ids,
})
}
fn build_remote_plugin_summary(
plugin: &RemotePluginDirectoryItem,
installed_plugin: Option<&RemotePluginInstalledItem>,
) -> RemotePluginSummary {
RemotePluginSummary {
id: plugin.id.clone(),
name: plugin.name.clone(),
installed: installed_plugin.is_some(),
enabled: installed_plugin.is_some_and(|plugin| plugin.enabled),
install_policy: plugin.installation_policy,
auth_policy: plugin.authentication_policy,
interface: remote_plugin_interface_to_info(plugin),
}
}
fn remote_plugin_interface_to_info(plugin: &RemotePluginDirectoryItem) -> Option<PluginInterface> {
let interface = &plugin.release.interface;
let display_name = non_empty_string(Some(&plugin.release.display_name));
let default_prompt = interface
.default_prompt
.as_ref()
.and_then(|prompt| normalize_remote_default_prompt(prompt));
let result = PluginInterface {
display_name,
short_description: interface.short_description.clone(),
long_description: interface.long_description.clone(),
developer_name: interface.developer_name.clone(),
category: interface.category.clone(),
capabilities: interface.capabilities.clone(),
website_url: interface.website_url.clone(),
privacy_policy_url: interface.privacy_policy_url.clone(),
terms_of_service_url: interface.terms_of_service_url.clone(),
default_prompt,
brand_color: interface.brand_color.clone(),
composer_icon: None,
composer_icon_url: interface.composer_icon_url.clone(),
logo: None,
logo_url: interface.logo_url.clone(),
screenshots: Vec::new(),
screenshot_urls: interface.screenshot_urls.clone(),
};
let has_fields = result.display_name.is_some()
|| result.short_description.is_some()
|| result.long_description.is_some()
|| result.developer_name.is_some()
|| result.category.is_some()
|| !result.capabilities.is_empty()
|| result.website_url.is_some()
|| result.privacy_policy_url.is_some()
|| result.terms_of_service_url.is_some()
|| result.default_prompt.is_some()
|| result.brand_color.is_some()
|| result.composer_icon_url.is_some()
|| result.logo_url.is_some()
|| !result.screenshot_urls.is_empty();
has_fields.then_some(result)
}
fn remote_skill_interface_to_info(
interface: Option<RemotePluginSkillInterfaceResponse>,
) -> Option<SkillInterface> {
interface.and_then(|interface| {
let result = SkillInterface {
display_name: interface.display_name,
short_description: interface.short_description,
icon_small: None,
icon_large: None,
brand_color: interface.brand_color,
default_prompt: interface.default_prompt,
};
let has_fields = result.display_name.is_some()
|| result.short_description.is_some()
|| result.brand_color.is_some()
|| result.default_prompt.is_some();
has_fields.then_some(result)
})
}
fn remote_plugin_display_name(plugin: &RemotePluginSummary) -> &str {
plugin
.interface
.as_ref()
.and_then(|interface| interface.display_name.as_deref())
.unwrap_or(&plugin.name)
}
fn non_empty_string(value: Option<&str>) -> Option<String> {
value.and_then(|value| {
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
})
}
fn normalize_remote_default_prompt(prompt: &str) -> Option<Vec<String>> {
let prompt = prompt.trim();
if prompt.is_empty() || prompt.chars().count() > MAX_REMOTE_DEFAULT_PROMPT_LEN {
return None;
}
Some(vec![prompt.to_string()])
}
async fn fetch_directory_plugins_for_scope(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
scope: RemotePluginScope,
) -> 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?;
plugins.extend(response.plugins);
let Some(next_page_token) = response.pagination.next_page_token else {
break;
};
page_token = Some(next_page_token);
}
Ok(plugins)
}
async fn fetch_installed_plugins_for_scope(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
scope: RemotePluginScope,
) -> Result<Vec<RemotePluginInstalledItem>, RemotePluginCatalogError> {
let mut plugins = Vec::new();
let mut page_token = None;
loop {
let response =
get_remote_plugin_installed_page(config, auth, scope, page_token.as_deref()).await?;
plugins.extend(response.plugins);
let Some(next_page_token) = response.pagination.next_page_token else {
break;
};
page_token = Some(next_page_token);
}
Ok(plugins)
}
async fn get_remote_plugin_list_page(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
scope: RemotePluginScope,
page_token: Option<&str>,
) -> Result<RemotePluginListResponse, RemotePluginCatalogError> {
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/ps/plugins/list");
let client = build_reqwest_client();
let mut request = authenticated_request(client.get(&url), auth)?;
request = request.query(&[("scope", scope.api_value())]);
if let Some(page_token) = page_token {
request = request.query(&[("pageToken", page_token)]);
}
send_and_decode(request, &url).await
}
async fn get_remote_plugin_installed_page(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
scope: RemotePluginScope,
page_token: Option<&str>,
) -> Result<RemotePluginInstalledResponse, RemotePluginCatalogError> {
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/ps/plugins/installed");
let client = build_reqwest_client();
let mut request = authenticated_request(client.get(&url), auth)?;
request = request.query(&[("scope", scope.api_value())]);
if let Some(page_token) = page_token {
request = request.query(&[("pageToken", page_token)]);
}
send_and_decode(request, &url).await
}
async fn fetch_plugin_detail(
config: &RemotePluginServiceConfig,
auth: &CodexAuth,
plugin_id: &str,
) -> 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)?;
send_and_decode(request, &url).await
}
fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginCatalogError> {
let Some(auth) = auth else {
return Err(RemotePluginCatalogError::AuthRequired);
};
if !auth.is_chatgpt_auth() {
return Err(RemotePluginCatalogError::UnsupportedAuthMode);
}
Ok(auth)
}
fn authenticated_request(
request: RequestBuilder,
auth: &CodexAuth,
) -> Result<RequestBuilder, RemotePluginCatalogError> {
let token = auth
.get_token()
.map_err(RemotePluginCatalogError::AuthToken)?;
let mut request = request
.timeout(REMOTE_PLUGIN_CATALOG_TIMEOUT)
.bearer_auth(token);
if let Some(account_id) = auth.get_account_id() {
request = request.header("chatgpt-account-id", account_id);
}
Ok(request)
}
async fn send_and_decode<T: for<'de> Deserialize<'de>>(
request: RequestBuilder,
url: &str,
) -> Result<T, RemotePluginCatalogError> {
let response = request
.send()
.await
.map_err(|source| RemotePluginCatalogError::Request {
url: url.to_string(),
source,
})?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(RemotePluginCatalogError::UnexpectedStatus {
url: url.to_string(),
status,
body,
});
}
serde_json::from_str(&body).map_err(|source| RemotePluginCatalogError::Decode {
url: url.to_string(),
source,
})
}

View file

@ -1,313 +0,0 @@
use crate::remote::RemotePluginServiceConfig;
use codex_login::CodexAuth;
use codex_login::default_client::build_reqwest_client;
use codex_protocol::protocol::Product;
use serde::Deserialize;
use std::time::Duration;
use url::Url;
const DEFAULT_REMOTE_MARKETPLACE_NAME: &str = "openai-curated";
const REMOTE_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(30);
const REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(10);
const REMOTE_PLUGIN_MUTATION_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
pub struct RemotePluginStatusSummary {
pub name: String,
#[serde(default = "default_remote_marketplace_name")]
pub marketplace_name: String,
pub enabled: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RemotePluginMutationResponse {
pub id: String,
pub enabled: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum RemotePluginMutationError {
#[error("chatgpt authentication required for remote plugin mutation")]
AuthRequired,
#[error(
"chatgpt authentication required for remote plugin mutation; api key auth is not supported"
)]
UnsupportedAuthMode,
#[error("failed to read auth token for remote plugin mutation: {0}")]
AuthToken(#[source] std::io::Error),
#[error("invalid chatgpt base url for remote plugin mutation: {0}")]
InvalidBaseUrl(#[source] url::ParseError),
#[error("chatgpt base url cannot be used for plugin mutation")]
InvalidBaseUrlPath,
#[error("failed to send remote plugin mutation request to {url}: {source}")]
Request {
url: String,
#[source]
source: reqwest::Error,
},
#[error("remote plugin mutation failed with status {status} from {url}: {body}")]
UnexpectedStatus {
url: String,
status: reqwest::StatusCode,
body: String,
},
#[error("failed to parse remote plugin mutation response from {url}: {source}")]
Decode {
url: String,
#[source]
source: serde_json::Error,
},
#[error(
"remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`"
)]
UnexpectedPluginId { expected: String, actual: String },
#[error(
"remote plugin mutation returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}"
)]
UnexpectedEnabledState {
plugin_id: String,
expected_enabled: bool,
actual_enabled: bool,
},
}
#[derive(Debug, thiserror::Error)]
pub enum RemotePluginFetchError {
#[error("chatgpt authentication required to sync remote plugins")]
AuthRequired,
#[error(
"chatgpt authentication required to sync remote plugins; api key auth is not supported"
)]
UnsupportedAuthMode,
#[error("failed to read auth token for remote plugin sync: {0}")]
AuthToken(#[source] std::io::Error),
#[error("failed to send remote plugin sync request to {url}: {source}")]
Request {
url: String,
#[source]
source: reqwest::Error,
},
#[error("remote plugin sync request to {url} failed with status {status}: {body}")]
UnexpectedStatus {
url: String,
status: reqwest::StatusCode,
body: String,
},
#[error("failed to parse remote plugin sync response from {url}: {source}")]
Decode {
url: String,
#[source]
source: serde_json::Error,
},
}
pub async fn fetch_remote_plugin_status(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
) -> Result<Vec<RemotePluginStatusSummary>, RemotePluginFetchError> {
let Some(auth) = auth else {
return Err(RemotePluginFetchError::AuthRequired);
};
if !auth.is_chatgpt_auth() {
return Err(RemotePluginFetchError::UnsupportedAuthMode);
}
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/plugins/list");
let client = build_reqwest_client();
let token = auth
.get_token()
.map_err(RemotePluginFetchError::AuthToken)?;
let mut request = client
.get(&url)
.timeout(REMOTE_PLUGIN_FETCH_TIMEOUT)
.bearer_auth(token);
if let Some(account_id) = auth.get_account_id() {
request = request.header("chatgpt-account-id", account_id);
}
let response = request
.send()
.await
.map_err(|source| RemotePluginFetchError::Request {
url: url.clone(),
source,
})?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body });
}
serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode {
url: url.clone(),
source,
})
}
pub async fn fetch_remote_featured_plugin_ids(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
product: Option<Product>,
) -> Result<Vec<String>, RemotePluginFetchError> {
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/plugins/featured");
let client = build_reqwest_client();
let mut request = client
.get(&url)
.query(&[(
"platform",
product.unwrap_or(Product::Codex).to_app_platform(),
)])
.timeout(REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT);
if let Some(auth) = auth.filter(|auth| auth.is_chatgpt_auth()) {
let token = auth
.get_token()
.map_err(RemotePluginFetchError::AuthToken)?;
request = request.bearer_auth(token);
if let Some(account_id) = auth.get_account_id() {
request = request.header("chatgpt-account-id", account_id);
}
}
let response = request
.send()
.await
.map_err(|source| RemotePluginFetchError::Request {
url: url.clone(),
source,
})?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body });
}
serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode {
url: url.clone(),
source,
})
}
pub async fn enable_remote_plugin(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
plugin_id: &str,
) -> Result<(), RemotePluginMutationError> {
post_remote_plugin_mutation(config, auth, plugin_id, "enable").await?;
Ok(())
}
pub async fn uninstall_remote_plugin(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
plugin_id: &str,
) -> Result<(), RemotePluginMutationError> {
post_remote_plugin_mutation(config, auth, plugin_id, "uninstall").await?;
Ok(())
}
fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginMutationError> {
let Some(auth) = auth else {
return Err(RemotePluginMutationError::AuthRequired);
};
if !auth.is_chatgpt_auth() {
return Err(RemotePluginMutationError::UnsupportedAuthMode);
}
Ok(auth)
}
fn default_remote_marketplace_name() -> String {
DEFAULT_REMOTE_MARKETPLACE_NAME.to_string()
}
async fn post_remote_plugin_mutation(
config: &RemotePluginServiceConfig,
auth: Option<&CodexAuth>,
plugin_id: &str,
action: &str,
) -> Result<RemotePluginMutationResponse, RemotePluginMutationError> {
let auth = ensure_chatgpt_auth(auth)?;
let url = remote_plugin_mutation_url(config, plugin_id, action)?;
let client = build_reqwest_client();
let token = auth
.get_token()
.map_err(RemotePluginMutationError::AuthToken)?;
let mut request = client
.post(url.clone())
.timeout(REMOTE_PLUGIN_MUTATION_TIMEOUT)
.bearer_auth(token);
if let Some(account_id) = auth.get_account_id() {
request = request.header("chatgpt-account-id", account_id);
}
let response = request
.send()
.await
.map_err(|source| RemotePluginMutationError::Request {
url: url.clone(),
source,
})?;
let status = response.status();
let body = response.text().await.unwrap_or_default();
if !status.is_success() {
return Err(RemotePluginMutationError::UnexpectedStatus { url, status, body });
}
let parsed: RemotePluginMutationResponse =
serde_json::from_str(&body).map_err(|source| RemotePluginMutationError::Decode {
url: url.clone(),
source,
})?;
let expected_enabled = action == "enable";
if parsed.id != plugin_id {
return Err(RemotePluginMutationError::UnexpectedPluginId {
expected: plugin_id.to_string(),
actual: parsed.id,
});
}
if parsed.enabled != expected_enabled {
return Err(RemotePluginMutationError::UnexpectedEnabledState {
plugin_id: plugin_id.to_string(),
expected_enabled,
actual_enabled: parsed.enabled,
});
}
Ok(parsed)
}
fn remote_plugin_mutation_url(
config: &RemotePluginServiceConfig,
plugin_id: &str,
action: &str,
) -> Result<String, RemotePluginMutationError> {
let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/'))
.map_err(RemotePluginMutationError::InvalidBaseUrl)?;
{
let mut segments = url
.path_segments_mut()
.map_err(|()| RemotePluginMutationError::InvalidBaseUrlPath)?;
segments.pop_if_empty();
segments.push("plugins");
segments.push(plugin_id);
segments.push(action);
}
Ok(url.to_string())
}

View file

@ -1,341 +0,0 @@
use crate::manifest::PluginManifest;
use crate::manifest::load_plugin_manifest;
use codex_plugin::PluginId;
use codex_plugin::validate_plugin_segment;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_plugins::find_plugin_manifest_path;
use serde::Deserialize;
use serde_json::Value as JsonValue;
use std::fs;
use std::io;
use std::path::Path;
use std::path::PathBuf;
pub const DEFAULT_PLUGIN_VERSION: &str = "local";
pub const PLUGINS_CACHE_DIR: &str = "plugins/cache";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PluginInstallResult {
pub plugin_id: PluginId,
pub plugin_version: String,
pub installed_path: AbsolutePathBuf,
}
#[derive(Debug, Clone)]
pub struct PluginStore {
root: AbsolutePathBuf,
}
impl PluginStore {
pub fn new(codex_home: PathBuf) -> Self {
Self::try_new(codex_home)
.unwrap_or_else(|err| panic!("plugin cache root should be absolute: {err}"))
}
pub fn try_new(codex_home: PathBuf) -> Result<Self, PluginStoreError> {
let root = AbsolutePathBuf::from_absolute_path_checked(codex_home.join(PLUGINS_CACHE_DIR))
.map_err(|err| PluginStoreError::io("failed to resolve plugin cache root", err))?;
Ok(Self { root })
}
pub fn root(&self) -> &AbsolutePathBuf {
&self.root
}
pub fn plugin_base_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf {
self.root
.join(&plugin_id.marketplace_name)
.join(&plugin_id.plugin_name)
}
pub fn plugin_root(&self, plugin_id: &PluginId, plugin_version: &str) -> AbsolutePathBuf {
self.plugin_base_root(plugin_id).join(plugin_version)
}
pub fn active_plugin_version(&self, plugin_id: &PluginId) -> Option<String> {
let mut discovered_versions = fs::read_dir(self.plugin_base_root(plugin_id).as_path())
.ok()?
.filter_map(Result::ok)
.filter_map(|entry| {
entry.file_type().ok().filter(std::fs::FileType::is_dir)?;
entry.file_name().into_string().ok()
})
.filter(|version| validate_plugin_version_segment(version).is_ok())
.collect::<Vec<_>>();
discovered_versions.sort_unstable();
if discovered_versions.is_empty() {
None
} else if discovered_versions
.iter()
.any(|version| version == DEFAULT_PLUGIN_VERSION)
{
Some(DEFAULT_PLUGIN_VERSION.to_string())
} else {
discovered_versions.pop()
}
}
pub fn active_plugin_root(&self, plugin_id: &PluginId) -> Option<AbsolutePathBuf> {
self.active_plugin_version(plugin_id)
.map(|plugin_version| self.plugin_root(plugin_id, &plugin_version))
}
pub fn is_installed(&self, plugin_id: &PluginId) -> bool {
self.active_plugin_version(plugin_id).is_some()
}
pub fn install(
&self,
source_path: AbsolutePathBuf,
plugin_id: PluginId,
) -> Result<PluginInstallResult, PluginStoreError> {
let plugin_version = plugin_version_for_source(source_path.as_path())?;
self.install_with_version(source_path, plugin_id, plugin_version)
}
pub fn install_with_version(
&self,
source_path: AbsolutePathBuf,
plugin_id: PluginId,
plugin_version: String,
) -> Result<PluginInstallResult, PluginStoreError> {
if !source_path.as_path().is_dir() {
return Err(PluginStoreError::Invalid(format!(
"plugin source path is not a directory: {}",
source_path.display()
)));
}
let plugin_name = plugin_name_for_source(source_path.as_path())?;
if plugin_name != plugin_id.plugin_name {
return Err(PluginStoreError::Invalid(format!(
"plugin.json name `{plugin_name}` does not match marketplace plugin name `{}`",
plugin_id.plugin_name
)));
}
validate_plugin_version_segment(&plugin_version).map_err(PluginStoreError::Invalid)?;
let installed_path = self.plugin_root(&plugin_id, &plugin_version);
replace_plugin_root_atomically(
source_path.as_path(),
self.plugin_base_root(&plugin_id).as_path(),
&plugin_version,
)?;
Ok(PluginInstallResult {
plugin_id,
plugin_version,
installed_path,
})
}
pub fn uninstall(&self, plugin_id: &PluginId) -> Result<(), PluginStoreError> {
remove_existing_target(self.plugin_base_root(plugin_id).as_path())
}
}
#[derive(Debug, thiserror::Error)]
pub enum PluginStoreError {
#[error("{context}: {source}")]
Io {
context: &'static str,
#[source]
source: io::Error,
},
#[error("{0}")]
Invalid(String),
}
impl PluginStoreError {
fn io(context: &'static str, source: io::Error) -> Self {
Self::Io { context, source }
}
}
pub fn plugin_version_for_source(source_path: &Path) -> Result<String, PluginStoreError> {
let plugin_version = plugin_manifest_version_for_source(source_path)?
.unwrap_or_else(|| DEFAULT_PLUGIN_VERSION.to_string());
validate_plugin_version_segment(&plugin_version).map_err(PluginStoreError::Invalid)?;
Ok(plugin_version)
}
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());
}
if matches!(plugin_version, "." | "..") {
return Err("invalid plugin version: path traversal is not allowed".to_string());
}
if !plugin_version
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '+'))
{
return Err(
"invalid plugin version: only ASCII letters, digits, `.`, `+`, `_`, and `-` are allowed"
.to_string(),
);
}
Ok(())
}
fn plugin_manifest_for_source(source_path: &Path) -> Result<PluginManifest, PluginStoreError> {
load_plugin_manifest(source_path)
.ok_or_else(|| PluginStoreError::Invalid("missing or invalid plugin.json".to_string()))
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct RawPluginManifestVersion {
#[serde(default)]
version: Option<JsonValue>,
}
fn plugin_manifest_version_for_source(
source_path: &Path,
) -> Result<Option<String>, PluginStoreError> {
let manifest_path = find_plugin_manifest_path(source_path)
.ok_or_else(|| PluginStoreError::Invalid("missing plugin.json".to_string()))?;
let contents = fs::read_to_string(&manifest_path)
.map_err(|err| PluginStoreError::io("failed to read plugin.json", err))?;
let manifest: RawPluginManifestVersion = serde_json::from_str(&contents)
.map_err(|err| PluginStoreError::Invalid(format!("failed to parse plugin.json: {err}")))?;
let Some(version) = manifest.version else {
return Ok(None);
};
let Some(version) = version.as_str() else {
return Err(PluginStoreError::Invalid(
"invalid plugin version in plugin.json: expected string".to_string(),
));
};
let version = version.trim();
if version.is_empty() {
return Err(PluginStoreError::Invalid(
"invalid plugin version in plugin.json: must not be blank".to_string(),
));
}
Ok(Some(version.to_string()))
}
fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError> {
let manifest = plugin_manifest_for_source(source_path)?;
let plugin_name = manifest.name;
validate_plugin_segment(&plugin_name, "plugin name")
.map_err(PluginStoreError::Invalid)
.map(|_| plugin_name)
}
fn remove_existing_target(path: &Path) -> Result<(), PluginStoreError> {
if !path.exists() {
return Ok(());
}
if path.is_dir() {
fs::remove_dir_all(path).map_err(|err| {
PluginStoreError::io("failed to remove existing plugin cache entry", err)
})
} else {
fs::remove_file(path).map_err(|err| {
PluginStoreError::io("failed to remove existing plugin cache entry", err)
})
}
}
fn replace_plugin_root_atomically(
source: &Path,
target_root: &Path,
plugin_version: &str,
) -> Result<(), PluginStoreError> {
let Some(parent) = target_root.parent() else {
return Err(PluginStoreError::Invalid(format!(
"plugin cache path has no parent: {}",
target_root.display()
)));
};
fs::create_dir_all(parent)
.map_err(|err| PluginStoreError::io("failed to create plugin cache directory", err))?;
let Some(plugin_dir_name) = target_root.file_name() else {
return Err(PluginStoreError::Invalid(format!(
"plugin cache path has no directory name: {}",
target_root.display()
)));
};
let staged_dir = tempfile::Builder::new()
.prefix("plugin-install-")
.tempdir_in(parent)
.map_err(|err| {
PluginStoreError::io("failed to create temporary plugin cache directory", err)
})?;
let staged_root = staged_dir.path().join(plugin_dir_name);
let staged_version_root = staged_root.join(plugin_version);
copy_dir_recursive(source, &staged_version_root)?;
if target_root.exists() {
let backup_dir = tempfile::Builder::new()
.prefix("plugin-backup-")
.tempdir_in(parent)
.map_err(|err| {
PluginStoreError::io("failed to create plugin cache backup directory", err)
})?;
let backup_root = backup_dir.path().join(plugin_dir_name);
fs::rename(target_root, &backup_root)
.map_err(|err| PluginStoreError::io("failed to back up plugin cache entry", err))?;
if let Err(err) = fs::rename(&staged_root, target_root) {
let rollback_result = fs::rename(&backup_root, target_root);
return match rollback_result {
Ok(()) => Err(PluginStoreError::io(
"failed to activate updated plugin cache entry",
err,
)),
Err(rollback_err) => {
let backup_path = backup_dir.keep().join(plugin_dir_name);
Err(PluginStoreError::Invalid(format!(
"failed to activate updated plugin cache entry at {}: {err}; failed to restore previous cache entry (left at {}): {rollback_err}",
target_root.display(),
backup_path.display()
)))
}
};
}
} else {
fs::rename(&staged_root, target_root)
.map_err(|err| PluginStoreError::io("failed to activate plugin cache entry", err))?;
}
Ok(())
}
fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
fs::create_dir_all(target)
.map_err(|err| PluginStoreError::io("failed to create plugin target directory", err))?;
for entry in fs::read_dir(source)
.map_err(|err| PluginStoreError::io("failed to read plugin source directory", err))?
{
let entry =
entry.map_err(|err| PluginStoreError::io("failed to enumerate plugin source", err))?;
let source_path = entry.path();
let target_path = target.join(entry.file_name());
let file_type = entry
.file_type()
.map_err(|err| PluginStoreError::io("failed to inspect plugin source entry", err))?;
if file_type.is_dir() {
copy_dir_recursive(&source_path, &target_path)?;
} else if file_type.is_file() {
fs::copy(&source_path, &target_path)
.map_err(|err| PluginStoreError::io("failed to copy plugin file", err))?;
}
}
Ok(())
}
#[cfg(test)]
#[path = "store_tests.rs"]
mod tests;

View file

@ -1,318 +0,0 @@
use super::*;
use codex_plugin::PluginId;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
fn write_plugin_with_version(
root: &Path,
dir_name: &str,
manifest_name: &str,
manifest_version: Option<&str>,
) {
let plugin_root = root.join(dir_name);
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
fs::create_dir_all(plugin_root.join("skills")).unwrap();
let version = manifest_version
.map(|manifest_version| format!(r#","version":"{manifest_version}""#))
.unwrap_or_default();
fs::write(
plugin_root.join(".codex-plugin/plugin.json"),
format!(r#"{{"name":"{manifest_name}"{version}}}"#),
)
.unwrap();
fs::write(plugin_root.join("skills/SKILL.md"), "skill").unwrap();
fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap();
}
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
write_plugin_with_version(
root,
dir_name,
manifest_name,
/*manifest_version*/ None,
);
}
#[test]
fn try_new_rejects_relative_codex_home() {
let err = PluginStore::try_new(PathBuf::from("relative"))
.expect_err("relative codex home should fail");
let err = err.to_string().replace('\\', "/");
assert_eq!(
err,
"failed to resolve plugin cache root: path is not absolute: relative/plugins/cache"
);
}
#[test]
fn install_copies_plugin_into_default_marketplace() {
let tmp = tempdir().unwrap();
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
let result = PluginStore::new(tmp.path().to_path_buf())
.install(
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
plugin_id.clone(),
)
.unwrap();
let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local");
assert_eq!(
result,
PluginInstallResult {
plugin_id,
plugin_version: "local".to_string(),
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
}
);
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
assert!(installed_path.join("skills/SKILL.md").is_file());
}
#[test]
fn install_uses_manifest_name_for_destination_and_key() {
let tmp = tempdir().unwrap();
write_plugin(tmp.path(), "source-dir", "manifest-name");
let plugin_id = PluginId::new("manifest-name".to_string(), "market".to_string()).unwrap();
let result = PluginStore::new(tmp.path().to_path_buf())
.install(
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
plugin_id.clone(),
)
.unwrap();
assert_eq!(
result,
PluginInstallResult {
plugin_id,
plugin_version: "local".to_string(),
installed_path: AbsolutePathBuf::try_from(
tmp.path().join("plugins/cache/market/manifest-name/local"),
)
.unwrap(),
}
);
}
#[test]
fn plugin_root_derives_path_from_key_and_version() {
let tmp = tempdir().unwrap();
let store = PluginStore::new(tmp.path().to_path_buf());
let plugin_id = PluginId::new("sample".to_string(), "debug".to_string()).unwrap();
assert_eq!(
store.plugin_root(&plugin_id, "local").as_path(),
tmp.path().join("plugins/cache/debug/sample/local")
);
}
#[test]
fn install_with_version_uses_requested_cache_version() {
let tmp = tempdir().unwrap();
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
let plugin_id =
PluginId::new("sample-plugin".to_string(), "openai-curated".to_string()).unwrap();
let plugin_version = "0123456789abcdef".to_string();
let result = PluginStore::new(tmp.path().to_path_buf())
.install_with_version(
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
plugin_id.clone(),
plugin_version.clone(),
)
.unwrap();
let installed_path = tmp.path().join(format!(
"plugins/cache/openai-curated/sample-plugin/{plugin_version}"
));
assert_eq!(
result,
PluginInstallResult {
plugin_id,
plugin_version,
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
}
);
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
}
#[test]
fn install_uses_manifest_version_when_present() {
let tmp = tempdir().unwrap();
write_plugin_with_version(
tmp.path(),
"sample-plugin",
"sample-plugin",
Some("1.2.3-beta+7"),
);
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
let result = PluginStore::new(tmp.path().to_path_buf())
.install(
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
plugin_id.clone(),
)
.unwrap();
let installed_path = tmp
.path()
.join("plugins/cache/debug/sample-plugin/1.2.3-beta+7");
assert_eq!(
result,
PluginInstallResult {
plugin_id,
plugin_version: "1.2.3-beta+7".to_string(),
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
}
);
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
}
#[test]
fn install_rejects_blank_manifest_version() {
let tmp = tempdir().unwrap();
write_plugin_with_version(tmp.path(), "sample-plugin", "sample-plugin", Some(" "));
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
let err = PluginStore::new(tmp.path().to_path_buf())
.install(
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
plugin_id,
)
.expect_err("blank manifest version should be rejected");
let err = err.to_string().replace('\\', "/");
assert_eq!(
err,
"invalid plugin version in plugin.json: must not be blank"
);
}
#[test]
fn active_plugin_version_reads_version_directory_name() {
let tmp = tempdir().unwrap();
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"sample-plugin/local",
"sample-plugin",
);
let store = PluginStore::new(tmp.path().to_path_buf());
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
assert_eq!(
store.active_plugin_version(&plugin_id),
Some("local".to_string())
);
assert_eq!(
store.active_plugin_root(&plugin_id).unwrap().as_path(),
tmp.path().join("plugins/cache/debug/sample-plugin/local")
);
}
#[test]
fn active_plugin_version_prefers_default_local_version_when_multiple_versions_exist() {
let tmp = tempdir().unwrap();
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"sample-plugin/0123456789abcdef",
"sample-plugin",
);
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"sample-plugin/local",
"sample-plugin",
);
let store = PluginStore::new(tmp.path().to_path_buf());
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
assert_eq!(
store.active_plugin_version(&plugin_id),
Some("local".to_string())
);
}
#[test]
fn active_plugin_version_returns_last_sorted_version_when_default_is_missing() {
let tmp = tempdir().unwrap();
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"sample-plugin/0123456789abcdef",
"sample-plugin",
);
write_plugin(
&tmp.path().join("plugins/cache/debug"),
"sample-plugin/fedcba9876543210",
"sample-plugin",
);
let store = PluginStore::new(tmp.path().to_path_buf());
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
assert_eq!(
store.active_plugin_version(&plugin_id),
Some("fedcba9876543210".to_string())
);
}
#[test]
fn plugin_root_rejects_path_separators_in_key_segments() {
let err = PluginId::parse("../../etc@debug").unwrap_err();
assert_eq!(
err.to_string(),
"invalid plugin name: only ASCII letters, digits, `_`, and `-` are allowed in `../../etc@debug`"
);
let err = PluginId::parse("sample@../../etc").unwrap_err();
assert_eq!(
err.to_string(),
"invalid marketplace name: only ASCII letters, digits, `_`, and `-` are allowed in `sample@../../etc`"
);
}
#[test]
fn install_rejects_manifest_names_with_path_separators() {
let tmp = tempdir().unwrap();
write_plugin(tmp.path(), "source-dir", "../../etc");
let err = PluginStore::new(tmp.path().to_path_buf())
.install(
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
PluginId::new("source-dir".to_string(), "debug".to_string()).unwrap(),
)
.unwrap_err();
assert_eq!(
err.to_string(),
"invalid plugin name: only ASCII letters, digits, `_`, and `-` are allowed"
);
}
#[test]
fn install_rejects_marketplace_names_with_path_separators() {
let err = PluginId::new("sample-plugin".to_string(), "../../etc".to_string()).unwrap_err();
assert_eq!(
err.to_string(),
"invalid marketplace name: only ASCII letters, digits, `_`, and `-` are allowed"
);
}
#[test]
fn install_rejects_manifest_names_that_do_not_match_marketplace_plugin_name() {
let tmp = tempdir().unwrap();
write_plugin(tmp.path(), "source-dir", "manifest-name");
let err = PluginStore::new(tmp.path().to_path_buf())
.install(
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
PluginId::new("different-name".to_string(), "debug".to_string()).unwrap(),
)
.unwrap_err();
assert_eq!(
err.to_string(),
"plugin.json name `manifest-name` does not match marketplace plugin name `different-name`"
);
}

View file

@ -1,100 +0,0 @@
use serde_json::Value as JsonValue;
use std::collections::BTreeMap;
pub fn collect_plugin_enabled_candidates<'a>(
edits: impl Iterator<Item = (&'a String, &'a JsonValue)>,
) -> BTreeMap<String, bool> {
let mut pending_changes = BTreeMap::new();
for (key_path, value) in edits {
let segments = key_path
.split('.')
.map(str::to_string)
.collect::<Vec<String>>();
match segments.as_slice() {
[plugins, plugin_id, enabled]
if plugins == "plugins" && enabled == "enabled" && value.is_boolean() =>
{
if let Some(enabled) = value.as_bool() {
pending_changes.insert(plugin_id.clone(), enabled);
}
}
[plugins, plugin_id] if plugins == "plugins" => {
if let Some(enabled) = value.get("enabled").and_then(JsonValue::as_bool) {
pending_changes.insert(plugin_id.clone(), enabled);
}
}
[plugins] if plugins == "plugins" => {
let Some(entries) = value.as_object() else {
continue;
};
for (plugin_id, plugin_value) in entries {
let Some(enabled) = plugin_value.get("enabled").and_then(JsonValue::as_bool)
else {
continue;
};
pending_changes.insert(plugin_id.clone(), enabled);
}
}
_ => {}
}
}
pending_changes
}
#[cfg(test)]
mod tests {
use super::collect_plugin_enabled_candidates;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
#[test]
fn collect_plugin_enabled_candidates_tracks_direct_and_table_writes() {
let candidates = collect_plugin_enabled_candidates(
[
(&"plugins.sample@test.enabled".to_string(), &json!(true)),
(
&"plugins.other@test".to_string(),
&json!({ "enabled": false, "ignored": true }),
),
(
&"plugins".to_string(),
&json!({
"nested@test": { "enabled": true },
"skip@test": { "name": "skip" },
}),
),
]
.into_iter(),
);
assert_eq!(
candidates,
BTreeMap::from([
("nested@test".to_string(), true),
("other@test".to_string(), false),
("sample@test".to_string(), true),
])
);
}
#[test]
fn collect_plugin_enabled_candidates_uses_last_write_for_same_plugin() {
let candidates = collect_plugin_enabled_candidates(
[
(&"plugins.sample@test.enabled".to_string(), &json!(true)),
(
&"plugins.sample@test".to_string(),
&json!({ "enabled": false }),
),
]
.into_iter(),
);
assert_eq!(
candidates,
BTreeMap::from([("sample@test".to_string(), false)])
);
}
}