Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions crates/protocol/src/runtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand All @@ -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));
}

Expand Down
174 changes: 28 additions & 146 deletions crates/tui/src/commands/groups/plugins/marketplace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> <path> read a local catalog file (kimi/claude/codex/codewhale)\n\
\x20 list show catalogs and their candidates\n\
Expand All @@ -59,78 +56,20 @@ fn open_store(app: &App) -> Result<MarketplaceStore, Box<CommandResult>> {
})
}

/// 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::<serde_json::Value>(&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.",
Expand Down Expand Up @@ -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<PathBuf, String> {
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<String, String> {
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 {
Expand Down
7 changes: 3 additions & 4 deletions crates/tui/src/commands/groups/plugins/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
8 changes: 4 additions & 4 deletions crates/tui/src/plugins/install/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ pub async fn install(
max_size: u64,
network: &NetworkPolicy,
update: bool,
name_conflict: &dyn Fn(&str) -> Option<String>,
name_conflict: &(dyn Fn(&str) -> Option<String> + Send + Sync),
) -> Result<PluginInstallOutcome> {
Comment on lines 239 to 242
install_inner(
source,
Expand All @@ -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<String>,
name_conflict: &(dyn Fn(&str) -> Option<String> + Send + Sync),
expected_content_hash: &str,
) -> Result<PluginInstallOutcome> {
install_inner(
Expand All @@ -281,7 +281,7 @@ async fn install_inner(
max_size: u64,
network: &NetworkPolicy,
update: bool,
name_conflict: &dyn Fn(&str) -> Option<String>,
name_conflict: &(dyn Fn(&str) -> Option<String> + Send + Sync),
expected_content_hash: Option<&str>,
) -> Result<PluginInstallOutcome> {
match &source {
Expand Down Expand Up @@ -356,7 +356,7 @@ fn install_remote_bytes(
user_plugins_dir: &Path,
max_size: u64,
update: bool,
name_conflict: &dyn Fn(&str) -> Option<String>,
name_conflict: &(dyn Fn(&str) -> Option<String> + Send + Sync),
expected_content_hash: Option<&str>,
) -> Result<PluginInstallOutcome> {
let checksum = sha256_hex(bytes);
Expand Down
Loading
Loading