From 63d51ea5924d1c5102b041821acaea6839bcc6ce Mon Sep 17 00:00:00 2001 From: charlesgong-openai Date: Thu, 18 Jun 2026 03:41:28 +0000 Subject: [PATCH] Support plugin agent roles --- codex-rs/core-plugins/src/loader.rs | 25 +++ codex-rs/core-plugins/src/loader_tests.rs | 77 +++++++ codex-rs/core-plugins/src/manager_tests.rs | 3 + codex-rs/core-plugins/src/manifest.rs | 4 + codex-rs/core/src/config/agent_roles.rs | 77 +++++++ codex-rs/core/src/config/config_tests.rs | 57 +++++ codex-rs/core/src/session/turn_context.rs | 24 +- codex-rs/core/tests/suite/plugins.rs | 62 +++++ .../mcp/src/executor_plugin/provider_tests.rs | 1 + codex-rs/plugin/src/load_outcome.rs | 47 ++++ codex-rs/plugin/src/manifest.rs | 6 + codex-rs/plugin/src/provider_tests.rs | 4 + .../references/plugin-json-spec.md | 6 +- .../scripts/create_basic_plugin.py | 17 +- .../plugin-creator/scripts/validate_plugin.py | 2 + codex-rs/utils/plugins/src/lib.rs | 7 + scripts/reset_claude_import_targets.py | 211 ++++++++++++++++++ 17 files changed, 626 insertions(+), 4 deletions(-) create mode 100644 scripts/reset_claude_import_targets.py diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index 2b5c4782e8..635b027fbf 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -57,6 +57,7 @@ use tracing::instrument; use tracing::warn; const DEFAULT_SKILLS_DIR_NAME: &str = "skills"; +const DEFAULT_AGENTS_DIR_NAME: &str = "agents"; const DEFAULT_HOOKS_CONFIG_FILE: &str = "hooks/hooks.json"; const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json"; const DEFAULT_APP_CONFIG_FILE: &str = ".app.json"; @@ -764,6 +765,7 @@ async fn load_plugin( manifest_description: None, root, enabled: plugin.enabled, + agent_roots: Vec::new(), skill_roots: Vec::new(), disabled_skill_paths: HashSet::new(), has_enabled_skills: false, @@ -812,6 +814,7 @@ async fn load_plugin( } => { loaded_plugin.manifest_name = Some(manifest.display_name().to_string()); loaded_plugin.manifest_description = manifest.description.clone(); + loaded_plugin.agent_roots = plugin_agent_roots(&plugin_root, manifest_paths); loaded_plugin.skill_roots = plugin_skill_roots(&plugin_root, manifest_paths); let resolved_skills = load_plugin_skills( &plugin_root, @@ -972,6 +975,28 @@ fn plugin_skill_roots( paths } +fn plugin_agent_roots( + plugin_root: &AbsolutePathBuf, + manifest_paths: &PluginManifestPaths, +) -> Vec { + let mut paths = default_agent_roots(plugin_root); + if let Some(path) = &manifest_paths.agents { + paths.push(path.clone()); + } + paths.sort_unstable(); + paths.dedup(); + paths +} + +fn default_agent_roots(plugin_root: &AbsolutePathBuf) -> Vec { + let agents_dir = plugin_root.join(DEFAULT_AGENTS_DIR_NAME); + if agents_dir.is_dir() { + vec![agents_dir] + } else { + Vec::new() + } +} + fn default_skill_roots(plugin_root: &AbsolutePathBuf) -> Vec { let skills_dir = plugin_root.join(DEFAULT_SKILLS_DIR_NAME); if skills_dir.is_dir() { diff --git a/codex-rs/core-plugins/src/loader_tests.rs b/codex-rs/core-plugins/src/loader_tests.rs index 8e5a76b4bf..a7c54c272d 100644 --- a/codex-rs/core-plugins/src/loader_tests.rs +++ b/codex-rs/core-plugins/src/loader_tests.rs @@ -6,6 +6,9 @@ use codex_config::ConfigLayerSource; use codex_config::ConfigRequirements; use codex_config::ConfigRequirementsToml; use codex_plugin::PluginId; +use codex_plugin::PluginLoadOutcome; +use codex_utils_absolute_path::test_support::PathBufExt; +use codex_utils_plugins::PluginAgentRoot; use pretty_assertions::assert_eq; use tempfile::TempDir; @@ -66,6 +69,80 @@ fn configured_plugins_from_stack_merges_user_layers() { ); } +#[tokio::test] +async fn load_plugins_discovers_default_and_manifest_agent_roots() { + let temp_dir = TempDir::new().expect("tempdir"); + let plugin_root = temp_dir.path().join("plugins/cache/test/agented/local"); + write_file( + &plugin_root.join(".codex-plugin/plugin.json"), + r#"{"name":"agented","agents":"./extra-agents"}"#, + ); + write_file( + &plugin_root.join("agents/researcher.toml"), + r#"name = "researcher" +description = "Research role" +developer_instructions = "Research carefully" +"#, + ); + write_file( + &plugin_root.join("extra-agents/reviewer.toml"), + r#"name = "reviewer" +description = "Review role" +developer_instructions = "Review carefully" +"#, + ); + + let stack = ConfigLayerStack::new( + vec![user_layer( + user_config_path(&temp_dir, "config.toml"), + r#" +[plugins."agented@test"] +enabled = true +"#, + )], + 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, + HashMap::new(), + &store, + Some(Product::Codex), + /*prefer_remote_curated_conflicts*/ false, + ) + .await; + + let plugin = plugins + .iter() + .find(|plugin| plugin.config_name == "agented@test") + .expect("agented plugin should load"); + assert_eq!( + plugin.agent_roots, + vec![ + plugin_root.join("agents").abs(), + plugin_root.join("extra-agents").abs(), + ] + ); + assert_eq!( + PluginLoadOutcome::from_plugins(plugins).effective_plugin_agent_roots(), + vec![ + PluginAgentRoot { + path: plugin_root.join("agents").abs(), + plugin_id: "agented@test".to_string(), + plugin_root: plugin_root.abs(), + }, + PluginAgentRoot { + path: plugin_root.join("extra-agents").abs(), + plugin_id: "agented@test".to_string(), + plugin_root: plugin_root.abs(), + }, + ] + ); +} + #[tokio::test] async fn hooks_only_scope_shares_plugin_resolution_without_loading_other_capabilities() { let temp_dir = TempDir::new().expect("tempdir"); diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index 5e743416f2..ed70e8b6d5 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -864,6 +864,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() { ), root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(), enabled: true, + agent_roots: Vec::new(), skill_roots: vec![plugin_root.join("skills").abs()], disabled_skill_paths: HashSet::new(), has_enabled_skills: true, @@ -2145,6 +2146,7 @@ async fn load_plugins_preserves_disabled_plugins_without_effective_contributions manifest_description: None, root: AbsolutePathBuf::try_from(plugin_root).unwrap(), enabled: false, + agent_roots: Vec::new(), skill_roots: Vec::new(), disabled_skill_paths: HashSet::new(), has_enabled_skills: false, @@ -2320,6 +2322,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { manifest_description: None, root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(), enabled: true, + agent_roots: Vec::new(), skill_roots: Vec::new(), disabled_skill_paths: HashSet::new(), has_enabled_skills: false, diff --git a/codex-rs/core-plugins/src/manifest.rs b/codex-rs/core-plugins/src/manifest.rs index 0edebe3699..ebd0b7a0b7 100644 --- a/codex-rs/core-plugins/src/manifest.rs +++ b/codex-rs/core-plugins/src/manifest.rs @@ -33,6 +33,8 @@ struct RawPluginManifest { // Keep manifest paths as raw strings so we can validate the required `./...` syntax before // resolving them under the plugin root. #[serde(default)] + agents: Option, + #[serde(default)] skills: Option, #[serde(default)] mcp_servers: Option, @@ -162,6 +164,7 @@ pub(crate) fn parse_plugin_manifest_uri( version, description, keywords, + agents, skills, mcp_servers, apps, @@ -258,6 +261,7 @@ pub(crate) fn parse_plugin_manifest_uri( description, keywords, paths: codex_plugin::manifest::PluginManifestPaths { + agents: resolve_manifest_paths(plugin_root, "agents", agents.as_ref()), skills: resolve_manifest_paths(plugin_root, "skills", skills.as_ref()), mcp_servers: resolve_manifest_mcp_servers(plugin_root, mcp_servers), apps: resolve_manifest_path(plugin_root, "apps", apps.as_deref()), diff --git a/codex-rs/core/src/config/agent_roles.rs b/codex-rs/core/src/config/agent_roles.rs index 483cd3a98d..54243e335c 100644 --- a/codex-rs/core/src/config/agent_roles.rs +++ b/codex-rs/core/src/config/agent_roles.rs @@ -8,6 +8,8 @@ use codex_exec_server::ExecutorFileSystem; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_absolute_path::AbsolutePathBufGuard; use codex_utils_path_uri::PathUri; +use codex_utils_plugins::PluginAgentRoot; +use codex_utils_plugins::plugin_namespace_for_skill_path; use serde::Deserialize; use std::collections::BTreeMap; use std::collections::BTreeSet; @@ -115,6 +117,81 @@ pub(crate) async fn load_agent_roles( Ok(roles) } +pub(crate) async fn load_plugin_agent_roles( + fs: &dyn ExecutorFileSystem, + plugin_agent_roots: Vec, + startup_warnings: &mut Vec, +) -> std::io::Result> { + let mut roles = BTreeMap::new(); + for plugin_agent_root in plugin_agent_roots { + let discovered_roles = discover_agent_roles_in_dir( + fs, + &plugin_agent_root.path, + &BTreeSet::new(), + startup_warnings, + ) + .await?; + if discovered_roles.is_empty() { + continue; + } + let Some(plugin_namespace) = plugin_agent_role_namespace(fs, &plugin_agent_root).await + else { + push_agent_role_warning( + startup_warnings, + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "unable to determine plugin namespace for agent roles in {}", + plugin_agent_root.path.as_path().display() + ), + ), + ); + continue; + }; + + for (role_name, role) in discovered_roles { + let namespaced_role_name = format!("{plugin_namespace}:{role_name}"); + if roles.contains_key(&namespaced_role_name) { + push_agent_role_warning( + startup_warnings, + std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "duplicate plugin agent role name `{namespaced_role_name}` discovered in {}", + plugin_agent_root.path.as_path().display() + ), + ), + ); + continue; + } + if let Err(err) = validate_required_agent_role_description( + &namespaced_role_name, + role.description.as_deref(), + ) { + push_agent_role_warning(startup_warnings, err); + continue; + } + roles.insert(namespaced_role_name, role); + } + } + + Ok(roles) +} + +async fn plugin_agent_role_namespace( + fs: &dyn ExecutorFileSystem, + plugin_agent_root: &PluginAgentRoot, +) -> Option { + plugin_namespace_for_skill_path(fs, &plugin_agent_root.path) + .await + .or_else(|| { + plugin_agent_root + .plugin_id + .split_once('@') + .map(|(name, _)| name.to_string()) + }) +} + fn push_agent_role_warning(startup_warnings: &mut Vec, err: std::io::Error) { let message = format!("Ignoring malformed agent role definition: {err}"); tracing::warn!("{message}"); diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 0c8d0899a4..8e192b1d1a 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -95,6 +95,7 @@ use codex_protocol::protocol::NetworkAccess; use codex_protocol::protocol::RealtimeVoice; use codex_protocol::protocol::SandboxPolicy; use codex_utils_path_uri::LegacyAppPathString; +use codex_utils_plugins::PluginAgentRoot; use serde::Deserialize; use tempfile::tempdir; @@ -7464,6 +7465,62 @@ nickname_candidates = ["Noether"] Ok(()) } +#[tokio::test] +async fn plugin_agent_roles_are_namespaced_with_plugin_manifest_name() -> std::io::Result<()> { + let temp_dir = TempDir::new()?; + let plugin_root = temp_dir.path().join("plugins").join("sample"); + let agents_dir = plugin_root.join("agents"); + let agent_path = agents_dir.join("researcher.toml"); + tokio::fs::create_dir_all(plugin_root.join(".codex-plugin")).await?; + tokio::fs::write( + plugin_root.join(".codex-plugin").join("plugin.json"), + r#"{"name":"sample-plugin"}"#, + ) + .await?; + tokio::fs::create_dir_all(&agents_dir).await?; + tokio::fs::write( + &agent_path, + r#" +name = "researcher" +description = "Research role" +nickname_candidates = ["Hypatia"] +developer_instructions = "Research carefully" +model = "gpt-5.2" +"#, + ) + .await?; + + let mut startup_warnings = Vec::new(); + let roles = agent_roles::load_plugin_agent_roles( + LOCAL_FS.as_ref(), + vec![PluginAgentRoot { + path: agents_dir.abs(), + plugin_id: "fallback@test".to_string(), + plugin_root: plugin_root.abs(), + }], + &mut startup_warnings, + ) + .await?; + + assert_eq!(startup_warnings, Vec::::new()); + assert_eq!( + roles.get("sample-plugin:researcher").map(|role| ( + role.description.as_deref(), + role.config_file.as_ref(), + role.nickname_candidates + .as_ref() + .map(|candidates| candidates.iter().map(String::as_str).collect::>()) + )), + Some(( + Some("Research role"), + Some(&agent_path), + Some(vec!["Hypatia"]) + )) + ); + + Ok(()) +} + #[tokio::test] async fn agent_role_file_without_developer_instructions_is_dropped_with_warning() -> std::io::Result<()> { diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index b9bf42073b..07a3cab3e5 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -2,6 +2,7 @@ use super::*; use crate::environment_selection::TurnEnvironmentSnapshot; use crate::shell_snapshot::ShellSnapshotFile; use codex_core_skills::HostSkillsSnapshot; +use codex_exec_server::LOCAL_FS; use codex_file_system::FileSystemSandboxContext; use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; @@ -684,7 +685,7 @@ impl Session { .as_ref() .and_then(|turn_environment| turn_environment.cwd().to_abs_path().ok()) .unwrap_or_else(|| session_configuration.cwd().clone()); - let per_turn_config = Self::build_per_turn_config(&session_configuration, cwd.clone()); + let mut per_turn_config = Self::build_per_turn_config(&session_configuration, cwd.clone()); { let mcp_runtime = self.services.latest_mcp_runtime(); let mcp_connection_manager = mcp_runtime.manager(); @@ -720,6 +721,27 @@ impl Session { .plugins_manager .plugins_for_config(&plugins_input) .await; + let mut plugin_agent_role_warnings = Vec::new(); + match crate::config::agent_roles::load_plugin_agent_roles( + LOCAL_FS.as_ref(), + plugin_outcome.effective_plugin_agent_roots(), + &mut plugin_agent_role_warnings, + ) + .await + { + Ok(plugin_agent_roles) => { + for (role_name, role) in plugin_agent_roles { + per_turn_config.agent_roles.entry(role_name).or_insert(role); + } + } + Err(err) => { + plugin_agent_role_warnings + .push(format!("Failed to load plugin agent roles: {err}")); + } + } + per_turn_config + .startup_warnings + .extend(plugin_agent_role_warnings); let effective_skill_roots = plugin_outcome.effective_plugin_skill_roots(); let plugin_skill_snapshots = self .services diff --git a/codex-rs/core/tests/suite/plugins.rs b/codex-rs/core/tests/suite/plugins.rs index aaf65baad0..f05d252e1b 100644 --- a/codex-rs/core/tests/suite/plugins.rs +++ b/codex-rs/core/tests/suite/plugins.rs @@ -29,6 +29,7 @@ use core_test_support::test_codex::TestCodex; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use core_test_support::wait_for_mcp_server; +use serde_json::Value; use tempfile::TempDir; use wiremock::MockServer; @@ -39,6 +40,8 @@ const SAMPLE_PLUGIN_APP_NAMESPACE: &str = "mcp__codex_apps__google_calendar"; const SAMPLE_PLUGIN_MCP_NAMESPACE: &str = "mcp__sample"; const PLUGIN_APP_SEARCH_CALL_ID: &str = "plugin-app-search"; const PLUGIN_MCP_SEARCH_CALL_ID: &str = "plugin-mcp-search"; +const MULTI_AGENT_V1_NAMESPACE: &str = "multi_agent_v1"; +const SPAWN_AGENT_TOOL_NAME: &str = "spawn_agent"; fn sample_plugin_root(home: &TempDir) -> std::path::PathBuf { home.path().join("plugins/cache/test/sample/local") @@ -76,6 +79,20 @@ fn write_plugin_skill_plugin(home: &TempDir) -> std::path::PathBuf { skill_dir.join("SKILL.md") } +fn write_plugin_agent_plugin(home: &TempDir) { + let plugin_root = write_sample_plugin_manifest_and_config(home); + let agents_dir = plugin_root.join("agents"); + std::fs::create_dir_all(&agents_dir).expect("create plugin agents dir"); + std::fs::write( + agents_dir.join("researcher.toml"), + r#"name = "researcher" +description = "Research sample data" +developer_instructions = "Research the sample data carefully" +"#, + ) + .expect("write plugin agent role"); +} + fn write_plugin_mcp_plugin(home: &TempDir, command: &str) { let plugin_root = write_sample_plugin_manifest_and_config(home); std::fs::write( @@ -204,6 +221,15 @@ fn searched_plugin_tools( ) } +fn tool_parameter_description(tool: &Value, parameter_name: &str) -> Option { + tool.get("parameters") + .and_then(|parameters| parameters.get("properties")) + .and_then(|properties| properties.get(parameter_name)) + .and_then(|parameter| parameter.get("description")) + .and_then(Value::as_str) + .map(str::to_string) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn capability_sections_render_in_developer_message_in_order() -> Result<()> { skip_if_no_network!(Ok(())); @@ -274,6 +300,42 @@ async fn capability_sections_render_in_developer_message_in_order() -> Result<() Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn plugin_agent_roles_are_available_to_spawn_agent() -> Result<()> { + skip_if_no_network!(Ok(())); + let server = start_mock_server().await; + let resp_mock = mount_sse_once( + &server, + sse(vec![ev_response_created("resp1"), ev_completed("resp1")]), + ) + .await; + + let codex_home = Arc::new(TempDir::new()?); + write_plugin_agent_plugin(codex_home.as_ref()); + let mut builder = test_codex().with_home(codex_home).with_config(|config| { + config + .features + .enable(Feature::Collab) + .expect("test config should allow feature update"); + config.multi_agent_v2.hide_spawn_agent_metadata = false; + }); + let test_codex = builder.build(&server).await?; + + test_codex.submit_turn("hello").await?; + + let body = resp_mock.single_request().body_json(); + let spawn_agent = namespace_child_tool(&body, MULTI_AGENT_V1_NAMESPACE, SPAWN_AGENT_TOOL_NAME) + .expect("spawn_agent should be available"); + let agent_type_description = tool_parameter_description(spawn_agent, "agent_type") + .expect("spawn_agent agent_type description"); + assert!( + agent_type_description.contains("sample:researcher: {\nResearch sample data\n}"), + "expected plugin agent role in spawn_agent description: {agent_type_description:?}" + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn explicit_plugin_mentions_use_apps_for_chatgpt_dual_surface_plugins() -> Result<()> { skip_if_no_network!(Ok(())); diff --git a/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs b/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs index bc59e104a0..9a8030d30b 100644 --- a/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs +++ b/codex-rs/ext/mcp/src/executor_plugin/provider_tests.rs @@ -401,6 +401,7 @@ fn resolved_plugin( description: None, keywords: Vec::new(), paths: PluginManifestPaths { + agents: Vec::new(), skills: Vec::new(), mcp_servers, apps: None, diff --git a/codex-rs/plugin/src/load_outcome.rs b/codex-rs/plugin/src/load_outcome.rs index ad83655463..294b56adb8 100644 --- a/codex-rs/plugin/src/load_outcome.rs +++ b/codex-rs/plugin/src/load_outcome.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::collections::HashSet; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_plugins::PluginAgentRoot; use codex_utils_plugins::PluginSkillRoot; use crate::AppConnectorId; @@ -21,6 +22,7 @@ pub struct LoadedPlugin { pub manifest_description: Option, pub root: AbsolutePathBuf, pub enabled: bool, + pub agent_roots: Vec, pub skill_roots: Vec, pub disabled_skill_paths: HashSet, pub has_enabled_skills: bool, @@ -123,6 +125,25 @@ impl PluginLoadOutcome { skill_roots } + pub fn effective_plugin_agent_roots(&self) -> Vec { + let mut agent_roots = Vec::new(); + let mut seen_paths = HashSet::new(); + for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) { + for path in &plugin.agent_roots { + if seen_paths.insert(path.clone()) { + agent_roots.push(PluginAgentRoot { + path: path.clone(), + plugin_id: plugin.config_name.clone(), + plugin_root: plugin.root.clone(), + }); + } + } + } + + agent_roots.sort_unstable_by(|a, b| a.path.cmp(&b.path)); + agent_roots + } + pub fn effective_plugin_skill_roots(&self) -> Vec { let mut skill_roots = Vec::new(); let mut seen_paths = HashSet::new(); @@ -197,6 +218,8 @@ impl PluginLoadOutcome { pub trait EffectiveSkillRoots { fn effective_skill_roots(&self) -> Vec; + fn effective_plugin_agent_roots(&self) -> Vec; + fn effective_plugin_skill_roots(&self) -> Vec; } @@ -205,6 +228,10 @@ impl EffectiveSkillRoots for PluginLoadOutcome { PluginLoadOutcome::effective_skill_roots(self) } + fn effective_plugin_agent_roots(&self) -> Vec { + PluginLoadOutcome::effective_plugin_agent_roots(self) + } + fn effective_plugin_skill_roots(&self) -> Vec { PluginLoadOutcome::effective_plugin_skill_roots(self) } @@ -232,6 +259,7 @@ mod tests { manifest_description: None, root: test_path(config_name), enabled: true, + agent_roots: Vec::new(), skill_roots, disabled_skill_paths: HashSet::new(), has_enabled_skills: true, @@ -261,4 +289,23 @@ mod tests { }] ); } + + #[test] + fn effective_plugin_agent_roots_preserves_first_plugin_for_shared_root() { + let shared_root = test_path("shared-agents"); + let mut zeta = loaded_plugin("zeta@test", Vec::new()); + zeta.agent_roots = vec![shared_root.clone()]; + let mut alpha = loaded_plugin("alpha@test", Vec::new()); + alpha.agent_roots = vec![shared_root.clone()]; + let outcome = PluginLoadOutcome::from_plugins(vec![zeta, alpha]); + + assert_eq!( + outcome.effective_plugin_agent_roots(), + vec![PluginAgentRoot { + path: shared_root, + plugin_id: "zeta@test".to_string(), + plugin_root: test_path("zeta@test"), + }] + ); + } } diff --git a/codex-rs/plugin/src/manifest.rs b/codex-rs/plugin/src/manifest.rs index 89cfd8d0b6..d513d6ff1d 100644 --- a/codex-rs/plugin/src/manifest.rs +++ b/codex-rs/plugin/src/manifest.rs @@ -17,6 +17,7 @@ pub struct PluginManifest { /// Component resources declared by a plugin manifest. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginManifestPaths { + pub agents: Vec, pub skills: Vec, pub mcp_servers: Option>, pub apps: Option, @@ -104,6 +105,7 @@ impl PluginManifest { interface, } = self; let PluginManifestPaths { + agents, skills, mcp_servers, apps, @@ -177,6 +179,10 @@ impl PluginManifest { description, keywords, paths: PluginManifestPaths { + agents: agents + .into_iter() + .map(&mut map) + .collect::, _>>()?, skills: skills .into_iter() .map(&mut map) diff --git a/codex-rs/plugin/src/provider_tests.rs b/codex-rs/plugin/src/provider_tests.rs index 7f9e70d330..c0c538b589 100644 --- a/codex-rs/plugin/src/provider_tests.rs +++ b/codex-rs/plugin/src/provider_tests.rs @@ -30,6 +30,7 @@ fn environment_descriptor_binds_every_manifest_resource() { let root = absolute(std::env::current_dir().expect("cwd").join("plugin-root")); let root_uri = path_uri(&root); let manifest_path = root.join(".codex-plugin/plugin.json"); + let agents = root.join("agents"); let skills = root.join("skills"); let mcp_servers = root.join(".mcp.json"); let apps = root.join(".app.json"); @@ -43,6 +44,7 @@ fn environment_descriptor_binds_every_manifest_resource() { description: None, keywords: Vec::new(), paths: PluginManifestPaths { + agents: vec![path_uri(&agents)], skills: vec![path_uri(&skills)], mcp_servers: Some(PluginManifestMcpServers::Path(path_uri(&mcp_servers))), apps: Some(path_uri(&apps)), @@ -77,6 +79,7 @@ fn environment_descriptor_binds_every_manifest_resource() { description: None, keywords: Vec::new(), paths: PluginManifestPaths { + agents: vec![resource("executor-1", &agents)], skills: vec![resource("executor-1", &skills)], mcp_servers: Some(PluginManifestMcpServers::Path(resource( "executor-1", @@ -109,6 +112,7 @@ fn environment_descriptor_rejects_resources_outside_package_root() { description: None, keywords: Vec::new(), paths: PluginManifestPaths { + agents: Vec::new(), skills: Vec::new(), mcp_servers: Some(PluginManifestMcpServers::Path(path_uri(&outside))), apps: None, diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md index 40d3ae6b54..9566131f19 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md +++ b/codex-rs/skills/src/assets/samples/plugin-creator/references/plugin-json-spec.md @@ -14,6 +14,7 @@ "repository": "https://github.com/author/plugin", "license": "MIT", "keywords": ["keyword1", "keyword2"], + "agents": "./agents/", "skills": "./skills/", "hooks": "./hooks.json", "mcpServers": "./.mcp.json", @@ -61,6 +62,7 @@ - `repository` (`string`): Source code URL. - `license` (`string`): License identifier (for example `MIT`, `Apache-2.0`). - `keywords` (`array` of `string`): Search/discovery tags. +- `agents` (`string`): Relative path to plugin-bundled agent role TOML files. - `skills` (`string`): Relative path to skill directories/files. - `hooks` (`string`): Hook config path. - `mcpServers` (`string` or `object`): MCP config path, or an object whose keys are MCP server names and whose values are MCP server config objects. @@ -114,7 +116,7 @@ Or as an object directly in `plugin.json`: ### Path conventions and defaults - Path values should be relative and begin with `./`. -- `skills`, `hooks`, and string-valued `mcpServers` are supplemented on top of default component discovery; they do not replace defaults. +- `agents`, `skills`, `hooks`, and string-valued `mcpServers` are supplemented on top of default component discovery; they do not replace defaults. - Custom path values must follow the plugin root convention and naming/namespacing rules. - This repo’s scaffold writes `.codex-plugin/plugin.json`; treat that as the manifest location this skill generates. @@ -209,6 +211,8 @@ personal marketplace unless the caller explicitly requests a repo-local destinat present. - `composerIcon`, `logo`, `logoDark`, and `screenshots` must point to real files inside the plugin archive when present. +- `agents` should appear in `plugin.json` only when the plugin has an `agents/` directory with + role TOML files. - `apps` should appear in `plugin.json` only when `.app.json` actually exists. - `mcpServers` may point to `.mcp.json` or contain the MCP server object directly in `plugin.json`. diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py index 78b9fca8fc..ba42963378 100755 --- a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/create_basic_plugin.py @@ -53,7 +53,9 @@ def display_name_from_plugin_name(plugin_name: str) -> str: return " ".join(part.capitalize() for part in re.split(r"[-_]+", plugin_name)) -def build_plugin_json(plugin_name: str, *, with_mcp: bool, with_apps: bool) -> dict[str, Any]: +def build_plugin_json( + plugin_name: str, *, with_agents: bool, with_mcp: bool, with_apps: bool +) -> dict[str, Any]: display_name = display_name_from_plugin_name(plugin_name) payload: dict[str, Any] = { "name": plugin_name, @@ -73,6 +75,8 @@ def build_plugin_json(plugin_name: str, *, with_mcp: bool, with_apps: bool) -> d "defaultPrompt": f"Help me use {display_name}.", }, } + if with_agents: + payload["agents"] = "./agents/" if with_mcp: payload["mcpServers"] = "./.mcp.json" if with_apps: @@ -204,6 +208,9 @@ def parse_args() -> argparse.Namespace: ), ) parser.add_argument("--with-skills", action="store_true", help="Create skills/ directory") + parser.add_argument( + "--with-agents", action="store_true", help="Create agents/ directory" + ) parser.add_argument("--with-hooks", action="store_true", help="Create hooks/ directory") parser.add_argument("--with-scripts", action="store_true", help="Create scripts/ directory") parser.add_argument("--with-assets", action="store_true", help="Create assets/ directory") @@ -272,11 +279,17 @@ def main() -> None: plugin_json_path = plugin_root / ".codex-plugin" / "plugin.json" write_json( plugin_json_path, - build_plugin_json(plugin_name, with_mcp=args.with_mcp, with_apps=args.with_apps), + build_plugin_json( + plugin_name, + with_agents=args.with_agents, + with_mcp=args.with_mcp, + with_apps=args.with_apps, + ), args.force, ) optional_directories = { + "agents": args.with_agents, "skills": args.with_skills, "hooks": args.with_hooks, "scripts": args.with_scripts, diff --git a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py index 88fae0fd00..200339c5be 100644 --- a/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py +++ b/codex-rs/skills/src/assets/samples/plugin-creator/scripts/validate_plugin.py @@ -97,6 +97,7 @@ def validate_manifest_shape( "name", "version", "description", + "agents", "skills", "apps", "mcpServers", @@ -125,6 +126,7 @@ def validate_manifest_shape( validate_optional_https_url(author, "url", errors, prefix="author") validate_optional_contract_path(manifest, "skills", "skills", errors) + validate_optional_contract_path(manifest, "agents", "agents", errors) validate_optional_contract_path(manifest, "apps", ".app.json", errors) validate_manifest_mcp_servers(plugin_root, manifest, errors) diff --git a/codex-rs/utils/plugins/src/lib.rs b/codex-rs/utils/plugins/src/lib.rs index 5cb5905495..4fb700fee4 100644 --- a/codex-rs/utils/plugins/src/lib.rs +++ b/codex-rs/utils/plugins/src/lib.rs @@ -20,3 +20,10 @@ pub struct PluginSkillRoot { pub plugin_namespace: String, pub plugin_root: AbsolutePathBuf, } + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct PluginAgentRoot { + pub path: AbsolutePathBuf, + pub plugin_id: String, + pub plugin_root: AbsolutePathBuf, +} diff --git a/scripts/reset_claude_import_targets.py b/scripts/reset_claude_import_targets.py new file mode 100644 index 0000000000..3ccd823dee --- /dev/null +++ b/scripts/reset_claude_import_targets.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 + +"""Reset selected Codex-side targets created by Claude Code import. + +This is a developer helper for retesting the external-agent migration flow. It +never reads from or mutates Claude's source directories. By default it only +prints the changes it would make; pass --apply to mutate Codex targets. +""" + +import argparse +import os +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path + + +MCP_TABLE_HEADER = re.compile(r"^\s*\[\[?\s*mcp_servers(?:\s*\.|\s*\])") +TABLE_HEADER = re.compile(r"^\s*\[\[?.*\]\]?\s*(?:#.*)?$") +ROOT_MCP_ASSIGNMENT = re.compile(r"^\s*mcp_servers(?:\s*\.|\s*=)") + + +@dataclass(frozen=True) +class ResetTarget: + label: str + config_toml: Path + skills_dir: Path + hooks_json: Path + + +def strip_mcp_servers(text: str) -> tuple[str, bool]: + """Remove generated [mcp_servers...] TOML tables while preserving other text.""" + kept_lines: list[str] = [] + skipping_mcp_table = False + before_first_table = True + removed = False + + for line in text.splitlines(keepends=True): + if TABLE_HEADER.match(line): + before_first_table = False + if MCP_TABLE_HEADER.match(line): + skipping_mcp_table = True + removed = True + continue + skipping_mcp_table = False + + if skipping_mcp_table: + continue + + # The migration writer emits table headers, but handle an inline or + # dotted root assignment too so the reset means "all MCP servers". + if before_first_table and ROOT_MCP_ASSIGNMENT.match(line): + removed = True + continue + + kept_lines.append(line) + + return "".join(kept_lines), removed + + +def resolve_repo_root(explicit_repo_root: Path | None) -> Path | None: + if explicit_repo_root is not None: + return explicit_repo_root.expanduser().resolve() + + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + return None + return Path(result.stdout.strip()).resolve() + + +def backup_path(path: Path, timestamp: str) -> Path: + base = path.with_name(f"{path.name}.before-claude-import-reset-{timestamp}.bak") + if not base.exists(): + return base + + counter = 2 + while True: + candidate = base.with_name(f"{base.name}.{counter}") + if not candidate.exists(): + return candidate + counter += 1 + + +def move_to_backup(path: Path, timestamp: str, apply: bool) -> None: + destination = backup_path(path, timestamp) + verb = "moving" if apply else "would move" + print(f" {verb} {path} -> {destination}") + if apply: + shutil.move(path, destination) + + +def reset_config(path: Path, timestamp: str, apply: bool) -> None: + if not path.is_file(): + print(f" no config.toml at {path}") + return + + original = path.read_text(encoding="utf-8") + updated, removed = strip_mcp_servers(original) + if not removed: + print(f" no MCP server tables in {path}") + return + + backup = backup_path(path, timestamp) + verb = "removing" if apply else "would remove" + print(f" {verb} MCP server tables from {path}") + print(f" {'backing up' if apply else 'would back up'} {path} -> {backup}") + if apply: + shutil.copy2(path, backup) + path.write_text(updated, encoding="utf-8") + + +def reset_target(target: ResetTarget, timestamp: str, apply: bool) -> None: + print(f"{target.label}:") + reset_config(target.config_toml, timestamp, apply) + + if target.skills_dir.exists(): + if not target.skills_dir.is_dir() and not target.skills_dir.is_symlink(): + raise RuntimeError(f"skills target is not a directory: {target.skills_dir}") + move_to_backup(target.skills_dir, timestamp, apply) + else: + print(f" no skills directory at {target.skills_dir}") + + if target.hooks_json.exists(): + if target.hooks_json.is_dir(): + raise RuntimeError(f"hooks target is a directory: {target.hooks_json}") + move_to_backup(target.hooks_json, timestamp, apply) + else: + print(f" no hooks.json at {target.hooks_json}") + + +def home_target(codex_home: Path) -> ResetTarget: + return ResetTarget( + label="home-scoped import targets", + config_toml=codex_home / "config.toml", + skills_dir=codex_home.parent / ".agents" / "skills", + hooks_json=codex_home / "hooks.json", + ) + + +def repo_target(repo_root: Path) -> ResetTarget: + return ResetTarget( + label="repo-scoped import targets", + config_toml=repo_root / ".codex" / "config.toml", + skills_dir=repo_root / ".agents" / "skills", + hooks_json=repo_root / ".codex" / "hooks.json", + ) + + +def main() -> int: + parser = argparse.ArgumentParser( + description=( + "Reset Codex-side MCP servers, skills, and hooks.json created while " + "testing Claude Code import. Defaults to a dry run." + ) + ) + parser.add_argument( + "--apply", + action="store_true", + help="Apply the reset. Without this flag, only print planned changes.", + ) + parser.add_argument( + "--scope", + choices=("all", "home", "repo"), + default="all", + help="Which import targets to reset. Default: all.", + ) + parser.add_argument( + "--codex-home", + type=Path, + default=Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")), + help="Codex home for home-scoped targets. Default: $CODEX_HOME or ~/.codex.", + ) + parser.add_argument( + "--repo-root", + type=Path, + help="Repo root for repo-scoped targets. Default: current git worktree root.", + ) + args = parser.parse_args() + + codex_home = args.codex_home.expanduser().resolve() + repo_root = resolve_repo_root(args.repo_root) + targets: list[ResetTarget] = [] + + if args.scope in ("all", "home"): + targets.append(home_target(codex_home)) + if args.scope in ("all", "repo"): + if repo_root is None: + parser.error( + "--scope includes repo, but no git worktree or --repo-root was found" + ) + targets.append(repo_target(repo_root)) + + timestamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + if not args.apply: + print("dry run: pass --apply to perform these changes") + for target in targets: + reset_target(target, timestamp, args.apply) + + return 0 + + +if __name__ == "__main__": + sys.exit(main())