Delete orphaned core plugins crate
This commit is contained in:
parent
a4310dd4e9
commit
f23ef8445c
20 changed files with 11 additions and 6509 deletions
|
|
@ -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.
|
|
@ -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
28
vendor/codex/codex-rs/Cargo.lock
generated
vendored
|
|
@ -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"
|
||||
|
|
|
|||
2
vendor/codex/codex-rs/Cargo.toml
vendored
2
vendor/codex/codex-rs/Cargo.toml
vendored
|
|
@ -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" }
|
||||
|
|
|
|||
15
vendor/codex/codex-rs/core-plugins/BUILD.bazel
vendored
15
vendor/codex/codex-rs/core-plugins/BUILD.bazel
vendored
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
40
vendor/codex/codex-rs/core-plugins/Cargo.toml
vendored
40
vendor/codex/codex-rs/core-plugins/Cargo.toml
vendored
|
|
@ -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 }
|
||||
|
|
@ -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;
|
||||
1130
vendor/codex/codex-rs/core-plugins/src/loader.rs
vendored
1130
vendor/codex/codex-rs/core-plugins/src/loader.rs
vendored
File diff suppressed because it is too large
Load diff
545
vendor/codex/codex-rs/core-plugins/src/manifest.rs
vendored
545
vendor/codex/codex-rs/core-plugins/src/manifest.rs
vendored
|
|
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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,
|
||||
))
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
656
vendor/codex/codex-rs/core-plugins/src/remote.rs
vendored
656
vendor/codex/codex-rs/core-plugins/src/remote.rs
vendored
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
@ -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())
|
||||
}
|
||||
341
vendor/codex/codex-rs/core-plugins/src/store.rs
vendored
341
vendor/codex/codex-rs/core-plugins/src/store.rs
vendored
|
|
@ -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;
|
||||
|
|
@ -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`"
|
||||
);
|
||||
}
|
||||
100
vendor/codex/codex-rs/core-plugins/src/toggles.rs
vendored
100
vendor/codex/codex-rs/core-plugins/src/toggles.rs
vendored
|
|
@ -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)])
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in a new issue