diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e4a2b0fc79..3aa47eb02c 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2313,6 +2313,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "uuid", ] [[package]] diff --git a/codex-rs/character/Cargo.toml b/codex-rs/character/Cargo.toml index 02d4066548..5ce39d62a7 100644 --- a/codex-rs/character/Cargo.toml +++ b/codex-rs/character/Cargo.toml @@ -17,6 +17,7 @@ codex-protocol = { workspace = true } image = { workspace = true, features = ["gif", "jpeg", "png", "pnm", "webp"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +uuid = { workspace = true, features = ["v4"] } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/character/src/lib.rs b/codex-rs/character/src/lib.rs index 2accbbc42d..6765b215ac 100644 --- a/codex-rs/character/src/lib.rs +++ b/codex-rs/character/src/lib.rs @@ -1,5 +1,6 @@ mod avatar; mod manifest; +mod onboarding; pub use avatar::AvatarAnimation; pub use avatar::AvatarAnimationFrame; @@ -20,5 +21,22 @@ pub use manifest::ValidationIssue; pub use manifest::ValidationIssueCode; pub use manifest::ValidationReport; pub use manifest::validate_manifest_path; +pub use manifest::validate_canonical_id; +pub use onboarding::ActivationError; +pub use onboarding::ActivationJournal; +pub use onboarding::ActivationJournalCoordinator; +pub use onboarding::ActivationPhase; +pub use onboarding::CharacterWizardRequestV1; +pub use onboarding::CharacterWizardResultV1; +pub use onboarding::LastActiveStore; +pub use onboarding::CharacterPackageSource; +pub use onboarding::DirectoryPackageSource; +pub use onboarding::WizardOperation; +pub use onboarding::WizardOperationId; +pub use onboarding::WizardOperationReceipt; +pub use onboarding::WizardOutcome; +pub use onboarding::encode_bounded_request; +pub use onboarding::resolve_candidate_path; +pub use onboarding::resolve_wizard_executable; pub const CHARACTER_SCHEMA_VERSION: u32 = 1; diff --git a/codex-rs/character/src/manifest.rs b/codex-rs/character/src/manifest.rs index af03265569..2fb16e5ac4 100644 --- a/codex-rs/character/src/manifest.rs +++ b/codex-rs/character/src/manifest.rs @@ -403,7 +403,7 @@ fn validate_manifest_fields(manifest: &CharacterManifestV1, errors: &mut Vec Vec { errors } -fn is_valid_id(id: &str) -> bool { +pub fn validate_canonical_id(id: &str) -> bool { let bytes = id.as_bytes(); !bytes.is_empty() && bytes.len() <= 64 diff --git a/codex-rs/character/src/onboarding.rs b/codex-rs/character/src/onboarding.rs new file mode 100644 index 0000000000..4344552af0 --- /dev/null +++ b/codex-rs/character/src/onboarding.rs @@ -0,0 +1,448 @@ +//! Lower-level contracts for the external character wizard and activation journal. +//! +//! This module deliberately has no TUI or state-database dependency. Callers provide package +//! bytes and an implementation of the last-active transaction boundary. + +use std::fs; +use std::fs::File; +use std::io; +use std::io::Write; +use std::path::{Component, Path, PathBuf}; + +use serde::{Deserialize, Serialize}; +use crate::validate_canonical_id; + +pub const WIZARD_PROTOCOL_VERSION: u32 = 1; +const MAX_JSON_BYTES: usize = 64 * 1024; + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +#[serde(rename_all = "camelCase")] +pub struct CharacterWizardRequestV1 { + pub protocol_version: u32, + pub operation: WizardOperation, + pub operation_id: WizardOperationId, + /// The process sees only `.`; the caller sets its cwd to the private operation root. + pub operation_root: String, + pub canonical_id: Option, + pub display_name: Option, + pub aliases: Vec, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum WizardOperation { + Create, + Import, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +#[serde(rename_all = "camelCase")] +pub struct CharacterWizardResultV1 { + pub protocol_version: u32, + pub outcome: WizardOutcome, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(deny_unknown_fields)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum WizardOutcome { + Success { candidate_path: String }, + Cancelled, + Error { message: String }, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +pub struct WizardOperationReceipt { + pub operation_id: WizardOperationId, + pub expected_prior: Option, + pub target: String, + pub committed: bool, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq, Hash)] +#[serde(transparent)] +pub struct WizardOperationId(String); + +impl WizardOperationId { + pub fn generate() -> Self { + Self(uuid::Uuid::new_v4().simple().to_string()) + } + + pub fn parse(value: impl Into) -> io::Result { + let value = value.into(); + if value.len() != 32 || !value.bytes().all(|byte| byte.is_ascii_hexdigit() && !byte.is_ascii_uppercase()) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "operation id must be 32 lowercase hexadecimal characters")); + } + Ok(Self(value)) + } + pub fn as_str(&self) -> &str { &self.0 } +} + +impl CharacterWizardResultV1 { + pub fn decode_bounded(bytes: &[u8]) -> io::Result { + if bytes.len() > MAX_JSON_BYTES { + return Err(io::Error::new(io::ErrorKind::InvalidData, "wizard result is too large")); + } + let result: Self = serde_json::from_slice(bytes) + .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))?; + if result.protocol_version != WIZARD_PROTOCOL_VERSION { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "unsupported wizard result protocol version", + )); + } + match &result.outcome { + WizardOutcome::Success { candidate_path } => { + if candidate_path.is_empty() || candidate_path.len() > 4096 || candidate_path.chars().any(char::is_control) { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid wizard candidate")); + } + } + WizardOutcome::Error { message } => { + if message.is_empty() || message.len() > 4096 || message.chars().any(char::is_control) { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid wizard error")); + } + } + WizardOutcome::Cancelled => {} + } + Ok(result) + } +} + +pub fn encode_bounded_request(request: &CharacterWizardRequestV1) -> io::Result> { + if request.protocol_version != WIZARD_PROTOCOL_VERSION || request.operation_root != "." { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid wizard request")); + } + if request.operation_id.as_str().len() != 32 { return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid operation id")); } + for value in request.canonical_id.iter().chain(request.display_name.iter()).chain(request.aliases.iter()) { + if value.is_empty() || value.len() > 256 || value.chars().any(char::is_control) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid wizard metadata")); + } + } + if matches!(request.operation, WizardOperation::Create) && request.canonical_id.is_none() { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "create requires a canonical id")); + } + let bytes = serde_json::to_vec(request).map_err(io::Error::other)?; + if bytes.len() > MAX_JSON_BYTES { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "wizard request is too large")); + } + Ok(bytes) +} + +pub fn resolve_wizard_executable(provider_root: &Path, relative: &Path) -> io::Result { + if relative.is_absolute() || !is_safe_relative(relative) { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, "wizard executable escapes provider")); + } + let root = fs::canonicalize(provider_root)?; + let candidate = fs::canonicalize(root.join(relative))?; + if !candidate.starts_with(&root) { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, "wizard executable escapes provider")); + } + let metadata = fs::metadata(&candidate)?; + if !metadata.is_file() || !is_executable(&metadata) { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, "wizard executable is not an ordinary executable file")); + } + Ok(candidate) +} + +pub fn resolve_candidate_path(operation_root: &Path, relative: &Path) -> io::Result { + if relative.is_absolute() || !is_safe_relative(relative) { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, "wizard candidate escapes operation")); + } + let root = fs::canonicalize(operation_root)?; + let candidate = fs::canonicalize(root.join(relative))?; + if !candidate.starts_with(&root) { + return Err(io::Error::new(io::ErrorKind::PermissionDenied, "wizard candidate escapes operation")); + } + Ok(candidate) +} + +fn is_safe_relative(path: &Path) -> bool { + !path.as_os_str().is_empty() + && path.components().all(|component| { + matches!(component, Component::Normal(name) if !name.is_empty() && name.to_str().is_some_and(|value| !value.chars().any(char::is_control))) + }) +} + +#[cfg(unix)] +fn is_executable(metadata: &fs::Metadata) -> bool { + use std::os::unix::fs::PermissionsExt; + metadata.permissions().mode() & 0o111 != 0 +} + +#[cfg(not(unix))] +fn is_executable(_metadata: &fs::Metadata) -> bool { + true +} + +#[derive(Clone, Copy, Debug, Deserialize, Serialize, Eq, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ActivationPhase { + Prepared, + PackageInstalled, + StateCommitted, +} + +#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)] +pub struct ActivationJournal { + pub schema_version: u32, + pub operation_id: WizardOperationId, + pub canonical_id: String, + pub prior_last_active: Option, + pub phase: ActivationPhase, +} + +pub trait LastActiveStore { + type Error: std::error::Error + Send + Sync + 'static; + + fn commit_activation( + &mut self, + operation_id: &str, + expected_prior: Option<&str>, + canonical_id: &str, + ) -> Result; + fn activation_status(&self, operation_id: &str) -> Result, Self::Error>; +} + +pub struct ActivationJournalCoordinator { + characters_root: PathBuf, + store: S, +} + +impl ActivationJournalCoordinator { + pub fn new(characters_root: PathBuf, store: S) -> Self { + Self { characters_root, store } + } + + pub fn journal_path(&self, operation_id: &WizardOperationId) -> io::Result { + Ok(self.characters_root.join(".staging").join(format!("{}.json", operation_id.as_str()))) + } + + pub fn write_journal(&self, journal: &ActivationJournal) -> io::Result<()> { + if !validate_canonical_id(&journal.canonical_id) { return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid canonical id")); } + if journal.schema_version != 1 { + return Err(io::Error::new(io::ErrorKind::InvalidData, "unsupported activation journal")); + } + let staging = self.characters_root.join(".staging"); + fs::create_dir_all(&staging)?; + let path = self.journal_path(&journal.operation_id)?; + let temporary = path.with_extension("json.tmp"); + let bytes = serde_json::to_vec(journal).map_err(io::Error::other)?; + let mut file = File::create(&temporary)?; + file.write_all(&bytes)?; + file.sync_all()?; + fs::rename(&temporary, &path)?; + File::open(&staging)?.sync_all()?; + Ok(()) + } + + pub fn commit_last_active( + &mut self, + journal: &ActivationJournal, + ) -> Result> { + let receipt = self.store + .commit_activation( + journal.operation_id.as_str(), + journal.prior_last_active.as_deref(), + &journal.canonical_id, + ) + .map_err(ActivationError::Store)?; + validate_receipt(&receipt, journal)?; + Ok(receipt) + } + + pub fn state_is_committed( + &self, + journal: &ActivationJournal, + ) -> Result, ActivationError> { + let receipt = self.store.activation_status(journal.operation_id.as_str()).map_err(ActivationError::Store)?; + if let Some(receipt) = &receipt { + validate_receipt::(receipt, journal)?; + } + Ok(receipt) + } +} + +pub trait CharacterPackageSource { + fn copy_into(&self, destination: &Path) -> io::Result<()>; +} + +pub struct DirectoryPackageSource<'a> { + pub source: &'a Path, +} + +impl CharacterPackageSource for DirectoryPackageSource<'_> { + fn copy_into(&self, destination: &Path) -> io::Result<()> { + copy_package_tree(self.source, destination, 0, &mut 0, &mut 0) + } +} + +fn copy_package_tree(source: &Path, destination: &Path, depth: usize, files: &mut usize, bytes: &mut u64) -> io::Result<()> { + if depth > 8 { return Err(io::Error::new(io::ErrorKind::InvalidData, "package nesting is too deep")); } + fs::create_dir_all(destination)?; + for entry in fs::read_dir(source)? { + let entry = entry?; + let kind = entry.file_type()?; + if kind.is_symlink() || (!kind.is_file() && !kind.is_dir()) { + return Err(io::Error::new(io::ErrorKind::InvalidData, "package contains unsafe entry")); + } + let target = destination.join(entry.file_name()); + if kind.is_dir() { copy_package_tree(&entry.path(), &target, depth + 1, files, bytes)?; } + else { + *files += 1; + let metadata = entry.metadata()?; + *bytes = (*bytes).saturating_add(metadata.len()); + if *files > 256 || *bytes > 16 * 1024 * 1024 { return Err(io::Error::new(io::ErrorKind::InvalidData, "package exceeds bounds")); } + fs::copy(entry.path(), target)?; + } + } + Ok(()) +} + +pub fn validate_staged_package(package_root: &Path, canonical_id: &str) -> io::Result<()> { + if !validate_canonical_id(canonical_id) { return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid canonical id")); } + let report = crate::validate_manifest_path(&package_root.join("character.json")); + if !report.is_valid() { return Err(io::Error::new(io::ErrorKind::InvalidData, "character package is invalid")); } + Ok(()) +} + +pub fn stage_character_package( + characters_root: &Path, + operation_id: &str, + canonical_id: &str, + source: &S, +) -> io::Result { + let operation_id = WizardOperationId::parse(operation_id)?; + if !validate_canonical_id(canonical_id) { return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid canonical id")); } + let operation_dir = characters_root.join(".staging").join(operation_id.as_str()); + fs::create_dir_all(characters_root.join(".staging"))?; + fs::create_dir(&operation_dir)?; + let lock = operation_dir.join(".lock"); + if let Err(error) = File::options().write(true).create_new(true).open(&lock) + .and_then(|_| fs::create_dir(operation_dir.join(canonical_id))) + .and_then(|_| source.copy_into(&operation_dir.join(canonical_id))) + .and_then(|_| validate_staged_package(&operation_dir.join(canonical_id), canonical_id)) { + if let Err(cleanup) = fs::remove_dir_all(&operation_dir) { + return Err(io::Error::other(format!("{error}; cleanup failed: {cleanup}"))); + } + return Err(error); + } + Ok(operation_dir.join(canonical_id)) +} + +fn validate_receipt(receipt: &WizardOperationReceipt, journal: &ActivationJournal) -> Result<(), ActivationError> { + if !receipt.committed + || receipt.operation_id != journal.operation_id + || receipt.expected_prior != journal.prior_last_active + || receipt.target != journal.canonical_id + { + return Err(ActivationError::InvalidReceipt); + } + Ok(()) +} + +fn validate_segment(value: &str) -> io::Result<()> { + if value.is_empty() || value == "." || value == ".." || value.contains('/') || value.contains('\\') || value.chars().any(char::is_control) { + return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid operation or character id")); + } + Ok(()) +} + +#[derive(Debug)] +pub enum ActivationError { + Store(E), + InvalidReceipt, +} + +impl std::fmt::Display for ActivationError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Store(error) => write!(formatter, "last-active transaction failed: {error}"), + Self::InvalidReceipt => formatter.write_str("last-active receipt does not match operation"), + } + } +} + +impl std::error::Error for ActivationError {} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + use tempfile::tempdir; + + #[test] + fn request_forces_private_operation_root() { + let request = CharacterWizardRequestV1 { + protocol_version: 1, + operation: WizardOperation::Create, + operation_id: WizardOperationId::parse("0123456789abcdef0123456789abcdef").unwrap(), + operation_root: ".".into(), + canonical_id: Some("rusty".into()), + display_name: Some("Rusty".into()), + aliases: vec![], + }; + assert!(encode_bounded_request(&request).is_ok()); + let mut invalid = request; + invalid.operation_root = "/private/home".into(); + assert!(encode_bounded_request(&invalid).is_err()); + } + + #[test] + fn operation_ids_are_generated_unique_and_canonical() { + let first = WizardOperationId::generate(); + let second = WizardOperationId::generate(); + assert_ne!(first, second); + assert_eq!(first.as_str().len(), 32); + assert_eq!(WizardOperationId::parse(first.as_str()).unwrap(), first); + assert!(WizardOperationId::parse(&first.as_str().to_uppercase()).is_err()); + assert!(WizardOperationId::parse("not-an-operation-id").is_err()); + } + + #[test] + fn result_decode_is_versioned_and_bounded() { + let result = CharacterWizardResultV1 { + protocol_version: 1, + outcome: WizardOutcome::Success { candidate_path: "candidate".into() }, + }; + let bytes = serde_json::to_vec(&result).unwrap(); + assert_eq!(CharacterWizardResultV1::decode_bounded(&bytes).unwrap(), result); + assert!(CharacterWizardResultV1::decode_bounded(&[b'x'; MAX_JSON_BYTES + 1]).is_err()); + } + + #[test] + fn candidate_rejects_traversal_and_symlink_escape() { + let root = tempdir().unwrap(); + let outside = tempdir().unwrap(); + fs::write(outside.path().join("candidate"), b"x").unwrap(); + fs::create_dir_all(root.path().join("nested")).unwrap(); + #[cfg(unix)] std::os::unix::fs::symlink(outside.path().join("candidate"), root.path().join("nested/link")).unwrap(); + assert!(resolve_candidate_path(root.path(), Path::new("../candidate")).is_err()); + #[cfg(unix)] assert!(resolve_candidate_path(root.path(), Path::new("nested/link")).is_err()); + } + + #[test] + fn journal_uses_derived_operation_path_and_rejects_bad_versions() { + let root = tempdir().unwrap(); + let coordinator = ActivationJournalCoordinator::new(root.path().to_path_buf(), FakeStore::default()); + let operation_id = WizardOperationId::parse("0123456789abcdef0123456789abcdef").unwrap(); + let journal = ActivationJournal { schema_version: 1, operation_id: operation_id.clone(), canonical_id: "rusty".into(), prior_last_active: None, phase: ActivationPhase::Prepared }; + coordinator.write_journal(&journal).unwrap(); + assert_eq!(coordinator.journal_path(&operation_id).unwrap(), root.path().join(".staging/0123456789abcdef0123456789abcdef.json")); + let invalid = ActivationJournal { schema_version: 2, ..journal }; + assert!(coordinator.write_journal(&invalid).is_err()); + } + + #[derive(Default)] + struct FakeStore { committed: HashSet } + + impl LastActiveStore for FakeStore { + type Error = io::Error; + fn commit_activation(&mut self, operation_id: &str, expected_prior: Option<&str>, canonical_id: &str) -> Result { + self.committed.insert(operation_id.into()); + Ok(WizardOperationReceipt { operation_id: WizardOperationId::parse(operation_id).unwrap(), expected_prior: expected_prior.map(str::to_string), target: canonical_id.into(), committed: true }) + } + fn activation_status(&self, operation_id: &str) -> Result, Self::Error> { Ok(self.committed.contains(operation_id).then(|| WizardOperationReceipt { operation_id: WizardOperationId::parse(operation_id).unwrap(), expected_prior: None, target: "rusty".into(), committed: true })) } + } +} diff --git a/codex-rs/core-plugins/src/loader.rs b/codex-rs/core-plugins/src/loader.rs index b3fd3c9759..e5823aef95 100644 --- a/codex-rs/core-plugins/src/loader.rs +++ b/codex-rs/core-plugins/src/loader.rs @@ -768,6 +768,7 @@ async fn load_plugin( plugin_namespace: None, manifest_description: None, root, + character_wizard: None, enabled: plugin.enabled, skill_roots: Vec::new(), disabled_skill_paths: HashSet::new(), @@ -808,6 +809,7 @@ async fn load_plugin( }; let manifest_paths = &manifest.paths; + loaded_plugin.character_wizard = manifest.character_wizard.clone(); loaded_plugin.plugin_namespace = Some(manifest.name.clone()); match scope { PluginLoadScope::AllCapabilities { diff --git a/codex-rs/core-plugins/src/manager_tests.rs b/codex-rs/core-plugins/src/manager_tests.rs index fbb518e91f..97f7f9613e 100644 --- a/codex-rs/core-plugins/src/manager_tests.rs +++ b/codex-rs/core-plugins/src/manager_tests.rs @@ -863,6 +863,7 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() { "Plugin that includes the sample MCP server and Skills".to_string(), ), root: AbsolutePathBuf::try_from(plugin_root.clone()).unwrap(), + character_wizard: None, enabled: true, skill_roots: vec![plugin_root.join("skills").abs()], disabled_skill_paths: HashSet::new(), @@ -2064,6 +2065,7 @@ async fn load_plugin_skills_dedupes_overlapping_manifest_roots() { apps: None, hooks: None, }, + character_wizard: None, interface: None, }; let plugin_id = PluginId::parse("sample@test").expect("plugin id should parse"); @@ -2292,6 +2294,7 @@ async fn load_plugins_preserves_disabled_plugins_without_effective_contributions plugin_namespace: None, manifest_description: None, root: AbsolutePathBuf::try_from(plugin_root).unwrap(), + character_wizard: None, enabled: false, skill_roots: Vec::new(), disabled_skill_paths: HashSet::new(), @@ -2467,6 +2470,7 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() { ), manifest_description: None, root: AbsolutePathBuf::try_from(codex_home.path().join(dir_name)).unwrap(), + character_wizard: None, enabled: true, skill_roots: Vec::new(), disabled_skill_paths: HashSet::new(), diff --git a/codex-rs/core-plugins/src/manifest.rs b/codex-rs/core-plugins/src/manifest.rs index ff6d2c902a..bd3c5003ec 100644 --- a/codex-rs/core-plugins/src/manifest.rs +++ b/codex-rs/core-plugins/src/manifest.rs @@ -18,6 +18,8 @@ pub type PluginManifestInterface = codex_plugin::manifest::PluginManifestInterfa pub type PluginManifestMcpServers = codex_plugin::manifest::PluginManifestMcpServers; pub type PluginManifestPaths = codex_plugin::manifest::PluginManifestPaths; +pub type CharacterWizardCapability = + codex_plugin::manifest::CharacterWizardCapability; pub(crate) type UriPluginManifest = codex_plugin::manifest::PluginManifest; @@ -43,9 +45,18 @@ struct RawPluginManifest { #[serde(default)] hooks: Option, #[serde(default)] + character_wizard: Option, + #[serde(default)] interface: Option, } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct RawCharacterWizardCapability { + protocol_version: u32, + executable: String, +} + #[derive(Deserialize)] struct RawPluginCommandManifest { #[serde(default)] @@ -192,6 +203,7 @@ pub(crate) fn parse_plugin_manifest_uri( mcp_servers, apps, hooks, + character_wizard, interface, } = serde_json::from_str::(contents)?; let name = plugin_root @@ -278,6 +290,24 @@ pub(crate) fn parse_plugin_manifest_uri( has_fields.then_some(interface) }); + let character_wizard = character_wizard.and_then(|capability| { + if capability.protocol_version != 1 { + tracing::warn!( + protocol_version = capability.protocol_version, + "ignoring unsupported characterWizard protocol version" + ); + return None; + } + resolve_manifest_path( + plugin_root, + "characterWizard.executable", + Some(&capability.executable), + ) + .map(|executable| codex_plugin::manifest::CharacterWizardCapability { + protocol_version: capability.protocol_version, + executable, + }) + }); Ok(codex_plugin::manifest::PluginManifest { name, version, @@ -289,6 +319,7 @@ pub(crate) fn parse_plugin_manifest_uri( apps: resolve_manifest_path(plugin_root, "apps", apps.as_deref()), hooks: resolve_manifest_hooks(plugin_root, hooks), }, + character_wizard, interface, }) } @@ -889,6 +920,7 @@ mod tests { plugin_root.join("hooks.json").expect("hooks URI"), ])), }, + character_wizard: None, interface: Some(PluginManifestInterface { display_name: Some("Demo Plugin".to_string()), composer_icon: Some( diff --git a/codex-rs/plugin/src/lib.rs b/codex-rs/plugin/src/lib.rs index 8a00d8c5ef..fc70972746 100644 --- a/codex-rs/plugin/src/lib.rs +++ b/codex-rs/plugin/src/lib.rs @@ -15,6 +15,7 @@ use codex_utils_absolute_path::AbsolutePathBuf; pub use load_outcome::EffectiveSkillRoots; pub use load_outcome::LoadedPlugin; pub use load_outcome::PluginLoadOutcome; +pub use load_outcome::CharacterWizardProviderSelection; pub use load_outcome::prompt_safe_plugin_description; pub use plugin_id::PluginId; pub use plugin_id::PluginIdError; diff --git a/codex-rs/plugin/src/load_outcome.rs b/codex-rs/plugin/src/load_outcome.rs index ad83655463..50d66654e1 100644 --- a/codex-rs/plugin/src/load_outcome.rs +++ b/codex-rs/plugin/src/load_outcome.rs @@ -20,6 +20,7 @@ pub struct LoadedPlugin { pub plugin_namespace: Option, pub manifest_description: Option, pub root: AbsolutePathBuf, + pub character_wizard: Option>, pub enabled: bool, pub skill_roots: Vec, pub disabled_skill_paths: HashSet, @@ -93,6 +94,13 @@ pub struct PluginLoadOutcome { capability_summaries: Vec, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CharacterWizardProviderSelection<'a, M> { + None, + One(&'a LoadedPlugin), + Conflict(Vec), +} + impl Default for PluginLoadOutcome { fn default() -> Self { Self::from_plugins(Vec::new()) @@ -190,6 +198,29 @@ impl PluginLoadOutcome { pub fn plugins(&self) -> &[LoadedPlugin] { &self.plugins } + + /// Returns active providers in deterministic config-name order. Callers retain the full + /// LoadedPlugin receipt, including config name, namespace, and canonical installed root. + pub fn active_character_wizard_providers(&self) -> Vec<&LoadedPlugin> { + let mut providers = self + .plugins + .iter() + .filter(|plugin| plugin.is_active() && plugin.character_wizard.is_some()) + .collect::>(); + providers.sort_unstable_by(|left, right| left.config_name.cmp(&right.config_name)); + providers + } + + pub fn select_character_wizard_provider(&self) -> CharacterWizardProviderSelection<'_, M> { + let providers = self.active_character_wizard_providers(); + match providers.as_slice() { + [] => CharacterWizardProviderSelection::None, + [provider] => CharacterWizardProviderSelection::One(provider), + many => CharacterWizardProviderSelection::Conflict( + many.iter().map(|provider| provider.config_name.clone()).collect(), + ), + } + } } /// Implemented by [`PluginLoadOutcome`] so callers (e.g. skills) can depend on `codex-plugin` @@ -231,6 +262,7 @@ mod tests { ), manifest_description: None, root: test_path(config_name), + character_wizard: None, enabled: true, skill_roots, disabled_skill_paths: HashSet::new(), @@ -261,4 +293,23 @@ mod tests { }] ); } + + #[test] + fn character_wizard_providers_are_active_and_deterministic() { + let mut zeta = loaded_plugin("zeta@test", vec![]); + zeta.character_wizard = Some(crate::manifest::CharacterWizardCapability { + protocol_version: 1, + executable: test_path("zeta@test/wizard"), + }); + let mut alpha = loaded_plugin("alpha@test", vec![]); + alpha.character_wizard = Some(crate::manifest::CharacterWizardCapability { + protocol_version: 1, + executable: test_path("alpha@test/wizard"), + }); + alpha.enabled = false; + let outcome = PluginLoadOutcome::from_plugins(vec![zeta, alpha]); + let providers = outcome.active_character_wizard_providers(); + assert_eq!(providers.len(), 1); + assert_eq!(providers[0].config_name, "zeta@test"); + } } diff --git a/codex-rs/plugin/src/manifest.rs b/codex-rs/plugin/src/manifest.rs index 89cfd8d0b6..852cf40fe5 100644 --- a/codex-rs/plugin/src/manifest.rs +++ b/codex-rs/plugin/src/manifest.rs @@ -11,9 +11,16 @@ pub struct PluginManifest { pub description: Option, pub keywords: Vec, pub paths: PluginManifestPaths, + pub character_wizard: Option>, pub interface: Option>, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CharacterWizardCapability { + pub protocol_version: u32, + pub executable: Resource, +} + /// Component resources declared by a plugin manifest. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PluginManifestPaths { @@ -101,6 +108,7 @@ impl PluginManifest { description, keywords, paths, + character_wizard, interface, } = self; let PluginManifestPaths { @@ -185,6 +193,14 @@ impl PluginManifest { apps: apps.map(&mut map).transpose()?, hooks, }, + character_wizard: character_wizard + .map(|capability| { + Ok(CharacterWizardCapability { + protocol_version: capability.protocol_version, + executable: map(capability.executable)?, + }) + }) + .transpose()?, interface, }) } diff --git a/codex-rs/plugin/src/provider_tests.rs b/codex-rs/plugin/src/provider_tests.rs index 7f9e70d330..7a02441c04 100644 --- a/codex-rs/plugin/src/provider_tests.rs +++ b/codex-rs/plugin/src/provider_tests.rs @@ -48,6 +48,7 @@ fn environment_descriptor_binds_every_manifest_resource() { apps: Some(path_uri(&apps)), hooks: Some(PluginManifestHooks::Paths(vec![path_uri(&hooks)])), }, + character_wizard: None, interface: Some(PluginManifestInterface { composer_icon: Some(path_uri(&composer_icon)), logo: Some(path_uri(&logo)), @@ -88,6 +89,7 @@ fn environment_descriptor_binds_every_manifest_resource() { &hooks )])), }, + character_wizard: None, interface: Some(PluginManifestInterface { composer_icon: Some(resource("executor-1", &composer_icon)), logo: Some(resource("executor-1", &logo)), @@ -114,6 +116,7 @@ fn environment_descriptor_rejects_resources_outside_package_root() { apps: None, hooks: None, }, + character_wizard: None, interface: None, };