mirror of
https://github.com/openai/codex.git
synced 2026-10-20 17:38:45 +00:00
Enforce Agent Plugin runtime boundaries (#37027)
## What changed - Track Agent Plugin manifests through plugin, skill, and MCP loading so their capabilities use format-specific behavior without changing legacy plugins. - Discover only direct-child skills, exclude app and hook capabilities, isolate MCP data, and reject MCP configuration files that are non-regular or resolve outside the plugin root. - Bound model-visible skill instructions, plugin instructions, MCP descriptions, schemas, individual tools, and the aggregate Agent Plugin MCP tool set. - Stop MCP and OAuth redirects when Agent Plugins send configured or authorization headers, while retaining existing redirect behavior for legacy MCP servers. ## Testing - Add coverage for capability filtering, skill discovery, isolated MCP data and reserved-path expansion, unsafe MCP configuration files, context limits, and redirect handling. GitOrigin-RevId: c9af66b051269f3226628ca280a58d32c808c38f
This commit is contained in:
@@ -3,10 +3,11 @@ use crate::app_mcp_routing::apps_route_available;
|
||||
use crate::command_migration::migrated_command_skills_root;
|
||||
use crate::is_openai_curated_marketplace_name;
|
||||
use crate::manifest::PluginManifest;
|
||||
use crate::manifest::PluginManifestFormat;
|
||||
use crate::manifest::PluginManifestHooks;
|
||||
use crate::manifest::PluginManifestMcpServers;
|
||||
use crate::manifest::PluginManifestPaths;
|
||||
use crate::manifest::load_plugin_manifest;
|
||||
use crate::manifest::load_plugin_manifest_with_format;
|
||||
use crate::marketplace::MarketplacePluginSource;
|
||||
use crate::marketplace::find_marketplace_plugin;
|
||||
use crate::marketplace::list_marketplaces_with_home;
|
||||
@@ -23,6 +24,7 @@ use crate::store::plugin_version_for_source_with_fallback_manifest;
|
||||
use codex_config::ConfigLayerStack;
|
||||
use codex_config::HooksFile;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_config::types::PluginConfig;
|
||||
use codex_config::types::PluginMcpServerConfig;
|
||||
use codex_connectors::parse_plugin_app_config;
|
||||
@@ -33,6 +35,7 @@ use codex_core_skills::config_rules::skill_config_rules_from_stack;
|
||||
use codex_core_skills::loader::SkillRoot;
|
||||
use codex_core_skills::loader::load_skills_from_roots;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_mcp::parse_agent_plugin_mcp_config;
|
||||
use codex_mcp::parse_plugin_mcp_config;
|
||||
use codex_plugin::AppDeclaration;
|
||||
use codex_plugin::LoadedPlugin;
|
||||
@@ -831,6 +834,7 @@ async fn load_plugin(
|
||||
root,
|
||||
enabled: plugin.enabled,
|
||||
skill_roots: Vec::new(),
|
||||
skill_discovery_mode: SkillDiscoveryMode::Recursive,
|
||||
disabled_skill_paths: HashSet::new(),
|
||||
has_enabled_skills: false,
|
||||
mcp_servers: HashMap::new(),
|
||||
@@ -873,12 +877,19 @@ async fn load_plugin(
|
||||
return loaded_plugin;
|
||||
}
|
||||
|
||||
let Some(manifest) = load_plugin_manifest(plugin_root.as_path()) else {
|
||||
let Some(loaded_manifest) = load_plugin_manifest_with_format(plugin_root.as_path()) else {
|
||||
loaded_plugin.error = Some("missing or invalid plugin.json".to_string());
|
||||
return loaded_plugin;
|
||||
};
|
||||
loaded_plugin.skill_discovery_mode = match loaded_manifest.format {
|
||||
PluginManifestFormat::Legacy => SkillDiscoveryMode::Recursive,
|
||||
PluginManifestFormat::AgentPlugin => SkillDiscoveryMode::DirectChildren,
|
||||
};
|
||||
let manifest = loaded_manifest.manifest;
|
||||
|
||||
let manifest_paths = &manifest.paths;
|
||||
let plugin_data_root = store.plugin_data_root(&loaded_plugin_id);
|
||||
let mcp_plugin_data_root = store.mcp_data_root(&loaded_plugin_id, loaded_manifest.format);
|
||||
loaded_plugin.plugin_namespace = Some(manifest.name.clone());
|
||||
match scope {
|
||||
PluginLoadScope::AllCapabilities {
|
||||
@@ -890,40 +901,51 @@ async fn load_plugin(
|
||||
} => {
|
||||
loaded_plugin.manifest_name = Some(manifest.display_name().to_string());
|
||||
loaded_plugin.manifest_description = manifest.description.clone();
|
||||
loaded_plugin.skill_roots = plugin_skill_roots(&plugin_root, manifest_paths);
|
||||
loaded_plugin.skill_roots =
|
||||
plugin_skill_roots(&plugin_root, manifest_paths, loaded_manifest.format);
|
||||
let plugin_identity = PluginIdentity {
|
||||
plugin_id: loaded_plugin_id.as_key(),
|
||||
remote_plugin_id: loaded_plugin.remote_plugin_id.clone(),
|
||||
};
|
||||
let resolved_skills = load_plugin_skills_with_identity(
|
||||
let resolved_skills = load_plugin_skill_inventory(
|
||||
&plugin_root,
|
||||
&plugin_identity,
|
||||
&manifest,
|
||||
loaded_manifest.format,
|
||||
*restriction_product,
|
||||
skill_config_rules,
|
||||
*plugin_skill_snapshots,
|
||||
Arc::clone(root_scan_slots),
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
.resolve(skill_config_rules);
|
||||
let has_enabled_skills = resolved_skills.has_enabled_skills();
|
||||
loaded_plugin.disabled_skill_paths = resolved_skills.disabled_skill_paths;
|
||||
loaded_plugin.has_enabled_skills = has_enabled_skills;
|
||||
loaded_plugin.mcp_servers = load_plugin_mcp_servers_from_manifest(
|
||||
loaded_plugin.mcp_servers = load_plugin_mcp_servers_from_manifest_with_format(
|
||||
plugin_root.as_path(),
|
||||
manifest_paths,
|
||||
Some(&plugin.mcp_servers),
|
||||
Some(mcp_plugin_data_root.as_path()),
|
||||
loaded_manifest.format,
|
||||
)
|
||||
.await;
|
||||
loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await;
|
||||
if loaded_manifest.format == PluginManifestFormat::Legacy {
|
||||
loaded_plugin.apps = load_plugin_apps(plugin_root.as_path()).await;
|
||||
}
|
||||
}
|
||||
PluginLoadScope::HooksOnly => {}
|
||||
}
|
||||
let (hook_sources, hook_load_warnings) = load_plugin_hooks(
|
||||
&plugin_root,
|
||||
&loaded_plugin_id,
|
||||
&store.plugin_data_root(&loaded_plugin_id),
|
||||
manifest_paths,
|
||||
);
|
||||
let (hook_sources, hook_load_warnings) =
|
||||
if loaded_manifest.format == PluginManifestFormat::AgentPlugin {
|
||||
(Vec::new(), Vec::new())
|
||||
} else {
|
||||
load_plugin_hooks(
|
||||
&plugin_root,
|
||||
&loaded_plugin_id,
|
||||
&plugin_data_root,
|
||||
manifest_paths,
|
||||
)
|
||||
};
|
||||
loaded_plugin.hook_sources = hook_sources;
|
||||
loaded_plugin.hook_load_warnings = hook_load_warnings;
|
||||
loaded_plugin
|
||||
@@ -961,7 +983,7 @@ impl PluginSkillInventory {
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve(self, skill_config_rules: &SkillConfigRules) -> ResolvedPluginSkills {
|
||||
pub(crate) fn resolve(self, skill_config_rules: &SkillConfigRules) -> ResolvedPluginSkills {
|
||||
let disabled_skill_paths = resolve_disabled_skill_paths(&self.skills, skill_config_rules);
|
||||
ResolvedPluginSkills {
|
||||
skills: self.skills,
|
||||
@@ -1031,6 +1053,7 @@ pub(crate) async fn load_plugin_skills_with_identity(
|
||||
plugin_root,
|
||||
plugin_identity,
|
||||
manifest,
|
||||
PluginManifestFormat::Legacy,
|
||||
restriction_product,
|
||||
plugin_skill_snapshots,
|
||||
root_scan_slots,
|
||||
@@ -1043,11 +1066,16 @@ pub(crate) async fn load_plugin_skill_inventory(
|
||||
plugin_root: &AbsolutePathBuf,
|
||||
plugin_identity: &PluginIdentity,
|
||||
manifest: &PluginManifest,
|
||||
manifest_format: PluginManifestFormat,
|
||||
restriction_product: Option<Product>,
|
||||
plugin_skill_snapshots: Option<&PluginSkillSnapshots>,
|
||||
root_scan_slots: Arc<Semaphore>,
|
||||
) -> PluginSkillInventory {
|
||||
let roots = plugin_skill_roots(plugin_root, &manifest.paths)
|
||||
let discovery_mode = match manifest_format {
|
||||
PluginManifestFormat::Legacy => SkillDiscoveryMode::Recursive,
|
||||
PluginManifestFormat::AgentPlugin => SkillDiscoveryMode::DirectChildren,
|
||||
};
|
||||
let roots = plugin_skill_roots(plugin_root, &manifest.paths, manifest_format)
|
||||
.into_iter()
|
||||
.map(|path| SkillRoot {
|
||||
path,
|
||||
@@ -1056,7 +1084,7 @@ pub(crate) async fn load_plugin_skill_inventory(
|
||||
plugin_identity: Some(plugin_identity.clone()),
|
||||
plugin_namespace: Some(manifest.name.clone()),
|
||||
plugin_root: Some(plugin_root.clone()),
|
||||
discovery_mode: SkillDiscoveryMode::Recursive,
|
||||
discovery_mode,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let outcome = load_skills_from_roots(roots, plugin_skill_snapshots, root_scan_slots).await;
|
||||
@@ -1098,15 +1126,18 @@ pub(crate) async fn load_plugin_skill_inventory(
|
||||
fn plugin_skill_roots(
|
||||
plugin_root: &AbsolutePathBuf,
|
||||
manifest_paths: &PluginManifestPaths,
|
||||
manifest_format: PluginManifestFormat,
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
let mut paths = if manifest_paths.skills.is_empty() {
|
||||
default_skill_roots(plugin_root)
|
||||
} else {
|
||||
manifest_paths.skills.clone()
|
||||
};
|
||||
let migrated_command_skills = migrated_command_skills_root(plugin_root);
|
||||
if migrated_command_skills.is_dir() {
|
||||
paths.push(migrated_command_skills);
|
||||
if manifest_format == PluginManifestFormat::Legacy {
|
||||
let migrated_command_skills = migrated_command_skills_root(plugin_root);
|
||||
if migrated_command_skills.is_dir() {
|
||||
paths.push(migrated_command_skills);
|
||||
}
|
||||
}
|
||||
paths.sort_unstable();
|
||||
paths.dedup();
|
||||
@@ -1146,8 +1177,11 @@ fn default_mcp_config_paths(plugin_root: &Path) -> Vec<AbsolutePathBuf> {
|
||||
}
|
||||
|
||||
pub async fn load_plugin_apps(plugin_root: &Path) -> Vec<AppDeclaration> {
|
||||
if let Some(manifest) = load_plugin_manifest(plugin_root) {
|
||||
return load_plugin_apps_from_manifest(plugin_root, &manifest.paths).await;
|
||||
if let Some(loaded_manifest) = load_plugin_manifest_with_format(plugin_root) {
|
||||
if loaded_manifest.format == PluginManifestFormat::AgentPlugin {
|
||||
return Vec::new();
|
||||
}
|
||||
return load_plugin_apps_from_manifest(plugin_root, &loaded_manifest.manifest.paths).await;
|
||||
}
|
||||
load_apps_from_paths(plugin_root, default_app_config_paths(plugin_root)).await
|
||||
}
|
||||
@@ -1345,14 +1379,40 @@ pub async fn plugin_capability_summary_from_root(
|
||||
plugin_id: &PluginId,
|
||||
plugin_root: &AbsolutePathBuf,
|
||||
) -> Option<PluginCapabilitySummary> {
|
||||
let manifest = load_plugin_manifest(plugin_root.as_path())?;
|
||||
let loaded_manifest = load_plugin_manifest_with_format(plugin_root.as_path())?;
|
||||
let manifest_format = loaded_manifest.format;
|
||||
let manifest = loaded_manifest.manifest;
|
||||
let plugin_identity = PluginIdentity {
|
||||
plugin_id: plugin_id.as_key(),
|
||||
remote_plugin_id: None,
|
||||
};
|
||||
|
||||
let manifest_paths = &manifest.paths;
|
||||
let has_skills = !plugin_skill_roots(plugin_root, manifest_paths).is_empty();
|
||||
let mut mcp_server_names = load_plugin_mcp_servers_from_manifest(
|
||||
let has_skills = match manifest_format {
|
||||
PluginManifestFormat::Legacy => {
|
||||
!plugin_skill_roots(plugin_root, manifest_paths, manifest_format).is_empty()
|
||||
}
|
||||
PluginManifestFormat::AgentPlugin => {
|
||||
!load_plugin_skill_inventory(
|
||||
plugin_root,
|
||||
&plugin_identity,
|
||||
&manifest,
|
||||
manifest_format,
|
||||
/*restriction_product*/ None,
|
||||
/*plugin_skill_snapshots*/ None,
|
||||
Arc::new(Semaphore::new(1)),
|
||||
)
|
||||
.await
|
||||
.skills
|
||||
.is_empty()
|
||||
}
|
||||
};
|
||||
let mut mcp_server_names = load_plugin_mcp_servers_from_manifest_with_format(
|
||||
plugin_root.as_path(),
|
||||
manifest_paths,
|
||||
/*plugin_policy*/ None,
|
||||
/*plugin_data_root*/ None,
|
||||
manifest_format,
|
||||
)
|
||||
.await
|
||||
.into_keys()
|
||||
@@ -1360,16 +1420,17 @@ pub async fn plugin_capability_summary_from_root(
|
||||
mcp_server_names.sort_unstable();
|
||||
mcp_server_names.dedup();
|
||||
|
||||
let app_declarations = load_apps_from_paths(
|
||||
plugin_root.as_path(),
|
||||
plugin_app_config_paths(plugin_root.as_path(), manifest_paths),
|
||||
)
|
||||
.await;
|
||||
let app_declarations = if manifest_format == PluginManifestFormat::AgentPlugin {
|
||||
Vec::new()
|
||||
} else {
|
||||
load_plugin_apps_from_manifest(plugin_root.as_path(), manifest_paths).await
|
||||
};
|
||||
let app_connector_ids = app_connector_ids_from_declarations(&app_declarations);
|
||||
|
||||
Some(PluginCapabilitySummary {
|
||||
config_name: plugin_id.as_key(),
|
||||
display_name: plugin_id.plugin_name.clone(),
|
||||
plugin_namespace: Some(manifest.name.clone()),
|
||||
description: None,
|
||||
has_skills,
|
||||
mcp_server_names,
|
||||
@@ -1426,17 +1487,26 @@ async fn load_declared_plugin_mcp_servers(
|
||||
plugin_root: &Path,
|
||||
plugin_policy: Option<&HashMap<String, PluginMcpServerConfig>>,
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
let Some(manifest) = load_plugin_manifest(plugin_root) else {
|
||||
let Some(loaded_manifest) = load_plugin_manifest_with_format(plugin_root) else {
|
||||
return HashMap::new();
|
||||
};
|
||||
|
||||
load_plugin_mcp_servers_from_manifest(plugin_root, &manifest.paths, plugin_policy).await
|
||||
load_plugin_mcp_servers_from_manifest_with_format(
|
||||
plugin_root,
|
||||
&loaded_manifest.manifest.paths,
|
||||
plugin_policy,
|
||||
/*plugin_data_root*/ None,
|
||||
loaded_manifest.format,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn load_plugin_mcp_servers_from_manifest(
|
||||
pub(crate) async fn load_plugin_mcp_servers_from_manifest_with_format(
|
||||
plugin_root: &Path,
|
||||
manifest_paths: &PluginManifestPaths,
|
||||
plugin_policy: Option<&HashMap<String, PluginMcpServerConfig>>,
|
||||
plugin_data_root: Option<&Path>,
|
||||
manifest_format: PluginManifestFormat,
|
||||
) -> HashMap<String, McpServerConfig> {
|
||||
let mut mcp_servers = HashMap::new();
|
||||
match &manifest_paths.mcp_servers {
|
||||
@@ -1457,7 +1527,13 @@ pub(crate) async fn load_plugin_mcp_servers_from_manifest(
|
||||
}
|
||||
Some(PluginManifestMcpServers::Path(_)) | None => {
|
||||
for mcp_config_path in plugin_mcp_config_paths(plugin_root, manifest_paths) {
|
||||
let plugin_mcp = load_mcp_servers_from_file(plugin_root, &mcp_config_path).await;
|
||||
let plugin_mcp = load_mcp_servers_from_file(
|
||||
plugin_root,
|
||||
plugin_data_root,
|
||||
manifest_format,
|
||||
&mcp_config_path,
|
||||
)
|
||||
.await;
|
||||
for (name, mut config) in plugin_mcp.mcp_servers {
|
||||
if let Some(policy) = plugin_policy.and_then(|policy| policy.get(&name)) {
|
||||
apply_plugin_mcp_server_policy(&mut config, policy);
|
||||
@@ -1480,12 +1556,74 @@ pub(crate) async fn load_plugin_mcp_servers_from_manifest(
|
||||
|
||||
async fn load_mcp_servers_from_file(
|
||||
plugin_root: &Path,
|
||||
plugin_data_root: Option<&Path>,
|
||||
manifest_format: PluginManifestFormat,
|
||||
mcp_config_path: &AbsolutePathBuf,
|
||||
) -> PluginMcpDiscovery {
|
||||
let is_agent_plugin_mcp = manifest_format == PluginManifestFormat::AgentPlugin;
|
||||
if is_agent_plugin_mcp {
|
||||
match tokio::fs::symlink_metadata(mcp_config_path.as_path()).await {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
|
||||
warn!(
|
||||
path = %mcp_config_path.display(),
|
||||
"Agent Plugins MCP config is not a regular file; disabling MCP"
|
||||
);
|
||||
return PluginMcpDiscovery::default();
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
return PluginMcpDiscovery::default();
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
path = %mcp_config_path.display(),
|
||||
"failed to inspect Agent Plugins MCP config; disabling MCP: {err}"
|
||||
);
|
||||
return PluginMcpDiscovery::default();
|
||||
}
|
||||
}
|
||||
let resolved_root = match tokio::fs::canonicalize(plugin_root).await {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
plugin = %plugin_root.display(),
|
||||
"failed to resolve Agent Plugins root; disabling MCP: {err}"
|
||||
);
|
||||
return PluginMcpDiscovery::default();
|
||||
}
|
||||
};
|
||||
let resolved_config = match tokio::fs::canonicalize(mcp_config_path.as_path()).await {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
path = %mcp_config_path.display(),
|
||||
"failed to resolve Agent Plugins MCP config; disabling MCP: {err}"
|
||||
);
|
||||
return PluginMcpDiscovery::default();
|
||||
}
|
||||
};
|
||||
if !resolved_config.starts_with(&resolved_root) {
|
||||
warn!(
|
||||
plugin = %plugin_root.display(),
|
||||
path = %mcp_config_path.display(),
|
||||
"Agent Plugins MCP config resolves outside the plugin root; disabling MCP"
|
||||
);
|
||||
return PluginMcpDiscovery::default();
|
||||
}
|
||||
}
|
||||
let Ok(contents) = tokio::fs::read_to_string(mcp_config_path.as_path()).await else {
|
||||
return PluginMcpDiscovery::default();
|
||||
};
|
||||
let parsed = match parse_plugin_mcp_config(plugin_root, &contents) {
|
||||
let fallback_data_root = plugin_root.join(".plugin-data");
|
||||
let mut parsed = match if is_agent_plugin_mcp {
|
||||
parse_agent_plugin_mcp_config(
|
||||
plugin_root,
|
||||
plugin_data_root.unwrap_or(&fallback_data_root),
|
||||
&contents,
|
||||
)
|
||||
} else {
|
||||
parse_plugin_mcp_config(plugin_root, &contents)
|
||||
} {
|
||||
Ok(parsed) => parsed,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
@@ -1495,6 +1633,23 @@ async fn load_mcp_servers_from_file(
|
||||
return PluginMcpDiscovery::default();
|
||||
}
|
||||
};
|
||||
if is_agent_plugin_mcp
|
||||
&& let Some(plugin_data_root) = plugin_data_root
|
||||
&& parsed
|
||||
.servers
|
||||
.values()
|
||||
.any(|server| matches!(&server.transport, McpServerTransportConfig::Stdio { .. }))
|
||||
&& let Err(err) = tokio::fs::create_dir_all(plugin_data_root).await
|
||||
{
|
||||
warn!(
|
||||
plugin = %plugin_root.display(),
|
||||
path = %plugin_data_root.display(),
|
||||
"failed to create Agent Plugins data directory; disabling stdio MCP servers: {err}"
|
||||
);
|
||||
parsed.servers.retain(|_, server| {
|
||||
!matches!(&server.transport, McpServerTransportConfig::Stdio { .. })
|
||||
});
|
||||
}
|
||||
for error in parsed.errors {
|
||||
warn!(
|
||||
plugin = %plugin_root.display(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
use crate::manifest::load_plugin_manifest;
|
||||
use crate::manifest::load_plugin_manifest_with_format;
|
||||
use crate::test_support::write_file;
|
||||
use codex_config::ConfigLayerEntry;
|
||||
use codex_config::ConfigLayerSource;
|
||||
@@ -7,6 +8,7 @@ use codex_config::ConfigRequirements;
|
||||
use codex_config::ConfigRequirementsToml;
|
||||
use codex_core_skills::loader::MAX_CONCURRENT_ROOT_SCANS;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_utils_plugins::AGENT_PLUGIN_SCHEMA_URI;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
@@ -25,6 +27,177 @@ fn user_layer(path: AbsolutePathBuf, config: &str) -> ConfigLayerEntry {
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_plugin_overlay_apps_are_not_runtime_active() {
|
||||
let temp_dir = TempDir::new().expect("tempdir");
|
||||
let plugin_root = temp_dir.path().join("plugin");
|
||||
write_file(
|
||||
&plugin_root.join("plugin.json"),
|
||||
&format!(r#"{{"$schema":"{AGENT_PLUGIN_SCHEMA_URI}","name":"plugin"}}"#),
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"plugin","apps":"./.app.json"}"#,
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join(".app.json"),
|
||||
r#"{"apps":{"example":{"id":"connector_example"}}}"#,
|
||||
);
|
||||
|
||||
assert!(load_plugin_apps(&plugin_root).await.is_empty());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn agent_plugin_mcp_rejects_config_symlink_outside_plugin_root() {
|
||||
let temp_dir = TempDir::new().expect("tempdir");
|
||||
let plugin_root = temp_dir.path().join("plugin");
|
||||
let outside_config = temp_dir.path().join("outside-mcp.json");
|
||||
fs::create_dir_all(&plugin_root).expect("create plugin root");
|
||||
fs::write(
|
||||
plugin_root.join("plugin.json"),
|
||||
format!(r#"{{"$schema":"{AGENT_PLUGIN_SCHEMA_URI}","name":"plugin"}}"#),
|
||||
)
|
||||
.expect("write Agent Plugins manifest");
|
||||
fs::write(
|
||||
&outside_config,
|
||||
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"outside":{"type":"stdio","command":"echo"}}}"#,
|
||||
)
|
||||
.expect("write outside MCP config");
|
||||
std::os::unix::fs::symlink(&outside_config, plugin_root.join("mcp.json"))
|
||||
.expect("create MCP symlink");
|
||||
let config_path = AbsolutePathBuf::from_absolute_path(plugin_root.join("mcp.json"))
|
||||
.expect("absolute MCP path");
|
||||
|
||||
let discovered = load_mcp_servers_from_file(
|
||||
&plugin_root,
|
||||
/*plugin_data_root*/ None,
|
||||
PluginManifestFormat::AgentPlugin,
|
||||
&config_path,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(discovered.mcp_servers.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_plugin_mcp_rejects_present_nonregular_config() {
|
||||
let temp_dir = TempDir::new().expect("tempdir");
|
||||
let plugin_root = temp_dir.path().join("plugin");
|
||||
let config_path = plugin_root.join("mcp.json");
|
||||
fs::create_dir_all(&config_path).expect("create nonregular MCP config");
|
||||
|
||||
let discovered = load_mcp_servers_from_file(
|
||||
&plugin_root,
|
||||
/*plugin_data_root*/ None,
|
||||
PluginManifestFormat::AgentPlugin,
|
||||
&AbsolutePathBuf::from_absolute_path(config_path).expect("absolute MCP path"),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(discovered.mcp_servers.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn legacy_manifest_can_point_at_root_mcp_json() {
|
||||
let temp_dir = TempDir::new().expect("tempdir");
|
||||
let plugin_root = temp_dir.path().join("plugin");
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest directory");
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"plugin","mcpServers":"./mcp.json"}"#,
|
||||
)
|
||||
.expect("write legacy manifest");
|
||||
fs::write(
|
||||
plugin_root.join("mcp.json"),
|
||||
r#"{"mcpServers":{"legacy":{"command":"echo"}}}"#,
|
||||
)
|
||||
.expect("write legacy MCP config");
|
||||
let manifest = load_plugin_manifest(&plugin_root).expect("load legacy manifest");
|
||||
|
||||
let discovered = load_plugin_mcp_servers_from_manifest_with_format(
|
||||
&plugin_root,
|
||||
&manifest.paths,
|
||||
/*plugin_policy*/ None,
|
||||
/*plugin_data_root*/ None,
|
||||
PluginManifestFormat::Legacy,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
discovered.keys().collect::<Vec<_>>(),
|
||||
vec![&"legacy".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn installed_agent_plugin_uses_isolated_data_root_for_stdio_mcp() {
|
||||
let temp_dir = TempDir::new().expect("tempdir");
|
||||
let plugin_root = temp_dir.path().join("plugins/cache/c/a-b/local");
|
||||
write_file(
|
||||
&plugin_root.join("plugin.json"),
|
||||
&format!(r#"{{"$schema":"{AGENT_PLUGIN_SCHEMA_URI}","name":"a-b"}}"#),
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join("mcp.json"),
|
||||
r#"{
|
||||
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
|
||||
"mcpServers": {
|
||||
"example": {
|
||||
"type": "stdio",
|
||||
"command": "echo"
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
);
|
||||
let stack = ConfigLayerStack::new(
|
||||
vec![user_layer(
|
||||
user_config_path(&temp_dir, "config.toml"),
|
||||
"[plugins.\"a-b@c\"]\nenabled = true\n",
|
||||
)],
|
||||
ConfigRequirements::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("valid config layer stack");
|
||||
let store = PluginStore::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let plugins = load_plugins_from_layer_stack(
|
||||
&stack,
|
||||
RemoteInstalledPluginsSnapshot::default(),
|
||||
&store,
|
||||
/*plugin_skill_snapshots*/ None,
|
||||
Some(Product::Codex),
|
||||
/*remote_global_catalog_active*/ false,
|
||||
Arc::new(Semaphore::new(MAX_CONCURRENT_ROOT_SCANS)),
|
||||
)
|
||||
.await;
|
||||
|
||||
let expected_data_root = temp_dir
|
||||
.path()
|
||||
.join("plugins")
|
||||
.join("data")
|
||||
.join("agent-plugins")
|
||||
.join("6920dd17774030852d11d1b94758fcaae4f894c7b2f36301ed174bc3b33e0743");
|
||||
let expected_data_root = AbsolutePathBuf::from_absolute_path(expected_data_root)
|
||||
.expect("absolute Agent Plugin data root")
|
||||
.canonicalize()
|
||||
.expect("canonical Agent Plugin data root");
|
||||
let server = plugins
|
||||
.first()
|
||||
.and_then(|plugin| plugin.mcp_servers.get("example"))
|
||||
.expect("Agent plugin stdio MCP server");
|
||||
let McpServerTransportConfig::Stdio { env, .. } = &server.transport else {
|
||||
panic!("expected stdio MCP server");
|
||||
};
|
||||
assert_eq!(
|
||||
env.as_ref()
|
||||
.and_then(|env| env.get("PLUGIN_DATA"))
|
||||
.map(String::as_str),
|
||||
expected_data_root.as_path().to_str()
|
||||
);
|
||||
assert!(expected_data_root.as_path().is_dir());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn configured_plugins_from_stack_merges_user_layers() {
|
||||
let temp_dir = TempDir::new().expect("tempdir");
|
||||
@@ -265,7 +438,8 @@ fn write_hook_file(plugin_root: &AbsolutePathBuf, relative_path: &str, event: &s
|
||||
}
|
||||
|
||||
fn load_sources(plugin_root: &AbsolutePathBuf) -> (Vec<PluginHookSource>, Vec<String>) {
|
||||
let manifest = load_plugin_manifest(plugin_root.as_path()).expect("manifest");
|
||||
let loaded_manifest =
|
||||
load_plugin_manifest_with_format(plugin_root.as_path()).expect("manifest");
|
||||
let plugin_data_root = AbsolutePathBuf::try_from(
|
||||
plugin_root
|
||||
.as_path()
|
||||
@@ -278,7 +452,7 @@ fn load_sources(plugin_root: &AbsolutePathBuf) -> (Vec<PluginHookSource>, Vec<St
|
||||
plugin_root,
|
||||
&plugin_id(),
|
||||
&plugin_data_root,
|
||||
&manifest.paths,
|
||||
&loaded_manifest.manifest.paths,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ use crate::loader::curated_plugin_cache_version;
|
||||
use crate::loader::load_plugin_apps_from_manifest;
|
||||
use crate::loader::load_plugin_hooks;
|
||||
use crate::loader::load_plugin_hooks_from_layer_stack;
|
||||
use crate::loader::load_plugin_mcp_servers_from_manifest;
|
||||
use crate::loader::load_plugin_skills_with_identity;
|
||||
use crate::loader::load_plugin_mcp_servers_from_manifest_with_format;
|
||||
use crate::loader::load_plugin_skill_inventory;
|
||||
use crate::loader::load_plugins_from_layer_stack;
|
||||
use crate::loader::log_plugin_load_errors;
|
||||
use crate::loader::materialize_marketplace_plugin_source;
|
||||
@@ -21,8 +21,10 @@ use crate::loader::refresh_curated_plugin_cache;
|
||||
use crate::loader::refresh_non_curated_plugin_cache_detailed;
|
||||
use crate::loader::refresh_non_curated_plugin_cache_force_reinstall_detailed;
|
||||
use crate::loader::remote_installed_plugins_to_config;
|
||||
use crate::manifest::PluginManifestFormat;
|
||||
use crate::manifest::PluginManifestInterface;
|
||||
use crate::manifest::load_plugin_manifest;
|
||||
use crate::manifest::load_plugin_manifest_with_format;
|
||||
use crate::marketplace::MarketplaceError;
|
||||
use crate::marketplace::MarketplaceInterface;
|
||||
use crate::marketplace::MarketplaceListError;
|
||||
@@ -416,6 +418,7 @@ impl From<PluginDetail> for PluginCapabilitySummary {
|
||||
Self {
|
||||
config_name: value.id,
|
||||
display_name: value.name,
|
||||
plugin_namespace: None,
|
||||
description: prompt_safe_plugin_description(value.description.as_deref()),
|
||||
has_skills,
|
||||
mcp_server_names: value.mcp_server_names,
|
||||
@@ -1997,18 +2000,24 @@ impl PluginsManager {
|
||||
"path does not exist or is not a directory".to_string(),
|
||||
));
|
||||
}
|
||||
let manifest =
|
||||
let loaded_manifest =
|
||||
if codex_utils_plugins::find_plugin_manifest_path(source_path.as_path()).is_some() {
|
||||
load_plugin_manifest(source_path.as_path())
|
||||
load_plugin_manifest_with_format(source_path.as_path())
|
||||
} else {
|
||||
plugin
|
||||
.manifest_fallback
|
||||
.as_ref()
|
||||
.and_then(|fallback| fallback.parse_for_plugin_root(source_path.as_path()))
|
||||
.map(|manifest| crate::manifest::LoadedPluginManifest {
|
||||
manifest,
|
||||
format: PluginManifestFormat::Legacy,
|
||||
})
|
||||
}
|
||||
.ok_or_else(|| {
|
||||
MarketplaceError::InvalidPlugin("missing or invalid plugin.json".to_string())
|
||||
})?;
|
||||
let manifest_format = loaded_manifest.format;
|
||||
let manifest = loaded_manifest.manifest;
|
||||
let description = manifest.description.clone();
|
||||
let marketplace_category = plugin
|
||||
.interface
|
||||
@@ -2022,21 +2031,27 @@ impl PluginsManager {
|
||||
plugin_id: plugin_id.as_key(),
|
||||
remote_plugin_id: self.remote_plugin_id_for(&plugin_id),
|
||||
};
|
||||
let resolved_skills = load_plugin_skills_with_identity(
|
||||
let skill_config_rules = codex_core_skills::config_rules::skill_config_rules_from_stack(
|
||||
&config.config_layer_stack,
|
||||
);
|
||||
let resolved_skills = load_plugin_skill_inventory(
|
||||
&source_path,
|
||||
&plugin_identity,
|
||||
&manifest,
|
||||
manifest_format,
|
||||
self.restriction_product,
|
||||
&codex_core_skills::config_rules::skill_config_rules_from_stack(
|
||||
&config.config_layer_stack,
|
||||
),
|
||||
/*plugin_skill_snapshots*/ None,
|
||||
Arc::clone(&self.skill_root_scan_slots),
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
.resolve(&skill_config_rules);
|
||||
let plugin_data_root = self.store.plugin_data_root(&plugin_id);
|
||||
let (hook_sources, _hook_load_warnings) =
|
||||
load_plugin_hooks(&source_path, &plugin_id, &plugin_data_root, &manifest.paths);
|
||||
let (hook_sources, _hook_load_warnings) = if manifest_format == PluginManifestFormat::Legacy
|
||||
{
|
||||
load_plugin_hooks(&source_path, &plugin_id, &plugin_data_root, &manifest.paths)
|
||||
} else {
|
||||
(Vec::new(), Vec::new())
|
||||
};
|
||||
let hooks = plugin_hook_declarations(&hook_sources)
|
||||
.into_iter()
|
||||
.map(|hook| PluginHookSummary {
|
||||
@@ -2045,15 +2060,22 @@ impl PluginsManager {
|
||||
})
|
||||
.collect();
|
||||
let auth_mode = self.auth_mode();
|
||||
let mut app_declarations =
|
||||
load_plugin_apps_from_manifest(source_path.as_path(), &manifest.paths).await;
|
||||
let mut mcp_servers = load_plugin_mcp_servers_from_manifest(
|
||||
let mut app_declarations = if manifest_format == PluginManifestFormat::Legacy {
|
||||
load_plugin_apps_from_manifest(source_path.as_path(), &manifest.paths).await
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let mcp_data_root = (manifest_format == PluginManifestFormat::AgentPlugin)
|
||||
.then(|| self.store.mcp_data_root(&plugin_id, manifest_format));
|
||||
let mut mcp_servers = load_plugin_mcp_servers_from_manifest_with_format(
|
||||
source_path.as_path(),
|
||||
&manifest.paths,
|
||||
/*plugin_policy*/ None,
|
||||
mcp_data_root.as_deref(),
|
||||
manifest_format,
|
||||
)
|
||||
.await;
|
||||
if auth_mode.is_some() {
|
||||
if manifest_format == PluginManifestFormat::Legacy && auth_mode.is_some() {
|
||||
apply_app_mcp_routing_policy(
|
||||
&mut app_declarations,
|
||||
&mut mcp_servers,
|
||||
|
||||
@@ -52,6 +52,7 @@ use codex_skills_extension::HostSkillsLoadInput;
|
||||
use codex_skills_extension::HostSkillsService;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use codex_utils_plugins::SkillDiscoveryMode;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -675,6 +676,7 @@ async fn plugin_auth_projection_reprojects_cached_plugins_when_auth_changes() {
|
||||
PluginCapabilitySummary {
|
||||
config_name: "docs@test".to_string(),
|
||||
display_name: "docs".to_string(),
|
||||
plugin_namespace: Some("docs".to_string()),
|
||||
description: None,
|
||||
has_skills: false,
|
||||
mcp_server_names: vec!["docs".to_string()],
|
||||
@@ -683,6 +685,7 @@ async fn plugin_auth_projection_reprojects_cached_plugins_when_auth_changes() {
|
||||
PluginCapabilitySummary {
|
||||
config_name: "sample@test".to_string(),
|
||||
display_name: "sample".to_string(),
|
||||
plugin_namespace: Some("sample".to_string()),
|
||||
description: None,
|
||||
has_skills: false,
|
||||
mcp_server_names: Vec::new(),
|
||||
@@ -705,6 +708,7 @@ async fn plugin_auth_projection_reprojects_cached_plugins_when_auth_changes() {
|
||||
PluginCapabilitySummary {
|
||||
config_name: "docs@test".to_string(),
|
||||
display_name: "docs".to_string(),
|
||||
plugin_namespace: Some("docs".to_string()),
|
||||
description: None,
|
||||
has_skills: false,
|
||||
mcp_server_names: vec!["docs".to_string()],
|
||||
@@ -713,6 +717,7 @@ async fn plugin_auth_projection_reprojects_cached_plugins_when_auth_changes() {
|
||||
PluginCapabilitySummary {
|
||||
config_name: "sample@test".to_string(),
|
||||
display_name: "sample".to_string(),
|
||||
plugin_namespace: Some("sample".to_string()),
|
||||
description: None,
|
||||
has_skills: false,
|
||||
mcp_server_names: vec!["sample".to_string()],
|
||||
@@ -739,7 +744,11 @@ fn write_plugin_with_version(
|
||||
format!(r#"{{"name":"{manifest_name}"{version}}}"#),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(plugin_root.join("skills/SKILL.md"), "skill").unwrap();
|
||||
fs::write(
|
||||
plugin_root.join("skills/SKILL.md"),
|
||||
format!("---\nname: {manifest_name}-skill\ndescription: test skill\n---\n\n# Test skill\n"),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap();
|
||||
}
|
||||
|
||||
@@ -931,6 +940,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
|
||||
root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(),
|
||||
enabled: true,
|
||||
skill_roots: vec![plugin_root.join("skills").abs()],
|
||||
skill_discovery_mode: SkillDiscoveryMode::Recursive,
|
||||
disabled_skill_paths: HashSet::new(),
|
||||
has_enabled_skills: true,
|
||||
mcp_servers: HashMap::from([(
|
||||
@@ -973,6 +983,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
|
||||
&[PluginCapabilitySummary {
|
||||
config_name: "sample@test".to_string(),
|
||||
display_name: "sample".to_string(),
|
||||
plugin_namespace: Some("sample".to_string()),
|
||||
description: Some("Plugin that includes the sample MCP server and Skills".to_string(),),
|
||||
has_skills: true,
|
||||
mcp_server_names: vec!["sample".to_string()],
|
||||
@@ -1232,6 +1243,7 @@ async fn installed_plugin_telemetry_metadata_collects_capabilities() {
|
||||
capability_summary: Some(PluginCapabilitySummary {
|
||||
config_name: "sample@test".to_string(),
|
||||
display_name: "sample".to_string(),
|
||||
plugin_namespace: Some("sample".to_string()),
|
||||
description: None,
|
||||
has_skills: true,
|
||||
mcp_server_names: Vec::new(),
|
||||
@@ -1264,6 +1276,7 @@ async fn installed_plugin_telemetry_metadata_resolves_persisted_remote_identity(
|
||||
capability_summary: Some(PluginCapabilitySummary {
|
||||
config_name: "linear@openai-curated-remote".to_string(),
|
||||
display_name: "linear".to_string(),
|
||||
plugin_namespace: Some("linear".to_string()),
|
||||
description: None,
|
||||
has_skills: true,
|
||||
mcp_server_names: Vec::new(),
|
||||
@@ -1317,6 +1330,7 @@ async fn installed_plugin_telemetry_metadata_prefers_remote_snapshot_identity()
|
||||
capability_summary: Some(PluginCapabilitySummary {
|
||||
config_name: "linear@openai-curated-remote".to_string(),
|
||||
display_name: "linear".to_string(),
|
||||
plugin_namespace: Some("linear".to_string()),
|
||||
description: None,
|
||||
has_skills: true,
|
||||
mcp_server_names: Vec::new(),
|
||||
@@ -1354,6 +1368,7 @@ fn capability_summary_telemetry_metadata_uses_local_identity() {
|
||||
let summary = PluginCapabilitySummary {
|
||||
config_name: "linear@openai-curated-remote".to_string(),
|
||||
display_name: "Linear".to_string(),
|
||||
plugin_namespace: Some("linear".to_string()),
|
||||
description: Some("Track work".to_string()),
|
||||
has_skills: true,
|
||||
mcp_server_names: vec!["linear".to_string()],
|
||||
@@ -1387,6 +1402,7 @@ fn capability_summary_telemetry_metadata_resolves_persisted_remote_identity() {
|
||||
let summary = PluginCapabilitySummary {
|
||||
config_name: "linear@openai-curated-remote".to_string(),
|
||||
display_name: "Linear".to_string(),
|
||||
plugin_namespace: Some("linear".to_string()),
|
||||
description: Some("Track work".to_string()),
|
||||
has_skills: true,
|
||||
mcp_server_names: vec!["linear".to_string()],
|
||||
@@ -1772,6 +1788,7 @@ enabled = true
|
||||
&[PluginCapabilitySummary {
|
||||
config_name: "sample@test".to_string(),
|
||||
display_name: "sample".to_string(),
|
||||
plugin_namespace: Some("sample".to_string()),
|
||||
description: None,
|
||||
has_skills: true,
|
||||
mcp_server_names: Vec::new(),
|
||||
@@ -1817,6 +1834,7 @@ async fn plugin_telemetry_metadata_uses_default_mcp_config_path() {
|
||||
Some(PluginCapabilitySummary {
|
||||
config_name: "sample@test".to_string(),
|
||||
display_name: "sample".to_string(),
|
||||
plugin_namespace: Some("sample".to_string()),
|
||||
description: None,
|
||||
has_skills: false,
|
||||
mcp_server_names: vec!["sample".to_string()],
|
||||
@@ -1858,6 +1876,7 @@ async fn plugin_capability_summary_uses_manifest_mcp_server_objects() {
|
||||
Some(PluginCapabilitySummary {
|
||||
config_name: "counter-sample@test".to_string(),
|
||||
display_name: "counter-sample".to_string(),
|
||||
plugin_namespace: Some("counter-sample".to_string()),
|
||||
description: None,
|
||||
has_skills: false,
|
||||
mcp_server_names: vec!["counter".to_string()],
|
||||
@@ -2273,7 +2292,6 @@ async fn load_plugin_skills_dedupes_overlapping_manifest_roots() {
|
||||
interface: None,
|
||||
};
|
||||
let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse");
|
||||
|
||||
let resolved = load_plugin_skills(
|
||||
&plugin_root,
|
||||
&plugin_id,
|
||||
@@ -2502,6 +2520,7 @@ async fn load_plugins_preserves_disabled_plugins_without_effective_contributions
|
||||
root: AbsolutePathBuf::try_from(plugin_root).unwrap(),
|
||||
enabled: false,
|
||||
skill_roots: Vec::new(),
|
||||
skill_discovery_mode: SkillDiscoveryMode::Recursive,
|
||||
disabled_skill_paths: HashSet::new(),
|
||||
has_enabled_skills: false,
|
||||
mcp_servers: HashMap::new(),
|
||||
@@ -2679,6 +2698,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
|
||||
root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(),
|
||||
enabled: true,
|
||||
skill_roots: Vec::new(),
|
||||
skill_discovery_mode: SkillDiscoveryMode::Recursive,
|
||||
disabled_skill_paths: HashSet::new(),
|
||||
has_enabled_skills: false,
|
||||
mcp_servers: HashMap::new(),
|
||||
@@ -2690,6 +2710,12 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
|
||||
let summary = |config_name: &str, display_name: &str| PluginCapabilitySummary {
|
||||
config_name: config_name.to_string(),
|
||||
display_name: display_name.to_string(),
|
||||
plugin_namespace: Some(
|
||||
config_name
|
||||
.split_once('@')
|
||||
.map_or(config_name, |(name, _)| name)
|
||||
.to_string(),
|
||||
),
|
||||
description: None,
|
||||
..PluginCapabilitySummary::default()
|
||||
};
|
||||
@@ -3985,6 +4011,96 @@ plugins = true
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_plugin_read_and_tool_suggestions_use_portable_capabilities_only() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let plugin_root = repo_root.join("agent-plugin");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
write_file(
|
||||
&repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{"name":"debug","plugins":[{"name":"agent-plugin","source":"./agent-plugin"}]}"#,
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join("plugin.json"),
|
||||
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/plugin.schema.json","name":"agent.tools"}"#,
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join("skills/direct/SKILL.md"),
|
||||
"---\nname: direct\ndescription: Direct skill\n---\n",
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join("skills/group/nested/SKILL.md"),
|
||||
"---\nname: nested\ndescription: Nested skill\n---\n",
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join("mcp.json"),
|
||||
r#"{"$schema":"https://agent-plugins.org/schemas/1.0.0/mcp.schema.json","mcpServers":{"portable":{"type":"stdio","command":"echo"}}}"#,
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"apps":"./.app.json","hooks":"./hooks/hooks.json"}"#,
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join(".app.json"),
|
||||
r#"{"apps":{"legacy":{"id":"connector_legacy"}}}"#,
|
||||
);
|
||||
write_file(
|
||||
&plugin_root.join("hooks/hooks.json"),
|
||||
r#"{"hooks":{"SessionStart":[{"hooks":[{"type":"command","command":"echo legacy"}]}]}}"#,
|
||||
);
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
"[features]\nplugins = true\n",
|
||||
);
|
||||
|
||||
let config = load_config(tmp.path(), &repo_root).await;
|
||||
let manager = PluginsManager::new(tmp.path().to_path_buf());
|
||||
let plugin = manager
|
||||
.list_marketplaces_for_config(
|
||||
&config,
|
||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||
/*include_openai_curated*/ false,
|
||||
)
|
||||
.unwrap()
|
||||
.marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| marketplace.name == "debug")
|
||||
.unwrap()
|
||||
.plugins
|
||||
.into_iter()
|
||||
.find(|plugin| plugin.name == "agent-plugin")
|
||||
.unwrap();
|
||||
let detail = manager
|
||||
.read_plugin_detail_for_marketplace_plugin(&config, "debug", plugin.clone())
|
||||
.await
|
||||
.unwrap();
|
||||
let suggestion = manager
|
||||
.tool_suggest_metadata_for_marketplace_plugin(
|
||||
"debug",
|
||||
&plugin,
|
||||
&SkillConfigRules::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
detail
|
||||
.skills
|
||||
.iter()
|
||||
.map(|skill| skill.name.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["agent.tools:direct"]
|
||||
);
|
||||
assert_eq!(detail.mcp_server_names, vec!["portable"]);
|
||||
assert!(detail.apps.is_empty());
|
||||
assert!(detail.hooks.is_empty());
|
||||
assert!(suggestion.has_skills);
|
||||
assert_eq!(suggestion.mcp_server_names, vec!["portable"]);
|
||||
assert!(suggestion.app_connector_ids.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_plugin_for_config_does_not_fallback_from_invalid_plugin_manifest() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -31,6 +31,17 @@ pub type PluginManifestPaths = codex_plugin::manifest::PluginManifestPaths<Absol
|
||||
|
||||
pub type UriPluginManifest = codex_plugin::manifest::PluginManifest<PathUri>;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum PluginManifestFormat {
|
||||
Legacy,
|
||||
AgentPlugin,
|
||||
}
|
||||
|
||||
pub(crate) struct LoadedPluginManifest {
|
||||
pub manifest: PluginManifest,
|
||||
pub format: PluginManifestFormat,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawPluginManifest {
|
||||
@@ -143,6 +154,15 @@ enum RawPluginManifestHooks {
|
||||
|
||||
/// Loads a plugin manifest from the local host filesystem.
|
||||
pub fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
|
||||
load_plugin_manifest_with_format(plugin_root).map(|loaded| loaded.manifest)
|
||||
}
|
||||
|
||||
pub fn is_agent_plugin_manifest(plugin_root: &Path) -> bool {
|
||||
load_plugin_manifest_with_format(plugin_root)
|
||||
.is_some_and(|loaded| loaded.format == PluginManifestFormat::AgentPlugin)
|
||||
}
|
||||
|
||||
pub(crate) fn load_plugin_manifest_with_format(plugin_root: &Path) -> Option<LoadedPluginManifest> {
|
||||
let manifest_path = find_plugin_manifest_path(plugin_root)?;
|
||||
let contents = fs::read_to_string(&manifest_path).ok()?;
|
||||
let is_agent_plugin = manifest_path == plugin_root.join(AGENT_PLUGIN_MANIFEST_RELATIVE_PATH);
|
||||
@@ -162,7 +182,14 @@ pub fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
|
||||
.as_ref()
|
||||
.map(|(path, contents)| (path.as_path(), contents.as_str())),
|
||||
) {
|
||||
Ok(manifest) => Some(manifest),
|
||||
Ok(manifest) => Some(LoadedPluginManifest {
|
||||
manifest,
|
||||
format: if is_agent_plugin {
|
||||
PluginManifestFormat::AgentPlugin
|
||||
} else {
|
||||
PluginManifestFormat::Legacy
|
||||
},
|
||||
}),
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
path = %manifest_path.display(),
|
||||
|
||||
@@ -1524,6 +1524,7 @@ pub async fn resolve_remote_plugin_uninstall_target(
|
||||
let fallback_capability_summary = PluginCapabilitySummary {
|
||||
config_name: plugin_id.as_key(),
|
||||
display_name: plugin.release.display_name,
|
||||
plugin_namespace: Some(plugin_id.plugin_name.clone()),
|
||||
description: prompt_safe_plugin_description(Some(&plugin.release.description)),
|
||||
has_skills: !plugin.release.skills.is_empty(),
|
||||
mcp_server_names,
|
||||
|
||||
@@ -10,6 +10,7 @@ use crate::test_support::write_curated_plugin_sha_with;
|
||||
use crate::test_support::write_openai_api_curated_marketplace;
|
||||
use crate::test_support::write_openai_curated_marketplace;
|
||||
use codex_plugin::PluginLoadOutcome;
|
||||
use codex_utils_plugins::SkillDiscoveryMode;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
@@ -30,6 +31,7 @@ fn loaded_plugin(config_name: &str, root: &Path, enabled: bool) -> LoadedPlugin
|
||||
root: path(root),
|
||||
enabled,
|
||||
skill_roots: Vec::new(),
|
||||
skill_discovery_mode: SkillDiscoveryMode::Recursive,
|
||||
disabled_skill_paths: HashSet::new(),
|
||||
has_enabled_skills: false,
|
||||
mcp_servers: HashMap::new(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::command_migration::migrate_plugin_commands;
|
||||
use crate::manifest::PluginManifest;
|
||||
use crate::manifest::PluginManifestFormat;
|
||||
use crate::manifest::load_plugin_manifest;
|
||||
use crate::manifest::parse_plugin_manifest;
|
||||
use codex_plugin::PluginId;
|
||||
@@ -24,6 +25,7 @@ use std::path::PathBuf;
|
||||
pub const DEFAULT_PLUGIN_VERSION: &str = "local";
|
||||
pub const PLUGINS_CACHE_DIR: &str = "plugins/cache";
|
||||
pub const PLUGINS_DATA_DIR: &str = "plugins/data";
|
||||
const AGENT_PLUGINS_DATA_DIR: &str = "agent-plugins";
|
||||
const REMOTE_PLUGIN_INSTALL_METADATA_FILE: &str = ".codex-remote-plugin-install.json";
|
||||
const REMOTE_PLUGIN_INSTALL_METADATA_SCHEMA_VERSION: u8 = 1;
|
||||
const DEFAULT_AGENT_PLUGIN_VERSION: &str = "1.0.0";
|
||||
@@ -143,6 +145,27 @@ impl PluginStore {
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn agent_plugin_data_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf {
|
||||
let mut digest = Sha256::new();
|
||||
digest.update(plugin_id.marketplace_name.as_bytes());
|
||||
digest.update([0]);
|
||||
digest.update(plugin_id.plugin_name.as_bytes());
|
||||
self.data_root
|
||||
.join(AGENT_PLUGINS_DATA_DIR)
|
||||
.join(hex_prefix(&digest.finalize(), /*count*/ 32))
|
||||
}
|
||||
|
||||
pub(crate) fn mcp_data_root(
|
||||
&self,
|
||||
plugin_id: &PluginId,
|
||||
manifest_format: PluginManifestFormat,
|
||||
) -> AbsolutePathBuf {
|
||||
match manifest_format {
|
||||
PluginManifestFormat::AgentPlugin => self.agent_plugin_data_root(plugin_id),
|
||||
PluginManifestFormat::Legacy => self.plugin_data_root(plugin_id),
|
||||
}
|
||||
}
|
||||
|
||||
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()?
|
||||
|
||||
@@ -162,6 +162,28 @@ fn plugin_data_root_derives_path_from_key() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_plugin_data_root_is_stable_and_unambiguous() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let store = PluginStore::new(tmp.path().to_path_buf());
|
||||
let first = PluginId::new("a-b".to_string(), "c".to_string()).unwrap();
|
||||
let second = PluginId::new("a".to_string(), "b-c".to_string()).unwrap();
|
||||
|
||||
let first_root = store.agent_plugin_data_root(&first);
|
||||
let second_root = store.agent_plugin_data_root(&second);
|
||||
let expected_parent = tmp.path().join("plugins/data/agent-plugins");
|
||||
|
||||
assert_ne!(first_root, second_root);
|
||||
assert_eq!(
|
||||
first_root.as_path(),
|
||||
expected_parent.join("6920dd17774030852d11d1b94758fcaae4f894c7b2f36301ed174bc3b33e0743")
|
||||
);
|
||||
assert_eq!(
|
||||
second_root.as_path(),
|
||||
expected_parent.join("fa89b988ebbe54a68fdcbeb87fb913a5238d482084a3cee49a86288c2d45fa90")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_with_version_uses_requested_cache_version() {
|
||||
let tmp = tempdir().unwrap();
|
||||
|
||||
@@ -21,7 +21,8 @@ use crate::loader::load_plugin_mcp_servers;
|
||||
use crate::loader::load_plugin_skill_inventory;
|
||||
use crate::manager::ConfiguredMarketplacePlugin;
|
||||
use crate::manager::remote_plugin_install_required_description;
|
||||
use crate::manifest::load_plugin_manifest;
|
||||
use crate::manifest::PluginManifestFormat;
|
||||
use crate::manifest::load_plugin_manifest_with_format;
|
||||
use crate::marketplace::MarketplaceError;
|
||||
use crate::marketplace::MarketplacePluginSource;
|
||||
|
||||
@@ -53,6 +54,7 @@ struct PluginArtifactIdentity {
|
||||
pub(crate) struct ToolSuggestMetadataFragment {
|
||||
config_name: String,
|
||||
display_name: String,
|
||||
plugin_namespace: Option<String>,
|
||||
description: Option<String>,
|
||||
mcp_server_names: Vec<String>,
|
||||
app_declarations: Vec<AppDeclaration>,
|
||||
@@ -86,6 +88,7 @@ impl ToolSuggestMetadataFragment {
|
||||
PluginCapabilitySummary {
|
||||
config_name: self.config_name.clone(),
|
||||
display_name: self.display_name.clone(),
|
||||
plugin_namespace: self.plugin_namespace.clone(),
|
||||
description: self.description.clone(),
|
||||
has_skills: self
|
||||
.skill_inventory
|
||||
@@ -206,6 +209,7 @@ async fn load_plugin_metadata(
|
||||
return Ok(Arc::new(ToolSuggestMetadataFragment {
|
||||
config_name: plugin.id.clone(),
|
||||
display_name: plugin.name.clone(),
|
||||
plugin_namespace: Some(plugin.name.clone()),
|
||||
description: prompt_safe_plugin_description(Some(
|
||||
&remote_plugin_install_required_description(&plugin.source),
|
||||
)),
|
||||
@@ -217,16 +221,18 @@ async fn load_plugin_metadata(
|
||||
if !plugin_root.as_path().is_dir() {
|
||||
return Err("path does not exist or is not a directory".to_string());
|
||||
}
|
||||
let manifest = load_plugin_manifest(plugin_root.as_path())
|
||||
let loaded_manifest = load_plugin_manifest_with_format(plugin_root.as_path())
|
||||
.ok_or_else(|| "missing or invalid plugin.json".to_string())?;
|
||||
let plugin_identity = PluginIdentity {
|
||||
plugin_id: plugin_id.as_key(),
|
||||
remote_plugin_id: None,
|
||||
};
|
||||
let manifest = loaded_manifest.manifest;
|
||||
let skill_inventory = load_plugin_skill_inventory(
|
||||
plugin_root,
|
||||
&plugin_identity,
|
||||
&manifest,
|
||||
loaded_manifest.format,
|
||||
restriction_product,
|
||||
/*plugin_skill_snapshots*/ None,
|
||||
root_scan_slots,
|
||||
@@ -239,11 +245,16 @@ async fn load_plugin_metadata(
|
||||
.collect::<Vec<_>>();
|
||||
mcp_server_names.sort_unstable();
|
||||
mcp_server_names.dedup();
|
||||
let app_declarations = load_plugin_apps(plugin_root.as_path()).await;
|
||||
let app_declarations = if loaded_manifest.format == PluginManifestFormat::AgentPlugin {
|
||||
Vec::new()
|
||||
} else {
|
||||
load_plugin_apps(plugin_root.as_path()).await
|
||||
};
|
||||
|
||||
Ok(Arc::new(ToolSuggestMetadataFragment {
|
||||
config_name: plugin.id.clone(),
|
||||
display_name: plugin.name.clone(),
|
||||
plugin_namespace: Some(manifest.name.clone()),
|
||||
description: prompt_safe_plugin_description(manifest.description.as_deref()),
|
||||
mcp_server_names,
|
||||
app_declarations,
|
||||
|
||||
Reference in New Issue
Block a user