Update: 将子项目从 submodule 转为完整内容

- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用
- 添加所有子项目的完整源代码
- 保留原始 .git 为 .git.bak 备份
This commit is contained in:
freedak
2026-07-04 19:20:46 +08:00
parent 54d6465fa7
commit f7a720204a
3360 changed files with 802660 additions and 3 deletions
@@ -0,0 +1,83 @@
use std::path::{Component, Path, PathBuf};
/// Return `true` when the asset reference is already a remote URL.
pub(crate) fn is_remote_asset_url(value: &str) -> bool {
value.starts_with("http://") || value.starts_with("https://")
}
/// Normalize a user-supplied relative asset path and reject traversal.
pub(crate) fn normalize_relative_asset_path(path: &str) -> Option<PathBuf> {
if path.contains('\\') {
return None;
}
let mut normalized = PathBuf::new();
for component in Path::new(path).components() {
match component {
Component::Normal(part) => normalized.push(part),
Component::CurDir => {}
Component::ParentDir | Component::RootDir | Component::Prefix(_) => return None,
}
}
if normalized.as_os_str().is_empty() {
return None;
}
Some(normalized)
}
/// Convert a normalized relative path into a URL path with forward slashes.
pub(crate) fn normalized_asset_url_path(path: &str) -> Option<String> {
let normalized = normalize_relative_asset_path(path)?;
Some(
normalized
.components()
.filter_map(|component| match component {
Component::Normal(part) => Some(part.to_string_lossy().into_owned()),
_ => None,
})
.collect::<Vec<_>>()
.join("/"),
)
}
/// Resolve an extension-scoped asset reference into a backend-served URL.
pub(crate) fn resolve_extension_asset_url(extension_name: &str, raw: &str) -> Option<String> {
if is_remote_asset_url(raw) {
return Some(raw.to_owned());
}
let relative = normalized_asset_url_path(raw)?;
Some(format!("/api/extensions/{extension_name}/assets/{relative}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn remote_asset_url_detects_http_and_https() {
assert!(is_remote_asset_url("http://example.com/icon.png"));
assert!(is_remote_asset_url("https://example.com/icon.png"));
assert!(!is_remote_asset_url("/local/icon.png"));
}
#[test]
fn normalize_relative_asset_path_rejects_traversal_and_absolute_paths() {
assert!(normalize_relative_asset_path("../secret.txt").is_none());
assert!(normalize_relative_asset_path("/etc/passwd").is_none());
assert!(normalize_relative_asset_path("C:\\Windows\\System32").is_none());
}
#[test]
fn normalize_relative_asset_path_preserves_nested_relative_paths() {
let path = normalize_relative_asset_path("./settings/ui/index.html").unwrap();
assert_eq!(path, PathBuf::from("settings/ui/index.html"));
assert_eq!(
normalized_asset_url_path("./settings/ui/index.html").as_deref(),
Some("settings/ui/index.html")
);
}
}
@@ -0,0 +1,36 @@
//! Assistant source classification + rule/skill dispatch traits used by
//! `skill_routes` to route rule-md / skill-md reads/writes to the correct
//! source (built-in file, extension resolution, or user-writable directory).
//!
//! These traits live in `nomifun-extension` (not `nomifun-assistant`) so
//! `skill_routes` can depend on them without pulling `nomifun-assistant` into
//! the dependency graph; the concrete implementation ships from
//! `nomifun-assistant::AssistantService`.
use nomifun_api_types::AssistantSource;
use nomifun_common::AppError;
/// Classify an assistant id into its source (builtin / extension / user).
#[async_trait::async_trait]
pub trait AssistantClassifier: Send + Sync {
/// Return the source of the assistant. Callers treat `User` as "not
/// known to builtins or extensions"; confirming existence in the user
/// table is the repository's job.
async fn classify(&self, id: &str) -> AssistantSource;
}
/// Source-dispatched read/write access for assistant rule/skill md files.
///
/// Implemented by `nomifun_assistant::AssistantService`; depended on by
/// `skill_routes` so the existing `/api/skills/assistant-rule/*` and
/// `/api/skills/assistant-skill/*` endpoints dispatch per source.
#[async_trait::async_trait]
pub trait AssistantRuleDispatcher: Send + Sync {
async fn read_rule(&self, id: &str, locale: Option<&str>) -> Result<String, AppError>;
async fn write_rule(&self, id: &str, locale: Option<&str>, content: &str) -> Result<(), AppError>;
async fn delete_rule(&self, id: &str) -> Result<bool, AppError>;
async fn read_skill(&self, id: &str, locale: Option<&str>) -> Result<String, AppError>;
async fn write_skill(&self, id: &str, locale: Option<&str>, content: &str) -> Result<(), AppError>;
async fn delete_skill(&self, id: &str) -> Result<bool, AppError>;
}
@@ -0,0 +1,173 @@
/// Manifest filename that identifies an extension directory.
pub const EXTENSION_MANIFEST_FILE: &str = "nomi-extension.json";
/// Default subdirectory name for extensions.
pub const EXTENSIONS_DIR_NAME: &str = "extensions";
/// Current extension API version.
pub const EXTENSION_API_VERSION: &str = "1.0.0";
/// Hub index schema version we support.
pub const HUB_SUPPORTED_SCHEMA_VERSION: u32 = 1;
/// Cache TTL for agent activity snapshots (milliseconds).
pub const ACTIVITY_SNAPSHOT_TTL_MS: u64 = 3000;
/// Debounce delay for hot-reload file watching (milliseconds).
pub const DEBOUNCE_MS: u64 = 1000;
/// Debounce delay for state persistence writes (milliseconds).
pub const STATE_PERSIST_DEBOUNCE_MS: u64 = 500;
/// Reserved extension name prefixes that third-party extensions cannot use.
pub const RESERVED_NAME_PREFIXES: &[&str] = &["nomi-", "internal-", "builtin-", "system-"];
/// Preset agent type identifiers.
pub const PRESET_AGENT_TYPES: &[&str] = &["gemini", "claude", "codex", "codebuddy", "opencode"];
// ---------------------------------------------------------------------------
// Lifecycle hook timeouts (seconds)
// ---------------------------------------------------------------------------
/// Timeout for `onInstall` hook — may involve downloading dependencies.
pub const LIFECYCLE_ON_INSTALL_TIMEOUT_SECS: u64 = 120;
/// Timeout for `onUninstall` hook — cleanup operations.
pub const LIFECYCLE_ON_UNINSTALL_TIMEOUT_SECS: u64 = 60;
/// Timeout for `onActivate` hook — runs every activation.
pub const LIFECYCLE_ON_ACTIVATE_TIMEOUT_SECS: u64 = 30;
/// Timeout for `onDeactivate` hook — runs every deactivation.
pub const LIFECYCLE_ON_DEACTIVATE_TIMEOUT_SECS: u64 = 30;
// ---------------------------------------------------------------------------
// Reserved WebUI route prefixes
// ---------------------------------------------------------------------------
/// Route prefixes reserved for internal use — extensions cannot register these.
pub const RESERVED_ROUTE_PREFIXES: &[&str] = &["/api/", "/auth/", "/ws/"];
// ---------------------------------------------------------------------------
// Skill & rule management
// ---------------------------------------------------------------------------
/// Default subdirectory name for user-created skills.
pub const SKILLS_DIR_NAME: &str = "skills";
/// Default subdirectory name for per-job cron skills under the data dir.
pub const CRON_SKILLS_DIR_NAME: &str = "cron/skills";
/// Default subdirectory name for built-in skills.
pub const BUILTIN_SKILLS_DIR_NAME: &str = "builtin-skills";
/// Default subdirectory name for built-in rules.
pub const BUILTIN_RULES_DIR_NAME: &str = "builtin-rules";
/// Subdirectory inside the built-in skills corpus whose children are
/// auto-injected into every assistant. Historical name was `_builtin`;
/// renamed to `auto-inject` as part of the 2026-04-23 built-in skill
/// migration (skills are now embedded in the backend binary via
/// `include_dir!`).
pub const BUILTIN_AUTO_SKILLS_SUBDIR: &str = "auto-inject";
/// Default subdirectory name for assistant-level rules.
pub const ASSISTANT_RULES_DIR_NAME: &str = "assistant-rules";
/// Default subdirectory name for assistant-level skills.
pub const ASSISTANT_SKILLS_DIR_NAME: &str = "assistant-skills";
/// Filename that identifies a skill directory.
pub const SKILL_MANIFEST_FILE: &str = "SKILL.md";
/// Persistence file for custom external skill paths.
pub const CUSTOM_SKILL_PATHS_FILE: &str = "custom-skill-paths.json";
/// Well-known skill source name for the nomifun skills market.
pub const SKILLS_MARKET_NAME: &str = "nomifun-skills";
/// Well-known skill source path for the nomifun skills market.
///
/// NOTE: This is a URL placeholder, not a filesystem path. When used in
/// `ExternalPathsManager`, it serves as an identifier for the skills market
/// source. Filesystem scanning functions like `detect_and_count_external_skills`
/// will silently skip it since the path does not exist on disk.
pub const SKILLS_MARKET_PATH: &str = "https://github.com/nomifun/nomifun-skills";
/// Common skill directory names to detect on the filesystem.
///
/// Each tuple is `(display_name, relative_path, source_slug)`:
/// - `display_name` — user-facing label (e.g. the tab title).
/// - `relative_path` — path under the user's home directory.
/// - `source_slug` — stable machine-readable identifier mirrored to
/// the renderer as `ExternalSkillSourceResponse.source`. Used as a
/// React key and `data-testid` suffix in `SkillsHubSettings.tsx`.
pub const COMMON_SKILL_DIRS: &[(&str, &str, &str)] = &[
("Claude Skills", ".claude/skills", "claude"),
("Gemini Skills", ".gemini/skills", "gemini"),
("Codex / Agent Skills", ".agents/skills", "agents"),
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_manifest_file_name() {
assert_eq!(EXTENSION_MANIFEST_FILE, "nomi-extension.json");
}
#[test]
fn test_reserved_prefixes_contains_expected() {
assert!(RESERVED_NAME_PREFIXES.contains(&"nomi-"));
assert!(RESERVED_NAME_PREFIXES.contains(&"internal-"));
assert!(RESERVED_NAME_PREFIXES.contains(&"builtin-"));
assert!(RESERVED_NAME_PREFIXES.contains(&"system-"));
}
#[test]
fn test_preset_agent_types_non_empty() {
assert!(!PRESET_AGENT_TYPES.is_empty());
assert!(PRESET_AGENT_TYPES.contains(&"claude"));
}
#[test]
fn test_lifecycle_timeouts_ordering() {
// onInstall should have the longest timeout
const {
assert!(LIFECYCLE_ON_INSTALL_TIMEOUT_SECS >= LIFECYCLE_ON_ACTIVATE_TIMEOUT_SECS);
assert!(LIFECYCLE_ON_INSTALL_TIMEOUT_SECS >= LIFECYCLE_ON_DEACTIVATE_TIMEOUT_SECS);
assert!(LIFECYCLE_ON_UNINSTALL_TIMEOUT_SECS >= LIFECYCLE_ON_DEACTIVATE_TIMEOUT_SECS);
}
}
#[test]
fn test_reserved_route_prefixes() {
assert!(RESERVED_ROUTE_PREFIXES.contains(&"/api/"));
assert!(RESERVED_ROUTE_PREFIXES.contains(&"/auth/"));
assert!(RESERVED_ROUTE_PREFIXES.contains(&"/ws/"));
}
#[test]
fn test_debounce_values_positive() {
const {
assert!(DEBOUNCE_MS > 0);
assert!(STATE_PERSIST_DEBOUNCE_MS > 0);
assert!(ACTIVITY_SNAPSHOT_TTL_MS > 0);
}
}
#[test]
fn common_skill_dirs_include_codex_agent_skills_home() {
let codex = COMMON_SKILL_DIRS
.iter()
.find(|(_, _, slug)| *slug == "agents")
.expect("common Agent Skills source must exist");
assert_eq!(
*codex,
("Codex / Agent Skills", ".agents/skills", "agents"),
"Codex reads user skills from ~/.agents/skills, not the broader ~/.agents folder"
);
}
}
@@ -0,0 +1,675 @@
use std::collections::{HashMap, HashSet, VecDeque};
use serde::{Deserialize, Serialize};
use crate::types::LoadedExtension;
#[cfg(test)]
use crate::types::{ExtensionManifest, ExtensionSource, ExtensionState};
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/// A single dependency issue found during validation.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum DependencyIssue {
/// A required dependency is not installed.
Missing {
#[serde(rename = "ext")]
extension: String,
#[serde(rename = "dep")]
dependency: String,
required: String,
},
/// A dependency exists but its version does not satisfy the requirement.
VersionMismatch {
#[serde(rename = "ext")]
extension: String,
#[serde(rename = "dep")]
dependency: String,
required: String,
actual: String,
},
/// A cycle was detected in the dependency graph.
Circular { cycle: Vec<String> },
}
/// Outcome of dependency validation across a set of extensions.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DependencyValidationResult {
/// `true` when no issues were found.
pub valid: bool,
/// All detected issues (missing, version mismatch, circular).
pub issues: Vec<DependencyIssue>,
/// Topological load order — dependencies before dependents.
/// Cyclic extensions are appended at the end in alphabetical order.
pub load_order: Vec<String>,
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/// Validate all inter-extension dependencies: missing, version mismatch,
/// circular. Returns a [`DependencyValidationResult`] containing the issues
/// found and a topological load order.
pub fn validate_dependencies(extensions: &[LoadedExtension]) -> DependencyValidationResult {
let versions: HashMap<&str, &str> = extensions
.iter()
.map(|ext| (ext.manifest.name.as_str(), ext.manifest.version.as_str()))
.collect();
let mut issues = Vec::new();
// 1. Check missing and version mismatches.
for ext in extensions {
for (dep_name, dep_req) in &ext.manifest.dependencies {
match versions.get(dep_name.as_str()) {
None => {
issues.push(DependencyIssue::Missing {
extension: ext.manifest.name.clone(),
dependency: dep_name.clone(),
required: dep_req.clone(),
});
}
Some(actual_version) => {
if !version_matches(dep_req, actual_version) {
issues.push(DependencyIssue::VersionMismatch {
extension: ext.manifest.name.clone(),
dependency: dep_name.clone(),
required: dep_req.clone(),
actual: actual_version.to_string(),
});
}
}
}
}
}
// 2. Topological sort + cycle detection.
let (load_order, cycles) = compute_topological_order(extensions);
for cycle in cycles {
issues.push(DependencyIssue::Circular { cycle });
}
DependencyValidationResult {
valid: issues.is_empty(),
issues,
load_order,
}
}
// ---------------------------------------------------------------------------
// Version matching
// ---------------------------------------------------------------------------
/// Check whether `actual` satisfies `requirement`.
///
/// - Bare version (`"1.2.3"`) → **exact** match.
/// - Caret (`"^1.2.3"`) → `>=1.2.3, <2.0.0`.
/// - Tilde (`"~1.2.3"`) → `>=1.2.3, <1.3.0`.
fn version_matches(requirement: &str, actual: &str) -> bool {
let Ok(version) = semver::Version::parse(actual) else {
return false;
};
let req_str = if requirement.starts_with(|c: char| c.is_ascii_digit()) {
// Bare version → exact match via '=' operator.
format!("={requirement}")
} else {
requirement.to_string()
};
let Ok(req) = semver::VersionReq::parse(&req_str) else {
return false;
};
req.matches(&version)
}
// ---------------------------------------------------------------------------
// Topological sort internals
// ---------------------------------------------------------------------------
/// Kahn's algorithm with cycle detection.
///
/// Returns `(load_order, detected_cycles)`. Cyclic nodes are appended to
/// `load_order` in alphabetical order so that the caller can still attempt
/// loading them (API Spec requirement).
fn compute_topological_order(extensions: &[LoadedExtension]) -> (Vec<String>, Vec<Vec<String>>) {
let known: HashSet<&str> = extensions.iter().map(|e| e.manifest.name.as_str()).collect();
// adjacency: dependency → vec of dependents
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
let mut in_degree: HashMap<&str, usize> = HashMap::new();
for ext in extensions {
in_degree.entry(ext.manifest.name.as_str()).or_insert(0);
for dep_name in ext.manifest.dependencies.keys() {
if known.contains(dep_name.as_str()) {
adj.entry(dep_name.as_str())
.or_default()
.push(ext.manifest.name.as_str());
*in_degree.entry(ext.manifest.name.as_str()).or_insert(0) += 1;
}
}
}
// Seed the queue with zero-in-degree nodes (sorted for determinism).
let mut queue: VecDeque<&str> = {
let mut seeds: Vec<&str> = in_degree
.iter()
.filter(|(_, deg)| **deg == 0)
.map(|(&name, _)| name)
.collect();
seeds.sort_unstable();
seeds.into_iter().collect()
};
let mut order: Vec<String> = Vec::with_capacity(extensions.len());
while let Some(node) = queue.pop_front() {
order.push(node.to_string());
if let Some(dependents) = adj.get(node) {
let mut ready = Vec::new();
for &dep in dependents {
let deg = in_degree.get_mut(dep).expect("in_degree entry");
*deg -= 1;
if *deg == 0 {
ready.push(dep);
}
}
ready.sort_unstable();
for n in ready {
queue.push_back(n);
}
}
}
// Nodes not emitted are part of cycles.
let emitted: HashSet<&str> = order.iter().map(|s| s.as_str()).collect();
let remaining: HashSet<&str> = known.difference(&emitted).copied().collect();
let cycles = if remaining.is_empty() {
Vec::new()
} else {
find_cycles(extensions, &remaining)
};
// Append cyclic nodes alphabetically (best-effort load).
let mut remaining_sorted: Vec<String> = remaining.iter().map(|s| s.to_string()).collect();
remaining_sorted.sort_unstable();
order.extend(remaining_sorted);
(order, cycles)
}
/// Find distinct cycles in the subgraph induced by `involved` nodes.
fn find_cycles(extensions: &[LoadedExtension], involved: &HashSet<&str>) -> Vec<Vec<String>> {
// Build adjacency: node → its dependencies (restricted to involved set).
let mut adj: HashMap<&str, Vec<&str>> = HashMap::new();
for ext in extensions {
let name = ext.manifest.name.as_str();
if involved.contains(name) {
let mut deps: Vec<&str> = ext
.manifest
.dependencies
.keys()
.map(|s| s.as_str())
.filter(|s| involved.contains(s))
.collect();
deps.sort_unstable();
adj.insert(name, deps);
}
}
let mut visited: HashSet<&str> = HashSet::new();
let mut on_stack: HashSet<&str> = HashSet::new();
let mut path: Vec<&str> = Vec::new();
let mut cycles: Vec<Vec<String>> = Vec::new();
let mut sorted_nodes: Vec<&str> = involved.iter().copied().collect();
sorted_nodes.sort_unstable();
for node in sorted_nodes {
if !visited.contains(node) {
dfs_find_cycles(node, &adj, &mut visited, &mut on_stack, &mut path, &mut cycles);
}
}
cycles
}
fn dfs_find_cycles<'a>(
node: &'a str,
adj: &HashMap<&'a str, Vec<&'a str>>,
visited: &mut HashSet<&'a str>,
on_stack: &mut HashSet<&'a str>,
path: &mut Vec<&'a str>,
cycles: &mut Vec<Vec<String>>,
) {
visited.insert(node);
on_stack.insert(node);
path.push(node);
if let Some(neighbors) = adj.get(node) {
for &next in neighbors {
if !visited.contains(next) {
dfs_find_cycles(next, adj, visited, on_stack, path, cycles);
} else if on_stack.contains(next) {
// Extract cycle from path.
if let Some(start) = path.iter().position(|&n| n == next) {
let mut cycle: Vec<String> = path[start..].iter().map(|s| s.to_string()).collect();
cycle.push(next.to_string()); // close the cycle
cycles.push(cycle);
}
}
}
}
path.pop();
on_stack.remove(node);
}
// ---------------------------------------------------------------------------
// Test helper
// ---------------------------------------------------------------------------
/// Build a minimal [`LoadedExtension`] for testing purposes.
#[cfg(test)]
fn make_extension(name: &str, version: &str, deps: &[(&str, &str)]) -> LoadedExtension {
use std::collections::HashMap;
LoadedExtension {
manifest: ExtensionManifest {
name: name.to_string(),
version: version.to_string(),
display_name: None,
description: None,
author: None,
license: None,
homepage: None,
icon: None,
engine: None,
api_version: None,
dependencies: deps
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect::<HashMap<_, _>>(),
entry_point: None,
permissions: None,
contributes: None,
lifecycle: None,
i18n: None,
},
directory: format!("/extensions/{name}"),
source: ExtensionSource::Local,
state: ExtensionState {
name: name.to_string(),
version: version.to_string(),
enabled: true,
installed_at: None,
last_activated_at: None,
},
}
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
// -- version_matches ---------------------------------------------------
#[test]
fn exact_match_same_version() {
assert!(version_matches("1.2.3", "1.2.3"));
}
#[test]
fn exact_match_different_version() {
assert!(!version_matches("2.0.0", "1.5.0"));
}
#[test]
fn exact_match_rejects_higher_patch() {
assert!(!version_matches("1.2.3", "1.2.4"));
}
#[test]
fn caret_match_within_range() {
// ^1.2.3 allows >=1.2.3, <2.0.0
assert!(version_matches("^1.2.3", "1.9.0"));
}
#[test]
fn caret_match_at_lower_bound() {
assert!(version_matches("^1.2.3", "1.2.3"));
}
#[test]
fn caret_match_rejects_next_major() {
assert!(!version_matches("^1.2.3", "2.0.0"));
}
#[test]
fn caret_match_rejects_below_lower() {
assert!(!version_matches("^1.2.3", "1.2.2"));
}
#[test]
fn tilde_match_within_range() {
// ~1.2.3 allows >=1.2.3, <1.3.0
assert!(version_matches("~1.2.3", "1.2.9"));
}
#[test]
fn tilde_match_at_lower_bound() {
assert!(version_matches("~1.2.3", "1.2.3"));
}
#[test]
fn tilde_match_rejects_next_minor() {
assert!(!version_matches("~1.2.3", "1.3.0"));
}
#[test]
fn tilde_match_rejects_below_lower() {
assert!(!version_matches("~1.2.3", "1.2.0"));
}
#[test]
fn invalid_requirement_returns_false() {
assert!(!version_matches("not-a-version", "1.0.0"));
}
#[test]
fn invalid_actual_returns_false() {
assert!(!version_matches("^1.0.0", "not-semver"));
}
// -- topological_sort --------------------------------------------------
#[test]
fn sort_no_deps() {
let exts = vec![
make_extension("alpha", "1.0.0", &[]),
make_extension("beta", "1.0.0", &[]),
make_extension("gamma", "1.0.0", &[]),
];
let order = compute_topological_order(&exts).0;
assert_eq!(order.len(), 3);
// Alphabetical when no constraints.
assert_eq!(order, vec!["alpha", "beta", "gamma"]);
}
#[test]
fn sort_linear_chain() {
// gamma → beta → alpha
let exts = vec![
make_extension("alpha", "1.0.0", &[]),
make_extension("beta", "1.0.0", &[("alpha", "^1.0.0")]),
make_extension("gamma", "1.0.0", &[("beta", "^1.0.0")]),
];
let order = compute_topological_order(&exts).0;
assert_eq!(order, vec!["alpha", "beta", "gamma"]);
}
#[test]
fn sort_diamond() {
// d → b, d → c, b → a, c → a
let exts = vec![
make_extension("a", "1.0.0", &[]),
make_extension("b", "1.0.0", &[("a", "^1.0.0")]),
make_extension("c", "1.0.0", &[("a", "^1.0.0")]),
make_extension("d", "1.0.0", &[("b", "^1.0.0"), ("c", "^1.0.0")]),
];
let order = compute_topological_order(&exts).0;
// a must come before b and c; b and c before d.
let pos = |name: &str| order.iter().position(|n| n == name).unwrap();
assert!(pos("a") < pos("b"));
assert!(pos("a") < pos("c"));
assert!(pos("b") < pos("d"));
assert!(pos("c") < pos("d"));
}
#[test]
fn sort_with_cycle_still_includes_all() {
// a → b → c → a (cycle)
let exts = vec![
make_extension("a", "1.0.0", &[("c", "^1.0.0")]),
make_extension("b", "1.0.0", &[("a", "^1.0.0")]),
make_extension("c", "1.0.0", &[("b", "^1.0.0")]),
];
let order = compute_topological_order(&exts).0;
assert_eq!(order.len(), 3);
// All three should be present.
assert!(order.contains(&"a".to_string()));
assert!(order.contains(&"b".to_string()));
assert!(order.contains(&"c".to_string()));
}
#[test]
fn sort_ignores_unknown_deps() {
// ext-a depends on ext-missing (not in list).
let exts = vec![make_extension("ext-a", "1.0.0", &[("ext-missing", "^1.0.0")])];
let order = compute_topological_order(&exts).0;
assert_eq!(order, vec!["ext-a"]);
}
// -- validate_dependencies ---------------------------------------------
#[test]
fn valid_satisfied_deps() {
let exts = vec![
make_extension("ext-b", "1.2.0", &[]),
make_extension("ext-a", "1.0.0", &[("ext-b", "^1.0.0")]),
];
let result = validate_dependencies(&exts);
assert!(result.valid);
assert!(result.issues.is_empty());
assert_eq!(result.load_order, vec!["ext-b", "ext-a"]);
}
#[test]
fn detect_missing_dependency() {
let exts = vec![make_extension("ext-a", "1.0.0", &[("ext-missing", "^1.0.0")])];
let result = validate_dependencies(&exts);
assert!(!result.valid);
assert_eq!(result.issues.len(), 1);
assert!(matches!(
&result.issues[0],
DependencyIssue::Missing {
extension,
dependency,
..
} if extension == "ext-a" && dependency == "ext-missing"
));
}
#[test]
fn detect_version_mismatch_exact() {
let exts = vec![
make_extension("ext-b", "1.5.0", &[]),
make_extension("ext-a", "1.0.0", &[("ext-b", "2.0.0")]),
];
let result = validate_dependencies(&exts);
assert!(!result.valid);
assert!(result.issues.iter().any(|i| matches!(
i,
DependencyIssue::VersionMismatch {
required,
actual,
..
} if required == "2.0.0" && actual == "1.5.0"
)));
}
#[test]
fn detect_circular_dependency() {
let exts = vec![
make_extension("ext-a", "1.0.0", &[("ext-c", "^1.0.0")]),
make_extension("ext-b", "1.0.0", &[("ext-a", "^1.0.0")]),
make_extension("ext-c", "1.0.0", &[("ext-b", "^1.0.0")]),
];
let result = validate_dependencies(&exts);
assert!(!result.valid);
let circular = result
.issues
.iter()
.filter(|i| matches!(i, DependencyIssue::Circular { .. }))
.count();
assert!(circular >= 1);
// All nodes still in load_order.
assert_eq!(result.load_order.len(), 3);
}
#[test]
fn circular_cycle_path_closes() {
let exts = vec![
make_extension("a", "1.0.0", &[("c", "^1.0.0")]),
make_extension("b", "1.0.0", &[("a", "^1.0.0")]),
make_extension("c", "1.0.0", &[("b", "^1.0.0")]),
];
let result = validate_dependencies(&exts);
let cycles: Vec<&Vec<String>> = result
.issues
.iter()
.filter_map(|i| match i {
DependencyIssue::Circular { cycle } => Some(cycle),
_ => None,
})
.collect();
assert!(!cycles.is_empty());
// Cycle path must close (first == last).
for cycle in &cycles {
assert!(cycle.len() >= 3);
assert_eq!(cycle.first(), cycle.last());
}
}
#[test]
fn no_deps_all_valid() {
let exts = vec![make_extension("x", "1.0.0", &[]), make_extension("y", "2.0.0", &[])];
let result = validate_dependencies(&exts);
assert!(result.valid);
assert!(result.issues.is_empty());
assert_eq!(result.load_order.len(), 2);
}
#[test]
fn mixed_issues() {
// ext-a → ext-missing (missing), ext-a → ext-b bad version, ext-c → ext-d → ext-c (cycle)
let exts = vec![
make_extension("ext-a", "1.0.0", &[("ext-missing", "^1.0.0"), ("ext-b", "^2.0.0")]),
make_extension("ext-b", "1.0.0", &[]),
make_extension("ext-c", "1.0.0", &[("ext-d", "^1.0.0")]),
make_extension("ext-d", "1.0.0", &[("ext-c", "^1.0.0")]),
];
let result = validate_dependencies(&exts);
assert!(!result.valid);
let missing_count = result
.issues
.iter()
.filter(|i| matches!(i, DependencyIssue::Missing { .. }))
.count();
let mismatch_count = result
.issues
.iter()
.filter(|i| matches!(i, DependencyIssue::VersionMismatch { .. }))
.count();
let circular_count = result
.issues
.iter()
.filter(|i| matches!(i, DependencyIssue::Circular { .. }))
.count();
assert_eq!(missing_count, 1);
assert_eq!(mismatch_count, 1);
assert!(circular_count >= 1);
// All 4 extensions still in load order.
assert_eq!(result.load_order.len(), 4);
}
#[test]
fn empty_extensions_list() {
let result = validate_dependencies(&[]);
assert!(result.valid);
assert!(result.issues.is_empty());
assert!(result.load_order.is_empty());
}
#[test]
fn caret_match_success_via_validate() {
let exts = vec![
make_extension("base", "1.9.0", &[]),
make_extension("consumer", "1.0.0", &[("base", "^1.2.3")]),
];
let result = validate_dependencies(&exts);
assert!(result.valid);
}
#[test]
fn caret_match_failure_via_validate() {
let exts = vec![
make_extension("base", "2.0.0", &[]),
make_extension("consumer", "1.0.0", &[("base", "^1.2.3")]),
];
let result = validate_dependencies(&exts);
assert!(!result.valid);
assert!(
result
.issues
.iter()
.any(|i| matches!(i, DependencyIssue::VersionMismatch { .. }))
);
}
#[test]
fn tilde_match_success_via_validate() {
let exts = vec![
make_extension("base", "1.2.9", &[]),
make_extension("consumer", "1.0.0", &[("base", "~1.2.3")]),
];
let result = validate_dependencies(&exts);
assert!(result.valid);
}
#[test]
fn tilde_match_failure_via_validate() {
let exts = vec![
make_extension("base", "1.3.0", &[]),
make_extension("consumer", "1.0.0", &[("base", "~1.2.3")]),
];
let result = validate_dependencies(&exts);
assert!(!result.valid);
assert!(
result
.issues
.iter()
.any(|i| matches!(i, DependencyIssue::VersionMismatch { .. }))
);
}
#[test]
fn partial_cycle_does_not_block_acyclic_nodes() {
// a and b form a cycle; c has no deps.
let exts = vec![
make_extension("a", "1.0.0", &[("b", "^1.0.0")]),
make_extension("b", "1.0.0", &[("a", "^1.0.0")]),
make_extension("c", "1.0.0", &[]),
];
let result = validate_dependencies(&exts);
// c should appear before the cyclic nodes in load_order.
let pos_c = result.load_order.iter().position(|n| n == "c").unwrap();
let pos_a = result.load_order.iter().position(|n| n == "a").unwrap();
let pos_b = result.load_order.iter().position(|n| n == "b").unwrap();
assert!(pos_c < pos_a);
assert!(pos_c < pos_b);
}
}
@@ -0,0 +1,202 @@
use nomifun_common::AppError;
/// Extension system domain errors.
#[derive(Debug, thiserror::Error)]
pub enum ExtensionError {
#[error("Manifest validation failed: {0}")]
ManifestValidation(String),
#[error("Extension name '{name}' uses reserved prefix '{prefix}'")]
ReservedNamePrefix { name: String, prefix: String },
#[error("Invalid version '{version}': {reason}")]
InvalidVersion { version: String, reason: String },
#[error("Undefined environment variable: {0}")]
UndefinedEnvVariable(String),
#[error("File reference not found: {0}")]
FileReferenceNotFound(String),
#[error("Path traversal detected: {0}")]
PathTraversal(String),
#[error("Engine incompatible: extension '{name}' requires nomifun {required}, got {actual}")]
EngineIncompatible {
name: String,
required: String,
actual: String,
},
#[error("API version incompatible: extension '{name}' requires API {required}, supported {supported}")]
ApiVersionIncompatible {
name: String,
required: String,
supported: String,
},
#[error("WebUI route '{route}' must be under '/{extension_name}/' namespace")]
InvalidWebuiRouteNamespace { extension_name: String, route: String },
#[error("WebUI route '{route}' uses reserved prefix '{prefix}'")]
ReservedWebuiRoute { route: String, prefix: String },
#[error("Theme CSS file not found: {0}")]
ThemeCssNotFound(String),
#[error("Contribution resolution failed for '{extension_name}': {reason}")]
ResolutionFailed { extension_name: String, reason: String },
#[error("Lifecycle hook '{hook}' timed out after {timeout_secs}s for extension '{extension_name}'")]
HookTimeout {
extension_name: String,
hook: String,
timeout_secs: u64,
},
#[error("Lifecycle hook '{hook}' failed for extension '{extension_name}': {reason}")]
HookFailed {
extension_name: String,
hook: String,
reason: String,
},
#[error("Lifecycle hook script not found: {0}")]
HookNotFound(String),
#[error("Extension not found: {0}")]
NotFound(String),
#[error("State persistence failed: {0}")]
StatePersistence(String),
#[error("Cannot delete built-in skill: {0}")]
BuiltinSkillDeletion(String),
#[error("Skill not found: {0}")]
SkillNotFound(String),
#[error("Invalid skill path: {0}")]
InvalidSkillPath(String),
#[error("{0}")]
Io(#[from] std::io::Error),
#[error("{0}")]
JsonParse(#[from] serde_json::Error),
}
impl From<ExtensionError> for AppError {
fn from(err: ExtensionError) -> Self {
match err {
ExtensionError::ManifestValidation(msg) => AppError::BadRequest(msg),
ExtensionError::ReservedNamePrefix { .. } => AppError::BadRequest(err.to_string()),
ExtensionError::InvalidVersion { .. } => AppError::BadRequest(err.to_string()),
ExtensionError::UndefinedEnvVariable(var) => {
AppError::BadRequest(format!("Undefined environment variable: {var}"))
}
ExtensionError::FileReferenceNotFound(path) => {
AppError::NotFound(format!("File reference not found: {path}"))
}
ExtensionError::PathTraversal(path) => AppError::BadRequest(format!("Path traversal detected: {path}")),
ExtensionError::EngineIncompatible { .. } => AppError::BadRequest(err.to_string()),
ExtensionError::ApiVersionIncompatible { .. } => AppError::BadRequest(err.to_string()),
ExtensionError::InvalidWebuiRouteNamespace { .. } => AppError::BadRequest(err.to_string()),
ExtensionError::ReservedWebuiRoute { .. } => AppError::BadRequest(err.to_string()),
ExtensionError::ThemeCssNotFound(path) => AppError::NotFound(format!("Theme CSS not found: {path}")),
ExtensionError::HookTimeout { .. } => AppError::Internal(err.to_string()),
ExtensionError::HookFailed { .. } => AppError::Internal(err.to_string()),
ExtensionError::HookNotFound(path) => AppError::NotFound(format!("Hook script not found: {path}")),
ExtensionError::ResolutionFailed { .. } => AppError::Internal(err.to_string()),
ExtensionError::NotFound(name) => AppError::NotFound(format!("Extension not found: {name}")),
ExtensionError::StatePersistence(msg) => AppError::Internal(msg),
ExtensionError::BuiltinSkillDeletion(name) => {
AppError::BadRequest(format!("Cannot delete built-in skill: {name}"))
}
ExtensionError::SkillNotFound(name) => AppError::NotFound(format!("Skill not found: {name}")),
ExtensionError::InvalidSkillPath(path) => AppError::BadRequest(format!("Invalid skill path: {path}")),
ExtensionError::Io(e) => AppError::Internal(e.to_string()),
ExtensionError::JsonParse(e) => AppError::BadRequest(e.to_string()),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_manifest_validation_error_display() {
let err = ExtensionError::ManifestValidation("name is required".into());
assert_eq!(err.to_string(), "Manifest validation failed: name is required");
}
#[test]
fn test_reserved_name_prefix_error_display() {
let err = ExtensionError::ReservedNamePrefix {
name: "nomi-test".into(),
prefix: "nomi-".into(),
};
assert_eq!(
err.to_string(),
"Extension name 'nomi-test' uses reserved prefix 'nomi-'"
);
}
#[test]
fn test_invalid_version_error_display() {
let err = ExtensionError::InvalidVersion {
version: "not-semver".into(),
reason: "unexpected character".into(),
};
assert_eq!(err.to_string(), "Invalid version 'not-semver': unexpected character");
}
#[test]
fn test_undefined_env_variable_error_display() {
let err = ExtensionError::UndefinedEnvVariable("MY_SECRET".into());
assert_eq!(err.to_string(), "Undefined environment variable: MY_SECRET");
}
#[test]
fn test_file_reference_not_found_error_display() {
let err = ExtensionError::FileReferenceNotFound("prompts/system.md".into());
assert_eq!(err.to_string(), "File reference not found: prompts/system.md");
}
#[test]
fn test_path_traversal_error_display() {
let err = ExtensionError::PathTraversal("../../etc/passwd".into());
assert_eq!(err.to_string(), "Path traversal detected: ../../etc/passwd");
}
#[test]
fn test_into_app_error_path_traversal() {
let err = ExtensionError::PathTraversal("../secret".into());
let app_err: AppError = err.into();
assert!(matches!(app_err, AppError::BadRequest(_)));
}
#[test]
fn test_into_app_error_bad_request() {
let err = ExtensionError::ManifestValidation("test".into());
let app_err: AppError = err.into();
assert!(matches!(app_err, AppError::BadRequest(_)));
}
#[test]
fn test_into_app_error_not_found() {
let err = ExtensionError::FileReferenceNotFound("missing.md".into());
let app_err: AppError = err.into();
assert!(matches!(app_err, AppError::NotFound(_)));
}
#[test]
fn test_io_error_conversion() {
let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file missing");
let err = ExtensionError::from(io_err);
assert!(matches!(err, ExtensionError::Io(_)));
let app_err: AppError = err.into();
assert!(matches!(app_err, AppError::Internal(_)));
}
}
@@ -0,0 +1,295 @@
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tokio::sync::RwLock;
use tracing::{debug, warn};
use crate::constants::{CUSTOM_SKILL_PATHS_FILE, SKILLS_MARKET_NAME, SKILLS_MARKET_PATH};
use crate::error::ExtensionError;
use crate::skill_service::NamedPath;
/// Persistent storage for custom external skill paths.
///
/// Data is stored in `~/.nomifun/custom-skill-paths.json`.
pub struct ExternalPathsManager {
file_path: PathBuf,
paths: RwLock<Vec<PersistedNamedPath>>,
}
/// Serializable named path entry.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
struct PersistedNamedPath {
name: String,
path: String,
}
impl ExternalPathsManager {
/// Create a new manager that persists to the given data directory.
///
/// Loads existing paths from disk if the file exists.
pub async fn new(data_dir: &Path) -> Self {
let file_path = data_dir.join(CUSTOM_SKILL_PATHS_FILE);
let paths = load_from_file(&file_path).await;
Self {
file_path,
paths: RwLock::new(paths),
}
}
/// Create a manager with an explicit persistence file path.
///
/// Useful for testing.
pub async fn with_file(file_path: PathBuf) -> Self {
let paths = load_from_file(&file_path).await;
Self {
file_path,
paths: RwLock::new(paths),
}
}
/// Get all custom external paths.
pub async fn get_custom_external_paths(&self) -> Vec<NamedPath> {
let paths = self.paths.read().await;
paths
.iter()
.map(|p| NamedPath {
name: p.name.clone(),
path: p.path.clone(),
})
.collect()
}
/// Add a custom external path.
///
/// If a path with the same value already exists, it is updated with the new name.
pub async fn add_custom_external_path(&self, name: &str, path: &str) -> Result<(), ExtensionError> {
let mut paths = self.paths.write().await;
// Update existing or add new
if let Some(existing) = paths.iter_mut().find(|p| p.path == path) {
existing.name = name.to_string();
} else {
paths.push(PersistedNamedPath {
name: name.to_string(),
path: path.to_string(),
});
}
save_to_file(&self.file_path, &paths).await?;
debug!(name = %name, path = %path, "added custom external path");
Ok(())
}
/// Remove a custom external path by its path value.
pub async fn remove_custom_external_path(&self, path: &str) -> Result<(), ExtensionError> {
let mut paths = self.paths.write().await;
let before_len = paths.len();
paths.retain(|p| p.path != path);
if paths.len() < before_len {
save_to_file(&self.file_path, &paths).await?;
debug!(path = %path, "removed custom external path");
}
Ok(())
}
/// Enable the nomifun skills market by adding it to external paths.
pub async fn enable_skills_market(&self) -> Result<(), ExtensionError> {
self.add_custom_external_path(SKILLS_MARKET_NAME, SKILLS_MARKET_PATH)
.await
}
/// Disable the nomifun skills market by removing it from external paths.
pub async fn disable_skills_market(&self) -> Result<(), ExtensionError> {
self.remove_custom_external_path(SKILLS_MARKET_PATH).await
}
}
/// Load paths from the persistence file.
async fn load_from_file(file_path: &Path) -> Vec<PersistedNamedPath> {
match tokio::fs::read_to_string(file_path).await {
Ok(content) => match serde_json::from_str::<Vec<PersistedNamedPath>>(&content) {
Ok(paths) => paths,
Err(e) => {
warn!(
path = %file_path.display(),
error = %e,
"failed to parse custom skill paths file, starting fresh"
);
Vec::new()
}
},
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => {
warn!(
path = %file_path.display(),
error = %e,
"failed to read custom skill paths file, starting fresh"
);
Vec::new()
}
}
}
/// Save paths to the persistence file.
async fn save_to_file(file_path: &Path, paths: &[PersistedNamedPath]) -> Result<(), ExtensionError> {
// Ensure parent directory exists
if let Some(parent) = file_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let json = serde_json::to_string_pretty(paths)?;
tokio::fs::write(file_path, json).await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
// -----------------------------------------------------------------------
// Basic CRUD
// -----------------------------------------------------------------------
#[tokio::test]
async fn new_manager_empty_when_no_file() {
let tmp = TempDir::new().unwrap();
let mgr = ExternalPathsManager::new(tmp.path()).await;
let paths = mgr.get_custom_external_paths().await;
assert!(paths.is_empty());
}
#[tokio::test]
async fn add_and_get_paths() {
let tmp = TempDir::new().unwrap();
let mgr = ExternalPathsManager::new(tmp.path()).await;
mgr.add_custom_external_path("My Skills", "/home/user/skills")
.await
.unwrap();
mgr.add_custom_external_path("Work Skills", "/work/skills")
.await
.unwrap();
let paths = mgr.get_custom_external_paths().await;
assert_eq!(paths.len(), 2);
assert_eq!(paths[0].name, "My Skills");
assert_eq!(paths[0].path, "/home/user/skills");
assert_eq!(paths[1].name, "Work Skills");
assert_eq!(paths[1].path, "/work/skills");
}
#[tokio::test]
async fn add_duplicate_path_updates_name() {
let tmp = TempDir::new().unwrap();
let mgr = ExternalPathsManager::new(tmp.path()).await;
mgr.add_custom_external_path("Original", "/my/path").await.unwrap();
mgr.add_custom_external_path("Updated", "/my/path").await.unwrap();
let paths = mgr.get_custom_external_paths().await;
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].name, "Updated");
}
#[tokio::test]
async fn remove_existing_path() {
let tmp = TempDir::new().unwrap();
let mgr = ExternalPathsManager::new(tmp.path()).await;
mgr.add_custom_external_path("Skills", "/path/a").await.unwrap();
mgr.add_custom_external_path("More", "/path/b").await.unwrap();
mgr.remove_custom_external_path("/path/a").await.unwrap();
let paths = mgr.get_custom_external_paths().await;
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].path, "/path/b");
}
#[tokio::test]
async fn remove_nonexistent_path_is_noop() {
let tmp = TempDir::new().unwrap();
let mgr = ExternalPathsManager::new(tmp.path()).await;
mgr.remove_custom_external_path("/nonexistent").await.unwrap();
let paths = mgr.get_custom_external_paths().await;
assert!(paths.is_empty());
}
// -----------------------------------------------------------------------
// Persistence
// -----------------------------------------------------------------------
#[tokio::test]
async fn persists_and_reloads() {
let tmp = TempDir::new().unwrap();
// First session: add paths
{
let mgr = ExternalPathsManager::new(tmp.path()).await;
mgr.add_custom_external_path("A", "/path/a").await.unwrap();
mgr.add_custom_external_path("B", "/path/b").await.unwrap();
}
// Second session: paths should still be there
{
let mgr = ExternalPathsManager::new(tmp.path()).await;
let paths = mgr.get_custom_external_paths().await;
assert_eq!(paths.len(), 2);
assert_eq!(paths[0].name, "A");
assert_eq!(paths[1].name, "B");
}
}
#[tokio::test]
async fn handles_corrupted_file() {
let tmp = TempDir::new().unwrap();
let file_path = tmp.path().join(CUSTOM_SKILL_PATHS_FILE);
std::fs::write(&file_path, "not valid json").unwrap();
let mgr = ExternalPathsManager::new(tmp.path()).await;
let paths = mgr.get_custom_external_paths().await;
assert!(paths.is_empty());
}
// -----------------------------------------------------------------------
// Skills market
// -----------------------------------------------------------------------
#[tokio::test]
async fn enable_and_disable_skills_market() {
let tmp = TempDir::new().unwrap();
let mgr = ExternalPathsManager::new(tmp.path()).await;
mgr.enable_skills_market().await.unwrap();
let paths = mgr.get_custom_external_paths().await;
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].name, SKILLS_MARKET_NAME);
assert_eq!(paths[0].path, SKILLS_MARKET_PATH);
mgr.disable_skills_market().await.unwrap();
let paths = mgr.get_custom_external_paths().await;
assert!(paths.is_empty());
}
#[tokio::test]
async fn enable_market_idempotent() {
let tmp = TempDir::new().unwrap();
let mgr = ExternalPathsManager::new(tmp.path()).await;
mgr.enable_skills_market().await.unwrap();
mgr.enable_skills_market().await.unwrap();
let paths = mgr.get_custom_external_paths().await;
assert_eq!(paths.len(), 1);
}
}
@@ -0,0 +1,385 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use tracing::{debug, warn};
use crate::constants::HUB_SUPPORTED_SCHEMA_VERSION;
use crate::error::ExtensionError;
use crate::registry::ExtensionRegistry;
use crate::types::{HubExtensionStatus, HubExtensionWithStatus};
// ---------------------------------------------------------------------------
// Hub index on-disk format
// ---------------------------------------------------------------------------
/// Schema envelope for a Hub index file.
#[derive(Debug, Clone, Serialize, Deserialize)]
struct HubIndexFile {
/// Schema version — we only support [`HUB_SUPPORTED_SCHEMA_VERSION`].
#[serde(default = "default_schema_version")]
schema_version: u32,
/// Extension entries in the index.
#[serde(default)]
extensions: Vec<HubIndexEntry>,
}
fn default_schema_version() -> u32 {
1
}
/// A single entry in the Hub index file.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct HubIndexEntry {
pub name: String,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
/// Whether this extension is bundled with the app (no download needed).
#[serde(default)]
pub bundled: bool,
/// Optional download URL for remote extensions.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub download_url: Option<String>,
}
// ---------------------------------------------------------------------------
// HubIndexManager
// ---------------------------------------------------------------------------
/// Manages the Hub extension index — loads from local file, merges
/// install status from the live extension registry.
#[derive(Clone)]
pub struct HubIndexManager {
/// Directory that contains `index.json`.
index_dir: PathBuf,
/// Reference to the live extension registry for status resolution.
registry: ExtensionRegistry,
}
impl HubIndexManager {
/// Create a new index manager.
///
/// - `index_dir`: directory containing the Hub `index.json`.
/// - `registry`: live extension registry used to determine install status.
pub fn new(index_dir: PathBuf, registry: ExtensionRegistry) -> Self {
Self { index_dir, registry }
}
/// Load the Hub index and merge install status from the registry.
///
/// Returns a list of extensions with their current status.
pub async fn load_index(&self) -> Vec<HubExtensionWithStatus> {
let entries = self.load_index_entries();
self.merge_with_registry_status(entries).await
}
/// Look up a single extension by name from the index.
pub(crate) fn get_extension(&self, name: &str) -> Option<HubIndexEntry> {
let entries = self.load_index_entries();
entries.into_iter().find(|e| e.name == name)
}
/// Return the directory where extensions should be installed.
pub fn install_target_dir(&self) -> PathBuf {
self.index_dir.clone()
}
/// Return the index file path.
fn index_file_path(&self) -> PathBuf {
self.index_dir.join("index.json")
}
/// Load index entries from disk, falling back to an empty list.
fn load_index_entries(&self) -> Vec<HubIndexEntry> {
let path = self.index_file_path();
match load_index_from_file(&path) {
Ok(entries) => entries,
Err(e) => {
debug!(
path = %path.display(),
error = %e,
"hub index not found or invalid, returning empty list"
);
Vec::new()
}
}
}
/// Merge index entries with live registry status.
async fn merge_with_registry_status(&self, entries: Vec<HubIndexEntry>) -> Vec<HubExtensionWithStatus> {
let loaded = self.registry.get_loaded_extensions().await;
let installed: HashMap<String, String> = loaded.into_iter().map(|s| (s.name, s.version)).collect();
entries
.into_iter()
.map(|entry| {
let status = resolve_status(&entry, &installed);
HubExtensionWithStatus {
name: entry.name,
version: entry.version,
display_name: entry.display_name,
description: entry.description,
author: entry.author,
icon: entry.icon,
tags: entry.tags,
bundled: entry.bundled,
status,
}
})
.collect()
}
}
// ---------------------------------------------------------------------------
// Index file I/O
// ---------------------------------------------------------------------------
/// Read and parse the Hub index file, returning entries.
fn load_index_from_file(path: &Path) -> Result<Vec<HubIndexEntry>, ExtensionError> {
let bytes = std::fs::read(path)?;
let index: HubIndexFile = serde_json::from_slice(&bytes)?;
if index.schema_version != HUB_SUPPORTED_SCHEMA_VERSION {
warn!(
found = index.schema_version,
expected = HUB_SUPPORTED_SCHEMA_VERSION,
"hub index schema version mismatch — attempting best-effort parse"
);
}
Ok(index.extensions)
}
// ---------------------------------------------------------------------------
// Status resolution
// ---------------------------------------------------------------------------
/// Determine the runtime status of a Hub entry by checking whether
/// it is loaded in the registry.
fn resolve_status(entry: &HubIndexEntry, installed: &HashMap<String, String>) -> HubExtensionStatus {
if entry.bundled {
return HubExtensionStatus::Installed;
}
match installed.get(&entry.name) {
Some(installed_version) => {
if is_update_available(&entry.version, installed_version) {
HubExtensionStatus::UpdateAvailable
} else {
HubExtensionStatus::Installed
}
}
None => HubExtensionStatus::NotInstalled,
}
}
/// Check if the index version is newer than the installed version.
fn is_update_available(index_version: &str, installed_version: &str) -> bool {
let Ok(idx) = semver::Version::parse(index_version) else {
return false;
};
let Ok(inst) = semver::Version::parse(installed_version) else {
return false;
};
idx > inst
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_status_bundled_always_installed() {
let entry = HubIndexEntry {
name: "builtin-ext".into(),
version: "1.0.0".into(),
display_name: None,
description: None,
author: None,
icon: None,
tags: Vec::new(),
bundled: true,
download_url: None,
};
let installed = HashMap::new();
assert_eq!(resolve_status(&entry, &installed), HubExtensionStatus::Installed);
}
#[test]
fn resolve_status_not_installed() {
let entry = HubIndexEntry {
name: "new-ext".into(),
version: "1.0.0".into(),
display_name: None,
description: None,
author: None,
icon: None,
tags: Vec::new(),
bundled: false,
download_url: None,
};
let installed = HashMap::new();
assert_eq!(resolve_status(&entry, &installed), HubExtensionStatus::NotInstalled);
}
#[test]
fn resolve_status_installed_same_version() {
let entry = HubIndexEntry {
name: "my-ext".into(),
version: "1.0.0".into(),
display_name: None,
description: None,
author: None,
icon: None,
tags: Vec::new(),
bundled: false,
download_url: None,
};
let installed = HashMap::from([("my-ext".into(), "1.0.0".into())]);
assert_eq!(resolve_status(&entry, &installed), HubExtensionStatus::Installed);
}
#[test]
fn resolve_status_update_available() {
let entry = HubIndexEntry {
name: "my-ext".into(),
version: "2.0.0".into(),
display_name: None,
description: None,
author: None,
icon: None,
tags: Vec::new(),
bundled: false,
download_url: None,
};
let installed = HashMap::from([("my-ext".into(), "1.0.0".into())]);
assert_eq!(resolve_status(&entry, &installed), HubExtensionStatus::UpdateAvailable);
}
#[test]
fn resolve_status_installed_newer_than_index() {
let entry = HubIndexEntry {
name: "my-ext".into(),
version: "1.0.0".into(),
display_name: None,
description: None,
author: None,
icon: None,
tags: Vec::new(),
bundled: false,
download_url: None,
};
let installed = HashMap::from([("my-ext".into(), "2.0.0".into())]);
// Installed version is newer — still "installed", not "update_available".
assert_eq!(resolve_status(&entry, &installed), HubExtensionStatus::Installed);
}
#[test]
fn is_update_available_newer() {
assert!(is_update_available("2.0.0", "1.0.0"));
}
#[test]
fn is_update_available_same() {
assert!(!is_update_available("1.0.0", "1.0.0"));
}
#[test]
fn is_update_available_older() {
assert!(!is_update_available("1.0.0", "2.0.0"));
}
#[test]
fn is_update_available_invalid_version() {
assert!(!is_update_available("not-semver", "1.0.0"));
assert!(!is_update_available("1.0.0", "not-semver"));
}
#[test]
fn load_index_from_file_valid() {
let tmp = tempfile::TempDir::new().unwrap();
let index = HubIndexFile {
schema_version: 1,
extensions: vec![HubIndexEntry {
name: "test-ext".into(),
version: "1.0.0".into(),
display_name: Some("Test Extension".into()),
description: Some("A test extension".into()),
author: Some("Test Author".into()),
icon: None,
tags: vec!["tools".into()],
bundled: false,
download_url: Some("https://example.com/test-ext-1.0.0.tar.gz".into()),
}],
};
let path = tmp.path().join("index.json");
std::fs::write(&path, serde_json::to_vec_pretty(&index).unwrap()).unwrap();
let entries = load_index_from_file(&path).unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "test-ext");
assert_eq!(entries[0].version, "1.0.0");
assert!(!entries[0].bundled);
}
#[test]
fn load_index_from_file_not_found() {
let result = load_index_from_file(Path::new("/nonexistent/index.json"));
assert!(result.is_err());
}
#[test]
fn load_index_from_file_invalid_json() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("index.json");
std::fs::write(&path, b"not valid json").unwrap();
let result = load_index_from_file(&path);
assert!(result.is_err());
}
#[test]
fn load_index_from_file_empty_extensions() {
let tmp = tempfile::TempDir::new().unwrap();
let index = HubIndexFile {
schema_version: 1,
extensions: Vec::new(),
};
let path = tmp.path().join("index.json");
std::fs::write(&path, serde_json::to_vec(&index).unwrap()).unwrap();
let entries = load_index_from_file(&path).unwrap();
assert!(entries.is_empty());
}
#[test]
fn hub_index_entry_deserialization() {
let json = serde_json::json!({
"name": "my-ext",
"version": "2.0.0",
"display_name": "My Extension",
"tags": ["ai", "tools"],
"bundled": true
});
let entry: HubIndexEntry = serde_json::from_value(json).unwrap();
assert_eq!(entry.name, "my-ext");
assert_eq!(entry.version, "2.0.0");
assert_eq!(entry.display_name.as_deref(), Some("My Extension"));
assert_eq!(entry.tags, vec!["ai", "tools"]);
assert!(entry.bundled);
assert!(entry.download_url.is_none());
}
}
@@ -0,0 +1,466 @@
use std::path::Path;
use std::sync::Arc;
use nomifun_api_types::WebSocketMessage;
use nomifun_realtime::EventBroadcaster;
use serde_json::json;
use tracing::{debug, info, warn};
use crate::constants::EXTENSION_MANIFEST_FILE;
use crate::error::ExtensionError;
use crate::manifest::{parse_manifest, validate_manifest};
use crate::registry::ExtensionRegistry;
use crate::resolvers::resolve_extension_contributions;
use crate::types::{ExtensionSource, ExtensionState, LoadedExtension};
use super::index_manager::HubIndexManager;
// ---------------------------------------------------------------------------
// Result type
// ---------------------------------------------------------------------------
/// Outcome of a Hub install/update/uninstall operation.
#[derive(Debug, Clone)]
pub struct HubResult {
pub success: bool,
pub msg: Option<String>,
}
impl HubResult {
fn ok() -> Self {
Self {
success: true,
msg: None,
}
}
fn err(msg: impl Into<String>) -> Self {
Self {
success: false,
msg: Some(msg.into()),
}
}
}
/// Info about an available update.
#[derive(Debug, Clone)]
pub struct HubUpdateInfo {
pub name: String,
pub current_version: String,
pub latest_version: String,
}
// ---------------------------------------------------------------------------
// HubInstaller
// ---------------------------------------------------------------------------
/// Handles extension installation, update, uninstall, and verification.
///
/// For this phase, remote downloading is a stub — extensions must already
/// be present in the Hub directory or have a bundled flag. The installer
/// verifies the manifest and contributions, then triggers a hot reload.
#[derive(Clone)]
pub struct HubInstaller {
index_manager: HubIndexManager,
registry: ExtensionRegistry,
broadcaster: Arc<dyn EventBroadcaster>,
}
impl HubInstaller {
pub fn new(index_manager: HubIndexManager, registry: ExtensionRegistry) -> Self {
let broadcaster = registry.event_broadcaster();
Self {
index_manager,
registry,
broadcaster,
}
}
/// Install an extension from the Hub by name.
///
/// Flow: look up in index → verify the extension directory exists →
/// validate manifest → verify contributions → trigger hot reload.
pub async fn install(&self, name: &str) -> HubResult {
info!(name, "hub: installing extension");
self.broadcast_state_changed(name, "installing", None);
let entry = match self.index_manager.get_extension(name) {
Some(e) => e,
None => {
let error = format!("Extension '{name}' not found in hub index");
self.broadcast_state_changed(name, "failed", Some(error.clone()));
return HubResult::err(error);
}
};
let target_dir = self.index_manager.install_target_dir();
let ext_dir = target_dir.join(&entry.name);
// For now, the extension directory must already exist (no remote download).
// Future: download from entry.download_url and extract.
if !ext_dir.exists() {
let error = format!(
"Extension directory not found: {}. Remote download not yet implemented.",
ext_dir.display()
);
self.broadcast_state_changed(name, "failed", Some(error.clone()));
return HubResult::err(error);
}
if let Err(e) = self.verify_installation(&ext_dir) {
let error = format!("Installation verification failed: {e}");
self.broadcast_state_changed(name, "failed", Some(error.clone()));
return HubResult::err(error);
}
// Trigger hot reload to pick up the new extension.
self.registry.hot_reload().await;
self.broadcast_state_changed(name, "installed", None);
info!(name, "hub: extension installed successfully");
HubResult::ok()
}
/// Retry a previously failed installation.
pub async fn retry_install(&self, name: &str) -> HubResult {
debug!(name, "hub: retrying installation");
self.install(name).await
}
/// Update an installed extension to the latest version from the index.
///
/// For this phase, update is equivalent to re-verifying the existing
/// directory (which may have been updated externally) and hot-reloading.
pub async fn update(&self, name: &str) -> HubResult {
info!(name, "hub: updating extension");
self.broadcast_state_changed(name, "updating", None);
let entry = match self.index_manager.get_extension(name) {
Some(e) => e,
None => {
let error = format!("Extension '{name}' not found in hub index");
self.broadcast_state_changed(name, "failed", Some(error.clone()));
return HubResult::err(error);
}
};
let target_dir = self.index_manager.install_target_dir();
let ext_dir = target_dir.join(&entry.name);
if !ext_dir.exists() {
let error = format!("Extension not installed: {}", ext_dir.display());
self.broadcast_state_changed(name, "failed", Some(error.clone()));
return HubResult::err(error);
}
if let Err(e) = self.verify_installation(&ext_dir) {
let error = format!("Update verification failed: {e}");
self.broadcast_state_changed(name, "failed", Some(error.clone()));
return HubResult::err(error);
}
self.registry.hot_reload().await;
self.broadcast_state_changed(name, "installed", None);
info!(name, "hub: extension updated successfully");
HubResult::ok()
}
/// Uninstall an extension by removing its directory and hot-reloading.
pub async fn uninstall(&self, name: &str) -> HubResult {
if let Err(msg) = validate_hub_name(name) {
self.broadcast_state_changed(name, "failed", Some(msg.clone()));
return HubResult::err(msg);
}
info!(name, "hub: uninstalling extension");
let target_dir = self.index_manager.install_target_dir();
let ext_dir = target_dir.join(name);
if !ext_dir.exists() {
let error = format!("Extension '{name}' is not installed");
self.broadcast_state_changed(name, "failed", Some(error.clone()));
return HubResult::err(error);
}
if let Err(e) = std::fs::remove_dir_all(&ext_dir) {
warn!(
name,
error = %e,
"hub: failed to remove extension directory"
);
let error = format!("Failed to remove extension directory: {e}");
self.broadcast_state_changed(name, "failed", Some(error.clone()));
return HubResult::err(error);
}
self.registry.hot_reload().await;
self.broadcast_state_changed(name, "uninstalled", None);
info!(name, "hub: extension uninstalled successfully");
HubResult::ok()
}
/// Check for available updates across all installed extensions.
///
/// Compares installed versions against the Hub index.
pub async fn check_updates(&self) -> Vec<HubUpdateInfo> {
let index_list = self.index_manager.load_index().await;
let loaded = self.registry.get_loaded_extensions().await;
let mut updates = Vec::new();
for hub_ext in &index_list {
if hub_ext.bundled {
continue;
}
if let Some(installed) = loaded.iter().find(|l| l.name == hub_ext.name)
&& is_newer(&hub_ext.version, &installed.version)
{
updates.push(HubUpdateInfo {
name: hub_ext.name.clone(),
current_version: installed.version.clone(),
latest_version: hub_ext.version.clone(),
});
}
}
updates
}
/// Verify that an extension directory contains a valid manifest
/// and that its contributions can be resolved without errors.
pub fn verify_installation(&self, ext_dir: &Path) -> Result<(), ExtensionError> {
let manifest_path = ext_dir.join(EXTENSION_MANIFEST_FILE);
if !manifest_path.exists() {
return Err(ExtensionError::ManifestValidation(format!(
"Manifest not found: {}",
manifest_path.display()
)));
}
let bytes = std::fs::read(&manifest_path)?;
let manifest = parse_manifest(&bytes)?;
validate_manifest(&manifest)?;
// Build a temporary LoadedExtension to test contribution resolution.
let loaded = LoadedExtension {
manifest,
directory: ext_dir.to_str().unwrap_or_default().to_owned(),
source: ExtensionSource::Local,
state: ExtensionState {
name: "verification-check".into(),
version: "0.0.0".into(),
enabled: true,
installed_at: None,
last_activated_at: None,
},
};
// Resolve contributions — this validates CSS files exist for themes,
// route namespaces for webui, etc.
let _contributions = resolve_extension_contributions(&loaded);
debug!(
dir = %ext_dir.display(),
"hub: installation verification passed"
);
Ok(())
}
fn broadcast_state_changed(&self, name: &str, status: &str, error: Option<String>) {
self.broadcaster.broadcast(WebSocketMessage::new(
"hub.state-changed",
json!({
"name": name,
"status": status,
"error": error,
}),
));
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Validate an extension name to prevent path traversal attacks.
fn validate_hub_name(name: &str) -> Result<(), String> {
if name.is_empty() || name.contains('/') || name.contains('\\') || name.contains("..") {
return Err(format!("Invalid extension name: '{name}'"));
}
Ok(())
}
/// Check if `index_version` is newer than `installed_version`.
fn is_newer(index_version: &str, installed_version: &str) -> bool {
let Ok(idx) = semver::Version::parse(index_version) else {
return false;
};
let Ok(inst) = semver::Version::parse(installed_version) else {
return false;
};
idx > inst
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use nomifun_realtime::BroadcastEventBus;
#[test]
fn hub_result_ok() {
let r = HubResult::ok();
assert!(r.success);
assert!(r.msg.is_none());
}
#[test]
fn hub_result_err() {
let r = HubResult::err("something failed");
assert!(!r.success);
assert_eq!(r.msg.as_deref(), Some("something failed"));
}
#[test]
fn is_newer_true() {
assert!(is_newer("2.0.0", "1.0.0"));
assert!(is_newer("1.1.0", "1.0.0"));
assert!(is_newer("1.0.1", "1.0.0"));
}
#[test]
fn is_newer_false() {
assert!(!is_newer("1.0.0", "1.0.0"));
assert!(!is_newer("1.0.0", "2.0.0"));
}
#[test]
fn is_newer_invalid_versions() {
assert!(!is_newer("not-semver", "1.0.0"));
assert!(!is_newer("1.0.0", "not-semver"));
}
#[test]
fn verify_installation_no_manifest() {
let tmp = tempfile::TempDir::new().unwrap();
let registry = make_test_registry();
let index_mgr = HubIndexManager::new(tmp.path().to_path_buf(), registry.clone());
let installer = HubInstaller::new(index_mgr, registry);
let result = installer.verify_installation(tmp.path());
assert!(result.is_err());
}
#[test]
fn verify_installation_invalid_manifest() {
let tmp = tempfile::TempDir::new().unwrap();
std::fs::write(tmp.path().join(EXTENSION_MANIFEST_FILE), b"not valid json").unwrap();
let registry = make_test_registry();
let index_mgr = HubIndexManager::new(tmp.path().to_path_buf(), registry.clone());
let installer = HubInstaller::new(index_mgr, registry);
let result = installer.verify_installation(tmp.path());
assert!(result.is_err());
}
#[test]
fn verify_installation_valid_manifest() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = serde_json::json!({
"name": "test-ext",
"version": "1.0.0"
});
std::fs::write(
tmp.path().join(EXTENSION_MANIFEST_FILE),
serde_json::to_vec_pretty(&manifest).unwrap(),
)
.unwrap();
let registry = make_test_registry();
let index_mgr = HubIndexManager::new(tmp.path().to_path_buf(), registry.clone());
let installer = HubInstaller::new(index_mgr, registry);
let result = installer.verify_installation(tmp.path());
assert!(result.is_ok());
}
#[test]
fn verify_installation_reserved_name_fails() {
let tmp = tempfile::TempDir::new().unwrap();
let manifest = serde_json::json!({
"name": "nomi-internal-ext",
"version": "1.0.0"
});
std::fs::write(
tmp.path().join(EXTENSION_MANIFEST_FILE),
serde_json::to_vec_pretty(&manifest).unwrap(),
)
.unwrap();
let registry = make_test_registry();
let index_mgr = HubIndexManager::new(tmp.path().to_path_buf(), registry.clone());
let installer = HubInstaller::new(index_mgr, registry);
let result = installer.verify_installation(tmp.path());
assert!(result.is_err());
}
#[test]
fn validate_hub_name_rejects_traversal() {
assert!(validate_hub_name("../etc").is_err());
assert!(validate_hub_name("foo/../../bar").is_err());
assert!(validate_hub_name("foo\\bar").is_err());
assert!(validate_hub_name("").is_err());
assert!(validate_hub_name("..").is_err());
}
#[test]
fn validate_hub_name_accepts_valid() {
assert!(validate_hub_name("my-extension").is_ok());
assert!(validate_hub_name("ext_v2").is_ok());
assert!(validate_hub_name("a").is_ok());
}
fn make_test_registry() -> ExtensionRegistry {
use crate::state::ExtensionStateStore;
let tmp = tempfile::TempDir::new().unwrap();
let store = ExtensionStateStore::new(tmp.path().join("states.json"));
let bus = Arc::new(BroadcastEventBus::new(64));
// Leak the TempDir so it lives long enough for the test.
std::mem::forget(tmp);
ExtensionRegistry::new(store, bus, "1.0.0".into())
}
#[tokio::test]
async fn install_broadcasts_installing_then_failed_for_missing_index_entry() {
let tmp = tempfile::TempDir::new().unwrap();
let store = crate::state::ExtensionStateStore::new(tmp.path().join("states.json"));
let bus = Arc::new(BroadcastEventBus::new(64));
let registry = ExtensionRegistry::new(store, bus.clone(), "1.0.0".into());
let index_mgr = HubIndexManager::new(tmp.path().to_path_buf(), registry.clone());
let installer = HubInstaller::new(index_mgr, registry);
let mut rx = bus.subscribe();
let result = installer.install("missing-ext").await;
assert!(!result.success);
let first = rx.recv().await.unwrap();
assert_eq!(first.name, "hub.state-changed");
assert_eq!(first.data["name"], "missing-ext");
assert_eq!(first.data["status"], "installing");
let second = rx.recv().await.unwrap();
assert_eq!(second.name, "hub.state-changed");
assert_eq!(second.data["status"], "failed");
}
}
@@ -0,0 +1,5 @@
pub mod index_manager;
pub mod installer;
pub use index_manager::HubIndexManager;
pub use installer::HubInstaller;
@@ -0,0 +1,175 @@
use axum::Router;
use axum::extract::rejection::JsonRejection;
use axum::extract::{Json, State};
use axum::routing::{get, post};
use nomifun_api_types::{
ApiResponse, HubExtensionListItem, HubOperationResponse, HubUpdateInfo as ApiHubUpdateInfo, InstallExtensionRequest,
};
use nomifun_common::AppError;
use crate::hub::index_manager::HubIndexManager;
use crate::hub::installer::HubInstaller;
// ---------------------------------------------------------------------------
// Router state
// ---------------------------------------------------------------------------
/// Shared state for Hub route handlers.
#[derive(Clone)]
pub struct HubRouterState {
pub index_manager: HubIndexManager,
pub installer: HubInstaller,
}
// ---------------------------------------------------------------------------
// Router builder
// ---------------------------------------------------------------------------
/// Build the Hub router with all `/api/hub/*` routes.
///
/// All routes require authentication (applied by the caller).
pub fn hub_routes(state: HubRouterState) -> Router {
Router::new()
.route("/api/hub/extensions", get(get_hub_extensions))
.route("/api/hub/install", post(install_extension))
.route("/api/hub/retry-install", post(retry_install))
.route("/api/hub/check-updates", post(check_updates))
.route("/api/hub/update", post(update_extension))
.route("/api/hub/uninstall", post(uninstall_extension))
.with_state(state)
}
// ---------------------------------------------------------------------------
// Handlers
// ---------------------------------------------------------------------------
/// `GET /api/hub/extensions` — get Hub extension list with statuses.
async fn get_hub_extensions(
State(state): State<HubRouterState>,
) -> Result<Json<ApiResponse<Vec<HubExtensionListItem>>>, AppError> {
let entries = state.index_manager.load_index().await;
let items: Vec<HubExtensionListItem> = entries
.into_iter()
.map(|e| {
let status_str = serde_json::to_value(e.status)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_else(|| "notInstalled".to_string());
HubExtensionListItem {
name: e.name,
version: e.version,
display_name: e.display_name,
description: e.description,
author: e.author,
icon: e.icon,
tags: e.tags,
bundled: e.bundled,
status: status_str,
}
})
.collect();
Ok(Json(ApiResponse::ok(items)))
}
/// `POST /api/hub/install` — install an extension from the Hub.
async fn install_extension(
State(state): State<HubRouterState>,
body: Result<Json<InstallExtensionRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<HubOperationResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let result = state.installer.install(&req.name).await;
Ok(Json(ApiResponse::ok(HubOperationResponse {
success: result.success,
msg: result.msg,
})))
}
/// `POST /api/hub/retry-install` — retry a failed installation.
async fn retry_install(
State(state): State<HubRouterState>,
body: Result<Json<InstallExtensionRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<HubOperationResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let result = state.installer.retry_install(&req.name).await;
Ok(Json(ApiResponse::ok(HubOperationResponse {
success: result.success,
msg: result.msg,
})))
}
/// `POST /api/hub/check-updates` — check for available updates.
async fn check_updates(
State(state): State<HubRouterState>,
) -> Result<Json<ApiResponse<Vec<ApiHubUpdateInfo>>>, AppError> {
let updates = state.installer.check_updates().await;
let resp: Vec<ApiHubUpdateInfo> = updates
.into_iter()
.map(|u| ApiHubUpdateInfo {
name: u.name,
current_version: u.current_version,
latest_version: u.latest_version,
})
.collect();
Ok(Json(ApiResponse::ok(resp)))
}
/// `POST /api/hub/update` — update an installed extension.
async fn update_extension(
State(state): State<HubRouterState>,
body: Result<Json<InstallExtensionRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<HubOperationResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let result = state.installer.update(&req.name).await;
Ok(Json(ApiResponse::ok(HubOperationResponse {
success: result.success,
msg: result.msg,
})))
}
/// `POST /api/hub/uninstall` — uninstall an extension.
async fn uninstall_extension(
State(state): State<HubRouterState>,
body: Result<Json<InstallExtensionRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<HubOperationResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let result = state.installer.uninstall(&req.name).await;
Ok(Json(ApiResponse::ok(HubOperationResponse {
success: result.success,
msg: result.msg,
})))
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::registry::ExtensionRegistry;
use crate::state::ExtensionStateStore;
use nomifun_realtime::BroadcastEventBus;
use std::sync::Arc;
fn make_state() -> HubRouterState {
let tmp = tempfile::TempDir::new().unwrap();
let store = ExtensionStateStore::new(tmp.path().join("states.json"));
let bus = Arc::new(BroadcastEventBus::new(64));
let hub_dir = tmp.path().to_path_buf();
std::mem::forget(tmp);
let registry = ExtensionRegistry::new(store, bus, "1.0.0".into());
let index_manager = HubIndexManager::new(hub_dir, registry.clone());
let installer = HubInstaller::new(index_manager.clone(), registry);
HubRouterState {
index_manager,
installer,
}
}
#[test]
fn hub_routes_builds_router() {
let state = make_state();
let _router = hub_routes(state);
}
}
@@ -0,0 +1,61 @@
//! Extension registry: manifest parsing, hub installer, skill scanning, and lifecycle hooks.
mod asset_paths;
pub mod classifier;
pub mod constants;
pub mod dependency;
pub mod error;
pub mod external_paths;
pub mod hub;
pub mod hub_routes;
pub mod lifecycle;
pub mod loader;
pub mod manifest;
pub mod permission;
pub mod registry;
mod registry_helpers;
pub mod resolvers;
pub mod routes;
pub mod skill_routes;
pub mod skill_service;
pub mod startup_materialize;
pub mod state;
pub mod template;
pub mod types;
pub mod watcher;
pub use classifier::{AssistantClassifier, AssistantRuleDispatcher};
pub use constants::*;
pub use dependency::{DependencyIssue, DependencyValidationResult, validate_dependencies};
pub use error::ExtensionError;
pub use lifecycle::{HookKind, execute_hook, needs_install_hook, resolve_hook_path};
pub use loader::{
ScanPath, filter_by_engine_compatibility, load_all, resolve_install_target_dir_for_data_dir, resolve_scan_paths,
resolve_scan_paths_for_data_dir,
};
pub use manifest::{parse_manifest, validate_manifest};
pub use permission::{build_permission_summary, calculate_risk_level};
pub use registry::{ExtensionRegistry, ExtensionSummary};
pub use resolvers::{resolve_all_contributions, resolve_extension_contributions, resolve_i18n_for_all};
pub use startup_materialize::materialize_if_needed;
pub use state::{ExtensionStateStore, load_states_from_file, resolve_state_file_path, save_states_to_file};
pub use template::{resolve_env_map, resolve_env_templates, resolve_file_reference};
pub use types::*;
pub use watcher::ExtensionWatcher;
pub use external_paths::ExternalPathsManager;
pub use hub::{HubIndexManager, HubInstaller};
pub use hub_routes::{HubRouterState, hub_routes};
pub use routes::{ExtensionRouterState, extension_routes};
pub use skill_routes::{SkillRouterState, skill_routes};
pub use skill_service::{
BUILTIN_SKILLS_ENV_VAR, BuiltinAutoSkillItem, ExternalSkillSource, NamedPath, ResolvedAgentSkill, ScannedSkill,
SkillListItem, SkillPaths, SkillSource, builtin_skills_corpus, delete_skill, detect_and_count_external_skills,
detect_common_skill_paths, export_skill_with_symlink, get_skill_paths, import_skill, import_skill_with_symlink,
link_workspace_skills, list_available_skills, list_builtin_auto_skills, materialize_skills_for_agent,
read_builtin_rule, read_builtin_skill, read_skill_info, resolve_skill_paths, scan_for_skills,
};
pub use skill_service::{
delete_assistant_rule, delete_assistant_skill, read_assistant_rule, read_assistant_skill, write_assistant_rule,
write_assistant_skill,
};
@@ -0,0 +1,521 @@
use std::ffi::OsString;
use std::path::Path;
use nomifun_runtime::Builder as CmdBuilder;
use tracing::{info, warn};
use crate::constants::{
LIFECYCLE_ON_ACTIVATE_TIMEOUT_SECS, LIFECYCLE_ON_DEACTIVATE_TIMEOUT_SECS, LIFECYCLE_ON_INSTALL_TIMEOUT_SECS,
LIFECYCLE_ON_UNINSTALL_TIMEOUT_SECS,
};
use crate::error::ExtensionError;
use crate::types::LifecycleHooks;
/// Which lifecycle hook to execute.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookKind {
OnInstall,
OnUninstall,
OnActivate,
OnDeactivate,
}
impl HookKind {
/// Default timeout in seconds for this hook kind.
pub fn timeout_secs(self) -> u64 {
match self {
Self::OnInstall => LIFECYCLE_ON_INSTALL_TIMEOUT_SECS,
Self::OnUninstall => LIFECYCLE_ON_UNINSTALL_TIMEOUT_SECS,
Self::OnActivate => LIFECYCLE_ON_ACTIVATE_TIMEOUT_SECS,
Self::OnDeactivate => LIFECYCLE_ON_DEACTIVATE_TIMEOUT_SECS,
}
}
/// Human-readable label for logging and error messages.
pub fn label(self) -> &'static str {
match self {
Self::OnInstall => "onInstall",
Self::OnUninstall => "onUninstall",
Self::OnActivate => "onActivate",
Self::OnDeactivate => "onDeactivate",
}
}
}
/// Resolve the hook script path from the manifest for a given hook kind.
pub fn resolve_hook_path(hooks: &LifecycleHooks, kind: HookKind) -> Option<&str> {
let value = match kind {
HookKind::OnInstall => hooks.on_install.as_deref(),
HookKind::OnUninstall => hooks.on_uninstall.as_deref(),
HookKind::OnActivate => hooks.on_activate.as_deref(),
HookKind::OnDeactivate => hooks.on_deactivate.as_deref(),
};
value.filter(|s| !s.is_empty())
}
/// Map a hook script to the interpreter (program) and argument list used to
/// run it, dispatching on the script's file extension. This is the single
/// source of truth for how lifecycle hook scripts are executed across
/// platforms.
///
/// `CmdBuilder` resolves a bare program name (no path separators) through
/// `PATH` — including the Windows `.cmd`/`.ps1`/`.bat` shim fallbacks — so we
/// intentionally pass the interpreter as a bare name (`sh`, `cmd`,
/// `powershell` / `pwsh`) and the script path as an argument, rather than
/// spawning the script file directly as the program. Spawning a `.sh`/shebang
/// script directly is fatal on Windows (`CreateProcess` → `ERROR_BAD_EXE_FORMAT`).
///
/// Extension dispatch:
/// - `.sh` → `sh <script>` (works on unix; on Windows via git-bash/MSYS `sh` on PATH).
/// - `.ps1` → `pwsh -NoProfile -ExecutionPolicy Bypass -File <script>` on unix,
/// `powershell -NoProfile -ExecutionPolicy Bypass -File <script>` on Windows.
/// - `.cmd` / `.bat` → `cmd /C <script>` on Windows; on unix run directly (shebang) as a fallback.
/// - none / other → unix: run directly (rely on shebang); Windows: `cmd /C <script>`.
///
/// `<script>` is always passed as the absolute path so the interpreter resolves
/// it regardless of the child's working directory.
fn hook_command(script: &Path) -> (OsString, Vec<OsString>) {
let ext = script
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase);
let script_arg: OsString = script.as_os_str().to_owned();
match ext.as_deref() {
Some("sh") => (OsString::from("sh"), vec![script_arg]),
Some("ps1") => {
let program = if cfg!(windows) { "powershell" } else { "pwsh" };
(
OsString::from(program),
vec![
OsString::from("-NoProfile"),
OsString::from("-ExecutionPolicy"),
OsString::from("Bypass"),
OsString::from("-File"),
script_arg,
],
)
}
Some("cmd") | Some("bat") => {
if cfg!(windows) {
(OsString::from("cmd"), vec![OsString::from("/C"), script_arg])
} else {
// No cmd.exe on unix; best effort is to exec the script directly
// (a `.cmd` on unix is unusual but we honour the shebang if any).
(script_arg, Vec::new())
}
}
// No / unknown extension: unix executes directly via shebang; Windows
// cannot exec a shebang script, so route through `cmd /C`.
_ => {
if cfg!(windows) {
(OsString::from("cmd"), vec![OsString::from("/C"), script_arg])
} else {
(script_arg, Vec::new())
}
}
}
}
/// Execute a lifecycle hook script in a child process.
///
/// - `ext_dir`: absolute path to the extension root directory (used as cwd).
/// - `hook_path`: script path relative to `ext_dir`.
/// - `kind`: which hook is being executed (determines timeout and label).
/// - `extension_name`: used for logging and error context.
///
/// Returns `Ok(())` on success. Returns an error if the script is not found,
/// times out, or exits with a non-zero status.
pub async fn execute_hook(
ext_dir: &Path,
hook_path: &str,
kind: HookKind,
extension_name: &str,
) -> Result<(), ExtensionError> {
let script = ext_dir.join(hook_path);
if !script.exists() {
warn!(
extension = extension_name,
hook = kind.label(),
path = %script.display(),
"lifecycle hook script not found, skipping"
);
return Err(ExtensionError::HookNotFound(script.display().to_string()));
}
let timeout_secs = kind.timeout_secs();
let label = kind.label();
info!(
extension = extension_name,
hook = label,
path = %script.display(),
timeout_secs,
"executing lifecycle hook"
);
// Select an interpreter by the script's file extension and pass the script
// as an argument. Spawning the script path directly as the program fails on
// Windows (`CreateProcess` cannot exec a `.sh`/shebang file). The interpreter
// is a bare name so `CmdBuilder` resolves it through PATH (+ Windows shims).
let (program, args) = hook_command(&script);
let mut builder = CmdBuilder::clean_cli(&program);
builder.args(&args);
builder.current_dir(ext_dir);
let child_future = builder.output();
let result = tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), child_future).await;
match result {
Err(_elapsed) => {
warn!(
extension = extension_name,
hook = label,
timeout_secs,
"lifecycle hook timed out"
);
Err(ExtensionError::HookTimeout {
extension_name: extension_name.to_owned(),
hook: label.to_owned(),
timeout_secs,
})
}
Ok(Err(io_err)) => {
warn!(
extension = extension_name,
hook = label,
error = %io_err,
"lifecycle hook I/O error"
);
Err(ExtensionError::HookFailed {
extension_name: extension_name.to_owned(),
hook: label.to_owned(),
reason: io_err.to_string(),
})
}
Ok(Ok(output)) => {
if output.status.success() {
info!(
extension = extension_name,
hook = label,
"lifecycle hook completed successfully"
);
Ok(())
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
let code = output
.status
.code()
.map_or_else(|| "signal".to_owned(), |c| c.to_string());
warn!(
extension = extension_name,
hook = label,
exit_code = %code,
stderr = %stderr,
"lifecycle hook exited with error"
);
Err(ExtensionError::HookFailed {
extension_name: extension_name.to_owned(),
hook: label.to_owned(),
reason: format!("exit code {code}: {}", stderr.trim()),
})
}
}
}
}
/// Determine whether the `onInstall` hook should run.
///
/// Returns `true` when:
/// - There is no persisted version (first-time install).
/// - The persisted version differs from the current manifest version.
pub fn needs_install_hook(current_version: &str, persisted_version: Option<&str>) -> bool {
match persisted_version {
None => true,
Some(prev) => prev != current_version,
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Write a lifecycle hook script with platform-appropriate syntax and
/// return the file name (relative to `dir`) to hand to `execute_hook`.
///
/// On Windows a `.cmd` batch file is written; elsewhere a `#!/bin/sh`
/// script (made executable). `stem` is the file name without extension.
/// `unix_body` / `windows_body` are the script bodies for each platform.
fn write_hook(dir: &Path, stem: &str, unix_body: &str, windows_body: &str) -> String {
#[cfg(windows)]
{
let name = format!("{stem}.cmd");
// `@echo off` keeps the interpreter from echoing each command into
// stdout, and CRLF line endings keep cmd.exe happy.
let content = format!("@echo off\r\n{}\r\n", windows_body.replace('\n', "\r\n"));
std::fs::write(dir.join(&name), content).unwrap();
let _ = unix_body;
name
}
#[cfg(not(windows))]
{
let name = format!("{stem}.sh");
let full = dir.join(&name);
std::fs::write(&full, format!("#!/bin/sh\n{unix_body}\n")).unwrap();
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&full, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let _ = windows_body;
name
}
}
// -----------------------------------------------------------------------
// needs_install_hook
// -----------------------------------------------------------------------
#[test]
fn test_needs_install_first_time() {
assert!(needs_install_hook("1.0.0", None));
}
#[test]
fn test_needs_install_version_changed() {
assert!(needs_install_hook("2.0.0", Some("1.0.0")));
}
#[test]
fn test_no_install_same_version() {
assert!(!needs_install_hook("1.0.0", Some("1.0.0")));
}
#[test]
fn test_needs_install_downgrade() {
assert!(needs_install_hook("0.9.0", Some("1.0.0")));
}
// -----------------------------------------------------------------------
// HookKind
// -----------------------------------------------------------------------
#[test]
fn test_hook_kind_timeout_values() {
assert_eq!(HookKind::OnInstall.timeout_secs(), 120);
assert_eq!(HookKind::OnUninstall.timeout_secs(), 60);
assert_eq!(HookKind::OnActivate.timeout_secs(), 30);
assert_eq!(HookKind::OnDeactivate.timeout_secs(), 30);
}
#[test]
fn test_hook_kind_labels() {
assert_eq!(HookKind::OnInstall.label(), "onInstall");
assert_eq!(HookKind::OnUninstall.label(), "onUninstall");
assert_eq!(HookKind::OnActivate.label(), "onActivate");
assert_eq!(HookKind::OnDeactivate.label(), "onDeactivate");
}
// -----------------------------------------------------------------------
// resolve_hook_path
// -----------------------------------------------------------------------
#[test]
fn test_resolve_hook_path_present() {
let hooks = LifecycleHooks {
on_install: Some("scripts/install.sh".into()),
on_activate: Some("scripts/activate.sh".into()),
on_deactivate: None,
on_uninstall: None,
};
assert_eq!(
resolve_hook_path(&hooks, HookKind::OnInstall),
Some("scripts/install.sh")
);
assert_eq!(
resolve_hook_path(&hooks, HookKind::OnActivate),
Some("scripts/activate.sh")
);
assert_eq!(resolve_hook_path(&hooks, HookKind::OnDeactivate), None);
assert_eq!(resolve_hook_path(&hooks, HookKind::OnUninstall), None);
}
#[test]
fn test_resolve_hook_path_empty_string() {
let hooks = LifecycleHooks {
on_install: Some(String::new()),
on_activate: None,
on_deactivate: None,
on_uninstall: None,
};
assert_eq!(resolve_hook_path(&hooks, HookKind::OnInstall), None);
}
// -----------------------------------------------------------------------
// hook_command — interpreter dispatch (single source of truth)
// -----------------------------------------------------------------------
/// Collect `(program, args)` as plain `String`s for assertion convenience.
fn dispatch(name: &str) -> (String, Vec<String>) {
let (program, args) = hook_command(Path::new(name));
(
program.to_string_lossy().into_owned(),
args.iter().map(|a| a.to_string_lossy().into_owned()).collect(),
)
}
#[test]
fn hook_command_sh_runs_via_sh() {
let (program, args) = dispatch("/ext/scripts/install.sh");
assert_eq!(program, "sh");
assert_eq!(args, vec!["/ext/scripts/install.sh".to_owned()]);
}
#[test]
fn hook_command_ps1_runs_via_powershell_with_file_flag() {
let (program, args) = dispatch("/ext/scripts/setup.ps1");
let expected_program = if cfg!(windows) { "powershell" } else { "pwsh" };
assert_eq!(program, expected_program);
// …-NoProfile -ExecutionPolicy Bypass -File <script>
assert_eq!(args.first().map(String::as_str), Some("-NoProfile"));
assert_eq!(args.get(1).map(String::as_str), Some("-ExecutionPolicy"));
assert_eq!(args.get(2).map(String::as_str), Some("Bypass"));
assert_eq!(args.get(3).map(String::as_str), Some("-File"));
assert_eq!(args.get(4).map(String::as_str), Some("/ext/scripts/setup.ps1"));
}
#[test]
fn hook_command_extension_is_case_insensitive() {
// `.SH` must dispatch like `.sh`.
let (program, _args) = dispatch("/ext/Install.SH");
assert_eq!(program, "sh");
}
#[cfg(windows)]
#[test]
fn hook_command_cmd_and_bare_run_via_cmd_on_windows() {
for name in ["C:/ext/install.cmd", "C:/ext/install.bat", "C:/ext/install"] {
let (program, args) = dispatch(name);
assert_eq!(program, "cmd", "name={name}");
assert_eq!(args.first().map(String::as_str), Some("/C"), "name={name}");
assert_eq!(args.get(1).map(String::as_str), Some(name));
}
}
#[cfg(not(windows))]
#[test]
fn hook_command_bare_runs_directly_on_unix() {
// No extension → execute directly (rely on shebang), no interpreter arg.
let (program, args) = dispatch("/ext/scripts/install");
assert_eq!(program, "/ext/scripts/install");
assert!(args.is_empty());
}
// -----------------------------------------------------------------------
// execute_hook (async unit tests)
// -----------------------------------------------------------------------
#[tokio::test]
async fn test_execute_hook_script_not_found() {
let dir = tempfile::tempdir().unwrap();
let result = execute_hook(dir.path(), "nonexistent.sh", HookKind::OnActivate, "test-ext").await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, ExtensionError::HookNotFound(_)));
}
#[tokio::test]
async fn test_execute_hook_success() {
let dir = tempfile::tempdir().unwrap();
let name = write_hook(dir.path(), "hook", "exit 0", "exit /b 0");
let result = execute_hook(dir.path(), &name, HookKind::OnActivate, "test-ext").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_execute_hook_nonzero_exit() {
let dir = tempfile::tempdir().unwrap();
let name = write_hook(
dir.path(),
"fail",
"echo 'something broke' >&2\nexit 1",
"echo something broke 1>&2 & exit /b 1",
);
let result = execute_hook(dir.path(), &name, HookKind::OnInstall, "test-ext").await;
assert!(result.is_err());
match result.unwrap_err() {
ExtensionError::HookFailed {
extension_name,
hook,
reason,
} => {
assert_eq!(extension_name, "test-ext");
assert_eq!(hook, "onInstall");
assert!(reason.contains("something broke"));
}
other => panic!("expected HookFailed, got {other:?}"),
}
}
#[tokio::test]
async fn test_execute_hook_timeout() {
let dir = tempfile::tempdir().unwrap();
// A long-enough-to-outlive-the-deadline hook on each platform. The 200ms
// deadline below must elapse before the process completes (Err == timeout),
// but `CmdBuilder::output()` blocks the executor (not cooperatively
// cancellable), so `timeout` only reports `Elapsed` AFTER the child exits —
// keep the sleep short so the test stays ~1s while still comfortably
// exceeding 200ms (so a fast spawn-failure regression still surfaces as Ok).
let name = write_hook(
dir.path(),
"slow",
"sleep 1",
"ping -n 2 127.0.0.1 >NUL",
);
let script = dir.path().join(&name);
assert!(script.exists());
let (program, args) = hook_command(&script);
let mut builder = CmdBuilder::clean_cli(&program);
builder.args(&args);
builder.current_dir(dir.path());
let result = tokio::time::timeout(std::time::Duration::from_millis(200), builder.output()).await;
// The deadline must elapse before the long-running process completes
// (Err == timeout). On a fast Windows `CreateProcess` failure this
// would instead resolve immediately and `result` would be Ok — which
// is exactly the regression this guards against.
assert!(result.is_err(), "should have timed out");
}
#[tokio::test]
async fn test_execute_hook_working_directory() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("cwd_marker.txt");
// print cwd to a file: unix `pwd`, cmd `cd` with no args prints cwd.
let name = write_hook(
dir.path(),
"check_cwd",
"pwd > cwd_marker.txt",
"cd > cwd_marker.txt",
);
let result = execute_hook(dir.path(), &name, HookKind::OnActivate, "test-ext").await;
assert!(result.is_ok());
assert!(marker.exists());
let cwd_content = std::fs::read_to_string(&marker).unwrap();
// The cwd written by the script should match the extension dir
// (may have symlink resolution differences, compare canonical)
let expected = dir.path().canonicalize().unwrap();
let actual_trimmed = cwd_content.trim();
let actual = Path::new(actual_trimmed).canonicalize().unwrap();
assert_eq!(actual, expected);
}
}
@@ -0,0 +1,737 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tracing::{debug, warn};
use crate::constants::{EXTENSION_API_VERSION, EXTENSION_MANIFEST_FILE, EXTENSIONS_DIR_NAME};
use crate::manifest::parse_manifest_in_dir;
use crate::types::{ExtensionSource, ExtensionState, LoadedExtension};
// ---------------------------------------------------------------------------
// Scan path resolution
// ---------------------------------------------------------------------------
/// A scan path paired with its source classification.
#[derive(Debug, Clone)]
pub struct ScanPath {
pub path: PathBuf,
pub source: ExtensionSource,
}
/// Resolve the default list of directories to scan for extensions.
///
/// Priority (highest first):
/// 1. `$NOMIFUN_EXTENSIONS_PATH`
/// 2. `~/.nomifun/extensions/` — legacy user data directory
/// 3. Platform AppData directory
///
/// In E2E test mode (`NOMIFUN_E2E_TEST=1`), only the environment variable
/// paths are returned to ensure test isolation.
pub fn resolve_scan_paths() -> Vec<ScanPath> {
let env_path = std::env::var("NOMIFUN_EXTENSIONS_PATH").ok();
let e2e_mode = is_e2e_test_mode();
resolve_scan_paths_inner(env_path.as_deref(), e2e_mode, None)
}
/// Resolve scan paths using the historical Electron desktop rules for the
/// provided `data_dir`.
///
/// Priority (highest first):
/// 1. `$NOMIFUN_EXTENSIONS_PATH`
/// 2. `<data_dir>/extensions`
/// 3. Legacy appData sibling directory derived from `<data_dir>`
///
/// In E2E test mode (`NOMIFUN_E2E_TEST=1`), only the environment variable
/// paths are returned to ensure test isolation.
pub fn resolve_scan_paths_for_data_dir(data_dir: &Path) -> Vec<ScanPath> {
let env_path = std::env::var("NOMIFUN_EXTENSIONS_PATH").ok();
let e2e_mode = is_e2e_test_mode();
resolve_scan_paths_inner(env_path.as_deref(), e2e_mode, Some(data_dir))
}
/// Resolve the install target directory using the same priority order as
/// `resolve_scan_paths_for_data_dir`.
pub fn resolve_install_target_dir_for_data_dir(data_dir: &Path) -> PathBuf {
resolve_scan_paths_for_data_dir(data_dir)
.into_iter()
.next()
.map(|sp| sp.path)
.unwrap_or_else(|| data_dir.join(EXTENSIONS_DIR_NAME))
}
/// Inner implementation that accepts explicit parameters for testability.
///
/// Production callers should use [`resolve_scan_paths`] which reads from
/// environment variables automatically.
fn resolve_scan_paths_inner(
env_extensions_path: Option<&str>,
e2e_mode: bool,
explicit_data_dir: Option<&Path>,
) -> Vec<ScanPath> {
let mut paths = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut push = |path: PathBuf, source: ExtensionSource| {
let normalized = path;
if seen.insert(normalized.clone()) {
paths.push(ScanPath {
path: normalized,
source,
});
}
};
// 1. Environment variable paths (highest priority).
if let Some(env_paths) = env_extensions_path {
for path in std::env::split_paths(env_paths) {
if !path.as_os_str().is_empty() {
push(path, ExtensionSource::Env);
}
}
}
// E2E test mode: only scan env var paths for isolation.
if e2e_mode {
return paths;
}
// 2. User data directory (desktop data dir or historical ~/.nomifun fallback).
if let Some(data_dir) = explicit_data_dir {
push(data_dir.join(EXTENSIONS_DIR_NAME), ExtensionSource::Local);
if let Some(appdata_dir) = derive_legacy_appdata_extensions_dir(data_dir) {
push(appdata_dir, ExtensionSource::Appdata);
}
} else {
if let Some(home) = dirs::home_dir() {
push(home.join(".nomifun").join(EXTENSIONS_DIR_NAME), ExtensionSource::Local);
}
// 3. AppData directory (platform-specific).
if let Some(data_dir) = dirs::data_dir() {
push(
data_dir.join("nomifun").join(EXTENSIONS_DIR_NAME),
ExtensionSource::Appdata,
);
}
}
paths
}
fn derive_legacy_appdata_extensions_dir(data_dir: &Path) -> Option<PathBuf> {
let resolved = std::fs::canonicalize(data_dir).unwrap_or_else(|_| data_dir.to_path_buf());
let leaf = resolved.file_name()?.to_str()?;
if leaf != "nomifun" {
return None;
}
Some(resolved.parent()?.join(EXTENSIONS_DIR_NAME))
}
// ---------------------------------------------------------------------------
// Extension loading
// ---------------------------------------------------------------------------
/// Scan all provided directories and load valid extension manifests.
///
/// When the same extension name appears in multiple scan paths, the first
/// occurrence wins (earlier entries have higher priority).
pub fn load_all(scan_paths: &[ScanPath]) -> Vec<LoadedExtension> {
let mut seen: HashMap<String, usize> = HashMap::new();
let mut result: Vec<LoadedExtension> = Vec::new();
for sp in scan_paths {
let loaded = scan_directory(&sp.path, sp.source);
for ext in loaded {
let name = ext.manifest.name.clone();
if let std::collections::hash_map::Entry::Vacant(e) = seen.entry(name.clone()) {
e.insert(result.len());
result.push(ext);
} else {
debug!(
name = %name,
skipped_path = %sp.path.display(),
"skipping duplicate extension (higher-priority copy already loaded)"
);
}
}
}
result
}
/// Scan a single directory for extension subdirectories containing a
/// valid manifest file.
fn scan_directory(dir: &Path, source: ExtensionSource) -> Vec<LoadedExtension> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) => {
if e.kind() != std::io::ErrorKind::NotFound {
warn!(dir = %dir.display(), error = %e, "failed to read extensions directory");
}
return Vec::new();
}
};
let mut loaded = Vec::new();
for entry in entries.flatten() {
let entry_path = entry.path();
if !entry_path.is_dir() {
continue;
}
let manifest_path = entry_path.join(EXTENSION_MANIFEST_FILE);
match load_single_extension(&manifest_path, &entry_path, source) {
Ok(ext) => {
debug!(name = %ext.manifest.name, dir = %entry_path.display(), "loaded extension");
loaded.push(ext);
}
Err(e) => {
// Skip extensions with invalid manifests but continue loading others.
warn!(
dir = %entry_path.display(),
error = %e,
"skipping extension with invalid manifest"
);
}
}
}
loaded
}
/// Load a single extension from its manifest file.
fn load_single_extension(
manifest_path: &Path,
ext_dir: &Path,
source: ExtensionSource,
) -> Result<LoadedExtension, crate::error::ExtensionError> {
let bytes = std::fs::read(manifest_path)?;
let manifest = parse_manifest_in_dir(&bytes, ext_dir)?;
let state = ExtensionState {
name: manifest.name.clone(),
version: manifest.version.clone(),
enabled: true,
installed_at: None,
last_activated_at: None,
};
let directory = ext_dir.to_str().unwrap_or_default().to_owned();
Ok(LoadedExtension {
manifest,
directory,
source,
state,
})
}
// ---------------------------------------------------------------------------
// Engine compatibility filtering
// ---------------------------------------------------------------------------
/// Filter extensions by engine and API version compatibility.
///
/// Extensions that declare `engine.nomifun` with a version range incompatible
/// with `app_version` are excluded. Extensions whose `apiVersion` is
/// incompatible with the supported [`EXTENSION_API_VERSION`] are also excluded.
///
/// Incompatible extensions are logged as warnings but do not cause errors.
pub fn filter_by_engine_compatibility(extensions: Vec<LoadedExtension>, app_version: &str) -> Vec<LoadedExtension> {
let Ok(app_ver) = semver::Version::parse(app_version) else {
warn!(
app_version = %app_version,
"invalid app version — skipping engine compatibility filter"
);
return extensions;
};
extensions
.into_iter()
.filter(|ext| is_engine_compatible(ext, &app_ver) && is_api_version_compatible(ext))
.collect()
}
/// Check whether the extension's `engine.nomifun` requirement is satisfied.
fn is_engine_compatible(ext: &LoadedExtension, app_version: &semver::Version) -> bool {
let Some(engine) = &ext.manifest.engine else {
return true; // no engine constraint
};
let Some(required) = &engine.nomifun else {
return true; // no nomifun constraint
};
match semver::VersionReq::parse(required) {
Ok(req) if req.matches(app_version) => true,
Ok(_) => {
warn!(
name = %ext.manifest.name,
required = %required,
actual = %app_version,
"extension filtered out: engine.nomifun incompatible"
);
false
}
Err(e) => {
warn!(
name = %ext.manifest.name,
required = %required,
error = %e,
"extension filtered out: invalid engine.nomifun version requirement"
);
false
}
}
}
/// Check whether the extension's `apiVersion` is compatible with the
/// supported API version.
fn is_api_version_compatible(ext: &LoadedExtension) -> bool {
let Some(api_ver_str) = &ext.manifest.api_version else {
return true; // no API version constraint
};
let Ok(declared) = semver::Version::parse(api_ver_str) else {
warn!(
name = %ext.manifest.name,
api_version = %api_ver_str,
"extension filtered out: invalid apiVersion"
);
return false;
};
let Ok(supported) = semver::Version::parse(EXTENSION_API_VERSION) else {
return true; // defensive — should never happen with a valid constant
};
// Compatible if major versions match and declared <= supported.
if declared.major == supported.major && declared <= supported {
true
} else {
warn!(
name = %ext.manifest.name,
declared = %declared,
supported = %supported,
"extension filtered out: apiVersion incompatible"
);
false
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
fn is_e2e_test_mode() -> bool {
std::env::var("NOMIFUN_E2E_TEST").map(|v| v == "1").unwrap_or(false)
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{EngineConfig, ExtensionManifest};
use std::fs;
use tempfile::TempDir;
/// Helper: create a minimal valid manifest JSON.
fn write_manifest(dir: &Path, name: &str, version: &str) {
write_manifest_full(dir, name, version, None, None);
}
/// Helper: create a manifest with optional engine and apiVersion fields.
fn write_manifest_full(
dir: &Path,
name: &str,
version: &str,
engine_nomifun: Option<&str>,
api_version: Option<&str>,
) {
let mut manifest = serde_json::json!({
"name": name,
"version": version,
});
if let Some(eng) = engine_nomifun {
manifest["engine"] = serde_json::json!({ "nomifun": eng });
}
if let Some(api) = api_version {
manifest["apiVersion"] = serde_json::json!(api);
}
let manifest_path = dir.join(EXTENSION_MANIFEST_FILE);
fs::write(manifest_path, serde_json::to_vec_pretty(&manifest).unwrap()).unwrap();
}
// -- scan_directory -------------------------------------------------------
#[test]
fn scan_empty_directory() {
let tmp = TempDir::new().unwrap();
let result = scan_directory(tmp.path(), ExtensionSource::Local);
assert!(result.is_empty());
}
#[test]
fn scan_nonexistent_directory() {
let result = scan_directory(Path::new("/nonexistent/path"), ExtensionSource::Local);
assert!(result.is_empty());
}
#[test]
fn scan_loads_valid_extension() {
let tmp = TempDir::new().unwrap();
let ext_dir = tmp.path().join("my-ext");
fs::create_dir(&ext_dir).unwrap();
write_manifest(&ext_dir, "my-ext", "1.0.0");
let result = scan_directory(tmp.path(), ExtensionSource::Local);
assert_eq!(result.len(), 1);
assert_eq!(result[0].manifest.name, "my-ext");
assert_eq!(result[0].manifest.version, "1.0.0");
assert_eq!(result[0].source, ExtensionSource::Local);
assert!(result[0].state.enabled);
}
#[test]
fn scan_loads_nomifun_main_contract_extension() {
let tmp = TempDir::new().unwrap();
let ext_dir = tmp.path().join("legacy-ext");
fs::create_dir(&ext_dir).unwrap();
fs::create_dir(ext_dir.join("contributes")).unwrap();
fs::write(
ext_dir.join("contributes/settings-tabs.json"),
serde_json::to_vec_pretty(&serde_json::json!([
{
"id": "legacy-settings",
"name": "Legacy Settings",
"entryPoint": "settings/legacy.html",
"position": { "anchor": "display", "placement": "after" }
}
]))
.unwrap(),
)
.unwrap();
fs::write(
ext_dir.join(EXTENSION_MANIFEST_FILE),
serde_json::to_vec_pretty(&serde_json::json!({
"name": "legacy-ext",
"displayName": "Legacy Extension",
"version": "1.0.0",
"i18n": {
"localesDir": "i18n",
"defaultLocale": "en-US"
},
"contributes": {
"settingsTabs": "$file:contributes/settings-tabs.json",
"webui": {
"apiRoutes": [
{
"path": "/legacy-ext/collect",
"entryPoint": "webui/collector.js"
}
],
"staticAssets": [
{
"urlPrefix": "/legacy-ext/assets",
"directory": "assets"
}
]
}
}
}))
.unwrap(),
)
.unwrap();
let result = scan_directory(tmp.path(), ExtensionSource::Local);
assert_eq!(result.len(), 1);
let manifest = &result[0].manifest;
assert_eq!(manifest.display_name.as_deref(), Some("Legacy Extension"));
assert_eq!(manifest.i18n.as_ref().unwrap().locales, vec!["en-US".to_owned()]);
assert_eq!(manifest.contributes.as_ref().unwrap().settings_tabs.len(), 1);
assert_eq!(manifest.contributes.as_ref().unwrap().webui.len(), 2);
}
#[test]
fn scan_skips_invalid_manifest() {
let tmp = TempDir::new().unwrap();
// Valid extension
let good_dir = tmp.path().join("good-ext");
fs::create_dir(&good_dir).unwrap();
write_manifest(&good_dir, "good-ext", "1.0.0");
// Invalid extension (bad JSON)
let bad_dir = tmp.path().join("bad-ext");
fs::create_dir(&bad_dir).unwrap();
fs::write(bad_dir.join(EXTENSION_MANIFEST_FILE), b"not valid json").unwrap();
let result = scan_directory(tmp.path(), ExtensionSource::Env);
assert_eq!(result.len(), 1);
assert_eq!(result[0].manifest.name, "good-ext");
}
#[test]
fn scan_skips_directories_without_manifest() {
let tmp = TempDir::new().unwrap();
let ext_dir = tmp.path().join("no-manifest");
fs::create_dir(&ext_dir).unwrap();
fs::write(ext_dir.join("README.md"), b"hello").unwrap();
let result = scan_directory(tmp.path(), ExtensionSource::Local);
assert!(result.is_empty());
}
#[test]
fn scan_skips_files_not_directories() {
let tmp = TempDir::new().unwrap();
fs::write(tmp.path().join("not-a-dir.txt"), b"hello").unwrap();
let result = scan_directory(tmp.path(), ExtensionSource::Local);
assert!(result.is_empty());
}
// -- load_all -------------------------------------------------------------
#[test]
fn load_all_deduplicates_by_name() {
let tmp1 = TempDir::new().unwrap();
let tmp2 = TempDir::new().unwrap();
// Same extension name in two directories
let ext1 = tmp1.path().join("my-ext");
fs::create_dir(&ext1).unwrap();
write_manifest(&ext1, "my-ext", "1.0.0");
let ext2 = tmp2.path().join("my-ext");
fs::create_dir(&ext2).unwrap();
write_manifest(&ext2, "my-ext", "2.0.0");
let scan_paths = vec![
ScanPath {
path: tmp1.path().to_path_buf(),
source: ExtensionSource::Env,
},
ScanPath {
path: tmp2.path().to_path_buf(),
source: ExtensionSource::Local,
},
];
let result = load_all(&scan_paths);
assert_eq!(result.len(), 1);
// First occurrence wins (higher priority).
assert_eq!(result[0].manifest.version, "1.0.0");
assert_eq!(result[0].source, ExtensionSource::Env);
}
#[test]
fn load_all_from_multiple_directories() {
let tmp1 = TempDir::new().unwrap();
let tmp2 = TempDir::new().unwrap();
let ext1 = tmp1.path().join("ext-a");
fs::create_dir(&ext1).unwrap();
write_manifest(&ext1, "ext-a", "1.0.0");
let ext2 = tmp2.path().join("ext-b");
fs::create_dir(&ext2).unwrap();
write_manifest(&ext2, "ext-b", "1.0.0");
let scan_paths = vec![
ScanPath {
path: tmp1.path().to_path_buf(),
source: ExtensionSource::Env,
},
ScanPath {
path: tmp2.path().to_path_buf(),
source: ExtensionSource::Local,
},
];
let result = load_all(&scan_paths);
assert_eq!(result.len(), 2);
}
#[test]
fn load_all_empty_paths() {
let result = load_all(&[]);
assert!(result.is_empty());
}
// -- filter_by_engine_compatibility ----------------------------------------
fn make_loaded_ext(
name: &str,
version: &str,
engine_nomifun: Option<&str>,
api_version: Option<&str>,
) -> LoadedExtension {
LoadedExtension {
manifest: ExtensionManifest {
name: name.to_string(),
version: version.to_string(),
display_name: None,
description: None,
author: None,
license: None,
homepage: None,
icon: None,
engine: engine_nomifun.map(|v| EngineConfig {
nomifun: Some(v.to_string()),
}),
api_version: api_version.map(|v| v.to_string()),
dependencies: HashMap::new(),
entry_point: None,
permissions: None,
contributes: None,
lifecycle: None,
i18n: None,
},
directory: format!("/test/{name}"),
source: ExtensionSource::Local,
state: ExtensionState {
name: name.to_string(),
version: version.to_string(),
enabled: true,
installed_at: None,
last_activated_at: None,
},
}
}
#[test]
fn filter_keeps_compatible_engine() {
let exts = vec![make_loaded_ext("ext-a", "1.0.0", Some("^1.0.0"), None)];
let filtered = filter_by_engine_compatibility(exts, "1.5.0");
assert_eq!(filtered.len(), 1);
}
#[test]
fn filter_removes_incompatible_engine() {
let exts = vec![make_loaded_ext("ext-a", "1.0.0", Some("^2.0.0"), None)];
let filtered = filter_by_engine_compatibility(exts, "1.5.0");
assert!(filtered.is_empty());
}
#[test]
fn filter_keeps_no_engine_constraint() {
let exts = vec![make_loaded_ext("ext-a", "1.0.0", None, None)];
let filtered = filter_by_engine_compatibility(exts, "1.5.0");
assert_eq!(filtered.len(), 1);
}
#[test]
fn filter_keeps_compatible_api_version() {
let exts = vec![make_loaded_ext("ext-a", "1.0.0", None, Some("1.0.0"))];
let filtered = filter_by_engine_compatibility(exts, "1.0.0");
assert_eq!(filtered.len(), 1);
}
#[test]
fn filter_removes_incompatible_api_version() {
// Extension requires API 2.0.0 but we support 1.0.0
let exts = vec![make_loaded_ext("ext-a", "1.0.0", None, Some("2.0.0"))];
let filtered = filter_by_engine_compatibility(exts, "1.0.0");
assert!(filtered.is_empty());
}
#[test]
fn filter_removes_invalid_engine_requirement() {
let exts = vec![make_loaded_ext("ext-a", "1.0.0", Some("not-valid-semver-req"), None)];
let filtered = filter_by_engine_compatibility(exts, "1.0.0");
assert!(filtered.is_empty());
}
#[test]
fn filter_keeps_all_with_invalid_app_version() {
// If the app version itself is invalid, skip filtering entirely.
let exts = vec![make_loaded_ext("ext-a", "1.0.0", Some("^2.0.0"), None)];
let filtered = filter_by_engine_compatibility(exts, "not-semver");
assert_eq!(filtered.len(), 1);
}
#[test]
fn filter_mixed_compatible_and_incompatible() {
let exts = vec![
make_loaded_ext("compatible", "1.0.0", Some("^1.0.0"), Some("1.0.0")),
make_loaded_ext("bad-engine", "1.0.0", Some("^3.0.0"), None),
make_loaded_ext("bad-api", "1.0.0", None, Some("2.0.0")),
make_loaded_ext("no-constraint", "1.0.0", None, None),
];
let filtered = filter_by_engine_compatibility(exts, "1.5.0");
assert_eq!(filtered.len(), 2);
assert_eq!(filtered[0].manifest.name, "compatible");
assert_eq!(filtered[1].manifest.name, "no-constraint");
}
// -- resolve_scan_paths_inner ------------------------------------------------
#[test]
fn resolve_scan_paths_includes_env_paths() {
let paths = resolve_scan_paths_inner(Some("/tmp/test-exts"), false, None);
assert!(
paths
.iter()
.any(|sp| sp.path.as_path() == Path::new("/tmp/test-exts") && sp.source == ExtensionSource::Env)
);
}
#[test]
fn resolve_scan_paths_e2e_mode_only_env() {
let paths = resolve_scan_paths_inner(Some("/tmp/e2e-exts"), true, None);
assert!(paths.iter().all(|sp| sp.source == ExtensionSource::Env));
assert!(paths.iter().any(|sp| sp.path.as_path() == Path::new("/tmp/e2e-exts")));
}
#[test]
fn resolve_scan_paths_no_env_includes_platform_dirs() {
let paths = resolve_scan_paths_inner(None, false, None);
// Should have at least one platform dir (home or appdata).
assert!(
paths
.iter()
.any(|sp| sp.source == ExtensionSource::Local || sp.source == ExtensionSource::Appdata)
);
}
#[test]
fn resolve_scan_paths_e2e_no_env_returns_empty() {
let paths = resolve_scan_paths_inner(None, true, None);
assert!(paths.is_empty());
}
#[test]
fn resolve_scan_paths_for_data_dir_prefers_env_then_data_dir_then_appdata() {
let tmp = tempfile::TempDir::new().unwrap();
let app_root = tmp.path().join("Nomi-Dev");
let data_dir = app_root.join("nomifun");
std::fs::create_dir_all(&data_dir).unwrap();
let canonical_app_root = std::fs::canonicalize(&app_root).unwrap();
let paths = resolve_scan_paths_inner(Some("/tmp/env-exts"), false, Some(&data_dir));
assert_eq!(paths[0].path, PathBuf::from("/tmp/env-exts"));
assert_eq!(paths[0].source, ExtensionSource::Env);
assert_eq!(paths[1].path, data_dir.join(EXTENSIONS_DIR_NAME));
assert_eq!(paths[1].source, ExtensionSource::Local);
assert_eq!(paths[2].path, canonical_app_root.join(EXTENSIONS_DIR_NAME));
assert_eq!(paths[2].source, ExtensionSource::Appdata);
}
#[test]
fn resolve_scan_paths_for_data_dir_deduplicates_local_and_appdata() {
let tmp = tempfile::TempDir::new().unwrap();
let data_dir = tmp.path().join("plain-data");
std::fs::create_dir_all(&data_dir).unwrap();
let paths = resolve_scan_paths_inner(None, false, Some(&data_dir));
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].path, data_dir.join(EXTENSIONS_DIR_NAME));
assert_eq!(paths[0].source, ExtensionSource::Local);
}
}
@@ -0,0 +1,719 @@
use crate::constants::RESERVED_NAME_PREFIXES;
use crate::error::ExtensionError;
use crate::types::ExtensionManifest;
use serde_json::{Map, Value};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
/// Validate an extension manifest for required fields, name format, and version format.
pub fn validate_manifest(manifest: &ExtensionManifest) -> Result<(), ExtensionError> {
validate_name(&manifest.name)?;
validate_version(&manifest.version)?;
Ok(())
}
/// Reject extension names that use reserved prefixes.
fn validate_name(name: &str) -> Result<(), ExtensionError> {
if name.is_empty() {
return Err(ExtensionError::ManifestValidation(
"extension name must not be empty".into(),
));
}
let lower = name.to_lowercase();
for prefix in RESERVED_NAME_PREFIXES {
if lower.starts_with(prefix) {
return Err(ExtensionError::ReservedNamePrefix {
name: name.to_owned(),
prefix: (*prefix).to_owned(),
});
}
}
Ok(())
}
/// Validate that the version string is valid semver.
fn validate_version(version: &str) -> Result<(), ExtensionError> {
if version.is_empty() {
return Err(ExtensionError::ManifestValidation(
"extension version must not be empty".into(),
));
}
semver::Version::parse(version).map_err(|e| ExtensionError::InvalidVersion {
version: version.to_owned(),
reason: e.to_string(),
})?;
Ok(())
}
/// Parse and validate a manifest from JSON bytes.
pub fn parse_manifest(json_bytes: &[u8]) -> Result<ExtensionManifest, ExtensionError> {
parse_manifest_inner(json_bytes, None)
}
/// Parse and validate a manifest from JSON bytes, resolving legacy `$file:`
/// references relative to the extension directory before deserialization.
pub fn parse_manifest_in_dir(json_bytes: &[u8], extension_dir: &Path) -> Result<ExtensionManifest, ExtensionError> {
parse_manifest_inner(json_bytes, Some(extension_dir))
}
fn parse_manifest_inner(json_bytes: &[u8], extension_dir: Option<&Path>) -> Result<ExtensionManifest, ExtensionError> {
let mut manifest_json: Value = serde_json::from_slice(json_bytes)?;
if let Some(dir) = extension_dir {
let mut visited = HashSet::new();
manifest_json = resolve_file_refs(manifest_json, dir, &mut visited)?;
}
normalize_manifest_json(&mut manifest_json);
let manifest: ExtensionManifest = serde_json::from_value(manifest_json)?;
validate_manifest(&manifest)?;
Ok(manifest)
}
fn resolve_file_refs(
value: Value,
extension_dir: &Path,
visited: &mut HashSet<PathBuf>,
) -> Result<Value, ExtensionError> {
match value {
Value::String(text) if is_file_ref(&text) => resolve_file_ref_value(&text, extension_dir, visited),
Value::Array(values) => {
let mut resolved = Vec::with_capacity(values.len());
for item in values {
resolved.push(resolve_file_refs(item, extension_dir, visited)?);
}
Ok(Value::Array(resolved))
}
Value::Object(map) => {
let mut resolved = Map::with_capacity(map.len());
for (key, value) in map {
resolved.insert(key, resolve_file_refs(value, extension_dir, visited)?);
}
Ok(Value::Object(resolved))
}
other => Ok(other),
}
}
fn is_file_ref(value: &str) -> bool {
value.starts_with("$file:")
}
fn resolve_file_ref_value(
reference: &str,
extension_dir: &Path,
visited: &mut HashSet<PathBuf>,
) -> Result<Value, ExtensionError> {
let relative = reference.trim_start_matches("$file:").trim();
let absolute = extension_dir.join(relative);
let canonical_base = std::fs::canonicalize(extension_dir)?;
let canonical_path =
std::fs::canonicalize(&absolute).map_err(|_| ExtensionError::FileReferenceNotFound(relative.to_owned()))?;
if !canonical_path.starts_with(&canonical_base) {
return Err(ExtensionError::PathTraversal(relative.to_owned()));
}
if !visited.insert(canonical_path.clone()) {
return Err(ExtensionError::ManifestValidation(format!(
"circular $file reference detected: {relative}"
)));
}
let content = std::fs::read_to_string(&canonical_path)?;
let resolved = match canonical_path.extension().and_then(|ext| ext.to_str()) {
Some("json") | Some("jsonc") | Some("json5") => {
let parsed: Value = serde_json::from_str(&content)?;
resolve_file_refs(parsed, extension_dir, visited)?
}
_ => Value::String(content.trim_end_matches('\n').to_owned()),
};
visited.remove(&canonical_path);
Ok(resolved)
}
fn normalize_manifest_json(value: &mut Value) {
let Some(root) = value.as_object_mut() else {
return;
};
move_key(root, "displayName", "display_name");
move_key(root, "apiVersion", "api_version");
move_key(root, "entryPoint", "entry_point");
if let Some(i18n) = root.get_mut("i18n") {
normalize_i18n(i18n);
}
if let Some(lifecycle) = root.get_mut("lifecycle") {
normalize_lifecycle(lifecycle);
}
if let Some(contributes) = root.get_mut("contributes") {
normalize_contributes(contributes);
}
}
fn normalize_i18n(value: &mut Value) {
let Some(obj) = value.as_object_mut() else {
return;
};
move_key(obj, "localesDir", "directory");
if !obj.contains_key("locales") {
if let Some(default_locale) = obj.remove("defaultLocale") {
obj.insert("locales".into(), Value::Array(vec![default_locale]));
}
} else {
obj.remove("defaultLocale");
}
}
fn normalize_lifecycle(value: &mut Value) {
let Some(obj) = value.as_object_mut() else {
return;
};
move_key(obj, "onInstall", "on_install");
move_key(obj, "onUninstall", "on_uninstall");
move_key(obj, "onActivate", "on_activate");
move_key(obj, "onDeactivate", "on_deactivate");
}
fn normalize_contributes(value: &mut Value) {
let Some(obj) = value.as_object_mut() else {
return;
};
move_key(obj, "acpAdapters", "acp_adapters");
move_key(obj, "mcpServers", "mcp_servers");
move_key(obj, "channelPlugins", "channel_plugins");
move_key(obj, "settingsTabs", "settings_tabs");
move_key(obj, "modelProviders", "model_providers");
normalize_array_entries(obj.get_mut("acp_adapters"), normalize_acp_adapter);
normalize_array_entries(obj.get_mut("mcp_servers"), normalize_mcp_server);
normalize_array_entries(obj.get_mut("assistants"), normalize_assistant);
normalize_array_entries(obj.get_mut("agents"), normalize_agent);
normalize_array_entries(obj.get_mut("skills"), normalize_skill);
normalize_array_entries(obj.get_mut("channel_plugins"), normalize_channel_plugin);
normalize_array_entries(obj.get_mut("themes"), normalize_theme);
normalize_array_entries(obj.get_mut("settings_tabs"), normalize_settings_tab);
normalize_array_entries(obj.get_mut("model_providers"), normalize_model_provider);
if let Some(webui) = obj.get_mut("webui") {
normalize_webui(webui);
}
}
fn normalize_array_entries(value: Option<&mut Value>, normalize_item: fn(&mut Value)) {
let Some(Value::Array(items)) = value else {
return;
};
for item in items {
normalize_item(item);
}
}
fn normalize_acp_adapter(value: &mut Value) {
let Some(obj) = value.as_object_mut() else {
return;
};
move_key(obj, "cliCommand", "cli_command");
move_key(obj, "defaultCliPath", "default_cli_path");
move_key(obj, "acpArgs", "acp_args");
move_key(obj, "authRequired", "auth_required");
move_key(obj, "supportsStreaming", "supports_streaming");
move_key(obj, "connectionType", "connection_type");
move_key(obj, "apiKeyFields", "api_key_fields");
move_key(obj, "yoloMode", "yolo_mode");
move_key(obj, "healthCheck", "health_check");
move_key(obj, "icon", "avatar");
}
fn normalize_mcp_server(value: &mut Value) {
let Some(obj) = value.as_object_mut() else {
return;
};
if !obj.contains_key("id")
&& let Some(Value::String(name)) = obj.get("name")
{
obj.insert("id".into(), Value::String(name.clone()));
}
}
fn normalize_assistant(value: &mut Value) {
let Some(obj) = value.as_object_mut() else {
return;
};
move_key(obj, "avatar", "icon");
move_key(obj, "systemPrompt", "system_prompt");
if !obj.contains_key("context") {
if let Some(Value::String(path)) = obj.remove("contextFile") {
obj.insert("context".into(), Value::String(format!("@file:{path}")));
}
} else {
obj.remove("contextFile");
}
}
fn normalize_agent(value: &mut Value) {
normalize_assistant(value);
let Some(obj) = value.as_object_mut() else {
return;
};
move_key(obj, "presetAgentType", "agent_type");
}
fn normalize_skill(value: &mut Value) {
let Some(obj) = value.as_object_mut() else {
return;
};
move_key(obj, "file", "path");
}
fn normalize_channel_plugin(value: &mut Value) {
let Some(obj) = value.as_object_mut() else {
return;
};
if !obj.contains_key("id") {
move_key(obj, "type", "id");
}
move_key(obj, "entryPoint", "entry_point");
}
fn normalize_theme(value: &mut Value) {
let Some(obj) = value.as_object_mut() else {
return;
};
move_key(obj, "file", "css_file");
move_key(obj, "cover", "cover_image");
}
fn normalize_settings_tab(value: &mut Value) {
let Some(obj) = value.as_object_mut() else {
return;
};
move_key(obj, "name", "label");
move_key(obj, "entryPoint", "url");
if let Some(position) = obj.get_mut("position").and_then(Value::as_object_mut) {
move_key(position, "anchor", "relativeTo");
}
}
fn normalize_model_provider(value: &mut Value) {
let Some(obj) = value.as_object_mut() else {
return;
};
move_key(obj, "baseUrl", "base_url");
move_key(obj, "platform", "protocol");
}
fn normalize_webui(value: &mut Value) {
let Some(obj) = value.as_object_mut() else {
return;
};
if obj.contains_key("directory") && obj.contains_key("routes") {
return;
}
let api_routes = obj
.remove("apiRoutes")
.or_else(|| obj.remove("api_routes"))
.and_then(|value| value.as_array().cloned())
.unwrap_or_default();
let static_assets = obj
.remove("staticAssets")
.or_else(|| obj.remove("static_assets"))
.and_then(|value| value.as_array().cloned())
.unwrap_or_default();
let mut webui_entries = Vec::new();
if !api_routes.is_empty() {
let routes = api_routes
.into_iter()
.map(|mut route| {
if let Some(route_obj) = route.as_object_mut() {
move_key(route_obj, "entryPoint", "handler");
route_obj.entry("method").or_insert_with(|| Value::String("GET".into()));
}
route
})
.collect::<Vec<_>>();
webui_entries.push(Value::Object(Map::from_iter([
("id".into(), Value::String("legacy-webui-routes".into())),
("directory".into(), Value::String(".".into())),
("routes".into(), Value::Array(routes)),
])));
}
for (index, asset) in static_assets.into_iter().enumerate() {
let Some(asset_obj) = asset.as_object() else {
continue;
};
let directory = asset_obj
.get("directory")
.cloned()
.unwrap_or_else(|| Value::String(".".into()));
let id = asset_obj
.get("urlPrefix")
.or_else(|| asset_obj.get("url_prefix"))
.and_then(Value::as_str)
.map(|prefix| prefix.trim_matches('/').replace(['/', '.', '_'], "-"))
.filter(|value| !value.is_empty())
.unwrap_or_else(|| format!("legacy-webui-assets-{index}"));
webui_entries.push(Value::Object(Map::from_iter([
("id".into(), Value::String(id)),
("directory".into(), directory),
("routes".into(), Value::Array(Vec::new())),
])));
}
*value = Value::Array(webui_entries);
}
fn move_key(map: &mut Map<String, Value>, old_key: &str, new_key: &str) {
if map.contains_key(new_key) {
map.remove(old_key);
return;
}
if let Some(value) = map.remove(old_key) {
map.insert(new_key.to_owned(), value);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use tempfile::TempDir;
// -- validate_manifest --
#[test]
fn test_valid_manifest() {
let manifest = ExtensionManifest {
name: "my-cool-ext".into(),
version: "1.0.0".into(),
display_name: None,
description: None,
author: None,
license: None,
homepage: None,
icon: None,
engine: None,
api_version: None,
dependencies: Default::default(),
entry_point: None,
permissions: None,
contributes: None,
lifecycle: None,
i18n: None,
};
assert!(validate_manifest(&manifest).is_ok());
}
#[test]
fn test_empty_name_rejected() {
let manifest = ExtensionManifest {
name: "".into(),
version: "1.0.0".into(),
display_name: None,
description: None,
author: None,
license: None,
homepage: None,
icon: None,
engine: None,
api_version: None,
dependencies: Default::default(),
entry_point: None,
permissions: None,
contributes: None,
lifecycle: None,
i18n: None,
};
let err = validate_manifest(&manifest).unwrap_err();
assert!(matches!(err, ExtensionError::ManifestValidation(_)));
}
#[test]
fn test_reserved_prefix_nomi() {
let manifest = ExtensionManifest {
name: "nomi-my-ext".into(),
version: "1.0.0".into(),
display_name: None,
description: None,
author: None,
license: None,
homepage: None,
icon: None,
engine: None,
api_version: None,
dependencies: Default::default(),
entry_point: None,
permissions: None,
contributes: None,
lifecycle: None,
i18n: None,
};
let err = validate_manifest(&manifest).unwrap_err();
assert!(matches!(
err,
ExtensionError::ReservedNamePrefix { ref prefix, .. } if prefix == "nomi-"
));
}
#[test]
fn test_all_reserved_prefixes_rejected() {
for prefix in RESERVED_NAME_PREFIXES {
let name = format!("{prefix}test");
let manifest = ExtensionManifest {
name,
version: "1.0.0".into(),
display_name: None,
description: None,
author: None,
license: None,
homepage: None,
icon: None,
engine: None,
api_version: None,
dependencies: Default::default(),
entry_point: None,
permissions: None,
contributes: None,
lifecycle: None,
i18n: None,
};
assert!(
validate_manifest(&manifest).is_err(),
"prefix '{prefix}' should be rejected"
);
}
}
#[test]
fn test_reserved_prefix_case_insensitive() {
let manifest = ExtensionManifest {
name: "NOMI-upper".into(),
version: "1.0.0".into(),
display_name: None,
description: None,
author: None,
license: None,
homepage: None,
icon: None,
engine: None,
api_version: None,
dependencies: Default::default(),
entry_point: None,
permissions: None,
contributes: None,
lifecycle: None,
i18n: None,
};
assert!(validate_manifest(&manifest).is_err());
}
#[test]
fn test_empty_version_rejected() {
let manifest = ExtensionManifest {
name: "my-ext".into(),
version: "".into(),
display_name: None,
description: None,
author: None,
license: None,
homepage: None,
icon: None,
engine: None,
api_version: None,
dependencies: Default::default(),
entry_point: None,
permissions: None,
contributes: None,
lifecycle: None,
i18n: None,
};
let err = validate_manifest(&manifest).unwrap_err();
assert!(matches!(err, ExtensionError::ManifestValidation(_)));
}
#[test]
fn test_invalid_semver_rejected() {
let manifest = ExtensionManifest {
name: "my-ext".into(),
version: "not-semver".into(),
display_name: None,
description: None,
author: None,
license: None,
homepage: None,
icon: None,
engine: None,
api_version: None,
dependencies: Default::default(),
entry_point: None,
permissions: None,
contributes: None,
lifecycle: None,
i18n: None,
};
let err = validate_manifest(&manifest).unwrap_err();
assert!(matches!(err, ExtensionError::InvalidVersion { .. }));
}
#[test]
fn test_valid_semver_versions() {
for version in &["0.0.1", "1.0.0", "1.2.3", "10.20.30", "1.0.0-alpha.1"] {
let manifest = ExtensionManifest {
name: "ext".into(),
version: (*version).into(),
display_name: None,
description: None,
author: None,
license: None,
homepage: None,
icon: None,
engine: None,
api_version: None,
dependencies: Default::default(),
entry_point: None,
permissions: None,
contributes: None,
lifecycle: None,
i18n: None,
};
assert!(
validate_manifest(&manifest).is_ok(),
"version '{version}' should be accepted"
);
}
}
// -- parse_manifest --
#[test]
fn test_parse_manifest_valid() {
let raw = json!({"name": "my-ext", "version": "1.0.0"});
let bytes = serde_json::to_vec(&raw).unwrap();
let manifest = parse_manifest(&bytes).unwrap();
assert_eq!(manifest.name, "my-ext");
assert_eq!(manifest.version, "1.0.0");
}
#[test]
fn test_parse_manifest_invalid_json() {
let err = parse_manifest(b"not json").unwrap_err();
assert!(matches!(err, ExtensionError::JsonParse(_)));
}
#[test]
fn test_parse_manifest_missing_name() {
let raw = json!({"version": "1.0.0"});
let bytes = serde_json::to_vec(&raw).unwrap();
let err = parse_manifest(&bytes).unwrap_err();
assert!(matches!(err, ExtensionError::JsonParse(_)));
}
#[test]
fn test_parse_manifest_reserved_name() {
let raw = json!({"name": "internal-test", "version": "1.0.0"});
let bytes = serde_json::to_vec(&raw).unwrap();
let err = parse_manifest(&bytes).unwrap_err();
assert!(matches!(err, ExtensionError::ReservedNamePrefix { .. }));
}
#[test]
fn test_parse_manifest_in_dir_supports_nomifun_main_contract() {
let tmp = TempDir::new().unwrap();
let contributes_dir = tmp.path().join("contributes");
std::fs::create_dir_all(&contributes_dir).unwrap();
std::fs::write(
contributes_dir.join("settings-tabs.json"),
serde_json::to_vec(&json!([
{
"id": "legacy-settings",
"name": "Legacy Settings",
"entryPoint": "settings/legacy.html",
"position": { "anchor": "display", "placement": "after" }
}
]))
.unwrap(),
)
.unwrap();
let raw = json!({
"name": "legacy-ext",
"displayName": "Legacy Extension",
"version": "1.0.0",
"i18n": {
"localesDir": "i18n",
"defaultLocale": "en-US"
},
"contributes": {
"settingsTabs": "$file:contributes/settings-tabs.json"
}
});
let manifest = parse_manifest_in_dir(&serde_json::to_vec(&raw).unwrap(), tmp.path()).unwrap();
assert_eq!(manifest.display_name.as_deref(), Some("Legacy Extension"));
assert_eq!(manifest.i18n.as_ref().unwrap().locales, vec!["en-US".to_owned()]);
assert_eq!(manifest.i18n.as_ref().unwrap().directory, "i18n");
let settings_tabs = &manifest.contributes.as_ref().unwrap().settings_tabs;
assert_eq!(settings_tabs.len(), 1);
assert_eq!(settings_tabs[0].label, "Legacy Settings");
assert_eq!(settings_tabs[0].url, "settings/legacy.html");
assert_eq!(settings_tabs[0].position.as_ref().unwrap().relative_to, "display");
}
#[test]
fn test_parse_manifest_in_dir_supports_legacy_webui_object() {
let tmp = TempDir::new().unwrap();
let raw = json!({
"name": "legacy-webui-ext",
"version": "1.0.0",
"contributes": {
"webui": {
"apiRoutes": [
{
"path": "/legacy-webui-ext/collect",
"entryPoint": "webui/collector.js"
}
],
"staticAssets": [
{
"urlPrefix": "/legacy-webui-ext/assets",
"directory": "assets"
}
]
}
}
});
let manifest = parse_manifest_in_dir(&serde_json::to_vec(&raw).unwrap(), tmp.path()).unwrap();
let webui = &manifest.contributes.as_ref().unwrap().webui;
assert_eq!(webui.len(), 2);
assert_eq!(webui[0].routes[0].handler, "webui/collector.js");
assert_eq!(webui[0].routes[0].method, "GET");
assert_eq!(webui[1].directory, "assets");
}
}
@@ -0,0 +1,336 @@
use crate::types::{
ExtPermissions, FilesystemScope, NetworkPermission, PermissionDetail, PermissionLevel, PermissionSummary, RiskLevel,
};
/// Calculate the overall risk level from permission declarations.
///
/// Rules (from API Spec):
/// - **dangerous**: `shell=true`, `filesystem=full`, or `network=true` (unrestricted)
/// - **moderate**: scoped `network` (domain-restricted) or `filesystem=extension-only|workspace`
/// - **safe**: everything else (only storage, events, clipboard, activeUser, or nothing)
pub fn calculate_risk_level(permissions: &ExtPermissions) -> RiskLevel {
// Dangerous: shell access
if permissions.shell == Some(true) {
return RiskLevel::Dangerous;
}
// Dangerous: full filesystem access
if permissions.filesystem == Some(FilesystemScope::Full) {
return RiskLevel::Dangerous;
}
// Dangerous: unrestricted network
if let Some(NetworkPermission::Unrestricted(true)) = &permissions.network {
return RiskLevel::Dangerous;
}
// Moderate: scoped network (with allowed domains)
if matches!(&permissions.network, Some(NetworkPermission::Scoped { .. })) {
return RiskLevel::Moderate;
}
// Moderate: workspace or extension-only filesystem
if matches!(
permissions.filesystem,
Some(FilesystemScope::Workspace) | Some(FilesystemScope::ExtensionOnly)
) {
return RiskLevel::Moderate;
}
RiskLevel::Safe
}
/// Build a complete permission summary with risk analysis details.
pub fn build_permission_summary(permissions: &ExtPermissions) -> PermissionSummary {
let risk_level = calculate_risk_level(permissions);
let details = build_details(permissions);
PermissionSummary {
permissions: permissions.clone(),
risk_level,
details,
}
}
fn build_details(permissions: &ExtPermissions) -> Vec<PermissionDetail> {
vec![
build_storage_detail(permissions.storage),
build_network_detail(&permissions.network),
build_shell_detail(permissions.shell),
build_filesystem_detail(permissions.filesystem),
build_bool_detail("clipboard", permissions.clipboard, "Clipboard read/write access"),
build_bool_detail(
"activeUser",
permissions.active_user,
"Access to current user information",
),
build_bool_detail("events", permissions.events, "Extension event bus communication"),
]
}
fn build_storage_detail(storage: Option<bool>) -> PermissionDetail {
match storage {
Some(true) => PermissionDetail {
permission: "storage".into(),
level: PermissionLevel::Full,
description: "Persistent key-value storage access".into(),
},
_ => PermissionDetail {
permission: "storage".into(),
level: PermissionLevel::None,
description: "No storage access".into(),
},
}
}
fn build_network_detail(network: &Option<NetworkPermission>) -> PermissionDetail {
match network {
Some(NetworkPermission::Unrestricted(true)) => PermissionDetail {
permission: "network".into(),
level: PermissionLevel::Full,
description: "Unrestricted network access".into(),
},
Some(NetworkPermission::Scoped { allowed_domains, .. }) => PermissionDetail {
permission: "network".into(),
level: PermissionLevel::Limited,
description: format!("Network access limited to: {}", allowed_domains.join(", ")),
},
_ => PermissionDetail {
permission: "network".into(),
level: PermissionLevel::None,
description: "No network access".into(),
},
}
}
fn build_shell_detail(shell: Option<bool>) -> PermissionDetail {
match shell {
Some(true) => PermissionDetail {
permission: "shell".into(),
level: PermissionLevel::Full,
description: "System command execution".into(),
},
_ => PermissionDetail {
permission: "shell".into(),
level: PermissionLevel::None,
description: "No shell access".into(),
},
}
}
fn build_filesystem_detail(filesystem: Option<FilesystemScope>) -> PermissionDetail {
match filesystem {
Some(FilesystemScope::Full) => PermissionDetail {
permission: "filesystem".into(),
level: PermissionLevel::Full,
description: "Full filesystem access".into(),
},
Some(FilesystemScope::Workspace) => PermissionDetail {
permission: "filesystem".into(),
level: PermissionLevel::Limited,
description: "Workspace directory access".into(),
},
Some(FilesystemScope::ExtensionOnly) => PermissionDetail {
permission: "filesystem".into(),
level: PermissionLevel::Limited,
description: "Extension directory access only".into(),
},
None => PermissionDetail {
permission: "filesystem".into(),
level: PermissionLevel::None,
description: "No filesystem access".into(),
},
}
}
fn build_bool_detail(name: &str, value: Option<bool>, granted_desc: &str) -> PermissionDetail {
match value {
Some(true) => PermissionDetail {
permission: name.into(),
level: PermissionLevel::Full,
description: granted_desc.into(),
},
_ => PermissionDetail {
permission: name.into(),
level: PermissionLevel::None,
description: format!("No {name} access"),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
// -- calculate_risk_level --
#[test]
fn test_no_permissions_is_safe() {
let perms = ExtPermissions::default();
assert_eq!(calculate_risk_level(&perms), RiskLevel::Safe);
}
#[test]
fn test_storage_and_events_only_is_safe() {
let perms = ExtPermissions {
storage: Some(true),
events: Some(true),
..Default::default()
};
assert_eq!(calculate_risk_level(&perms), RiskLevel::Safe);
}
#[test]
fn test_clipboard_is_safe() {
let perms = ExtPermissions {
clipboard: Some(true),
..Default::default()
};
assert_eq!(calculate_risk_level(&perms), RiskLevel::Safe);
}
#[test]
fn test_active_user_is_safe() {
let perms = ExtPermissions {
active_user: Some(true),
..Default::default()
};
assert_eq!(calculate_risk_level(&perms), RiskLevel::Safe);
}
#[test]
fn test_scoped_network_is_moderate() {
let perms = ExtPermissions {
network: Some(NetworkPermission::Scoped {
allowed_domains: vec!["api.example.com".into()],
reasoning: "API calls".into(),
}),
..Default::default()
};
assert_eq!(calculate_risk_level(&perms), RiskLevel::Moderate);
}
#[test]
fn test_workspace_filesystem_is_moderate() {
let perms = ExtPermissions {
filesystem: Some(FilesystemScope::Workspace),
..Default::default()
};
assert_eq!(calculate_risk_level(&perms), RiskLevel::Moderate);
}
#[test]
fn test_extension_only_filesystem_is_moderate() {
let perms = ExtPermissions {
filesystem: Some(FilesystemScope::ExtensionOnly),
..Default::default()
};
assert_eq!(calculate_risk_level(&perms), RiskLevel::Moderate);
}
#[test]
fn test_shell_is_dangerous() {
let perms = ExtPermissions {
shell: Some(true),
..Default::default()
};
assert_eq!(calculate_risk_level(&perms), RiskLevel::Dangerous);
}
#[test]
fn test_full_filesystem_is_dangerous() {
let perms = ExtPermissions {
filesystem: Some(FilesystemScope::Full),
..Default::default()
};
assert_eq!(calculate_risk_level(&perms), RiskLevel::Dangerous);
}
#[test]
fn test_unrestricted_network_is_dangerous() {
let perms = ExtPermissions {
network: Some(NetworkPermission::Unrestricted(true)),
..Default::default()
};
assert_eq!(calculate_risk_level(&perms), RiskLevel::Dangerous);
}
#[test]
fn test_dangerous_overrides_moderate() {
let perms = ExtPermissions {
shell: Some(true),
network: Some(NetworkPermission::Scoped {
allowed_domains: vec!["example.com".into()],
reasoning: "test".into(),
}),
..Default::default()
};
assert_eq!(calculate_risk_level(&perms), RiskLevel::Dangerous);
}
// -- build_permission_summary --
#[test]
fn test_summary_includes_all_permissions() {
let perms = ExtPermissions {
storage: Some(true),
events: Some(true),
..Default::default()
};
let summary = build_permission_summary(&perms);
assert_eq!(summary.risk_level, RiskLevel::Safe);
assert_eq!(summary.permissions, perms);
assert_eq!(summary.details.len(), 7);
}
#[test]
fn test_summary_storage_detail() {
let perms = ExtPermissions {
storage: Some(true),
..Default::default()
};
let summary = build_permission_summary(&perms);
let storage = summary.details.iter().find(|d| d.permission == "storage").unwrap();
assert_eq!(storage.level, PermissionLevel::Full);
}
#[test]
fn test_summary_network_scoped_detail() {
let perms = ExtPermissions {
network: Some(NetworkPermission::Scoped {
allowed_domains: vec!["a.com".into(), "b.com".into()],
reasoning: "test".into(),
}),
..Default::default()
};
let summary = build_permission_summary(&perms);
let network = summary.details.iter().find(|d| d.permission == "network").unwrap();
assert_eq!(network.level, PermissionLevel::Limited);
assert!(network.description.contains("a.com"));
assert!(network.description.contains("b.com"));
}
#[test]
fn test_summary_filesystem_full_detail() {
let perms = ExtPermissions {
filesystem: Some(FilesystemScope::Full),
..Default::default()
};
let summary = build_permission_summary(&perms);
let fs = summary.details.iter().find(|d| d.permission == "filesystem").unwrap();
assert_eq!(fs.level, PermissionLevel::Full);
}
#[test]
fn test_summary_no_permissions_all_none() {
let perms = ExtPermissions::default();
let summary = build_permission_summary(&perms);
for detail in &summary.details {
assert_eq!(
detail.level,
PermissionLevel::None,
"{} should be None",
detail.permission
);
}
}
}
@@ -0,0 +1,641 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use nomifun_api_types::WebSocketMessage;
use nomifun_common::{TimestampMs, now_ms};
use nomifun_realtime::EventBroadcaster;
use serde_json::json;
use tokio::sync::RwLock;
use tracing::{debug, info, warn};
use crate::error::ExtensionError;
use crate::lifecycle::{HookKind, execute_hook, needs_install_hook, resolve_hook_path};
use crate::loader::{ScanPath, resolve_scan_paths};
use crate::registry_helpers::{
build_state_map, load_and_validate, merge_persisted_states, run_deactivation_hooks, to_summary,
};
use crate::resolvers::{resolve_all_contributions, resolve_i18n_for_all};
use crate::state::ExtensionStateStore;
use crate::types::{
ExtensionLifecyclePayload, ExtensionState, ExtensionSystemEvent, LoadedExtension, ResolvedAcpAdapter,
ResolvedAgent, ResolvedAssistant, ResolvedChannelPlugin, ResolvedContributions, ResolvedModelProvider,
ResolvedSettingsTab, ResolvedSkill, ResolvedTheme, WebuiContribution,
};
// Re-export ExtensionSummary from registry_helpers so that
// `registry::{ExtensionRegistry, ExtensionSummary}` continues to work.
pub use crate::registry_helpers::ExtensionSummary;
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
/// Central registry orchestrating extension loading, activation, contribution
/// resolution, and event broadcasting.
///
/// Thread-safe: can be shared across HTTP handlers, the file watcher, and
/// other async tasks via `Arc`.
#[derive(Clone)]
pub struct ExtensionRegistry {
inner: Arc<RwLock<RegistryInner>>,
state_store: ExtensionStateStore,
broadcaster: Arc<dyn EventBroadcaster>,
app_version: String,
}
struct RegistryInner {
extensions: Vec<LoadedExtension>,
contributions: ResolvedContributions,
scan_paths: Vec<ScanPath>,
initialized: bool,
}
// ---------------------------------------------------------------------------
// Construction
// ---------------------------------------------------------------------------
impl ExtensionRegistry {
/// Create a new registry.
///
/// - `state_store`: persists enabled/disabled states across restarts.
/// - `broadcaster`: pushes WebSocket events to connected clients.
/// - `app_version`: current application version for engine compatibility.
pub fn new(state_store: ExtensionStateStore, broadcaster: Arc<dyn EventBroadcaster>, app_version: String) -> Self {
Self {
inner: Arc::new(RwLock::new(RegistryInner {
extensions: Vec::new(),
contributions: ResolvedContributions::default(),
scan_paths: Vec::new(),
initialized: false,
})),
state_store,
broadcaster,
app_version,
}
}
}
// ---------------------------------------------------------------------------
// Initialization
// ---------------------------------------------------------------------------
impl ExtensionRegistry {
/// Run the full initialization pipeline using auto-detected scan paths.
///
/// Resolves scan paths from environment variables and platform defaults,
/// then delegates to [`Self::initialize_with_scan_paths`].
pub async fn initialize(&self) -> Result<(), ExtensionError> {
let scan_paths = resolve_scan_paths();
self.initialize_with_scan_paths(scan_paths).await
}
/// Run the full initialization pipeline with explicit scan paths.
///
/// Prefer this over [`Self::initialize`] when the caller already knows
/// the extension directories (e.g., in tests or embedded deployments).
///
/// Pipeline:
/// 1. Load manifests from all directories
/// 2. Filter by engine compatibility
/// 3. Validate dependencies + topological sort
/// 4. Merge persisted states (enabled/disabled)
/// 5. Run lifecycle hooks (onInstall if needed, then onActivate)
/// 6. Resolve all contributions
/// 7. Persist updated states
pub async fn initialize_with_scan_paths(&self, scan_paths: Vec<ScanPath>) -> Result<(), ExtensionError> {
info!("initializing extension registry");
debug!(count = scan_paths.len(), "resolved scan paths");
// 1-3. Load, filter, validate (all sync/blocking).
let (extensions, dep_result) = load_and_validate(&scan_paths, &self.app_version);
// 4. Merge persisted states.
let persisted = self.state_store.load().await?;
let extensions = merge_persisted_states(extensions, &persisted);
// 5. Run lifecycle hooks.
let extensions = self.run_activation_hooks(extensions, &persisted).await;
// 6. Resolve contributions.
let contributions = resolve_all_contributions(&extensions);
// 7. Persist updated states.
let states = build_state_map(&extensions);
self.state_store.set_all(states).await;
// Commit to inner state.
{
let mut guard = self.inner.write().await;
guard.extensions = extensions;
guard.contributions = contributions;
guard.scan_paths = scan_paths;
guard.initialized = true;
}
if !dep_result.issues.is_empty() {
warn!(issues = dep_result.issues.len(), "dependency validation found issues");
}
info!("extension registry initialized");
Ok(())
}
}
// ---------------------------------------------------------------------------
// Hot reload
// ---------------------------------------------------------------------------
impl ExtensionRegistry {
/// Hot-reload the registry: deactivate all -> clear -> reload -> re-resolve.
///
/// Emits `REGISTRY_RELOADED` event when complete.
pub async fn hot_reload(&self) {
info!("hot-reloading extension registry");
// 1. Snapshot current extensions and scan paths for deactivation.
let (current_exts, scan_paths) = {
let guard = self.inner.read().await;
(guard.extensions.clone(), guard.scan_paths.clone())
};
// 2. Run onDeactivate hooks for each currently active extension.
run_deactivation_hooks(&current_exts).await;
// 3. Reload pipeline (same as initialize but reuses existing scan paths).
let (extensions, _dep_result) = load_and_validate(&scan_paths, &self.app_version);
// Use in-memory state (not file) to preserve pending writes that
// haven't been flushed yet by the debounce timer.
let persisted = self.state_store.get_all().await;
let extensions = merge_persisted_states(extensions, &persisted);
let extensions = self.run_activation_hooks(extensions, &persisted).await;
let contributions = resolve_all_contributions(&extensions);
let states = build_state_map(&extensions);
self.state_store.set_all(states).await;
// 4. Commit new state.
{
let mut guard = self.inner.write().await;
guard.extensions = extensions;
guard.contributions = contributions;
// scan_paths stay the same
}
// 5. Broadcast REGISTRY_RELOADED event.
self.broadcast_lifecycle_event("registry", ExtensionSystemEvent::RegistryReloaded, None);
info!("extension registry hot-reloaded");
}
}
// ---------------------------------------------------------------------------
// Enable / Disable
// ---------------------------------------------------------------------------
impl ExtensionRegistry {
/// Enable an extension by name.
///
/// Updates the in-memory state, re-resolves contributions, persists the
/// change, and broadcasts `extensions.state-changed`.
pub async fn enable_extension(&self, name: &str) -> Result<(), ExtensionError> {
let state = {
let mut guard = self.inner.write().await;
let idx = guard
.extensions
.iter()
.position(|e| e.manifest.name == name)
.ok_or_else(|| ExtensionError::NotFound(name.to_owned()))?;
if guard.extensions[idx].state.enabled {
debug!(name, "extension already enabled");
return Ok(());
}
guard.extensions[idx].state.enabled = true;
guard.extensions[idx].state.last_activated_at = Some(now_ms());
// Re-resolve contributions with updated enabled set.
guard.contributions = resolve_all_contributions(&guard.extensions);
guard.extensions[idx].state.clone()
};
// Persist + broadcast outside the write lock.
self.state_store.set(state).await;
self.broadcast_state_changed(name, true);
info!(name, "extension enabled");
Ok(())
}
/// Disable an extension by name.
///
/// Optionally records a reason (logged for auditing). Updates state,
/// re-resolves contributions, persists, and broadcasts
/// `extensions.state-changed`.
pub async fn disable_extension(&self, name: &str, reason: Option<&str>) -> Result<(), ExtensionError> {
let state = {
let mut guard = self.inner.write().await;
let idx = guard
.extensions
.iter()
.position(|e| e.manifest.name == name)
.ok_or_else(|| ExtensionError::NotFound(name.to_owned()))?;
if !guard.extensions[idx].state.enabled {
debug!(name, "extension already disabled");
return Ok(());
}
guard.extensions[idx].state.enabled = false;
// Re-resolve contributions with updated enabled set.
guard.contributions = resolve_all_contributions(&guard.extensions);
guard.extensions[idx].state.clone()
};
// Persist + broadcast outside the write lock.
self.state_store.set(state).await;
self.broadcast_state_changed(name, false);
if let Some(r) = reason {
info!(name, reason = r, "extension disabled");
} else {
info!(name, "extension disabled");
}
Ok(())
}
}
// ---------------------------------------------------------------------------
// Query methods
// ---------------------------------------------------------------------------
impl ExtensionRegistry {
/// Return summaries of all loaded extensions.
pub async fn get_loaded_extensions(&self) -> Vec<ExtensionSummary> {
let guard = self.inner.read().await;
guard.extensions.iter().map(to_summary).collect()
}
pub(crate) fn event_broadcaster(&self) -> Arc<dyn EventBroadcaster> {
self.broadcaster.clone()
}
/// Look up a single loaded extension by name.
pub async fn get_extension_by_name(&self, name: &str) -> Option<LoadedExtension> {
let guard = self.inner.read().await;
guard.extensions.iter().find(|e| e.manifest.name == name).cloned()
}
/// Snapshot of all resolved contributions.
pub async fn get_contributions(&self) -> ResolvedContributions {
let guard = self.inner.read().await;
guard.contributions.clone()
}
pub async fn get_themes(&self) -> Vec<ResolvedTheme> {
let guard = self.inner.read().await;
guard.contributions.themes.clone()
}
pub async fn get_assistants(&self) -> Vec<ResolvedAssistant> {
let guard = self.inner.read().await;
guard.contributions.assistants.clone()
}
/// Return `true` if any extension contributes an assistant with this id.
pub async fn has_assistant(&self, id: &str) -> bool {
let guard = self.inner.read().await;
guard.contributions.assistants.iter().any(|a| a.id == id)
}
/// Lookup a single extension-contributed assistant by id.
pub async fn get_assistant_by_id(&self, id: &str) -> Option<ResolvedAssistant> {
let guard = self.inner.read().await;
guard.contributions.assistants.iter().find(|a| a.id == id).cloned()
}
pub async fn get_acp_adapters(&self) -> Vec<ResolvedAcpAdapter> {
let guard = self.inner.read().await;
guard.contributions.acp_adapters.clone()
}
pub async fn get_agents(&self) -> Vec<ResolvedAgent> {
let guard = self.inner.read().await;
guard.contributions.agents.clone()
}
pub async fn get_mcp_servers(&self) -> Vec<crate::types::ResolvedMcpServer> {
let guard = self.inner.read().await;
guard.contributions.mcp_servers.clone()
}
pub async fn get_skills(&self) -> Vec<ResolvedSkill> {
let guard = self.inner.read().await;
guard.contributions.skills.clone()
}
pub async fn get_settings_tabs(&self) -> Vec<ResolvedSettingsTab> {
let guard = self.inner.read().await;
guard.contributions.settings_tabs.clone()
}
pub async fn get_webui_contributions(&self) -> Vec<WebuiContribution> {
let guard = self.inner.read().await;
guard.contributions.webui.clone()
}
pub async fn get_channel_plugins(&self) -> Vec<ResolvedChannelPlugin> {
let guard = self.inner.read().await;
guard.contributions.channel_plugins.clone()
}
pub async fn get_model_providers(&self) -> Vec<ResolvedModelProvider> {
let guard = self.inner.read().await;
guard.contributions.model_providers.clone()
}
/// Resolve i18n data for a given locale across all enabled extensions.
pub async fn get_i18n_for_locale(&self, locale: &str) -> HashMap<String, HashMap<String, String>> {
let guard = self.inner.read().await;
resolve_i18n_for_all(&guard.extensions, locale)
}
/// Whether the registry has been initialized.
pub async fn is_initialized(&self) -> bool {
let guard = self.inner.read().await;
guard.initialized
}
}
// ---------------------------------------------------------------------------
// Event broadcasting helpers
// ---------------------------------------------------------------------------
impl ExtensionRegistry {
fn broadcast_state_changed(&self, name: &str, enabled: bool) {
let event = WebSocketMessage::new("extensions.state-changed", json!({ "name": name, "enabled": enabled }));
self.broadcaster.broadcast(event);
}
fn broadcast_lifecycle_event(
&self,
extension_name: &str,
event: ExtensionSystemEvent,
data: Option<serde_json::Value>,
) {
let payload = ExtensionLifecyclePayload {
extension_name: extension_name.to_owned(),
event,
timestamp: now_ms(),
data,
};
let msg = WebSocketMessage::new(
"extensions.lifecycle",
serde_json::to_value(&payload).unwrap_or_default(),
);
self.broadcaster.broadcast(msg);
}
}
// ---------------------------------------------------------------------------
// Activation hooks
// ---------------------------------------------------------------------------
impl ExtensionRegistry {
/// Run lifecycle hooks for each extension in order:
/// - `onInstall` if first time or version changed
/// - `onActivate` for each enabled extension
///
/// Hook failures are logged but do not prevent other extensions from
/// activating.
async fn run_activation_hooks(
&self,
mut extensions: Vec<LoadedExtension>,
persisted: &HashMap<String, ExtensionState>,
) -> Vec<LoadedExtension> {
let now: TimestampMs = now_ms();
for ext in &mut extensions {
if !ext.state.enabled {
continue;
}
let ext_name = ext.manifest.name.clone();
let ext_dir = Path::new(&ext.directory);
// Check onInstall + onActivate hooks.
if let Some(hooks) = &ext.manifest.lifecycle {
let persisted_version = persisted.get(&ext_name).map(|s| s.version.as_str());
if needs_install_hook(&ext.manifest.version, persisted_version)
&& let Some(hook_path) = resolve_hook_path(hooks, HookKind::OnInstall)
&& let Err(e) = execute_hook(ext_dir, hook_path, HookKind::OnInstall, &ext_name).await
{
warn!(
extension = %ext_name,
error = %e,
"onInstall hook failed, continuing"
);
}
// Run onActivate
if let Some(hook_path) = resolve_hook_path(hooks, HookKind::OnActivate)
&& let Err(e) = execute_hook(ext_dir, hook_path, HookKind::OnActivate, &ext_name).await
{
warn!(
extension = %ext_name,
error = %e,
"onActivate hook failed, continuing"
);
}
}
// Update activation timestamp and install time.
ext.state.last_activated_at = Some(now);
if ext.state.installed_at.is_none() {
ext.state.installed_at = Some(now);
}
self.broadcast_lifecycle_event(&ext_name, ExtensionSystemEvent::ExtensionActivated, None);
}
extensions
}
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ExtensionManifest, ExtensionSource, ExtensionState};
use nomifun_realtime::BroadcastEventBus;
fn make_test_ext(name: &str, enabled: bool) -> LoadedExtension {
LoadedExtension {
manifest: ExtensionManifest {
name: name.to_owned(),
version: "1.0.0".to_owned(),
display_name: Some(format!("{name} Display")),
description: Some(format!("{name} description")),
author: None,
license: None,
homepage: None,
icon: None,
engine: None,
api_version: None,
dependencies: HashMap::new(),
entry_point: None,
permissions: None,
contributes: None,
lifecycle: None,
i18n: None,
},
directory: format!("/tmp/ext/{name}"),
source: ExtensionSource::Local,
state: ExtensionState {
name: name.to_owned(),
version: "1.0.0".to_owned(),
enabled,
installed_at: Some(1000),
last_activated_at: None,
},
}
}
fn make_registry() -> (ExtensionRegistry, ExtensionStateStore, Arc<BroadcastEventBus>) {
let tmp = tempfile::TempDir::new().unwrap();
let store = ExtensionStateStore::new(tmp.path().join("states.json"));
let bus = Arc::new(BroadcastEventBus::new(64));
let registry = ExtensionRegistry::new(store.clone(), bus.clone(), "1.0.0".to_owned());
(registry, store, bus)
}
// -- enable_extension / disable_extension -----------------------------------
#[tokio::test]
async fn enable_nonexistent_returns_not_found() {
let (registry, _, _) = make_registry();
let result = registry.enable_extension("no-such-ext").await;
assert!(matches!(result, Err(ExtensionError::NotFound(_))));
}
#[tokio::test]
async fn disable_nonexistent_returns_not_found() {
let (registry, _, _) = make_registry();
let result = registry.disable_extension("no-such-ext", None).await;
assert!(matches!(result, Err(ExtensionError::NotFound(_))));
}
#[tokio::test]
async fn enable_disable_roundtrip() {
let (registry, _, bus) = make_registry();
// Seed the registry with a disabled extension.
{
let mut guard = registry.inner.write().await;
guard.extensions = vec![make_test_ext("test-ext", false)];
guard.initialized = true;
}
let mut rx = bus.subscribe();
// Enable
registry.enable_extension("test-ext").await.unwrap();
{
let guard = registry.inner.read().await;
assert!(guard.extensions[0].state.enabled);
}
let msg = rx.recv().await.unwrap();
assert_eq!(msg.name, "extensions.state-changed");
assert_eq!(msg.data["enabled"], true);
// Disable
registry
.disable_extension("test-ext", Some("test reason"))
.await
.unwrap();
{
let guard = registry.inner.read().await;
assert!(!guard.extensions[0].state.enabled);
}
let msg = rx.recv().await.unwrap();
assert_eq!(msg.name, "extensions.state-changed");
assert_eq!(msg.data["enabled"], false);
}
#[tokio::test]
async fn enable_already_enabled_is_noop() {
let (registry, _, _) = make_registry();
{
let mut guard = registry.inner.write().await;
guard.extensions = vec![make_test_ext("ext", true)];
}
// Should succeed without error.
registry.enable_extension("ext").await.unwrap();
}
#[tokio::test]
async fn disable_already_disabled_is_noop() {
let (registry, _, _) = make_registry();
{
let mut guard = registry.inner.write().await;
guard.extensions = vec![make_test_ext("ext", false)];
}
registry.disable_extension("ext", None).await.unwrap();
}
// -- query methods --------------------------------------------------------
#[tokio::test]
async fn get_loaded_extensions_returns_summaries() {
let (registry, _, _) = make_registry();
{
let mut guard = registry.inner.write().await;
guard.extensions = vec![make_test_ext("ext-a", true), make_test_ext("ext-b", false)];
}
let summaries = registry.get_loaded_extensions().await;
assert_eq!(summaries.len(), 2);
assert_eq!(summaries[0].name, "ext-a");
assert!(summaries[0].enabled);
assert_eq!(summaries[1].name, "ext-b");
assert!(!summaries[1].enabled);
}
#[tokio::test]
async fn get_extension_by_name_found_and_not_found() {
let (registry, _, _) = make_registry();
{
let mut guard = registry.inner.write().await;
guard.extensions = vec![make_test_ext("my-ext", true)];
}
assert!(registry.get_extension_by_name("my-ext").await.is_some());
assert!(registry.get_extension_by_name("nope").await.is_none());
}
#[tokio::test]
async fn is_initialized_before_and_after() {
let (registry, _, _) = make_registry();
assert!(!registry.is_initialized().await);
{
let mut guard = registry.inner.write().await;
guard.initialized = true;
}
assert!(registry.is_initialized().await);
}
}
@@ -0,0 +1,283 @@
use std::collections::HashMap;
use std::path::Path;
use tracing::{debug, warn};
use crate::dependency::{DependencyValidationResult, validate_dependencies};
use crate::lifecycle::{HookKind, execute_hook, resolve_hook_path};
use crate::loader::{ScanPath, filter_by_engine_compatibility, load_all};
use crate::types::{ExtensionSource, ExtensionState, LoadedExtension};
// ---------------------------------------------------------------------------
// ExtensionSummary
// ---------------------------------------------------------------------------
/// Lightweight summary of a loaded extension.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, PartialEq)]
pub struct ExtensionSummary {
pub name: String,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub enabled: bool,
pub source: ExtensionSource,
}
pub(crate) fn to_summary(ext: &LoadedExtension) -> ExtensionSummary {
ExtensionSummary {
name: ext.manifest.name.clone(),
version: ext.manifest.version.clone(),
display_name: ext.manifest.display_name.clone(),
description: ext.manifest.description.clone(),
enabled: ext.state.enabled,
source: ext.source,
}
}
// ---------------------------------------------------------------------------
// Load + validate pipeline
// ---------------------------------------------------------------------------
/// Load extensions, filter by engine compatibility, validate dependencies, and
/// sort by topological order. Returns the sorted extensions and the validation
/// result.
pub(crate) fn load_and_validate(
scan_paths: &[ScanPath],
app_version: &str,
) -> (Vec<LoadedExtension>, DependencyValidationResult) {
let loaded = load_all(scan_paths);
debug!(count = loaded.len(), "loaded extension manifests");
let filtered = filter_by_engine_compatibility(loaded, app_version);
debug!(count = filtered.len(), "after engine compatibility filter");
let dep_result = validate_dependencies(&filtered);
let sorted = sort_by_load_order(filtered, &dep_result.load_order);
debug!(count = sorted.len(), "after dependency sort");
(sorted, dep_result)
}
// ---------------------------------------------------------------------------
// Sorting
// ---------------------------------------------------------------------------
/// Reorder extensions according to the given load order.
///
/// Extensions not in `load_order` are appended at the end in alphabetical
/// order.
pub(crate) fn sort_by_load_order(extensions: Vec<LoadedExtension>, load_order: &[String]) -> Vec<LoadedExtension> {
let mut by_name: HashMap<String, LoadedExtension> =
extensions.into_iter().map(|e| (e.manifest.name.clone(), e)).collect();
let mut sorted = Vec::with_capacity(by_name.len());
// First, add extensions in load_order.
for name in load_order {
if let Some(ext) = by_name.remove(name) {
sorted.push(ext);
}
}
// Append any remaining (not in load_order) in alphabetical order.
let mut remaining: Vec<LoadedExtension> = by_name.into_values().collect();
remaining.sort_by(|a, b| a.manifest.name.cmp(&b.manifest.name));
sorted.extend(remaining);
sorted
}
// ---------------------------------------------------------------------------
// State merging + building
// ---------------------------------------------------------------------------
/// Merge persisted enabled/disabled states into freshly loaded extensions.
///
/// If no persisted state exists for an extension, it defaults to enabled.
pub(crate) fn merge_persisted_states(
mut extensions: Vec<LoadedExtension>,
persisted: &HashMap<String, ExtensionState>,
) -> Vec<LoadedExtension> {
for ext in &mut extensions {
if let Some(saved) = persisted.get(&ext.manifest.name) {
ext.state.enabled = saved.enabled;
ext.state.installed_at = saved.installed_at;
ext.state.last_activated_at = saved.last_activated_at;
}
}
extensions
}
/// Build a state map from the current extensions for persistence.
pub(crate) fn build_state_map(extensions: &[LoadedExtension]) -> HashMap<String, ExtensionState> {
extensions
.iter()
.map(|e| (e.state.name.clone(), e.state.clone()))
.collect()
}
// ---------------------------------------------------------------------------
// Deactivation hooks
// ---------------------------------------------------------------------------
/// Run `onDeactivate` hooks for all enabled extensions.
///
/// Errors are logged but do not propagate.
pub(crate) async fn run_deactivation_hooks(extensions: &[LoadedExtension]) {
for ext in extensions {
if !ext.state.enabled {
continue;
}
let Some(hooks) = &ext.manifest.lifecycle else {
continue;
};
let Some(hook_path) = resolve_hook_path(hooks, HookKind::OnDeactivate) else {
continue;
};
let ext_dir = Path::new(&ext.directory);
if let Err(e) = execute_hook(ext_dir, hook_path, HookKind::OnDeactivate, &ext.manifest.name).await {
warn!(
extension = %ext.manifest.name,
error = %e,
"onDeactivate hook failed during hot reload"
);
}
}
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{ExtensionManifest, ExtensionSource, ExtensionState};
fn make_test_ext(name: &str, enabled: bool) -> LoadedExtension {
LoadedExtension {
manifest: ExtensionManifest {
name: name.to_owned(),
version: "1.0.0".to_owned(),
display_name: Some(format!("{name} Display")),
description: Some(format!("{name} description")),
author: None,
license: None,
homepage: None,
icon: None,
engine: None,
api_version: None,
dependencies: HashMap::new(),
entry_point: None,
permissions: None,
contributes: None,
lifecycle: None,
i18n: None,
},
directory: format!("/tmp/ext/{name}"),
source: ExtensionSource::Local,
state: ExtensionState {
name: name.to_owned(),
version: "1.0.0".to_owned(),
enabled,
installed_at: Some(1000),
last_activated_at: None,
},
}
}
// -- sort_by_load_order ---------------------------------------------------
#[test]
fn sort_respects_load_order() {
let exts = vec![
make_test_ext("ext-c", true),
make_test_ext("ext-a", true),
make_test_ext("ext-b", true),
];
let order = vec!["ext-a".to_owned(), "ext-b".to_owned(), "ext-c".to_owned()];
let sorted = sort_by_load_order(exts, &order);
let names: Vec<&str> = sorted.iter().map(|e| e.manifest.name.as_str()).collect();
assert_eq!(names, vec!["ext-a", "ext-b", "ext-c"]);
}
#[test]
fn sort_appends_unordered_extensions() {
let exts = vec![
make_test_ext("ext-z", true),
make_test_ext("ext-a", true),
make_test_ext("ext-m", true),
];
// Only ext-a is in load order
let order = vec!["ext-a".to_owned()];
let sorted = sort_by_load_order(exts, &order);
let names: Vec<&str> = sorted.iter().map(|e| e.manifest.name.as_str()).collect();
assert_eq!(names, vec!["ext-a", "ext-m", "ext-z"]);
}
#[test]
fn sort_empty_load_order() {
let exts = vec![make_test_ext("ext-b", true), make_test_ext("ext-a", true)];
let sorted = sort_by_load_order(exts, &[]);
let names: Vec<&str> = sorted.iter().map(|e| e.manifest.name.as_str()).collect();
assert_eq!(names, vec!["ext-a", "ext-b"]);
}
// -- merge_persisted_states ------------------------------------------------
#[test]
fn merge_applies_persisted_enabled() {
let exts = vec![make_test_ext("ext-a", true)];
let mut persisted = HashMap::new();
persisted.insert(
"ext-a".to_owned(),
ExtensionState {
name: "ext-a".to_owned(),
version: "1.0.0".to_owned(),
enabled: false,
installed_at: Some(500),
last_activated_at: Some(600),
},
);
let merged = merge_persisted_states(exts, &persisted);
assert!(!merged[0].state.enabled);
assert_eq!(merged[0].state.installed_at, Some(500));
assert_eq!(merged[0].state.last_activated_at, Some(600));
}
#[test]
fn merge_defaults_to_enabled_when_no_persisted() {
let exts = vec![make_test_ext("ext-a", true)];
let merged = merge_persisted_states(exts, &HashMap::new());
assert!(merged[0].state.enabled);
}
// -- build_state_map ------------------------------------------------------
#[test]
fn build_state_map_includes_all_extensions() {
let exts = vec![make_test_ext("ext-a", true), make_test_ext("ext-b", false)];
let map = build_state_map(&exts);
assert_eq!(map.len(), 2);
assert!(map["ext-a"].enabled);
assert!(!map["ext-b"].enabled);
}
// -- to_summary -----------------------------------------------------------
#[test]
fn summary_maps_fields_correctly() {
let ext = make_test_ext("my-ext", true);
let summary = to_summary(&ext);
assert_eq!(summary.name, "my-ext");
assert_eq!(summary.version, "1.0.0");
assert_eq!(summary.display_name.as_deref(), Some("my-ext Display"));
assert!(summary.enabled);
assert_eq!(summary.source, ExtensionSource::Local);
}
}
@@ -0,0 +1,149 @@
use std::path::Path;
use tracing::warn;
use crate::asset_paths::resolve_extension_asset_url;
use crate::error::ExtensionError;
use crate::template::resolve_env_map;
use crate::types::{ExtAcpAdapter, ResolvedAcpAdapter};
/// Resolve a single ACP adapter contribution.
///
/// Env template placeholders (`${VAR}`) in the `env` map are expanded.
/// Avatar paths are resolved relative to the extension directory.
pub fn resolve_acp_adapter(
adapter: &ExtAcpAdapter,
extension_name: &str,
_ext_dir: &Path,
) -> Result<ResolvedAcpAdapter, ExtensionError> {
let resolved_env = resolve_env_map(&adapter.env, false)?;
let avatar = adapter
.avatar
.as_ref()
.and_then(|a| resolve_extension_asset_url(extension_name, a));
Ok(ResolvedAcpAdapter {
extension_name: extension_name.to_owned(),
id: adapter.id.clone(),
name: adapter.name.clone(),
description: adapter.description.clone(),
cli_command: adapter.cli_command.clone(),
default_cli_path: adapter.default_cli_path.clone(),
acp_args: adapter.acp_args.clone(),
env: resolved_env,
avatar,
auth_required: adapter.auth_required,
supports_streaming: adapter.supports_streaming,
connection_type: adapter.connection_type.clone(),
endpoint: adapter.endpoint.clone(),
models: adapter.models.clone(),
yolo_mode: adapter.yolo_mode.clone(),
health_check: adapter.health_check.clone(),
api_key_fields: adapter.api_key_fields.clone(),
})
}
/// Resolve all ACP adapter contributions from an extension.
pub fn resolve_acp_adapters(
adapters: &[ExtAcpAdapter],
extension_name: &str,
ext_dir: &Path,
) -> Vec<ResolvedAcpAdapter> {
adapters
.iter()
.filter_map(|a| {
resolve_acp_adapter(a, extension_name, ext_dir)
.map_err(|e| {
warn!(
extension = extension_name,
adapter_id = a.id,
"Failed to resolve ACP adapter: {e}"
);
e
})
.ok()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn make_adapter(env: HashMap<String, String>) -> ExtAcpAdapter {
ExtAcpAdapter {
id: "test-adapter".into(),
name: "Test Adapter".into(),
description: Some("A test adapter".into()),
cli_command: Some("test-cli".into()),
default_cli_path: None,
acp_args: vec!["--verbose".into()],
env,
avatar: Some("icons/avatar.png".into()),
auth_required: Some(true),
supports_streaming: Some(true),
connection_type: Some("stdio".into()),
endpoint: None,
models: vec!["model-a".into()],
yolo_mode: None,
health_check: None,
api_key_fields: vec![],
}
}
#[test]
fn test_resolve_basic_adapter() {
let adapter = make_adapter(HashMap::new());
let result = resolve_acp_adapter(&adapter, "my-ext", Path::new("/ext/my-ext")).unwrap();
assert_eq!(result.extension_name, "my-ext");
assert_eq!(result.id, "test-adapter");
assert_eq!(result.name, "Test Adapter");
assert_eq!(result.cli_command.as_deref(), Some("test-cli"));
assert_eq!(result.acp_args, vec!["--verbose"]);
assert!(result.avatar.as_ref().unwrap().contains("icons/avatar.png"));
}
#[test]
fn test_resolve_adapter_env_templates() {
unsafe { std::env::set_var("_TEST_ACP_KEY", "secret123") };
let mut env = HashMap::new();
env.insert("API_KEY".into(), "${_TEST_ACP_KEY}".into());
env.insert("STATIC".into(), "fixed".into());
let adapter = make_adapter(env);
let result = resolve_acp_adapter(&adapter, "my-ext", Path::new("/ext/my-ext")).unwrap();
assert_eq!(result.env["API_KEY"], "secret123");
assert_eq!(result.env["STATIC"], "fixed");
unsafe { std::env::remove_var("_TEST_ACP_KEY") };
}
#[test]
fn test_resolve_adapter_undefined_env_lenient() {
let mut env = HashMap::new();
env.insert("KEY".into(), "${_NONEXISTENT_ACP_VAR}".into());
let adapter = make_adapter(env);
let result = resolve_acp_adapter(&adapter, "my-ext", Path::new("/ext/my-ext")).unwrap();
assert_eq!(result.env["KEY"], "");
}
#[test]
fn test_resolve_adapters_skips_failures() {
// With an empty list, we get an empty result.
let result = resolve_acp_adapters(&[], "my-ext", Path::new("/ext/my-ext"));
assert!(result.is_empty());
}
#[test]
fn test_resolve_adapter_no_avatar() {
let mut adapter = make_adapter(HashMap::new());
adapter.avatar = None;
let result = resolve_acp_adapter(&adapter, "my-ext", Path::new("/ext/my-ext")).unwrap();
assert!(result.avatar.is_none());
}
}
@@ -0,0 +1,143 @@
use std::path::Path;
use tracing::warn;
use crate::asset_paths::resolve_extension_asset_url;
use crate::error::ExtensionError;
use crate::template::resolve_file_reference;
use crate::types::{ExtAgent, ResolvedAgent};
/// Resolve a single agent contribution.
///
/// The `context` field supports `@file:` references.
pub fn resolve_agent(agent: &ExtAgent, extension_name: &str, ext_dir: &Path) -> Result<ResolvedAgent, ExtensionError> {
let context = agent
.context
.as_deref()
.map(|v| resolve_file_reference(v, ext_dir))
.transpose()?;
let icon = agent
.icon
.as_deref()
.and_then(|value| resolve_extension_asset_url(extension_name, value));
Ok(ResolvedAgent {
extension_name: extension_name.to_owned(),
id: agent.id.clone(),
name: agent.name.clone(),
description: agent.description.clone(),
agent_type: agent.agent_type.clone(),
context,
icon,
enabled_skills: agent.enabled_skills.clone(),
prompts: agent.prompts.clone(),
models: agent.models.clone(),
})
}
/// Resolve all agent contributions from an extension.
pub fn resolve_agents(agents: &[ExtAgent], extension_name: &str, ext_dir: &Path) -> Vec<ResolvedAgent> {
agents
.iter()
.filter_map(|a| {
resolve_agent(a, extension_name, ext_dir)
.map_err(|e| {
warn!(
extension = extension_name,
agent_id = a.id,
"Failed to resolve agent: {e}"
);
e
})
.ok()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_agent_plain_text() {
let agent = ExtAgent {
id: "agent-1".into(),
name: "My Agent".into(),
description: Some("Autonomous agent".into()),
agent_type: Some("claude".into()),
context: Some("You are an agent.".into()),
icon: None,
enabled_skills: vec![],
prompts: vec![],
models: vec![],
};
let result = resolve_agent(&agent, "my-ext", Path::new("/ext/my-ext")).unwrap();
assert_eq!(result.extension_name, "my-ext");
assert_eq!(result.id, "agent-1");
assert_eq!(result.agent_type.as_deref(), Some("claude"));
assert_eq!(result.context.as_deref(), Some("You are an agent."));
}
#[test]
fn test_resolve_agent_file_reference() {
let dir = std::env::temp_dir().join("ext_test_resolve_agent");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("agent_ctx.md"), "Agent context from file").unwrap();
let agent = ExtAgent {
id: "agent-2".into(),
name: "File Agent".into(),
description: None,
agent_type: None,
context: Some("@file:agent_ctx.md".into()),
icon: None,
enabled_skills: vec![],
prompts: vec![],
models: vec![],
};
let result = resolve_agent(&agent, "my-ext", &dir).unwrap();
assert_eq!(result.context.as_deref(), Some("Agent context from file"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn test_resolve_agent_missing_file_error() {
let agent = ExtAgent {
id: "agent-3".into(),
name: "Bad Agent".into(),
description: None,
agent_type: None,
context: Some("@file:missing.md".into()),
icon: None,
enabled_skills: vec![],
prompts: vec![],
models: vec![],
};
let err = resolve_agent(&agent, "my-ext", Path::new("/tmp/no_such_ext_dir")).unwrap_err();
assert!(matches!(err, ExtensionError::FileReferenceNotFound(_)));
}
#[test]
fn test_resolve_agent_no_context() {
let agent = ExtAgent {
id: "agent-4".into(),
name: "No Context".into(),
description: None,
agent_type: None,
context: None,
icon: None,
enabled_skills: vec![],
prompts: vec![],
models: vec![],
};
let result = resolve_agent(&agent, "my-ext", Path::new("/ext/my-ext")).unwrap();
assert!(result.context.is_none());
}
}
@@ -0,0 +1,200 @@
use std::path::Path;
use tracing::warn;
use crate::asset_paths::resolve_extension_asset_url;
use crate::error::ExtensionError;
use crate::template::resolve_file_reference;
use crate::types::{ExtAssistant, ResolvedAssistant};
/// Resolve a single assistant contribution.
///
/// Long-text fields (`system_prompt`, `context`) support `@file:` references
/// that are replaced with the referenced file's content.
pub fn resolve_assistant(
assistant: &ExtAssistant,
extension_name: &str,
ext_dir: &Path,
) -> Result<ResolvedAssistant, ExtensionError> {
let system_prompt = assistant
.system_prompt
.as_deref()
.map(|v| resolve_file_reference(v, ext_dir))
.transpose()?;
let context = assistant
.context
.as_deref()
.map(|v| resolve_file_reference(v, ext_dir))
.transpose()?;
let icon = assistant
.icon
.as_deref()
.and_then(|value| resolve_extension_asset_url(extension_name, value));
Ok(ResolvedAssistant {
extension_name: extension_name.to_owned(),
id: assistant.id.clone(),
name: assistant.name.clone(),
description: assistant.description.clone(),
system_prompt,
icon,
context,
preset_agent_type: assistant.preset_agent_type.clone(),
enabled_skills: assistant.enabled_skills.clone(),
prompts: assistant.prompts.clone(),
models: assistant.models.clone(),
})
}
/// Resolve all assistant contributions from an extension.
pub fn resolve_assistants(assistants: &[ExtAssistant], extension_name: &str, ext_dir: &Path) -> Vec<ResolvedAssistant> {
assistants
.iter()
.filter_map(|a| {
resolve_assistant(a, extension_name, ext_dir)
.map_err(|e| {
warn!(
extension = extension_name,
assistant_id = a.id,
"Failed to resolve assistant: {e}"
);
e
})
.ok()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_assistant_plain_text() {
let assistant = ExtAssistant {
id: "asst-1".into(),
name: "Helper".into(),
description: Some("A helpful assistant".into()),
system_prompt: Some("You are helpful.".into()),
icon: None,
context: None,
preset_agent_type: None,
enabled_skills: vec![],
prompts: vec![],
models: vec![],
};
let result = resolve_assistant(&assistant, "my-ext", Path::new("/ext/my-ext")).unwrap();
assert_eq!(result.extension_name, "my-ext");
assert_eq!(result.id, "asst-1");
assert_eq!(result.system_prompt.as_deref(), Some("You are helpful."));
}
#[test]
fn test_resolve_assistant_file_reference() {
let dir = std::env::temp_dir().join("ext_test_resolve_assistant");
let prompts = dir.join("prompts");
std::fs::create_dir_all(&prompts).unwrap();
std::fs::write(prompts.join("system.md"), "Loaded from file").unwrap();
let assistant = ExtAssistant {
id: "asst-2".into(),
name: "File Ref".into(),
description: None,
system_prompt: Some("@file:prompts/system.md".into()),
icon: None,
context: None,
preset_agent_type: None,
enabled_skills: vec![],
prompts: vec![],
models: vec![],
};
let result = resolve_assistant(&assistant, "my-ext", &dir).unwrap();
assert_eq!(result.system_prompt.as_deref(), Some("Loaded from file"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn test_resolve_assistant_file_not_found_error() {
let assistant = ExtAssistant {
id: "asst-3".into(),
name: "Bad Ref".into(),
description: None,
system_prompt: Some("@file:missing.md".into()),
icon: None,
context: None,
preset_agent_type: None,
enabled_skills: vec![],
prompts: vec![],
models: vec![],
};
let err = resolve_assistant(&assistant, "my-ext", Path::new("/tmp/no_such_ext_dir")).unwrap_err();
assert!(matches!(err, ExtensionError::FileReferenceNotFound(_)));
}
#[test]
fn test_resolve_assistant_context_file_reference() {
let dir = std::env::temp_dir().join("ext_test_resolve_assistant_ctx");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("context.md"), "Context content").unwrap();
let assistant = ExtAssistant {
id: "asst-4".into(),
name: "Ctx Ref".into(),
description: None,
system_prompt: None,
icon: None,
context: Some("@file:context.md".into()),
preset_agent_type: None,
enabled_skills: vec![],
prompts: vec![],
models: vec![],
};
let result = resolve_assistant(&assistant, "my-ext", &dir).unwrap();
assert_eq!(result.context.as_deref(), Some("Context content"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn test_resolve_assistants_skips_bad_refs() {
let assistants = vec![
ExtAssistant {
id: "good".into(),
name: "Good".into(),
description: None,
system_prompt: Some("plain text".into()),
icon: None,
context: None,
preset_agent_type: None,
enabled_skills: vec![],
prompts: vec![],
models: vec![],
},
ExtAssistant {
id: "bad".into(),
name: "Bad".into(),
description: None,
system_prompt: Some("@file:missing.md".into()),
icon: None,
context: None,
preset_agent_type: None,
enabled_skills: vec![],
prompts: vec![],
models: vec![],
},
];
let result = resolve_assistants(&assistants, "my-ext", Path::new("/tmp/no_such_ext"));
// Only the good one should be resolved
assert_eq!(result.len(), 1);
assert_eq!(result[0].id, "good");
}
}
@@ -0,0 +1,96 @@
use std::path::Path;
use crate::asset_paths::resolve_extension_asset_url;
use crate::types::{ExtChannelPlugin, ResolvedChannelPlugin};
/// Resolve a single channel plugin contribution.
///
/// Entry point paths are resolved relative to the extension directory.
/// Note: actual plugin code execution must go through the sandbox (not direct eval).
pub fn resolve_channel_plugin(
plugin: &ExtChannelPlugin,
extension_name: &str,
ext_dir: &Path,
) -> ResolvedChannelPlugin {
let entry_point = plugin
.entry_point
.as_ref()
.map(|ep| ext_dir.join(ep).to_string_lossy().into_owned());
let icon = plugin
.icon
.as_deref()
.and_then(|value| resolve_extension_asset_url(extension_name, value));
ResolvedChannelPlugin {
extension_name: extension_name.to_owned(),
id: plugin.id.clone(),
name: plugin.name.clone(),
description: plugin.description.clone(),
platform: plugin.platform.clone(),
entry_point,
icon,
credential_fields: plugin.credential_fields.clone(),
config_fields: plugin.config_fields.clone(),
}
}
/// Resolve all channel plugin contributions from an extension.
pub fn resolve_channel_plugins(
plugins: &[ExtChannelPlugin],
extension_name: &str,
ext_dir: &Path,
) -> Vec<ResolvedChannelPlugin> {
plugins
.iter()
.map(|p| resolve_channel_plugin(p, extension_name, ext_dir))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_channel_plugin_with_entry() {
let plugin = ExtChannelPlugin {
id: "slack-plugin".into(),
name: "Slack".into(),
description: Some("Slack integration".into()),
platform: Some("slack".into()),
entry_point: Some("plugins/slack.js".into()),
icon: None,
credential_fields: vec![],
config_fields: vec![],
};
let result = resolve_channel_plugin(&plugin, "my-ext", Path::new("/ext/my-ext"));
assert_eq!(result.extension_name, "my-ext");
assert_eq!(result.id, "slack-plugin");
assert_eq!(result.platform.as_deref(), Some("slack"));
assert!(result.entry_point.as_ref().unwrap().contains("plugins/slack.js"));
}
#[test]
fn test_resolve_channel_plugin_no_entry() {
let plugin = ExtChannelPlugin {
id: "simple".into(),
name: "Simple".into(),
description: None,
platform: None,
entry_point: None,
icon: None,
credential_fields: vec![],
config_fields: vec![],
};
let result = resolve_channel_plugin(&plugin, "my-ext", Path::new("/ext/my-ext"));
assert!(result.entry_point.is_none());
}
#[test]
fn test_resolve_channel_plugins_empty() {
let result = resolve_channel_plugins(&[], "my-ext", Path::new("/ext/my-ext"));
assert!(result.is_empty());
}
}
@@ -0,0 +1,180 @@
use std::collections::HashMap;
use std::path::Path;
use tracing::warn;
use crate::error::ExtensionError;
use crate::types::I18nConfig;
/// Load i18n messages for a specific locale from an extension.
///
/// Looks for `{ext_dir}/{directory}/{locale}.json` where `directory` defaults to "i18n".
/// Returns a flat key-value map of message strings.
pub fn load_extension_i18n(
i18n_config: &I18nConfig,
locale: &str,
extension_name: &str,
ext_dir: &Path,
) -> Result<HashMap<String, String>, ExtensionError> {
if !i18n_config.locales.contains(&locale.to_owned()) {
return Ok(HashMap::new());
}
let i18n_dir = ext_dir.join(&i18n_config.directory);
let file_path = i18n_dir.join(format!("{locale}.json"));
if !file_path.exists() {
tracing::debug!(
extension = extension_name,
locale = locale,
path = %file_path.display(),
"i18n file not found, returning empty map"
);
return Ok(HashMap::new());
}
let content = std::fs::read_to_string(&file_path)?;
let messages: HashMap<String, String> =
serde_json::from_str(&content).map_err(|e| ExtensionError::ResolutionFailed {
extension_name: extension_name.to_owned(),
reason: format!("Invalid i18n JSON for locale '{locale}': {e}"),
})?;
Ok(messages)
}
/// Load i18n data for a given locale across multiple extensions.
///
/// Returns `HashMap<extension_name, HashMap<key, value>>`.
pub fn resolve_i18n_for_locale(
extensions: &[(String, Option<I18nConfig>, String)], // (name, i18n_config, ext_dir)
locale: &str,
) -> HashMap<String, HashMap<String, String>> {
let mut result = HashMap::new();
for (name, i18n_config, ext_dir) in extensions {
let Some(config) = i18n_config else {
continue;
};
match load_extension_i18n(config, locale, name, Path::new(ext_dir)) {
Ok(messages) if !messages.is_empty() => {
result.insert(name.clone(), messages);
}
Ok(_) => {}
Err(e) => {
warn!(
extension = name.as_str(),
locale = locale,
"Failed to load i18n data: {e}"
);
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
fn make_i18n_config(locales: Vec<&str>) -> I18nConfig {
I18nConfig {
locales: locales.into_iter().map(String::from).collect(),
directory: "i18n".to_owned(),
}
}
#[test]
fn test_load_i18n_supported_locale() {
let dir = std::env::temp_dir().join("ext_test_i18n_load");
let i18n_dir = dir.join("i18n");
std::fs::create_dir_all(&i18n_dir).unwrap();
std::fs::write(
i18n_dir.join("en.json"),
r#"{"greeting": "Hello", "farewell": "Goodbye"}"#,
)
.unwrap();
let config = make_i18n_config(vec!["en", "zh-CN"]);
let result = load_extension_i18n(&config, "en", "my-ext", &dir).unwrap();
assert_eq!(result.len(), 2);
assert_eq!(result["greeting"], "Hello");
assert_eq!(result["farewell"], "Goodbye");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn test_load_i18n_unsupported_locale_returns_empty() {
let config = make_i18n_config(vec!["en"]);
let result = load_extension_i18n(&config, "fr", "my-ext", Path::new("/tmp")).unwrap();
assert!(result.is_empty());
}
#[test]
fn test_load_i18n_file_not_found_returns_empty() {
let dir = std::env::temp_dir().join("ext_test_i18n_missing");
std::fs::create_dir_all(&dir).unwrap();
let config = make_i18n_config(vec!["en"]);
let result = load_extension_i18n(&config, "en", "my-ext", &dir).unwrap();
assert!(result.is_empty());
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn test_load_i18n_invalid_json_returns_error() {
let dir = std::env::temp_dir().join("ext_test_i18n_bad_json");
let i18n_dir = dir.join("i18n");
std::fs::create_dir_all(&i18n_dir).unwrap();
std::fs::write(i18n_dir.join("en.json"), "not valid json").unwrap();
let config = make_i18n_config(vec!["en"]);
let err = load_extension_i18n(&config, "en", "my-ext", &dir).unwrap_err();
assert!(matches!(err, ExtensionError::ResolutionFailed { .. }));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn test_resolve_i18n_for_locale_multiple_extensions() {
let dir1 = std::env::temp_dir().join("ext_test_i18n_multi_1");
let dir2 = std::env::temp_dir().join("ext_test_i18n_multi_2");
let i18n1 = dir1.join("i18n");
let i18n2 = dir2.join("i18n");
std::fs::create_dir_all(&i18n1).unwrap();
std::fs::create_dir_all(&i18n2).unwrap();
std::fs::write(i18n1.join("en.json"), r#"{"key1": "val1"}"#).unwrap();
std::fs::write(i18n2.join("en.json"), r#"{"key2": "val2"}"#).unwrap();
let extensions = vec![
(
"ext-a".to_owned(),
Some(make_i18n_config(vec!["en"])),
dir1.to_string_lossy().into_owned(),
),
(
"ext-b".to_owned(),
Some(make_i18n_config(vec!["en"])),
dir2.to_string_lossy().into_owned(),
),
(
"ext-c".to_owned(),
None, // no i18n config
"/tmp".to_owned(),
),
];
let result = resolve_i18n_for_locale(&extensions, "en");
assert_eq!(result.len(), 2);
assert_eq!(result["ext-a"]["key1"], "val1");
assert_eq!(result["ext-b"]["key2"], "val2");
std::fs::remove_dir_all(&dir1).unwrap();
std::fs::remove_dir_all(&dir2).unwrap();
}
}
@@ -0,0 +1,82 @@
use tracing::warn;
use crate::types::{ExtMcpServer, ResolvedMcpServer};
/// Resolve a single MCP server contribution.
///
/// MCP server config is passed through as-is (opaque JSON).
pub fn resolve_mcp_server(server: &ExtMcpServer, extension_name: &str) -> ResolvedMcpServer {
ResolvedMcpServer {
extension_name: extension_name.to_owned(),
id: server.id.clone(),
name: server.name.clone(),
description: server.description.clone(),
config: server.config.clone(),
}
}
/// Resolve all MCP server contributions from an extension.
pub fn resolve_mcp_servers(servers: &[ExtMcpServer], extension_name: &str) -> Vec<ResolvedMcpServer> {
if servers.is_empty() {
return Vec::new();
}
tracing::debug!(
extension = extension_name,
count = servers.len(),
"Resolving MCP servers"
);
servers
.iter()
.inspect(|s| {
if s.id.is_empty() || s.name.is_empty() {
warn!(
extension = extension_name,
server_id = s.id,
"MCP server has empty id or name"
);
}
})
.map(|s| resolve_mcp_server(s, extension_name))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn make_server() -> ExtMcpServer {
ExtMcpServer {
id: "test-mcp".into(),
name: "Test MCP".into(),
description: Some("A test MCP server".into()),
config: serde_json::json!({
"command": "npx",
"args": ["-y", "test-server"]
}),
}
}
#[test]
fn test_resolve_basic_mcp_server() {
let server = make_server();
let result = resolve_mcp_server(&server, "my-ext");
assert_eq!(result.extension_name, "my-ext");
assert_eq!(result.id, "test-mcp");
assert_eq!(result.name, "Test MCP");
assert_eq!(result.config["command"], "npx");
}
#[test]
fn test_resolve_mcp_servers_empty() {
let result = resolve_mcp_servers(&[], "my-ext");
assert!(result.is_empty());
}
#[test]
fn test_resolve_mcp_servers_multiple() {
let servers = vec![make_server(), make_server()];
let result = resolve_mcp_servers(&servers, "my-ext");
assert_eq!(result.len(), 2);
}
}
@@ -0,0 +1,318 @@
//! Contribution resolvers — transform raw manifest declarations into
//! runtime-ready structures.
//!
//! Each sub-module handles one contribution type. The top-level
//! [`resolve_all_contributions`] orchestrates resolution across all
//! enabled extensions.
pub mod acp_adapter;
pub mod agent;
pub mod assistant;
pub mod channel_plugin;
pub mod i18n;
pub mod mcp_server;
pub mod model_provider;
pub mod settings_tab;
pub mod skill;
pub mod theme;
pub mod webui;
use std::path::Path;
use crate::types::{LoadedExtension, ResolvedContributions};
/// Resolve all contributions from a single extension.
///
/// Failures in individual contribution types are logged and skipped —
/// one broken theme does not block ACP adapter resolution.
pub fn resolve_extension_contributions(ext: &LoadedExtension) -> ResolvedContributions {
let ext_name = &ext.manifest.name;
let ext_dir = Path::new(&ext.directory);
let contributes = match &ext.manifest.contributes {
Some(c) => c,
None => return ResolvedContributions::default(),
};
ResolvedContributions {
acp_adapters: acp_adapter::resolve_acp_adapters(&contributes.acp_adapters, ext_name, ext_dir),
mcp_servers: mcp_server::resolve_mcp_servers(&contributes.mcp_servers, ext_name),
assistants: assistant::resolve_assistants(&contributes.assistants, ext_name, ext_dir),
agents: agent::resolve_agents(&contributes.agents, ext_name, ext_dir),
skills: skill::resolve_skills(&contributes.skills, ext_name, ext_dir),
themes: theme::resolve_themes(&contributes.themes, ext_name, ext_dir),
channel_plugins: channel_plugin::resolve_channel_plugins(&contributes.channel_plugins, ext_name, ext_dir),
webui: webui::resolve_webui_contributions(&contributes.webui, ext_name, ext_dir),
settings_tabs: settings_tab::resolve_settings_tabs(&contributes.settings_tabs, ext_name, ext_dir),
model_providers: model_provider::resolve_model_providers(&contributes.model_providers, ext_name),
// i18n is resolved separately via resolve_i18n_for_locale()
// because it requires a locale parameter at query time.
i18n: std::collections::HashMap::new(),
}
}
/// Resolve contributions from all enabled extensions.
///
/// Extensions that are disabled (`state.enabled == false`) are skipped.
pub fn resolve_all_contributions(extensions: &[LoadedExtension]) -> ResolvedContributions {
let mut merged = ResolvedContributions::default();
for ext in extensions {
if !ext.state.enabled {
tracing::debug!(extension = ext.manifest.name, "Skipping disabled extension");
continue;
}
let resolved = resolve_extension_contributions(ext);
merge_contributions(&mut merged, resolved, &ext.manifest.name);
}
merged
.settings_tabs
.sort_by(|left, right| left.order.cmp(&right.order).then_with(|| left.label.cmp(&right.label)));
merged
}
/// Merge `source` contributions into `target`.
fn merge_contributions(target: &mut ResolvedContributions, source: ResolvedContributions, extension_name: &str) {
if !source.acp_adapters.is_empty() {
tracing::debug!(
extension = extension_name,
count = source.acp_adapters.len(),
"Merged ACP adapters"
);
}
target.acp_adapters.extend(source.acp_adapters);
target.mcp_servers.extend(source.mcp_servers);
target.assistants.extend(source.assistants);
target.agents.extend(source.agents);
target.skills.extend(source.skills);
target.themes.extend(source.themes);
target.channel_plugins.extend(source.channel_plugins);
target.webui.extend(source.webui);
target.settings_tabs.extend(source.settings_tabs);
target.model_providers.extend(source.model_providers);
target.i18n.extend(source.i18n);
}
/// Convenience: resolve i18n data for a given locale across all enabled extensions.
pub fn resolve_i18n_for_all(
extensions: &[LoadedExtension],
locale: &str,
) -> std::collections::HashMap<String, std::collections::HashMap<String, String>> {
let ext_data: Vec<(String, Option<crate::types::I18nConfig>, String)> = extensions
.iter()
.filter(|ext| ext.state.enabled)
.map(|ext| {
(
ext.manifest.name.clone(),
ext.manifest.i18n.clone(),
ext.directory.clone(),
)
})
.collect();
i18n::resolve_i18n_for_locale(&ext_data, locale)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::*;
use std::collections::HashMap;
fn make_extension(name: &str, enabled: bool, contributes: Option<ExtContributes>) -> LoadedExtension {
LoadedExtension {
manifest: ExtensionManifest {
name: name.to_owned(),
version: "1.0.0".to_owned(),
display_name: None,
description: None,
author: None,
license: None,
homepage: None,
icon: None,
engine: None,
api_version: None,
dependencies: HashMap::new(),
entry_point: None,
permissions: None,
contributes,
lifecycle: None,
i18n: None,
},
directory: "/tmp/ext".to_owned(),
source: ExtensionSource::Local,
state: ExtensionState {
name: name.to_owned(),
version: "1.0.0".to_owned(),
enabled,
installed_at: None,
last_activated_at: None,
},
}
}
#[test]
fn test_resolve_extension_no_contributes() {
let ext = make_extension("empty-ext", true, None);
let result = resolve_extension_contributions(&ext);
assert!(result.acp_adapters.is_empty());
assert!(result.mcp_servers.is_empty());
assert!(result.assistants.is_empty());
}
#[test]
fn test_resolve_extension_with_model_providers() {
let contributes = ExtContributes {
model_providers: vec![ExtModelProvider {
id: "mp-1".into(),
name: "Test Provider".into(),
description: None,
protocol: None,
base_url: None,
models: vec![],
}],
..Default::default()
};
let ext = make_extension("provider-ext", true, Some(contributes));
let result = resolve_extension_contributions(&ext);
assert_eq!(result.model_providers.len(), 1);
assert_eq!(result.model_providers[0].extension_name, "provider-ext");
}
#[test]
fn test_resolve_all_skips_disabled() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("skills")).unwrap();
std::fs::write(dir.path().join("skills/my-skill.md"), "# skill").unwrap();
let enabled = make_extension(
"enabled-ext",
true,
Some(ExtContributes {
skills: vec![ExtSkill {
name: "my-skill".into(),
description: None,
path: Some("skills/my-skill.md".into()),
}],
..Default::default()
}),
);
let enabled = LoadedExtension {
directory: dir.path().to_string_lossy().into_owned(),
..enabled
};
let disabled = make_extension(
"disabled-ext",
false,
Some(ExtContributes {
skills: vec![ExtSkill {
name: "hidden-skill".into(),
description: None,
path: Some("skills/hidden-skill.md".into()),
}],
..Default::default()
}),
);
let disabled = LoadedExtension {
directory: dir.path().to_string_lossy().into_owned(),
..disabled
};
let result = resolve_all_contributions(&[enabled, disabled]);
assert_eq!(result.skills.len(), 1);
assert_eq!(result.skills[0].name, "my-skill");
}
#[test]
fn test_resolve_all_merges_multiple_extensions() {
let ext_a = make_extension(
"ext-a",
true,
Some(ExtContributes {
mcp_servers: vec![ExtMcpServer {
id: "mcp-a".into(),
name: "MCP A".into(),
description: None,
config: serde_json::json!({}),
}],
..Default::default()
}),
);
let ext_b = make_extension(
"ext-b",
true,
Some(ExtContributes {
mcp_servers: vec![ExtMcpServer {
id: "mcp-b".into(),
name: "MCP B".into(),
description: None,
config: serde_json::json!({}),
}],
..Default::default()
}),
);
let result = resolve_all_contributions(&[ext_a, ext_b]);
assert_eq!(result.mcp_servers.len(), 2);
}
#[test]
fn test_resolve_all_empty_extensions() {
let result = resolve_all_contributions(&[]);
assert!(result.acp_adapters.is_empty());
assert!(result.i18n.is_empty());
}
#[test]
fn test_resolve_all_sorts_settings_tabs_globally() {
let ext_a = make_extension(
"ext-a",
true,
Some(ExtContributes {
settings_tabs: vec![ExtSettingsTab {
id: "zeta".into(),
label: "Zeta".into(),
icon: None,
url: "settings/zeta.html".into(),
position: None,
order: 100,
}],
..Default::default()
}),
);
let ext_b = make_extension(
"ext-b",
true,
Some(ExtContributes {
settings_tabs: vec![
ExtSettingsTab {
id: "alpha".into(),
label: "Alpha".into(),
icon: None,
url: "settings/alpha.html".into(),
position: None,
order: 50,
},
ExtSettingsTab {
id: "beta".into(),
label: "Beta".into(),
icon: None,
url: "settings/beta.html".into(),
position: None,
order: 100,
},
],
..Default::default()
}),
);
let result = resolve_all_contributions(&[ext_a, ext_b]);
let ids: Vec<&str> = result.settings_tabs.iter().map(|tab| tab.id.as_str()).collect();
assert_eq!(ids, vec!["ext-ext-b-alpha", "ext-ext-b-beta", "ext-ext-a-zeta"]);
}
}
@@ -0,0 +1,69 @@
use crate::types::{ExtModelProvider, ResolvedModelProvider};
/// Resolve a single model provider contribution.
pub fn resolve_model_provider(provider: &ExtModelProvider, extension_name: &str) -> ResolvedModelProvider {
ResolvedModelProvider {
extension_name: extension_name.to_owned(),
id: provider.id.clone(),
name: provider.name.clone(),
description: provider.description.clone(),
protocol: provider.protocol.clone(),
base_url: provider.base_url.clone(),
models: provider.models.clone(),
}
}
/// Resolve all model provider contributions from an extension.
pub fn resolve_model_providers(providers: &[ExtModelProvider], extension_name: &str) -> Vec<ResolvedModelProvider> {
providers
.iter()
.map(|p| resolve_model_provider(p, extension_name))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_model_provider() {
let provider = ExtModelProvider {
id: "openai-compat".into(),
name: "OpenAI Compatible".into(),
description: Some("An OpenAI-compatible provider".into()),
protocol: Some("openai".into()),
base_url: Some("https://api.example.com/v1".into()),
models: vec!["gpt-4".into(), "gpt-3.5-turbo".into()],
};
let result = resolve_model_provider(&provider, "my-ext");
assert_eq!(result.extension_name, "my-ext");
assert_eq!(result.id, "openai-compat");
assert_eq!(result.protocol.as_deref(), Some("openai"));
assert_eq!(result.models.len(), 2);
}
#[test]
fn test_resolve_model_provider_minimal() {
let provider = ExtModelProvider {
id: "minimal".into(),
name: "Minimal".into(),
description: None,
protocol: None,
base_url: None,
models: vec![],
};
let result = resolve_model_provider(&provider, "my-ext");
assert!(result.description.is_none());
assert!(result.protocol.is_none());
assert!(result.models.is_empty());
}
#[test]
fn test_resolve_model_providers_empty() {
let result = resolve_model_providers(&[], "my-ext");
assert!(result.is_empty());
}
}
@@ -0,0 +1,178 @@
use std::path::Path;
use tracing::warn;
use crate::asset_paths::{is_remote_asset_url, normalized_asset_url_path};
use crate::types::{ExtSettingsTab, ResolvedSettingsTab};
fn resolve_asset_url(extension_name: &str, raw: &str) -> Option<String> {
if is_remote_asset_url(raw) {
return Some(raw.to_owned());
}
let relative = normalized_asset_url_path(raw)?;
Some(format!("/api/extensions/{extension_name}/assets/{relative}"))
}
/// Resolve a single settings tab contribution.
///
/// Position information (`relativeTo`, `placement`) is preserved for the
/// frontend to handle insertion ordering.
pub fn resolve_settings_tab(
tab: &ExtSettingsTab,
extension_name: &str,
_ext_dir: &Path,
) -> Option<ResolvedSettingsTab> {
let url = resolve_asset_url(extension_name, &tab.url).or_else(|| {
warn!(
extension = extension_name,
tab_id = tab.id,
url = tab.url,
"Skipping settings tab with invalid asset path"
);
None
})?;
let icon = tab.icon.as_ref().and_then(|icon| {
resolve_asset_url(extension_name, icon).or_else(|| {
warn!(
extension = extension_name,
tab_id = tab.id,
icon,
"Dropping settings tab icon with invalid asset path"
);
None
})
});
Some(ResolvedSettingsTab {
extension_name: extension_name.to_owned(),
id: format!("ext-{extension_name}-{}", tab.id),
label: tab.label.clone(),
icon,
url,
position: tab.position.clone(),
order: tab.order,
})
}
/// Resolve all settings tab contributions from an extension.
pub fn resolve_settings_tabs(
tabs: &[ExtSettingsTab],
extension_name: &str,
ext_dir: &Path,
) -> Vec<ResolvedSettingsTab> {
let mut resolved: Vec<_> = tabs
.iter()
.filter_map(|tab| resolve_settings_tab(tab, extension_name, ext_dir))
.collect();
resolved.sort_by(|left, right| left.order.cmp(&right.order).then_with(|| left.label.cmp(&right.label)));
resolved
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::SettingsTabPosition;
#[test]
fn test_resolve_settings_tab_with_local_assets_and_position() {
let tab = ExtSettingsTab {
id: "my-settings".into(),
label: "My Settings".into(),
icon: Some("icons/gear.svg".into()),
url: "settings/index.html".into(),
position: Some(SettingsTabPosition {
relative_to: "general".into(),
placement: "after".into(),
}),
order: 80,
};
let result = resolve_settings_tab(&tab, "my-ext", Path::new("/tmp/my-ext")).unwrap();
assert_eq!(result.extension_name, "my-ext");
assert_eq!(result.id, "ext-my-ext-my-settings");
assert_eq!(result.url, "/api/extensions/my-ext/assets/settings/index.html");
assert_eq!(
result.icon.as_deref(),
Some("/api/extensions/my-ext/assets/icons/gear.svg")
);
assert_eq!(result.order, 80);
let pos = result.position.unwrap();
assert_eq!(pos.relative_to, "general");
assert_eq!(pos.placement, "after");
}
#[test]
fn test_resolve_settings_tab_keeps_remote_urls() {
let tab = ExtSettingsTab {
id: "plain-tab".into(),
label: "Plain".into(),
icon: Some("https://example.com/icon.svg".into()),
url: "https://example.com/settings".into(),
position: None,
order: 100,
};
let result = resolve_settings_tab(&tab, "my-ext", Path::new("/tmp/my-ext")).unwrap();
assert!(result.position.is_none());
assert_eq!(result.url, "https://example.com/settings");
assert_eq!(result.icon.as_deref(), Some("https://example.com/icon.svg"));
}
#[test]
fn test_resolve_settings_tab_rejects_traversal_url() {
let tab = ExtSettingsTab {
id: "bad".into(),
label: "Bad".into(),
icon: None,
url: "../settings.html".into(),
position: None,
order: 100,
};
assert!(resolve_settings_tab(&tab, "my-ext", Path::new("/tmp/my-ext")).is_none());
}
#[test]
fn test_resolve_settings_tabs_sorts_by_order_then_label() {
let tabs = vec![
ExtSettingsTab {
id: "z".into(),
label: "Zulu".into(),
icon: None,
url: "z.html".into(),
position: None,
order: 100,
},
ExtSettingsTab {
id: "a".into(),
label: "Alpha".into(),
icon: None,
url: "a.html".into(),
position: Some(SettingsTabPosition {
relative_to: "general".into(),
placement: "before".into(),
}),
order: 50,
},
ExtSettingsTab {
id: "b".into(),
label: "Beta".into(),
icon: None,
url: "b.html".into(),
position: None,
order: 100,
},
];
let result = resolve_settings_tabs(&tabs, "my-ext", Path::new("/tmp/my-ext"));
assert_eq!(result.len(), 3);
assert_eq!(result[0].id, "ext-my-ext-a");
assert_eq!(result[1].id, "ext-my-ext-b");
assert_eq!(result[2].id, "ext-my-ext-z");
}
}
@@ -0,0 +1,116 @@
use std::path::Path;
use tracing::warn;
use crate::types::{ExtSkill, ResolvedSkill};
/// Resolve a single skill contribution.
///
/// Skill file paths are resolved relative to the extension directory.
pub fn resolve_skill(skill: &ExtSkill, extension_name: &str, ext_dir: &Path) -> Option<ResolvedSkill> {
let path = skill.path.as_ref()?;
let location = ext_dir.join(path);
if !location.exists() {
return None;
}
Some(ResolvedSkill {
extension_name: extension_name.to_owned(),
name: skill.name.clone(),
description: skill.description.clone(),
path: Some(location.to_string_lossy().into_owned()),
})
}
/// Resolve all skill contributions from an extension.
pub fn resolve_skills(skills: &[ExtSkill], extension_name: &str, ext_dir: &Path) -> Vec<ResolvedSkill> {
skills
.iter()
.filter_map(|s| {
resolve_skill(s, extension_name, ext_dir).or_else(|| {
warn!(
extension = extension_name,
skill_name = s.name,
"Failed to resolve skill path"
);
None
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_skill_with_path() {
let dir = std::env::temp_dir().join("ext_test_resolve_skill_with_path");
std::fs::create_dir_all(dir.join("skills")).unwrap();
std::fs::write(dir.join("skills/code-review.md"), "# review").unwrap();
let skill = ExtSkill {
name: "code-review".into(),
description: Some("Code review skill".into()),
path: Some("skills/code-review.md".into()),
};
let result = resolve_skill(&skill, "my-ext", &dir).unwrap();
assert_eq!(result.extension_name, "my-ext");
assert_eq!(result.name, "code-review");
assert!(result.path.as_ref().unwrap().contains("skills/code-review"));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn test_resolve_skill_no_path() {
let skill = ExtSkill {
name: "inline-skill".into(),
description: None,
path: None,
};
let result = resolve_skill(&skill, "my-ext", Path::new("/ext/my-ext"));
assert!(result.is_none());
}
#[test]
fn test_resolve_skill_missing_path() {
let skill = ExtSkill {
name: "missing-skill".into(),
description: None,
path: Some("skills/missing.md".into()),
};
let result = resolve_skill(&skill, "my-ext", Path::new("/ext/my-ext"));
assert!(result.is_none());
}
#[test]
fn test_resolve_skills_multiple() {
let dir = std::env::temp_dir().join("ext_test_resolve_skills_multiple");
std::fs::create_dir_all(dir.join("skills")).unwrap();
std::fs::write(dir.join("skills/b.md"), "# b").unwrap();
let skills = vec![
ExtSkill {
name: "a".into(),
description: None,
path: None,
},
ExtSkill {
name: "b".into(),
description: None,
path: Some("skills/b.md".into()),
},
];
let result = resolve_skills(&skills, "my-ext", &dir);
assert_eq!(result.len(), 1);
assert_eq!(result[0].name, "b");
std::fs::remove_dir_all(dir).unwrap();
}
}
@@ -0,0 +1,147 @@
use std::path::Path;
use tracing::warn;
use crate::asset_paths::resolve_extension_asset_url;
use crate::error::ExtensionError;
use crate::types::{ExtTheme, ResolvedTheme};
/// Resolve a single theme contribution by reading CSS file content.
///
/// The CSS file path is relative to the extension directory.
/// Cover image path is resolved to an absolute path.
pub fn resolve_theme(theme: &ExtTheme, extension_name: &str, ext_dir: &Path) -> Result<ResolvedTheme, ExtensionError> {
let css_path = ext_dir.join(&theme.css_file);
if !css_path.exists() {
return Err(ExtensionError::ThemeCssNotFound(css_path.display().to_string()));
}
let css_content = std::fs::read_to_string(&css_path)?;
let cover_image = theme
.cover_image
.as_deref()
.and_then(|img| resolve_extension_asset_url(extension_name, img));
Ok(ResolvedTheme {
extension_name: extension_name.to_owned(),
id: theme.id.clone(),
name: theme.name.clone(),
description: theme.description.clone(),
css_content,
cover_image,
})
}
/// Resolve all theme contributions from an extension.
pub fn resolve_themes(themes: &[ExtTheme], extension_name: &str, ext_dir: &Path) -> Vec<ResolvedTheme> {
themes
.iter()
.filter_map(|t| {
resolve_theme(t, extension_name, ext_dir)
.map_err(|e| {
warn!(
extension = extension_name,
theme_id = t.id,
"Failed to resolve theme: {e}"
);
e
})
.ok()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_theme_reads_css() {
let dir = std::env::temp_dir().join("ext_test_resolve_theme");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("dark.css"), ":root { --bg: #000; }").unwrap();
let theme = ExtTheme {
id: "dark-theme".into(),
name: "Dark Theme".into(),
description: Some("A dark theme".into()),
css_file: "dark.css".into(),
cover_image: Some("images/dark.png".into()),
};
let result = resolve_theme(&theme, "my-ext", &dir).unwrap();
assert_eq!(result.extension_name, "my-ext");
assert_eq!(result.id, "dark-theme");
assert_eq!(result.css_content, ":root { --bg: #000; }");
assert!(result.cover_image.as_ref().unwrap().contains("images/dark.png"));
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn test_resolve_theme_css_not_found() {
let theme = ExtTheme {
id: "missing-theme".into(),
name: "Missing".into(),
description: None,
css_file: "nonexistent.css".into(),
cover_image: None,
};
let err = resolve_theme(&theme, "my-ext", Path::new("/tmp/no_such_ext")).unwrap_err();
assert!(matches!(err, ExtensionError::ThemeCssNotFound(_)));
}
#[test]
fn test_resolve_theme_no_cover_image() {
let dir = std::env::temp_dir().join("ext_test_resolve_theme_no_cover");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("light.css"), "body { color: #333; }").unwrap();
let theme = ExtTheme {
id: "light-theme".into(),
name: "Light".into(),
description: None,
css_file: "light.css".into(),
cover_image: None,
};
let result = resolve_theme(&theme, "my-ext", &dir).unwrap();
assert!(result.cover_image.is_none());
assert_eq!(result.css_content, "body { color: #333; }");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn test_resolve_themes_skips_missing_css() {
let dir = std::env::temp_dir().join("ext_test_resolve_themes_skip");
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join("ok.css"), "ok").unwrap();
let themes = vec![
ExtTheme {
id: "good".into(),
name: "Good".into(),
description: None,
css_file: "ok.css".into(),
cover_image: None,
},
ExtTheme {
id: "bad".into(),
name: "Bad".into(),
description: None,
css_file: "missing.css".into(),
cover_image: None,
},
];
let result = resolve_themes(&themes, "my-ext", &dir);
assert_eq!(result.len(), 1);
assert_eq!(result[0].id, "good");
std::fs::remove_dir_all(&dir).unwrap();
}
}
@@ -0,0 +1,181 @@
use std::path::Path;
use tracing::warn;
use crate::constants::RESERVED_ROUTE_PREFIXES;
use crate::error::ExtensionError;
use crate::types::{ExtWebui, WebuiContribution};
/// Validate that a WebUI route path is within the extension's namespace
/// and does not use reserved prefixes.
fn validate_route(route_path: &str, extension_name: &str) -> Result<(), ExtensionError> {
let expected_prefix = format!("/{extension_name}/");
if !route_path.starts_with(&expected_prefix) {
return Err(ExtensionError::InvalidWebuiRouteNamespace {
extension_name: extension_name.to_owned(),
route: route_path.to_owned(),
});
}
for prefix in RESERVED_ROUTE_PREFIXES {
if route_path.starts_with(prefix) {
return Err(ExtensionError::ReservedWebuiRoute {
route: route_path.to_owned(),
prefix: (*prefix).to_owned(),
});
}
}
Ok(())
}
/// Resolve a single WebUI contribution.
///
/// All routes are validated to be within the `/{extensionName}/` namespace
/// and not using reserved prefixes.
pub fn resolve_webui(
webui: &ExtWebui,
extension_name: &str,
ext_dir: &Path,
) -> Result<WebuiContribution, ExtensionError> {
for route in &webui.routes {
validate_route(&route.path, extension_name)?;
}
let directory = ext_dir.join(&webui.directory).to_string_lossy().into_owned();
Ok(WebuiContribution {
extension_name: extension_name.to_owned(),
id: webui.id.clone(),
directory,
routes: webui.routes.clone(),
})
}
/// Resolve all WebUI contributions from an extension.
pub fn resolve_webui_contributions(
webuis: &[ExtWebui],
extension_name: &str,
ext_dir: &Path,
) -> Vec<WebuiContribution> {
webuis
.iter()
.filter_map(|w| {
resolve_webui(w, extension_name, ext_dir)
.map_err(|e| {
warn!(
extension = extension_name,
webui_id = w.id,
"Failed to resolve WebUI: {e}"
);
e
})
.ok()
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ExtWebuiRoute;
fn make_route(path: &str) -> ExtWebuiRoute {
ExtWebuiRoute {
path: path.into(),
method: "GET".into(),
handler: "handler.js".into(),
}
}
#[test]
fn test_validate_route_valid_namespace() {
assert!(validate_route("/my-ext/api/data", "my-ext").is_ok());
assert!(validate_route("/my-ext/page", "my-ext").is_ok());
}
#[test]
fn test_validate_route_wrong_namespace() {
let err = validate_route("/other-ext/api", "my-ext").unwrap_err();
assert!(matches!(err, ExtensionError::InvalidWebuiRouteNamespace { .. }));
}
#[test]
fn test_validate_route_reserved_prefix() {
let err = validate_route("/api/extensions", "api").unwrap_err();
assert!(matches!(err, ExtensionError::ReservedWebuiRoute { .. }));
}
#[test]
fn test_resolve_webui_valid() {
let webui = ExtWebui {
id: "web-1".into(),
directory: "dist".into(),
routes: vec![make_route("/my-ext/dashboard")],
};
let result = resolve_webui(&webui, "my-ext", Path::new("/ext/my-ext")).unwrap();
assert_eq!(result.extension_name, "my-ext");
assert_eq!(result.id, "web-1");
assert!(result.directory.contains("dist"));
assert_eq!(result.routes.len(), 1);
}
#[test]
fn test_resolve_webui_invalid_route_rejected() {
let webui = ExtWebui {
id: "web-bad".into(),
directory: "dist".into(),
routes: vec![make_route("/other-ext/api")],
};
let err = resolve_webui(&webui, "my-ext", Path::new("/ext/my-ext")).unwrap_err();
assert!(matches!(err, ExtensionError::InvalidWebuiRouteNamespace { .. }));
}
#[test]
fn test_resolve_webui_no_routes() {
let webui = ExtWebui {
id: "static-only".into(),
directory: "public".into(),
routes: vec![],
};
let result = resolve_webui(&webui, "my-ext", Path::new("/ext/my-ext")).unwrap();
assert!(result.routes.is_empty());
}
#[test]
fn test_resolve_webui_contributions_filters_invalid() {
let webuis = vec![
ExtWebui {
id: "good".into(),
directory: "dist".into(),
routes: vec![make_route("/my-ext/page")],
},
ExtWebui {
id: "bad".into(),
directory: "dist".into(),
routes: vec![make_route("/other/page")],
},
];
let result = resolve_webui_contributions(&webuis, "my-ext", Path::new("/ext/my-ext"));
assert_eq!(result.len(), 1);
assert_eq!(result[0].id, "good");
}
#[test]
fn test_validate_route_ws_reserved() {
let err = validate_route("/ws/stream", "ws").unwrap_err();
assert!(matches!(err, ExtensionError::ReservedWebuiRoute { .. }));
}
#[test]
fn test_validate_route_auth_reserved() {
let err = validate_route("/auth/login", "auth").unwrap_err();
assert!(matches!(err, ExtensionError::ReservedWebuiRoute { .. }));
}
}
@@ -0,0 +1,648 @@
use std::collections::HashMap;
use std::path::Path as FsPath;
use axum::Router;
use axum::body::Body;
use axum::extract::rejection::JsonRejection;
use axum::extract::{Json, Path, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::Response;
use axum::routing::{get, post};
use nomifun_api_types::{
ApiResponse, DisableExtensionRequest, EnableExtensionRequest, ExtensionSummaryResponse, GetI18nRequest,
GetPermissionsRequest, GetRiskLevelRequest, PermissionDetailResponse, PermissionSummaryResponse,
};
use nomifun_common::{AppError, now_ms};
use crate::asset_paths::normalize_relative_asset_path;
use crate::permission::{build_permission_summary, calculate_risk_level};
use crate::registry::ExtensionRegistry;
// ---------------------------------------------------------------------------
// Router state
// ---------------------------------------------------------------------------
/// Shared state for extension route handlers.
#[derive(Clone)]
pub struct ExtensionRouterState {
pub registry: ExtensionRegistry,
}
// ---------------------------------------------------------------------------
// Router builder
// ---------------------------------------------------------------------------
/// Build the extension router with all `/api/extensions/*` routes.
///
/// Includes query routes and management routes.
/// All routes require authentication (applied by the caller).
pub fn extension_routes(state: ExtensionRouterState) -> Router {
Router::new()
// Query routes
.route("/api/extensions", get(get_loaded_extensions))
.route("/api/extensions/themes", get(get_themes))
.route("/api/extensions/assistants", get(get_assistants))
.route("/api/extensions/acp-adapters", get(get_acp_adapters))
.route("/api/extensions/agents", get(get_agents))
.route("/api/extensions/mcp-servers", get(get_mcp_servers))
.route("/api/extensions/skills", get(get_skills))
.route("/api/extensions/channel-plugins", get(get_channel_plugins))
.route("/api/extensions/settings-tabs", get(get_settings_tabs))
.route(
"/api/extensions/{extension_name}/assets/{*asset_path}",
get(get_extension_asset),
)
.route("/api/extensions/webui", get(get_webui))
.route("/api/extensions/agent-activity", get(get_agent_activity))
// Query routes with body
.route("/api/extensions/i18n", post(get_i18n))
.route("/api/extensions/permissions", post(get_permissions))
.route("/api/extensions/risk-level", post(get_risk_level))
// Management routes
.route("/api/extensions/enable", post(enable_extension))
.route("/api/extensions/disable", post(disable_extension))
.with_state(state)
}
// ---------------------------------------------------------------------------
// Query handlers
// ---------------------------------------------------------------------------
/// `GET /api/extensions` — list all loaded extensions.
async fn get_loaded_extensions(
State(state): State<ExtensionRouterState>,
) -> Result<Json<ApiResponse<Vec<ExtensionSummaryResponse>>>, AppError> {
let summaries = state.registry.get_loaded_extensions().await;
let resp: Vec<ExtensionSummaryResponse> = summaries
.into_iter()
.map(|s| {
let source_str = serde_json::to_value(s.source)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_else(|| "local".to_string());
ExtensionSummaryResponse {
name: s.name,
version: s.version,
display_name: s.display_name,
description: s.description,
enabled: s.enabled,
source: source_str,
}
})
.collect();
Ok(Json(ApiResponse::ok(resp)))
}
/// `GET /api/extensions/themes` — get all resolved themes.
async fn get_themes(
State(state): State<ExtensionRouterState>,
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
let themes = state.registry.get_themes().await;
let timestamp = now_ms();
let value = serde_json::Value::Array(
themes
.into_iter()
.map(|theme| {
serde_json::json!({
"id": format!("ext-{}-{}", theme.extension_name, theme.id),
"name": format!("{} ({})", theme.name, theme.extension_name),
"cover": theme.cover_image,
"css": theme.css_content,
"is_preset": true,
"created_at": timestamp,
"updated_at": timestamp,
})
})
.collect(),
);
Ok(Json(ApiResponse::ok(value)))
}
/// `GET /api/extensions/assistants` — get all resolved assistants.
async fn get_assistants(
State(state): State<ExtensionRouterState>,
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
let assistants = state.registry.get_assistants().await;
let value = serde_json::Value::Array(
assistants
.into_iter()
.map(|assistant| {
serde_json::json!({
"id": format!("ext-{}", assistant.id),
"name": assistant.name,
"description": assistant.description,
"avatar": assistant.icon,
"presetAgentType": assistant.preset_agent_type,
"context": assistant.context.unwrap_or_default(),
"models": assistant.models,
"enabledSkills": assistant.enabled_skills,
"prompts": assistant.prompts,
"isPreset": true,
"isBuiltin": false,
"enabled": true,
"_source": "extension",
"_extensionName": assistant.extension_name,
"_kind": "assistant",
})
})
.collect(),
);
Ok(Json(ApiResponse::ok(value)))
}
/// `GET /api/extensions/acp-adapters` — get all resolved ACP adapters.
async fn get_acp_adapters(
State(state): State<ExtensionRouterState>,
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
let adapters = state.registry.get_acp_adapters().await;
let value = serde_json::Value::Array(
adapters
.into_iter()
.map(|adapter| {
let cli_command = adapter.cli_command.clone();
let default_cli_path = adapter.default_cli_path.clone().or_else(|| cli_command.clone());
serde_json::json!({
"id": adapter.id,
"name": adapter.name,
"description": adapter.description,
"cliCommand": cli_command,
"defaultCliPath": default_cli_path,
"acpArgs": adapter.acp_args,
"env": adapter.env,
"avatar": adapter.avatar,
"authRequired": adapter.auth_required,
"supportsStreaming": adapter.supports_streaming.unwrap_or(false),
"connectionType": adapter.connection_type.unwrap_or_else(|| "cli".to_string()),
"endpoint": adapter.endpoint,
"models": adapter.models,
"yoloMode": adapter.yolo_mode,
"healthCheck": adapter.health_check,
"apiKeyFields": adapter.api_key_fields,
"isPreset": false,
"isBuiltin": false,
"enabled": true,
"_source": "extension",
"_extensionName": adapter.extension_name,
})
})
.collect(),
);
Ok(Json(ApiResponse::ok(value)))
}
/// `GET /api/extensions/agents` — get all resolved agents.
async fn get_agents(
State(state): State<ExtensionRouterState>,
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
let agents = state.registry.get_agents().await;
let value = serde_json::Value::Array(
agents
.into_iter()
.map(|agent| {
serde_json::json!({
"id": format!("ext-{}", agent.id),
"name": agent.name,
"description": agent.description,
"avatar": agent.icon,
"presetAgentType": agent.agent_type,
"context": agent.context.unwrap_or_default(),
"models": agent.models,
"enabledSkills": agent.enabled_skills,
"prompts": agent.prompts,
"isPreset": true,
"isBuiltin": false,
"enabled": true,
"_source": "extension",
"_extensionName": agent.extension_name,
"_kind": "agent",
})
})
.collect(),
);
Ok(Json(ApiResponse::ok(value)))
}
/// `GET /api/extensions/mcp-servers` — get all resolved MCP servers.
async fn get_mcp_servers(
State(state): State<ExtensionRouterState>,
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
let servers = state.registry.get_mcp_servers().await;
let timestamp = now_ms();
let value = serde_json::Value::Array(
servers
.into_iter()
.map(|server| {
let enabled = server
.config
.get("enabled")
.and_then(serde_json::Value::as_bool)
.unwrap_or(true);
let transport = server
.config
.get("transport")
.cloned()
.unwrap_or(serde_json::Value::Null);
let original_transport = transport.clone();
let original_json = serde_json::json!({
"name": server.name,
"description": server.description,
"enabled": enabled,
"transport": original_transport,
});
serde_json::json!({
"id": format!("ext-{}-{}", server.extension_name, server.name),
"name": server.name,
"description": server.description,
"enabled": enabled,
"transport": transport,
"created_at": timestamp,
"updated_at": timestamp,
"original_json": serde_json::to_string_pretty(&original_json).unwrap_or_default(),
"_source": "extension",
"_extensionName": server.extension_name,
})
})
.collect(),
);
Ok(Json(ApiResponse::ok(value)))
}
/// `GET /api/extensions/skills` — get all resolved skills.
async fn get_skills(
State(state): State<ExtensionRouterState>,
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
let skills = state.registry.get_skills().await;
let value = serde_json::Value::Array(
skills
.into_iter()
.map(|skill| {
serde_json::json!({
"name": skill.name,
"description": skill.description.unwrap_or_else(|| format!("Skill from extension: {}", skill.extension_name)),
"location": skill.path,
})
})
.collect(),
);
Ok(Json(ApiResponse::ok(value)))
}
/// `GET /api/extensions/channel-plugins` — get all resolved channel plugins.
async fn get_channel_plugins(
State(state): State<ExtensionRouterState>,
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
let plugins = state.registry.get_channel_plugins().await;
let value = serde_json::Value::Array(
plugins
.into_iter()
.map(|plugin| {
serde_json::json!({
"id": plugin.id,
"type": plugin.id,
"name": plugin.name,
"platform": plugin.platform,
"entryPoint": plugin.entry_point,
"enabled": true,
"connected": false,
"active_users": 0,
"has_token": false,
"is_extension": true,
"extension_meta": {
"credentialFields": plugin.credential_fields,
"configFields": plugin.config_fields,
"description": plugin.description,
"extensionName": plugin.extension_name,
"icon": plugin.icon,
},
})
})
.collect(),
);
Ok(Json(ApiResponse::ok(value)))
}
/// `GET /api/extensions/settings-tabs` — get all resolved settings tabs.
async fn get_settings_tabs(
State(state): State<ExtensionRouterState>,
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
let tabs = state.registry.get_settings_tabs().await;
let value = serde_json::to_value(&tabs).unwrap_or_default();
Ok(Json(ApiResponse::ok(value)))
}
/// `GET /api/extensions/{extension_name}/assets/{*asset_path}` — serve an
/// extension asset under the trusted extension root.
async fn get_extension_asset(
State(state): State<ExtensionRouterState>,
Path((extension_name, asset_path)): Path<(String, String)>,
) -> Result<Response, AppError> {
let ext = state
.registry
.get_extension_by_name(&extension_name)
.await
.ok_or_else(|| AppError::NotFound(format!("Extension not found: {extension_name}")))?;
let canonical_root = tokio::fs::canonicalize(&ext.directory)
.await
.map_err(map_asset_lookup_error)?;
let relative_path = normalize_relative_asset_path(&asset_path).ok_or_else(|| {
AppError::Forbidden(format!(
"Asset path escapes extension root: {extension_name}/{asset_path}"
))
})?;
let requested_path = canonical_root.join(&relative_path);
let canonical_asset = tokio::fs::canonicalize(&requested_path)
.await
.map_err(map_asset_lookup_error)?;
if !canonical_asset.starts_with(&canonical_root) {
return Err(AppError::Forbidden(format!(
"Asset path escapes extension root: {}",
canonical_asset.display()
)));
}
let bytes = tokio::fs::read(&canonical_asset)
.await
.map_err(map_asset_lookup_error)?;
Response::builder()
.status(StatusCode::OK)
.header(header::CONTENT_TYPE, content_type_for_path(&canonical_asset))
.header(header::CACHE_CONTROL, "public, max-age=3600")
.body(Body::from(bytes))
.map_err(|err| AppError::Internal(err.to_string()))
}
/// `GET /api/extensions/webui` — get all WebUI contributions.
async fn get_webui(
State(state): State<ExtensionRouterState>,
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
let webui = state.registry.get_webui_contributions().await;
let value = serde_json::to_value(&webui).unwrap_or_default();
Ok(Json(ApiResponse::ok(value)))
}
/// `GET /api/extensions/agent-activity` — get agent activity snapshot.
///
/// Returns an empty object as a placeholder; real implementation will
/// integrate with the agent subsystem's activity tracking.
async fn get_agent_activity(
State(_state): State<ExtensionRouterState>,
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
// Agent activity snapshot is a cross-module concern;
// return an empty object for now.
Ok(Json(ApiResponse::ok(serde_json::json!({}))))
}
/// `POST /api/extensions/i18n` — get i18n data for a locale.
async fn get_i18n(
State(state): State<ExtensionRouterState>,
body: Result<Json<GetI18nRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<HashMap<String, HashMap<String, String>>>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let data = state.registry.get_i18n_for_locale(&req.locale).await;
Ok(Json(ApiResponse::ok(data)))
}
/// `POST /api/extensions/permissions` — get permission summary for an extension.
async fn get_permissions(
State(state): State<ExtensionRouterState>,
body: Result<Json<GetPermissionsRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<PermissionSummaryResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let ext = state
.registry
.get_extension_by_name(&req.name)
.await
.ok_or_else(|| AppError::NotFound(format!("Extension not found: {}", req.name)))?;
let permissions = ext.manifest.permissions.clone().unwrap_or_default();
let summary = build_permission_summary(&permissions);
let risk_level = calculate_risk_level(&permissions);
let details: Vec<PermissionDetailResponse> = summary
.details
.into_iter()
.map(|d| PermissionDetailResponse {
permission: d.permission,
level: enum_to_string(&d.level),
description: d.description,
})
.collect();
let resp = PermissionSummaryResponse {
permissions: serde_json::to_value(&permissions).unwrap_or_default(),
risk_level: enum_to_string(&risk_level),
details,
};
Ok(Json(ApiResponse::ok(resp)))
}
/// `POST /api/extensions/risk-level` — get risk level for an extension.
async fn get_risk_level(
State(state): State<ExtensionRouterState>,
body: Result<Json<GetRiskLevelRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<serde_json::Value>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let ext = state
.registry
.get_extension_by_name(&req.name)
.await
.ok_or_else(|| AppError::NotFound(format!("Extension not found: {}", req.name)))?;
let permissions = ext.manifest.permissions.clone().unwrap_or_default();
let risk_level = calculate_risk_level(&permissions);
Ok(Json(ApiResponse::ok(
serde_json::json!({ "riskLevel": enum_to_string(&risk_level) }),
)))
}
// ---------------------------------------------------------------------------
// Management handlers
// ---------------------------------------------------------------------------
/// `POST /api/extensions/enable` — enable an extension.
async fn enable_extension(
State(state): State<ExtensionRouterState>,
body: Result<Json<EnableExtensionRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state.registry.enable_extension(&req.name).await?;
Ok(Json(ApiResponse::success()))
}
/// `POST /api/extensions/disable` — disable an extension.
async fn disable_extension(
State(state): State<ExtensionRouterState>,
body: Result<Json<DisableExtensionRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state
.registry
.disable_extension(&req.name, req.reason.as_deref())
.await?;
Ok(Json(ApiResponse::success()))
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/// Serialize a serde enum to its JSON string representation.
fn enum_to_string<T: serde::Serialize>(value: &T) -> String {
serde_json::to_value(value)
.ok()
.and_then(|v| v.as_str().map(String::from))
.unwrap_or_default()
}
fn content_type_for_path(path: &FsPath) -> HeaderValue {
let mime = mime_guess::from_path(path).first_or_octet_stream();
HeaderValue::from_str(mime.as_ref()).unwrap_or_else(|_| HeaderValue::from_static("application/octet-stream"))
}
fn map_asset_lookup_error(error: std::io::Error) -> AppError {
match error.kind() {
std::io::ErrorKind::NotFound => AppError::NotFound("Extension asset not found".into()),
_ => AppError::Internal(error.to_string()),
}
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use axum::http::Request;
use http_body_util::BodyExt;
use std::path::PathBuf;
use std::sync::Arc;
use tower::ServiceExt;
use crate::state::ExtensionStateStore;
use crate::{ExtensionSource, ScanPath};
use nomifun_realtime::BroadcastEventBus;
fn make_state() -> ExtensionRouterState {
let tmp = tempfile::TempDir::new().unwrap();
let store = ExtensionStateStore::new(tmp.path().join("states.json"));
let bus = Arc::new(BroadcastEventBus::new(64));
std::mem::forget(tmp);
let registry = ExtensionRegistry::new(store, bus, "1.0.0".into());
ExtensionRouterState { registry }
}
#[test]
fn extension_routes_builds_router() {
let state = make_state();
let _router = extension_routes(state);
}
async fn make_router_with_extension() -> (Router, tempfile::TempDir, PathBuf) {
let tmp = tempfile::TempDir::new().unwrap();
let ext_root = tmp.path().join("extensions");
let ext_dir = ext_root.join("hello");
std::fs::create_dir_all(ext_dir.join("settings")).unwrap();
std::fs::write(
ext_dir.join("nomi-extension.json"),
serde_json::to_vec_pretty(&serde_json::json!({
"name": "hello",
"version": "1.0.0"
}))
.unwrap(),
)
.unwrap();
std::fs::write(ext_dir.join("settings").join("index.html"), "<h1>Hello</h1>").unwrap();
let store = ExtensionStateStore::new(tmp.path().join("states.json"));
let bus = Arc::new(BroadcastEventBus::new(64));
let registry = ExtensionRegistry::new(store, bus, "1.0.0".into());
registry
.initialize_with_scan_paths(vec![ScanPath {
path: ext_root,
source: ExtensionSource::Env,
}])
.await
.unwrap();
(extension_routes(ExtensionRouterState { registry }), tmp, ext_dir)
}
#[tokio::test]
async fn get_extension_asset_serves_local_file() {
let (router, _tmp, _ext_dir) = make_router_with_extension().await;
let response = router
.oneshot(
Request::builder()
.uri("/api/extensions/hello/assets/settings/index.html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()[header::CACHE_CONTROL], "public, max-age=3600");
assert_eq!(response.headers()[header::CONTENT_TYPE], "text/html");
let bytes = response.into_body().collect().await.unwrap().to_bytes();
assert_eq!(bytes, "<h1>Hello</h1>");
}
#[tokio::test]
async fn get_extension_asset_rejects_traversal() {
let (router, _tmp, _ext_dir) = make_router_with_extension().await;
let response = router
.oneshot(
Request::builder()
.uri("/api/extensions/hello/assets/%2E%2E%2Fsecret.txt")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn get_extension_asset_returns_not_found_for_missing_file() {
let (router, _tmp, _ext_dir) = make_router_with_extension().await;
let response = router
.oneshot(
Request::builder()
.uri("/api/extensions/hello/assets/settings/missing.html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
#[tokio::test]
async fn get_extension_asset_returns_not_found_for_unknown_extension() {
let (router, _tmp, _ext_dir) = make_router_with_extension().await;
let response = router
.oneshot(
Request::builder()
.uri("/api/extensions/unknown/assets/settings/index.html")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NOT_FOUND);
}
}
@@ -0,0 +1,617 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use axum::Router;
use axum::extract::rejection::JsonRejection;
use axum::extract::{Json, Path as AxumPath, State};
use axum::routing::{delete, get, post, put};
use nomifun_api_types::{
AddExternalPathRequest, ApiResponse, BuiltinAutoSkillResponse, ExportSkillRequest, ExternalSkillSourceResponse,
ImportSkillRequest, ImportSkillResponse, MaterializeSkillsRequest, MaterializeSkillsResponse, MaterializedSkillRef,
NamedPathResponse, ReadAssistantRuleRequest, ReadBuiltinResourceRequest, ReadSkillInfoRequest,
ReadSkillInfoResponse, RemoveExternalPathRequest, ScanForSkillsRequest, ScanForSkillsResponse,
ScannedSkillResponse, SetSkillTagsRequest, SkillListItemResponse, SkillPathsResponse, SkillSourceResponse,
WriteAssistantRuleRequest,
};
use nomifun_common::AppError;
use nomifun_db::ISkillTagRepository;
use crate::classifier::AssistantRuleDispatcher;
use crate::external_paths::ExternalPathsManager;
use crate::skill_service::{self, SkillPaths, SkillSource};
fn to_source_response(source: SkillSource) -> SkillSourceResponse {
match source {
SkillSource::Builtin => SkillSourceResponse::Builtin,
SkillSource::Custom => SkillSourceResponse::Custom,
SkillSource::Extension => SkillSourceResponse::Extension,
}
}
// ---------------------------------------------------------------------------
// Router state
// ---------------------------------------------------------------------------
/// Shared state for skill/rule route handlers.
#[derive(Clone)]
pub struct SkillRouterState {
pub skill_paths: SkillPaths,
pub external_paths_manager: Arc<ExternalPathsManager>,
/// Optional dispatcher that routes assistant-rule / assistant-skill
/// read/write/delete by source (builtin / extension / user). When
/// `None`, the legacy user-directory-only behavior is preserved.
#[allow(clippy::type_complexity)]
pub assistant_dispatcher: Option<Arc<dyn AssistantRuleDispatcher>>,
/// Per-skill tag assignment repo (user assignments/overrides).
pub skill_tag_repo: Arc<dyn ISkillTagRepository>,
/// Built-in skill tag seed: skill name → (audience_tags, scenario_tags).
pub builtin_skill_tags: Arc<HashMap<String, (Vec<String>, Vec<String>)>>,
}
// ---------------------------------------------------------------------------
// Router builder
// ---------------------------------------------------------------------------
/// Build the skill router with all `/api/skills/*` routes.
///
/// All routes require authentication (applied by the caller).
pub fn skill_routes(state: SkillRouterState) -> Router {
Router::new()
// Skill listing & info
.route("/api/skills", get(list_skills))
.route("/api/skills/builtin-auto", get(list_builtin_auto_skills))
.route("/api/skills/{name}/tags", put(set_skill_tags))
.route("/api/skills/info", post(read_skill_info))
.route("/api/skills/paths", get(get_skill_paths))
// Import / export / delete
.route("/api/skills/import", post(import_skill))
.route("/api/skills/import-symlink", post(import_skill_symlink))
.route("/api/skills/export-symlink", post(export_skill_symlink))
.route("/api/skills/{name}", delete(delete_skill))
// Scanning & discovery
.route("/api/skills/scan", post(scan_for_skills))
.route("/api/skills/detect-paths", get(detect_paths))
.route("/api/skills/detect-external", get(detect_external))
// Built-in resources
.route("/api/skills/builtin-rule", post(read_builtin_rule))
.route("/api/skills/builtin-skill", post(read_builtin_skill))
// Per-agent skill resolution (for agent CLI symlink layout).
.route("/api/skills/materialize-for-agent", post(materialize_for_agent))
// Assistant rules CRUD
.route("/api/skills/assistant-rule/read", post(read_assistant_rule))
.route("/api/skills/assistant-rule/write", post(write_assistant_rule))
.route("/api/skills/assistant-rule/{id}", delete(delete_assistant_rule))
// Assistant skills CRUD
.route("/api/skills/assistant-skill/read", post(read_assistant_skill))
.route("/api/skills/assistant-skill/write", post(write_assistant_skill))
.route("/api/skills/assistant-skill/{id}", delete(delete_assistant_skill))
// External path management
.route(
"/api/skills/external-paths",
get(get_external_paths)
.post(add_external_path)
.delete(remove_external_path),
)
// Skills market
.route("/api/skills/market/enable", post(enable_skills_market))
.route("/api/skills/market/disable", post(disable_skills_market))
.with_state(state)
}
// ---------------------------------------------------------------------------
// Skill listing & info
// ---------------------------------------------------------------------------
/// `GET /api/skills` — list all available skills.
async fn list_skills(
State(state): State<SkillRouterState>,
) -> Result<Json<ApiResponse<Vec<SkillListItemResponse>>>, AppError> {
let items = skill_service::list_available_skills(&state.skill_paths).await?;
// user sidecar assignments (decode JSON arrays), keyed by skill name
let user_rows = state.skill_tag_repo.get_all().await.map_err(AppError::from)?;
let mut user_map: HashMap<String, (Vec<String>, Vec<String>)> = HashMap::new();
for r in user_rows {
let aud = decode_tags(r.audience_tags.as_deref());
let scn = decode_tags(r.scenario_tags.as_deref());
user_map.insert(r.skill_name, (aud, scn));
}
let resp: Vec<SkillListItemResponse> = items
.into_iter()
.map(|s| {
let (audience_tags, scenario_tags) = user_map
.get(&s.name)
.cloned()
.or_else(|| state.builtin_skill_tags.get(&s.name).cloned())
.unwrap_or_default();
SkillListItemResponse {
name: s.name,
description: s.description,
location: s.location,
relative_location: s.relative_location,
is_custom: s.is_custom,
source: to_source_response(s.source),
audience_tags,
scenario_tags,
}
})
.collect();
Ok(Json(ApiResponse::ok(resp)))
}
/// Decode a JSON-array TEXT column into a `Vec<String>`. Fail-soft on purpose
/// (intentionally unlike `nomifun-assistant`'s `decode_str_list`, which 500s on
/// bad JSON): this is the read path for the skill list, so one corrupted sidecar
/// row must not break the whole listing — it degrades to no tags for that skill.
fn decode_tags(raw: Option<&str>) -> Vec<String> {
match raw {
Some(s) if !s.is_empty() => serde_json::from_str(s).unwrap_or_default(),
_ => Vec::new(),
}
}
/// `PUT /api/skills/{name}/tags` — set a skill's tag assignment (user sidecar).
async fn set_skill_tags(
State(state): State<SkillRouterState>,
AxumPath(name): AxumPath<String>,
body: Result<Json<SetSkillTagsRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let aud = serde_json::to_string(&req.audience_tags).map_err(|e| AppError::Internal(e.to_string()))?;
let scn = serde_json::to_string(&req.scenario_tags).map_err(|e| AppError::Internal(e.to_string()))?;
state
.skill_tag_repo
.upsert(&nomifun_db::UpsertSkillTagParams {
skill_name: &name,
audience_tags: Some(&aud),
scenario_tags: Some(&scn),
})
.await
.map_err(AppError::from)?;
Ok(Json(ApiResponse::success()))
}
/// `GET /api/skills/builtin-auto` — list auto-injected built-in skills.
async fn list_builtin_auto_skills(
State(state): State<SkillRouterState>,
) -> Result<Json<ApiResponse<Vec<BuiltinAutoSkillResponse>>>, AppError> {
let items = skill_service::list_builtin_auto_skills(&state.skill_paths).await?;
let resp: Vec<BuiltinAutoSkillResponse> = items
.into_iter()
.map(|s| BuiltinAutoSkillResponse {
name: s.name,
description: s.description,
location: s.location,
})
.collect();
Ok(Json(ApiResponse::ok(resp)))
}
/// `POST /api/skills/info` — read skill info without importing.
async fn read_skill_info(
body: Result<Json<ReadSkillInfoRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<ReadSkillInfoResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let (name, description) = skill_service::read_skill_info(Path::new(&req.skill_path)).await?;
Ok(Json(ApiResponse::ok(ReadSkillInfoResponse { name, description })))
}
/// `GET /api/skills/paths` — get user and built-in skill directories.
async fn get_skill_paths(
State(state): State<SkillRouterState>,
) -> Result<Json<ApiResponse<SkillPathsResponse>>, AppError> {
let (user_dir, builtin_dir) = skill_service::get_skill_paths(&state.skill_paths);
Ok(Json(ApiResponse::ok(SkillPathsResponse {
user_skills_dir: user_dir,
builtin_skills_dir: builtin_dir,
})))
}
// ---------------------------------------------------------------------------
// Import / export / delete
// ---------------------------------------------------------------------------
/// `POST /api/skills/import` — import a skill by copying.
async fn import_skill(
State(state): State<SkillRouterState>,
body: Result<Json<ImportSkillRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<ImportSkillResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let name = skill_service::import_skill(&state.skill_paths, Path::new(&req.skill_path)).await?;
Ok(Json(ApiResponse::ok(ImportSkillResponse {
skill_name: name.clone(),
skill_names: vec![name],
})))
}
/// `POST /api/skills/import-symlink` — import a skill by symlink.
async fn import_skill_symlink(
State(state): State<SkillRouterState>,
body: Result<Json<ImportSkillRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<ImportSkillResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let names = skill_service::import_skills_with_symlink(&state.skill_paths, Path::new(&req.skill_path)).await?;
let first_name = names.first().cloned().unwrap_or_default();
Ok(Json(ApiResponse::ok(ImportSkillResponse {
skill_name: first_name,
skill_names: names,
})))
}
/// `POST /api/skills/export-symlink` — export a skill symlink.
async fn export_skill_symlink(
body: Result<Json<ExportSkillRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
skill_service::export_skill_with_symlink(Path::new(&req.skill_path), Path::new(&req.target_dir)).await?;
Ok(Json(ApiResponse::success()))
}
/// `DELETE /api/skills/:name` — delete a user-custom skill.
async fn delete_skill(
State(state): State<SkillRouterState>,
AxumPath(name): AxumPath<String>,
) -> Result<Json<ApiResponse<()>>, AppError> {
skill_service::delete_skill(&state.skill_paths, &name).await?;
Ok(Json(ApiResponse::success()))
}
// ---------------------------------------------------------------------------
// Scanning & discovery
// ---------------------------------------------------------------------------
/// `POST /api/skills/scan` — scan a directory for skills.
async fn scan_for_skills(
body: Result<Json<ScanForSkillsRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<ScanForSkillsResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let skills = skill_service::scan_for_skills(Path::new(&req.folder_path)).await?;
let resp = ScanForSkillsResponse {
skills: skills
.into_iter()
.map(|s| ScannedSkillResponse {
name: s.name,
description: s.description,
path: s.path,
})
.collect(),
};
Ok(Json(ApiResponse::ok(resp)))
}
/// `GET /api/skills/detect-paths` — detect common skill paths.
async fn detect_paths() -> Result<Json<ApiResponse<Vec<NamedPathResponse>>>, AppError> {
let paths = skill_service::detect_common_skill_paths().await;
let resp: Vec<NamedPathResponse> = paths
.into_iter()
.map(|p| NamedPathResponse {
name: p.name,
path: p.path,
})
.collect();
Ok(Json(ApiResponse::ok(resp)))
}
/// `GET /api/skills/detect-external` — discover external skills from all sources.
async fn detect_external(
State(state): State<SkillRouterState>,
) -> Result<Json<ApiResponse<Vec<ExternalSkillSourceResponse>>>, AppError> {
let custom = state.external_paths_manager.get_custom_external_paths().await;
let sources = skill_service::detect_and_count_external_skills(&custom).await;
let resp: Vec<ExternalSkillSourceResponse> = sources
.into_iter()
.map(|s| ExternalSkillSourceResponse {
name: s.name,
path: s.path,
source: s.source,
skill_count: s.skill_count,
skills: s
.skills
.into_iter()
.map(|sk| ScannedSkillResponse {
name: sk.name,
description: sk.description,
path: sk.path,
})
.collect(),
})
.collect();
Ok(Json(ApiResponse::ok(resp)))
}
// ---------------------------------------------------------------------------
// Built-in resources
// ---------------------------------------------------------------------------
/// `POST /api/skills/builtin-rule` — read a built-in rule file.
async fn read_builtin_rule(
State(state): State<SkillRouterState>,
body: Result<Json<ReadBuiltinResourceRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<String>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let content = skill_service::read_builtin_rule(&state.skill_paths, &req.file_name).await?;
Ok(Json(ApiResponse::ok(content)))
}
/// `POST /api/skills/builtin-skill` — read a built-in skill file.
async fn read_builtin_skill(
State(state): State<SkillRouterState>,
body: Result<Json<ReadBuiltinResourceRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<String>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
let content = skill_service::read_builtin_skill(&state.skill_paths, &req.file_name).await?;
Ok(Json(ApiResponse::ok(content)))
}
/// `POST /api/skills/materialize-for-agent` — resolve each requested skill
/// name to its on-disk source directory. The frontend symlinks each
/// returned `source_path` into the agent CLI's native skills dir. The
/// backend no longer copies any files per-conversation.
async fn materialize_for_agent(
State(state): State<SkillRouterState>,
body: Result<Json<MaterializeSkillsRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<MaterializeSkillsResponse>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
if req.conversation_id <= 0 {
return Err(AppError::BadRequest("conversationId must not be empty".into()));
}
let conversation_id = req.conversation_id.to_string();
let resolved =
skill_service::materialize_skills_for_agent(&state.skill_paths, &conversation_id, &req.skills).await?;
let skills: Vec<MaterializedSkillRef> = resolved
.into_iter()
.map(|s| MaterializedSkillRef {
name: s.name,
source_path: s.source_path.to_string_lossy().into_owned(),
})
.collect();
Ok(Json(ApiResponse::ok(MaterializeSkillsResponse { skills })))
}
// ---------------------------------------------------------------------------
// Assistant rules CRUD
// ---------------------------------------------------------------------------
/// `POST /api/skills/assistant-rule/read` — read an assistant rule.
///
/// Dispatches by source via [`AssistantRuleDispatcher`] when wired; falls
/// back to user-directory-only legacy behavior otherwise.
async fn read_assistant_rule(
State(state): State<SkillRouterState>,
body: Result<Json<ReadAssistantRuleRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<String>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
if let Some(dispatcher) = &state.assistant_dispatcher {
let content = dispatcher.read_rule(&req.assistant_id, req.locale.as_deref()).await?;
return Ok(Json(ApiResponse::ok(content)));
}
let content =
skill_service::read_assistant_rule(&state.skill_paths, &req.assistant_id, req.locale.as_deref()).await?;
Ok(Json(ApiResponse::ok(content)))
}
/// `POST /api/skills/assistant-rule/write` — write an assistant rule.
///
/// Dispatches by source: builtin / extension ids reject with 400.
async fn write_assistant_rule(
State(state): State<SkillRouterState>,
body: Result<Json<WriteAssistantRuleRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<bool>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
if let Some(dispatcher) = &state.assistant_dispatcher {
dispatcher
.write_rule(&req.assistant_id, req.locale.as_deref(), &req.content)
.await?;
return Ok(Json(ApiResponse::ok(true)));
}
let ok = skill_service::write_assistant_rule(
&state.skill_paths,
&req.assistant_id,
&req.content,
req.locale.as_deref(),
)
.await?;
Ok(Json(ApiResponse::ok(ok)))
}
/// `DELETE /api/skills/assistant-rule/:id` — delete all locale versions.
async fn delete_assistant_rule(
State(state): State<SkillRouterState>,
AxumPath(id): AxumPath<String>,
) -> Result<Json<ApiResponse<bool>>, AppError> {
if let Some(dispatcher) = &state.assistant_dispatcher {
let ok = dispatcher.delete_rule(&id).await?;
return Ok(Json(ApiResponse::ok(ok)));
}
let ok = skill_service::delete_assistant_rule(&state.skill_paths, &id).await?;
Ok(Json(ApiResponse::ok(ok)))
}
// ---------------------------------------------------------------------------
// Assistant skills CRUD
// ---------------------------------------------------------------------------
/// `POST /api/skills/assistant-skill/read` — read an assistant skill.
///
/// Dispatches by source via [`AssistantRuleDispatcher`] when wired.
async fn read_assistant_skill(
State(state): State<SkillRouterState>,
body: Result<Json<ReadAssistantRuleRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<String>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
if let Some(dispatcher) = &state.assistant_dispatcher {
let content = dispatcher.read_skill(&req.assistant_id, req.locale.as_deref()).await?;
return Ok(Json(ApiResponse::ok(content)));
}
let content =
skill_service::read_assistant_skill(&state.skill_paths, &req.assistant_id, req.locale.as_deref()).await?;
Ok(Json(ApiResponse::ok(content)))
}
/// `POST /api/skills/assistant-skill/write` — write an assistant skill.
///
/// Dispatches by source: builtin / extension ids reject with 400.
async fn write_assistant_skill(
State(state): State<SkillRouterState>,
body: Result<Json<WriteAssistantRuleRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<bool>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
if let Some(dispatcher) = &state.assistant_dispatcher {
dispatcher
.write_skill(&req.assistant_id, req.locale.as_deref(), &req.content)
.await?;
return Ok(Json(ApiResponse::ok(true)));
}
let ok = skill_service::write_assistant_skill(
&state.skill_paths,
&req.assistant_id,
&req.content,
req.locale.as_deref(),
)
.await?;
Ok(Json(ApiResponse::ok(ok)))
}
/// `DELETE /api/skills/assistant-skill/:id` — delete all locale versions.
async fn delete_assistant_skill(
State(state): State<SkillRouterState>,
AxumPath(id): AxumPath<String>,
) -> Result<Json<ApiResponse<bool>>, AppError> {
if let Some(dispatcher) = &state.assistant_dispatcher {
let ok = dispatcher.delete_skill(&id).await?;
return Ok(Json(ApiResponse::ok(ok)));
}
let ok = skill_service::delete_assistant_skill(&state.skill_paths, &id).await?;
Ok(Json(ApiResponse::ok(ok)))
}
// ---------------------------------------------------------------------------
// External path management
// ---------------------------------------------------------------------------
/// `GET /api/skills/external-paths` — list custom external paths.
async fn get_external_paths(
State(state): State<SkillRouterState>,
) -> Result<Json<ApiResponse<Vec<NamedPathResponse>>>, AppError> {
let paths = state.external_paths_manager.get_custom_external_paths().await;
let resp: Vec<NamedPathResponse> = paths
.into_iter()
.map(|p| NamedPathResponse {
name: p.name,
path: p.path,
})
.collect();
Ok(Json(ApiResponse::ok(resp)))
}
/// `POST /api/skills/external-paths` — add a custom external path.
async fn add_external_path(
State(state): State<SkillRouterState>,
body: Result<Json<AddExternalPathRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state
.external_paths_manager
.add_custom_external_path(&req.name, &req.path)
.await?;
Ok(Json(ApiResponse::success()))
}
/// `DELETE /api/skills/external-paths` — remove a custom external path.
async fn remove_external_path(
State(state): State<SkillRouterState>,
body: Result<Json<RemoveExternalPathRequest>, JsonRejection>,
) -> Result<Json<ApiResponse<()>>, AppError> {
let Json(req) = body.map_err(|e| AppError::BadRequest(e.to_string()))?;
state
.external_paths_manager
.remove_custom_external_path(&req.path)
.await?;
Ok(Json(ApiResponse::success()))
}
// ---------------------------------------------------------------------------
// Skills market
// ---------------------------------------------------------------------------
/// `POST /api/skills/market/enable` — enable the nomifun skills market.
async fn enable_skills_market(State(state): State<SkillRouterState>) -> Result<Json<ApiResponse<()>>, AppError> {
state.external_paths_manager.enable_skills_market().await?;
Ok(Json(ApiResponse::success()))
}
/// `POST /api/skills/market/disable` — disable the nomifun skills market.
async fn disable_skills_market(State(state): State<SkillRouterState>) -> Result<Json<ApiResponse<()>>, AppError> {
state.external_paths_manager.disable_skills_market().await?;
Ok(Json(ApiResponse::success()))
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[derive(Default)]
struct InMemorySkillTagRepo {
rows: std::sync::Mutex<Vec<nomifun_db::SkillTagRow>>,
}
#[async_trait::async_trait]
impl nomifun_db::ISkillTagRepository for InMemorySkillTagRepo {
async fn get_all(&self) -> Result<Vec<nomifun_db::SkillTagRow>, nomifun_db::DbError> {
Ok(self.rows.lock().unwrap().clone())
}
async fn upsert(
&self,
p: &nomifun_db::UpsertSkillTagParams<'_>,
) -> Result<nomifun_db::SkillTagRow, nomifun_db::DbError> {
let row = nomifun_db::SkillTagRow {
skill_name: p.skill_name.into(),
audience_tags: p.audience_tags.map(String::from),
scenario_tags: p.scenario_tags.map(String::from),
updated_at: 0,
};
let mut g = self.rows.lock().unwrap();
g.retain(|r| r.skill_name != row.skill_name);
g.push(row.clone());
Ok(row)
}
async fn delete(&self, name: &str) -> Result<bool, nomifun_db::DbError> {
let mut g = self.rows.lock().unwrap();
let before = g.len();
g.retain(|r| r.skill_name != name);
Ok(g.len() != before)
}
}
async fn make_state() -> SkillRouterState {
let tmp = tempfile::TempDir::new().unwrap();
let paths = SkillPaths {
data_dir: tmp.path().to_path_buf(),
user_skills_dir: tmp.path().join("skills"),
cron_skills_dir: tmp.path().join("cron").join("skills"),
builtin_skills_dir: tmp.path().join("builtin-skills"),
builtin_rules_dir: tmp.path().join("builtin-rules"),
assistant_rules_dir: tmp.path().join("assistant-rules"),
assistant_skills_dir: tmp.path().join("assistant-skills"),
};
let ext_mgr = Arc::new(ExternalPathsManager::with_file(tmp.path().join("paths.json")).await);
std::mem::forget(tmp);
SkillRouterState {
skill_paths: paths,
external_paths_manager: ext_mgr,
assistant_dispatcher: None,
skill_tag_repo: std::sync::Arc::new(InMemorySkillTagRepo::default()),
builtin_skill_tags: std::sync::Arc::new(std::collections::HashMap::new()),
}
}
#[tokio::test]
async fn skill_routes_builds_router() {
let state = make_state().await;
let _router = skill_routes(state);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,317 @@
//! Startup-time materialization of the embedded builtin skills corpus to
//! `{data_dir}/builtin-skills/`. Gated on a `.version` file so repeat
//! starts with the same binary skip the rewrite.
//!
//! Algorithm:
//! staging = data_dir/.builtin-skills.tmp (fresh each call)
//! write all BUILTIN_SKILLS entries into staging
//! write staging/.version ← binary version
//! atomic rename(target → .builtin-skills.old, staging → target)
//! best-effort remove .builtin-skills.old
//!
//! The atomic rename guarantees that concurrent backend processes, or a
//! crash mid-write, never observe a half-populated target — the old tree
//! stays in place until staging is fully ready.
use std::fs::OpenOptions;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::time::Duration;
use fs2::FileExt;
use include_dir::Dir;
use tracing::{info, warn};
use crate::error::ExtensionError;
const VERSION_FILE: &str = ".version";
const LOCK_FILE_NAME: &str = ".builtin-skills.lock";
const STAGING_DIR_NAME: &str = ".builtin-skills.tmp";
const OLD_DIR_NAME: &str = ".builtin-skills.old";
const STARTUP_FILE_RETRY_DELAYS: [Duration; 5] = [
Duration::from_millis(50),
Duration::from_millis(100),
Duration::from_millis(200),
Duration::from_millis(400),
Duration::from_millis(800),
];
/// Decide whether to materialize based on the `.version` file, then do it.
/// Returns `true` if a write happened, `false` if the gate said "skip".
///
/// When `BUILTIN_SKILLS_ENV_VAR` is set and non-empty, the caller has
/// already routed `builtin_skills_dir` at the env-var path — this
/// function still runs but the gate will see whatever version the dev
/// tree has on disk (or missing, and materialize into that dev path,
/// which is wrong). Callers MUST check the env var before calling.
pub async fn materialize_if_needed(
data_dir: &Path,
corpus: &Dir<'static>,
binary_version: &str,
) -> Result<bool, ExtensionError> {
let target = data_dir.join(crate::constants::BUILTIN_SKILLS_DIR_NAME);
if version_file_matches(&target, binary_version).await {
info!(
target = %target.display(),
version = binary_version,
"builtin skills up to date; skipping materialize"
);
return Ok(false);
}
info!(
target = %target.display(),
version = binary_version,
"materializing embedded builtin skills"
);
let _guard = MaterializeLockGuard::acquire(data_dir).await?;
if version_file_matches(&target, binary_version).await {
info!(
target = %target.display(),
version = binary_version,
"builtin skills up to date after materialize lock; skipping rewrite"
);
return Ok(false);
}
match materialize_embedded_builtin_skills_unlocked(data_dir, corpus, binary_version).await {
Ok(()) => {}
Err(e) if existing_builtin_skills_looks_usable(&target).await => {
warn!(
target = %target.display(),
version = binary_version,
error = %e,
"failed to refresh builtin skills; continuing with existing tree"
);
return Ok(false);
}
Err(e) => return Err(e),
}
Ok(true)
}
/// Read `.version` and compare against the provided `binary_version`.
/// Returns `true` only on exact match. Missing file / IO error /
/// mismatch all return `false`.
async fn version_file_matches(target: &Path, binary_version: &str) -> bool {
let version_path = target.join(VERSION_FILE);
match tokio::fs::read_to_string(&version_path).await {
Ok(s) => s == binary_version,
Err(_) => false,
}
}
/// Unconditional materialize: stage, write each file, atomic rename.
/// Exposed separately for tests that want to bypass the gate.
pub async fn materialize_embedded_builtin_skills(
data_dir: &Path,
corpus: &Dir<'static>,
binary_version: &str,
) -> Result<(), ExtensionError> {
let _guard = MaterializeLockGuard::acquire(data_dir).await?;
materialize_embedded_builtin_skills_unlocked(data_dir, corpus, binary_version).await
}
async fn materialize_embedded_builtin_skills_unlocked(
data_dir: &Path,
corpus: &Dir<'static>,
binary_version: &str,
) -> Result<(), ExtensionError> {
let target = data_dir.join(crate::constants::BUILTIN_SKILLS_DIR_NAME);
let staging = data_dir.join(STAGING_DIR_NAME);
let old = data_dir.join(OLD_DIR_NAME);
// Ensure data_dir itself exists before we try to write into it.
tokio::fs::create_dir_all(data_dir).await?;
// Clean any leftover staging from a previous crashed run.
if staging.exists() {
retry_startup_file_op("remove builtin skills staging dir", &staging, || {
tokio::fs::remove_dir_all(&staging)
})
.await
.map_err(|e| {
ExtensionError::Io(std::io::Error::new(
e.kind(),
format!("failed to clean staging dir {}: {e}", staging.display()),
))
})?;
}
tokio::fs::create_dir_all(&staging).await?;
write_dir_recursive(corpus, &staging).await?;
let version_path = staging.join(VERSION_FILE);
tokio::fs::write(&version_path, binary_version).await?;
// Move existing target out of the way, then move staging in.
if target.exists() {
if old.exists() {
// Tolerate leftover .old from a crashed rename sequence.
if let Err(e) = retry_startup_file_op("remove old builtin skills dir", &old, || {
tokio::fs::remove_dir_all(&old)
})
.await
{
warn!(
old = %old.display(),
error = %e,
"failed to remove stale old builtin skills tree before refresh"
);
}
}
retry_startup_file_op("rename builtin skills target to old", &target, || {
tokio::fs::rename(&target, &old)
})
.await?;
}
if let Err(e) = retry_startup_file_op("rename builtin skills staging to target", &staging, || {
tokio::fs::rename(&staging, &target)
})
.await
{
// Try to restore the original target so we don't leave the user
// with no builtin skills.
if old.exists()
&& let Err(restore_error) = retry_startup_file_op("restore old builtin skills target", &old, || {
tokio::fs::rename(&old, &target)
})
.await
{
warn!(
old = %old.display(),
target = %target.display(),
error = %restore_error,
"failed to restore old builtin skills tree after refresh failure"
);
}
return Err(ExtensionError::Io(std::io::Error::new(
e.kind(),
format!(
"atomic rename staging→target failed ({}{}): {e}",
staging.display(),
target.display()
),
)));
}
// Best-effort cleanup of the superseded tree.
if old.exists()
&& let Err(e) = retry_startup_file_op("remove superseded builtin skills dir", &old, || {
tokio::fs::remove_dir_all(&old)
})
.await
{
warn!(
old = %old.display(),
error = %e,
"failed to remove superseded builtin skills tree (leaving behind)"
);
}
Ok(())
}
async fn existing_builtin_skills_looks_usable(target: &Path) -> bool {
if !target.is_dir() {
return false;
}
tokio::fs::metadata(target.join(VERSION_FILE))
.await
.map(|metadata| metadata.is_file())
.unwrap_or(false)
}
async fn retry_startup_file_op<T, F, Fut>(operation: &str, path: &Path, mut op: F) -> std::io::Result<T>
where
F: FnMut() -> Fut,
Fut: Future<Output = std::io::Result<T>>,
{
for (attempt, delay) in STARTUP_FILE_RETRY_DELAYS.iter().enumerate() {
match op().await {
Ok(value) => return Ok(value),
Err(e) if is_retryable_startup_file_error(&e) => {
warn!(
operation,
path = %path.display(),
attempt = attempt + 1,
retry_after_ms = delay.as_millis(),
raw_os_error = ?e.raw_os_error(),
error = %e,
"Startup file operation failed; retrying"
);
tokio::time::sleep(*delay).await;
}
Err(e) => return Err(e),
}
}
op().await
}
fn is_retryable_startup_file_error(error: &std::io::Error) -> bool {
match error.kind() {
std::io::ErrorKind::Interrupted
| std::io::ErrorKind::PermissionDenied
| std::io::ErrorKind::TimedOut
| std::io::ErrorKind::WouldBlock => true,
_ => matches!(error.raw_os_error(), Some(5 | 32 | 33)),
}
}
struct MaterializeLockGuard {
file: std::fs::File,
}
impl MaterializeLockGuard {
async fn acquire(data_dir: &Path) -> std::io::Result<Self> {
let data_dir = data_dir.to_path_buf();
tokio::task::spawn_blocking(move || {
std::fs::create_dir_all(&data_dir)?;
let lock_path = data_dir.join(LOCK_FILE_NAME);
let file = OpenOptions::new()
.create(true)
.truncate(false)
.read(true)
.write(true)
.open(lock_path)?;
FileExt::lock_exclusive(&file)?;
Ok(Self { file })
})
.await
.map_err(|e| std::io::Error::other(format!("builtin skills lock task failed: {e}")))?
}
}
impl Drop for MaterializeLockGuard {
fn drop(&mut self) {
let _ = FileExt::unlock(&self.file);
}
}
/// Recursively copy every file in an `include_dir::Dir` tree into `dest`.
/// Directories are created as needed. Files overwrite silently.
async fn write_dir_recursive(dir: &Dir<'static>, dest: &Path) -> Result<(), ExtensionError> {
// The include_dir API is synchronous; we flatten into a Vec then
// feed the writes through tokio::fs to stay off the reactor's thread
// for big IO bursts.
let mut stack: Vec<(&Dir<'static>, PathBuf)> = vec![(dir, dest.to_path_buf())];
while let Some((d, prefix)) = stack.pop() {
for file in d.files() {
let rel = file.path();
let out_path = prefix.join(rel.strip_prefix(d.path()).unwrap_or(rel));
if let Some(parent) = out_path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
tokio::fs::write(&out_path, file.contents()).await?;
}
for sub in d.dirs() {
let sub_rel = sub.path();
let sub_dest = prefix.join(sub_rel.strip_prefix(d.path()).unwrap_or(sub_rel));
tokio::fs::create_dir_all(&sub_dest).await?;
stack.push((sub, sub_dest));
}
}
Ok(())
}
@@ -0,0 +1,595 @@
use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use serde::{Deserialize, Serialize};
use tokio::sync::{Mutex, Notify};
use tracing::{debug, error, warn};
use crate::constants::STATE_PERSIST_DEBOUNCE_MS;
use crate::error::ExtensionError;
use crate::types::ExtensionState;
// ---------------------------------------------------------------------------
// ExtensionStateStore
// ---------------------------------------------------------------------------
/// Manages loading and saving extension states to a JSON file with debounced
/// writes.
///
/// State is persisted to `extension-states.json`. Writes are debounced by
/// [`STATE_PERSIST_DEBOUNCE_MS`] to avoid excessive disk I/O when multiple
/// state changes happen in quick succession.
#[derive(Clone)]
pub struct ExtensionStateStore {
inner: Arc<Inner>,
}
struct Inner {
/// Path to the state JSON file.
file_path: PathBuf,
/// In-memory state map protected by a mutex.
states: Mutex<HashMap<String, ExtensionState>>,
/// Notifier used to trigger a debounced write.
write_notify: Notify,
/// Whether the background writer task has been spawned.
writer_spawned: Mutex<bool>,
}
const EXTENSION_STATES_FILE_ENV: &str = "NOMIFUN_EXTENSION_STATES_FILE";
const DEFAULT_STATES_FILE: &str = "extension-states.json";
#[derive(Debug, Deserialize, Serialize)]
struct PersistedStates {
version: u32,
#[serde(default)]
extensions: BTreeMap<String, PersistedExtensionState>,
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct PersistedExtensionState {
enabled: bool,
#[serde(default)]
installed: Option<bool>,
#[serde(default, alias = "lastVersion", rename = "lastVersion")]
last_version: Option<String>,
}
impl ExtensionStateStore {
/// Create a new store backed by the given file path.
pub fn new(file_path: PathBuf) -> Self {
Self {
inner: Arc::new(Inner {
file_path,
states: Mutex::new(HashMap::new()),
write_notify: Notify::new(),
writer_spawned: Mutex::new(false),
}),
}
}
/// Return the file path backing this store.
pub fn file_path(&self) -> &Path {
&self.inner.file_path
}
// -----------------------------------------------------------------------
// Load
// -----------------------------------------------------------------------
/// Load persisted states from disk into memory.
///
/// If the file does not exist, an empty map is used (all extensions will
/// default to enabled). Parse errors are propagated as `ExtensionError`.
pub async fn load(&self) -> Result<HashMap<String, ExtensionState>, ExtensionError> {
let states = load_states_from_file(&self.inner.file_path)?;
let mut guard = self.inner.states.lock().await;
*guard = states.clone();
Ok(states)
}
// -----------------------------------------------------------------------
// Read helpers
// -----------------------------------------------------------------------
/// Get the persisted state for a single extension (or `None` if unknown).
pub async fn get(&self, name: &str) -> Option<ExtensionState> {
let guard = self.inner.states.lock().await;
guard.get(name).cloned()
}
/// Snapshot of all current states.
pub async fn get_all(&self) -> HashMap<String, ExtensionState> {
let guard = self.inner.states.lock().await;
guard.clone()
}
// -----------------------------------------------------------------------
// Write (debounced)
// -----------------------------------------------------------------------
/// Update (or insert) the state for a single extension and schedule a
/// debounced write to disk.
pub async fn set(&self, state: ExtensionState) {
{
let mut guard = self.inner.states.lock().await;
guard.insert(state.name.clone(), state);
}
self.schedule_write().await;
}
/// Replace the entire state map and schedule a debounced write.
pub async fn set_all(&self, states: HashMap<String, ExtensionState>) {
{
let mut guard = self.inner.states.lock().await;
*guard = states;
}
self.schedule_write().await;
}
/// Remove the persisted state for an extension and schedule a write.
pub async fn remove(&self, name: &str) {
{
let mut guard = self.inner.states.lock().await;
guard.remove(name);
}
self.schedule_write().await;
}
// -----------------------------------------------------------------------
// Synchronous write (for shutdown or testing)
// -----------------------------------------------------------------------
/// Immediately write the current in-memory states to disk (no debounce).
pub async fn flush(&self) -> Result<(), ExtensionError> {
let snapshot = {
let guard = self.inner.states.lock().await;
guard.clone()
};
save_states_to_file(&self.inner.file_path, &snapshot)
}
// -----------------------------------------------------------------------
// Debounce internals
// -----------------------------------------------------------------------
/// Notify the background writer that a write is pending. Spawns the
/// background task on first call.
async fn schedule_write(&self) {
self.ensure_writer_spawned().await;
self.inner.write_notify.notify_one();
}
/// Spawn the background debounce writer if not already running.
async fn ensure_writer_spawned(&self) {
let mut spawned = self.inner.writer_spawned.lock().await;
if *spawned {
return;
}
*spawned = true;
let inner = Arc::clone(&self.inner);
tokio::spawn(async move {
loop {
inner.write_notify.notified().await;
// Debounce: wait for the configured duration, collapsing
// additional notifications.
tokio::time::sleep(std::time::Duration::from_millis(STATE_PERSIST_DEBOUNCE_MS)).await;
let snapshot = {
let guard = inner.states.lock().await;
guard.clone()
};
if let Err(e) = save_states_to_file(&inner.file_path, &snapshot) {
error!(error = %e, "failed to persist extension states");
} else {
debug!(path = %inner.file_path.display(), "extension states persisted");
}
}
});
}
}
// ---------------------------------------------------------------------------
// File I/O (pure functions, no async needed)
// ---------------------------------------------------------------------------
/// Load extension states from a JSON file.
///
/// Returns an empty map if the file does not exist.
pub fn load_states_from_file(path: &Path) -> Result<HashMap<String, ExtensionState>, ExtensionError> {
match std::fs::read(path) {
Ok(bytes) => parse_states_from_bytes(path, &bytes),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
debug!(path = %path.display(), "no state file found — starting fresh");
Ok(HashMap::new())
}
Err(e) => {
warn!(path = %path.display(), error = %e, "failed to read state file");
Err(ExtensionError::Io(e))
}
}
}
fn parse_states_from_bytes(path: &Path, bytes: &[u8]) -> Result<HashMap<String, ExtensionState>, ExtensionError> {
let persisted: PersistedStates = serde_json::from_slice(bytes)?;
if persisted.version != 1 {
return Err(ExtensionError::StatePersistence(format!(
"unsupported extension state file version {} at {}",
persisted.version,
path.display()
)));
}
Ok(persisted
.extensions
.into_iter()
.map(|(name, state)| {
let version = state.last_version.unwrap_or_default();
let installed_at = if state.installed == Some(true) { Some(0) } else { None };
(
name.clone(),
ExtensionState {
name,
version,
enabled: state.enabled,
installed_at,
last_activated_at: None,
},
)
})
.collect())
}
/// Write extension states to a JSON file atomically.
///
/// Creates parent directories if they do not exist.
pub fn save_states_to_file(path: &Path, states: &HashMap<String, ExtensionState>) -> Result<(), ExtensionError> {
// Ensure parent directory exists.
if let Some(parent) = path.parent()
&& !parent.exists()
{
std::fs::create_dir_all(parent)?;
}
// Collect into a stable map keyed by extension name to match the
// historical Electron format consumed by existing users.
let mut names: Vec<&String> = states.keys().collect();
names.sort();
let mut extensions = BTreeMap::new();
for name in names {
let state = &states[name];
extensions.insert(
name.clone(),
PersistedExtensionState {
enabled: state.enabled,
installed: state.installed_at.map(|_| true),
last_version: (!state.version.is_empty()).then(|| state.version.clone()),
},
);
}
let json = serde_json::to_string_pretty(&PersistedStates { version: 1, extensions })?;
// Write to a temp file then rename for atomicity.
let tmp_path = path.with_extension("json.tmp");
std::fs::write(&tmp_path, json.as_bytes())?;
std::fs::rename(&tmp_path, path)?;
Ok(())
}
/// Resolve the extension state file path using the historical Nomi rules.
///
/// Priority:
/// 1. `NOMIFUN_EXTENSION_STATES_FILE`
/// 2. `<data_dir>/extension-states.json`
pub fn resolve_state_file_path(data_dir: &Path) -> PathBuf {
resolve_state_file_path_inner(std::env::var_os(EXTENSION_STATES_FILE_ENV).as_ref(), data_dir)
}
fn resolve_state_file_path_inner(override_path: Option<&std::ffi::OsString>, data_dir: &Path) -> PathBuf {
if let Some(override_path) = override_path {
let trimmed = override_path.to_string_lossy().trim().to_owned();
if !trimmed.is_empty() {
return PathBuf::from(trimmed);
}
}
data_dir.join(DEFAULT_STATES_FILE)
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use nomifun_common::now_ms;
use tempfile::TempDir;
fn make_state(name: &str, version: &str, enabled: bool) -> ExtensionState {
ExtensionState {
name: name.to_string(),
version: version.to_string(),
enabled,
installed_at: Some(now_ms()),
last_activated_at: None,
}
}
// -- load_states_from_file / save_states_to_file -------------------------
#[test]
fn load_nonexistent_file_returns_empty() {
let result = load_states_from_file(Path::new("/nonexistent/states.json")).unwrap();
assert!(result.is_empty());
}
#[test]
fn save_and_load_roundtrip() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("states.json");
let mut states = HashMap::new();
states.insert("ext-a".to_string(), make_state("ext-a", "1.0.0", true));
states.insert("ext-b".to_string(), make_state("ext-b", "2.0.0", false));
save_states_to_file(&path, &states).unwrap();
let loaded = load_states_from_file(&path).unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded["ext-a"].version, "1.0.0");
assert!(loaded["ext-a"].enabled);
assert_eq!(loaded["ext-b"].version, "2.0.0");
assert!(!loaded["ext-b"].enabled);
}
#[test]
fn save_creates_parent_directories() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("nested").join("dir").join("states.json");
let states = HashMap::new();
save_states_to_file(&path, &states).unwrap();
assert!(path.exists());
}
#[test]
fn save_produces_sorted_output() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("states.json");
let mut states = HashMap::new();
states.insert("z-ext".to_string(), make_state("z-ext", "1.0.0", true));
states.insert("a-ext".to_string(), make_state("a-ext", "1.0.0", true));
save_states_to_file(&path, &states).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
let parsed: PersistedStates = serde_json::from_str(&raw).unwrap();
let ordered: Vec<&str> = parsed.extensions.keys().map(|k| k.as_str()).collect();
assert_eq!(ordered, vec!["a-ext", "z-ext"]);
}
#[test]
fn load_invalid_json_returns_error() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("states.json");
std::fs::write(&path, b"not valid json").unwrap();
let result = load_states_from_file(&path);
assert!(result.is_err());
}
#[test]
fn load_object_format_returns_states() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("states.json");
std::fs::write(
&path,
r#"{
"version": 1,
"extensions": {
"ext-a": {
"enabled": true,
"installed": true,
"lastVersion": "1.2.3"
},
"ext-b": {
"enabled": false
}
}
}"#,
)
.unwrap();
let loaded = load_states_from_file(&path).unwrap();
assert_eq!(loaded.len(), 2);
assert!(loaded["ext-a"].enabled);
assert_eq!(loaded["ext-a"].version, "1.2.3");
assert!(!loaded["ext-b"].enabled);
assert_eq!(loaded["ext-b"].version, "");
assert!(loaded["ext-a"].installed_at.is_some());
assert!(loaded["ext-a"].last_activated_at.is_none());
}
#[test]
fn save_preserves_object_format() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("states.json");
std::fs::write(
&path,
r#"{
"version": 1,
"extensions": {
"ext-a": {
"enabled": true,
"lastVersion": "1.2.3"
}
}
}"#,
)
.unwrap();
let loaded = load_states_from_file(&path).unwrap();
save_states_to_file(&path, &loaded).unwrap();
let raw = std::fs::read_to_string(&path).unwrap();
let parsed: PersistedStates = serde_json::from_str(&raw).unwrap();
assert_eq!(parsed.version, 1);
assert_eq!(parsed.extensions.len(), 1);
assert_eq!(parsed.extensions["ext-a"].last_version.as_deref(), Some("1.2.3"));
assert!(parsed.extensions["ext-a"].enabled);
}
#[test]
fn resolve_state_file_path_prefers_data_dir() {
let dir = TempDir::new().unwrap();
let path = resolve_state_file_path_inner(None, dir.path());
assert_eq!(path, dir.path().join("extension-states.json"));
}
#[test]
fn resolve_state_file_path_honors_env_override() {
let dir = TempDir::new().unwrap();
let override_path = dir.path().join("custom-states.json");
let override_os = override_path.as_os_str().to_os_string();
let path = resolve_state_file_path_inner(Some(&override_os), Path::new("/ignored"));
assert_eq!(path, override_path);
}
#[test]
fn save_atomic_write() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("states.json");
let mut states = HashMap::new();
states.insert("ext-a".to_string(), make_state("ext-a", "1.0.0", true));
save_states_to_file(&path, &states).unwrap();
// Temp file should be cleaned up.
let tmp_path = path.with_extension("json.tmp");
assert!(!tmp_path.exists());
}
// -- ExtensionStateStore (async) ------------------------------------------
#[tokio::test]
async fn store_load_nonexistent_file() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("nonexistent.json");
let store = ExtensionStateStore::new(path);
let states = store.load().await.unwrap();
assert!(states.is_empty());
}
#[tokio::test]
async fn store_set_and_get() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("states.json");
let store = ExtensionStateStore::new(path);
store.load().await.unwrap();
store.set(make_state("ext-a", "1.0.0", true)).await;
let state = store.get("ext-a").await;
assert!(state.is_some());
assert!(state.unwrap().enabled);
}
#[tokio::test]
async fn store_set_all_replaces_everything() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("states.json");
let store = ExtensionStateStore::new(path);
store.load().await.unwrap();
store.set(make_state("old", "1.0.0", true)).await;
let mut new_states = HashMap::new();
new_states.insert("new".to_string(), make_state("new", "2.0.0", false));
store.set_all(new_states).await;
assert!(store.get("old").await.is_none());
assert!(store.get("new").await.is_some());
}
#[tokio::test]
async fn store_remove() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("states.json");
let store = ExtensionStateStore::new(path);
store.load().await.unwrap();
store.set(make_state("ext-a", "1.0.0", true)).await;
assert!(store.get("ext-a").await.is_some());
store.remove("ext-a").await;
assert!(store.get("ext-a").await.is_none());
}
#[tokio::test]
async fn store_flush_persists_to_disk() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("states.json");
let store = ExtensionStateStore::new(path.clone());
store.load().await.unwrap();
store.set(make_state("ext-a", "1.0.0", true)).await;
store.flush().await.unwrap();
// Verify file exists and contains the state.
let loaded = load_states_from_file(&path).unwrap();
assert_eq!(loaded.len(), 1);
assert!(loaded.contains_key("ext-a"));
}
#[tokio::test]
async fn store_load_restores_existing_states() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("states.json");
// Pre-populate the file.
let mut states = HashMap::new();
states.insert("ext-a".to_string(), make_state("ext-a", "1.0.0", false));
save_states_to_file(&path, &states).unwrap();
// Load into a fresh store.
let store = ExtensionStateStore::new(path);
let loaded = store.load().await.unwrap();
assert_eq!(loaded.len(), 1);
assert!(!loaded["ext-a"].enabled);
// Memory state matches.
let state = store.get("ext-a").await.unwrap();
assert!(!state.enabled);
}
#[tokio::test]
async fn store_debounced_write() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join("states.json");
let store = ExtensionStateStore::new(path.clone());
store.load().await.unwrap();
// Multiple rapid writes should be collapsed.
for i in 0..5 {
store.set(make_state(&format!("ext-{i}"), "1.0.0", true)).await;
}
// Wait for debounce to settle.
tokio::time::sleep(std::time::Duration::from_millis(STATE_PERSIST_DEBOUNCE_MS + 200)).await;
let loaded = load_states_from_file(&path).unwrap();
assert_eq!(loaded.len(), 5);
}
}
@@ -0,0 +1,257 @@
use std::path::Path;
use crate::error::ExtensionError;
/// Resolve `${ENV_VAR}` placeholders in a string value.
///
/// - **Lenient mode** (default): undefined variables are replaced with empty string.
/// - **Strict mode** (`strict = true`): undefined variables return an error.
pub fn resolve_env_templates(value: &str, strict: bool) -> Result<String, ExtensionError> {
let mut result = String::with_capacity(value.len());
let mut chars = value.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '$' && chars.peek() == Some(&'{') {
chars.next(); // consume '{'
let var_name = collect_until_closing_brace(&mut chars);
if var_name.is_empty() {
// Malformed `${}` — pass through literally
result.push_str("${}");
continue;
}
match std::env::var(&var_name) {
Ok(val) => result.push_str(&val),
Err(_) if strict => {
return Err(ExtensionError::UndefinedEnvVariable(var_name));
}
Err(_) => { /* lenient: replace with empty string */ }
}
} else {
result.push(ch);
}
}
Ok(result)
}
/// Collect characters until `}` or end-of-string.
fn collect_until_closing_brace(chars: &mut std::iter::Peekable<std::str::Chars<'_>>) -> String {
let mut name = String::new();
for ch in chars.by_ref() {
if ch == '}' {
return name;
}
name.push(ch);
}
name
}
/// If `value` starts with `@file:`, read the referenced file content relative to `ext_dir`.
/// Otherwise return the value unchanged.
///
/// Path traversal protection: the resolved path must remain within `ext_dir`.
pub fn resolve_file_reference(value: &str, ext_dir: &Path) -> Result<String, ExtensionError> {
let Some(rel_path) = value.strip_prefix("@file:") else {
return Ok(value.to_owned());
};
if rel_path.is_empty() {
return Err(ExtensionError::FileReferenceNotFound("@file: with empty path".into()));
}
let full_path = ext_dir.join(rel_path);
if !full_path.exists() {
return Err(ExtensionError::FileReferenceNotFound(full_path.display().to_string()));
}
// Canonicalize both paths to resolve symlinks and `..` components,
// then verify the target stays within the extension directory.
let canonical_dir = ext_dir.canonicalize().map_err(ExtensionError::from)?;
let canonical_file = full_path.canonicalize().map_err(ExtensionError::from)?;
if !canonical_file.starts_with(&canonical_dir) {
return Err(ExtensionError::PathTraversal(rel_path.to_owned()));
}
std::fs::read_to_string(&canonical_file).map_err(ExtensionError::from)
}
/// Resolve all `${ENV_VAR}` placeholders in a map of key-value env entries.
pub fn resolve_env_map(
env: &std::collections::HashMap<String, String>,
strict: bool,
) -> Result<std::collections::HashMap<String, String>, ExtensionError> {
env.iter()
.map(|(k, v)| {
let resolved = resolve_env_templates(v, strict)?;
Ok((k.clone(), resolved))
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
// -- resolve_env_templates --
#[test]
fn test_no_placeholders() {
let result = resolve_env_templates("hello world", false).unwrap();
assert_eq!(result, "hello world");
}
#[test]
fn test_single_env_var() {
unsafe { std::env::set_var("_TEST_RESOLVE_SINGLE", "resolved_value") };
let result = resolve_env_templates("key=${_TEST_RESOLVE_SINGLE}", false).unwrap();
assert_eq!(result, "key=resolved_value");
unsafe { std::env::remove_var("_TEST_RESOLVE_SINGLE") };
}
#[test]
fn test_multiple_env_vars() {
unsafe { std::env::set_var("_TEST_A", "alpha") };
unsafe { std::env::set_var("_TEST_B", "beta") };
let result = resolve_env_templates("${_TEST_A} and ${_TEST_B}", false).unwrap();
assert_eq!(result, "alpha and beta");
unsafe { std::env::remove_var("_TEST_A") };
unsafe { std::env::remove_var("_TEST_B") };
}
#[test]
fn test_undefined_lenient_replaces_empty() {
let result = resolve_env_templates("val=${_NONEXISTENT_VAR_123}", false).unwrap();
assert_eq!(result, "val=");
}
#[test]
fn test_undefined_strict_returns_error() {
let err = resolve_env_templates("${_NONEXISTENT_VAR_456}", true).unwrap_err();
assert!(matches!(err, ExtensionError::UndefinedEnvVariable(ref v) if v == "_NONEXISTENT_VAR_456"));
}
#[test]
fn test_empty_braces_pass_through() {
let result = resolve_env_templates("before${}after", false).unwrap();
assert_eq!(result, "before${}after");
}
#[test]
fn test_dollar_without_brace_pass_through() {
let result = resolve_env_templates("cost is $50", false).unwrap();
assert_eq!(result, "cost is $50");
}
#[test]
fn test_nested_dollar_brace() {
unsafe { std::env::set_var("_TEST_NESTED", "inner") };
let result = resolve_env_templates("${_TEST_NESTED}", false).unwrap();
assert_eq!(result, "inner");
unsafe { std::env::remove_var("_TEST_NESTED") };
}
// -- resolve_file_reference --
#[test]
fn test_non_file_reference_unchanged() {
let result = resolve_file_reference("just a string", Path::new("/tmp")).unwrap();
assert_eq!(result, "just a string");
}
#[test]
fn test_file_reference_reads_content() {
let dir = std::env::temp_dir().join("ext_test_file_ref");
std::fs::create_dir_all(&dir).unwrap();
let file_path = dir.join("prompt.md");
std::fs::write(&file_path, "You are a helpful assistant.").unwrap();
let result = resolve_file_reference("@file:prompt.md", &dir).unwrap();
assert_eq!(result, "You are a helpful assistant.");
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn test_file_reference_not_found() {
let err = resolve_file_reference("@file:nonexistent.md", Path::new("/tmp/no_such_ext")).unwrap_err();
assert!(matches!(err, ExtensionError::FileReferenceNotFound(_)));
}
#[test]
fn test_file_reference_empty_path() {
let err = resolve_file_reference("@file:", Path::new("/tmp")).unwrap_err();
assert!(matches!(err, ExtensionError::FileReferenceNotFound(_)));
}
#[test]
fn test_file_reference_path_traversal_blocked() {
let dir = std::env::temp_dir().join("ext_test_traversal");
std::fs::create_dir_all(&dir).unwrap();
// Create a file outside the extension directory
let outside_file = std::env::temp_dir().join("ext_test_traversal_secret.txt");
std::fs::write(&outside_file, "secret data").unwrap();
let err = resolve_file_reference("@file:../ext_test_traversal_secret.txt", &dir).unwrap_err();
assert!(matches!(err, ExtensionError::PathTraversal(_)));
std::fs::remove_dir_all(&dir).unwrap();
std::fs::remove_file(&outside_file).unwrap();
}
#[test]
fn test_file_reference_nested_traversal_blocked() {
let dir = std::env::temp_dir().join("ext_test_nested_traversal");
let sub = dir.join("sub");
std::fs::create_dir_all(&sub).unwrap();
let outside_file = std::env::temp_dir().join("ext_test_nested_secret.txt");
std::fs::write(&outside_file, "nested secret").unwrap();
let err = resolve_file_reference("@file:sub/../../ext_test_nested_secret.txt", &dir).unwrap_err();
assert!(matches!(err, ExtensionError::PathTraversal(_)));
std::fs::remove_dir_all(&dir).unwrap();
std::fs::remove_file(&outside_file).unwrap();
}
#[test]
fn test_file_reference_valid_subdir_allowed() {
let dir = std::env::temp_dir().join("ext_test_valid_subdir");
let sub = dir.join("prompts");
std::fs::create_dir_all(&sub).unwrap();
std::fs::write(sub.join("system.md"), "valid content").unwrap();
let result = resolve_file_reference("@file:prompts/system.md", &dir).unwrap();
assert_eq!(result, "valid content");
std::fs::remove_dir_all(&dir).unwrap();
}
// -- resolve_env_map --
#[test]
fn test_resolve_env_map_lenient() {
unsafe { std::env::set_var("_TEST_MAP_KEY", "secret123") };
let mut env = HashMap::new();
env.insert("API_KEY".into(), "${_TEST_MAP_KEY}".into());
env.insert("STATIC".into(), "static_value".into());
let resolved = resolve_env_map(&env, false).unwrap();
assert_eq!(resolved["API_KEY"], "secret123");
assert_eq!(resolved["STATIC"], "static_value");
unsafe { std::env::remove_var("_TEST_MAP_KEY") };
}
#[test]
fn test_resolve_env_map_strict_error() {
let mut env = HashMap::new();
env.insert("KEY".into(), "${_MISSING_MAP_VAR}".into());
let err = resolve_env_map(&env, true).unwrap_err();
assert!(matches!(err, ExtensionError::UndefinedEnvVariable(_)));
}
}
@@ -0,0 +1,679 @@
use std::collections::HashMap;
use nomifun_common::TimestampMs;
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
// A. Permissions & Risk
// ---------------------------------------------------------------------------
/// Network access permission — either unrestricted (`true`) or domain-scoped.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(untagged)]
pub enum NetworkPermission {
/// Unrestricted network access (dangerous).
Unrestricted(bool),
/// Domain-scoped network access (moderate).
Scoped {
#[serde(rename = "allowedDomains")]
allowed_domains: Vec<String>,
reasoning: String,
},
}
/// Filesystem access scope.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum FilesystemScope {
ExtensionOnly,
Workspace,
Full,
}
/// Extension permission declarations.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ExtPermissions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub storage: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub network: Option<NetworkPermission>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shell: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub filesystem: Option<FilesystemScope>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub clipboard: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub active_user: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub events: Option<bool>,
}
/// Overall risk level derived from permission declarations.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RiskLevel {
Safe,
Moderate,
Dangerous,
}
/// Granularity of a single permission entry.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PermissionLevel {
None,
Limited,
Full,
}
/// A single permission detail for display purposes.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PermissionDetail {
pub permission: String,
pub level: PermissionLevel,
pub description: String,
}
/// Complete permission analysis summary.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct PermissionSummary {
pub permissions: ExtPermissions,
pub risk_level: RiskLevel,
pub details: Vec<PermissionDetail>,
}
// ---------------------------------------------------------------------------
// B. Contribution types (what an extension provides)
// ---------------------------------------------------------------------------
/// ACP adapter contributed by an extension.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExtAcpAdapter {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cli_command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_cli_path: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub acp_args: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub avatar: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_required: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_streaming: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connection_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub yolo_mode: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health_check: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub api_key_fields: Vec<serde_json::Value>,
}
/// MCP server contributed by an extension.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExtMcpServer {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(flatten)]
pub config: serde_json::Value,
}
/// Assistant contributed by an extension.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExtAssistant {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none", alias = "presetAgentType")]
pub preset_agent_type: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty", alias = "enabledSkills")]
pub enabled_skills: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prompts: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<String>,
}
/// Autonomous agent contributed by an extension.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExtAgent {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty", alias = "enabledSkills")]
pub enabled_skills: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prompts: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<String>,
}
/// Skill contributed by an extension.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExtSkill {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
/// Theme contributed by an extension.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExtTheme {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Relative path to the CSS file.
pub css_file: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cover_image: Option<String>,
}
/// Channel plugin contributed by an extension.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExtChannelPlugin {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_point: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty", alias = "credentialFields")]
pub credential_fields: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty", alias = "configFields")]
pub config_fields: Vec<serde_json::Value>,
}
/// WebUI route definition.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExtWebuiRoute {
pub path: String,
pub method: String,
pub handler: String,
}
/// WebUI contribution from an extension.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExtWebui {
pub id: String,
pub directory: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub routes: Vec<ExtWebuiRoute>,
}
/// Settings tab position relative to a built-in tab.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct SettingsTabPosition {
#[serde(rename = "relativeTo", alias = "anchor", alias = "relative_to")]
pub relative_to: String,
pub placement: String,
}
fn default_settings_tab_order() -> u32 {
100
}
/// Settings tab contributed by an extension.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExtSettingsTab {
pub id: String,
#[serde(alias = "name")]
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(alias = "entryPoint")]
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub position: Option<SettingsTabPosition>,
#[serde(default = "default_settings_tab_order")]
pub order: u32,
}
/// Model provider contributed by an extension.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExtModelProvider {
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub protocol: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<String>,
}
/// All contributions declared by an extension.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ExtContributes {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub acp_adapters: Vec<ExtAcpAdapter>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub mcp_servers: Vec<ExtMcpServer>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub assistants: Vec<ExtAssistant>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub agents: Vec<ExtAgent>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub skills: Vec<ExtSkill>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub themes: Vec<ExtTheme>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channel_plugins: Vec<ExtChannelPlugin>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub webui: Vec<ExtWebui>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub settings_tabs: Vec<ExtSettingsTab>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub model_providers: Vec<ExtModelProvider>,
}
// ---------------------------------------------------------------------------
// C. Extension manifest
// ---------------------------------------------------------------------------
/// i18n configuration block.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct I18nConfig {
pub locales: Vec<String>,
#[serde(default = "default_i18n_directory")]
pub directory: String,
}
fn default_i18n_directory() -> String {
"i18n".to_owned()
}
/// Engine compatibility declaration.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct EngineConfig {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub nomifun: Option<String>,
}
/// Lifecycle hook declarations (paths relative to extension root).
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct LifecycleHooks {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_install: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_uninstall: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_activate: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub on_deactivate: Option<String>,
}
/// Complete extension manifest parsed from `nomi-extension.json`.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExtensionManifest {
pub name: String,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub license: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub homepage: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub engine: Option<EngineConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub api_version: Option<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub dependencies: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_point: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub permissions: Option<ExtPermissions>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub contributes: Option<ExtContributes>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub lifecycle: Option<LifecycleHooks>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub i18n: Option<I18nConfig>,
}
// ---------------------------------------------------------------------------
// D. Extension runtime state
// ---------------------------------------------------------------------------
/// Where the extension was loaded from.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ExtensionSource {
Local,
Appdata,
Env,
}
/// Persisted state for an extension.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExtensionState {
pub name: String,
pub version: String,
pub enabled: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub installed_at: Option<TimestampMs>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_activated_at: Option<TimestampMs>,
}
/// A fully loaded extension with its manifest, location, and runtime state.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct LoadedExtension {
pub manifest: ExtensionManifest,
pub directory: String,
pub source: ExtensionSource,
pub state: ExtensionState,
}
// ---------------------------------------------------------------------------
// E. Extension system events
// ---------------------------------------------------------------------------
/// Events emitted by the extension system.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "SCREAMING_SNAKE_CASE")]
pub enum ExtensionSystemEvent {
ExtensionActivated,
ExtensionDeactivated,
ExtensionInstalled,
ExtensionUninstalled,
RegistryReloaded,
StatesPersisted,
}
/// Payload for extension lifecycle events (M-46).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ExtensionLifecyclePayload {
pub extension_name: String,
pub event: ExtensionSystemEvent,
pub timestamp: TimestampMs,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub data: Option<serde_json::Value>,
}
// ---------------------------------------------------------------------------
// F. Hub types
// ---------------------------------------------------------------------------
/// Installation status of a Hub extension.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HubExtensionStatus {
NotInstalled,
Installed,
UpdateAvailable,
Installing,
InstallFailed,
}
/// A Hub extension entry with runtime status.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HubExtensionWithStatus {
pub name: String,
pub version: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub author: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default)]
pub bundled: bool,
pub status: HubExtensionStatus,
}
// ---------------------------------------------------------------------------
// G. Resolved contribution types (post-processing output)
// ---------------------------------------------------------------------------
/// Resolved ACP adapter (after env template resolution).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ResolvedAcpAdapter {
pub extension_name: String,
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cli_command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub default_cli_path: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub acp_args: Vec<String>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub env: HashMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub avatar: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub auth_required: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supports_streaming: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connection_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub endpoint: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub yolo_mode: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub health_check: Option<serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub api_key_fields: Vec<serde_json::Value>,
}
/// Resolved MCP server (after env template resolution).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ResolvedMcpServer {
pub extension_name: String,
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(flatten)]
pub config: serde_json::Value,
}
/// Resolved assistant (after @file: and env template resolution).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ResolvedAssistant {
pub extension_name: String,
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub system_prompt: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preset_agent_type: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub enabled_skills: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prompts: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<String>,
}
/// Resolved agent (after @file: and env template resolution).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ResolvedAgent {
pub extension_name: String,
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_type: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub context: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub enabled_skills: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub prompts: Vec<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<String>,
}
/// Resolved skill contributed by an extension.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ResolvedSkill {
pub extension_name: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
}
/// Resolved theme (CSS content loaded into memory).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ResolvedTheme {
pub extension_name: String,
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub css_content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cover_image: Option<String>,
}
/// Resolved channel plugin.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ResolvedChannelPlugin {
pub extension_name: String,
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub platform: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_point: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub credential_fields: Vec<serde_json::Value>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub config_fields: Vec<serde_json::Value>,
}
/// Resolved WebUI contribution (after route validation).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct WebuiContribution {
pub extension_name: String,
pub id: String,
pub directory: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub routes: Vec<ExtWebuiRoute>,
}
/// Resolved settings tab (after position parsing).
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ResolvedSettingsTab {
#[serde(rename = "extensionName")]
pub extension_name: String,
pub id: String,
pub label: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<String>,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub position: Option<SettingsTabPosition>,
pub order: u32,
}
/// Resolved model provider.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ResolvedModelProvider {
pub extension_name: String,
pub id: String,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub protocol: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub models: Vec<String>,
}
// ---------------------------------------------------------------------------
// H. Resolved contributions container
// ---------------------------------------------------------------------------
/// All resolved contributions from enabled extensions.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
pub struct ResolvedContributions {
pub acp_adapters: Vec<ResolvedAcpAdapter>,
pub mcp_servers: Vec<ResolvedMcpServer>,
pub assistants: Vec<ResolvedAssistant>,
pub agents: Vec<ResolvedAgent>,
pub skills: Vec<ResolvedSkill>,
pub themes: Vec<ResolvedTheme>,
pub channel_plugins: Vec<ResolvedChannelPlugin>,
pub webui: Vec<WebuiContribution>,
pub settings_tabs: Vec<ResolvedSettingsTab>,
pub model_providers: Vec<ResolvedModelProvider>,
/// i18n data keyed by extension name, then by message key.
pub i18n: HashMap<String, HashMap<String, String>>,
}
#[cfg(test)]
#[path = "types_tests.rs"]
mod tests;
@@ -0,0 +1,558 @@
use super::*;
use serde_json::json;
// -- Permissions & Risk --
#[test]
fn test_risk_level_serde() {
assert_eq!(serde_json::to_string(&RiskLevel::Safe).unwrap(), r#""safe""#);
assert_eq!(serde_json::to_string(&RiskLevel::Moderate).unwrap(), r#""moderate""#);
assert_eq!(serde_json::to_string(&RiskLevel::Dangerous).unwrap(), r#""dangerous""#);
}
#[test]
fn test_network_permission_unrestricted() {
let perm = NetworkPermission::Unrestricted(true);
let json = serde_json::to_value(&perm).unwrap();
assert_eq!(json, json!(true));
}
#[test]
fn test_network_permission_scoped() {
let perm = NetworkPermission::Scoped {
allowed_domains: vec!["api.example.com".into()],
reasoning: "needed for API calls".into(),
};
let json = serde_json::to_value(&perm).unwrap();
assert_eq!(json["allowedDomains"], json!(["api.example.com"]));
assert_eq!(json["reasoning"], "needed for API calls");
}
#[test]
fn test_network_permission_scoped_deserialize() {
let raw = json!({"allowedDomains": ["a.com"], "reasoning": "test"});
let perm: NetworkPermission = serde_json::from_value(raw).unwrap();
assert!(matches!(perm, NetworkPermission::Scoped { .. }));
}
#[test]
fn test_filesystem_scope_serde() {
assert_eq!(
serde_json::to_string(&FilesystemScope::ExtensionOnly).unwrap(),
r#""extension-only""#
);
assert_eq!(
serde_json::to_string(&FilesystemScope::Workspace).unwrap(),
r#""workspace""#
);
assert_eq!(serde_json::to_string(&FilesystemScope::Full).unwrap(), r#""full""#);
}
#[test]
fn test_ext_permissions_empty() {
let perms = ExtPermissions::default();
let json = serde_json::to_value(&perms).unwrap();
assert_eq!(json, json!({}));
}
#[test]
fn test_ext_permissions_roundtrip() {
let perms = ExtPermissions {
storage: Some(true),
network: Some(NetworkPermission::Unrestricted(true)),
shell: Some(true),
filesystem: Some(FilesystemScope::Full),
clipboard: None,
active_user: None,
events: Some(true),
};
let json_str = serde_json::to_string(&perms).unwrap();
let parsed: ExtPermissions = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed, perms);
}
#[test]
fn test_permission_level_serde() {
let cases = [
(PermissionLevel::None, r#""none""#),
(PermissionLevel::Limited, r#""limited""#),
(PermissionLevel::Full, r#""full""#),
];
for (variant, expected) in cases {
assert_eq!(serde_json::to_string(&variant).unwrap(), expected);
}
}
// -- Contributions --
#[test]
fn test_ext_contributes_empty() {
let c = ExtContributes::default();
let json = serde_json::to_value(&c).unwrap();
assert_eq!(json, json!({}));
}
#[test]
fn test_ext_contributes_with_skills() {
let c = ExtContributes {
skills: vec![ExtSkill {
name: "my-skill".into(),
description: Some("A test skill".into()),
path: Some("skills/my-skill".into()),
}],
..Default::default()
};
let json = serde_json::to_value(&c).unwrap();
assert_eq!(json["skills"][0]["name"], "my-skill");
}
#[test]
fn test_ext_acp_adapter_minimal() {
let adapter = ExtAcpAdapter {
id: "claude-adapter".into(),
name: "Claude".into(),
description: None,
cli_command: Some("claude".into()),
default_cli_path: None,
acp_args: vec![],
env: HashMap::new(),
avatar: None,
auth_required: None,
supports_streaming: Some(true),
connection_type: None,
endpoint: None,
models: vec![],
yolo_mode: None,
health_check: None,
api_key_fields: vec![],
};
let json = serde_json::to_value(&adapter).unwrap();
assert_eq!(json["id"], "claude-adapter");
assert_eq!(json["cli_command"], "claude");
assert_eq!(json["supports_streaming"], true);
// Empty vecs should be omitted
assert!(json.get("acp_args").is_none());
}
#[test]
fn test_ext_theme_serde() {
let theme = ExtTheme {
id: "dark".into(),
name: "Dark Mode".into(),
description: Some("A dark theme".into()),
css_file: "themes/dark.css".into(),
cover_image: Some("images/dark-preview.png".into()),
};
let json = serde_json::to_value(&theme).unwrap();
assert_eq!(json["css_file"], "themes/dark.css");
assert_eq!(json["cover_image"], "images/dark-preview.png");
}
#[test]
fn test_ext_webui_with_routes() {
let webui = ExtWebui {
id: "my-panel".into(),
directory: "webui/dist".into(),
routes: vec![ExtWebuiRoute {
path: "/my-ext/api/data".into(),
method: "GET".into(),
handler: "handlers/data.js".into(),
}],
};
let json = serde_json::to_value(&webui).unwrap();
assert_eq!(json["routes"][0]["path"], "/my-ext/api/data");
assert_eq!(json["routes"][0]["method"], "GET");
}
#[test]
fn test_ext_settings_tab_with_position() {
let tab = ExtSettingsTab {
id: "ext-settings".into(),
label: "Extension Settings".into(),
icon: None,
url: "settings/index.html".into(),
position: Some(SettingsTabPosition {
relative_to: "general".into(),
placement: "after".into(),
}),
order: 80,
};
let json = serde_json::to_value(&tab).unwrap();
assert_eq!(json["position"]["relativeTo"], "general");
assert_eq!(json["position"]["placement"], "after");
assert_eq!(json["order"], 80);
}
#[test]
fn test_ext_settings_tab_accepts_legacy_field_aliases() {
let raw = json!({
"id": "legacy-settings",
"name": "Legacy Settings",
"entryPoint": "settings/legacy.html",
"position": {
"anchor": "general",
"placement": "after"
}
});
let tab: ExtSettingsTab = serde_json::from_value(raw).unwrap();
assert_eq!(tab.label, "Legacy Settings");
assert_eq!(tab.url, "settings/legacy.html");
assert_eq!(tab.position.unwrap().relative_to, "general");
assert_eq!(tab.order, 100);
}
// -- ExtMcpServer flatten roundtrip (M-50) --
#[test]
fn test_ext_mcp_server_roundtrip_with_extra_config() {
let raw = json!({
"id": "my-mcp",
"name": "My MCP Server",
"description": "A test MCP server",
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem"],
"transport": "stdio"
});
let server: ExtMcpServer = serde_json::from_value(raw.clone()).unwrap();
assert_eq!(server.id, "my-mcp");
assert_eq!(server.name, "My MCP Server");
assert_eq!(server.description.as_deref(), Some("A test MCP server"));
// Flattened config should contain the extra fields
let re_serialized = serde_json::to_value(&server).unwrap();
assert_eq!(re_serialized["command"], "npx");
assert_eq!(re_serialized["transport"], "stdio");
assert_eq!(re_serialized["id"], "my-mcp");
assert_eq!(re_serialized["name"], "My MCP Server");
}
#[test]
fn test_ext_mcp_server_minimal() {
let raw = json!({"id": "s1", "name": "S1"});
let server: ExtMcpServer = serde_json::from_value(raw).unwrap();
assert_eq!(server.id, "s1");
let re_serialized = serde_json::to_value(&server).unwrap();
assert_eq!(re_serialized["id"], "s1");
assert_eq!(re_serialized["name"], "S1");
}
// -- Manifest --
#[test]
fn test_manifest_minimal_deserialize() {
let raw = json!({
"name": "my-ext",
"version": "1.0.0"
});
let manifest: ExtensionManifest = serde_json::from_value(raw).unwrap();
assert_eq!(manifest.name, "my-ext");
assert_eq!(manifest.version, "1.0.0");
assert!(manifest.contributes.is_none());
assert!(manifest.permissions.is_none());
assert!(manifest.dependencies.is_empty());
}
#[test]
fn test_manifest_full_roundtrip() {
let manifest = ExtensionManifest {
name: "test-ext".into(),
version: "2.1.0".into(),
display_name: Some("Test Extension".into()),
description: Some("A test extension".into()),
author: Some("Test Author".into()),
license: Some("MIT".into()),
homepage: Some("https://example.com".into()),
icon: Some("icon.png".into()),
engine: Some(EngineConfig {
nomifun: Some("^1.0.0".into()),
}),
api_version: Some("1.0.0".into()),
dependencies: HashMap::from([("dep-ext".into(), "^1.0.0".into())]),
entry_point: Some("main.js".into()),
permissions: Some(ExtPermissions {
storage: Some(true),
events: Some(true),
..Default::default()
}),
contributes: Some(ExtContributes::default()),
lifecycle: Some(LifecycleHooks {
on_install: Some("scripts/install.sh".into()),
on_activate: Some("scripts/activate.sh".into()),
on_deactivate: None,
on_uninstall: None,
}),
i18n: Some(I18nConfig {
locales: vec!["en".into(), "zh-CN".into()],
directory: "i18n".into(),
}),
};
let json_str = serde_json::to_string(&manifest).unwrap();
let parsed: ExtensionManifest = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed, manifest);
}
#[test]
fn test_manifest_snake_case_keys() {
let manifest = ExtensionManifest {
name: "x".into(),
version: "1.0.0".into(),
display_name: Some("X".into()),
api_version: Some("1.0.0".into()),
entry_point: Some("main.js".into()),
description: None,
author: None,
license: None,
homepage: None,
icon: None,
engine: None,
dependencies: HashMap::new(),
permissions: None,
contributes: None,
lifecycle: None,
i18n: None,
};
let json = serde_json::to_value(&manifest).unwrap();
assert!(json.get("display_name").is_some());
assert!(json.get("api_version").is_some());
assert!(json.get("entry_point").is_some());
// camelCase keys should not exist
assert!(json.get("displayName").is_none());
assert!(json.get("apiVersion").is_none());
}
// -- Extension state & source --
#[test]
fn test_extension_source_serde() {
let cases = [
(ExtensionSource::Local, r#""local""#),
(ExtensionSource::Appdata, r#""appdata""#),
(ExtensionSource::Env, r#""env""#),
];
for (variant, expected) in cases {
let json = serde_json::to_string(&variant).unwrap();
assert_eq!(json, expected);
let parsed: ExtensionSource = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, variant);
}
}
#[test]
fn test_extension_state_roundtrip() {
let state = ExtensionState {
name: "my-ext".into(),
version: "1.0.0".into(),
enabled: true,
installed_at: Some(1700000000000),
last_activated_at: Some(1700001000000),
};
let json_str = serde_json::to_string(&state).unwrap();
let parsed: ExtensionState = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed, state);
}
#[test]
fn test_extension_state_optional_timestamps() {
let raw = json!({
"name": "x",
"version": "1.0.0",
"enabled": false
});
let state: ExtensionState = serde_json::from_value(raw).unwrap();
assert!(!state.enabled);
assert!(state.installed_at.is_none());
assert!(state.last_activated_at.is_none());
}
// -- Events --
#[test]
fn test_extension_system_event_serde() {
let cases = [
(ExtensionSystemEvent::ExtensionActivated, r#""EXTENSION_ACTIVATED""#),
(ExtensionSystemEvent::ExtensionDeactivated, r#""EXTENSION_DEACTIVATED""#),
(ExtensionSystemEvent::ExtensionInstalled, r#""EXTENSION_INSTALLED""#),
(ExtensionSystemEvent::ExtensionUninstalled, r#""EXTENSION_UNINSTALLED""#),
(ExtensionSystemEvent::RegistryReloaded, r#""REGISTRY_RELOADED""#),
(ExtensionSystemEvent::StatesPersisted, r#""STATES_PERSISTED""#),
];
for (variant, expected) in cases {
let json = serde_json::to_string(&variant).unwrap();
assert_eq!(json, expected);
let parsed: ExtensionSystemEvent = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, variant);
}
}
#[test]
fn test_lifecycle_payload_roundtrip() {
let payload = ExtensionLifecyclePayload {
extension_name: "my-ext".into(),
event: ExtensionSystemEvent::ExtensionActivated,
timestamp: 1700000000000,
data: Some(json!({"reason": "user action"})),
};
let json_str = serde_json::to_string(&payload).unwrap();
let parsed: ExtensionLifecyclePayload = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed, payload);
}
#[test]
fn test_lifecycle_payload_without_data() {
let payload = ExtensionLifecyclePayload {
extension_name: "test".into(),
event: ExtensionSystemEvent::RegistryReloaded,
timestamp: 1700000000000,
data: None,
};
let json = serde_json::to_value(&payload).unwrap();
assert!(json.get("data").is_none());
}
#[test]
fn test_resolved_settings_tab_serializes_backend_contract_keys() {
let tab = ResolvedSettingsTab {
extension_name: "hello".into(),
id: "ext-hello-settings".into(),
label: "Hello Settings".into(),
icon: Some("/api/extensions/hello/assets/icons/gear.svg".into()),
url: "/api/extensions/hello/assets/settings/index.html".into(),
position: Some(SettingsTabPosition {
relative_to: "general".into(),
placement: "after".into(),
}),
order: 80,
};
let json = serde_json::to_value(&tab).unwrap();
assert_eq!(json["extensionName"], "hello");
assert_eq!(json["position"]["relativeTo"], "general");
assert_eq!(json["order"], 80);
}
// -- Hub --
#[test]
fn test_hub_extension_status_serde() {
let cases = [
(HubExtensionStatus::NotInstalled, r#""not_installed""#),
(HubExtensionStatus::Installed, r#""installed""#),
(HubExtensionStatus::UpdateAvailable, r#""update_available""#),
(HubExtensionStatus::Installing, r#""installing""#),
(HubExtensionStatus::InstallFailed, r#""install_failed""#),
];
for (variant, expected) in cases {
let json = serde_json::to_string(&variant).unwrap();
assert_eq!(json, expected);
let parsed: HubExtensionStatus = serde_json::from_str(&json).unwrap();
assert_eq!(parsed, variant);
}
}
#[test]
fn test_hub_extension_with_status_roundtrip() {
let ext = HubExtensionWithStatus {
name: "cool-ext".into(),
version: "1.2.3".into(),
display_name: Some("Cool Extension".into()),
description: Some("Does cool things".into()),
author: Some("Author".into()),
icon: None,
tags: vec!["productivity".into()],
bundled: false,
status: HubExtensionStatus::Installed,
};
let json_str = serde_json::to_string(&ext).unwrap();
let parsed: HubExtensionWithStatus = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed, ext);
}
#[test]
fn test_hub_extension_bundled_status() {
let ext = HubExtensionWithStatus {
name: "builtin-ext".into(),
version: "1.0.0".into(),
display_name: None,
description: None,
author: None,
icon: None,
tags: vec![],
bundled: true,
status: HubExtensionStatus::Installed,
};
let json = serde_json::to_value(&ext).unwrap();
assert_eq!(json["bundled"], true);
assert_eq!(json["status"], "installed");
}
// -- Loaded extension --
#[test]
fn test_loaded_extension_roundtrip() {
let loaded = LoadedExtension {
manifest: ExtensionManifest {
name: "test".into(),
version: "1.0.0".into(),
display_name: None,
description: None,
author: None,
license: None,
homepage: None,
icon: None,
engine: None,
api_version: None,
dependencies: HashMap::new(),
entry_point: None,
permissions: None,
contributes: None,
lifecycle: None,
i18n: None,
},
directory: "/path/to/ext".into(),
source: ExtensionSource::Env,
state: ExtensionState {
name: "test".into(),
version: "1.0.0".into(),
enabled: true,
installed_at: None,
last_activated_at: None,
},
};
let json_str = serde_json::to_string(&loaded).unwrap();
let parsed: LoadedExtension = serde_json::from_str(&json_str).unwrap();
assert_eq!(parsed, loaded);
}
// -- I18n config --
#[test]
fn test_i18n_config_default_directory() {
let raw = json!({"locales": ["en"]});
let config: I18nConfig = serde_json::from_value(raw).unwrap();
assert_eq!(config.directory, "i18n");
}
#[test]
fn test_i18n_config_custom_directory() {
let raw = json!({"locales": ["en", "zh-CN"], "directory": "lang"});
let config: I18nConfig = serde_json::from_value(raw).unwrap();
assert_eq!(config.directory, "lang");
}
// -- Lifecycle hooks --
#[test]
fn test_lifecycle_hooks_empty() {
let hooks = LifecycleHooks::default();
let json = serde_json::to_value(&hooks).unwrap();
assert_eq!(json, json!({}));
}
#[test]
fn test_lifecycle_hooks_partial() {
let raw = json!({"on_install": "scripts/install.sh"});
let hooks: LifecycleHooks = serde_json::from_value(raw).unwrap();
assert_eq!(hooks.on_install.as_deref(), Some("scripts/install.sh"));
assert!(hooks.on_activate.is_none());
}
@@ -0,0 +1,219 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use notify::{Config, Event, RecommendedWatcher, RecursiveMode, Watcher};
use tokio::sync::{Notify, mpsc};
use tracing::{debug, error, info, warn};
use crate::constants::DEBOUNCE_MS;
use crate::registry::ExtensionRegistry;
// ---------------------------------------------------------------------------
// ExtensionWatcher
// ---------------------------------------------------------------------------
/// Watches extension directories for file changes and triggers a debounced
/// hot-reload of the [`ExtensionRegistry`].
///
/// Uses the `notify` crate for cross-platform file system event monitoring
/// and a custom debounce mechanism (1000ms) to collapse rapid changes into
/// a single reload.
pub struct ExtensionWatcher {
/// Handle to the notify watcher — kept alive to maintain the watch.
_watcher: RecommendedWatcher,
/// Signal to request a graceful shutdown of the debounce task.
shutdown: Arc<Notify>,
}
impl ExtensionWatcher {
/// Start watching the given directories for changes.
///
/// File change events are debounced by [`DEBOUNCE_MS`] milliseconds
/// before triggering `registry.hot_reload()`.
///
/// Returns `None` if no valid directories are provided or if the watcher
/// fails to initialise.
pub fn start(directories: Vec<PathBuf>, registry: ExtensionRegistry) -> Option<Self> {
if directories.is_empty() {
debug!("no directories to watch — skipping extension watcher");
return None;
}
let shutdown = Arc::new(Notify::new());
// Channel for raw FS events → debounce task.
let (tx, rx) = mpsc::channel::<()>(16);
// Spawn the debounce consumer.
let shutdown_clone = Arc::clone(&shutdown);
tokio::spawn(debounce_loop(rx, registry, shutdown_clone));
// Create the notify watcher with a callback that feeds the channel.
let watcher = create_watcher(tx, &directories);
let watcher = match watcher {
Ok(w) => w,
Err(e) => {
error!(error = %e, "failed to create file watcher");
return None;
}
};
info!(dirs = directories.len(), "extension watcher started");
Some(Self {
_watcher: watcher,
shutdown,
})
}
/// Signal the debounce task to stop.
///
/// The background task will finish its current cycle (if any) and exit.
pub fn stop(&self) {
self.shutdown.notify_one();
}
}
impl Drop for ExtensionWatcher {
fn drop(&mut self) {
self.shutdown.notify_one();
}
}
// ---------------------------------------------------------------------------
// Internal: watcher creation
// ---------------------------------------------------------------------------
/// Create a `RecommendedWatcher` that sends a unit signal for every relevant
/// file-system event.
fn create_watcher(tx: mpsc::Sender<()>, directories: &[PathBuf]) -> Result<RecommendedWatcher, notify::Error> {
let mut watcher = RecommendedWatcher::new(
move |result: Result<Event, notify::Error>| {
match result {
Ok(event) if is_relevant_event(&event) => {
// Best-effort send — if the channel is full we'll coalesce
// anyway via debounce.
let _ = tx.try_send(());
}
Ok(_) => {}
Err(e) => {
warn!(error = %e, "file watcher error");
}
}
},
Config::default(),
)?;
for dir in directories {
if dir.exists() {
if let Err(e) = watcher.watch(dir, RecursiveMode::Recursive) {
warn!(
dir = %dir.display(),
error = %e,
"failed to watch directory"
);
} else {
debug!(dir = %dir.display(), "watching directory");
}
} else {
debug!(dir = %dir.display(), "skipping non-existent directory");
}
}
Ok(watcher)
}
/// Decide whether a file-system event should trigger a reload.
///
/// We care about creates, all modifications (data, metadata, renames), and
/// removes. Only access events and unclassified `Other` events are ignored.
///
/// Note: `Modify(_)` intentionally matches all modify sub-kinds including
/// metadata changes, because some platforms (e.g., macOS/FSEvents) report
/// content changes as generic `Modify(Any)` rather than specific sub-kinds.
fn is_relevant_event(event: &Event) -> bool {
use notify::EventKind;
matches!(
event.kind,
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
)
}
// ---------------------------------------------------------------------------
// Internal: debounce loop
// ---------------------------------------------------------------------------
/// Consume raw FS event signals, debounce by [`DEBOUNCE_MS`], and trigger
/// `registry.hot_reload()`.
async fn debounce_loop(mut rx: mpsc::Receiver<()>, registry: ExtensionRegistry, shutdown: Arc<Notify>) {
let debounce = Duration::from_millis(DEBOUNCE_MS);
loop {
tokio::select! {
// Wait for the first event signal.
event = rx.recv() => {
if event.is_none() {
// Channel closed — sender (watcher) dropped.
debug!("watcher channel closed, stopping debounce loop");
break;
}
// Drain any additional events that arrived during debounce.
tokio::time::sleep(debounce).await;
while rx.try_recv().is_ok() {}
info!("file change detected, triggering hot reload");
registry.hot_reload().await;
}
// Shutdown signal.
_ = shutdown.notified() => {
debug!("watcher shutdown signal received");
break;
}
}
}
debug!("debounce loop exited");
}
// ---------------------------------------------------------------------------
// Unit tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn relevant_event_create() {
let event = Event::new(notify::EventKind::Create(notify::event::CreateKind::File));
assert!(is_relevant_event(&event));
}
#[test]
fn relevant_event_modify() {
let event = Event::new(notify::EventKind::Modify(notify::event::ModifyKind::Data(
notify::event::DataChange::Content,
)));
assert!(is_relevant_event(&event));
}
#[test]
fn relevant_event_remove() {
let event = Event::new(notify::EventKind::Remove(notify::event::RemoveKind::File));
assert!(is_relevant_event(&event));
}
#[test]
fn irrelevant_event_access() {
let event = Event::new(notify::EventKind::Access(notify::event::AccessKind::Read));
assert!(!is_relevant_event(&event));
}
#[test]
fn irrelevant_event_other() {
let event = Event::new(notify::EventKind::Other);
assert!(!is_relevant_event(&event));
}
}