mirror of
https://github.com/openai/codex.git
synced 2026-09-06 15:29:32 +00:00
## Why Agent Plugins use a schema-declared root `plugin.json` and can have dotted names or versions that do not fit Codex's directory-safe version format. The packaging and installation paths still assumed the legacy manifest layout and identifier rules. ## What changed - Recognize valid root Agent Plugin manifests when discovering, packing, and installing plugins, while leaving unrelated root manifests on the legacy path. - Accept safe dotted plugin names, default missing Agent Plugin versions to `1.0.0`, and derive stable directory-safe versions when necessary without rewriting the portable manifest. - Skip legacy command migration for Agent Plugins and reject symlinks or other unsupported file types while copying plugin sources. ## Testing Add coverage for portable bundle round trips, manifest discovery, dotted names, version handling, command preservation, and symlink rejection. GitOrigin-RevId: 61476c4c4100495842253d8b429c0b896490962d
84 lines
2.8 KiB
Rust
84 lines
2.8 KiB
Rust
//! Stable plugin identifier parsing and validation shared with the plugin cache.
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum PluginIdError {
|
|
#[error("{0}")]
|
|
Invalid(String),
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub struct PluginId {
|
|
pub plugin_name: String,
|
|
pub marketplace_name: String,
|
|
}
|
|
|
|
impl PluginId {
|
|
pub fn new(plugin_name: String, marketplace_name: String) -> Result<Self, PluginIdError> {
|
|
validate_plugin_segment(&plugin_name, "plugin name").map_err(PluginIdError::Invalid)?;
|
|
validate_plugin_segment(&marketplace_name, "marketplace name")
|
|
.map_err(PluginIdError::Invalid)?;
|
|
Ok(Self {
|
|
plugin_name,
|
|
marketplace_name,
|
|
})
|
|
}
|
|
|
|
pub fn parse(plugin_key: &str) -> Result<Self, PluginIdError> {
|
|
let Some((plugin_name, marketplace_name)) = plugin_key.rsplit_once('@') else {
|
|
return Err(PluginIdError::Invalid(format!(
|
|
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
|
|
)));
|
|
};
|
|
if plugin_name.is_empty() || marketplace_name.is_empty() {
|
|
return Err(PluginIdError::Invalid(format!(
|
|
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
|
|
)));
|
|
}
|
|
|
|
Self::new(plugin_name.to_string(), marketplace_name.to_string()).map_err(|err| match err {
|
|
PluginIdError::Invalid(message) => {
|
|
PluginIdError::Invalid(format!("{message} in `{plugin_key}`"))
|
|
}
|
|
})
|
|
}
|
|
|
|
pub fn as_key(&self) -> String {
|
|
format!("{}@{}", self.plugin_name, self.marketplace_name)
|
|
}
|
|
}
|
|
|
|
/// Validates a single path segment used in plugin IDs and cache layout.
|
|
pub fn validate_plugin_segment(segment: &str, kind: &str) -> Result<(), String> {
|
|
if segment.is_empty() {
|
|
return Err(format!("invalid {kind}: must not be empty"));
|
|
}
|
|
let allow_dots = kind == "plugin name";
|
|
if allow_dots && matches!(segment, "." | "..") {
|
|
return Err(format!("invalid {kind}: path traversal is not allowed"));
|
|
}
|
|
if allow_dots && (segment.starts_with('.') || segment.ends_with('.') || segment.contains(".."))
|
|
{
|
|
return Err(format!(
|
|
"invalid {kind}: dots must separate non-empty name segments"
|
|
));
|
|
}
|
|
if !segment
|
|
.chars()
|
|
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') || allow_dots && ch == '.')
|
|
{
|
|
let allowed_characters = if allow_dots {
|
|
"ASCII letters, digits, `.`, `_`, and `-`"
|
|
} else {
|
|
"ASCII letters, digits, `_`, and `-`"
|
|
};
|
|
return Err(format!(
|
|
"invalid {kind}: only {allowed_characters} are allowed"
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
#[path = "plugin_id_tests.rs"]
|
|
mod tests;
|