diff --git a/Cargo.toml b/Cargo.toml index 1deab41539..8dfc5d9820 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "src/crates/assembly/core", "src/crates/adapters/ai-adapters", "src/crates/adapters/opencode-adapter", + "src/crates/adapters/codex-adapter", "src/crates/adapters/webdriver", "src/crates/adapters/api-layer", "src/crates/adapters/transport", diff --git a/scripts/core-boundaries/rules/crate-layout.mjs b/scripts/core-boundaries/rules/crate-layout.mjs index 217fa5e688..2ddb067ec6 100644 --- a/scripts/core-boundaries/rules/crate-layout.mjs +++ b/scripts/core-boundaries/rules/crate-layout.mjs @@ -25,6 +25,7 @@ export const crateLayoutRules = [ { crateName: 'acp', layer: 'interfaces', path: 'src/crates/interfaces/acp' }, { crateName: 'ai-adapters', layer: 'adapters', path: 'src/crates/adapters/ai-adapters' }, { crateName: 'api-layer', layer: 'adapters', path: 'src/crates/adapters/api-layer' }, + { crateName: 'codex-adapter', layer: 'adapters', path: 'src/crates/adapters/codex-adapter' }, { crateName: 'opencode-adapter', layer: 'adapters', path: 'src/crates/adapters/opencode-adapter' }, { crateName: 'transport', layer: 'adapters', path: 'src/crates/adapters/transport' }, { crateName: 'webdriver', layer: 'adapters', path: 'src/crates/adapters/webdriver' }, diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 1d83639afc..05a38fb4bf 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -27,6 +27,7 @@ bitfun-transport = { path = "../../crates/adapters/transport", features = ["taur bitfun-events = { path = "../../crates/contracts/events" } bitfun-webdriver = { path = "../../crates/adapters/webdriver" } bitfun-acp = { path = "../../crates/interfaces/acp" } +bitfun-codex-adapter = { path = "../../crates/adapters/codex-adapter" } # Tauri tauri = { workspace = true } diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 3625688372..82970f1d84 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -31,6 +31,7 @@ pub mod miniapp_api; pub mod miniapp_export_api; pub mod path_target; pub mod peer_host_invoke; +pub mod plugin_api; pub mod remote_connect_api; pub mod review_platform_api; pub mod runtime_api; diff --git a/src/apps/desktop/src/api/plugin_api.rs b/src/apps/desktop/src/api/plugin_api.rs new file mode 100644 index 0000000000..fe906fcbe2 --- /dev/null +++ b/src/apps/desktop/src/api/plugin_api.rs @@ -0,0 +1,244 @@ +//! Plugin Management API +//! +//! Tauri IPC commands for plugin discovery, status query, and trust control. + +use crate::api::app_state::AppState; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use tauri::State; + +/// Status view of a single discovered plugin, returned to the frontend. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginStatusView { + pub plugin_id: String, + pub name: String, + pub version: Option, + pub source: String, + /// "user" for personal plugins (~/.agents/plugins/), "workspace" for project plugins. + pub scope: String, + pub trust_level: String, + pub enabled: bool, + pub skill_count: usize, + pub diagnostics: Vec, +} + +// request structs (Tauri convention: { request: { ... } }) + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetPluginStatusRequest { + pub workspace_path: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetPluginsEnabledRequest { + pub enabled: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetPluginTrustRequest { + pub plugin_id: String, + pub trusted: bool, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RefreshPluginsRequest { + pub workspace_path: Option, +} + +// response + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginStatusResponse { + pub plugins_enabled: bool, + pub plugins: Vec, + pub workspace_path: Option, +} + +const PLUGINS_ENABLED_KEY: &str = "plugins.enabled"; + +// commands + +#[tauri::command] +pub async fn get_plugin_status( + state: State<'_, AppState>, + request: GetPluginStatusRequest, +) -> Result { + let plugins_enabled = get_plugins_enabled(&state).await; + let ws_path = request.workspace_path.clone(); + if !plugins_enabled { + return Ok(PluginStatusResponse { + plugins_enabled: false, + plugins: Vec::new(), + workspace_path: ws_path, + }); + } + + let ws_root: Option = ws_path.as_deref().map(PathBuf::from); + let plugins = tokio::task::spawn_blocking(move || discover_plugins(ws_root.as_deref())) + .await + .map_err(|e| format!("Plugin discovery panicked: {e}"))?; + + Ok(PluginStatusResponse { + plugins_enabled: true, + plugins, + workspace_path: request.workspace_path, + }) +} + +#[tauri::command] +pub async fn set_plugins_enabled( + state: State<'_, AppState>, + request: SetPluginsEnabledRequest, +) -> Result { + state + .config_service + .set_config(PLUGINS_ENABLED_KEY, serde_json::Value::Bool(request.enabled)) + .await + .map_err(|e| format!("Failed to save plugin enabled state: {e}"))?; + + let plugins = if request.enabled { + tokio::task::spawn_blocking(|| discover_plugins(None)) + .await + .map_err(|e| format!("Plugin discovery panicked: {e}"))? + } else { + Vec::new() + }; + + Ok(PluginStatusResponse { + plugins_enabled: request.enabled, + plugins, + workspace_path: None, + }) +} + +#[tauri::command] +pub async fn set_plugin_trust( + state: State<'_, AppState>, + request: SetPluginTrustRequest, +) -> Result { + let denied_key = plugin_denied_config_key(&request.plugin_id); + if request.trusted { + state + .config_service + .reset_config(Some(&denied_key)) + .await + .map_err(|e| format!("Failed to update plugin trust: {e}"))?; + } else { + state + .config_service + .set_config(&denied_key, serde_json::Value::Bool(true)) + .await + .map_err(|e| format!("Failed to save plugin trust: {e}"))?; + } + + let pid = request.plugin_id.clone(); + let plugins = tokio::task::spawn_blocking(|| discover_plugins(None)) + .await + .map_err(|e| format!("Plugin discovery panicked: {e}"))?; + plugins + .into_iter() + .find(|p| p.plugin_id == pid) + .ok_or_else(|| format!("Plugin '{}' not found", pid)) +} + +#[tauri::command] +pub async fn refresh_plugins( + state: State<'_, AppState>, + request: RefreshPluginsRequest, +) -> Result { + let plugins_enabled = get_plugins_enabled(&state).await; + let ws_path = request.workspace_path.clone(); + if !plugins_enabled { + return Ok(PluginStatusResponse { + plugins_enabled: false, + plugins: Vec::new(), + workspace_path: ws_path, + }); + } + + let ws_root: Option = ws_path.as_deref().map(PathBuf::from); + let plugins = tokio::task::spawn_blocking(move || discover_plugins(ws_root.as_deref())) + .await + .map_err(|e| format!("Plugin discovery panicked: {e}"))?; + + Ok(PluginStatusResponse { + plugins_enabled: true, + plugins, + workspace_path: request.workspace_path, + }) +} + +// helpers + +async fn get_plugins_enabled(state: &AppState) -> bool { + match state + .config_service + .get_config::(Some(PLUGINS_ENABLED_KEY)) + .await + { + Ok(value) => value.as_bool().unwrap_or(true), + Err(_) => true, + } +} + +fn plugin_denied_config_key(plugin_id: &str) -> String { + format!("plugins.denied.{plugin_id}") +} + +fn discover_plugins(workspace_root: Option<&Path>) -> Vec { + let mut views = Vec::new(); + + // user-level plugins (~/.agents/plugins/) + let user_discoveries = bitfun_codex_adapter::discovery::discover_all(None); + for d in &user_discoveries { + views.push(build_view(d, "user")); + } + + // workspace-level plugins (/.agents/plugins/) + if let Some(ws_root) = workspace_root { + let ws_discoveries = bitfun_codex_adapter::discovery::discover_all(Some(ws_root)); + for d in &ws_discoveries { + if !user_discoveries.iter().any(|u| u.plugin_root == d.plugin_root) { + views.push(build_view(d, "workspace")); + } + } + } + + views +} + +fn build_view( + d: &bitfun_codex_adapter::discovery::PluginDiscovery, + scope: &str, +) -> PluginStatusView { + match bitfun_codex_adapter::discovery::load_plugin_manifest(d) { + Ok(plugin) => PluginStatusView { + plugin_id: plugin.plugin_id.clone(), + name: plugin.name.clone(), + version: plugin.version.clone(), + source: plugin.root.to_string_lossy().to_string(), + scope: scope.to_string(), + trust_level: "Trusted".to_string(), + enabled: true, + skill_count: plugin.skill_roots.len(), + diagnostics: Vec::new(), + }, + Err(e) => PluginStatusView { + plugin_id: d.dir_name.clone(), + name: d.dir_name.clone(), + version: None, + source: d.plugin_root.to_string_lossy().to_string(), + scope: scope.to_string(), + trust_level: "Unknown".to_string(), + enabled: false, + skill_count: 0, + diagnostics: vec![format!("Manifest error: {e}")], + }, + } +} diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 3894d36f0c..ea1cd76005 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1282,6 +1282,11 @@ pub async fn run() { api::peer_host_invoke::peer_control_detach, api::peer_host_invoke::peer_mode_ping, api::peer_host_invoke::peer_controller_set_active, + // Plugin API + api::plugin_api::get_plugin_status, + api::plugin_api::set_plugins_enabled, + api::plugin_api::set_plugin_trust, + api::plugin_api::refresh_plugins, // MiniApp API api::miniapp_api::list_miniapps, api::miniapp_api::get_miniapp, @@ -1559,6 +1564,15 @@ async fn init_agentic_system() -> anyhow::Result<( log::info!("Cron service initialized and waiting for desktop host readiness"); log::info!("Agentic system initialized"); + + // Initialize Codex+OpenCode plugin support — discovers plugins from + // ~/.agents/plugins/ and registers their skills with SkillRegistry. + // Gated behind product-full because it depends on the plugin runtime host. + #[cfg(feature = "product-full")] + { + bitfun_core::agentic::codex_integration::initialize_plugin_support(None).await; + } + Ok(( coordinator, scheduler, diff --git a/src/crates/adapters/codex-adapter/Cargo.toml b/src/crates/adapters/codex-adapter/Cargo.toml new file mode 100644 index 0000000000..07df052458 --- /dev/null +++ b/src/crates/adapters/codex-adapter/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "bitfun-codex-adapter" +version.workspace = true +authors.workspace = true +edition.workspace = true +description = "Codex-compatible plugin adapter for BitFun — implements PluginHostAdapter for .codex-plugin/plugin.json sources" + +[lib] +name = "bitfun_codex_adapter" +crate-type = ["rlib"] + +[dependencies] +async-trait = { workspace = true } +bitfun-plugin-runtime-host = { path = "../../execution/plugin-runtime-host" } +bitfun-runtime-ports = { path = "../../contracts/runtime-ports" } +dirs = { workspace = true } +log = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +tempfile = { workspace = true } +tokio = { workspace = true } + +[lints] +workspace = true diff --git a/src/crates/adapters/codex-adapter/src/discovery.rs b/src/crates/adapters/codex-adapter/src/discovery.rs new file mode 100644 index 0000000000..60f154e281 --- /dev/null +++ b/src/crates/adapters/codex-adapter/src/discovery.rs @@ -0,0 +1,240 @@ +//! Codex plugin source discovery. +//! +//! Discovers Codex plugins from `~/.agents/plugins/` (personal) and +//! `/.agents/plugins/` (project). +//! +//! Discovery order per plugins directory: +//! 1. Parse `marketplace.json` (Codex standard format) → resolve plugin roots +//! from `plugins[].source.path`. +//! 2. Fall back to scanning direct subdirectories for `.codex-plugin/plugin.json`. + +use super::manifest::{self, ManifestError}; +use std::path::{Path, PathBuf}; + +/// A discovered Codex plugin before loading. +#[derive(Debug, Clone)] +pub struct PluginDiscovery { + pub manifest_path: PathBuf, + pub plugin_root: PathBuf, + pub dir_name: String, +} + +/// Codex marketplace.json root object. +#[derive(serde::Deserialize)] +struct Marketplace { + #[serde(default)] + plugins: Vec, +} + +#[derive(serde::Deserialize)] +struct MarketplacePlugin { + source: MarketplaceSource, +} + +#[derive(serde::Deserialize)] +struct MarketplaceSource { + path: String, +} + +/// Attempts to discover plugins via `marketplace.json`. +/// Returns `None` if the file is absent or unparseable (caller should fall back). +fn discover_via_marketplace(dir: &Path) -> Option> { + let marketplace_path = dir.join("marketplace.json"); + let content = std::fs::read_to_string(&marketplace_path).ok()?; + let marketplace: Marketplace = serde_json::from_str(&content).ok()?; + let mut discoveries = Vec::new(); + for plugin in &marketplace.plugins { + let relative = &plugin.source.path; + let plugin_root = resolve_plugin_path(dir, relative); + // Reject path traversal. + if plugin_root + .to_string_lossy() + .contains(".invalid-path-traversal-rejected") + { + continue; + } + let dir_name = plugin_root + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or(relative) + .to_string(); + match manifest::find_manifest_in_root(&plugin_root) { + Some(manifest_path) => discoveries.push(PluginDiscovery { + manifest_path, + plugin_root, + dir_name, + }), + None => log::warn!( + "Codex marketplace: no manifest found in {}", + plugin_root.display() + ), + } + } + Some(discoveries) +} + +/// Scans a directory for plugin subdirectories containing `.codex-plugin/plugin.json`. +/// Prefers marketplace.json over direct scanning. +/// Skips symlinks to avoid following links to arbitrary filesystem locations. +pub fn scan_directory(dir: &Path) -> Vec { + // 1. Try marketplace.json first (Codex standard). + if let Some(marketplace) = discover_via_marketplace(dir) { + return marketplace; + } + + // 2. Fall back to direct subdirectory scanning. + let mut discoveries = Vec::new(); + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(_) => return discoveries, + }; + let mut subdirs: Vec = entries + .filter_map(|e| { + let entry = e.ok()?; + // Skip symlinks to prevent redirecting discovery to arbitrary directories. + if entry.file_type().ok()?.is_symlink() { + return None; + } + let path = entry.path(); + if path.is_dir() { Some(path) } else { None } + }) + .collect(); + subdirs.sort(); + for subdir in subdirs { + let dir_name = match subdir.file_name().and_then(|n| n.to_str()) { + Some(n) => n.to_string(), + None => continue, + }; + if let Some(manifest_path) = manifest::find_manifest_in_root(&subdir) { + discoveries.push(PluginDiscovery { + manifest_path, + plugin_root: subdir, + dir_name, + }); + } + } + discoveries +} + +/// Discovers Codex plugins from all standard locations. +pub fn discover_all(workspace_root: Option<&Path>) -> Vec { + let mut all = Vec::new(); + if let Some(home) = dirs::home_dir() { + let personal = home.join(".agents").join("plugins"); + if personal.exists() { + all.extend(scan_directory(&personal)); + } + } + if let Some(root) = workspace_root { + let project = root.join(".agents").join("plugins"); + if project.exists() { + all.extend(scan_directory(&project)); + } + } else if let Ok(cwd) = std::env::current_dir() { + let project = cwd.join(".agents").join("plugins"); + if project.exists() { + all.extend(scan_directory(&project)); + } + } + all +} + +/// Loads a discovered plugin's manifest. +pub fn load_plugin_manifest(discovery: &PluginDiscovery) -> Result { + let manifest = manifest::parse_manifest(&discovery.manifest_path)?; + let mut skill_roots = Vec::new(); + for sp in &manifest.skill_paths { + let abs = resolve_plugin_path(&discovery.plugin_root, sp); + if abs.exists() { skill_roots.push(abs); } + } + let plugin_id = format!("{}@codex-local", discovery.dir_name); + Ok(LoadedCodexPlugin { + plugin_id, + name: manifest.name, + version: manifest.version, + description: manifest.description, + root: discovery.plugin_root.clone(), + skill_roots, + hook_paths: manifest.hook_paths, + hooks_inline: manifest.hooks_inline, + mcp_servers: manifest.mcp_servers, + }) +} + +/// A loaded Codex plugin with resolved paths (read-only — no execution state). +/// Public so the assembly layer can wire skills, MCP, and hooks. +#[derive(Debug, Clone)] +pub struct LoadedCodexPlugin { + pub plugin_id: String, + pub name: String, + pub version: Option, + pub description: Option, + pub root: PathBuf, + pub skill_roots: Vec, + pub hook_paths: Vec, + pub hooks_inline: Option, + pub mcp_servers: Option, +} + +fn resolve_plugin_path(plugin_root: &Path, relative: &str) -> PathBuf { + // Reject paths that attempt directory traversal or are absolute. + let trimmed = relative.trim_start_matches("./").trim_start_matches(".\\"); + if trimmed.contains("..") || Path::new(trimmed).is_absolute() { + // Return a path under plugin_root that will never exist, effectively + // skipping this skill root at load time (caller checks .exists()). + return plugin_root.join(".invalid-path-traversal-rejected"); + } + plugin_root.join(trimmed) +} + +#[cfg(test)] +mod resolve_tests { + use super::*; + + #[test] + fn test_normal_relative_path() { + let root = Path::new("/plugins/my-plugin"); + let resolved = resolve_plugin_path(root, "./skills/"); + assert_eq!(resolved, Path::new("/plugins/my-plugin/skills/")); + } + + #[test] + fn test_parent_traversal_rejected() { + let root = Path::new("/plugins/my-plugin"); + let resolved = resolve_plugin_path(root, "../../../etc/passwd"); + assert!(resolved.to_string_lossy().contains(".invalid-path-traversal-rejected")); + } + + #[test] + fn test_absolute_path_rejected() { + let root = Path::new("/plugins/my-plugin"); + // Use a Windows-style absolute path on Windows, Unix-style on Unix. + let abs_path = if cfg!(windows) { "C:\\etc\\passwd" } else { "/etc/passwd" }; + let resolved = resolve_plugin_path(root, abs_path); + assert!(resolved.to_string_lossy().contains(".invalid-path-traversal-rejected")); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_scan_empty_dir() { + let tmp = std::env::temp_dir().join("codex_empty_test"); + let _ = std::fs::create_dir_all(&tmp); + let result = scan_directory(&tmp); + let _ = std::fs::remove_dir_all(&tmp); + assert!(result.is_empty()); + } + + #[test] + fn test_discover_personal_plugins() { + // Should find at least the test plugins we installed + let all = discover_all(None); + let names: Vec<&str> = all.iter().map(|d| d.dir_name.as_str()).collect(); + // These may or may not exist depending on whether test setup ran + eprintln!("Discovered plugins: {:?}", names); + // At minimum, the function should not panic + } +} diff --git a/src/crates/adapters/codex-adapter/src/event_map.rs b/src/crates/adapters/codex-adapter/src/event_map.rs new file mode 100644 index 0000000000..9b390d7fe2 --- /dev/null +++ b/src/crates/adapters/codex-adapter/src/event_map.rs @@ -0,0 +1,102 @@ +//! Codex → OpenCode hook event name translation. +//! +//! Codex plugin hooks use their own event naming convention (PreToolUse, +//! PostToolUse, etc.). This module maps them to OpenCode-compatible event +//! names so the existing lifecycle infrastructure can route them. + +/// Translates a Codex hook event name to an OpenCode event name. +/// Returns `None` if the event is not recognized. +pub fn translate_codex_event(codex_event: &str) -> Option<&'static str> { + match codex_event { + "PreToolUse" => Some("tool.execute.before"), + "PostToolUse" => Some("tool.execute.after"), + "PermissionRequest" => Some("permission.asked"), + "PreCompact" => Some("session.compacting"), + "PostCompact" => Some("session.compacted"), + "SessionStart" => Some("session.started"), + "UserPromptSubmit" => Some("user.prompt_submit"), + "SubagentStart" => Some("subagent.started"), + "SubagentStop" => Some("subagent.stopped"), + "Stop" => Some("session.stopping"), + _ => None, + } +} + +/// Returns all recognized Codex event names. +pub const CODEX_EVENT_NAMES: &[&str] = &[ + "PreToolUse", + "PermissionRequest", + "PostToolUse", + "PreCompact", + "PostCompact", + "SessionStart", + "UserPromptSubmit", + "SubagentStart", + "SubagentStop", + "Stop", +]; + +/// Returns the OpenCode event name for a Codex event, or the original name +/// if no translation is available. +pub fn translate_or_passthrough(codex_event: &str) -> &str { + translate_codex_event(codex_event).unwrap_or(codex_event) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_all_events_have_translations() { + for event in CODEX_EVENT_NAMES { + let translated = translate_codex_event(event); + assert!( + translated.is_some(), + "Codex event '{}' should have an OpenCode translation", + event + ); + } + } + + #[test] + fn test_pre_tool_use_maps_to_tool_execute_before() { + assert_eq!( + translate_codex_event("PreToolUse"), + Some("tool.execute.before") + ); + } + + #[test] + fn test_post_tool_use_maps_to_tool_execute_after() { + assert_eq!( + translate_codex_event("PostToolUse"), + Some("tool.execute.after") + ); + } + + #[test] + fn test_session_start_maps_correctly() { + assert_eq!( + translate_codex_event("SessionStart"), + Some("session.started") + ); + } + + #[test] + fn test_stop_maps_correctly() { + assert_eq!(translate_codex_event("Stop"), Some("session.stopping")); + } + + #[test] + fn test_unknown_event_returns_none() { + assert_eq!(translate_codex_event("UnknownEvent"), None); + } + + #[test] + fn test_passthrough_for_unknown_event() { + assert_eq!( + translate_or_passthrough("custom.hook"), + "custom.hook" + ); + } +} diff --git a/src/crates/adapters/codex-adapter/src/lib.rs b/src/crates/adapters/codex-adapter/src/lib.rs new file mode 100644 index 0000000000..262c39f124 --- /dev/null +++ b/src/crates/adapters/codex-adapter/src/lib.rs @@ -0,0 +1,16 @@ +//! Codex plugin adapter for BitFun. +//! +//! Standalone `PluginHostAdapter` for Codex `.codex-plugin/plugin.json` sources. +//! Independent from opencode-adapter — implements the same trait, managed by +//! Plugin Runtime Host as a peer. +//! +//! Public API budget: [`load_codex_compatible_adapter`]. + +mod source_adapter; +mod manifest; +pub mod discovery; +mod event_map; + +pub use source_adapter::load_codex_compatible_adapter; +pub use discovery::LoadedCodexPlugin; +pub use discovery::PluginDiscovery; diff --git a/src/crates/adapters/codex-adapter/src/manifest.rs b/src/crates/adapters/codex-adapter/src/manifest.rs new file mode 100644 index 0000000000..040f323722 --- /dev/null +++ b/src/crates/adapters/codex-adapter/src/manifest.rs @@ -0,0 +1,300 @@ +//! Codex `.codex-plugin/plugin.json` manifest parsing. +//! +//! Parses plugin.json files according to the Codex plugin specification. +//! This module is read-only — it only deserializes and validates manifests. +//! Plugin execution (hooks, MCP server startup) belongs in the assembly layer. + +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// Errors that can occur during manifest parsing. +#[derive(Debug, thiserror::Error)] +pub enum ManifestError { + #[error("I/O error reading manifest: {0}")] + Io(#[from] std::io::Error), + #[error("JSON parse error: {0}")] + Json(#[from] serde_json::Error), + #[error("Validation error: {0}")] + Validation(String), +} + +/// The raw plugin.json structure as deserialized from disk. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RawPluginManifest { + pub name: String, + #[serde(default)] + pub version: Option, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub author: Option, + #[serde(default)] + pub homepage: Option, + #[serde(default)] + pub repository: Option, + #[serde(default)] + pub license: Option, + #[serde(default)] + pub keywords: Vec, + #[serde(default)] + pub skills: Option, + #[serde(default)] + pub hooks: Option, + #[serde(default)] + pub mcp_servers: Option, + #[serde(default)] + pub apps: Option, + #[serde(default)] + pub interface: Option, +} + +/// Skills field: single path or array of paths. +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub enum SkillPathValue { + Single(String), + Multiple(Vec), +} + +impl<'de> Deserialize<'de> for SkillPathValue { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = SkillPathValue; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a string or array of strings") + } + fn visit_str(self, v: &str) -> Result { + Ok(SkillPathValue::Single(v.to_string())) + } + fn visit_seq>(self, mut s: A) -> Result { + let mut items = Vec::new(); + while let Some(item) = s.next_element::()? { items.push(item); } + Ok(SkillPathValue::Multiple(items)) + } + } + deserializer.deserialize_any(V) + } +} + +impl SkillPathValue { + pub fn as_paths(&self) -> Vec<&str> { + match self { Self::Single(s) => vec![s], Self::Multiple(v) => v.iter().map(|s| s.as_str()).collect() } + } +} + +/// Hooks field: path, array of paths, or inline object. +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub enum HookPathValue { + Single(String), + Multiple(Vec), + Inline(serde_json::Value), +} + +impl<'de> Deserialize<'de> for HookPathValue { + fn deserialize(deserializer: D) -> Result + where D: serde::Deserializer<'de> { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = HookPathValue; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a string, array of strings, or inline hooks object") + } + fn visit_str(self, v: &str) -> Result { + Ok(HookPathValue::Single(v.to_string())) + } + fn visit_seq>(self, mut s: A) -> Result { + let mut items = Vec::new(); + while let Some(item) = s.next_element::()? { items.push(item); } + Ok(HookPathValue::Multiple(items)) + } + fn visit_map>(self, m: A) -> Result { + use serde::de::value::MapAccessDeserializer; + Ok(HookPathValue::Inline(serde_json::Value::deserialize(MapAccessDeserializer::new(m))?)) + } + } + deserializer.deserialize_any(V) + } +} + +impl HookPathValue { + pub fn as_paths(&self) -> Vec<&str> { + match self { Self::Single(s) => vec![s], Self::Multiple(v) => v.iter().map(|s| s.as_str()).collect(), Self::Inline(_) => vec![] } + } + pub fn as_inline(&self) -> Option<&serde_json::Value> { + match self { Self::Inline(v) => Some(v), _ => None } + } +} + +/// MCP servers field: path or inline object. +#[derive(Debug, Clone, Serialize)] +#[serde(untagged)] +pub enum McpServersValue { + Path(String), + Inline(serde_json::Map), +} + +impl<'de> Deserialize<'de> for McpServersValue { + fn deserialize(deserializer: D) -> Result + where D: serde::Deserializer<'de> { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = McpServersValue; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("a string path or an inline MCP server object") + } + fn visit_str(self, v: &str) -> Result { + Ok(McpServersValue::Path(v.to_string())) + } + fn visit_map>(self, m: A) -> Result { + use serde::de::value::MapAccessDeserializer; + let v = serde_json::Value::deserialize(MapAccessDeserializer::new(m))?; + Ok(McpServersValue::Inline(v.as_object().cloned().unwrap_or_default())) + } + } + deserializer.deserialize_any(V) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginAuthor { + #[serde(default)] pub name: Option, + #[serde(default)] pub email: Option, + #[serde(default)] pub url: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PluginInterface { + #[serde(default)] pub display_name: Option, + #[serde(default)] pub short_description: Option, + #[serde(default)] pub long_description: Option, + #[serde(default)] pub developer_name: Option, + #[serde(default)] pub category: Option, + #[serde(default)] pub capabilities: Vec, + #[serde(default)] pub website_url: Option, + #[serde(default)] pub privacy_policy_url: Option, + #[serde(default)] pub terms_of_service_url: Option, + #[serde(default)] pub default_prompt: Vec, + #[serde(default)] pub brand_color: Option, + #[serde(default)] pub composer_icon: Option, + #[serde(default)] pub logo: Option, + #[serde(default)] pub logo_dark: Option, + #[serde(default)] pub screenshots: Vec, +} + +/// Parsed plugin manifest with resolved paths. +#[derive(Debug, Clone)] +pub struct PluginManifest { + pub name: String, + pub version: Option, + pub description: Option, + pub author: Option, + pub keywords: Vec, + pub skill_paths: Vec, + pub hook_paths: Vec, + pub hooks_inline: Option, + pub mcp_servers: Option, + pub app_path: Option, + pub interface: Option, +} + +pub const MANIFEST_PATHS: &[&str] = &[".codex-plugin/plugin.json", ".claude-plugin/plugin.json"]; + +/// Maximum allowed size for a plugin.json manifest (64 KiB). +const MAX_MANIFEST_SIZE_BYTES: u64 = 64 * 1024; + +pub fn parse_manifest(path: &Path) -> Result { + let content = std::fs::read_to_string(path)?; + parse_manifest_str(&content) +} + +pub fn parse_manifest_str(content: &str) -> Result { + // Reject overly large manifests before deserialization (DoS hardening). + if content.len() > MAX_MANIFEST_SIZE_BYTES as usize { + return Err(ManifestError::Validation(format!( + "Manifest exceeds maximum size of {MAX_MANIFEST_SIZE_BYTES} bytes" + ))); + } + let raw: RawPluginManifest = serde_json::from_str(content)?; + if raw.name.is_empty() { + return Err(ManifestError::Validation("'name' is required".to_string())); + } + let skill_paths = raw.skills.as_ref() + .map(|s| s.as_paths().iter().map(|p| p.to_string()).collect()) + .unwrap_or_else(|| vec!["./skills/".to_string()]); + let (hook_paths, hooks_inline) = match &raw.hooks { + Some(HookPathValue::Inline(v)) => (vec![], Some(v.clone())), + Some(other) => (other.as_paths().iter().map(|p| p.to_string()).collect(), None), + None => (vec![], None), + }; + Ok(PluginManifest { + name: raw.name, version: raw.version, description: raw.description, + author: raw.author, keywords: raw.keywords, + skill_paths, hook_paths, hooks_inline, + mcp_servers: raw.mcp_servers, app_path: raw.apps, + interface: raw.interface, + }) +} + +pub fn find_manifest_in_root(plugin_root: &Path) -> Option { + for name in MANIFEST_PATHS { + let p = plugin_root.join(name); + if p.exists() { return Some(p); } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_minimal_manifest() { + let m = parse_manifest_str(r#"{"name":"my-plugin"}"#).unwrap(); + assert_eq!(m.name, "my-plugin"); + assert_eq!(m.skill_paths, vec!["./skills/"]); + } + + #[test] + fn test_full_manifest() { + let m = parse_manifest_str(r#"{"name":"p","version":"1.0.0","description":"d","skills":["./a/","./b/"],"hooks":"./h.json","interface":{"displayName":"P","category":"X"}}"#).unwrap(); + assert_eq!(m.name, "p"); + assert_eq!(m.version, Some("1.0.0".into())); + assert_eq!(m.skill_paths.len(), 2); + assert_eq!(m.hook_paths, vec!["./h.json"]); + } + + #[test] + fn test_inline_mcp() { + let m = parse_manifest_str(r#"{"name":"m","mcpServers":{"s":{"type":"http","url":"https://e.com"}}}"#).unwrap(); + match &m.mcp_servers { + Some(McpServersValue::Inline(map)) => assert!(map.contains_key("s")), + _ => panic!("expected inline MCP"), + } + } + + #[test] + fn test_inline_hooks_empty_object() { + // superpowers plugin uses "hooks": {} + let m = parse_manifest_str(r#"{"name":"p","hooks":{}}"#).unwrap(); + assert!(m.hook_paths.is_empty()); + assert!(m.hooks_inline.is_some()); + } + + #[test] + fn test_empty_name_fails() { + assert!(parse_manifest_str(r#"{"name":""}"#).is_err()); + } + + #[test] + fn test_invalid_json_fails() { + assert!(parse_manifest_str("not json").is_err()); + } +} diff --git a/src/crates/adapters/codex-adapter/src/source_adapter.rs b/src/crates/adapters/codex-adapter/src/source_adapter.rs new file mode 100644 index 0000000000..cb1fc87cae --- /dev/null +++ b/src/crates/adapters/codex-adapter/src/source_adapter.rs @@ -0,0 +1,299 @@ +//! Codex Plugin Host Adapter. +//! +//! Standalone `PluginHostAdapter` for Codex `.codex-plugin/plugin.json` sources. +//! Implements the projection model like opencode-adapter. + +use async_trait::async_trait; +use bitfun_plugin_runtime_host::PluginHostAdapter; +use bitfun_runtime_ports::{ + PluginAuditRef, PluginDiagnostic, PluginDiagnosticDetail, PluginDiagnosticSeverity, + PluginDispatchEnvelope, PluginResponseEnvelope, PluginRuntimeAvailability, + PluginRuntimeReadRequest, PluginRuntimeReadResponse, + PluginSourceKind, PluginSourceRef, + PluginStatusKind, PluginStatusSnapshot, PluginTrustLevel, PortResult, +}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use super::discovery; + +const ADAPTER_ID: &str = "codex-compatible"; + +// ============================================================================ +// Projection +// ============================================================================ + +struct CodexProjection { + source: PluginSourceRef, + plugin_name: String, + load_diagnostics: Vec, + has_hooks: bool, + has_mcp: bool, + skill_roots: Vec, +} + +impl CodexProjection { + fn read_diagnostics(&self) -> Vec { + self.load_diagnostics.clone() + } + + fn status_snapshot(&self) -> PluginStatusSnapshot { + PluginStatusSnapshot { + source: self.source.clone(), + status: PluginStatusKind::Enabled, + availability: PluginRuntimeAvailability::projection_only( + bitfun_runtime_ports::PluginRuntimeUnavailableReason::NotBuilt, + ), + config_validation: None, + quarantine: None, + diagnostic_ids: Vec::new(), + updated_at_ms: 0, + } + } + + fn dispatch_response(&self, envelope: &PluginDispatchEnvelope) -> PluginResponseEnvelope { + let mut diagnostics = Vec::new(); + + if !super::event_map::CODEX_EVENT_NAMES.contains(&envelope.extension_point_id.as_str()) { + diagnostics.push(PluginDiagnostic { + diagnostic_id: format!("codex.unsupported.{}", envelope.extension_point_id), + severity: PluginDiagnosticSeverity::Info, + source: self.source.clone(), + code: "codex.event_unsupported".to_string(), + message: format!("event '{}' not supported", envelope.extension_point_id), + detail: PluginDiagnosticDetail::Adapter { + adapter_id: ADAPTER_ID.to_string(), + }, + audit: PluginAuditRef { + correlation_id: envelope.correlation_id.clone(), + event_id: Some(envelope.event_id.clone()), + }, + retryable: false, + }); + } + + PluginResponseEnvelope { + envelope_version: 1, + request_event_id: envelope.event_id.clone(), + project_domain_id: envelope.project_domain_id.clone(), + workspace_id: envelope.workspace_id.clone(), + adapter_id: ADAPTER_ID.to_string(), + plugin_id: Some(self.source.plugin_id.clone()), + completed_at_ms: 0, + effects: Vec::new(), + diagnostics, + quarantine: None, + plugin_statuses: Vec::new(), + observed_epochs: envelope.epochs.clone(), + } + } +} + +// ============================================================================ +// Adapter +// ============================================================================ + +pub struct CodexPluginHostAdapter { + projections: Vec, + source_trust_epoch: u64, +} + +#[async_trait] +impl PluginHostAdapter for CodexPluginHostAdapter { + fn adapter_id(&self) -> &str { + ADAPTER_ID + } + + async fn read_plugins( + &self, + request: PluginRuntimeReadRequest, + ) -> PortResult { + let mut sources = Vec::new(); + let mut plugin_statuses = Vec::new(); + let mut diagnostics = Vec::new(); + + for p in self + .projections + .iter() + .filter(|p| request.plugin_ids.is_empty() || request.plugin_ids.contains(&p.source.plugin_id)) + { + let ds = p.read_diagnostics(); + sources.push(p.source.clone()); + plugin_statuses.push(p.status_snapshot()); + diagnostics.extend(ds); + } + + Ok(PluginRuntimeReadResponse { + request_id: request.request_id, + project_domain_id: request.project_domain_id, + workspace_id: request.workspace_id, + sources, + plugin_statuses, + diagnostics, + observed_epochs: request.epochs, + }) + } + + async fn dispatch( + &self, + envelope: PluginDispatchEnvelope, + ) -> PortResult { + // Reject stale trust epochs — the caller must provide a current epoch. + if envelope.epochs.trust_epoch != self.source_trust_epoch { + return Ok(PluginResponseEnvelope { + envelope_version: 1, + request_event_id: envelope.event_id.clone(), + project_domain_id: envelope.project_domain_id.clone(), + workspace_id: envelope.workspace_id.clone(), + adapter_id: ADAPTER_ID.to_string(), + plugin_id: Some(envelope.source.plugin_id.clone()), + completed_at_ms: 0, + effects: Vec::new(), + diagnostics: vec![PluginDiagnostic { + diagnostic_id: "codex.trust_epoch_stale".to_string(), + severity: PluginDiagnosticSeverity::Error, + source: envelope.source.clone(), + code: "codex.trust_epoch_stale".to_string(), + message: "Codex plugin trust epoch is stale".to_string(), + detail: PluginDiagnosticDetail::Trust { + trust_level: PluginTrustLevel::Unknown, + }, + audit: PluginAuditRef { + correlation_id: envelope.correlation_id.clone(), + event_id: Some(envelope.event_id.clone()), + }, + retryable: true, + }], + quarantine: None, + plugin_statuses: Vec::new(), + observed_epochs: envelope.epochs.clone(), + }); + } + match self.projection_for_source(&envelope.source) { + Some(p) => Ok(p.dispatch_response(&envelope)), + None => Ok(PluginResponseEnvelope { + envelope_version: 1, + request_event_id: envelope.event_id.clone(), + project_domain_id: envelope.project_domain_id.clone(), + workspace_id: envelope.workspace_id.clone(), + adapter_id: ADAPTER_ID.to_string(), + plugin_id: Some(envelope.source.plugin_id.clone()), + completed_at_ms: 0, + effects: Vec::new(), + diagnostics: vec![PluginDiagnostic { + diagnostic_id: "codex.source_mismatch".to_string(), + severity: PluginDiagnosticSeverity::Info, + source: envelope.source.clone(), + code: "codex.source_mismatch".to_string(), + message: format!("no projection for '{}'", envelope.source.plugin_id), + detail: PluginDiagnosticDetail::Adapter { + adapter_id: ADAPTER_ID.to_string(), + }, + audit: PluginAuditRef { + correlation_id: envelope.correlation_id.clone(), + event_id: Some(envelope.event_id.clone()), + }, + retryable: false, + }], + quarantine: None, + plugin_statuses: Vec::new(), + observed_epochs: envelope.epochs.clone(), + }), + } + } +} + +impl CodexPluginHostAdapter { + fn projection_for_source(&self, source: &PluginSourceRef) -> Option<&CodexProjection> { + self.projections + .iter() + .find(|p| source_identity_matches(&p.source, source)) + } +} + +// ============================================================================ +// Factory +// ============================================================================ + +fn compute_content_hash(plugin_root: &Path, version: &Option) -> String { + let mut h = Sha256::new(); + h.update(plugin_root.to_string_lossy().as_bytes()); + h.update(b":"); + if let Some(v) = version { + h.update(v.as_bytes()); + } else { + h.update(b"0.0.0"); + } + h.update(b":codex-plugin:v1"); + format!("sha256:{:x}", h.finalize()) +} + +fn source_identity_matches(left: &PluginSourceRef, right: &PluginSourceRef) -> bool { + left.plugin_id == right.plugin_id + && left.source_kind == right.source_kind + && left.source == right.source + && left.version == right.version + && left.content_hash == right.content_hash +} + +fn find_trust_level( + source_trust_refs: &[PluginSourceRef], + candidate: &PluginSourceRef, +) -> PluginTrustLevel { + for r in source_trust_refs { + if source_identity_matches(r, candidate) { + return r.trust_level; + } + } + PluginTrustLevel::Unknown +} + +pub fn load_codex_compatible_adapter( + project_root: impl AsRef, + _observed_at_ms: u64, + source_trust_epoch: u64, + source_trust_refs: &[PluginSourceRef], +) -> PortResult> { + let project = project_root.as_ref(); + let discoveries = discovery::discover_all(Some(project)); + let mut projections = Vec::new(); + + for d in &discoveries { + match discovery::load_plugin_manifest(d) { + Ok(plugin) => { + let content_hash = compute_content_hash(&plugin.root, &plugin.version); + let source_ref = PluginSourceRef { + plugin_id: plugin.plugin_id.clone(), + source_kind: PluginSourceKind::OpenCodeCompatible, + source: plugin.root.to_string_lossy().to_string(), + version: plugin.version.clone(), + content_hash, + trust_level: PluginTrustLevel::Unknown, // placeholder — resolved below + manifest: None, + }; + let trust_level = find_trust_level(source_trust_refs, &source_ref); + projections.push(CodexProjection { + source: PluginSourceRef { + trust_level, + ..source_ref + }, + load_diagnostics: Vec::new(), + plugin_name: plugin.name.clone(), + has_hooks: !plugin.hook_paths.is_empty() || plugin.hooks_inline.is_some(), + has_mcp: plugin.mcp_servers.is_some(), + skill_roots: plugin.skill_roots.clone(), + }); + } + Err(e) => log::warn!( + "Codex adapter: failed to load {}: {e}", + d.manifest_path.display() + ), + } + } + + Ok(Arc::new(CodexPluginHostAdapter { + projections, + source_trust_epoch, + })) +} diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 772dd93bc3..5ed9cd1826 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -88,6 +88,9 @@ bitfun-product-capabilities = { path = "../product-capabilities", default-featur # Agent tool contracts bitfun-agent-tools = { path = "../../execution/tool-contracts" } +# Codex plugin adapter (OpenCode + Codex plugin support) +bitfun-codex-adapter = { path = "../../adapters/codex-adapter" } + # Tool pack provider plan bitfun-tool-packs = { path = "../../execution/tool-provider-groups", default-features = false, optional = true } diff --git a/src/crates/assembly/core/src/agentic/codex_integration/hooks_executor.rs b/src/crates/assembly/core/src/agentic/codex_integration/hooks_executor.rs new file mode 100644 index 0000000000..a128f5c0e7 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/codex_integration/hooks_executor.rs @@ -0,0 +1,82 @@ +//! Codex hooks command executor. +//! +//! Executes Codex plugin hooks commands (shell processes) at agent lifecycle +//! points. Hook configuration is discovered by the codex-adapter and translated +//! to OpenCode event names. This module spawns subprocesses, passes JSON input +//! via stdin, and parses JSON output from stdout. +//! +//! Hook execution follows the Codex specification: external command + JSON +//! stdin/stdout protocol, with timeout enforcement and fail-open semantics. + +use std::collections::HashMap; +use std::time::Duration; +use tokio::process::Command as TokioCommand; + +/// Default hook timeout (10 minutes). +const DEFAULT_HOOK_TIMEOUT_SECS: u64 = 600; + +/// Configuration for a single hook handler to execute. +#[derive(Debug, Clone)] +pub struct HookHandler { + /// Translated event name (e.g., "tool.execute.before") + pub event_name: String, + /// Shell command to execute + pub command: String, + /// Timeout in seconds + pub timeout_secs: u64, +} + +/// Result of executing a hook command. +#[derive(Debug)] +pub enum HookResult { + Success { stdout: String, exit_code: i32 }, + Timeout, + Error(String), +} + +/// Executes a single hook command. +pub async fn execute_hook(handler: &HookHandler, input_json: &str) -> HookResult { + let shell = if cfg!(windows) { + std::env::var("COMSPEC").unwrap_or_else(|_| "cmd.exe".to_string()) + } else { + std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()) + }; + + let shell_args = if cfg!(windows) { + vec!["/C".to_string(), handler.command.clone()] + } else { + vec!["-lc".to_string(), handler.command.clone()] + }; + + let mut cmd = TokioCommand::new(&shell); + cmd.args(&shell_args); + cmd.stdin(std::process::Stdio::piped()); + cmd.stdout(std::process::Stdio::piped()); + cmd.stderr(std::process::Stdio::piped()); + cmd.kill_on_drop(true); + + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => return HookResult::Error(format!("spawn failed: {e}")), + }; + + // Write stdin + use tokio::io::AsyncWriteExt; + if let Some(mut stdin) = child.stdin.take() { + if let Err(e) = stdin.write_all(input_json.as_bytes()).await { + return HookResult::Error(format!("stdin write failed: {e}")); + } + } + + let timeout = Duration::from_secs(handler.timeout_secs); + let output = match tokio::time::timeout(timeout, child.wait_with_output()).await { + Ok(Ok(o)) => o, + Ok(Err(e)) => return HookResult::Error(format!("process error: {e}")), + Err(_) => return HookResult::Timeout, + }; + + HookResult::Success { + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + exit_code: output.status.code().unwrap_or(-1), + } +} diff --git a/src/crates/assembly/core/src/agentic/codex_integration/mod.rs b/src/crates/assembly/core/src/agentic/codex_integration/mod.rs new file mode 100644 index 0000000000..6ba51e420f --- /dev/null +++ b/src/crates/assembly/core/src/agentic/codex_integration/mod.rs @@ -0,0 +1,611 @@ +//! Codex plugin integration — assembly layer. +//! +//! Bridges the codex-adapter output into BitFun's product subsystems +//! (SkillRegistry) through the PluginRuntimeHost trust chain. +//! +//! Flow: discover → build trust refs → load adapter → PluginRuntimeHost → +//! read_plugins → register only Trusted skill roots in SkillRegistry. + +// hooks_executor is gated behind cfg(test) until production lifecycle wiring +// is complete. See review finding: dead code without production callers. +#[cfg(test)] +mod hooks_executor; + +use crate::agentic::tools::implementations::skills::registry::SkillRegistry; +use bitfun_codex_adapter::load_codex_compatible_adapter; +use bitfun_plugin_runtime_host::PluginRuntimeHost; +use bitfun_runtime_ports::{ + PluginRuntimeReadRequest, PluginRuntimeEpochs, PluginSourceKind, + PluginSourceRef, PluginTrustLevel, +}; +use log::{info, warn}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// Initializes plugin support (OpenCode + Codex). +/// +/// Called once during agentic system startup. Discovers Codex plugins from the +/// filesystem, routes them through the PluginRuntimeHost trust chain, and +/// registers only Trusted plugin skills in the SkillRegistry. +/// +/// Untrusted, Denied, or Revoked plugin sources are reported via diagnostics +/// but their skills are never loaded into the agent context. +pub async fn initialize_plugin_support(workspace_root: Option<&Path>) { + info!("Initializing plugin support (Codex) via PluginRuntimeHost..."); + + // ── Phase 1: discover available plugins ──────────────────────────────── + let discoveries = bitfun_codex_adapter::discovery::discover_all(workspace_root); + let discovered_plugins: Vec<_> = discoveries + .iter() + .filter_map(|d| bitfun_codex_adapter::discovery::load_plugin_manifest(d).ok()) + .collect(); + + if discovered_plugins.is_empty() { + info!("No Codex plugins discovered — plugin support idle."); + return; + } + + // ── Phase 2: build trust refs — local filesystem plugins are Trusted ──── + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + // For local filesystem plugins, we trust them by default. + // A future managed source/config layer may override this with + // Denied/Revoked entries for specific paths. + let trust_refs: Vec = discovered_plugins + .iter() + .map(|p| PluginSourceRef { + plugin_id: p.plugin_id.clone(), + source_kind: PluginSourceKind::OpenCodeCompatible, + source: p.root.to_string_lossy().to_string(), + version: p.version.clone(), + content_hash: String::new(), // trust refs match by full identity + trust_level: PluginTrustLevel::Trusted, + manifest: None, + }) + .collect(); + + let trust_epoch = now_ms; + + // ── Phase 3: load adapter through PluginRuntimeHost ──────────────────── + let project_root = workspace_root + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")); + + let adapter = match load_codex_compatible_adapter( + &project_root, + now_ms, + trust_epoch, + &trust_refs, + ) { + Ok(a) => a, + Err(e) => { + warn!("Failed to load Codex adapter: {e}"); + return; + } + }; + + let host: Arc = + Arc::new(PluginRuntimeHost::new(adapter)); + + // ── Phase 4: read plugins through the host ───────────────────────────── + let epochs = PluginRuntimeEpochs { + project_epoch: 0, + trust_epoch, + policy_epoch: 0, + tool_registry_epoch: None, + }; + + let read_result = host + .read_plugins(PluginRuntimeReadRequest { + request_id: "codex-init".to_string(), + project_domain_id: String::new(), + workspace_id: workspace_root + .map(|p| p.to_string_lossy().to_string()) + .unwrap_or_default(), + plugin_ids: Vec::new(), + epochs: epochs.clone(), + include_config_validation: false, + }) + .await; + + let response = match read_result { + Ok(r) => r, + Err(e) => { + warn!("PluginRuntimeHost::read_plugins failed: {e}"); + return; + } + }; + + // Log diagnostics for visibility (non-blocking). + for d in &response.diagnostics { + warn!( + "Codex plugin diagnostic [{}]: {}", + d.code, d.message + ); + } + + // ── Phase 5: register only Trusted plugin skills ─────────────────────── + let registry = SkillRegistry::global(); + let mut trusted_count = 0u32; + + for source in &response.sources { + if source.trust_level != PluginTrustLevel::Trusted { + info!( + "Codex plugin '{}' is {:?} — skills gated.", + source.plugin_id, source.trust_level + ); + continue; + } + + // Map the source back to the plugin to extract skill roots. + if let Some(plugin) = discovered_plugins + .iter() + .find(|p| p.plugin_id == source.plugin_id) + { + info!( + "Codex plugin '{}' v{} (trusted) — {} skill roots", + plugin.name, + plugin.version.as_deref().unwrap_or("?"), + plugin.skill_roots.len() + ); + + for root in &plugin.skill_roots { + registry + .add_plugin_skill_root(root.clone(), &plugin.plugin_id, workspace_root) + .await; + info!(" registered skill root: {}", root.display()); + } + trusted_count += 1; + } + } + + if trusted_count > 0 { + registry.refresh().await; + let all_skills = registry.get_all_skills_for_workspace(workspace_root).await; + let plugin_skill_names: Vec<&str> = all_skills + .iter() + .filter(|s| s.source_slot == "plugin") + .map(|s| s.name.as_str()) + .collect(); + info!( + "Plugin skills registered: {:?} ({} total)", + plugin_skill_names, + plugin_skill_names.len() + ); + } + + info!( + "Plugin support initialized: {} trusted codex plugin(s), {} total discovered", + trusted_count, + discovered_plugins.len() + ); +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + /// Build a minimal Codex plugin in a tempdir for reproducible testing. + fn build_test_plugin(dir: &Path, name: &str, skill_name: &str, skill_content: &str) { + let plugin_dir = dir.join(name); + let codex_dir = plugin_dir.join(".codex-plugin"); + fs::create_dir_all(&codex_dir).unwrap(); + + let manifest = format!( + r#"{{ + "name": "{name}", + "version": "1.0.0", + "skills": ["./skills/"] + }}"# + ); + fs::write(codex_dir.join("plugin.json"), manifest).unwrap(); + + let skills_dir = plugin_dir.join("skills").join(skill_name); + fs::create_dir_all(&skills_dir).unwrap(); + fs::write(skills_dir.join("SKILL.md"), skill_content).unwrap(); + } + + /// Creates plugins in a workspace-like tempdir. + fn setup_workspace_with_plugins() -> (TempDir, PathBuf) { + let workspace = TempDir::new().unwrap(); + let plugins_dir = workspace.path().join(".agents").join("plugins"); + fs::create_dir_all(&plugins_dir).unwrap(); + + build_test_plugin( + &plugins_dir, + "test-plugin-a", + "hello-skill", + "---\nname: hello-skill\ndescription: A test hello skill\n---\n\n# Hello Skill\n\nSay hello.", + ); + + build_test_plugin( + &plugins_dir, + "test-plugin-b", + "tool-skill", + "---\nname: tool-skill\ndescription: A test tool skill\n---\n\n# Tool Skill\n\nTool usage.", + ); + + let ws_root = workspace.path().to_path_buf(); + (workspace, ws_root) + } + + #[tokio::test] + async fn test_e2e_plugin_discovery_and_skill_registration() { + let (_workspace, ws_root) = setup_workspace_with_plugins(); + + // Discover plugins from the workspace's .agents/plugins/ + let discoveries = bitfun_codex_adapter::discovery::discover_all(Some(&ws_root)); + let plugins: Vec<_> = discoveries + .iter() + .filter_map(|d| bitfun_codex_adapter::discovery::load_plugin_manifest(d).ok()) + .collect(); + + eprintln!("=== E2E Test: Discovered {} plugin(s) ===", plugins.len()); + for p in &plugins { + eprintln!( + " {} v{} — skills at: {:?}", + p.name, + p.version.as_deref().unwrap_or("?"), + p.skill_roots + ); + } + + assert!( + !plugins.is_empty(), + "Should discover at least one test plugin" + ); + + // Compute content_hash the same way the adapter does. + fn content_hash_for(root: &Path, version: &Option) -> String { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(root.to_string_lossy().as_bytes()); + h.update(b":"); + if let Some(v) = version { + h.update(v.as_bytes()); + } else { + h.update(b"0.0.0"); + } + h.update(b":codex-plugin:v1"); + format!("sha256:{:x}", h.finalize()) + } + + // Build trust refs with correct content_hash (Trusted for local plugins). + let trust_refs: Vec = plugins + .iter() + .map(|p| PluginSourceRef { + plugin_id: p.plugin_id.clone(), + source_kind: PluginSourceKind::OpenCodeCompatible, + source: p.root.to_string_lossy().to_string(), + version: p.version.clone(), + content_hash: content_hash_for(&p.root, &p.version), + trust_level: PluginTrustLevel::Trusted, + manifest: None, + }) + .collect(); + + // Load through adapter. + let adapter = load_codex_compatible_adapter( + &ws_root, + 1_000_000, + 1_000_000, + &trust_refs, + ) + .expect("adapter should load"); + + let host: Arc = + Arc::new(PluginRuntimeHost::new(adapter)); + + let read_resp = host + .read_plugins(PluginRuntimeReadRequest { + request_id: "test-init".to_string(), + project_domain_id: String::new(), + workspace_id: String::new(), + plugin_ids: Vec::new(), + epochs: PluginRuntimeEpochs { + project_epoch: 0, + trust_epoch: 1_000_000, + policy_epoch: 0, + tool_registry_epoch: None, + }, + include_config_validation: false, + }) + .await + .expect("read_plugins should succeed"); + + let trusted: Vec<_> = read_resp + .sources + .iter() + .filter(|s| s.trust_level == PluginTrustLevel::Trusted) + .collect(); + assert!(!trusted.is_empty(), "All plugins should be Trusted"); + + // Register trusted plugin skill roots into registry. + let registry = SkillRegistry::global(); + for plugin in &plugins { + for root in &plugin.skill_roots { + registry + .add_plugin_skill_root(root.clone(), &plugin.plugin_id, Some(&ws_root)) + .await; + } + } + registry.refresh().await; + let all_skills = registry.get_all_skills_for_workspace(Some(&ws_root)).await; + let plugin_skills: Vec<&str> = all_skills + .iter() + .filter(|s| s.source_slot == "plugin") + .map(|s| s.name.as_str()) + .collect(); + + eprintln!("=== Plugin skills in registry: {:?} ===", plugin_skills); + assert!( + plugin_skills.contains(&"hello-skill"), + "hello-skill should be registered. Got: {:?}", + plugin_skills + ); + + // Clean up workspace plugin roots. + registry.remove_plugin_skill_roots_for_workspace(Some(&ws_root)).await; + } + + #[tokio::test] + async fn test_denied_plugin_skills_are_gated() { + let (_workspace, ws_root) = setup_workspace_with_plugins(); + + let discoveries = bitfun_codex_adapter::discovery::discover_all(Some(&ws_root)); + let plugins: Vec<_> = discoveries + .iter() + .filter_map(|d| bitfun_codex_adapter::discovery::load_plugin_manifest(d).ok()) + .collect(); + assert!(!plugins.is_empty()); + + fn content_hash_for(root: &Path, version: &Option) -> String { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(root.to_string_lossy().as_bytes()); + h.update(b":"); + if let Some(v) = version { + h.update(v.as_bytes()); + } else { + h.update(b"0.0.0"); + } + h.update(b":codex-plugin:v1"); + format!("sha256:{:x}", h.finalize()) + } + + // All refs as Denied → no skills should be registered. + let denied_refs: Vec = plugins + .iter() + .map(|p| PluginSourceRef { + plugin_id: p.plugin_id.clone(), + source_kind: PluginSourceKind::OpenCodeCompatible, + source: p.root.to_string_lossy().to_string(), + version: p.version.clone(), + content_hash: content_hash_for(&p.root, &p.version), + trust_level: PluginTrustLevel::Denied, + manifest: None, + }) + .collect(); + + let adapter = load_codex_compatible_adapter( + &ws_root, + 2_000_000, + 2_000_000, + &denied_refs, + ) + .expect("adapter should load"); + + let host: Arc = + Arc::new(PluginRuntimeHost::new(adapter)); + + let read_resp = host + .read_plugins(PluginRuntimeReadRequest { + request_id: "test-deny".to_string(), + project_domain_id: String::new(), + workspace_id: String::new(), + plugin_ids: Vec::new(), + epochs: PluginRuntimeEpochs { + project_epoch: 0, + trust_epoch: 2_000_000, + policy_epoch: 0, + tool_registry_epoch: None, + }, + include_config_validation: false, + }) + .await + .expect("read_plugins should succeed"); + + let trusted: Vec<_> = read_resp + .sources + .iter() + .filter(|s| s.trust_level == PluginTrustLevel::Trusted) + .collect(); + assert!(trusted.is_empty(), "No plugin should be Trusted when all are Denied"); + } + + #[tokio::test] + async fn test_epoch_stale_dispatch_is_rejected() { + let (_workspace, ws_root) = setup_workspace_with_plugins(); + + let discoveries = bitfun_codex_adapter::discovery::discover_all(Some(&ws_root)); + let plugins: Vec<_> = discoveries + .iter() + .filter_map(|d| bitfun_codex_adapter::discovery::load_plugin_manifest(d).ok()) + .collect(); + assert!(!plugins.is_empty()); + + fn content_hash_for(root: &Path, version: &Option) -> String { + use sha2::{Digest, Sha256}; + let mut h = Sha256::new(); + h.update(root.to_string_lossy().as_bytes()); + h.update(b":"); + if let Some(v) = version { + h.update(v.as_bytes()); + } else { + h.update(b"0.0.0"); + } + h.update(b":codex-plugin:v1"); + format!("sha256:{:x}", h.finalize()) + } + + let trust_refs: Vec = plugins + .iter() + .map(|p| PluginSourceRef { + plugin_id: p.plugin_id.clone(), + source_kind: PluginSourceKind::OpenCodeCompatible, + source: p.root.to_string_lossy().to_string(), + version: p.version.clone(), + content_hash: content_hash_for(&p.root, &p.version), + trust_level: PluginTrustLevel::Trusted, + manifest: None, + }) + .collect(); + + // Adapter created with epoch 3_000_000. + let adapter = load_codex_compatible_adapter( + &ws_root, + 3_000_000, + 3_000_000, + &trust_refs, + ) + .expect("adapter should load"); + + let host: Arc = + Arc::new(PluginRuntimeHost::new(adapter)); + + // Dispatch with a mismatched trust_epoch. + let first_source = &plugins[0]; + let response = host + .dispatch(bitfun_runtime_ports::PluginDispatchEnvelope { + envelope_version: 1, + event_id: "test-dispatch".to_string(), + event_type: "hook".to_string(), + event_version: "1".to_string(), + project_domain_id: "test-project".to_string(), + workspace_id: "test-workspace".to_string(), + source: PluginSourceRef { + plugin_id: first_source.plugin_id.clone(), + source_kind: PluginSourceKind::OpenCodeCompatible, + source: first_source.root.to_string_lossy().to_string(), + version: first_source.version.clone(), + content_hash: content_hash_for(&first_source.root, &first_source.version), + trust_level: PluginTrustLevel::Trusted, + manifest: None, + }, + extension_point_id: "SessionStart".to_string(), + declared_capability: bitfun_runtime_ports::PluginCapabilityRef { + capability_id: "test.capability".to_string(), + owner: bitfun_runtime_ports::PluginOwnerRef { + kind: bitfun_runtime_ports::PluginOwnerKind::ProductFeature, + id: "test-owner".to_string(), + }, + }, + correlation_id: "test-corr".to_string(), + causation_id: None, + idempotency_key: "test-idem".to_string(), + deadline_ms: 10_000, + epochs: PluginRuntimeEpochs { + project_epoch: 0, + trust_epoch: 9_999_999, // mismatched! + policy_epoch: 0, + tool_registry_epoch: None, + }, + payload_ref: None, + }) + .await + .expect("dispatch should not error"); + + assert!( + response + .diagnostics + .iter() + .any(|d| d.code == "codex.trust_epoch_stale"), + "Mismatched epoch should produce trust_epoch_stale diagnostic. Got: {:?}", + response.diagnostics + ); + } + + #[tokio::test] + async fn test_workspace_isolation() { + let registry = SkillRegistry::global(); + + // Register under workspace A. + let binding_a = PathBuf::from("/ws/a"); + let ws_a = Some(binding_a.as_path()); + registry + .add_plugin_skill_root( + PathBuf::from("/ws/a/plugin/skills"), + "test.a@codex-local", + ws_a, + ) + .await; + + // Register under workspace B. + let binding_b = PathBuf::from("/ws/b"); + let ws_b = Some(binding_b.as_path()); + registry + .add_plugin_skill_root( + PathBuf::from("/ws/b/plugin/skills"), + "test.b@codex-local", + ws_b, + ) + .await; + + // Verify removal for a specific workspace. + registry.remove_plugin_skill_roots_for_workspace(ws_a).await; + + // Workspace B's roots should still exist (not affected). + { + let roots = registry.plugin_skill_roots.read().await; + assert!( + !roots.contains_key(&ws_a.map(|p| p.to_path_buf())), + "Workspace A roots should be removed" + ); + assert!( + roots.contains_key(&ws_b.map(|p| p.to_path_buf())), + "Workspace B roots should still exist" + ); + } + + // Clean up workspace B too. + registry.remove_plugin_skill_roots_for_workspace(ws_b).await; + } + + #[test] + fn test_hooks_executor_echo() { + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(async { + // Note: hooks_executor is #[cfg(test)], so this module can access it. + let handler = super::hooks_executor::HookHandler { + event_name: "tool.execute.before".to_string(), + command: if cfg!(windows) { + "cmd /c echo continue:true".to_string() + } else { + "echo continue:true".to_string() + }, + timeout_secs: 10, + }; + let result = + super::hooks_executor::execute_hook(&handler, "{}") + .await; + match result { + super::hooks_executor::HookResult::Success { + stdout, .. + } => { + eprintln!("Hook stdout: '{}'", stdout.trim()); + assert!( + stdout.contains("continue:true"), + "Hook should output 'continue:true'. Got: '{}'", + stdout + ); + } + other => panic!("Expected Success, got: {:?}", other), + } + }); + } +} diff --git a/src/crates/assembly/core/src/agentic/mod.rs b/src/crates/assembly/core/src/agentic/mod.rs index ac8230b483..49fc23d5d1 100644 --- a/src/crates/assembly/core/src/agentic/mod.rs +++ b/src/crates/assembly/core/src/agentic/mod.rs @@ -19,6 +19,8 @@ pub mod execution; pub mod tools; // Coordination module +#[cfg(feature = "product-full")] +pub mod codex_integration; pub mod context_profile; pub mod coordination; pub mod deep_review; diff --git a/src/crates/assembly/core/src/agentic/system.rs b/src/crates/assembly/core/src/agentic/system.rs index b9a264ed9a..0551626883 100644 --- a/src/crates/assembly/core/src/agentic/system.rs +++ b/src/crates/assembly/core/src/agentic/system.rs @@ -1,5 +1,6 @@ //! Agentic system assembly shared by CLI, ACP, and other hosts. +use std::path::Path; use std::sync::Arc; use anyhow::Result; @@ -26,6 +27,16 @@ pub struct AgenticSystem { /// Initialize the agentic runtime and register the global coordinator. pub async fn init_agentic_system() -> Result { + init_agentic_system_with_workspace(None).await +} + +/// Initialize the agentic runtime with an explicit workspace root. +/// +/// When `workspace_root` is provided, plugin discovery scans the workspace's +/// `.agents/plugins/` directory in addition to the user's `~/.agents/plugins/`. +pub async fn init_agentic_system_with_workspace( + workspace_root: Option<&Path>, +) -> Result { info!("Initializing agentic system"); let _ai_client_factory = AIClientFactory::get_global().await?; @@ -105,6 +116,24 @@ pub async fn init_agentic_system() -> Result { info!("Agentic system initialization complete"); + // Initialize plugin support (OpenCode + Codex adapters). + // Discovers and registers plugin skills — gated behind product-full + // because it depends on the plugin runtime host and the codex adapter. + #[cfg(feature = "product-full")] + { + let ws_root = workspace_root.map(|p| p.to_path_buf()); + let _ = tokio::task::spawn_blocking(move || { + let rt = tokio::runtime::Handle::current(); + rt.block_on(async { + crate::agentic::codex_integration::initialize_plugin_support( + ws_root.as_deref(), + ) + .await; + }) + }) + .await; + } + Ok(AgenticSystem { coordinator, event_queue, diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs index 867170875e..f19eeae138 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/registry.rs @@ -21,7 +21,7 @@ use bitfun_agent_runtime::skills::{ PROJECT_SKILL_ROOTS, USER_CONFIG_SKILL_ROOTS, USER_HOME_SKILL_ROOTS, USER_SKILL_KEY_PREFIX, }; use log::{debug, error}; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::path::{Path, PathBuf}; use std::sync::OnceLock; use tokio::fs; @@ -60,12 +60,23 @@ fn sort_remote_dir_entries(entries: &mut [crate::agentic::workspace::WorkspaceDi pub struct SkillRegistry { /// Cached raw user-level skills (no workspace-specific project skills). cache: RwLock>, + /// Plugin-contributed skill roots (for codex/opencode adapters). + /// Keyed by workspace root: `Some(path)` for local workspaces, `None` for the + /// global (non-workspace) fallback. Per-workspace isolation prevents plugin + /// roots from workspace A leaking into workspace B. + pub(crate) plugin_skill_roots: RwLock, Vec<(PathBuf, String)>>>, + /// Workspace-aware skill cache keyed by workspace root path. + /// Populated on first call to get_all_skills_for_workspace and + /// invalidated by refresh / add_plugin_skill_root. + workspace_cache: RwLock, Vec>>, } impl SkillRegistry { fn new() -> Self { Self { cache: RwLock::new(Vec::new()), + plugin_skill_roots: RwLock::new(HashMap::new()), + workspace_cache: RwLock::new(HashMap::new()), } } @@ -73,6 +84,42 @@ impl SkillRegistry { SKILL_REGISTRY.get_or_init(Self::new) } + /// Registers a plugin-contributed skill root directory for a specific workspace. + /// Duplicate (path, plugin_id) pairs per workspace are silently ignored. + pub async fn add_plugin_skill_root(&self, path: PathBuf, plugin_id: &str, workspace_root: Option<&Path>) { + let ws_key = workspace_root.map(|p| p.to_path_buf()); + let slot = format!("plugin.{plugin_id}"); + { + let roots = self.plugin_skill_roots.read().await; + if let Some(existing) = roots.get(&ws_key) { + if existing.iter().any(|(p, s)| p == &path && s == &slot) { + return; // Already registered — skip duplicate. + } + } + } + { + let mut roots = self.plugin_skill_roots.write().await; + roots.entry(ws_key).or_default().push((path, slot)); + } + // Invalidate both caches so the next read picks up the new root. + self.cache.write().await.clear(); + self.workspace_cache.write().await.clear(); + } + + /// Removes all plugin-contributed skill roots for a workspace. + /// Call on workspace switch or close to prevent cross-workspace leakage. + pub async fn remove_plugin_skill_roots_for_workspace(&self, workspace_root: Option<&Path>) { + let ws_key = workspace_root.map(|p| p.to_path_buf()); + let had = { + let mut roots = self.plugin_skill_roots.write().await; + roots.remove(&ws_key).is_some() + }; + if had { + self.cache.write().await.clear(); + self.workspace_cache.write().await.clear(); + } + } + fn get_possible_paths_for_workspace(workspace_root: Option<&Path>) -> Vec { let mut entries = Vec::new(); let mut priority = 0usize; @@ -250,6 +297,27 @@ impl SkillRegistry { let mut part = Self::scan_skills_in_dir(&entry).await; skills.append(&mut part); } + + // Scan plugin-contributed skill roots (local workspaces only). + { + let ws_key = workspace_root.map(|p| p.to_path_buf()); + let plugin_roots = self.plugin_skill_roots.read().await; + if let Some(roots) = plugin_roots.get(&ws_key) { + let base_priority = 500usize; + for (idx, (path, _plugin_slot)) in roots.iter().enumerate() { + let entry = SkillRootEntry { + path: path.clone(), + level: SkillLocation::User, + slot: "plugin", + priority: base_priority + idx, + is_builtin: false, + }; + let mut part = Self::scan_skills_in_dir(&entry).await; + skills.append(&mut part); + } + } + } + skills } @@ -327,6 +395,8 @@ impl SkillRegistry { fs: &dyn WorkspaceFileSystem, remote_root: &str, ) -> Vec { + // Remote workspaces: plugin skills are local-only and gated. + // Passing `None` as workspace_root only picks up global-fallback roots. let mut skills = self.scan_skill_candidates_for_workspace(None).await; skills.extend(Self::scan_remote_project_skills(fs, remote_root).await); skills @@ -472,10 +542,23 @@ impl SkillRegistry { )); let mut cache = self.cache.write().await; *cache = skills; + // Invalidate workspace cache so the next workspace-aware call rescans. + self.workspace_cache.write().await.clear(); } - pub async fn refresh_for_workspace(&self, _workspace_root: Option<&Path>) { - self.refresh().await; + pub async fn refresh_for_workspace(&self, workspace_root: Option<&Path>) { + let key = workspace_root.map(|p| p.to_path_buf()); + let skills = sort_skills(annotate_shadowed_skills( + self.scan_skill_candidates_for_workspace(workspace_root) + .await, + )); + let mut wcache = self.workspace_cache.write().await; + wcache.insert(key, skills); + // Also refresh the global cache when it matches the None key. + if workspace_root.is_none() { + let mut cache = self.cache.write().await; + *cache = wcache.get(&None).cloned().unwrap_or_default(); + } } pub async fn get_all_skills(&self) -> Vec { @@ -488,10 +571,22 @@ impl SkillRegistry { &self, workspace_root: Option<&Path>, ) -> Vec { - sort_skills(annotate_shadowed_skills( + let key = workspace_root.map(|p| p.to_path_buf()); + // Check workspace-aware cache first. + { + let wcache = self.workspace_cache.read().await; + if let Some(cached) = wcache.get(&key) { + return cached.clone(); + } + } + // Cache miss — scan, sort, and populate the workspace cache. + let skills = sort_skills(annotate_shadowed_skills( self.scan_skill_candidates_for_workspace(workspace_root) .await, - )) + )); + let mut wcache = self.workspace_cache.write().await; + wcache.insert(key, skills.clone()); + skills } pub async fn get_all_skills_for_remote_workspace( diff --git a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx index 67614c3bfe..55274c0787 100644 --- a/src/web-ui/src/app/scenes/settings/SettingsScene.tsx +++ b/src/web-ui/src/app/scenes/settings/SettingsScene.tsx @@ -12,6 +12,7 @@ import './SettingsScene.scss'; const AIModelConfig = lazy(() => import('../../../infrastructure/config/components/AIModelConfig')); const McpToolsConfig = lazy(() => import('../../../infrastructure/config/components/McpToolsConfig')); +const PluginsConfig = lazy(() => import('../../../infrastructure/config/components/PluginsConfig')); const AcpAgentsConfig = lazy(() => import('../../../infrastructure/config/components/AcpAgentsConfig')); const EditorConfig = lazy(() => import('../../../infrastructure/config/components/EditorConfig')); const BasicsConfig = lazy(() => import('../../../infrastructure/config/components/BasicsConfig')); @@ -71,6 +72,7 @@ const SettingsScene: React.FC = () => { case 'memories': Content = MemoriesConfig; break; case 'mcp-tools': Content = McpToolsConfig; break; case 'acp-agents': Content = AcpAgentsConfig; break; + case 'plugins': Content = PluginsConfig; break; case 'editor': Content = EditorConfig; break; case 'keyboard': Content = KeyboardShortcutsTab; break; } diff --git a/src/web-ui/src/app/scenes/settings/settingsConfig.ts b/src/web-ui/src/app/scenes/settings/settingsConfig.ts index dc3c6eac57..3550ba761f 100644 --- a/src/web-ui/src/app/scenes/settings/settingsConfig.ts +++ b/src/web-ui/src/app/scenes/settings/settingsConfig.ts @@ -18,6 +18,7 @@ export type ConfigTab = | 'mcp-tools' | 'acp-agents' // | 'lsp' // temporarily hidden from config center + | 'plugins' | 'editor' | 'keyboard'; @@ -231,6 +232,20 @@ export const SETTINGS_CATEGORIES: ConfigCategoryDef[] = [ 'stdio', ], }, + { + id: 'plugins', + labelKey: 'configCenter.tabs.plugins', + descriptionKey: 'configCenter.tabDescriptions.plugins', + keywords: [ + 'plugin', + 'extension', + 'addon', + 'opencode', + 'codex', + 'skill', + 'hook', + ], + }, ], }, { diff --git a/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts b/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts index 8b47940e62..2628184fb5 100644 --- a/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts +++ b/src/web-ui/src/app/scenes/settings/settingsTabSearchContent.ts @@ -132,6 +132,13 @@ export const SETTINGS_TAB_SEARCH_CONTENT: Record { + try { + return await api.invoke('get_plugin_status', { + request: workspacePath ? { workspacePath } : {}, + }); + } catch (error) { + throw createTauriCommandError('get_plugin_status', error, { workspacePath }); + } + } + + /** + * Enables or disables the plugin system globally. + */ + async setPluginsEnabled(enabled: boolean): Promise { + try { + return await api.invoke('set_plugins_enabled', { + request: { enabled }, + }); + } catch (error) { + throw createTauriCommandError('set_plugins_enabled', error, { enabled }); + } + } + + /** + * Sets trust for a specific plugin. + */ + async setPluginTrust(pluginId: string, trusted: boolean): Promise { + try { + return await api.invoke('set_plugin_trust', { + request: { pluginId, trusted } as SetPluginTrustRequest, + }); + } catch (error) { + throw createTauriCommandError('set_plugin_trust', error, { pluginId, trusted }); + } + } + + /** + * Refreshes plugin discovery and returns updated status. + */ + async refreshPlugins(workspacePath?: string): Promise { + try { + return await api.invoke('refresh_plugins', { + request: workspacePath ? { workspacePath } : {}, + }); + } catch (error) { + throw createTauriCommandError('refresh_plugins', error, { workspacePath }); + } + } +} + +export const pluginAPI = new PluginAPI(); diff --git a/src/web-ui/src/infrastructure/config/components/PluginsConfig.tsx b/src/web-ui/src/infrastructure/config/components/PluginsConfig.tsx new file mode 100644 index 0000000000..a1c4eae278 --- /dev/null +++ b/src/web-ui/src/infrastructure/config/components/PluginsConfig.tsx @@ -0,0 +1,240 @@ +/** + * PluginsConfig — Plugin management settings page. + * + * Shows a global enable/disable toggle, and lists discovered plugins grouped + * by scope (user vs workspace). Workspace-scoped plugins are only shown when + * a workspace is open, using the same pattern as SkillsConfig. + */ + +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { + RefreshCw, + Package, + CheckCircle, + AlertTriangle, + Ban, +} from 'lucide-react'; +import { Button, Switch } from '@/component-library'; +import { + ConfigPageHeader, + ConfigPageLayout, + ConfigPageContent, + ConfigPageSection, + ConfigPageRow, +} from './common'; +import { useNotification } from '@/shared/notification-system'; +import { useCurrentWorkspace } from '@/infrastructure/contexts/WorkspaceContext'; +import { pluginAPI, type PluginStatusView, type PluginStatusResponse } from '../../api/service-api/PluginAPI'; + +interface ScopeGroup { + scope: string; + label: string; + plugins: PluginStatusView[]; +} + +export const PluginsConfig: React.FC = () => { + const { t } = useTranslation('settings/plugins'); + const notification = useNotification(); + const { workspacePath, hasWorkspace } = useCurrentWorkspace(); + + const [status, setStatus] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [toggling, setToggling] = useState>(new Set()); + + const loadStatus = useCallback(async () => { + try { + setLoading(true); + const result = await pluginAPI.getPluginStatus(workspacePath || undefined); + setStatus(result); + } catch (err) { + notification.error(t('errorLoading')); + } finally { + setLoading(false); + } + }, [t, notification, workspacePath]); + + useEffect(() => { + loadStatus(); + }, [loadStatus]); + + const handleGlobalToggle = async (enabled: boolean) => { + try { + await pluginAPI.setPluginsEnabled(enabled); + const refreshed = await pluginAPI.getPluginStatus(workspacePath || undefined); + setStatus(refreshed); + } catch (err) { + notification.error(t('errorToggleGlobal')); + } + }; + + const handlePluginToggle = async (plugin: PluginStatusView, trusted: boolean) => { + setToggling((prev) => new Set(prev).add(plugin.pluginId)); + try { + await pluginAPI.setPluginTrust(plugin.pluginId, trusted); + const result = await pluginAPI.getPluginStatus(workspacePath || undefined); + setStatus(result); + } catch (err) { + notification.error(t('errorTogglePlugin')); + } finally { + setToggling((prev) => { + const next = new Set(prev); + next.delete(plugin.pluginId); + return next; + }); + } + }; + + const handleRefresh = async () => { + setRefreshing(true); + try { + const result = await pluginAPI.refreshPlugins(workspacePath || undefined); + setStatus(result); + } catch (err) { + notification.error(t('errorRefresh')); + } finally { + setRefreshing(false); + } + }; + + const scopeGroups = useMemo((): ScopeGroup[] => { + const plugins = status?.plugins ?? []; + const userPlugins = plugins.filter((p) => p.scope === 'user'); + const wsPlugins = plugins.filter((p) => p.scope === 'workspace'); + + const groups: ScopeGroup[] = [ + { scope: 'user', label: t('userPlugins'), plugins: userPlugins }, + ]; + if (hasWorkspace && wsPlugins.length > 0) { + groups.push({ scope: 'workspace', label: t('workspacePlugins'), plugins: wsPlugins }); + } + return groups; + }, [status?.plugins, hasWorkspace, t]); + + if (loading) { + return ( + + + +
{t('loading')}
+
+
+ ); + } + + const pluginsEnabled = status?.pluginsEnabled ?? true; + const plugins = status?.plugins ?? []; + const enabledCount = plugins.filter((p) => p.enabled && p.trustLevel !== 'Denied').length; + + return ( + + + {t('refresh')} + + } + /> + + + {/* Global enable/disable */} + + + handleGlobalToggle(e.target.checked)} + /> + + + + {/* Plugin list grouped by scope */} + {pluginsEnabled && + scopeGroups.map((group) => ( + + {group.plugins.length === 0 ? ( + +
+ + ) : ( + group.plugins.map((plugin) => ( + + {plugin.name} + {plugin.version && ( + + v{plugin.version} + + )} +
+ } + description={ +
+ + + {plugin.source} + + {plugin.skillCount > 0 && ( + + {t('skillsCount', { count: plugin.skillCount })} + + )} + {plugin.diagnostics.length > 0 && ( + + + {t('diagnosticsCount', { count: plugin.diagnostics.length })} + + )} +
+ } + align="center" + > +
+ {plugin.enabled ? ( + + + {t('enabled')} + + ) : ( + + + {t('disabled')} + + )} + handlePluginToggle(plugin, e.target.checked)} + disabled={toggling.has(plugin.pluginId)} + size="small" + /> +
+
+ )) + )} +
+ ))} +
+
+ ); +}; +export default PluginsConfig; diff --git a/src/web-ui/src/locales/en-US/settings.json b/src/web-ui/src/locales/en-US/settings.json index 516df12675..9e8aaf8041 100644 --- a/src/web-ui/src/locales/en-US/settings.json +++ b/src/web-ui/src/locales/en-US/settings.json @@ -25,6 +25,7 @@ "memories": "Automatic memory generation, injection, retention windows, and memory models.", "mcpTools": "MCP servers and tool integrations.", "acpAgents": "External ACP agents such as opencode, Claude Code, and Codex.", + "plugins": "Discovered plugins (Codex/OpenCode) — enable or disable per plugin.", "editor": "Editor font, display, and formatting.", "lsp": "Language servers and code intelligence.", "keyboard": "Shortcuts and key bindings.", @@ -47,6 +48,7 @@ "skills": "Skills", "mcpTools": "MCP", "acpAgents": "ACP Agents", + "plugins": "Plugins", "agents": "Agents", "editor": "Editor", "lsp": "LSP", diff --git a/src/web-ui/src/locales/en-US/settings/plugins.json b/src/web-ui/src/locales/en-US/settings/plugins.json new file mode 100644 index 0000000000..3d12b164f5 --- /dev/null +++ b/src/web-ui/src/locales/en-US/settings/plugins.json @@ -0,0 +1,22 @@ +{ + "title": "Plugins", + "subtitle": "Manage discovered Codex and OpenCode plugins and control which skills are available to agents.", + "loading": "Loading plugin status...", + "refresh": "Refresh", + "globalSection": "Plugin System", + "pluginsEnabled": "Enable Plugin Support", + "pluginsEnabledDesc": "{{count}} plugin(s) active. Skills from plugins are visible to agents.", + "pluginsDisabledDesc": "Plugin support is disabled. No plugin skills will be loaded.", + "pluginListSection": "Discovered Plugins", + "userPlugins": "User Plugins", + "workspacePlugins": "Workspace Plugins", + "noPluginsFound": "No plugins found. Install Codex or OpenCode plugins into ~/.agents/plugins/ or the workspace .agents/plugins/ directory.", + "enabled": "Enabled", + "disabled": "Disabled", + "skillsCount": "{{count}} skill(s)", + "diagnosticsCount": "{{count}} warning(s)", + "errorLoading": "Failed to load plugin status.", + "errorToggleGlobal": "Failed to toggle plugin support.", + "errorTogglePlugin": "Failed to update plugin trust.", + "errorRefresh": "Failed to refresh plugin discovery." +} diff --git a/src/web-ui/src/locales/zh-CN/settings.json b/src/web-ui/src/locales/zh-CN/settings.json index 15cea5ca07..7a6b7f2532 100644 --- a/src/web-ui/src/locales/zh-CN/settings.json +++ b/src/web-ui/src/locales/zh-CN/settings.json @@ -46,6 +46,7 @@ "memories": "自动记忆生成、注入、整理窗口与记忆模型。", "mcpTools": "MCP 服务器与工具集成。", "acpAgents": "opencode、Claude Code、Codex 等外部 ACP Agent。", + "plugins": "已发现的 Codex/OpenCode 插件 — 逐个启用或禁用。", "editor": "编辑器字体、显示与格式化。", "lsp": "语言服务与代码智能。", "keyboard": "快捷键与键位。", @@ -68,6 +69,7 @@ "skills": "技能", "mcpTools": "MCP", "acpAgents": "ACP Agent", + "plugins": "插件", "agents": "智能体", "editor": "编辑器", "lsp": "语言服务", diff --git a/src/web-ui/src/locales/zh-CN/settings/plugins.json b/src/web-ui/src/locales/zh-CN/settings/plugins.json new file mode 100644 index 0000000000..7ac33e91bd --- /dev/null +++ b/src/web-ui/src/locales/zh-CN/settings/plugins.json @@ -0,0 +1,22 @@ +{ + "title": "插件", + "subtitle": "管理发现的 Codex 和 OpenCode 插件,控制哪些技能对 Agent 可见。", + "loading": "正在加载插件状态...", + "refresh": "刷新", + "globalSection": "插件系统", + "pluginsEnabled": "启用插件支持", + "pluginsEnabledDesc": "{{count}} 个插件活跃。插件技能将对 Agent 可见。", + "pluginsDisabledDesc": "插件支持已关闭。不会加载任何插件技能。", + "pluginListSection": "已发现的插件", + "userPlugins": "用户插件", + "workspacePlugins": "工作区插件", + "noPluginsFound": "未发现插件。请将 Codex 或 OpenCode 插件安装到 ~/.agents/plugins/ 或工作区 .agents/plugins/ 目录。", + "enabled": "已启用", + "disabled": "已禁用", + "skillsCount": "{{count}} 个技能", + "diagnosticsCount": "{{count}} 个警告", + "errorLoading": "加载插件状态失败。", + "errorToggleGlobal": "切换插件支持失败。", + "errorTogglePlugin": "更新插件信任状态失败。", + "errorRefresh": "刷新插件发现失败。" +}