From 6865dfb4cd68aebf6a05f4790b6d0dfaa7d01ed6 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Wed, 2 Sep 2026 11:57:12 -0700 Subject: [PATCH] feat(runtime-api): plugin + marketplace management over /v1/apps (Engine side) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the Engine's plugin authority to app clients over the runtime API the desktop shell already spawns (codewhale app-server --http → serve): - GET /v1/apps/plugins, GET /v1/apps/plugins/{selector} with structured capability review payload + review token (env/header values redacted) - POST install / update / DELETE uninstall through plugins::mutation (installs land disabled+untrusted; network policy enforced) - POST trust (hash-bound token) / enable / disable / revoke through the registry receipt flow - Marketplace CRUD + candidate install sharing one catalog loader (plugins/marketplace/document.rs) with /plugin marketplace - RuntimeCapabilities.plugin_management advertised by /v1/runtime/info - install:: name_conflict callbacks now &(dyn Fn + Send + Sync) so the install future is Send (required by async axum handlers) Local gates: cargo clippy -p codewhale-tui --lib --tests clean; new tests 8/8 pass (capability, full lifecycle over HTTP, 404s, marketplace add/list/install/remove, symlink refusal); plugins + command suites pass except two stack-overflow crashers reproduced on pristine origin/main (kimi_plan_codes_resolve_at_render_time, marketplace_add_list_show_remove_roundtrip) — pre-existing, not from this change; documented in handoff. --- crates/protocol/src/runtime/mod.rs | 8 + .../commands/groups/plugins/marketplace.rs | 174 +--- .../tui/src/commands/groups/plugins/render.rs | 7 +- crates/tui/src/plugins/install/mod.rs | 8 +- .../tui/src/plugins/marketplace/document.rs | 238 +++++ crates/tui/src/plugins/marketplace/mod.rs | 5 +- crates/tui/src/plugins/types.rs | 9 + crates/tui/src/runtime_api.rs | 43 + crates/tui/src/runtime_api/plugins.rs | 947 ++++++++++++++++++ crates/tui/src/runtime_api/tests.rs | 414 ++++++++ 10 files changed, 1698 insertions(+), 155 deletions(-) create mode 100644 crates/tui/src/plugins/marketplace/document.rs create mode 100644 crates/tui/src/runtime_api/plugins.rs diff --git a/crates/protocol/src/runtime/mod.rs b/crates/protocol/src/runtime/mod.rs index edb35d8bef..10c794c653 100644 --- a/crates/protocol/src/runtime/mod.rs +++ b/crates/protocol/src/runtime/mod.rs @@ -82,6 +82,12 @@ pub struct RuntimeCapabilities { /// are available via the HTTP API. #[serde(default)] pub skill_lifecycle: bool, + /// Plugin bundle and marketplace lifecycle operations (list/detail, + /// install/update/uninstall, trust/enable/disable/revoke, marketplace + /// add/remove/install) are available via the `/v1/apps/plugins` and + /// `/v1/apps/marketplaces` endpoint families. + #[serde(default)] + pub plugin_management: bool, /// Durable, workspace-scoped cross-task Agent Mail endpoints and events. #[serde(default)] pub agent_mail: bool, @@ -386,6 +392,7 @@ mod tests { memory: true, mcp_server_management: false, skill_lifecycle: false, + plugin_management: false, agent_mail: true, }; let value = serde_json::to_value(&caps).unwrap(); @@ -399,6 +406,7 @@ mod tests { assert_eq!(obj.get("fleet_event_stream").unwrap(), &json!(true)); assert_eq!(obj.get("thread_goals").unwrap(), &json!(true)); assert_eq!(obj.get("memory").unwrap(), &json!(true)); + assert_eq!(obj.get("plugin_management").unwrap(), &json!(false)); assert_eq!(obj.get("agent_mail").unwrap(), &json!(true)); } diff --git a/crates/tui/src/commands/groups/plugins/marketplace.rs b/crates/tui/src/commands/groups/plugins/marketplace.rs index 7371b5c94a..2e55a567f9 100644 --- a/crates/tui/src/commands/groups/plugins/marketplace.rs +++ b/crates/tui/src/commands/groups/plugins/marketplace.rs @@ -2,37 +2,34 @@ //! //! `add` reads a LOCAL catalog document (no network here, ever), parses it //! with the strict per-format parsers, and persists the parsed result next to -//! the plugin registry state. `list`/`show` render candidates with their -//! honest install plans and per-entry diagnostics. `install` routes a -//! candidate through the EXISTING reviewed installer — the same code path as -//! `/plugin install`, so installed bundles still enter disabled and untrusted. +//! the plugin registry state; the shared loader in +//! `plugins::marketplace::document` is the same one the Runtime API serves. +//! `list`/`show` render candidates with their honest install plans and +//! per-entry diagnostics. `install` routes a candidate through the EXISTING +//! reviewed installer — the same code path as `/plugin install`, so installed +//! bundles still enter disabled and untrusted. //! //! Catalog-declared tiers and provenance are display-only: nothing in this //! module grants trust, enables anything, or auto-installs (Codex //! `INSTALLED_BY_DEFAULT` is visibly ignored). use std::fmt::Write as _; -use std::io::Read; -use std::path::{Path, PathBuf}; +use std::path::Path; use super::render::{escape_review_path, escape_review_text}; use crate::commands::CommandResult; use crate::localization::{Locale, MessageId, tr}; -use crate::plugins::marketplace::parsers::MarketplaceDocument; +use crate::plugins::marketplace::document::{ + CatalogInstallResolution, load_catalog_document, resolve_candidate_install, +}; use crate::plugins::marketplace::parsers::kimi::{ KIMI_GZIP_TARBALL_SOURCE_KIND, KIMI_REMOTE_UNSUPPORTED_REASON, KIMI_ZIP_UNSUPPORTED_REASON, }; -use crate::plugins::marketplace::store::{MarketplaceStore, StoredMarketplaceCatalog}; -use crate::plugins::marketplace::types::{ - MarketplaceCatalog, MarketplaceFormat, MarketplaceInstallPlan, MarketplaceSourceSpec, -}; +use crate::plugins::marketplace::store::MarketplaceStore; +use crate::plugins::marketplace::types::{MarketplaceCatalog, MarketplaceInstallPlan}; use crate::plugins::types::PluginDiagnosticLevel; use crate::tui::app::App; -/// Catalog documents are JSON text; four megabytes is far beyond any real -/// published catalog and caps the parse cost of a user-supplied file. -const MAX_CATALOG_BYTES: u64 = 4 * 1024 * 1024; - const USAGE: &str = "Usage: /plugin marketplace add|list|show|remove|install\n\ \x20 add read a local catalog file (kimi/claude/codex/codewhale)\n\ \x20 list show catalogs and their candidates\n\ @@ -59,78 +56,20 @@ fn open_store(app: &App) -> Result> { }) } -/// Conservative catalog name: it becomes a key, appears in candidate IDs, -/// and is rendered back to the operator. -fn valid_name(name: &str) -> bool { - !name.is_empty() - && name.len() <= 64 - && name - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') -} - fn add(app: &mut App, name: &str, raw_path: &str) -> CommandResult { - if !valid_name(name) { - return CommandResult::error( - "Marketplace name must be 1-64 characters of letters, digits, `-`, `_`, or `.`", - ); - } let store = match open_store(app) { Ok(store) => store, Err(result) => return *result, }; - let path = PathBuf::from(raw_path.trim()); - let path = if path.is_absolute() { - path - } else { - app.workspace.join(path) - }; - let path = match canonical_document(&path) { - Ok(path) => path, - Err(error) => return CommandResult::error(error), - }; - - let body = match read_bounded(&path) { - Ok(text) => text, + let loaded = match load_catalog_document(name, &app.workspace, raw_path) { + Ok(loaded) => loaded, Err(error) => return CommandResult::error(error), }; - let root = match serde_json::from_str::(&body) { - Ok(root) => root, - Err(error) => { - return CommandResult::error(format!( - "Catalog at {} is not valid JSON: {error}", - escape_review_path(&path) - )); - } - }; - let document = MarketplaceDocument { - catalog_id: crate::plugins::marketplace::types::MarketplaceCatalogId::new(name), - format: MarketplaceFormat::Auto, - root, - base: Some(path.display().to_string()), - }; - let catalog = crate::plugins::marketplace::parsers::parse_catalog(document); - - // A document-level error (unknown/ambiguous format, not-an-object) means - // nothing useful was parsed; do not persist it. - if catalog.candidates.is_empty() && catalog.error_count() > 0 { - return CommandResult::error(format!( - "Catalog `{}` could not be parsed as any known marketplace format (kimi, claude, codex, codewhale):\n{}", - escape_review_text(name), - render_diagnostics_inline(&catalog.diagnostics) - )); - } - - let entry = StoredMarketplaceCatalog { - added_at: chrono::Utc::now().to_rfc3339(), - source_path: path.display().to_string(), - catalog, - }; - let candidate_count = entry.catalog.total_candidates(); - let warning_count = entry.catalog.warning_count(); - let summary = render_catalog_summary(name, &entry.catalog); - match store.add(&entry.catalog.id.clone(), entry) { + let summary = render_catalog_summary(name, &loaded.entry.catalog); + let candidate_count = loaded.candidate_count; + let warning_count = loaded.warning_count; + match store.add(&loaded.entry.catalog.id.clone(), loaded.entry) { Ok(()) => CommandResult::message(format!( "Added marketplace `{}` ({} candidate(s), {} warning(s)).\n{summary}\n\ Tiers and provenance are display-only. Nothing was installed, trusted, or enabled.", @@ -249,76 +188,19 @@ fn install(app: &mut App, catalog_name: &str, candidate_name: &str) -> CommandRe escape_review_text(catalog_name) )); }; - if candidate.has_errors() { - return CommandResult::error(format!( - "Candidate `{}` has parse errors and cannot be installed:\n{}", - escape_review_text(candidate_name), - render_diagnostics_inline(&candidate.diagnostics) - )); - } - let MarketplaceInstallPlan::Supported { spec, .. } = &candidate.install_plan else { - let MarketplaceInstallPlan::Unsupported { reason, .. } = &candidate.install_plan else { - unreachable!("is_supported and this match agree"); - }; - return CommandResult::error(format!( + match resolve_candidate_install(entry, candidate) { + CatalogInstallResolution::Supported { spec, .. } => super::install_bundle(app, &spec), + CatalogInstallResolution::Unsupported { reason } => CommandResult::error(format!( "Candidate `{}` cannot be installed by Codewhale: {}", escape_review_text(candidate_name), - escape_review_text(&localized_marketplace_plan_text(app.ui_locale, reason)) - )); - }; - let spec = spec.as_str(); - // Relative local paths resolve against the catalog document's own - // directory, not the TUI's working directory. - let spec = resolve_spec(&entry.source_path, &candidate.source, spec); - super::install_bundle(app, &spec) -} - -fn resolve_spec(source_path: &str, source: &MarketplaceSourceSpec, spec: &str) -> String { - if let MarketplaceSourceSpec::LocalPath { path } = source - && path.is_relative() - && let Some(dir) = Path::new(source_path).parent() - { - return format!("path:{}", dir.join(path).display()); - } - spec.to_string() -} - -/// Resolve a user-supplied document path to an existing regular file without -/// following a final symlink (the document is untrusted input). -fn canonical_document(path: &Path) -> Result { - let metadata = std::fs::symlink_metadata(path) - .map_err(|e| format!("Cannot read catalog at {}: {e}", path.display()))?; - if metadata.is_symlink() { - return Err(format!( - "Catalog path {} is a symlink; marketplace documents must be regular files", - path.display() - )); - } - if !metadata.is_file() { - return Err(format!( - "Catalog path {} is not a regular file", - path.display() - )); - } - Ok(path.to_path_buf()) -} - -fn read_bounded(path: &Path) -> Result { - let file = std::fs::File::open(path) - .map_err(|e| format!("Cannot read catalog at {}: {e}", path.display()))?; - if file.metadata().map_err(|e| e.to_string())?.len() > MAX_CATALOG_BYTES { - return Err(format!( - "Catalog at {} exceeds the {} byte limit", - path.display(), - MAX_CATALOG_BYTES - )); + escape_review_text(&localized_marketplace_plan_text(app.ui_locale, &reason)) + )), + CatalogInstallResolution::HasErrors { diagnostics } => CommandResult::error(format!( + "Candidate `{}` has parse errors and cannot be installed:\n{}", + escape_review_text(candidate_name), + escape_review_text(&diagnostics) + )), } - let mut text = String::new(); - let mut limited = file.take(MAX_CATALOG_BYTES + 1); - limited - .read_to_string(&mut text) - .map_err(|e| format!("Cannot read catalog at {}: {e}", path.display()))?; - Ok(text) } fn render_catalog_summary(name: &str, catalog: &MarketplaceCatalog) -> String { diff --git a/crates/tui/src/commands/groups/plugins/render.rs b/crates/tui/src/commands/groups/plugins/render.rs index 4768a16a30..ec50fe2e93 100644 --- a/crates/tui/src/commands/groups/plugins/render.rs +++ b/crates/tui/src/commands/groups/plugins/render.rs @@ -312,10 +312,9 @@ pub(super) fn escape_review_text(value: &str) -> String { } pub(super) fn review_token(plugin: &LoadedPlugin) -> String { - // This is an explicit user confirmation, not cosmetic display text. Bind - // the command to both complete SHA-256 receipts so a same-inventory bundle - // cannot collide through the former 48-bit content prefix. - format!("{}.{}", plugin.content_hash, plugin.capability_hash) + // One implementation lives on `LoadedPlugin`; the TUI command and the + // Runtime API trust endpoint must agree byte-for-byte. + plugin.review_token() } pub(super) fn append_diagnostics( diff --git a/crates/tui/src/plugins/install/mod.rs b/crates/tui/src/plugins/install/mod.rs index da3897fa73..88cabfb1b1 100644 --- a/crates/tui/src/plugins/install/mod.rs +++ b/crates/tui/src/plugins/install/mod.rs @@ -238,7 +238,7 @@ pub async fn install( max_size: u64, network: &NetworkPolicy, update: bool, - name_conflict: &dyn Fn(&str) -> Option, + name_conflict: &(dyn Fn(&str) -> Option + Send + Sync), ) -> Result { install_inner( source, @@ -260,7 +260,7 @@ pub async fn install_with_expected_content_hash( user_plugins_dir: &Path, max_size: u64, network: &NetworkPolicy, - name_conflict: &dyn Fn(&str) -> Option, + name_conflict: &(dyn Fn(&str) -> Option + Send + Sync), expected_content_hash: &str, ) -> Result { install_inner( @@ -281,7 +281,7 @@ async fn install_inner( max_size: u64, network: &NetworkPolicy, update: bool, - name_conflict: &dyn Fn(&str) -> Option, + name_conflict: &(dyn Fn(&str) -> Option + Send + Sync), expected_content_hash: Option<&str>, ) -> Result { match &source { @@ -356,7 +356,7 @@ fn install_remote_bytes( user_plugins_dir: &Path, max_size: u64, update: bool, - name_conflict: &dyn Fn(&str) -> Option, + name_conflict: &(dyn Fn(&str) -> Option + Send + Sync), expected_content_hash: Option<&str>, ) -> Result { let checksum = sha256_hex(bytes); diff --git a/crates/tui/src/plugins/marketplace/document.rs b/crates/tui/src/plugins/marketplace/document.rs new file mode 100644 index 0000000000..f0427ac1cb --- /dev/null +++ b/crates/tui/src/plugins/marketplace/document.rs @@ -0,0 +1,238 @@ +//! Local catalog document loading shared by the `/plugin marketplace add` +//! command and the Runtime API marketplace endpoints (#5311 surface). +//! +//! One loader so both entry points apply the same rules: LOCAL file only +//! (never network), bounded size, no symlink documents, strict per-format +//! parsing, and refusal to persist a document that parsed to nothing but +//! errors. Candidate→install-spec resolution also lives here so the TUI +//! command and the HTTP API can never disagree about what would be fetched. + +use std::io::Read; +use std::path::{Path, PathBuf}; + +use super::parsers::{self, MarketplaceDocument}; +use super::store::StoredMarketplaceCatalog; +use super::types::{ + MarketplaceCandidate, MarketplaceCatalogId, MarketplaceFormat, MarketplaceInstallPlan, + MarketplaceSourceSpec, +}; + +/// Catalog documents are JSON text; four megabytes is far beyond any real +/// published catalog and caps the parse cost of a user-supplied file. +const MAX_CATALOG_BYTES: u64 = 4 * 1024 * 1024; + +/// A parsed catalog ready to store, plus the counts callers render back. +#[derive(Debug)] +pub struct LoadedCatalogDocument { + pub entry: StoredMarketplaceCatalog, + pub candidate_count: usize, + pub warning_count: usize, +} + +/// Conservative catalog name: it becomes a key, appears in candidate IDs, +/// and is rendered back to the operator. +#[must_use] +pub fn valid_marketplace_name(name: &str) -> bool { + !name.is_empty() + && name.len() <= 64 + && name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.') +} + +/// Read and parse a LOCAL catalog document. Relative paths resolve against +/// `workspace`. No network is touched, here or anywhere in this module. +pub fn load_catalog_document( + name: &str, + workspace: &Path, + raw_path: &str, +) -> Result { + if !valid_marketplace_name(name) { + return Err( + "Marketplace name must be 1-64 characters of letters, digits, `-`, `_`, or `.`" + .to_string(), + ); + } + let path = PathBuf::from(raw_path.trim()); + let path = if path.is_absolute() { + path + } else { + workspace.join(path) + }; + let path = canonical_document(&path)?; + let body = read_bounded(&path)?; + let root = serde_json::from_str::(&body) + .map_err(|error| format!("Catalog at {} is not valid JSON: {error}", path.display()))?; + + let document = MarketplaceDocument { + catalog_id: MarketplaceCatalogId::new(name), + format: MarketplaceFormat::Auto, + root, + base: Some(path.display().to_string()), + }; + let catalog = parsers::parse_catalog(document); + + // A document-level error (unknown/ambiguous format, not-an-object) means + // nothing useful was parsed; do not persist it. + if catalog.candidates.is_empty() && catalog.error_count() > 0 { + return Err(format!( + "Catalog `{name}` could not be parsed as any known marketplace format (kimi, claude, codex, codewhale):\n{}", + render_diagnostics_inline(&catalog.diagnostics) + )); + } + + let entry = StoredMarketplaceCatalog { + added_at: chrono::Utc::now().to_rfc3339(), + source_path: path.display().to_string(), + catalog, + }; + Ok(LoadedCatalogDocument { + candidate_count: entry.catalog.total_candidates(), + warning_count: entry.catalog.warning_count(), + entry, + }) +} + +/// What installing a stored candidate would do, resolved once for every +/// caller. `Supported.spec` is exactly what the reviewed installer accepts. +pub enum CatalogInstallResolution { + Supported { spec: String, source_kind: String }, + Unsupported { reason: String }, + HasErrors { diagnostics: String }, +} + +/// Resolve a stored catalog candidate to its install spec. Relative local +/// paths resolve against the catalog document's own directory, not the +/// caller's working directory. +pub fn resolve_candidate_install( + entry: &StoredMarketplaceCatalog, + candidate: &MarketplaceCandidate, +) -> CatalogInstallResolution { + if candidate.has_errors() { + return CatalogInstallResolution::HasErrors { + diagnostics: render_diagnostics_inline(&candidate.diagnostics), + }; + } + match &candidate.install_plan { + MarketplaceInstallPlan::Supported { spec, source_kind } => { + CatalogInstallResolution::Supported { + spec: resolve_spec(&entry.source_path, &candidate.source, spec), + source_kind: source_kind.clone(), + } + } + MarketplaceInstallPlan::Unsupported { reason, .. } => { + CatalogInstallResolution::Unsupported { + reason: reason.clone(), + } + } + } +} + +fn resolve_spec(source_path: &str, source: &MarketplaceSourceSpec, spec: &str) -> String { + if let MarketplaceSourceSpec::LocalPath { path } = source + && path.is_relative() + && let Some(dir) = Path::new(source_path).parent() + { + return format!("path:{}", dir.join(path).display()); + } + spec.to_string() +} + +/// Resolve a user-supplied document path to an existing regular file without +/// following a final symlink (the document is untrusted input). +fn canonical_document(path: &Path) -> Result { + let metadata = std::fs::symlink_metadata(path) + .map_err(|e| format!("Cannot read catalog at {}: {e}", path.display()))?; + if metadata.is_symlink() { + return Err(format!( + "Catalog path {} is a symlink; marketplace documents must be regular files", + path.display() + )); + } + if !metadata.is_file() { + return Err(format!( + "Catalog path {} is not a regular file", + path.display() + )); + } + Ok(path.to_path_buf()) +} + +fn read_bounded(path: &Path) -> Result { + let file = std::fs::File::open(path) + .map_err(|e| format!("Cannot read catalog at {}: {e}", path.display()))?; + if file.metadata().map_err(|e| e.to_string())?.len() > MAX_CATALOG_BYTES { + return Err(format!( + "Catalog at {} exceeds the {} byte limit", + path.display(), + MAX_CATALOG_BYTES + )); + } + let mut text = String::new(); + let mut limited = file.take(MAX_CATALOG_BYTES + 1); + limited + .read_to_string(&mut text) + .map_err(|e| format!("Cannot read catalog at {}: {e}", path.display()))?; + Ok(text) +} + +fn render_diagnostics_inline(diagnostics: &[super::types::MarketplaceDiagnostic]) -> String { + use crate::plugins::types::PluginDiagnosticLevel; + diagnostics + .iter() + .map(|d| { + format!( + "{} {}: {}", + match d.level { + PluginDiagnosticLevel::Error => "error", + PluginDiagnosticLevel::Warning => "warning", + }, + d.code, + d.message + ) + }) + .collect::>() + .join("; ") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn marketplace_names_are_conservative() { + assert!(valid_marketplace_name("official")); + assert!(valid_marketplace_name("My-Catalog_2.beta")); + assert!(!valid_marketplace_name("")); + assert!(!valid_marketplace_name("has space")); + assert!(!valid_marketplace_name("a".repeat(65).as_str())); + } + + #[test] + fn load_refuses_symlink_documents() { + let dir = tempfile::tempdir().unwrap(); + let real = dir.path().join("real.json"); + std::fs::write(&real, "{}").unwrap(); + let link = dir.path().join("link.json"); + #[cfg(unix)] + std::os::unix::fs::symlink(&real, &link).unwrap(); + #[cfg(not(unix))] + let link = real.clone(); + + let error = load_catalog_document("test", dir.path(), link.to_str().unwrap()) + .expect_err("symlink document must be refused"); + #[cfg(unix)] + assert!(error.contains("symlink"), "{error}"); + } + + #[test] + fn load_refuses_unknown_format_documents() { + let dir = tempfile::tempdir().unwrap(); + let doc = dir.path().join("catalog.json"); + std::fs::write(&doc, r#"{"totally":"unknown"}"#).unwrap(); + + let error = load_catalog_document("test", dir.path(), doc.to_str().unwrap()) + .expect_err("unknown format must be refused"); + assert!(error.contains("could not be parsed"), "{error}"); + } +} diff --git a/crates/tui/src/plugins/marketplace/mod.rs b/crates/tui/src/plugins/marketplace/mod.rs index 183a526150..b91e625a0e 100644 --- a/crates/tui/src/plugins/marketplace/mod.rs +++ b/crates/tui/src/plugins/marketplace/mod.rs @@ -7,8 +7,11 @@ //! //! This layer is parser-only: no network, no filesystem reads, no process //! execution. Every fetch happens through the existing reviewed installer -//! when an operator explicitly installs a candidate. +//! when an operator explicitly installs a candidate. The one filesystem +//! seam — reading a local catalog document a operator pointed at — lives in +//! [`document`] and is shared by the TUI command and the Runtime API. +pub mod document; pub mod parsers; pub mod store; #[cfg(test)] diff --git a/crates/tui/src/plugins/types.rs b/crates/tui/src/plugins/types.rs index 7abd301a21..736bef4f94 100644 --- a/crates/tui/src/plugins/types.rs +++ b/crates/tui/src/plugins/types.rs @@ -203,6 +203,15 @@ impl LoadedPlugin { self.trust_status == PluginTrustStatus::Trusted } + /// Explicit-review confirmation token binding a trust confirmation to + /// both complete SHA-256 receipts, so a same-inventory bundle cannot + /// collide through a short content prefix. The TUI `/plugin trust` flow + /// and the Runtime API trust endpoint both compare against this value. + #[must_use] + pub fn review_token(&self) -> String { + format!("{}.{}", self.content_hash, self.capability_hash) + } + #[must_use] pub fn compatibility(&self) -> PluginCompatibility { self.inventory.compatibility() diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 9136602c7d..774f89cc3a 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -88,6 +88,7 @@ use codewhale_protocol::fleet::{ }; mod auth; +mod plugins; mod sessions; mod web; mod workspace; @@ -548,6 +549,7 @@ fn default_runtime_capabilities() -> RuntimeCapabilities { memory: true, mcp_server_management: true, skill_lifecycle: true, + plugin_management: true, agent_mail: true, } } @@ -1150,6 +1152,47 @@ pub fn build_router(state: RuntimeApiState) -> Router { .route("/v1/skills/{name}/trust", post(trust_skill_api)) .route("/v1/skills/{name}/audit", get(audit_skill_api)) .route("/v1/apps/mcp/tools", get(list_mcp_tools)) + .route("/v1/apps/plugins", get(plugins::list_plugins)) + .route( + "/v1/apps/plugins/install", + post(plugins::install_plugin_api), + ) + .route( + "/v1/apps/plugins/{selector}", + get(plugins::get_plugin).delete(plugins::uninstall_plugin_api), + ) + .route( + "/v1/apps/plugins/{selector}/update", + post(plugins::update_plugin_api), + ) + .route( + "/v1/apps/plugins/{selector}/trust", + post(plugins::trust_plugin_api), + ) + .route( + "/v1/apps/plugins/{selector}/enable", + post(plugins::enable_plugin_api), + ) + .route( + "/v1/apps/plugins/{selector}/disable", + post(plugins::disable_plugin_api), + ) + .route( + "/v1/apps/plugins/{selector}/revoke", + post(plugins::revoke_plugin_api), + ) + .route( + "/v1/apps/marketplaces", + get(plugins::list_marketplaces).post(plugins::add_marketplace), + ) + .route( + "/v1/apps/marketplaces/{name}", + get(plugins::get_marketplace).delete(plugins::remove_marketplace), + ) + .route( + "/v1/apps/marketplaces/{name}/install", + post(plugins::install_marketplace_candidate_api), + ) .route( "/v1/automations", get(list_automations).post(create_automation), diff --git a/crates/tui/src/runtime_api/plugins.rs b/crates/tui/src/runtime_api/plugins.rs new file mode 100644 index 0000000000..3fe7af9483 --- /dev/null +++ b/crates/tui/src/runtime_api/plugins.rs @@ -0,0 +1,947 @@ +//! Plugin bundle and marketplace management over the Runtime API. +//! +//! `GET /v1/apps/plugins` and `GET /v1/apps/plugins/{selector}` expose the +//! same registry the TUI reads, with the same honest state vocabulary +//! (`active`, `enabled-untrusted`, `unstaged`, …) and the same capability +//! inventory a terminal review shows. Mutations run through the exact +//! reviewed paths the TUI uses — `plugins::mutation::execute` for +//! install/update/uninstall (installs always land disabled and untrusted) +//! and the registry's hash-bound receipt flow for trust/enable/disable — +//! so a GUI client can never bypass a review the TUI would require. +//! +//! Marketplace endpoints share `plugins::marketplace::document` with the +//! `/plugin marketplace` command: local catalog documents only, tiers are +//! display-only, and installs route through the same reviewed installer. + +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use serde::{Deserialize, Serialize}; + +use crate::plugins::marketplace::document::{ + CatalogInstallResolution, load_catalog_document, resolve_candidate_install, +}; +use crate::plugins::marketplace::store::MarketplaceStore; +use crate::plugins::mutation::{ + PluginMutationContext, PluginMutationOutcome, PluginMutationRequest, +}; +use crate::plugins::types::{LoadedPlugin, PluginDiagnostic, PluginDiagnosticLevel}; + +use super::{ApiError, RuntimeApiState}; + +// --------------------------------------------------------------------------- +// Response shapes +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize)] +pub(super) struct PluginInventorySummary { + pub(super) skills: usize, + pub(super) mcp_servers: usize, + pub(super) stdio_mcp_servers: usize, + pub(super) remote_mcp_servers: usize, + pub(super) commands: usize, + pub(super) agents: usize, + pub(super) hooks: usize, + pub(super) lsp: usize, + pub(super) native: usize, + pub(super) filesystem_roots: Vec, + pub(super) network_hosts: Vec, + pub(super) lifecycle_mutation: bool, +} + +#[derive(Debug, Serialize)] +pub(super) struct PluginDiagnosticEntry { + pub(super) level: &'static str, + pub(super) code: String, + pub(super) message: String, + pub(super) path: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct PluginSummaryEntry { + pub(super) id: String, + pub(super) name: String, + pub(super) display_name: Option, + pub(super) version: String, + pub(super) description: Option, + pub(super) scope: &'static str, + pub(super) origin: &'static str, + pub(super) path: String, + pub(super) state: &'static str, + pub(super) enabled: bool, + pub(super) trust_status: &'static str, + pub(super) active: bool, + pub(super) compatibility: &'static str, + pub(super) inventory: PluginInventorySummary, + pub(super) content_hash: String, + pub(super) capability_hash: String, + pub(super) state_generation: u64, + pub(super) diagnostics: Vec, +} + +#[derive(Debug, Serialize)] +pub(super) struct PluginsResponse { + pub(super) workspace: String, + pub(super) plugins: Vec, + pub(super) registry_diagnostics: Vec, + pub(super) validation_clean: bool, +} + +/// One reviewed-plugin MCP server in the trust-review payload. Secret-bearing +/// maps are reduced to key names, mirroring `McpServerDetail` for configured +/// servers: a reviewer sees what would run and where it would talk, never +/// credential values. +#[derive(Debug, Serialize)] +pub(super) struct PluginMcpServerReview { + pub(super) name: String, + pub(super) kind: &'static str, + pub(super) command: Option, + pub(super) args: Vec, + pub(super) url: Option, + pub(super) env_keys: Vec, + pub(super) header_keys: Vec, +} + +#[derive(Debug, Serialize)] +pub(super) struct PluginSkillReview { + pub(super) name: String, + pub(super) description: String, +} + +/// The capability review a human approves (or rejects) before trusting a +/// bundle. Structured so a GUI renders it without parsing prose. +#[derive(Debug, Serialize)] +pub(super) struct PluginReviewPayload { + /// Confirmation token binding a trust call to this exact content and + /// capability set (`POST .../trust {"token": ...}`). + pub(super) token: String, + pub(super) capabilities: Vec<&'static str>, + pub(super) unsupported_capabilities: Vec<&'static str>, + pub(super) filesystem_roots: Vec, + pub(super) network_hosts: Vec, + pub(super) lifecycle_mutation: bool, + pub(super) mcp_servers: Vec, + pub(super) skills: Vec, + pub(super) commands: Vec, + pub(super) agents: Vec, + pub(super) hooks: Vec, +} + +#[derive(Debug, Serialize)] +pub(super) struct PluginDetailResponse { + #[serde(flatten)] + pub(super) summary: PluginSummaryEntry, + pub(super) author: Option, + pub(super) homepage: Option, + pub(super) repository: Option, + pub(super) license: Option, + pub(super) keywords: Vec, + pub(super) staged: bool, + pub(super) review: PluginReviewPayload, +} + +#[derive(Debug, Serialize)] +pub(super) struct PluginMutationResponse { + pub(super) outcome: &'static str, + pub(super) name: String, + pub(super) path: Option, + pub(super) content_hash: Option, + pub(super) note: Option<&'static str>, + /// Fresh post-mutation state of the affected bundle, when it still + /// exists (uninstall removes it). + pub(super) plugin: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct PluginActionResponse { + pub(super) name: String, + pub(super) action: &'static str, + pub(super) state: &'static str, + pub(super) note: Option<&'static str>, +} + +// --------------------------------------------------------------------------- +// Request shapes +// --------------------------------------------------------------------------- + +#[derive(Debug, Deserialize)] +pub(super) struct InstallPluginRequest { + /// Install spec accepted by `PluginInstallSource::parse`: a local path + /// (plain or `path:`), `github:owner/repo`, or an HTTPS tarball URL. + pub(super) source: String, + /// When present, the install is refused (and rolled back) unless the + /// installed tree matches this reviewed content hash. + #[serde(default)] + pub(super) expected_content_hash: Option, +} + +#[derive(Debug, Deserialize)] +pub(super) struct TrustPluginRequest { + /// Review token from `GET /v1/apps/plugins/{selector}`. Required: trust + /// is an explicit confirmation bound to both SHA-256 receipts. + pub(super) token: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct AddMarketplaceRequest { + pub(super) name: String, + /// LOCAL catalog document path (kimi/claude/codex/codewhale format, + /// auto-detected). Never fetched over the network. + pub(super) path: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct InstallMarketplaceCandidateRequest { + pub(super) candidate: String, +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +fn registry_for_state(state: &RuntimeApiState) -> Arc { + state + .plugin_discovery + .registry_for_workspace(&state.workspace) +} + +fn diagnostic_entry(diagnostic: &PluginDiagnostic) -> PluginDiagnosticEntry { + PluginDiagnosticEntry { + level: match diagnostic.level { + PluginDiagnosticLevel::Warning => "warning", + PluginDiagnosticLevel::Error => "error", + }, + code: diagnostic.code.to_string(), + message: diagnostic.message.clone(), + path: diagnostic.path.as_ref().map(|p| p.display().to_string()), + } +} + +fn inventory_summary(plugin: &LoadedPlugin) -> PluginInventorySummary { + let inventory = &plugin.inventory; + PluginInventorySummary { + skills: inventory.skills, + mcp_servers: inventory.mcp_servers, + stdio_mcp_servers: inventory.stdio_mcp_servers, + remote_mcp_servers: inventory.remote_mcp_servers, + commands: inventory.commands, + agents: inventory.agents, + hooks: inventory.hooks, + lsp: inventory.lsp, + native: inventory.native, + filesystem_roots: inventory.filesystem_roots.clone(), + network_hosts: inventory.network_hosts.clone(), + lifecycle_mutation: inventory.lifecycle_mutation, + } +} + +fn plugin_summary(plugin: &LoadedPlugin) -> PluginSummaryEntry { + PluginSummaryEntry { + id: plugin.id.as_str().to_string(), + name: plugin.name().to_string(), + display_name: plugin.manifest.plugin.display_name.clone(), + version: plugin.manifest.plugin.version.clone(), + description: plugin.manifest.plugin.description.clone(), + scope: plugin.scope.as_str(), + origin: plugin.origin.as_str(), + path: plugin.canonical_root.display().to_string(), + state: plugin.state_label(), + enabled: plugin.enabled, + trust_status: plugin.trust_status.as_str(), + active: plugin.active(), + compatibility: plugin.compatibility().as_str(), + inventory: inventory_summary(plugin), + content_hash: plugin.content_hash.clone(), + capability_hash: plugin.capability_hash.clone(), + state_generation: plugin.state_generation, + diagnostics: plugin.diagnostics.iter().map(diagnostic_entry).collect(), + } +} + +fn mcp_server_review(name: &str, cfg: &crate::mcp::McpServerConfig) -> PluginMcpServerReview { + let mut env_keys: Vec = cfg.env.keys().cloned().collect(); + env_keys.sort(); + let mut header_keys: Vec = cfg.headers.keys().cloned().collect(); + header_keys.sort(); + PluginMcpServerReview { + name: name.to_string(), + kind: if cfg.url.is_some() { "remote" } else { "stdio" }, + command: cfg.command.clone(), + args: cfg.args.clone(), + url: cfg.url.clone(), + env_keys, + header_keys, + } +} + +fn file_stem(path: &std::path::Path) -> String { + path.file_stem() + .map(|stem| stem.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()) +} + +fn review_payload(plugin: &LoadedPlugin) -> PluginReviewPayload { + let mut mcp_servers: Vec<_> = plugin + .manifest + .mcp_servers + .as_ref() + .map(|servers| { + servers + .iter() + .map(|(name, cfg)| mcp_server_review(name, cfg)) + .collect() + }) + .unwrap_or_default(); + mcp_servers.sort_by(|a, b| a.name.cmp(&b.name)); + let mut commands: Vec<_> = plugin + .components + .commands + .iter() + .map(|p| file_stem(p)) + .collect(); + commands.sort(); + let mut agents: Vec<_> = plugin + .components + .agents + .iter() + .map(|p| file_stem(p)) + .collect(); + agents.sort(); + let mut hooks: Vec<_> = plugin + .components + .hooks + .iter() + .map(|p| file_stem(p)) + .collect(); + hooks.sort(); + let mut skills: Vec<_> = plugin + .skill_snapshots + .iter() + .map(|skill| PluginSkillReview { + name: skill.name.clone(), + description: skill.description.clone(), + }) + .collect(); + skills.sort_by(|a, b| a.name.cmp(&b.name)); + + PluginReviewPayload { + token: plugin.review_token(), + capabilities: plugin.inventory.supported_labels(), + unsupported_capabilities: plugin.inventory.unsupported_labels(), + filesystem_roots: plugin.inventory.filesystem_roots.clone(), + network_hosts: plugin.inventory.network_hosts.clone(), + lifecycle_mutation: plugin.inventory.lifecycle_mutation, + mcp_servers, + skills, + commands, + agents, + hooks, + } +} + +fn plugin_detail(plugin: &LoadedPlugin) -> PluginDetailResponse { + PluginDetailResponse { + summary: plugin_summary(plugin), + author: plugin.manifest.plugin.author.clone(), + homepage: plugin.manifest.plugin.homepage.clone(), + repository: plugin.manifest.plugin.repository.clone(), + license: plugin.manifest.plugin.license.clone(), + keywords: plugin.manifest.plugin.keywords.clone(), + staged: plugin.staged_root.is_some(), + review: review_payload(plugin), + } +} + +fn find_plugin(state: &RuntimeApiState, selector: &str) -> Result { + registry_for_state(state) + .get(selector) + .cloned() + .ok_or_else(|| ApiError::not_found(format!("plugin '{selector}' not found"))) +} + +/// Execute an install/update/uninstall through the reviewed mutation +/// controller using the server's own config for network policy, then +/// invalidate the MCP pool so merged plugin servers reload on next use. +async fn run_plugin_mutation( + state: &RuntimeApiState, + request: PluginMutationRequest, +) -> Result { + let network = { + let config = state.config.read(); + config + .network + .clone() + .map(|policy| policy.into_runtime()) + .unwrap_or_default() + }; + let ctx = PluginMutationContext { + network: &network, + max_size: crate::plugins::install::DEFAULT_MAX_SIZE_BYTES, + }; + let mut registry = (*registry_for_state(state)).clone(); + let receipt = crate::plugins::mutation::execute(request, &ctx, &mut registry) + .await + .map_err(|error| ApiError::internal(format!("plugin mutation failed: {error:#}")))?; + + // Policy outcomes are not server errors: report the blocked host with + // the same wording the skill lifecycle API uses. + let outcome = match &receipt.outcome { + PluginMutationOutcome::NeedsApproval(host) => { + return Err(ApiError::forbidden(format!( + "network access to '{host}' requires explicit approval; \ + approve the host in your network policy before installing this plugin" + ))); + } + PluginMutationOutcome::NetworkDenied(host) => { + return Err(ApiError::forbidden(format!( + "network access to '{host}' was denied by the active network policy" + ))); + } + PluginMutationOutcome::Installed => "installed", + PluginMutationOutcome::Updated => "updated", + PluginMutationOutcome::NoChange => "no_change", + PluginMutationOutcome::Uninstalled => "uninstalled", + }; + + // Mutations can change merged plugin MCP servers; drop the cached pool + // exactly like the MCP config write endpoints do. + *state.mcp_pool.lock().await = None; + + let plugin = registry_for_state(state) + .get(receipt.name.as_str()) + .map(plugin_summary); + let note = match receipt.outcome { + PluginMutationOutcome::Installed => Some( + "Installed disabled and untrusted. Review the capability payload \ + (GET /v1/apps/plugins/{name}), then trust and enable it.", + ), + PluginMutationOutcome::Updated => Some( + "Content changed; the previous trust receipt no longer matches. \ + Review and trust it again before enabling.", + ), + _ => None, + }; + Ok(PluginMutationResponse { + outcome, + name: receipt.name.clone(), + path: receipt.path.as_ref().map(|p| p.display().to_string()), + content_hash: receipt.installed_content_hash.or(receipt.content_hash), + note, + plugin, + }) +} + +/// Run a registry state mutation (`trust`/`enable`/`disable`/`revoke`) +/// against a fresh registry, then invalidate the MCP pool. Trust is the only +/// one with a precondition beyond the registry's own checks: the request +/// token must match the bundle's review token. +async fn run_registry_mutation( + state: &RuntimeApiState, + selector: &str, + mutation: RegistryMutation<'_>, +) -> Result { + let registry = registry_for_state(state); + if let RegistryMutation::Trust { token } = &mutation { + let Some(plugin) = registry.get(selector) else { + return Err(ApiError::not_found(format!( + "plugin '{selector}' not found" + ))); + }; + if token != &plugin.review_token() { + return Err(ApiError::bad_request( + "review token does not match this bundle's content and capability set; \ + re-read GET /v1/apps/plugins/{name} and confirm the current token", + )); + } + } + + let action = match mutation { + RegistryMutation::Trust { .. } => "trusted", + RegistryMutation::Enable => "enabled", + RegistryMutation::Disable => "disabled", + RegistryMutation::Revoke => "trust-revoked", + }; + + let mut registry = (*registry).clone(); + let result = match mutation { + RegistryMutation::Trust { .. } => registry.trust(selector), + RegistryMutation::Enable => registry.enable(selector), + RegistryMutation::Disable => registry.disable(selector), + RegistryMutation::Revoke => registry.revoke_trust(selector), + }; + result.map_err(|error| { + ApiError::conflict(format!("{action} failed for '{selector}': {error}")) + })?; + + *state.mcp_pool.lock().await = None; + + let fresh = registry_for_state(state); + let Some(plugin) = fresh.get(selector) else { + return Ok(PluginActionResponse { + name: selector.to_string(), + action, + state: "removed", + note: None, + }); + }; + let note = match (action, plugin.state_label()) { + ("enabled", "enabled-untrusted") => Some( + "enabled-untrusted: the bundle is not trusted; run the review flow \ + (GET /v1/apps/plugins/{name}) and trust it first", + ), + ("enabled", _) => { + let inactive = plugin.inventory.unsupported_labels(); + (!inactive.is_empty()).then_some( + "supported declarative components are active; inventory-only \ + capabilities stay inactive", + ) + } + _ => None, + }; + Ok(PluginActionResponse { + name: selector.to_string(), + action, + state: plugin.state_label(), + note, + }) +} + +enum RegistryMutation<'a> { + Trust { token: &'a str }, + Enable, + Disable, + Revoke, +} + +fn open_marketplace_store(state: &RuntimeApiState) -> Result { + MarketplaceStore::open(registry_for_state(state).state_path()).ok_or_else(|| { + ApiError::internal( + "this plugin registry has no persistence store; \ + marketplace catalogs cannot be saved", + ) + }) +} + +fn load_marketplace_state( + store: &MarketplaceStore, +) -> Result { + store.load().map_err(|error| { + ApiError::internal(format!( + "marketplace state is fail-closed and will not be rewritten: {error}" + )) + }) +} + +// --------------------------------------------------------------------------- +// Marketplace DTOs +// --------------------------------------------------------------------------- + +#[derive(Debug, Serialize)] +pub(super) struct MarketplaceInstallPlanEntry { + pub(super) installable: bool, + pub(super) spec: Option, + pub(super) source_kind: Option, + pub(super) reason: Option, +} + +#[derive(Debug, Serialize)] +pub(super) struct MarketplaceCandidateEntry { + pub(super) name: String, + pub(super) display_name: Option, + pub(super) description: Option, + pub(super) version: Option, + pub(super) author: Option, + pub(super) homepage: Option, + pub(super) repository: Option, + pub(super) license: Option, + pub(super) keywords: Vec, + pub(super) categories: Vec, + pub(super) tier: String, + pub(super) compatibility: Option<&'static str>, + pub(super) install: MarketplaceInstallPlanEntry, + pub(super) diagnostics: Vec, +} + +#[derive(Debug, Serialize)] +pub(super) struct MarketplaceCatalogEntry { + pub(super) name: String, + pub(super) display_name: Option, + pub(super) description: Option, + pub(super) format: &'static str, + pub(super) tier: String, + pub(super) added_at: String, + pub(super) source_path: String, + pub(super) candidate_count: usize, + pub(super) warning_count: usize, + pub(super) error_count: usize, + pub(super) diagnostics: Vec, + pub(super) candidates: Vec, +} + +#[derive(Debug, Serialize)] +pub(super) struct MarketplacesResponse { + pub(super) marketplaces: Vec, +} + +#[derive(Debug, Serialize)] +pub(super) struct MarketplaceActionResponse { + pub(super) name: String, + pub(super) action: &'static str, + pub(super) candidate_count: Option, + pub(super) warning_count: Option, +} + +fn marketplace_candidate_entry( + entry: &crate::plugins::marketplace::store::StoredMarketplaceCatalog, + candidate: &crate::plugins::marketplace::types::MarketplaceCandidate, +) -> MarketplaceCandidateEntry { + let install = match resolve_candidate_install(entry, candidate) { + CatalogInstallResolution::Supported { spec, source_kind } => MarketplaceInstallPlanEntry { + installable: true, + spec: Some(spec), + source_kind: Some(source_kind), + reason: None, + }, + CatalogInstallResolution::Unsupported { reason } => MarketplaceInstallPlanEntry { + installable: false, + spec: None, + source_kind: None, + reason: Some(reason), + }, + CatalogInstallResolution::HasErrors { diagnostics } => MarketplaceInstallPlanEntry { + installable: false, + spec: None, + source_kind: None, + reason: Some(format!("candidate has parse errors: {diagnostics}")), + }, + }; + MarketplaceCandidateEntry { + name: candidate.name.clone(), + display_name: candidate.display_name.clone(), + description: candidate.description.clone(), + version: candidate.version.clone(), + author: candidate.author.clone(), + homepage: candidate.homepage.clone(), + repository: candidate.repository.clone(), + license: candidate.license.clone(), + keywords: candidate.keywords.clone(), + categories: candidate.categories.clone(), + tier: candidate.provenance.tier.to_string(), + compatibility: candidate.compatibility.as_ref().map(|c| c.as_str()), + install, + diagnostics: candidate + .diagnostics + .iter() + .map(|d| PluginDiagnosticEntry { + level: match d.level { + PluginDiagnosticLevel::Warning => "warning", + PluginDiagnosticLevel::Error => "error", + }, + code: d.code.to_string(), + message: d.message.clone(), + path: None, + }) + .collect(), + } +} + +fn marketplace_catalog_entry( + name: &str, + entry: &crate::plugins::marketplace::store::StoredMarketplaceCatalog, +) -> MarketplaceCatalogEntry { + MarketplaceCatalogEntry { + name: name.to_string(), + display_name: entry.catalog.display_name.clone(), + description: entry.catalog.description.clone(), + format: entry.catalog.format.as_str(), + tier: entry.catalog.provenance.tier.to_string(), + added_at: entry.added_at.clone(), + source_path: entry.source_path.clone(), + candidate_count: entry.catalog.total_candidates(), + warning_count: entry.catalog.warning_count(), + error_count: entry.catalog.error_count(), + diagnostics: entry + .catalog + .diagnostics + .iter() + .map(|d| PluginDiagnosticEntry { + level: match d.level { + PluginDiagnosticLevel::Warning => "warning", + PluginDiagnosticLevel::Error => "error", + }, + code: d.code.to_string(), + message: d.message.clone(), + path: None, + }) + .collect(), + candidates: entry + .catalog + .candidates + .iter() + .map(|candidate| marketplace_candidate_entry(entry, candidate)) + .collect(), + } +} + +// --------------------------------------------------------------------------- +// Handlers — plugins +// --------------------------------------------------------------------------- + +/// `GET /v1/apps/plugins` +pub(super) async fn list_plugins( + State(state): State, +) -> Result, ApiError> { + let registry = registry_for_state(&state); + Ok(Json(PluginsResponse { + workspace: state.workspace.display().to_string(), + plugins: registry.list().iter().map(|p| plugin_summary(p)).collect(), + registry_diagnostics: registry + .diagnostics() + .iter() + .map(diagnostic_entry) + .collect(), + validation_clean: registry.validation_is_clean(), + })) +} + +/// `GET /v1/apps/plugins/{selector}` +pub(super) async fn get_plugin( + State(state): State, + Path(selector): Path, +) -> Result, ApiError> { + Ok(Json(plugin_detail(&find_plugin(&state, &selector)?))) +} + +/// `POST /v1/apps/plugins/install` +pub(super) async fn install_plugin_api( + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json), ApiError> { + let source = + crate::plugins::install::PluginInstallSource::parse(&req.source).map_err(|error| { + ApiError::bad_request(format!( + "invalid plugin install source '{}': {error:#}; expected a local \ + path, github:owner/repo, or an HTTPS tarball URL", + req.source + )) + })?; + let request = match req.expected_content_hash { + Some(expected) => PluginMutationRequest::InstallExact { + source, + expected_content_hash: expected, + }, + None => PluginMutationRequest::Install { source }, + }; + let response = run_plugin_mutation(&state, request).await?; + Ok((StatusCode::CREATED, Json(response))) +} + +/// `POST /v1/apps/plugins/{selector}/update` +pub(super) async fn update_plugin_api( + State(state): State, + Path(selector): Path, +) -> Result, ApiError> { + find_plugin(&state, &selector)?; + Ok(Json( + run_plugin_mutation( + &state, + PluginMutationRequest::Update { + selector: selector.clone(), + }, + ) + .await?, + )) +} + +/// `DELETE /v1/apps/plugins/{selector}` +pub(super) async fn uninstall_plugin_api( + State(state): State, + Path(selector): Path, +) -> Result, ApiError> { + find_plugin(&state, &selector)?; + Ok(Json( + run_plugin_mutation( + &state, + PluginMutationRequest::Uninstall { + selector: selector.clone(), + }, + ) + .await?, + )) +} + +/// `POST /v1/apps/plugins/{selector}/trust` +pub(super) async fn trust_plugin_api( + State(state): State, + Path(selector): Path, + Json(req): Json, +) -> Result, ApiError> { + Ok(Json( + run_registry_mutation( + &state, + &selector, + RegistryMutation::Trust { token: &req.token }, + ) + .await?, + )) +} + +/// `POST /v1/apps/plugins/{selector}/enable` +pub(super) async fn enable_plugin_api( + State(state): State, + Path(selector): Path, +) -> Result, ApiError> { + Ok(Json( + run_registry_mutation(&state, &selector, RegistryMutation::Enable).await?, + )) +} + +/// `POST /v1/apps/plugins/{selector}/disable` +pub(super) async fn disable_plugin_api( + State(state): State, + Path(selector): Path, +) -> Result, ApiError> { + Ok(Json( + run_registry_mutation(&state, &selector, RegistryMutation::Disable).await?, + )) +} + +/// `POST /v1/apps/plugins/{selector}/revoke` +pub(super) async fn revoke_plugin_api( + State(state): State, + Path(selector): Path, +) -> Result, ApiError> { + Ok(Json( + run_registry_mutation(&state, &selector, RegistryMutation::Revoke).await?, + )) +} + +// --------------------------------------------------------------------------- +// Handlers — marketplaces +// --------------------------------------------------------------------------- + +/// `GET /v1/apps/marketplaces` +pub(super) async fn list_marketplaces( + State(state): State, +) -> Result, ApiError> { + let store = open_marketplace_store(&state)?; + let marketplace_state = load_marketplace_state(&store)?; + Ok(Json(MarketplacesResponse { + marketplaces: marketplace_state + .catalogs() + .iter() + .map(|(name, entry)| marketplace_catalog_entry(name, entry)) + .collect(), + })) +} + +/// `GET /v1/apps/marketplaces/{name}` +pub(super) async fn get_marketplace( + State(state): State, + Path(name): Path, +) -> Result, ApiError> { + let store = open_marketplace_store(&state)?; + let marketplace_state = load_marketplace_state(&store)?; + let entry = marketplace_state + .get(&name) + .ok_or_else(|| ApiError::not_found(format!("marketplace '{name}' not found")))?; + Ok(Json(marketplace_catalog_entry(&name, entry))) +} + +/// `POST /v1/apps/marketplaces` +pub(super) async fn add_marketplace( + State(state): State, + Json(req): Json, +) -> Result<(StatusCode, Json), ApiError> { + let store = open_marketplace_store(&state)?; + let loaded = load_catalog_document(&req.name, &state.workspace, &req.path) + .map_err(ApiError::bad_request)?; + store + .add(&loaded.entry.catalog.id.clone(), loaded.entry) + .map_err(ApiError::conflict)?; + Ok(( + StatusCode::CREATED, + Json(MarketplaceActionResponse { + name: req.name, + action: "added", + candidate_count: Some(loaded.candidate_count), + warning_count: Some(loaded.warning_count), + }), + )) +} + +/// `DELETE /v1/apps/marketplaces/{name}` +pub(super) async fn remove_marketplace( + State(state): State, + Path(name): Path, +) -> Result, ApiError> { + let store = open_marketplace_store(&state)?; + let removed = store + .remove(&name) + .map_err(|error| ApiError::internal(format!("remove marketplace: {error}")))?; + if !removed { + return Err(ApiError::not_found(format!( + "marketplace '{name}' not found" + ))); + } + Ok(Json(MarketplaceActionResponse { + name, + action: "removed", + candidate_count: None, + warning_count: None, + })) +} + +/// `POST /v1/apps/marketplaces/{name}/install` +/// +/// Resolves the stored candidate through the shared plan resolver, then +/// routes through the reviewed installer exactly like +/// `POST /v1/apps/plugins/install`. +pub(super) async fn install_marketplace_candidate_api( + State(state): State, + Path(name): Path, + Json(req): Json, +) -> Result<(StatusCode, Json), ApiError> { + let store = open_marketplace_store(&state)?; + let marketplace_state = load_marketplace_state(&store)?; + let entry = marketplace_state + .get(&name) + .ok_or_else(|| ApiError::not_found(format!("marketplace '{name}' not found")))?; + let candidate = entry + .catalog + .candidate_by_name(&req.candidate) + .ok_or_else(|| { + ApiError::not_found(format!( + "candidate '{}' not found in marketplace '{name}'", + req.candidate + )) + })?; + match resolve_candidate_install(entry, candidate) { + CatalogInstallResolution::Supported { spec, .. } => { + let response = run_plugin_mutation( + &state, + PluginMutationRequest::Install { + source: crate::plugins::install::PluginInstallSource::parse(&spec).map_err( + |error| { + ApiError::internal(format!( + "resolved install spec '{spec}' no longer parses: {error:#}" + )) + }, + )?, + }, + ) + .await?; + Ok((StatusCode::CREATED, Json(response))) + } + CatalogInstallResolution::Unsupported { reason } => Err(ApiError::conflict(format!( + "candidate '{}' cannot be installed by Codewhale: {reason}", + req.candidate + ))), + CatalogInstallResolution::HasErrors { diagnostics } => Err(ApiError::conflict(format!( + "candidate '{}' has parse errors and cannot be installed: {diagnostics}", + req.candidate + ))), + } +} diff --git a/crates/tui/src/runtime_api/tests.rs b/crates/tui/src/runtime_api/tests.rs index dc75aba02e..14f3ad2974 100644 --- a/crates/tui/src/runtime_api/tests.rs +++ b/crates/tui/src/runtime_api/tests.rs @@ -10555,3 +10555,417 @@ fn receipt_evidence_paths_must_stay_confined_to_the_workspace() { "../escape.json" ))); } + +// ─── Plugin management API tests ──────────────────────────────────────────── + +/// Write a minimal but real plugin bundle: manifest plus one skill, so the +/// bundle has a supported declarative component and can be trusted+enabled. +fn write_plugin_source_bundle(dir: &Path, name: &str) -> Result { + let bundle = dir.join(name); + fs::create_dir_all(bundle.join("skills").join("greet"))?; + fs::write( + bundle.join("plugin.toml"), + format!( + "schema_version = 1\n[plugin]\nname = \"{name}\"\nversion = \"1.0.0\"\n\ + description = \"test bundle\"\n\n[skills]\npath = \"skills\"\n" + ), + )?; + fs::write( + bundle.join("skills").join("greet").join("SKILL.md"), + "---\nname: greet\ndescription: Greets a person\n---\nSay hi.\n", + )?; + Ok(bundle) +} + +/// An isolated plugin discovery context: user plugins, workspace plugins, +/// state, and marketplaces all live under the test root instead of the real +/// `~/.codewhale`. +fn isolated_plugin_discovery( + root: &Path, + workspace: &Path, +) -> Arc { + crate::plugins::PluginDiscoveryContext::from_config_and_environment( + &crate::plugins::discovery::DiscoveryConfig { + workspace: workspace.to_path_buf(), + user_plugins_dir: root.join("plugins"), + workspace_plugins_dir: crate::plugins::discovery::default_workspace_plugins_dir( + workspace, + ), + builtin_plugin_dirs: Vec::new(), + state_path: root.join("plugins").join("state.json"), + }, + crate::plugins::HostEnvironment::from_entries(Vec::new()), + ) +} + +async fn spawn_plugin_api_server( + root: PathBuf, + workspace: PathBuf, +) -> Result)>> { + let Some((addr, _runtime_threads, handle)) = + spawn_test_server_with_root_token_mobile_workspace_and_overrides( + root.clone(), + root.join("sessions"), + None, + false, + workspace.clone(), + TestServerOverrides { + plugin_discovery: Some(isolated_plugin_discovery(&root, &workspace)), + ..TestServerOverrides::default() + }, + ) + .await? + else { + return Ok(None); + }; + Ok(Some((addr, handle))) +} + +#[tokio::test] +async fn runtime_info_advertises_plugin_management_capability() -> Result<()> { + let Some((addr, _runtime_threads, handle)) = spawn_test_server().await? else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let info: serde_json::Value = client + .get(format!("http://{addr}/v1/runtime/info")) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!( + info["capabilities"]["plugin_management"], true, + "runtime/info must advertise plugin_management capability" + ); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn plugin_lifecycle_over_http_installs_reviews_enables_and_uninstalls() -> Result<()> { + let tmp = tempfile::tempdir()?; + let root = tmp.path().join("runtime"); + let workspace = tmp.path().join("ws"); + fs::create_dir_all(&root)?; + let source = write_plugin_source_bundle(tmp.path(), "demo")?; + + let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).await? else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}/v1/apps/plugins"); + + // Empty inventory to start. + let list: serde_json::Value = client + .get(&base) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert!(list["plugins"].as_array().is_some_and(Vec::is_empty)); + + // Install from a local path: lands disabled and untrusted. + let install: serde_json::Value = client + .post(format!("{base}/install")) + .json(&serde_json::json!({ + "source": format!("path:{}", source.display()) + })) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(install["outcome"], "installed"); + assert_eq!(install["name"], "demo"); + assert_eq!(install["plugin"]["state"], "disabled"); + assert_eq!(install["plugin"]["trust_status"], "not-reviewed"); + assert!( + install["note"] + .as_str() + .is_some_and(|n| n.contains("untrusted")), + "install note must route to review: {}", + install["note"] + ); + + // Detail carries the structured review payload and token. The summary is + // flattened into the detail object. + let detail: serde_json::Value = client + .get(format!("{base}/demo")) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(detail["inventory"]["skills"], 1); + assert_eq!(detail["review"]["token"], { + let summary = &detail; + format!( + "{}.{}", + summary["content_hash"].as_str().unwrap(), + summary["capability_hash"].as_str().unwrap() + ) + }); + assert!( + detail["review"]["capabilities"] + .as_array() + .is_some_and(|c| c.iter().any(|label| label == "skills")) + ); + + // Trust requires the exact review token. + let wrong = client + .post(format!("{base}/demo/trust")) + .json(&serde_json::json!({"token": "not-the-token"})) + .send() + .await?; + assert_eq!(wrong.status(), StatusCode::BAD_REQUEST); + + let token = detail["review"]["token"].as_str().unwrap().to_string(); + let trusted: serde_json::Value = client + .post(format!("{base}/demo/trust")) + .json(&serde_json::json!({"token": token})) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(trusted["action"], "trusted"); + assert_eq!(trusted["state"], "disabled"); + + // Enable only works after trust; the bundle becomes active. + let enabled: serde_json::Value = client + .post(format!("{base}/demo/enable")) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(enabled["action"], "enabled"); + assert_eq!(enabled["state"], "active"); + + let disabled: serde_json::Value = client + .post(format!("{base}/demo/disable")) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(disabled["action"], "disabled"); + assert_eq!(disabled["state"], "disabled"); + + // Uninstall removes it. + let uninstalled: serde_json::Value = client + .delete(format!("{base}/demo")) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(uninstalled["outcome"], "uninstalled"); + + let list: serde_json::Value = client + .get(&base) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert!(list["plugins"].as_array().is_some_and(Vec::is_empty)); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn plugin_api_404s_for_unknown_selector() -> Result<()> { + let tmp = tempfile::tempdir()?; + let root = tmp.path().join("runtime"); + let workspace = tmp.path().join("ws"); + fs::create_dir_all(&root)?; + + let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).await? else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let detail = client + .get(format!("http://{addr}/v1/apps/plugins/ghost")) + .send() + .await?; + assert_eq!(detail.status(), StatusCode::NOT_FOUND); + + let trust = client + .post(format!("http://{addr}/v1/apps/plugins/ghost/trust")) + .json(&serde_json::json!({"token": "x"})) + .send() + .await?; + assert_eq!(trust.status(), StatusCode::NOT_FOUND); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn marketplace_catalog_lifecycle_over_http_lists_installs_and_removes() -> Result<()> { + let tmp = tempfile::tempdir()?; + let root = tmp.path().join("runtime"); + let workspace = tmp.path().join("ws"); + fs::create_dir_all(&root)?; + + // The catalog directory holds both the document and the bundle it points + // at via a relative `./demo` source. + let catalog_dir = tmp.path().join("catalog"); + fs::create_dir_all(&catalog_dir)?; + write_plugin_source_bundle(&catalog_dir, "demo")?; + let catalog_path = catalog_dir.join("catalog.json"); + fs::write( + &catalog_path, + r#"{ + "name": "team", + "description": "Team plugins", + "version": "1", + "plugins": [ + {"name": "demo", "source": "path:./demo", "description": "Demo bundle"} + ] + }"#, + )?; + + let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).await? else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + let base = format!("http://{addr}/v1/apps/marketplaces"); + + let add_resp = client + .post(&base) + .json(&serde_json::json!({ + "name": "team", + "path": catalog_path.display().to_string() + })) + .send() + .await?; + let add: serde_json::Value = add_resp.json().await?; + assert!(add["action"] == "added", "marketplace add failed: {add}"); + assert_eq!(add["candidate_count"], 1); + + // Listing shows the candidate with an honest, resolved install plan. + let list: serde_json::Value = client + .get(&base) + .send() + .await? + .error_for_status()? + .json() + .await?; + let candidate = &list["marketplaces"][0]["candidates"][0]; + assert_eq!(candidate["name"], "demo"); + assert_eq!(candidate["install"]["installable"], true); + assert!( + candidate["install"]["spec"] + .as_str() + .is_some_and(|spec| spec.contains("demo")), + "relative ./demo must resolve against the catalog directory: {}", + candidate["install"]["spec"] + ); + + // Install through the marketplace routes into the reviewed installer and + // lands disabled + untrusted, exactly like a direct install. + let install: serde_json::Value = client + .post(format!("{base}/team/install")) + .json(&serde_json::json!({"candidate": "demo"})) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(install["outcome"], "installed"); + assert_eq!(install["plugin"]["trust_status"], "not-reviewed"); + + // Unknown candidate and unknown catalog are honest 404s. + let missing = client + .post(format!("{base}/team/install")) + .json(&serde_json::json!({"candidate": "ghost"})) + .send() + .await?; + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + + // Removing the catalog never touches installed bundles. + let plugins: serde_json::Value = client + .get(format!("http://{addr}/v1/apps/plugins")) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert!( + plugins["plugins"] + .as_array() + .is_some_and(|p| p.iter().any(|entry| entry["name"] == "demo")) + ); + + let removed: serde_json::Value = client + .delete(format!("{base}/team")) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert_eq!(removed["action"], "removed"); + + let plugins_after: serde_json::Value = client + .get(format!("http://{addr}/v1/apps/plugins")) + .send() + .await? + .error_for_status()? + .json() + .await?; + assert!( + plugins_after["plugins"] + .as_array() + .is_some_and(|p| p.iter().any(|entry| entry["name"] == "demo")) + ); + + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn marketplace_add_rejects_symlink_documents_over_http() -> Result<()> { + let tmp = tempfile::tempdir()?; + let root = tmp.path().join("runtime"); + let workspace = tmp.path().join("ws"); + fs::create_dir_all(&root)?; + + let catalog_dir = tmp.path().join("catalog"); + fs::create_dir_all(&catalog_dir)?; + let real = catalog_dir.join("real.json"); + fs::write(&real, r#"{"plugins":[]}"#)?; + let link = catalog_dir.join("link.json"); + #[cfg(unix)] + std::os::unix::fs::symlink(&real, &link)?; + #[cfg(not(unix))] + let link = real; + + let Some((addr, handle)) = spawn_plugin_api_server(root, workspace).await? else { + return Ok(()); + }; + let client = crate::tls::reqwest_client(); + + let resp = client + .post(format!("http://{addr}/v1/apps/marketplaces")) + .json(&serde_json::json!({ + "name": "team", + "path": link.display().to_string() + })) + .send() + .await?; + #[cfg(unix)] + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + #[cfg(not(unix))] + assert!(resp.status().is_success()); + + handle.abort(); + Ok(()) +}