Update: 将子项目从 submodule 转为完整内容
- 移除 GovAI, nomifun-tauri, 算力盒子 的 submodule 引用 - 添加所有子项目的完整源代码 - 保留原始 .git 为 .git.bak 备份
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "nomifun-extension"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
|
||||
[dependencies]
|
||||
nomifun-common.workspace = true
|
||||
nomifun-api-types.workspace = true
|
||||
nomifun-db.workspace = true
|
||||
nomifun-realtime.workspace = true
|
||||
nomifun-runtime.workspace = true
|
||||
async-trait.workspace = true
|
||||
axum.workspace = true
|
||||
dirs.workspace = true
|
||||
fs2.workspace = true
|
||||
include_dir.workspace = true
|
||||
semver.workspace = true
|
||||
notify.workspace = true
|
||||
tokio.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
thiserror.workspace = true
|
||||
tracing.workspace = true
|
||||
mime_guess.workspace = true
|
||||
zip.workspace = true
|
||||
|
||||
# NTFS junctions are used on Windows to materialize skill links without
|
||||
# requiring `SeCreateSymbolicLink` (Developer Mode / Admin). Pulled in
|
||||
# only on Windows targets to avoid building it on Linux/macOS.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
junction = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
async-trait.workspace = true
|
||||
http-body-util.workspace = true
|
||||
tempfile.workspace = true
|
||||
tower = { workspace = true, features = ["util"] }
|
||||
@@ -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(¤t_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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,432 @@
|
||||
//! Integration coverage for the `/api/skills/assistant-rule/*` and
|
||||
//! `/api/skills/assistant-skill/*` source dispatch introduced by T1b.
|
||||
//!
|
||||
//! Exercises the three dispatch paths (builtin → assets, extension → empty,
|
||||
//! user → writable dir) using a fake [`AssistantRuleDispatcher`] that
|
||||
//! captures call inputs so we can assert the handler routed through it.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::http::{Request, StatusCode};
|
||||
use http_body_util::BodyExt;
|
||||
use nomifun_api_types::{ApiResponse, AssistantSource};
|
||||
use nomifun_common::AppError;
|
||||
use nomifun_extension::classifier::{AssistantClassifier, AssistantRuleDispatcher};
|
||||
use nomifun_extension::external_paths::ExternalPathsManager;
|
||||
use nomifun_extension::skill_routes::{SkillRouterState, skill_routes};
|
||||
use nomifun_extension::skill_service::SkillPaths;
|
||||
use tower::ServiceExt;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fake dispatcher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[derive(Default)]
|
||||
struct CallLog {
|
||||
rule_reads: Vec<(String, Option<String>)>,
|
||||
rule_writes: Vec<(String, Option<String>, String)>,
|
||||
rule_deletes: Vec<String>,
|
||||
skill_reads: Vec<(String, Option<String>)>,
|
||||
skill_writes: Vec<(String, Option<String>, String)>,
|
||||
skill_deletes: Vec<String>,
|
||||
}
|
||||
|
||||
struct FakeDispatcher {
|
||||
// Pre-seeded responses by id.
|
||||
rule_content: std::collections::HashMap<String, String>,
|
||||
skill_content: std::collections::HashMap<String, String>,
|
||||
// Ids that should be treated as builtin (write rejects with 400).
|
||||
reject_writes_for: std::collections::HashSet<String>,
|
||||
log: Mutex<CallLog>,
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AssistantClassifier for FakeDispatcher {
|
||||
async fn classify(&self, id: &str) -> AssistantSource {
|
||||
if self.reject_writes_for.contains(id) {
|
||||
AssistantSource::Builtin
|
||||
} else {
|
||||
AssistantSource::User
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl AssistantRuleDispatcher for FakeDispatcher {
|
||||
async fn read_rule(&self, id: &str, locale: Option<&str>) -> Result<String, AppError> {
|
||||
self.log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.rule_reads
|
||||
.push((id.to_string(), locale.map(str::to_string)));
|
||||
Ok(self.rule_content.get(id).cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn write_rule(&self, id: &str, locale: Option<&str>, content: &str) -> Result<(), AppError> {
|
||||
if self.reject_writes_for.contains(id) {
|
||||
return Err(AppError::BadRequest("Cannot write rule for built-in assistant".into()));
|
||||
}
|
||||
self.log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.rule_writes
|
||||
.push((id.to_string(), locale.map(str::to_string), content.to_string()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_rule(&self, id: &str) -> Result<bool, AppError> {
|
||||
if self.reject_writes_for.contains(id) {
|
||||
return Err(AppError::BadRequest("Cannot delete rule for built-in assistant".into()));
|
||||
}
|
||||
self.log.lock().unwrap().rule_deletes.push(id.to_string());
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn read_skill(&self, id: &str, locale: Option<&str>) -> Result<String, AppError> {
|
||||
self.log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.skill_reads
|
||||
.push((id.to_string(), locale.map(str::to_string)));
|
||||
Ok(self.skill_content.get(id).cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
async fn write_skill(&self, id: &str, locale: Option<&str>, content: &str) -> Result<(), AppError> {
|
||||
if self.reject_writes_for.contains(id) {
|
||||
return Err(AppError::BadRequest("Cannot write skill for built-in assistant".into()));
|
||||
}
|
||||
self.log
|
||||
.lock()
|
||||
.unwrap()
|
||||
.skill_writes
|
||||
.push((id.to_string(), locale.map(str::to_string), content.to_string()));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_skill(&self, id: &str) -> Result<bool, AppError> {
|
||||
if self.reject_writes_for.contains(id) {
|
||||
return Err(AppError::BadRequest(
|
||||
"Cannot delete skill for built-in assistant".into(),
|
||||
));
|
||||
}
|
||||
self.log.lock().unwrap().skill_deletes.push(id.to_string());
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Router fixture
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async fn router_with_dispatcher(dispatcher: Arc<FakeDispatcher>) -> axum::Router {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let root: PathBuf = tmp.path().to_path_buf();
|
||||
let paths = SkillPaths {
|
||||
data_dir: root.clone(),
|
||||
user_skills_dir: root.join("skills"),
|
||||
cron_skills_dir: root.join("cron").join("skills"),
|
||||
builtin_skills_dir: root.join("builtin-skills"),
|
||||
builtin_rules_dir: root.join("builtin-rules"),
|
||||
assistant_rules_dir: root.join("assistant-rules"),
|
||||
assistant_skills_dir: root.join("assistant-skills"),
|
||||
};
|
||||
let ext_mgr = Arc::new(ExternalPathsManager::with_file(root.join("paths.json")).await);
|
||||
std::mem::forget(tmp);
|
||||
|
||||
let state = SkillRouterState {
|
||||
skill_paths: paths,
|
||||
external_paths_manager: ext_mgr,
|
||||
assistant_dispatcher: Some(dispatcher),
|
||||
skill_tag_repo: {
|
||||
let db = nomifun_db::init_database_memory().await.unwrap();
|
||||
Arc::new(nomifun_db::SqliteSkillTagRepository::new(db.pool().clone()))
|
||||
},
|
||||
builtin_skill_tags: Arc::new(std::collections::HashMap::new()),
|
||||
};
|
||||
skill_routes(state)
|
||||
}
|
||||
|
||||
async fn body_json<T: serde::de::DeserializeOwned>(resp: axum::response::Response) -> T {
|
||||
let body = resp.into_body().collect().await.unwrap().to_bytes();
|
||||
serde_json::from_slice(&body).unwrap()
|
||||
}
|
||||
|
||||
/// Construct the request body for rule/skill read using the wire field names.
|
||||
fn read_body(assistant_id: &str, locale: Option<&str>) -> Vec<u8> {
|
||||
let body = match locale {
|
||||
Some(loc) => serde_json::json!({ "assistant_id": assistant_id, "locale": loc }),
|
||||
None => serde_json::json!({ "assistant_id": assistant_id }),
|
||||
};
|
||||
serde_json::to_vec(&body).unwrap()
|
||||
}
|
||||
|
||||
fn write_body(assistant_id: &str, content: &str, locale: Option<&str>) -> Vec<u8> {
|
||||
let body = match locale {
|
||||
Some(loc) => serde_json::json!({
|
||||
"assistant_id": assistant_id,
|
||||
"content": content,
|
||||
"locale": loc,
|
||||
}),
|
||||
None => serde_json::json!({
|
||||
"assistant_id": assistant_id,
|
||||
"content": content,
|
||||
}),
|
||||
};
|
||||
serde_json::to_vec(&body).unwrap()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_rule_routes_through_dispatcher_for_builtin() {
|
||||
let mut rule_content = std::collections::HashMap::new();
|
||||
rule_content.insert("builtin-office".into(), "office rule body".into());
|
||||
let dispatcher = Arc::new(FakeDispatcher {
|
||||
rule_content,
|
||||
skill_content: Default::default(),
|
||||
reject_writes_for: Default::default(),
|
||||
log: Mutex::new(CallLog::default()),
|
||||
});
|
||||
let router = router_with_dispatcher(dispatcher.clone()).await;
|
||||
|
||||
let req_body = read_body("builtin-office", Some("en-US"));
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/skills/assistant-rule/read")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(req_body))
|
||||
.unwrap();
|
||||
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body: ApiResponse<String> = body_json(resp).await;
|
||||
assert_eq!(body.data.unwrap(), "office rule body");
|
||||
|
||||
let log = dispatcher.log.lock().unwrap();
|
||||
assert_eq!(log.rule_reads.len(), 1);
|
||||
assert_eq!(log.rule_reads[0].0, "builtin-office");
|
||||
assert_eq!(log.rule_reads[0].1.as_deref(), Some("en-US"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_rule_routes_through_dispatcher_for_user() {
|
||||
// Classification returns User by default (not in reject set).
|
||||
let dispatcher = Arc::new(FakeDispatcher {
|
||||
rule_content: std::collections::HashMap::from([("u1".into(), "user body".into())]),
|
||||
skill_content: Default::default(),
|
||||
reject_writes_for: Default::default(),
|
||||
log: Mutex::new(CallLog::default()),
|
||||
});
|
||||
let router = router_with_dispatcher(dispatcher.clone()).await;
|
||||
|
||||
let req_body = read_body("u1", None);
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/skills/assistant-rule/read")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(req_body))
|
||||
.unwrap();
|
||||
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body: ApiResponse<String> = body_json(resp).await;
|
||||
assert_eq!(body.data.unwrap(), "user body");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_rule_routes_through_dispatcher_for_extension_returns_empty() {
|
||||
let dispatcher = Arc::new(FakeDispatcher {
|
||||
rule_content: Default::default(), // no content for any id
|
||||
skill_content: Default::default(),
|
||||
reject_writes_for: Default::default(),
|
||||
log: Mutex::new(CallLog::default()),
|
||||
});
|
||||
let router = router_with_dispatcher(dispatcher).await;
|
||||
|
||||
let req_body = read_body("ext-assistant", Some("en-US"));
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/skills/assistant-rule/read")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(req_body))
|
||||
.unwrap();
|
||||
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body: ApiResponse<String> = body_json(resp).await;
|
||||
assert_eq!(body.data.unwrap(), "");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_rule_rejects_builtin() {
|
||||
let mut reject = std::collections::HashSet::new();
|
||||
reject.insert("builtin-office".to_string());
|
||||
let dispatcher = Arc::new(FakeDispatcher {
|
||||
rule_content: Default::default(),
|
||||
skill_content: Default::default(),
|
||||
reject_writes_for: reject,
|
||||
log: Mutex::new(CallLog::default()),
|
||||
});
|
||||
let router = router_with_dispatcher(dispatcher).await;
|
||||
|
||||
let req_body = write_body("builtin-office", "hack", None);
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/skills/assistant-rule/write")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(req_body))
|
||||
.unwrap();
|
||||
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_rule_allows_user() {
|
||||
let dispatcher = Arc::new(FakeDispatcher {
|
||||
rule_content: Default::default(),
|
||||
skill_content: Default::default(),
|
||||
reject_writes_for: Default::default(),
|
||||
log: Mutex::new(CallLog::default()),
|
||||
});
|
||||
let router = router_with_dispatcher(dispatcher.clone()).await;
|
||||
|
||||
let req_body = write_body("u1", "rule!", Some("en-US"));
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/skills/assistant-rule/write")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(req_body))
|
||||
.unwrap();
|
||||
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body: ApiResponse<bool> = body_json(resp).await;
|
||||
assert!(body.data.unwrap());
|
||||
|
||||
let log = dispatcher.log.lock().unwrap();
|
||||
assert_eq!(log.rule_writes.len(), 1);
|
||||
assert_eq!(log.rule_writes[0].2, "rule!");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_rule_rejects_builtin() {
|
||||
let mut reject = std::collections::HashSet::new();
|
||||
reject.insert("builtin-office".to_string());
|
||||
let dispatcher = Arc::new(FakeDispatcher {
|
||||
rule_content: Default::default(),
|
||||
skill_content: Default::default(),
|
||||
reject_writes_for: reject,
|
||||
log: Mutex::new(CallLog::default()),
|
||||
});
|
||||
let router = router_with_dispatcher(dispatcher).await;
|
||||
|
||||
let req = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri("/api/skills/assistant-rule/builtin-office")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_rule_user_dispatches() {
|
||||
let dispatcher = Arc::new(FakeDispatcher {
|
||||
rule_content: Default::default(),
|
||||
skill_content: Default::default(),
|
||||
reject_writes_for: Default::default(),
|
||||
log: Mutex::new(CallLog::default()),
|
||||
});
|
||||
let router = router_with_dispatcher(dispatcher.clone()).await;
|
||||
|
||||
let req = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri("/api/skills/assistant-rule/u1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let log = dispatcher.log.lock().unwrap();
|
||||
assert_eq!(log.rule_deletes, vec!["u1".to_string()]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_skill_routes_through_dispatcher_for_builtin() {
|
||||
let dispatcher = Arc::new(FakeDispatcher {
|
||||
rule_content: Default::default(),
|
||||
skill_content: std::collections::HashMap::from([("builtin-office".into(), "skill body".into())]),
|
||||
reject_writes_for: Default::default(),
|
||||
log: Mutex::new(CallLog::default()),
|
||||
});
|
||||
let router = router_with_dispatcher(dispatcher.clone()).await;
|
||||
|
||||
let req_body = read_body("builtin-office", Some("en-US"));
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/skills/assistant-skill/read")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(req_body))
|
||||
.unwrap();
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let body: ApiResponse<String> = body_json(resp).await;
|
||||
assert_eq!(body.data.unwrap(), "skill body");
|
||||
|
||||
let log = dispatcher.log.lock().unwrap();
|
||||
assert_eq!(log.skill_reads.len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_skill_rejects_builtin() {
|
||||
let mut reject = std::collections::HashSet::new();
|
||||
reject.insert("builtin-office".to_string());
|
||||
let dispatcher = Arc::new(FakeDispatcher {
|
||||
rule_content: Default::default(),
|
||||
skill_content: Default::default(),
|
||||
reject_writes_for: reject,
|
||||
log: Mutex::new(CallLog::default()),
|
||||
});
|
||||
let router = router_with_dispatcher(dispatcher).await;
|
||||
|
||||
let req_body = write_body("builtin-office", "x", None);
|
||||
let req = Request::builder()
|
||||
.method("POST")
|
||||
.uri("/api/skills/assistant-skill/write")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(req_body))
|
||||
.unwrap();
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_skill_user_dispatches() {
|
||||
let dispatcher = Arc::new(FakeDispatcher {
|
||||
rule_content: Default::default(),
|
||||
skill_content: Default::default(),
|
||||
reject_writes_for: Default::default(),
|
||||
log: Mutex::new(CallLog::default()),
|
||||
});
|
||||
let router = router_with_dispatcher(dispatcher.clone()).await;
|
||||
|
||||
let req = Request::builder()
|
||||
.method("DELETE")
|
||||
.uri("/api/skills/assistant-skill/u1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let resp = router.oneshot(req).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
|
||||
let log = dispatcher.log.lock().unwrap();
|
||||
assert_eq!(log.skill_deletes, vec!["u1".to_string()]);
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
//! Integration tests for contribution resolution (test-plan CR-1..CR-10).
|
||||
//!
|
||||
//! These are black-box tests that exercise the public resolver APIs with
|
||||
//! realistic extension manifests and file system fixtures.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use nomifun_extension::types::*;
|
||||
use nomifun_extension::{resolve_all_contributions, resolve_extension_contributions, resolve_i18n_for_all};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn make_loaded_extension(name: &str, dir: &str, contributes: 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: Some(contributes),
|
||||
lifecycle: None,
|
||||
i18n: None,
|
||||
},
|
||||
directory: dir.to_owned(),
|
||||
source: ExtensionSource::Local,
|
||||
state: ExtensionState {
|
||||
name: name.to_owned(),
|
||||
version: "1.0.0".to_owned(),
|
||||
enabled: true,
|
||||
installed_at: None,
|
||||
last_activated_at: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn make_loaded_extension_with_i18n(name: &str, dir: &str, i18n: I18nConfig) -> 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: None,
|
||||
lifecycle: None,
|
||||
i18n: Some(i18n),
|
||||
},
|
||||
directory: dir.to_owned(),
|
||||
source: ExtensionSource::Local,
|
||||
state: ExtensionState {
|
||||
name: name.to_owned(),
|
||||
version: "1.0.0".to_owned(),
|
||||
enabled: true,
|
||||
installed_at: None,
|
||||
last_activated_at: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CR-1: ACP Adapter resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cr1_acp_adapter_resolved_with_env_and_avatar() {
|
||||
unsafe { std::env::set_var("_CR1_API_KEY", "test-key-123") };
|
||||
|
||||
let mut env = HashMap::new();
|
||||
env.insert("API_KEY".into(), "${_CR1_API_KEY}".into());
|
||||
|
||||
let contributes = ExtContributes {
|
||||
acp_adapters: vec![ExtAcpAdapter {
|
||||
id: "claude-adapter".into(),
|
||||
name: "Claude Adapter".into(),
|
||||
description: Some("Claude via ACP".into()),
|
||||
cli_command: Some("claude".into()),
|
||||
default_cli_path: None,
|
||||
acp_args: vec!["--dangerously-skip-permissions".into()],
|
||||
env,
|
||||
avatar: Some("icons/claude.png".into()),
|
||||
auth_required: Some(true),
|
||||
supports_streaming: Some(true),
|
||||
connection_type: Some("stdio".into()),
|
||||
endpoint: None,
|
||||
models: vec!["claude-sonnet-4-20250514".into()],
|
||||
yolo_mode: Some(serde_json::json!({
|
||||
"type": "session"
|
||||
})),
|
||||
health_check: None,
|
||||
api_key_fields: vec![],
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ext = make_loaded_extension("claude-ext", "/ext/claude-ext", contributes);
|
||||
let result = resolve_extension_contributions(&ext);
|
||||
|
||||
assert_eq!(result.acp_adapters.len(), 1);
|
||||
let adapter = &result.acp_adapters[0];
|
||||
assert_eq!(adapter.extension_name, "claude-ext");
|
||||
assert_eq!(adapter.id, "claude-adapter");
|
||||
assert_eq!(adapter.cli_command.as_deref(), Some("claude"));
|
||||
assert_eq!(adapter.env["API_KEY"], "test-key-123");
|
||||
assert!(adapter.avatar.as_ref().unwrap().contains("icons/claude.png"));
|
||||
|
||||
unsafe { std::env::remove_var("_CR1_API_KEY") };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CR-2: MCP Server resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cr2_mcp_server_resolved_as_opaque_config() {
|
||||
let contributes = ExtContributes {
|
||||
mcp_servers: vec![ExtMcpServer {
|
||||
id: "sqlite-mcp".into(),
|
||||
name: "SQLite MCP".into(),
|
||||
description: Some("SQLite via MCP".into()),
|
||||
config: serde_json::json!({
|
||||
"command": "npx",
|
||||
"args": ["-y", "@anthropic/mcp-server-sqlite"],
|
||||
"transport": "stdio"
|
||||
}),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ext = make_loaded_extension("sqlite-ext", "/ext/sqlite-ext", contributes);
|
||||
let result = resolve_extension_contributions(&ext);
|
||||
|
||||
assert_eq!(result.mcp_servers.len(), 1);
|
||||
let server = &result.mcp_servers[0];
|
||||
assert_eq!(server.extension_name, "sqlite-ext");
|
||||
assert_eq!(server.id, "sqlite-mcp");
|
||||
assert_eq!(server.config["command"], "npx");
|
||||
assert_eq!(server.config["transport"], "stdio");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CR-3: Assistant resolution with @file: reference
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cr3_assistant_file_reference_resolved() {
|
||||
let dir = std::env::temp_dir().join("cr3_assistant_resolve");
|
||||
let prompts = dir.join("prompts");
|
||||
std::fs::create_dir_all(&prompts).unwrap();
|
||||
std::fs::write(prompts.join("system.md"), "You are a helpful coding assistant.").unwrap();
|
||||
|
||||
let contributes = ExtContributes {
|
||||
assistants: vec![ExtAssistant {
|
||||
id: "code-helper".into(),
|
||||
name: "Code Helper".into(),
|
||||
description: Some("AI coding assistant".into()),
|
||||
system_prompt: Some("@file:prompts/system.md".into()),
|
||||
icon: Some("icons/code.png".into()),
|
||||
context: None,
|
||||
preset_agent_type: Some("gemini".into()),
|
||||
enabled_skills: vec!["code-review".into()],
|
||||
prompts: vec!["Review this patch".into()],
|
||||
models: vec!["gemini-2.0-flash".into()],
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ext = make_loaded_extension("helper-ext", &dir.to_string_lossy(), contributes);
|
||||
let result = resolve_extension_contributions(&ext);
|
||||
|
||||
assert_eq!(result.assistants.len(), 1);
|
||||
let assistant = &result.assistants[0];
|
||||
assert_eq!(assistant.extension_name, "helper-ext");
|
||||
assert_eq!(
|
||||
assistant.system_prompt.as_deref(),
|
||||
Some("You are a helpful coding assistant.")
|
||||
);
|
||||
assert_eq!(assistant.preset_agent_type.as_deref(), Some("gemini"));
|
||||
assert_eq!(assistant.enabled_skills, vec!["code-review"]);
|
||||
assert_eq!(assistant.prompts, vec!["Review this patch"]);
|
||||
assert_eq!(assistant.models, vec!["gemini-2.0-flash"]);
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CR-4: Agent resolution with @file: reference
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cr4_agent_file_reference_resolved() {
|
||||
let dir = std::env::temp_dir().join("cr4_agent_resolve");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("agent_ctx.md"), "Agent context loaded from file.").unwrap();
|
||||
|
||||
let contributes = ExtContributes {
|
||||
agents: vec![ExtAgent {
|
||||
id: "auto-agent".into(),
|
||||
name: "Auto Agent".into(),
|
||||
description: Some("Autonomous coding agent".into()),
|
||||
agent_type: Some("claude".into()),
|
||||
context: Some("@file:agent_ctx.md".into()),
|
||||
icon: None,
|
||||
enabled_skills: vec!["ship-it".into()],
|
||||
prompts: vec!["Fix the build".into()],
|
||||
models: vec!["claude-sonnet-4".into()],
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ext = make_loaded_extension("agent-ext", &dir.to_string_lossy(), contributes);
|
||||
let result = resolve_extension_contributions(&ext);
|
||||
|
||||
assert_eq!(result.agents.len(), 1);
|
||||
let agent = &result.agents[0];
|
||||
assert_eq!(agent.extension_name, "agent-ext");
|
||||
assert_eq!(agent.agent_type.as_deref(), Some("claude"));
|
||||
assert_eq!(agent.context.as_deref(), Some("Agent context loaded from file."));
|
||||
assert_eq!(agent.enabled_skills, vec!["ship-it"]);
|
||||
assert_eq!(agent.prompts, vec!["Fix the build"]);
|
||||
assert_eq!(agent.models, vec!["claude-sonnet-4"]);
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CR-5: Skill resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cr5_skill_resolved_with_path() {
|
||||
let dir = std::env::temp_dir().join("cr5_skill_resolved_with_path");
|
||||
std::fs::create_dir_all(dir.join("skills")).unwrap();
|
||||
std::fs::write(dir.join("skills/code-review.md"), "# review").unwrap();
|
||||
|
||||
let contributes = ExtContributes {
|
||||
skills: vec![ExtSkill {
|
||||
name: "code-review".into(),
|
||||
description: Some("Review code for quality".into()),
|
||||
path: Some("skills/code-review.md".into()),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ext = make_loaded_extension("skill-ext", &dir.to_string_lossy(), contributes);
|
||||
let result = resolve_extension_contributions(&ext);
|
||||
|
||||
assert_eq!(result.skills.len(), 1);
|
||||
let skill = &result.skills[0];
|
||||
assert_eq!(skill.extension_name, "skill-ext");
|
||||
assert_eq!(skill.name, "code-review");
|
||||
assert!(skill.path.as_ref().unwrap().contains("skills/code-review"));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CR-6: Theme resolution (CSS content loaded)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cr6_theme_css_content_loaded() {
|
||||
let dir = std::env::temp_dir().join("cr6_theme_resolve");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("dark.css"), ":root { --bg: #1a1a2e; --text: #eaeaea; }").unwrap();
|
||||
|
||||
let contributes = ExtContributes {
|
||||
themes: vec![ExtTheme {
|
||||
id: "dark-theme".into(),
|
||||
name: "Dark Theme".into(),
|
||||
description: Some("A dark color scheme".into()),
|
||||
css_file: "dark.css".into(),
|
||||
cover_image: Some("images/dark-preview.png".into()),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ext = make_loaded_extension("theme-ext", &dir.to_string_lossy(), contributes);
|
||||
let result = resolve_extension_contributions(&ext);
|
||||
|
||||
assert_eq!(result.themes.len(), 1);
|
||||
let theme = &result.themes[0];
|
||||
assert_eq!(theme.extension_name, "theme-ext");
|
||||
assert_eq!(theme.css_content, ":root { --bg: #1a1a2e; --text: #eaeaea; }");
|
||||
assert!(theme.cover_image.as_ref().unwrap().contains("images/dark-preview.png"));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CR-7: WebUI route namespace validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cr7_webui_valid_namespace_resolves() {
|
||||
let contributes = ExtContributes {
|
||||
webui: vec![ExtWebui {
|
||||
id: "dashboard".into(),
|
||||
directory: "dist".into(),
|
||||
routes: vec![ExtWebuiRoute {
|
||||
path: "/my-ext/dashboard".into(),
|
||||
method: "GET".into(),
|
||||
handler: "handler.js".into(),
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ext = make_loaded_extension("my-ext", "/ext/my-ext", contributes);
|
||||
let result = resolve_extension_contributions(&ext);
|
||||
|
||||
assert_eq!(result.webui.len(), 1);
|
||||
assert_eq!(result.webui[0].extension_name, "my-ext");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cr7_webui_wrong_namespace_rejected() {
|
||||
let contributes = ExtContributes {
|
||||
webui: vec![ExtWebui {
|
||||
id: "bad-route".into(),
|
||||
directory: "dist".into(),
|
||||
routes: vec![ExtWebuiRoute {
|
||||
path: "/other-ext/api".into(),
|
||||
method: "GET".into(),
|
||||
handler: "handler.js".into(),
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ext = make_loaded_extension("my-ext", "/ext/my-ext", contributes);
|
||||
let result = resolve_extension_contributions(&ext);
|
||||
|
||||
// Invalid route should be filtered out
|
||||
assert!(result.webui.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cr7_webui_reserved_prefix_rejected() {
|
||||
let contributes = ExtContributes {
|
||||
webui: vec![ExtWebui {
|
||||
id: "reserved".into(),
|
||||
directory: "dist".into(),
|
||||
routes: vec![ExtWebuiRoute {
|
||||
path: "/api/data".into(),
|
||||
method: "GET".into(),
|
||||
handler: "handler.js".into(),
|
||||
}],
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Extension name "api" would match namespace but /api/ is reserved
|
||||
let ext = make_loaded_extension("api", "/ext/api", contributes);
|
||||
let result = resolve_extension_contributions(&ext);
|
||||
assert!(result.webui.is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CR-8: Settings tab with position
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cr8_settings_tab_position_preserved() {
|
||||
let contributes = ExtContributes {
|
||||
settings_tabs: vec![ExtSettingsTab {
|
||||
id: "ext-settings".into(),
|
||||
label: "My Extension".into(),
|
||||
icon: Some("icons/gear.svg".into()),
|
||||
url: "settings/index.html".into(),
|
||||
position: Some(SettingsTabPosition {
|
||||
relative_to: "general".into(),
|
||||
placement: "after".into(),
|
||||
}),
|
||||
order: 80,
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ext = make_loaded_extension("my-ext", "/ext/my-ext", contributes);
|
||||
let result = resolve_extension_contributions(&ext);
|
||||
|
||||
assert_eq!(result.settings_tabs.len(), 1);
|
||||
let tab = &result.settings_tabs[0];
|
||||
assert_eq!(tab.extension_name, "my-ext");
|
||||
assert_eq!(tab.id, "ext-my-ext-ext-settings");
|
||||
assert_eq!(tab.url, "/api/extensions/my-ext/assets/settings/index.html");
|
||||
assert_eq!(
|
||||
tab.icon.as_deref(),
|
||||
Some("/api/extensions/my-ext/assets/icons/gear.svg")
|
||||
);
|
||||
assert_eq!(tab.order, 80);
|
||||
let pos = tab.position.as_ref().unwrap();
|
||||
assert_eq!(pos.relative_to, "general");
|
||||
assert_eq!(pos.placement, "after");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CR-9: Model provider resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cr9_model_provider_resolved() {
|
||||
let contributes = ExtContributes {
|
||||
model_providers: vec![ExtModelProvider {
|
||||
id: "custom-provider".into(),
|
||||
name: "Custom LLM".into(),
|
||||
description: Some("Custom model provider".into()),
|
||||
protocol: Some("openai".into()),
|
||||
base_url: Some("https://api.custom.com/v1".into()),
|
||||
models: vec!["custom-model-1".into(), "custom-model-2".into()],
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ext = make_loaded_extension("provider-ext", "/ext/provider-ext", contributes);
|
||||
let result = resolve_extension_contributions(&ext);
|
||||
|
||||
assert_eq!(result.model_providers.len(), 1);
|
||||
let provider = &result.model_providers[0];
|
||||
assert_eq!(provider.extension_name, "provider-ext");
|
||||
assert_eq!(provider.id, "custom-provider");
|
||||
assert_eq!(provider.protocol.as_deref(), Some("openai"));
|
||||
assert_eq!(provider.models.len(), 2);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CR-10: i18n data loading
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cr10_i18n_data_loaded_for_supported_locale() {
|
||||
let dir = std::env::temp_dir().join("cr10_i18n_resolve");
|
||||
let i18n_dir = dir.join("i18n");
|
||||
std::fs::create_dir_all(&i18n_dir).unwrap();
|
||||
std::fs::write(
|
||||
i18n_dir.join("zh-CN.json"),
|
||||
r#"{"greeting": "你好", "settings.title": "设置"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let ext = make_loaded_extension_with_i18n(
|
||||
"i18n-ext",
|
||||
&dir.to_string_lossy(),
|
||||
I18nConfig {
|
||||
locales: vec!["en".into(), "zh-CN".into()],
|
||||
directory: "i18n".into(),
|
||||
},
|
||||
);
|
||||
|
||||
let result = resolve_i18n_for_all(&[ext], "zh-CN");
|
||||
assert_eq!(result.len(), 1);
|
||||
let messages = &result["i18n-ext"];
|
||||
assert_eq!(messages["greeting"], "你好");
|
||||
assert_eq!(messages["settings.title"], "设置");
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cr10_i18n_unsupported_locale_returns_empty() {
|
||||
let dir = std::env::temp_dir().join("cr10_i18n_unsupported");
|
||||
let i18n_dir = dir.join("i18n");
|
||||
std::fs::create_dir_all(&i18n_dir).unwrap();
|
||||
std::fs::write(i18n_dir.join("en.json"), r#"{"key": "value"}"#).unwrap();
|
||||
|
||||
let ext = make_loaded_extension_with_i18n(
|
||||
"en-only-ext",
|
||||
&dir.to_string_lossy(),
|
||||
I18nConfig {
|
||||
locales: vec!["en".into()],
|
||||
directory: "i18n".into(),
|
||||
},
|
||||
);
|
||||
|
||||
let result = resolve_i18n_for_all(&[ext], "fr");
|
||||
assert!(result.is_empty());
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Cross-cutting: resolve_all_contributions merges and filters
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn resolve_all_merges_contributions_from_multiple_extensions() {
|
||||
let dir = std::env::temp_dir().join("resolve_all_merges_contributions_from_multiple_extensions");
|
||||
std::fs::create_dir_all(dir.join("a/skills")).unwrap();
|
||||
std::fs::create_dir_all(dir.join("b/skills")).unwrap();
|
||||
std::fs::write(dir.join("a/skills/skill-a.md"), "# a").unwrap();
|
||||
std::fs::write(dir.join("b/skills/skill-b.md"), "# b").unwrap();
|
||||
|
||||
let ext_a = make_loaded_extension(
|
||||
"ext-a",
|
||||
&dir.join("a").to_string_lossy(),
|
||||
ExtContributes {
|
||||
skills: vec![ExtSkill {
|
||||
name: "skill-a".into(),
|
||||
description: None,
|
||||
path: Some("skills/skill-a.md".into()),
|
||||
}],
|
||||
model_providers: vec![ExtModelProvider {
|
||||
id: "mp-a".into(),
|
||||
name: "Provider A".into(),
|
||||
description: None,
|
||||
protocol: None,
|
||||
base_url: None,
|
||||
models: vec![],
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
let ext_b = make_loaded_extension(
|
||||
"ext-b",
|
||||
&dir.join("b").to_string_lossy(),
|
||||
ExtContributes {
|
||||
skills: vec![ExtSkill {
|
||||
name: "skill-b".into(),
|
||||
description: None,
|
||||
path: Some("skills/skill-b.md".into()),
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
|
||||
let result = resolve_all_contributions(&[ext_a, ext_b]);
|
||||
assert_eq!(result.skills.len(), 2);
|
||||
assert_eq!(result.model_providers.len(), 1);
|
||||
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_all_skips_disabled_extensions() {
|
||||
let mut ext = make_loaded_extension(
|
||||
"disabled-ext",
|
||||
"/ext/disabled",
|
||||
ExtContributes {
|
||||
skills: vec![ExtSkill {
|
||||
name: "hidden".into(),
|
||||
description: None,
|
||||
path: None,
|
||||
}],
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
ext.state.enabled = false;
|
||||
|
||||
let result = resolve_all_contributions(&[ext]);
|
||||
assert!(result.skills.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channel_plugin_resolved_with_entry_point() {
|
||||
let contributes = ExtContributes {
|
||||
channel_plugins: vec![ExtChannelPlugin {
|
||||
id: "slack".into(),
|
||||
name: "Slack".into(),
|
||||
description: Some("Slack channel plugin".into()),
|
||||
platform: Some("slack".into()),
|
||||
entry_point: Some("plugins/slack.js".into()),
|
||||
icon: Some("icons/slack.png".into()),
|
||||
credential_fields: vec![serde_json::json!({ "key": "token" })],
|
||||
config_fields: vec![serde_json::json!({ "key": "channel" })],
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let ext = make_loaded_extension("channel-ext", "/ext/channel-ext", contributes);
|
||||
let result = resolve_extension_contributions(&ext);
|
||||
|
||||
assert_eq!(result.channel_plugins.len(), 1);
|
||||
assert_eq!(result.channel_plugins[0].extension_name, "channel-ext");
|
||||
assert!(
|
||||
result.channel_plugins[0]
|
||||
.entry_point
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains("plugins/slack.js")
|
||||
);
|
||||
assert_eq!(result.channel_plugins[0].credential_fields.len(), 1);
|
||||
assert_eq!(result.channel_plugins[0].config_fields.len(), 1);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
use nomifun_extension::{resolve_skill_paths, skill_service};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn unique_temp_dir(label: &str) -> std::path::PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("nomifun-extension-{label}-{}-{nanos}", std::process::id()))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resolve_skill_paths_includes_cron_skills_dir() {
|
||||
let base = unique_temp_dir("cron-paths");
|
||||
std::fs::create_dir_all(&base).unwrap();
|
||||
|
||||
let paths = resolve_skill_paths(&base, &base);
|
||||
assert_eq!(paths.cron_skills_dir, base.join("cron").join("skills"));
|
||||
|
||||
std::fs::remove_dir_all(&base).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn materialize_resolves_saved_cron_skill() {
|
||||
let base = unique_temp_dir("cron-materialize");
|
||||
let skill_dir = base.join("cron").join("skills").join("cron-job-123");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
"---\nname: cron-job-123\ndescription: Saved cron skill\n---\nUse the saved steps.",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let paths = resolve_skill_paths(&base, &base);
|
||||
let resolved = skill_service::materialize_skills_for_agent(&paths, "conv-1", &["cron-job-123".to_owned()])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert_eq!(resolved[0].name, "cron-job-123");
|
||||
assert_eq!(resolved[0].source_path, skill_dir);
|
||||
assert!(resolved[0].source_path.join("SKILL.md").exists());
|
||||
|
||||
std::fs::remove_dir_all(&base).unwrap();
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
//! Integration tests for dependency management (test-plan DM-1 through DM-9).
|
||||
//!
|
||||
//! Black-box tests exercising the public API surface of
|
||||
//! `nomifun_extension::dependency`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use nomifun_extension::{
|
||||
DependencyIssue, ExtensionManifest, ExtensionSource, ExtensionState, LoadedExtension, validate_dependencies,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn ext(name: &str, version: &str, deps: &[(&str, &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: 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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DM-1: satisfied deps load in order
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn dm1_satisfied_deps_load_in_order() {
|
||||
let exts = vec![
|
||||
ext("ext-a", "1.0.0", &[("ext-b", "^1.0.0")]),
|
||||
ext("ext-b", "1.2.0", &[]),
|
||||
];
|
||||
let result = validate_dependencies(&exts);
|
||||
assert!(result.valid);
|
||||
assert!(result.issues.is_empty());
|
||||
assert_eq!(result.load_order, vec!["ext-b", "ext-a"]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DM-2: missing dependency detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn dm2_missing_dependency_detected() {
|
||||
let exts = vec![ext("ext-a", "1.0.0", &[("ext-missing", "^1.0.0")])];
|
||||
let result = validate_dependencies(&exts);
|
||||
assert!(!result.valid);
|
||||
assert_eq!(result.issues.len(), 1);
|
||||
match &result.issues[0] {
|
||||
DependencyIssue::Missing {
|
||||
extension,
|
||||
dependency,
|
||||
required,
|
||||
} => {
|
||||
assert_eq!(extension, "ext-a");
|
||||
assert_eq!(dependency, "ext-missing");
|
||||
assert_eq!(required, "^1.0.0");
|
||||
}
|
||||
other => panic!("expected Missing issue, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DM-3: version mismatch detection (exact)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn dm3_exact_version_mismatch() {
|
||||
let exts = vec![ext("ext-b", "1.5.0", &[]), ext("ext-a", "1.0.0", &[("ext-b", "2.0.0")])];
|
||||
let result = validate_dependencies(&exts);
|
||||
assert!(!result.valid);
|
||||
let mismatch = result
|
||||
.issues
|
||||
.iter()
|
||||
.find(|i| matches!(i, DependencyIssue::VersionMismatch { .. }))
|
||||
.expect("should contain VersionMismatch");
|
||||
match mismatch {
|
||||
DependencyIssue::VersionMismatch { required, actual, .. } => {
|
||||
assert_eq!(required, "2.0.0");
|
||||
assert_eq!(actual, "1.5.0");
|
||||
}
|
||||
_ => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DM-4: caret (^) match success
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn dm4_caret_match_succeeds() {
|
||||
let exts = vec![
|
||||
ext("base", "1.9.0", &[]),
|
||||
ext("consumer", "1.0.0", &[("base", "^1.2.3")]),
|
||||
];
|
||||
let result = validate_dependencies(&exts);
|
||||
assert!(result.valid);
|
||||
assert!(result.issues.is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DM-5: caret (^) match failure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn dm5_caret_match_fails() {
|
||||
let exts = vec![
|
||||
ext("base", "2.0.0", &[]),
|
||||
ext("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 { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DM-6: tilde (~) match success
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn dm6_tilde_match_succeeds() {
|
||||
let exts = vec![
|
||||
ext("base", "1.2.9", &[]),
|
||||
ext("consumer", "1.0.0", &[("base", "~1.2.3")]),
|
||||
];
|
||||
let result = validate_dependencies(&exts);
|
||||
assert!(result.valid);
|
||||
assert!(result.issues.is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DM-7: tilde (~) match failure
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn dm7_tilde_match_fails() {
|
||||
let exts = vec![
|
||||
ext("base", "1.3.0", &[]),
|
||||
ext("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 { .. }))
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DM-8: circular dependency detection
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn dm8_circular_dependency_detected() {
|
||||
let exts = vec![
|
||||
ext("ext-a", "1.0.0", &[("ext-c", "^1.0.0")]),
|
||||
ext("ext-b", "1.0.0", &[("ext-a", "^1.0.0")]),
|
||||
ext("ext-c", "1.0.0", &[("ext-b", "^1.0.0")]),
|
||||
];
|
||||
let result = validate_dependencies(&exts);
|
||||
assert!(!result.valid);
|
||||
|
||||
let circulars: Vec<&Vec<String>> = result
|
||||
.issues
|
||||
.iter()
|
||||
.filter_map(|i| match i {
|
||||
DependencyIssue::Circular { cycle } => Some(cycle),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
assert!(!circulars.is_empty(), "should detect at least one cycle");
|
||||
|
||||
// Verify cycle path closes (first == last) and contains all three.
|
||||
let cycle = &circulars[0];
|
||||
assert_eq!(cycle.first(), cycle.last(), "cycle must close");
|
||||
let cycle_members: std::collections::HashSet<&str> = cycle.iter().map(|s| s.as_str()).collect();
|
||||
assert!(cycle_members.contains("ext-a"));
|
||||
assert!(cycle_members.contains("ext-b"));
|
||||
assert!(cycle_members.contains("ext-c"));
|
||||
|
||||
// Still attempt to load all extensions.
|
||||
assert_eq!(result.load_order.len(), 3);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DM-9: extensions with no dependencies
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn dm9_no_deps_all_valid() {
|
||||
let exts = vec![
|
||||
ext("ext-x", "1.0.0", &[]),
|
||||
ext("ext-y", "2.0.0", &[]),
|
||||
ext("ext-z", "0.1.0", &[]),
|
||||
];
|
||||
let result = validate_dependencies(&exts);
|
||||
assert!(result.valid);
|
||||
assert!(result.issues.is_empty());
|
||||
assert_eq!(result.load_order.len(), 3);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn mixed_acyclic_and_cyclic_subsets() {
|
||||
// c is acyclic; a↔b form a cycle.
|
||||
let exts = vec![
|
||||
ext("a", "1.0.0", &[("b", "^1.0.0")]),
|
||||
ext("b", "1.0.0", &[("a", "^1.0.0")]),
|
||||
ext("c", "1.0.0", &[]),
|
||||
];
|
||||
let result = validate_dependencies(&exts);
|
||||
assert!(!result.valid);
|
||||
|
||||
// c should come before the cyclic pair in load order.
|
||||
let pos = |n: &str| result.load_order.iter().position(|x| x == n).unwrap();
|
||||
assert!(pos("c") < pos("a"));
|
||||
assert!(pos("c") < pos("b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_chain_preserves_order() {
|
||||
// e → d → c → b → a
|
||||
let exts = vec![
|
||||
ext("a", "1.0.0", &[]),
|
||||
ext("b", "1.0.0", &[("a", "^1.0.0")]),
|
||||
ext("c", "1.0.0", &[("b", "^1.0.0")]),
|
||||
ext("d", "1.0.0", &[("c", "^1.0.0")]),
|
||||
ext("e", "1.0.0", &[("d", "^1.0.0")]),
|
||||
];
|
||||
let result = validate_dependencies(&exts);
|
||||
assert!(result.valid);
|
||||
assert_eq!(result.load_order, vec!["a", "b", "c", "d", "e"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_missing_deps_all_reported() {
|
||||
let exts = vec![ext("ext-a", "1.0.0", &[("dep-1", "^1.0.0"), ("dep-2", "~2.0.0")])];
|
||||
let result = validate_dependencies(&exts);
|
||||
assert!(!result.valid);
|
||||
let missing: Vec<_> = result
|
||||
.issues
|
||||
.iter()
|
||||
.filter(|i| matches!(i, DependencyIssue::Missing { .. }))
|
||||
.collect();
|
||||
assert_eq!(missing.len(), 2);
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
//! Integration tests for extension loading (test-plan EL-1..EL-5).
|
||||
//!
|
||||
//! These tests exercise `load_all` and `filter_by_engine_compatibility` as
|
||||
//! black-box functions, verifying scan priority, engine filtering, invalid
|
||||
//! manifest handling, E2E isolation, and empty directory behaviour.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use nomifun_extension::{ExtensionSource, ScanPath, filter_by_engine_compatibility, load_all};
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn write_manifest(dir: &Path, name: &str, version: &str) {
|
||||
write_manifest_full(dir, name, version, None, None);
|
||||
}
|
||||
|
||||
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["api_version"] = serde_json::json!(api);
|
||||
}
|
||||
fs::write(
|
||||
dir.join("nomi-extension.json"),
|
||||
serde_json::to_vec_pretty(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn create_ext_dir(parent: &Path, name: &str) -> std::path::PathBuf {
|
||||
let dir = parent.join(name);
|
||||
fs::create_dir_all(&dir).unwrap();
|
||||
dir
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EL-1: Scan priority — env > user > appdata, same-name deduplication
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn el1_scan_priority_env_wins_over_local() {
|
||||
let env_dir = TempDir::new().unwrap();
|
||||
let local_dir = TempDir::new().unwrap();
|
||||
let appdata_dir = TempDir::new().unwrap();
|
||||
|
||||
// Same extension name in all three directories with different versions.
|
||||
let ext = create_ext_dir(env_dir.path(), "my-ext");
|
||||
write_manifest(&ext, "my-ext", "3.0.0");
|
||||
|
||||
let ext = create_ext_dir(local_dir.path(), "my-ext");
|
||||
write_manifest(&ext, "my-ext", "2.0.0");
|
||||
|
||||
let ext = create_ext_dir(appdata_dir.path(), "my-ext");
|
||||
write_manifest(&ext, "my-ext", "1.0.0");
|
||||
|
||||
let scan_paths = vec![
|
||||
ScanPath {
|
||||
path: env_dir.path().to_path_buf(),
|
||||
source: ExtensionSource::Env,
|
||||
},
|
||||
ScanPath {
|
||||
path: local_dir.path().to_path_buf(),
|
||||
source: ExtensionSource::Local,
|
||||
},
|
||||
ScanPath {
|
||||
path: appdata_dir.path().to_path_buf(),
|
||||
source: ExtensionSource::Appdata,
|
||||
},
|
||||
];
|
||||
|
||||
let loaded = load_all(&scan_paths);
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].manifest.version, "3.0.0");
|
||||
assert_eq!(loaded[0].source, ExtensionSource::Env);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EL-2: Engine compatibility filtering
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn el2_engine_incompatible_extension_filtered_out() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
// Extension requires nomifun ^2.0.0 but app is 1.5.0.
|
||||
let ext = create_ext_dir(tmp.path(), "future-ext");
|
||||
write_manifest_full(&ext, "future-ext", "1.0.0", Some("^2.0.0"), None);
|
||||
|
||||
// Compatible extension.
|
||||
let ext2 = create_ext_dir(tmp.path(), "good-ext");
|
||||
write_manifest_full(&ext2, "good-ext", "1.0.0", Some("^1.0.0"), None);
|
||||
|
||||
let scan = vec![ScanPath {
|
||||
path: tmp.path().to_path_buf(),
|
||||
source: ExtensionSource::Local,
|
||||
}];
|
||||
|
||||
let loaded = load_all(&scan);
|
||||
assert_eq!(loaded.len(), 2);
|
||||
|
||||
let filtered = filter_by_engine_compatibility(loaded, "1.5.0");
|
||||
assert_eq!(filtered.len(), 1);
|
||||
assert_eq!(filtered[0].manifest.name, "good-ext");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EL-3: Invalid manifest skipped, other extensions load normally
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn el3_invalid_manifest_skipped_others_load() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
// Valid extension.
|
||||
let good = create_ext_dir(tmp.path(), "valid-ext");
|
||||
write_manifest(&good, "valid-ext", "1.0.0");
|
||||
|
||||
// Invalid JSON.
|
||||
let bad = create_ext_dir(tmp.path(), "bad-json");
|
||||
fs::write(bad.join("nomi-extension.json"), b"{ broken json").unwrap();
|
||||
|
||||
// Missing required fields.
|
||||
let incomplete = create_ext_dir(tmp.path(), "incomplete");
|
||||
fs::write(
|
||||
incomplete.join("nomi-extension.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({"name": "incomplete"})).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let scan = vec![ScanPath {
|
||||
path: tmp.path().to_path_buf(),
|
||||
source: ExtensionSource::Local,
|
||||
}];
|
||||
|
||||
let loaded = load_all(&scan);
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].manifest.name, "valid-ext");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EL-4: E2E test mode isolation
|
||||
//
|
||||
// Since NOMIFUN_E2E_TEST=1 changes the behaviour of resolve_scan_paths()
|
||||
// (a global function reading env vars), this test validates the semantics
|
||||
// rather than calling resolve_scan_paths() directly to avoid data races
|
||||
// with other tests.
|
||||
//
|
||||
// The behaviour is: when E2E mode is on, only ScanPaths with source=Env
|
||||
// should be used. We validate this by checking that load_all with only Env
|
||||
// sources works correctly.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn el4_e2e_test_mode_only_env_sources() {
|
||||
let env_dir = TempDir::new().unwrap();
|
||||
let local_dir = TempDir::new().unwrap();
|
||||
|
||||
let ext = create_ext_dir(env_dir.path(), "env-ext");
|
||||
write_manifest(&ext, "env-ext", "1.0.0");
|
||||
|
||||
let ext = create_ext_dir(local_dir.path(), "local-ext");
|
||||
write_manifest(&ext, "local-ext", "1.0.0");
|
||||
|
||||
// Simulate E2E mode: only include env-sourced paths.
|
||||
let scan = vec![ScanPath {
|
||||
path: env_dir.path().to_path_buf(),
|
||||
source: ExtensionSource::Env,
|
||||
}];
|
||||
|
||||
let loaded = load_all(&scan);
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert_eq!(loaded[0].manifest.name, "env-ext");
|
||||
assert_eq!(loaded[0].source, ExtensionSource::Env);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EL-5: Empty scan directories → empty result, no error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn el5_empty_scan_directories_return_empty() {
|
||||
let empty1 = TempDir::new().unwrap();
|
||||
let empty2 = TempDir::new().unwrap();
|
||||
|
||||
let scan = vec![
|
||||
ScanPath {
|
||||
path: empty1.path().to_path_buf(),
|
||||
source: ExtensionSource::Local,
|
||||
},
|
||||
ScanPath {
|
||||
path: empty2.path().to_path_buf(),
|
||||
source: ExtensionSource::Appdata,
|
||||
},
|
||||
];
|
||||
|
||||
let loaded = load_all(&scan);
|
||||
assert!(loaded.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn el5_nonexistent_scan_directory_returns_empty() {
|
||||
let scan = vec![ScanPath {
|
||||
path: std::path::PathBuf::from("/nonexistent/extensions/dir"),
|
||||
source: ExtensionSource::Local,
|
||||
}];
|
||||
|
||||
let loaded = load_all(&scan);
|
||||
assert!(loaded.is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn api_version_filtering() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
// Extension requiring API 2.0.0 — incompatible with current 1.0.0.
|
||||
let ext = create_ext_dir(tmp.path(), "future-api");
|
||||
write_manifest_full(&ext, "future-api", "1.0.0", None, Some("2.0.0"));
|
||||
|
||||
// Extension with compatible API version.
|
||||
let ext2 = create_ext_dir(tmp.path(), "current-api");
|
||||
write_manifest_full(&ext2, "current-api", "1.0.0", None, Some("1.0.0"));
|
||||
|
||||
// Extension with no API version constraint.
|
||||
let ext3 = create_ext_dir(tmp.path(), "no-api");
|
||||
write_manifest(&ext3, "no-api", "1.0.0");
|
||||
|
||||
let scan = vec![ScanPath {
|
||||
path: tmp.path().to_path_buf(),
|
||||
source: ExtensionSource::Local,
|
||||
}];
|
||||
|
||||
let loaded = load_all(&scan);
|
||||
assert_eq!(loaded.len(), 3);
|
||||
|
||||
let filtered = filter_by_engine_compatibility(loaded, "1.0.0");
|
||||
assert_eq!(filtered.len(), 2);
|
||||
let names: Vec<&str> = filtered.iter().map(|e| e.manifest.name.as_str()).collect();
|
||||
assert!(names.contains(&"current-api"));
|
||||
assert!(names.contains(&"no-api"));
|
||||
assert!(!names.contains(&"future-api"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_extensions_from_single_directory() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
for i in 0..5 {
|
||||
let ext = create_ext_dir(tmp.path(), &format!("ext-{i}"));
|
||||
write_manifest(&ext, &format!("ext-{i}"), "1.0.0");
|
||||
}
|
||||
|
||||
let scan = vec![ScanPath {
|
||||
path: tmp.path().to_path_buf(),
|
||||
source: ExtensionSource::Local,
|
||||
}];
|
||||
|
||||
let loaded = load_all(&scan);
|
||||
assert_eq!(loaded.len(), 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_state_defaults_to_enabled() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ext = create_ext_dir(tmp.path(), "my-ext");
|
||||
write_manifest(&ext, "my-ext", "1.0.0");
|
||||
|
||||
let scan = vec![ScanPath {
|
||||
path: tmp.path().to_path_buf(),
|
||||
source: ExtensionSource::Local,
|
||||
}];
|
||||
|
||||
let loaded = load_all(&scan);
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert!(loaded[0].state.enabled);
|
||||
assert_eq!(loaded[0].state.name, "my-ext");
|
||||
assert_eq!(loaded[0].state.version, "1.0.0");
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
name: example-skill
|
||||
description: Fixture skill used by startup_materialize integration tests.
|
||||
---
|
||||
|
||||
Fixture body.
|
||||
@@ -0,0 +1,397 @@
|
||||
//! Integration tests for Hub extension marketplace (test-plan HM scenarios).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_extension::hub::{HubIndexManager, HubInstaller};
|
||||
use nomifun_extension::registry::ExtensionRegistry;
|
||||
use nomifun_extension::state::ExtensionStateStore;
|
||||
use nomifun_extension::types::HubExtensionStatus;
|
||||
use nomifun_realtime::BroadcastEventBus;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
struct TestHarness {
|
||||
hub_dir: TempDir,
|
||||
index_manager: HubIndexManager,
|
||||
installer: HubInstaller,
|
||||
registry: ExtensionRegistry,
|
||||
_state_dir: TempDir,
|
||||
}
|
||||
|
||||
fn setup() -> TestHarness {
|
||||
let hub_dir = TempDir::new().unwrap();
|
||||
let state_dir = TempDir::new().unwrap();
|
||||
|
||||
let store = ExtensionStateStore::new(state_dir.path().join("states.json"));
|
||||
let bus = Arc::new(BroadcastEventBus::new(64));
|
||||
let registry = ExtensionRegistry::new(store, bus, "1.0.0".into());
|
||||
|
||||
let index_manager = HubIndexManager::new(hub_dir.path().to_path_buf(), registry.clone());
|
||||
let installer = HubInstaller::new(index_manager.clone(), registry.clone());
|
||||
|
||||
TestHarness {
|
||||
hub_dir,
|
||||
index_manager,
|
||||
installer,
|
||||
registry,
|
||||
_state_dir: state_dir,
|
||||
}
|
||||
}
|
||||
|
||||
fn write_hub_index(hub_dir: &std::path::Path, extensions: &[serde_json::Value]) {
|
||||
let index = json!({
|
||||
"schema_version": 1,
|
||||
"extensions": extensions,
|
||||
});
|
||||
std::fs::write(hub_dir.join("index.json"), serde_json::to_vec_pretty(&index).unwrap()).unwrap();
|
||||
}
|
||||
|
||||
fn write_extension_manifest(ext_dir: &std::path::Path, name: &str, version: &str) {
|
||||
std::fs::create_dir_all(ext_dir).unwrap();
|
||||
let manifest = json!({
|
||||
"name": name,
|
||||
"version": version,
|
||||
});
|
||||
std::fs::write(
|
||||
ext_dir.join("nomi-extension.json"),
|
||||
serde_json::to_vec_pretty(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HM-1: Get extension list
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn hm1_empty_index_returns_empty_list() {
|
||||
let h = setup();
|
||||
// No index.json at all.
|
||||
let list = h.index_manager.load_index().await;
|
||||
assert!(list.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hm1_load_index_with_entries() {
|
||||
let h = setup();
|
||||
write_hub_index(
|
||||
h.hub_dir.path(),
|
||||
&[
|
||||
json!({
|
||||
"name": "ext-alpha",
|
||||
"version": "1.0.0",
|
||||
"display_name": "Alpha Extension",
|
||||
"tags": ["tools"],
|
||||
"bundled": false,
|
||||
}),
|
||||
json!({
|
||||
"name": "ext-beta",
|
||||
"version": "2.0.0",
|
||||
"bundled": true,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
let list = h.index_manager.load_index().await;
|
||||
assert_eq!(list.len(), 2);
|
||||
assert_eq!(list[0].name, "ext-alpha");
|
||||
assert_eq!(list[0].status, HubExtensionStatus::NotInstalled);
|
||||
assert_eq!(list[1].name, "ext-beta");
|
||||
// Bundled always installed.
|
||||
assert_eq!(list[1].status, HubExtensionStatus::Installed);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HM-2: Install extension
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn hm2_install_extension_with_valid_directory() {
|
||||
let h = setup();
|
||||
|
||||
// Create index entry and extension directory.
|
||||
write_hub_index(
|
||||
h.hub_dir.path(),
|
||||
&[json!({
|
||||
"name": "my-ext",
|
||||
"version": "1.0.0",
|
||||
})],
|
||||
);
|
||||
write_extension_manifest(&h.hub_dir.path().join("my-ext"), "my-ext", "1.0.0");
|
||||
|
||||
let result = h.installer.install("my-ext").await;
|
||||
assert!(result.success, "install should succeed: {:?}", result.msg);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HM-3: Install failure (nonexistent extension)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn hm3_install_nonexistent_extension() {
|
||||
let h = setup();
|
||||
write_hub_index(h.hub_dir.path(), &[]);
|
||||
|
||||
let result = h.installer.install("nonexistent-ext").await;
|
||||
assert!(!result.success);
|
||||
assert!(result.msg.is_some());
|
||||
assert!(result.msg.unwrap().contains("not found in hub index"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hm3_install_no_directory() {
|
||||
let h = setup();
|
||||
// Extension is in the index but no directory exists.
|
||||
write_hub_index(
|
||||
h.hub_dir.path(),
|
||||
&[json!({
|
||||
"name": "remote-ext",
|
||||
"version": "1.0.0",
|
||||
})],
|
||||
);
|
||||
|
||||
let result = h.installer.install("remote-ext").await;
|
||||
assert!(!result.success);
|
||||
assert!(result.msg.is_some());
|
||||
assert!(result.msg.unwrap().contains("not found"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HM-4: Retry install
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn hm4_retry_install_succeeds_after_directory_created() {
|
||||
let h = setup();
|
||||
write_hub_index(
|
||||
h.hub_dir.path(),
|
||||
&[json!({
|
||||
"name": "retry-ext",
|
||||
"version": "1.0.0",
|
||||
})],
|
||||
);
|
||||
|
||||
// First attempt fails — no directory.
|
||||
let result = h.installer.retry_install("retry-ext").await;
|
||||
assert!(!result.success);
|
||||
|
||||
// Create the directory, then retry.
|
||||
write_extension_manifest(&h.hub_dir.path().join("retry-ext"), "retry-ext", "1.0.0");
|
||||
|
||||
let result = h.installer.retry_install("retry-ext").await;
|
||||
assert!(result.success);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HM-5: Check updates
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn hm5_check_updates_empty_when_no_installed() {
|
||||
let h = setup();
|
||||
write_hub_index(
|
||||
h.hub_dir.path(),
|
||||
&[json!({
|
||||
"name": "ext-a",
|
||||
"version": "2.0.0",
|
||||
})],
|
||||
);
|
||||
|
||||
let updates = h.installer.check_updates().await;
|
||||
// No extensions installed → no updates.
|
||||
assert!(updates.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hm5_check_updates_detects_newer_version() {
|
||||
let h = setup();
|
||||
|
||||
// Create an extension and initialize the registry with it.
|
||||
let ext_dir = h.hub_dir.path().join("my-ext");
|
||||
write_extension_manifest(&ext_dir, "my-ext", "1.0.0");
|
||||
|
||||
let scan_paths = vec![nomifun_extension::loader::ScanPath {
|
||||
path: h.hub_dir.path().to_path_buf(),
|
||||
source: nomifun_extension::types::ExtensionSource::Env,
|
||||
}];
|
||||
h.registry.initialize_with_scan_paths(scan_paths).await.unwrap();
|
||||
|
||||
// Index has a newer version.
|
||||
write_hub_index(
|
||||
h.hub_dir.path(),
|
||||
&[json!({
|
||||
"name": "my-ext",
|
||||
"version": "2.0.0",
|
||||
})],
|
||||
);
|
||||
|
||||
let updates = h.installer.check_updates().await;
|
||||
assert_eq!(updates.len(), 1);
|
||||
assert_eq!(updates[0].name, "my-ext");
|
||||
assert_eq!(updates[0].current_version, "1.0.0");
|
||||
assert_eq!(updates[0].latest_version, "2.0.0");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HM-6: Update extension
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn hm6_update_extension() {
|
||||
let h = setup();
|
||||
write_hub_index(
|
||||
h.hub_dir.path(),
|
||||
&[json!({
|
||||
"name": "upd-ext",
|
||||
"version": "2.0.0",
|
||||
})],
|
||||
);
|
||||
// Extension directory exists with a valid manifest.
|
||||
write_extension_manifest(&h.hub_dir.path().join("upd-ext"), "upd-ext", "2.0.0");
|
||||
|
||||
let result = h.installer.update("upd-ext").await;
|
||||
assert!(result.success, "update should succeed: {:?}", result.msg);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hm6_update_nonexistent_extension() {
|
||||
let h = setup();
|
||||
write_hub_index(h.hub_dir.path(), &[]);
|
||||
|
||||
let result = h.installer.update("no-ext").await;
|
||||
assert!(!result.success);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HM-7: Bundled extension status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn hm7_bundled_extensions_always_installed() {
|
||||
let h = setup();
|
||||
write_hub_index(
|
||||
h.hub_dir.path(),
|
||||
&[json!({
|
||||
"name": "bundled-ext",
|
||||
"version": "1.0.0",
|
||||
"bundled": true,
|
||||
})],
|
||||
);
|
||||
|
||||
let list = h.index_manager.load_index().await;
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].status, HubExtensionStatus::Installed);
|
||||
assert!(list[0].bundled);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HM (extra): Uninstall extension
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn hub_uninstall_removes_directory() {
|
||||
let h = setup();
|
||||
let ext_dir = h.hub_dir.path().join("remove-ext");
|
||||
write_extension_manifest(&ext_dir, "remove-ext", "1.0.0");
|
||||
|
||||
assert!(ext_dir.exists());
|
||||
|
||||
let result = h.installer.uninstall("remove-ext").await;
|
||||
assert!(result.success, "uninstall should succeed: {:?}", result.msg);
|
||||
assert!(!ext_dir.exists(), "extension directory should be removed");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hub_uninstall_nonexistent_returns_error() {
|
||||
let h = setup();
|
||||
let result = h.installer.uninstall("no-such-ext").await;
|
||||
assert!(!result.success);
|
||||
assert!(result.msg.unwrap().contains("not installed"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Verification tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_installation_catches_invalid_manifest() {
|
||||
let h = setup();
|
||||
let ext_dir = h.hub_dir.path().join("bad-ext");
|
||||
std::fs::create_dir_all(&ext_dir).unwrap();
|
||||
std::fs::write(ext_dir.join("nomi-extension.json"), b"not valid json").unwrap();
|
||||
|
||||
let result = h.installer.verify_installation(&ext_dir);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn verify_installation_catches_reserved_name() {
|
||||
let h = setup();
|
||||
let ext_dir = h.hub_dir.path().join("nomi-bad");
|
||||
write_extension_manifest(&ext_dir, "nomi-bad", "1.0.0");
|
||||
|
||||
let result = h.installer.verify_installation(&ext_dir);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Index status merge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn index_merge_shows_update_available() {
|
||||
let h = setup();
|
||||
|
||||
// Initialize registry with v1.0.0.
|
||||
let ext_dir = h.hub_dir.path().join("status-ext");
|
||||
write_extension_manifest(&ext_dir, "status-ext", "1.0.0");
|
||||
|
||||
let scan_paths = vec![nomifun_extension::loader::ScanPath {
|
||||
path: h.hub_dir.path().to_path_buf(),
|
||||
source: nomifun_extension::types::ExtensionSource::Env,
|
||||
}];
|
||||
h.registry.initialize_with_scan_paths(scan_paths).await.unwrap();
|
||||
|
||||
// Index advertises v2.0.0.
|
||||
write_hub_index(
|
||||
h.hub_dir.path(),
|
||||
&[json!({
|
||||
"name": "status-ext",
|
||||
"version": "2.0.0",
|
||||
})],
|
||||
);
|
||||
|
||||
let list = h.index_manager.load_index().await;
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].status, HubExtensionStatus::UpdateAvailable);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn index_merge_shows_installed_when_same_version() {
|
||||
let h = setup();
|
||||
|
||||
let ext_dir = h.hub_dir.path().join("same-ext");
|
||||
write_extension_manifest(&ext_dir, "same-ext", "1.0.0");
|
||||
|
||||
let scan_paths = vec![nomifun_extension::loader::ScanPath {
|
||||
path: h.hub_dir.path().to_path_buf(),
|
||||
source: nomifun_extension::types::ExtensionSource::Env,
|
||||
}];
|
||||
h.registry.initialize_with_scan_paths(scan_paths).await.unwrap();
|
||||
|
||||
write_hub_index(
|
||||
h.hub_dir.path(),
|
||||
&[json!({
|
||||
"name": "same-ext",
|
||||
"version": "1.0.0",
|
||||
})],
|
||||
);
|
||||
|
||||
let list = h.index_manager.load_index().await;
|
||||
assert_eq!(list.len(), 1);
|
||||
assert_eq!(list[0].status, HubExtensionStatus::Installed);
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
//! Integration tests for lifecycle hooks (test-plan LH-1..LH-6).
|
||||
//!
|
||||
//! These tests exercise `execute_hook`, `needs_install_hook`, and
|
||||
//! `resolve_hook_path` as black-box functions, verifying first install,
|
||||
//! version change, activate/deactivate execution, timeout behaviour,
|
||||
//! and graceful handling of missing scripts.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use nomifun_extension::{HookKind, LifecycleHooks, execute_hook, needs_install_hook, resolve_hook_path};
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Write a platform-appropriate lifecycle hook script and return its path
|
||||
/// relative to `dir` (including the platform-correct extension) to hand to
|
||||
/// `execute_hook`.
|
||||
///
|
||||
/// `rel_stem` is the relative path WITHOUT a file extension (e.g.
|
||||
/// `"scripts/install"`). On Windows a `.cmd` batch file is written using
|
||||
/// `windows_body`; elsewhere a `#!/bin/sh` script (made executable) using
|
||||
/// `unix_body`. `execute_hook`'s extension→interpreter dispatch then picks the
|
||||
/// matching interpreter (`cmd /C` vs `sh`).
|
||||
fn write_script(dir: &Path, rel_stem: &str, unix_body: &str, windows_body: &str) -> String {
|
||||
#[cfg(windows)]
|
||||
let rel_path = format!("{rel_stem}.cmd");
|
||||
#[cfg(not(windows))]
|
||||
let rel_path = format!("{rel_stem}.sh");
|
||||
|
||||
let full = dir.join(&rel_path);
|
||||
if let Some(parent) = full.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
// `@echo off` stops the interpreter from echoing commands into stdout;
|
||||
// CRLF endings keep cmd.exe happy.
|
||||
let content = format!("@echo off\r\n{}\r\n", windows_body.replace('\n', "\r\n"));
|
||||
fs::write(&full, content).unwrap();
|
||||
let _ = unix_body;
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
fs::write(&full, format!("#!/bin/sh\n{unix_body}\n")).unwrap();
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
fs::set_permissions(&full, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
}
|
||||
let _ = windows_body;
|
||||
}
|
||||
|
||||
rel_path
|
||||
}
|
||||
|
||||
fn setup_ext_dir() -> TempDir {
|
||||
tempfile::tempdir().unwrap()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LH-1: First install executes onInstall
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn lh1_first_install_executes_on_install() {
|
||||
let dir = setup_ext_dir();
|
||||
let marker = dir.path().join("installed.marker");
|
||||
let rel = write_script(
|
||||
dir.path(),
|
||||
"scripts/install",
|
||||
&format!("touch '{}'", marker.display()),
|
||||
&format!("type nul > \"{}\"", marker.display()),
|
||||
);
|
||||
|
||||
let hooks = LifecycleHooks {
|
||||
on_install: Some(rel),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// First install: no persisted version
|
||||
assert!(needs_install_hook("1.0.0", None));
|
||||
|
||||
let hook_path = resolve_hook_path(&hooks, HookKind::OnInstall).unwrap();
|
||||
let result = execute_hook(dir.path(), hook_path, HookKind::OnInstall, "test-ext").await;
|
||||
assert!(result.is_ok());
|
||||
assert!(marker.exists(), "onInstall marker file should be created");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LH-2: Version change executes onInstall
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn lh2_version_change_executes_on_install() {
|
||||
let dir = setup_ext_dir();
|
||||
let marker = dir.path().join("upgraded.marker");
|
||||
let rel = write_script(
|
||||
dir.path(),
|
||||
"scripts/install",
|
||||
&format!("touch '{}'", marker.display()),
|
||||
&format!("type nul > \"{}\"", marker.display()),
|
||||
);
|
||||
|
||||
let hooks = LifecycleHooks {
|
||||
on_install: Some(rel),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
// Version changed from 1.0.0 to 2.0.0
|
||||
assert!(needs_install_hook("2.0.0", Some("1.0.0")));
|
||||
|
||||
let hook_path = resolve_hook_path(&hooks, HookKind::OnInstall).unwrap();
|
||||
let result = execute_hook(dir.path(), hook_path, HookKind::OnInstall, "test-ext").await;
|
||||
assert!(result.is_ok());
|
||||
assert!(marker.exists(), "onInstall marker should be created on upgrade");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LH-2 (negative): Same version does NOT trigger onInstall
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn lh2_same_version_skips_install() {
|
||||
assert!(!needs_install_hook("1.0.0", Some("1.0.0")));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LH-3: Each activation executes onActivate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn lh3_activate_executes_on_activate() {
|
||||
let dir = setup_ext_dir();
|
||||
let counter_file = dir.path().join("activate_count.txt");
|
||||
// Append a line on each activation to count calls
|
||||
let rel = write_script(
|
||||
dir.path(),
|
||||
"scripts/activate",
|
||||
&format!("echo 'activated' >> '{}'", counter_file.display()),
|
||||
&format!("echo activated >> \"{}\"", counter_file.display()),
|
||||
);
|
||||
|
||||
let hooks = LifecycleHooks {
|
||||
on_activate: Some(rel),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let hook_path = resolve_hook_path(&hooks, HookKind::OnActivate).unwrap();
|
||||
|
||||
// Activate twice
|
||||
execute_hook(dir.path(), hook_path, HookKind::OnActivate, "test-ext")
|
||||
.await
|
||||
.unwrap();
|
||||
execute_hook(dir.path(), hook_path, HookKind::OnActivate, "test-ext")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let content = fs::read_to_string(&counter_file).unwrap();
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
assert_eq!(lines.len(), 2, "onActivate should run on each activation");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LH-4: Deactivation executes onDeactivate
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn lh4_deactivate_executes_on_deactivate() {
|
||||
let dir = setup_ext_dir();
|
||||
let marker = dir.path().join("deactivated.marker");
|
||||
let rel = write_script(
|
||||
dir.path(),
|
||||
"scripts/deactivate",
|
||||
&format!("touch '{}'", marker.display()),
|
||||
&format!("type nul > \"{}\"", marker.display()),
|
||||
);
|
||||
|
||||
let hooks = LifecycleHooks {
|
||||
on_deactivate: Some(rel),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let hook_path = resolve_hook_path(&hooks, HookKind::OnDeactivate).unwrap();
|
||||
let result = execute_hook(dir.path(), hook_path, HookKind::OnDeactivate, "test-ext").await;
|
||||
assert!(result.is_ok());
|
||||
assert!(marker.exists(), "onDeactivate marker should be created");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LH-5: Hook timeout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn lh5_hook_timeout() {
|
||||
let dir = setup_ext_dir();
|
||||
// A hook that outlives the short deadline below on each platform: unix
|
||||
// `sleep`, Windows `ping` to localhost (a portable busy-wait — Windows has
|
||||
// no `sleep`). `CmdBuilder::output()` blocks the executor (not cooperatively
|
||||
// cancellable), so the wrapping `timeout` only reports `Elapsed` after the
|
||||
// child exits — keep it short (~1s) so the test is fast while still
|
||||
// comfortably exceeding the 200ms deadline.
|
||||
let rel = write_script(
|
||||
dir.path(),
|
||||
"scripts/slow",
|
||||
"sleep 1",
|
||||
"ping -n 2 127.0.0.1 >NUL",
|
||||
);
|
||||
|
||||
let hooks = LifecycleHooks {
|
||||
on_activate: Some(rel.clone()),
|
||||
..Default::default()
|
||||
};
|
||||
let hook_path = resolve_hook_path(&hooks, HookKind::OnActivate).unwrap();
|
||||
assert_eq!(hook_path, rel);
|
||||
assert!(dir.path().join(&rel).exists());
|
||||
|
||||
// `execute_hook`'s built-in timeout (30s+) is far too long for a unit
|
||||
// test, so we wrap our own short deadline around it. The hook never
|
||||
// finishes within 200ms, so the deadline elapses (Err == timed out).
|
||||
// Critically, this routes through `execute_hook` → the real interpreter
|
||||
// dispatch + spawn — on the old code a Windows `.sh` spawn failed
|
||||
// instantly and this would resolve before the deadline (Ok), the exact
|
||||
// regression this guards.
|
||||
let result = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(200),
|
||||
execute_hook(dir.path(), hook_path, HookKind::OnActivate, "test-ext"),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_err(), "should time out before script completes");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LH-6: Hook script does not exist — graceful handling
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn lh6_missing_script_graceful() {
|
||||
let dir = setup_ext_dir();
|
||||
|
||||
let hooks = LifecycleHooks {
|
||||
on_activate: Some("nonexistent.sh".into()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let hook_path = resolve_hook_path(&hooks, HookKind::OnActivate).unwrap();
|
||||
let result = execute_hook(dir.path(), hook_path, HookKind::OnActivate, "test-ext").await;
|
||||
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
nomifun_extension::ExtensionError::HookNotFound(path) => {
|
||||
assert!(
|
||||
path.contains("nonexistent.sh"),
|
||||
"error should mention the missing script path"
|
||||
);
|
||||
}
|
||||
other => panic!("expected HookNotFound, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional: resolve_hook_path returns None when hook is not declared
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn resolve_hook_path_none_when_not_declared() {
|
||||
let hooks = LifecycleHooks::default();
|
||||
assert!(resolve_hook_path(&hooks, HookKind::OnInstall).is_none());
|
||||
assert!(resolve_hook_path(&hooks, HookKind::OnUninstall).is_none());
|
||||
assert!(resolve_hook_path(&hooks, HookKind::OnActivate).is_none());
|
||||
assert!(resolve_hook_path(&hooks, HookKind::OnDeactivate).is_none());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional: Hook script exits with non-zero status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_nonzero_exit_returns_hook_failed() {
|
||||
let dir = setup_ext_dir();
|
||||
let rel = write_script(
|
||||
dir.path(),
|
||||
"scripts/fail",
|
||||
"echo 'setup failed' >&2; exit 42",
|
||||
"echo setup failed 1>&2 & exit /b 42",
|
||||
);
|
||||
|
||||
let result = execute_hook(dir.path(), &rel, HookKind::OnInstall, "failing-ext").await;
|
||||
|
||||
assert!(result.is_err());
|
||||
match result.unwrap_err() {
|
||||
nomifun_extension::ExtensionError::HookFailed {
|
||||
extension_name,
|
||||
hook,
|
||||
reason,
|
||||
} => {
|
||||
assert_eq!(extension_name, "failing-ext");
|
||||
assert_eq!(hook, "onInstall");
|
||||
assert!(reason.contains("42"), "should include exit code");
|
||||
assert!(reason.contains("setup failed"), "should include stderr");
|
||||
}
|
||||
other => panic!("expected HookFailed, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional: Hook uses working directory correctly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn hook_working_directory_is_ext_dir() {
|
||||
let dir = setup_ext_dir();
|
||||
// print cwd to a file: unix `pwd`, cmd `cd` with no args prints cwd.
|
||||
let rel = write_script(dir.path(), "check_dir", "pwd > cwd_out.txt", "cd > cwd_out.txt");
|
||||
|
||||
let result = execute_hook(dir.path(), &rel, HookKind::OnActivate, "cwd-ext").await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
let cwd_file = dir.path().join("cwd_out.txt");
|
||||
assert!(cwd_file.exists());
|
||||
let cwd = fs::read_to_string(&cwd_file).unwrap();
|
||||
let expected = dir.path().canonicalize().unwrap();
|
||||
let actual = std::path::Path::new(cwd.trim()).canonicalize().unwrap();
|
||||
assert_eq!(actual, expected);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
//! Integration tests for manifest validation (test-plan MV-1 through MV-4).
|
||||
//!
|
||||
//! These test the public API surface of `nomifun_extension::manifest`.
|
||||
|
||||
use nomifun_extension::{ExtensionError, ExtensionManifest, parse_manifest, validate_manifest};
|
||||
|
||||
// -- MV-1: valid manifest loads successfully --
|
||||
|
||||
#[test]
|
||||
fn mv1_valid_manifest_parses_and_validates() {
|
||||
let json = serde_json::json!({
|
||||
"name": "my-cool-extension",
|
||||
"version": "1.0.0",
|
||||
"display_name": "My Cool Extension",
|
||||
"description": "A test extension",
|
||||
"contributes": {
|
||||
"skills": [{ "name": "test-skill" }]
|
||||
}
|
||||
});
|
||||
let bytes = serde_json::to_vec(&json).unwrap();
|
||||
let manifest = parse_manifest(&bytes).unwrap();
|
||||
assert_eq!(manifest.name, "my-cool-extension");
|
||||
assert_eq!(manifest.version, "1.0.0");
|
||||
assert!(manifest.contributes.is_some());
|
||||
}
|
||||
|
||||
// -- MV-2: reserved name prefix rejected --
|
||||
|
||||
#[test]
|
||||
fn mv2_reserved_prefix_nomi_rejected() {
|
||||
let json = serde_json::json!({"name": "nomi-my-ext", "version": "1.0.0"});
|
||||
let bytes = serde_json::to_vec(&json).unwrap();
|
||||
let err = parse_manifest(&bytes).unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::ReservedNamePrefix { ref prefix, .. } if prefix == "nomi-"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mv2_reserved_prefix_internal_rejected() {
|
||||
let json = serde_json::json!({"name": "internal-utils", "version": "1.0.0"});
|
||||
let bytes = serde_json::to_vec(&json).unwrap();
|
||||
assert!(parse_manifest(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mv2_reserved_prefix_builtin_rejected() {
|
||||
let json = serde_json::json!({"name": "builtin-theme", "version": "1.0.0"});
|
||||
let bytes = serde_json::to_vec(&json).unwrap();
|
||||
assert!(parse_manifest(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mv2_reserved_prefix_system_rejected() {
|
||||
let json = serde_json::json!({"name": "system-core", "version": "1.0.0"});
|
||||
let bytes = serde_json::to_vec(&json).unwrap();
|
||||
assert!(parse_manifest(&bytes).is_err());
|
||||
}
|
||||
|
||||
// -- MV-3: missing required fields --
|
||||
|
||||
#[test]
|
||||
fn mv3_missing_version_rejected() {
|
||||
let json = serde_json::json!({"name": "my-ext"});
|
||||
let bytes = serde_json::to_vec(&json).unwrap();
|
||||
// serde will fail because `version` is a required field
|
||||
assert!(parse_manifest(&bytes).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mv3_missing_name_rejected() {
|
||||
let json = serde_json::json!({"version": "1.0.0"});
|
||||
let bytes = serde_json::to_vec(&json).unwrap();
|
||||
assert!(parse_manifest(&bytes).is_err());
|
||||
}
|
||||
|
||||
// -- MV-4: invalid version rejected --
|
||||
|
||||
#[test]
|
||||
fn mv4_invalid_version_rejected() {
|
||||
let json = serde_json::json!({"name": "my-ext", "version": "not-semver"});
|
||||
let bytes = serde_json::to_vec(&json).unwrap();
|
||||
let err = parse_manifest(&bytes).unwrap_err();
|
||||
assert!(matches!(err, ExtensionError::InvalidVersion { .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mv4_partial_version_rejected() {
|
||||
let json = serde_json::json!({"name": "my-ext", "version": "1.0"});
|
||||
let bytes = serde_json::to_vec(&json).unwrap();
|
||||
assert!(parse_manifest(&bytes).is_err());
|
||||
}
|
||||
|
||||
// -- Edge cases --
|
||||
|
||||
#[test]
|
||||
fn manifest_with_all_optional_fields() {
|
||||
let json = serde_json::json!({
|
||||
"name": "full-ext",
|
||||
"version": "2.0.0",
|
||||
"display_name": "Full Extension",
|
||||
"description": "Has everything",
|
||||
"author": "Test",
|
||||
"license": "MIT",
|
||||
"homepage": "https://example.com",
|
||||
"icon": "icon.png",
|
||||
"engine": { "nomifun": "^1.0.0" },
|
||||
"api_version": "1.0.0",
|
||||
"dependencies": { "other-ext": "^1.0.0" },
|
||||
"entry_point": "main.js",
|
||||
"permissions": { "storage": true, "events": true },
|
||||
"contributes": {},
|
||||
"lifecycle": {
|
||||
"on_install": "scripts/install.sh",
|
||||
"on_activate": "scripts/activate.sh"
|
||||
},
|
||||
"i18n": { "locales": ["en", "zh-CN"] }
|
||||
});
|
||||
let bytes = serde_json::to_vec(&json).unwrap();
|
||||
let manifest = parse_manifest(&bytes).unwrap();
|
||||
assert_eq!(manifest.display_name.as_deref(), Some("Full Extension"));
|
||||
assert!(manifest.engine.is_some());
|
||||
assert!(manifest.lifecycle.is_some());
|
||||
assert!(manifest.i18n.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_manifest_directly() {
|
||||
let manifest = ExtensionManifest {
|
||||
name: "valid-ext".into(),
|
||||
version: "1.0.0-beta.1".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());
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
//! Integration tests for permission system (test-plan PS-1 through PS-7).
|
||||
//!
|
||||
//! These test the public API surface of risk level calculation and permission summary.
|
||||
|
||||
use nomifun_extension::{
|
||||
ExtPermissions, FilesystemScope, NetworkPermission, PermissionLevel, RiskLevel, build_permission_summary,
|
||||
calculate_risk_level,
|
||||
};
|
||||
|
||||
// -- PS-1: storage + events only → safe --
|
||||
|
||||
#[test]
|
||||
fn ps1_storage_and_events_is_safe() {
|
||||
let perms = ExtPermissions {
|
||||
storage: Some(true),
|
||||
events: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(calculate_risk_level(&perms), RiskLevel::Safe);
|
||||
}
|
||||
|
||||
// -- PS-2: scoped network → moderate --
|
||||
|
||||
#[test]
|
||||
fn ps2_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);
|
||||
}
|
||||
|
||||
// -- PS-3: shell → dangerous --
|
||||
|
||||
#[test]
|
||||
fn ps3_shell_is_dangerous() {
|
||||
let perms = ExtPermissions {
|
||||
shell: Some(true),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(calculate_risk_level(&perms), RiskLevel::Dangerous);
|
||||
}
|
||||
|
||||
// -- PS-4: full filesystem → dangerous --
|
||||
|
||||
#[test]
|
||||
fn ps4_full_filesystem_is_dangerous() {
|
||||
let perms = ExtPermissions {
|
||||
filesystem: Some(FilesystemScope::Full),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(calculate_risk_level(&perms), RiskLevel::Dangerous);
|
||||
}
|
||||
|
||||
// -- PS-5: workspace filesystem → moderate --
|
||||
|
||||
#[test]
|
||||
fn ps5_workspace_filesystem_is_moderate() {
|
||||
let perms = ExtPermissions {
|
||||
filesystem: Some(FilesystemScope::Workspace),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(calculate_risk_level(&perms), RiskLevel::Moderate);
|
||||
}
|
||||
|
||||
// -- PS-6: unrestricted network → dangerous --
|
||||
|
||||
#[test]
|
||||
fn ps6_unrestricted_network_is_dangerous() {
|
||||
let perms = ExtPermissions {
|
||||
network: Some(NetworkPermission::Unrestricted(true)),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(calculate_risk_level(&perms), RiskLevel::Dangerous);
|
||||
}
|
||||
|
||||
// -- PS-7: no permissions → safe --
|
||||
|
||||
#[test]
|
||||
fn ps7_no_permissions_is_safe() {
|
||||
let perms = ExtPermissions::default();
|
||||
assert_eq!(calculate_risk_level(&perms), RiskLevel::Safe);
|
||||
}
|
||||
|
||||
// -- Summary integration --
|
||||
|
||||
#[test]
|
||||
fn summary_contains_correct_risk_and_details() {
|
||||
let perms = ExtPermissions {
|
||||
storage: Some(true),
|
||||
network: Some(NetworkPermission::Scoped {
|
||||
allowed_domains: vec!["api.example.com".into()],
|
||||
reasoning: "needed".into(),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let summary = build_permission_summary(&perms);
|
||||
assert_eq!(summary.risk_level, RiskLevel::Moderate);
|
||||
assert_eq!(summary.permissions, perms);
|
||||
|
||||
// Should have 7 detail entries (one per permission type)
|
||||
assert_eq!(summary.details.len(), 7);
|
||||
|
||||
// Storage should be Full
|
||||
let storage = summary.details.iter().find(|d| d.permission == "storage").unwrap();
|
||||
assert_eq!(storage.level, PermissionLevel::Full);
|
||||
|
||||
// Network should be Limited
|
||||
let network = summary.details.iter().find(|d| d.permission == "network").unwrap();
|
||||
assert_eq!(network.level, PermissionLevel::Limited);
|
||||
|
||||
// Shell should be None
|
||||
let shell = summary.details.iter().find(|d| d.permission == "shell").unwrap();
|
||||
assert_eq!(shell.level, PermissionLevel::None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn summary_dangerous_permissions_detail() {
|
||||
let perms = ExtPermissions {
|
||||
shell: Some(true),
|
||||
filesystem: Some(FilesystemScope::Full),
|
||||
network: Some(NetworkPermission::Unrestricted(true)),
|
||||
..Default::default()
|
||||
};
|
||||
let summary = build_permission_summary(&perms);
|
||||
assert_eq!(summary.risk_level, RiskLevel::Dangerous);
|
||||
|
||||
let shell = summary.details.iter().find(|d| d.permission == "shell").unwrap();
|
||||
assert_eq!(shell.level, PermissionLevel::Full);
|
||||
|
||||
let fs = summary.details.iter().find(|d| d.permission == "filesystem").unwrap();
|
||||
assert_eq!(fs.level, PermissionLevel::Full);
|
||||
|
||||
let net = summary.details.iter().find(|d| d.permission == "network").unwrap();
|
||||
assert_eq!(net.level, PermissionLevel::Full);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
//! Integration tests for the extension registry (test-plan EM-1..EM-4, HR-3,
|
||||
//! EQ-1..EQ-2, SP-1..SP-3 at registry level).
|
||||
//!
|
||||
//! These tests exercise `ExtensionRegistry` as a black box: initialization,
|
||||
//! enable/disable with event broadcasting, hot-reload sequence, and state
|
||||
//! persistence through the registry API.
|
||||
//!
|
||||
//! All tests use `initialize_with_scan_paths` with explicit paths to avoid
|
||||
//! process-level env var races when running in parallel.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use nomifun_extension::{
|
||||
ExtensionManifest, ExtensionRegistry, ExtensionSource, ExtensionState, ExtensionStateStore, LoadedExtension,
|
||||
ScanPath, save_states_to_file,
|
||||
};
|
||||
use nomifun_realtime::BroadcastEventBus;
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn make_ext(name: &str, version: &str, enabled: bool) -> LoadedExtension {
|
||||
LoadedExtension {
|
||||
manifest: ExtensionManifest {
|
||||
name: name.to_owned(),
|
||||
version: version.to_owned(),
|
||||
display_name: Some(format!("{name} Display")),
|
||||
description: Some(format!("Description for {name}")),
|
||||
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/test-ext/{name}"),
|
||||
source: ExtensionSource::Local,
|
||||
state: ExtensionState {
|
||||
name: name.to_owned(),
|
||||
version: version.to_owned(),
|
||||
enabled,
|
||||
installed_at: Some(1_700_000_000_000),
|
||||
last_activated_at: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Write extension fixture files to `ext_dir` and return scan paths for them.
|
||||
fn write_fixtures(tmp: &TempDir, extensions: &[LoadedExtension]) -> (std::path::PathBuf, Vec<ScanPath>) {
|
||||
let ext_dir = tmp.path().join("extensions");
|
||||
std::fs::create_dir_all(&ext_dir).unwrap();
|
||||
|
||||
for ext in extensions {
|
||||
let dir = ext_dir.join(&ext.manifest.name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let manifest = serde_json::json!({
|
||||
"name": ext.manifest.name,
|
||||
"version": ext.manifest.version,
|
||||
"display_name": ext.manifest.display_name,
|
||||
"description": ext.manifest.description,
|
||||
});
|
||||
std::fs::write(
|
||||
dir.join("nomi-extension.json"),
|
||||
serde_json::to_vec_pretty(&manifest).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
let scan_paths = vec![ScanPath {
|
||||
path: ext_dir.clone(),
|
||||
source: ExtensionSource::Env,
|
||||
}];
|
||||
(ext_dir, scan_paths)
|
||||
}
|
||||
|
||||
/// Create a registry pre-seeded with extensions (bypasses env var resolution).
|
||||
async fn seeded_registry(extensions: Vec<LoadedExtension>) -> (ExtensionRegistry, Arc<BroadcastEventBus>, TempDir) {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = ExtensionStateStore::new(tmp.path().join("states.json"));
|
||||
let bus = Arc::new(BroadcastEventBus::new(64));
|
||||
let registry = ExtensionRegistry::new(store, bus.clone(), "1.0.0".to_owned());
|
||||
|
||||
let (_, scan_paths) = write_fixtures(&tmp, &extensions);
|
||||
|
||||
// Pre-populate state file for disabled extensions.
|
||||
let states: HashMap<String, ExtensionState> = extensions
|
||||
.iter()
|
||||
.map(|e| (e.state.name.clone(), e.state.clone()))
|
||||
.collect();
|
||||
save_states_to_file(&tmp.path().join("states.json"), &states).unwrap();
|
||||
|
||||
// Initialize using explicit scan paths — no env vars needed.
|
||||
registry.initialize_with_scan_paths(scan_paths).await.unwrap();
|
||||
|
||||
(registry, bus, tmp)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EQ-1: Get loaded extensions — empty
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn eq1_get_loaded_extensions_empty() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = ExtensionStateStore::new(tmp.path().join("states.json"));
|
||||
let bus = Arc::new(BroadcastEventBus::new(16));
|
||||
let registry = ExtensionRegistry::new(store, bus, "1.0.0".to_owned());
|
||||
|
||||
// Empty directory — no extensions to load.
|
||||
let empty_dir = tmp.path().join("empty-exts");
|
||||
std::fs::create_dir_all(&empty_dir).unwrap();
|
||||
|
||||
let scan_paths = vec![ScanPath {
|
||||
path: empty_dir,
|
||||
source: ExtensionSource::Env,
|
||||
}];
|
||||
registry.initialize_with_scan_paths(scan_paths).await.unwrap();
|
||||
|
||||
let exts = registry.get_loaded_extensions().await;
|
||||
assert!(exts.is_empty(), "expected empty extension list");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EQ-2: Get loaded extensions — with extensions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn eq2_get_loaded_extensions_with_content() {
|
||||
let extensions = vec![
|
||||
make_ext("ext-alpha", "1.0.0", true),
|
||||
make_ext("ext-beta", "2.0.0", true),
|
||||
];
|
||||
|
||||
let (registry, _, _tmp) = seeded_registry(extensions).await;
|
||||
|
||||
let summaries = registry.get_loaded_extensions().await;
|
||||
assert_eq!(summaries.len(), 2);
|
||||
|
||||
let names: Vec<&str> = summaries.iter().map(|s| s.name.as_str()).collect();
|
||||
assert!(names.contains(&"ext-alpha"));
|
||||
assert!(names.contains(&"ext-beta"));
|
||||
|
||||
// Verify summary fields.
|
||||
let alpha = summaries.iter().find(|s| s.name == "ext-alpha").unwrap();
|
||||
assert_eq!(alpha.version, "1.0.0");
|
||||
assert!(alpha.enabled);
|
||||
assert_eq!(alpha.display_name.as_deref(), Some("ext-alpha Display"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EM-1: Enable extension → stateChanged event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn em1_enable_extension_broadcasts_state_changed() {
|
||||
let extensions = vec![make_ext("my-ext", "1.0.0", false)];
|
||||
let (registry, bus, _tmp) = seeded_registry(extensions).await;
|
||||
|
||||
let mut rx = bus.subscribe();
|
||||
|
||||
registry.enable_extension("my-ext").await.unwrap();
|
||||
|
||||
// Verify the extension is now enabled.
|
||||
let ext = registry.get_extension_by_name("my-ext").await.unwrap();
|
||||
assert!(ext.state.enabled);
|
||||
assert!(ext.state.last_activated_at.is_some());
|
||||
|
||||
// Verify stateChanged event was broadcast.
|
||||
let msg = rx.recv().await.unwrap();
|
||||
assert_eq!(msg.name, "extensions.state-changed");
|
||||
assert_eq!(msg.data["name"], "my-ext");
|
||||
assert_eq!(msg.data["enabled"], true);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EM-2: Disable extension with reason → stateChanged event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn em2_disable_extension_broadcasts_state_changed() {
|
||||
let extensions = vec![make_ext("my-ext", "1.0.0", true)];
|
||||
let (registry, bus, _tmp) = seeded_registry(extensions).await;
|
||||
|
||||
let mut rx = bus.subscribe();
|
||||
|
||||
registry
|
||||
.disable_extension("my-ext", Some("Security concern"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Verify the extension is now disabled.
|
||||
let ext = registry.get_extension_by_name("my-ext").await.unwrap();
|
||||
assert!(!ext.state.enabled);
|
||||
|
||||
// Verify stateChanged event was broadcast.
|
||||
let msg = rx.recv().await.unwrap();
|
||||
assert_eq!(msg.name, "extensions.state-changed");
|
||||
assert_eq!(msg.data["name"], "my-ext");
|
||||
assert_eq!(msg.data["enabled"], false);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EM-3: Enable non-existent extension → error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn em3_enable_nonexistent_returns_error() {
|
||||
let (registry, _, _tmp) = seeded_registry(vec![]).await;
|
||||
let result = registry.enable_extension("nonexistent").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EM-4: Disable non-existent extension → error
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn em4_disable_nonexistent_returns_error() {
|
||||
let (registry, _, _tmp) = seeded_registry(vec![]).await;
|
||||
let result = registry.disable_extension("nonexistent", None).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HR-3: Hot-reload sequence — deactivate → clear → reload → event
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn hr3_hot_reload_emits_registry_reloaded() {
|
||||
let extensions = vec![make_ext("test-ext", "1.0.0", true)];
|
||||
let (registry, bus, _tmp) = seeded_registry(extensions).await;
|
||||
|
||||
let mut rx = bus.subscribe();
|
||||
|
||||
registry.hot_reload().await;
|
||||
|
||||
// Should receive at least one lifecycle event for REGISTRY_RELOADED.
|
||||
// Drain events until we find it (there may be EXTENSION_ACTIVATED events first).
|
||||
let mut found_reload = false;
|
||||
while let Ok(msg) = tokio::time::timeout(std::time::Duration::from_millis(500), rx.recv()).await {
|
||||
if let Ok(msg) = msg
|
||||
&& msg.name == "extensions.lifecycle"
|
||||
&& msg.data["event"] == "REGISTRY_RELOADED"
|
||||
{
|
||||
found_reload = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(found_reload, "expected REGISTRY_RELOADED event");
|
||||
|
||||
// Extensions should still be loaded after reload.
|
||||
let exts = registry.get_loaded_extensions().await;
|
||||
assert_eq!(exts.len(), 1);
|
||||
assert_eq!(exts[0].name, "test-ext");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// HR-3 (continued): Hot-reload preserves enabled/disabled state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn hr3_hot_reload_preserves_disabled_state() {
|
||||
let extensions = vec![
|
||||
make_ext("enabled-ext", "1.0.0", true),
|
||||
make_ext("disabled-ext", "1.0.0", false),
|
||||
];
|
||||
let (registry, _, _tmp) = seeded_registry(extensions).await;
|
||||
|
||||
// Disable an extension, then hot-reload.
|
||||
registry.disable_extension("enabled-ext", None).await.unwrap();
|
||||
|
||||
registry.hot_reload().await;
|
||||
|
||||
let exts = registry.get_loaded_extensions().await;
|
||||
let enabled = exts.iter().find(|e| e.name == "enabled-ext").unwrap();
|
||||
assert!(!enabled.enabled, "disabled state should persist through reload");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SP: State persistence through registry enable/disable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn sp_enable_disable_persists_through_reload() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let state_path = tmp.path().join("states.json");
|
||||
let store = ExtensionStateStore::new(state_path.clone());
|
||||
let bus = Arc::new(BroadcastEventBus::new(16));
|
||||
|
||||
// Create fixture extensions.
|
||||
let ext_dir = tmp.path().join("extensions");
|
||||
let ext_a_dir = ext_dir.join("ext-a");
|
||||
std::fs::create_dir_all(&ext_a_dir).unwrap();
|
||||
std::fs::write(
|
||||
ext_a_dir.join("nomi-extension.json"),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
"name": "ext-a",
|
||||
"version": "1.0.0",
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let scan_paths = vec![ScanPath {
|
||||
path: ext_dir,
|
||||
source: ExtensionSource::Env,
|
||||
}];
|
||||
|
||||
let registry = ExtensionRegistry::new(store.clone(), bus, "1.0.0".to_owned());
|
||||
registry.initialize_with_scan_paths(scan_paths.clone()).await.unwrap();
|
||||
|
||||
// Extension should be enabled by default (SP-3).
|
||||
let exts = registry.get_loaded_extensions().await;
|
||||
assert!(exts[0].enabled, "first load should default to enabled");
|
||||
|
||||
// Disable it.
|
||||
registry.disable_extension("ext-a", None).await.unwrap();
|
||||
|
||||
// Flush to disk.
|
||||
store.flush().await.unwrap();
|
||||
|
||||
// Verify persisted state (SP-1).
|
||||
let loaded = nomifun_extension::load_states_from_file(&state_path).unwrap();
|
||||
assert!(!loaded["ext-a"].enabled);
|
||||
|
||||
// Re-initialize (simulate restart) — should restore disabled state (SP-2).
|
||||
let store2 = ExtensionStateStore::new(state_path);
|
||||
let bus2 = Arc::new(BroadcastEventBus::new(16));
|
||||
let registry2 = ExtensionRegistry::new(store2, bus2, "1.0.0".to_owned());
|
||||
registry2.initialize_with_scan_paths(scan_paths).await.unwrap();
|
||||
|
||||
let exts2 = registry2.get_loaded_extensions().await;
|
||||
assert!(!exts2.is_empty(), "second registry should find extensions");
|
||||
assert!(!exts2[0].enabled, "disabled state should persist across restarts");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Query: contribution getters return empty on no extensions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn query_empty_contributions_on_no_extensions() {
|
||||
let (registry, _, _tmp) = seeded_registry(vec![]).await;
|
||||
|
||||
assert!(registry.get_themes().await.is_empty());
|
||||
assert!(registry.get_assistants().await.is_empty());
|
||||
assert!(registry.get_acp_adapters().await.is_empty());
|
||||
assert!(registry.get_agents().await.is_empty());
|
||||
assert!(registry.get_mcp_servers().await.is_empty());
|
||||
assert!(registry.get_skills().await.is_empty());
|
||||
assert!(registry.get_settings_tabs().await.is_empty());
|
||||
assert!(registry.get_webui_contributions().await.is_empty());
|
||||
assert!(registry.get_channel_plugins().await.is_empty());
|
||||
assert!(registry.get_model_providers().await.is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Enable idempotent — enabling an already-enabled extension is a no-op
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn enable_already_enabled_is_noop() {
|
||||
let extensions = vec![make_ext("my-ext", "1.0.0", true)];
|
||||
let (registry, bus, _tmp) = seeded_registry(extensions).await;
|
||||
|
||||
let mut rx = bus.subscribe();
|
||||
|
||||
// Enable an already-enabled extension — should not broadcast.
|
||||
registry.enable_extension("my-ext").await.unwrap();
|
||||
|
||||
// No stateChanged event expected — timeout means success.
|
||||
let result = tokio::time::timeout(std::time::Duration::from_millis(100), rx.recv()).await;
|
||||
assert!(result.is_err(), "no event expected for no-op enable");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Initialize sets initialized flag
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn initialize_sets_flag() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let store = ExtensionStateStore::new(tmp.path().join("states.json"));
|
||||
let bus = Arc::new(BroadcastEventBus::new(16));
|
||||
let registry = ExtensionRegistry::new(store, bus, "1.0.0".to_owned());
|
||||
|
||||
assert!(!registry.is_initialized().await);
|
||||
|
||||
let empty_dir = tmp.path().join("empty-exts");
|
||||
std::fs::create_dir_all(&empty_dir).unwrap();
|
||||
|
||||
let scan_paths = vec![ScanPath {
|
||||
path: empty_dir,
|
||||
source: ExtensionSource::Env,
|
||||
}];
|
||||
registry.initialize_with_scan_paths(scan_paths).await.unwrap();
|
||||
|
||||
assert!(registry.is_initialized().await);
|
||||
}
|
||||
@@ -0,0 +1,611 @@
|
||||
//! Black-box integration tests for skill & rule management (test-plan SM, RM, CP).
|
||||
//!
|
||||
//! These tests exercise the public API surface of `nomifun_extension::skill_service`
|
||||
//! and `nomifun_extension::external_paths` against the functional requirements in
|
||||
//! `08-file-workspace.md` §B.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use nomifun_extension::external_paths::ExternalPathsManager;
|
||||
use nomifun_extension::skill_service::{
|
||||
NamedPath, SkillPaths, delete_assistant_rule, delete_assistant_skill, delete_skill,
|
||||
detect_and_count_external_skills, export_skill_with_symlink, import_skill, import_skill_with_symlink,
|
||||
list_available_skills, read_assistant_rule, read_assistant_skill, read_builtin_rule, read_builtin_skill,
|
||||
read_skill_info, resolve_skill_paths, scan_for_skills, write_assistant_rule, write_assistant_skill,
|
||||
};
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SKILL_MD: &str = "SKILL.md";
|
||||
|
||||
fn make_paths(base: &Path) -> SkillPaths {
|
||||
SkillPaths {
|
||||
data_dir: base.to_path_buf(),
|
||||
user_skills_dir: base.join("skills"),
|
||||
cron_skills_dir: base.join("cron").join("skills"),
|
||||
builtin_skills_dir: base.join("builtin-skills"),
|
||||
builtin_rules_dir: base.join("builtin-rules"),
|
||||
assistant_rules_dir: base.join("assistant-rules"),
|
||||
assistant_skills_dir: base.join("assistant-skills"),
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_dir(paths: &SkillPaths) -> &Path {
|
||||
&paths.builtin_skills_dir
|
||||
}
|
||||
|
||||
fn create_skill(base: &Path, name: &str, desc: &str) {
|
||||
let dir = base.join(name);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join(SKILL_MD),
|
||||
format!("---\nname: {name}\ndescription: {desc}\n---\nBody of {name}."),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn create_builtin_rule(base: &Path, name: &str, content: &str) {
|
||||
std::fs::create_dir_all(base).unwrap();
|
||||
std::fs::write(base.join(name), content).unwrap();
|
||||
}
|
||||
|
||||
fn create_builtin_skill(base: &Path, name: &str, content: &str) {
|
||||
std::fs::create_dir_all(base).unwrap();
|
||||
std::fs::write(base.join(name), content).unwrap();
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// SM — Skill Management
|
||||
// ===========================================================================
|
||||
|
||||
/// SM-1: List available skills (builtin + custom, deduplication).
|
||||
#[tokio::test]
|
||||
async fn sm1_list_available_skills_deduplication() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = make_paths(tmp.path());
|
||||
|
||||
// 3 built-in, 2 custom (1 overlaps)
|
||||
create_skill(builtin_dir(&paths), "review", "Built-in review");
|
||||
create_skill(builtin_dir(&paths), "debug", "Built-in debug");
|
||||
create_skill(builtin_dir(&paths), "test", "Built-in test");
|
||||
create_skill(&paths.user_skills_dir, "review", "Custom review override");
|
||||
create_skill(&paths.user_skills_dir, "my-tool", "My custom tool");
|
||||
|
||||
let skills = list_available_skills(&paths).await.unwrap();
|
||||
|
||||
// 4 total: debug(builtin) + my-tool(custom) + review(custom) + test(builtin)
|
||||
assert_eq!(skills.len(), 4);
|
||||
|
||||
// Verify deduplication: review should be the custom version
|
||||
let review = skills.iter().find(|s| s.name == "review").unwrap();
|
||||
assert!(review.is_custom);
|
||||
assert_eq!(review.description, "Custom review override");
|
||||
|
||||
// Verify each has required fields
|
||||
for s in &skills {
|
||||
assert!(!s.name.is_empty());
|
||||
assert!(!s.description.is_empty());
|
||||
assert!(!s.location.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
/// SM-2: Read skill info from path.
|
||||
#[tokio::test]
|
||||
async fn sm2_read_skill_info() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let skill_dir = tmp.path().join("my-skill");
|
||||
std::fs::create_dir_all(&skill_dir).unwrap();
|
||||
std::fs::write(
|
||||
skill_dir.join(SKILL_MD),
|
||||
"---\nname: my-skill\ndescription: A test skill\n---\nBody content.",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (name, desc) = read_skill_info(&skill_dir).await.unwrap();
|
||||
assert_eq!(name, "my-skill");
|
||||
assert_eq!(desc, "A test skill");
|
||||
}
|
||||
|
||||
/// SM-2 error: Path does not exist → error.
|
||||
#[tokio::test]
|
||||
async fn sm2_read_skill_info_not_found() {
|
||||
let result = read_skill_info(Path::new("/nonexistent/skill")).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
/// SM-3: Import skill (copy).
|
||||
#[tokio::test]
|
||||
async fn sm3_import_skill_copy() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = make_paths(tmp.path());
|
||||
|
||||
let source = tmp.path().join("external-skill");
|
||||
std::fs::create_dir_all(&source).unwrap();
|
||||
std::fs::write(
|
||||
source.join(SKILL_MD),
|
||||
"---\nname: ext-tool\ndescription: External tool\n---\nContent.",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(source.join("helper.py"), "print('hello')").unwrap();
|
||||
|
||||
let name = import_skill(&paths, &source).await.unwrap();
|
||||
assert_eq!(name, "ext-tool");
|
||||
|
||||
// Verify files were copied
|
||||
let imported = paths.user_skills_dir.join("ext-tool");
|
||||
assert!(imported.join(SKILL_MD).exists());
|
||||
assert!(imported.join("helper.py").exists());
|
||||
}
|
||||
|
||||
/// SM-4: Import skill (symlink).
|
||||
#[tokio::test]
|
||||
async fn sm4_import_skill_symlink() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = make_paths(tmp.path());
|
||||
|
||||
let source = tmp.path().join("linked-skill");
|
||||
std::fs::create_dir_all(&source).unwrap();
|
||||
std::fs::write(
|
||||
source.join(SKILL_MD),
|
||||
"---\nname: linked\ndescription: Linked skill\n---\nBody.",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let name = import_skill_with_symlink(&paths, &source).await.unwrap();
|
||||
assert_eq!(name, "linked");
|
||||
|
||||
let link = paths.user_skills_dir.join("linked");
|
||||
assert!(link.is_symlink());
|
||||
|
||||
// Verify content is accessible through the symlink
|
||||
let content = std::fs::read_to_string(link.join(SKILL_MD)).unwrap();
|
||||
assert!(content.contains("Linked skill"));
|
||||
}
|
||||
|
||||
/// SM-5: Export skill (symlink).
|
||||
#[tokio::test]
|
||||
async fn sm5_export_skill_symlink() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let source = tmp.path().join("source-skill");
|
||||
std::fs::create_dir_all(&source).unwrap();
|
||||
std::fs::write(
|
||||
source.join(SKILL_MD),
|
||||
"---\nname: source\ndescription: Source\n---\nBody.",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let export_dir = tmp.path().join("exports");
|
||||
export_skill_with_symlink(&source, &export_dir).await.unwrap();
|
||||
|
||||
let link = export_dir.join("source-skill");
|
||||
assert!(link.is_symlink());
|
||||
}
|
||||
|
||||
/// SM-6: Delete user custom skill.
|
||||
#[tokio::test]
|
||||
async fn sm6_delete_custom_skill() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = make_paths(tmp.path());
|
||||
|
||||
create_skill(&paths.user_skills_dir, "deletable", "Will be deleted");
|
||||
assert!(paths.user_skills_dir.join("deletable").exists());
|
||||
|
||||
delete_skill(&paths, "deletable").await.unwrap();
|
||||
assert!(!paths.user_skills_dir.join("deletable").exists());
|
||||
}
|
||||
|
||||
/// SM-7: Delete built-in skill → rejected.
|
||||
#[tokio::test]
|
||||
async fn sm7_delete_builtin_skill_rejected() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = make_paths(tmp.path());
|
||||
|
||||
create_skill(builtin_dir(&paths), "protected", "Cannot delete");
|
||||
|
||||
let result = delete_skill(&paths, "protected").await;
|
||||
assert!(result.is_err());
|
||||
|
||||
// Verify it still exists
|
||||
assert!(builtin_dir(&paths).join("protected").exists());
|
||||
}
|
||||
|
||||
/// SM-8: Scan for skills in a directory.
|
||||
#[tokio::test]
|
||||
async fn sm8_scan_for_skills() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let scan_dir = tmp.path().join("scan-target");
|
||||
|
||||
create_skill(&scan_dir, "alpha", "Alpha skill");
|
||||
create_skill(&scan_dir, "beta", "Beta skill");
|
||||
// Directory without SKILL.md
|
||||
std::fs::create_dir_all(scan_dir.join("not-a-skill")).unwrap();
|
||||
|
||||
let skills = scan_for_skills(&scan_dir).await.unwrap();
|
||||
assert_eq!(skills.len(), 2);
|
||||
assert!(skills.iter().any(|s| s.name == "alpha"));
|
||||
assert!(skills.iter().any(|s| s.name == "beta"));
|
||||
}
|
||||
|
||||
/// SM-11: Get skill directory paths.
|
||||
///
|
||||
/// Production mode: no `NOMIFUN_BUILTIN_SKILLS_PATH` set — the built-in
|
||||
/// skills tree lives at `{data_dir}/builtin-skills/`, populated at
|
||||
/// startup by `startup_materialize::materialize_if_needed`. The user
|
||||
/// skills directory is derived from `data_dir`, not `resource_dir`.
|
||||
#[tokio::test]
|
||||
async fn sm11_get_skill_paths() {
|
||||
// Ensure the env var is unset for a deterministic assertion.
|
||||
// Safe: the test runs single-threaded w.r.t. this env var.
|
||||
// (SAFETY: `remove_var` is unsafe in 2024 edition due to process-wide
|
||||
// side-effects.)
|
||||
unsafe {
|
||||
std::env::remove_var("NOMIFUN_BUILTIN_SKILLS_PATH");
|
||||
}
|
||||
|
||||
let resource_dir = Path::new("/app/resources");
|
||||
let data_dir = Path::new("/home/user/.nomifun");
|
||||
let paths = resolve_skill_paths(resource_dir, data_dir);
|
||||
|
||||
assert!(paths.user_skills_dir.to_string_lossy().contains("skills"));
|
||||
assert_eq!(
|
||||
paths.builtin_skills_dir,
|
||||
data_dir.join("builtin-skills"),
|
||||
"production mode must resolve builtin_skills_dir under data_dir"
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// RM — Rule Management
|
||||
// ===========================================================================
|
||||
|
||||
/// RM-1: Read built-in rule.
|
||||
#[tokio::test]
|
||||
async fn rm1_read_builtin_rule() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = make_paths(tmp.path());
|
||||
|
||||
create_builtin_rule(&paths.builtin_rules_dir, "code-review.md", "# Review Rules");
|
||||
|
||||
let content = read_builtin_rule(&paths, "code-review.md").await.unwrap();
|
||||
assert_eq!(content, "# Review Rules");
|
||||
}
|
||||
|
||||
/// RM-1 error: File not found → empty string.
|
||||
#[tokio::test]
|
||||
async fn rm1_read_builtin_rule_not_found() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = make_paths(tmp.path());
|
||||
|
||||
let content = read_builtin_rule(&paths, "nonexistent.md").await.unwrap();
|
||||
assert!(content.is_empty());
|
||||
}
|
||||
|
||||
/// RM-1 variant: Read built-in skill.
|
||||
#[tokio::test]
|
||||
async fn rm1_read_builtin_skill() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = make_paths(tmp.path());
|
||||
|
||||
create_builtin_skill(builtin_dir(&paths), "tdd.md", "# TDD Workflow");
|
||||
|
||||
let content = read_builtin_skill(&paths, "tdd.md").await.unwrap();
|
||||
assert_eq!(content, "# TDD Workflow");
|
||||
}
|
||||
|
||||
/// RM-2: Read assistant rule with locale fallback.
|
||||
#[tokio::test]
|
||||
async fn rm2_assistant_rule_locale_fallback() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = make_paths(tmp.path());
|
||||
|
||||
// Write both default and locale-specific
|
||||
write_assistant_rule(&paths, "abc123", "Default rule", None)
|
||||
.await
|
||||
.unwrap();
|
||||
write_assistant_rule(&paths, "abc123", "中文规则", Some("zh-CN"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// 1. Matching locale → locale-specific content
|
||||
let content = read_assistant_rule(&paths, "abc123", Some("zh-CN")).await.unwrap();
|
||||
assert_eq!(content, "中文规则");
|
||||
|
||||
// 2. Non-matching locale → fallback to default
|
||||
let content = read_assistant_rule(&paths, "abc123", Some("en-US")).await.unwrap();
|
||||
assert_eq!(content, "Default rule");
|
||||
|
||||
// 3. No locale → default
|
||||
let content = read_assistant_rule(&paths, "abc123", None).await.unwrap();
|
||||
assert_eq!(content, "Default rule");
|
||||
|
||||
// 4. Not found → empty string
|
||||
let content = read_assistant_rule(&paths, "missing", None).await.unwrap();
|
||||
assert!(content.is_empty());
|
||||
}
|
||||
|
||||
/// RM-3: Write assistant rule.
|
||||
#[tokio::test]
|
||||
async fn rm3_write_assistant_rule() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = make_paths(tmp.path());
|
||||
|
||||
let result = write_assistant_rule(&paths, "abc123", "New rule content", Some("en-US"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(result);
|
||||
|
||||
// Verify file created
|
||||
let file = paths.assistant_rules_dir.join("abc123.en-US.md");
|
||||
assert!(file.exists());
|
||||
let content = std::fs::read_to_string(file).unwrap();
|
||||
assert_eq!(content, "New rule content");
|
||||
}
|
||||
|
||||
/// RM-4: Delete assistant rule (all locales).
|
||||
#[tokio::test]
|
||||
async fn rm4_delete_assistant_rule_all_locales() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = make_paths(tmp.path());
|
||||
|
||||
write_assistant_rule(&paths, "abc123", "Default", None).await.unwrap();
|
||||
write_assistant_rule(&paths, "abc123", "Chinese", Some("zh-CN"))
|
||||
.await
|
||||
.unwrap();
|
||||
write_assistant_rule(&paths, "abc123", "English", Some("en-US"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deleted = delete_assistant_rule(&paths, "abc123").await.unwrap();
|
||||
assert!(deleted);
|
||||
|
||||
// Verify all versions removed
|
||||
let content = read_assistant_rule(&paths, "abc123", None).await.unwrap();
|
||||
assert!(content.is_empty());
|
||||
let content = read_assistant_rule(&paths, "abc123", Some("zh-CN")).await.unwrap();
|
||||
assert!(content.is_empty());
|
||||
let content = read_assistant_rule(&paths, "abc123", Some("en-US")).await.unwrap();
|
||||
assert!(content.is_empty());
|
||||
}
|
||||
|
||||
/// RM-5: Read assistant skill with locale fallback (same as RM-2 pattern).
|
||||
#[tokio::test]
|
||||
async fn rm5_assistant_skill_locale_fallback() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = make_paths(tmp.path());
|
||||
|
||||
write_assistant_skill(&paths, "abc123", "Default skill", None)
|
||||
.await
|
||||
.unwrap();
|
||||
write_assistant_skill(&paths, "abc123", "English skill", Some("en-US"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let content = read_assistant_skill(&paths, "abc123", Some("en-US")).await.unwrap();
|
||||
assert_eq!(content, "English skill");
|
||||
|
||||
let content = read_assistant_skill(&paths, "abc123", Some("fr-FR")).await.unwrap();
|
||||
assert_eq!(content, "Default skill");
|
||||
}
|
||||
|
||||
/// RM-6: Write and delete assistant skill.
|
||||
#[tokio::test]
|
||||
async fn rm6_write_and_delete_assistant_skill() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = make_paths(tmp.path());
|
||||
|
||||
write_assistant_skill(&paths, "abc123", "Content", None).await.unwrap();
|
||||
write_assistant_skill(&paths, "abc123", "Locale", Some("zh-CN"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let deleted = delete_assistant_skill(&paths, "abc123").await.unwrap();
|
||||
assert!(deleted);
|
||||
|
||||
let content = read_assistant_skill(&paths, "abc123", None).await.unwrap();
|
||||
assert!(content.is_empty());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// CP — Custom External Paths
|
||||
// ===========================================================================
|
||||
|
||||
/// CP-1: Get custom paths (initially empty).
|
||||
#[tokio::test]
|
||||
async fn cp1_get_custom_paths_empty() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mgr = ExternalPathsManager::new(tmp.path()).await;
|
||||
|
||||
let paths = mgr.get_custom_external_paths().await;
|
||||
assert!(paths.is_empty());
|
||||
}
|
||||
|
||||
/// CP-2: Add custom path and verify persistence.
|
||||
#[tokio::test]
|
||||
async fn cp2_add_custom_path() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mgr = ExternalPathsManager::new(tmp.path()).await;
|
||||
|
||||
mgr.add_custom_external_path("My Skills", "/home/user/skills")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let paths = mgr.get_custom_external_paths().await;
|
||||
assert_eq!(paths.len(), 1);
|
||||
assert_eq!(paths[0].name, "My Skills");
|
||||
assert_eq!(paths[0].path, "/home/user/skills");
|
||||
|
||||
// Verify persistence across reload
|
||||
drop(mgr);
|
||||
let mgr2 = ExternalPathsManager::new(tmp.path()).await;
|
||||
let paths = mgr2.get_custom_external_paths().await;
|
||||
assert_eq!(paths.len(), 1);
|
||||
}
|
||||
|
||||
/// CP-3: Remove custom path.
|
||||
#[tokio::test]
|
||||
async fn cp3_remove_custom_path() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
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();
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
/// CP-4: Enable skills market.
|
||||
#[tokio::test]
|
||||
async fn cp4_enable_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, "nomifun-skills");
|
||||
}
|
||||
|
||||
/// CP-5: Disable skills market.
|
||||
#[tokio::test]
|
||||
async fn cp5_disable_skills_market() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let mgr = ExternalPathsManager::new(tmp.path()).await;
|
||||
|
||||
mgr.enable_skills_market().await.unwrap();
|
||||
mgr.disable_skills_market().await.unwrap();
|
||||
|
||||
let paths = mgr.get_custom_external_paths().await;
|
||||
assert!(paths.is_empty());
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// External skill discovery
|
||||
// ===========================================================================
|
||||
|
||||
/// Test detect_and_count_external_skills with custom paths.
|
||||
#[tokio::test]
|
||||
async fn detect_external_skills_from_custom_paths() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let ext_dir = tmp.path().join("external-skills");
|
||||
create_skill(&ext_dir, "ext-a", "External A");
|
||||
create_skill(&ext_dir, "ext-b", "External B");
|
||||
|
||||
let custom_paths = vec![NamedPath {
|
||||
name: "External".to_string(),
|
||||
path: ext_dir.to_string_lossy().into_owned(),
|
||||
}];
|
||||
|
||||
let sources = detect_and_count_external_skills(&custom_paths).await;
|
||||
|
||||
// Should have at least the custom path source
|
||||
let external = sources
|
||||
.iter()
|
||||
.find(|s| s.name == "External")
|
||||
.expect("custom external source should be found");
|
||||
assert_eq!(external.skill_count, 2);
|
||||
assert!(external.skills.iter().any(|s| s.name == "ext-a"));
|
||||
assert!(external.skills.iter().any(|s| s.name == "ext-b"));
|
||||
// `source` for custom paths is `custom-<abs-path>` — used by the renderer
|
||||
// as a React key / testid suffix, and asserted by e2e spec
|
||||
// `edge-cases.e2e.ts` (prefix `external-source-tab-custom-`).
|
||||
assert_eq!(external.source, format!("custom-{}", ext_dir.to_string_lossy()));
|
||||
assert!(external.source.starts_with("custom-"));
|
||||
}
|
||||
|
||||
/// Custom paths with distinct filesystem locations get distinct slugs so
|
||||
/// the renderer can use them as unique React keys / testid suffixes.
|
||||
#[tokio::test]
|
||||
async fn detect_external_skills_custom_sources_are_unique() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir_a = tmp.path().join("a");
|
||||
let dir_b = tmp.path().join("b");
|
||||
create_skill(&dir_a, "a-skill", "A");
|
||||
create_skill(&dir_b, "b-skill", "B");
|
||||
|
||||
let custom_paths = vec![
|
||||
NamedPath {
|
||||
name: "A".into(),
|
||||
path: dir_a.to_string_lossy().into_owned(),
|
||||
},
|
||||
NamedPath {
|
||||
name: "B".into(),
|
||||
path: dir_b.to_string_lossy().into_owned(),
|
||||
},
|
||||
];
|
||||
|
||||
let sources = detect_and_count_external_skills(&custom_paths).await;
|
||||
let slugs: Vec<&str> = sources
|
||||
.iter()
|
||||
.filter(|s| s.name == "A" || s.name == "B")
|
||||
.map(|s| s.source.as_str())
|
||||
.collect();
|
||||
assert_eq!(slugs.len(), 2);
|
||||
assert_ne!(slugs[0], slugs[1]);
|
||||
}
|
||||
|
||||
/// Verify path traversal is blocked in skill deletion.
|
||||
#[tokio::test]
|
||||
async fn security_path_traversal_blocked() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = make_paths(tmp.path());
|
||||
|
||||
// Path traversal attempts
|
||||
assert!(delete_skill(&paths, "../escape").await.is_err());
|
||||
assert!(delete_skill(&paths, "foo/bar").await.is_err());
|
||||
assert!(delete_skill(&paths, "foo\\bar").await.is_err());
|
||||
}
|
||||
|
||||
/// Verify built-in resource reads block path traversal.
|
||||
#[tokio::test]
|
||||
async fn security_builtin_read_path_traversal() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = make_paths(tmp.path());
|
||||
|
||||
assert!(read_builtin_rule(&paths, "../secret.md").await.is_err());
|
||||
assert!(read_builtin_skill(&paths, "../../etc/passwd").await.is_err());
|
||||
assert!(read_builtin_rule(&paths, "").await.is_err());
|
||||
}
|
||||
|
||||
/// Verify assistant CRUD functions block path traversal in assistant_id.
|
||||
#[tokio::test]
|
||||
async fn security_assistant_crud_path_traversal_id() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = make_paths(tmp.path());
|
||||
|
||||
// read
|
||||
assert!(read_assistant_rule(&paths, "../escape", None).await.is_err());
|
||||
assert!(read_assistant_skill(&paths, "foo/bar", None).await.is_err());
|
||||
|
||||
// write
|
||||
assert!(write_assistant_rule(&paths, "../escape", "x", None).await.is_err());
|
||||
assert!(write_assistant_skill(&paths, "foo\\bar", "x", None).await.is_err());
|
||||
|
||||
// delete
|
||||
assert!(delete_assistant_rule(&paths, "../escape").await.is_err());
|
||||
assert!(delete_assistant_skill(&paths, "a/b").await.is_err());
|
||||
}
|
||||
|
||||
/// Verify assistant read/write functions block path traversal in locale.
|
||||
#[tokio::test]
|
||||
async fn security_assistant_crud_path_traversal_locale() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let paths = make_paths(tmp.path());
|
||||
|
||||
assert!(read_assistant_rule(&paths, "valid", Some("../bad")).await.is_err());
|
||||
assert!(
|
||||
write_assistant_rule(&paths, "valid", "x", Some("../../evil"))
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert!(read_assistant_skill(&paths, "valid", Some("a/b")).await.is_err());
|
||||
assert!(write_assistant_skill(&paths, "valid", "x", Some("a\\b")).await.is_err());
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
use nomifun_extension::{resolve_skill_paths, skill_service};
|
||||
use tempfile::TempDir;
|
||||
|
||||
/// `BUILTIN_SKILLS_ENV_VAR` is process-global; this test mutates it, so
|
||||
/// it must not run in parallel with other `skill_service` tests that
|
||||
/// touch the same env var. Vitest-style serialization inside a single
|
||||
/// test is sufficient here.
|
||||
#[tokio::test]
|
||||
async fn materialize_returns_only_listed_skill_source_paths() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Stage two builtin auto-inject skills on disk.
|
||||
let builtin_root = tmp.path().join("builtin-skills");
|
||||
let auto_dir = builtin_root.join("auto-inject");
|
||||
std::fs::create_dir_all(auto_dir.join("cron")).unwrap();
|
||||
std::fs::write(
|
||||
auto_dir.join("cron").join("SKILL.md"),
|
||||
"---\nname: cron\ndescription: \n---",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::create_dir_all(auto_dir.join("todo")).unwrap();
|
||||
std::fs::write(
|
||||
auto_dir.join("todo").join("SKILL.md"),
|
||||
"---\nname: todo\ndescription: \n---",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// SAFETY: single-threaded test harness.
|
||||
unsafe {
|
||||
std::env::set_var(nomifun_extension::BUILTIN_SKILLS_ENV_VAR, &builtin_root);
|
||||
}
|
||||
let paths = resolve_skill_paths(tmp.path(), tmp.path());
|
||||
|
||||
let resolved = skill_service::materialize_skills_for_agent(&paths, "conv-1", &["cron".to_owned()])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolved.len(), 1);
|
||||
assert_eq!(resolved[0].name, "cron");
|
||||
assert_eq!(resolved[0].source_path, auto_dir.join("cron"));
|
||||
assert!(resolved[0].source_path.is_dir());
|
||||
assert!(resolved[0].source_path.join("SKILL.md").exists());
|
||||
|
||||
// Guardrail: the new contract forbids any per-conversation dir on
|
||||
// disk. Nothing under data_dir should have been created.
|
||||
assert!(!tmp.path().join("agent-skills").exists());
|
||||
assert!(!tmp.path().join("conversations").exists());
|
||||
|
||||
unsafe {
|
||||
std::env::remove_var(nomifun_extension::BUILTIN_SKILLS_ENV_VAR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Integration tests for startup materialization of the embedded
|
||||
//! builtin-skills corpus. Uses a purpose-built in-test `include_dir`
|
||||
//! tree so results are deterministic and independent of the real
|
||||
//! embedded corpus's contents.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use include_dir::{Dir, include_dir};
|
||||
use nomifun_extension::startup_materialize::{materialize_embedded_builtin_skills, materialize_if_needed};
|
||||
use tempfile::TempDir;
|
||||
|
||||
static FIXTURE_CORPUS: Dir<'static> = include_dir!("$CARGO_MANIFEST_DIR/tests/fixtures/builtin-skills-fixture");
|
||||
|
||||
fn read_version(target: &Path) -> Option<String> {
|
||||
std::fs::read_to_string(target.join(".version")).ok()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn materialize_writes_tree_and_version_file() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
materialize_embedded_builtin_skills(tmp.path(), &FIXTURE_CORPUS, "1.2.3")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let target = tmp.path().join("builtin-skills");
|
||||
assert!(target.is_dir(), "target dir should exist");
|
||||
assert_eq!(read_version(&target).as_deref(), Some("1.2.3"));
|
||||
assert!(
|
||||
target.join("example-skill").join("SKILL.md").is_file(),
|
||||
"expected fixture skill to be materialized"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn materialize_overwrites_existing_target() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let target = tmp.path().join("builtin-skills");
|
||||
std::fs::create_dir_all(target.join("stale-dir")).unwrap();
|
||||
std::fs::write(target.join("junk.txt"), b"old").unwrap();
|
||||
|
||||
materialize_embedded_builtin_skills(tmp.path(), &FIXTURE_CORPUS, "1.2.3")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!target.join("junk.txt").exists(), "stale file should be gone");
|
||||
assert!(!target.join("stale-dir").exists(), "stale dir should be gone");
|
||||
assert_eq!(read_version(&target).as_deref(), Some("1.2.3"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn materialize_cleans_staging_from_prior_crash() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let staging = tmp.path().join(".builtin-skills.tmp");
|
||||
std::fs::create_dir_all(&staging).unwrap();
|
||||
std::fs::write(staging.join("leftover.txt"), b"x").unwrap();
|
||||
|
||||
materialize_embedded_builtin_skills(tmp.path(), &FIXTURE_CORPUS, "1.2.3")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!staging.exists(), "staging should be cleaned after success");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gate_skips_when_version_matches() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
// Pre-populate target with matching version.
|
||||
let target = tmp.path().join("builtin-skills");
|
||||
std::fs::create_dir_all(&target).unwrap();
|
||||
std::fs::write(target.join(".version"), "1.2.3").unwrap();
|
||||
std::fs::write(target.join("sentinel"), "do-not-delete").unwrap();
|
||||
|
||||
let wrote = materialize_if_needed(tmp.path(), &FIXTURE_CORPUS, "1.2.3")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!wrote, "version match should skip materialize");
|
||||
assert!(
|
||||
target.join("sentinel").is_file(),
|
||||
"sentinel must be preserved when gate says skip"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gate_triggers_when_version_mismatches() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let target = tmp.path().join("builtin-skills");
|
||||
std::fs::create_dir_all(&target).unwrap();
|
||||
std::fs::write(target.join(".version"), "0.0.0").unwrap();
|
||||
std::fs::write(target.join("sentinel"), "will-be-wiped").unwrap();
|
||||
|
||||
let wrote = materialize_if_needed(tmp.path(), &FIXTURE_CORPUS, "1.2.3")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(wrote, "version mismatch should materialize");
|
||||
assert!(
|
||||
!target.join("sentinel").exists(),
|
||||
"sentinel must be wiped by fresh materialize"
|
||||
);
|
||||
assert_eq!(read_version(&target).as_deref(), Some("1.2.3"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gate_triggers_when_version_file_missing() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
|
||||
let wrote = materialize_if_needed(tmp.path(), &FIXTURE_CORPUS, "1.2.3")
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(wrote, "no existing version should materialize");
|
||||
assert_eq!(
|
||||
read_version(&tmp.path().join("builtin-skills")).as_deref(),
|
||||
Some("1.2.3")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gate_keeps_existing_tree_when_refresh_fails() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let target = tmp.path().join("builtin-skills");
|
||||
std::fs::create_dir_all(&target).unwrap();
|
||||
std::fs::write(target.join(".version"), "0.0.0").unwrap();
|
||||
std::fs::write(target.join("sentinel"), "keep-existing").unwrap();
|
||||
std::fs::write(tmp.path().join(".builtin-skills.tmp"), "stale-file").unwrap();
|
||||
|
||||
let wrote = materialize_if_needed(tmp.path(), &FIXTURE_CORPUS, "1.2.3")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(!wrote, "refresh failure should fall back to existing tree");
|
||||
assert_eq!(read_version(&target).as_deref(), Some("0.0.0"));
|
||||
assert!(
|
||||
target.join("sentinel").is_file(),
|
||||
"existing tree must be preserved when refresh fails"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gate_fails_when_initial_materialize_fails_without_existing_tree() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
std::fs::write(tmp.path().join(".builtin-skills.tmp"), "stale-file").unwrap();
|
||||
|
||||
let result = materialize_if_needed(tmp.path(), &FIXTURE_CORPUS, "1.2.3").await;
|
||||
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"first startup must not silently continue without a usable builtin-skills tree"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_materialize_produces_consistent_tree() {
|
||||
// Two concurrent invocations share the same staging/target paths, so
|
||||
// at most one reliably wins the atomic rename; the other may legally
|
||||
// fail mid-staging. What matters is that after both complete, the
|
||||
// on-disk tree is in a consistent, fully-populated state driven by
|
||||
// whichever call succeeded.
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let dir1 = tmp.path().to_path_buf();
|
||||
let dir2 = tmp.path().to_path_buf();
|
||||
|
||||
let (a, b) = tokio::join!(
|
||||
materialize_embedded_builtin_skills(&dir1, &FIXTURE_CORPUS, "1.2.3"),
|
||||
materialize_embedded_builtin_skills(&dir2, &FIXTURE_CORPUS, "1.2.3"),
|
||||
);
|
||||
assert!(
|
||||
a.is_ok() || b.is_ok(),
|
||||
"at least one concurrent materialize must succeed; a={a:?}, b={b:?}"
|
||||
);
|
||||
|
||||
let target = tmp.path().join("builtin-skills");
|
||||
assert_eq!(read_version(&target).as_deref(), Some("1.2.3"));
|
||||
assert!(target.join("example-skill").join("SKILL.md").is_file());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn concurrent_gate_materialize_all_callers_succeed() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let data_dir = tmp.path().to_path_buf();
|
||||
let mut handles = Vec::new();
|
||||
for _ in 0..8 {
|
||||
let dir = data_dir.clone();
|
||||
handles.push(tokio::spawn(async move {
|
||||
materialize_if_needed(&dir, &FIXTURE_CORPUS, "1.2.3").await
|
||||
}));
|
||||
}
|
||||
let mut results = Vec::new();
|
||||
for handle in handles {
|
||||
results.push(handle.await.unwrap());
|
||||
}
|
||||
|
||||
for result in &results {
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"concurrent startup materialize callers should not fail: {results:?}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
results.iter().filter(|result| matches!(result, Ok(true))).count() >= 1,
|
||||
"at least one caller should perform the materialize write: {results:?}"
|
||||
);
|
||||
|
||||
let target = tmp.path().join("builtin-skills");
|
||||
assert_eq!(read_version(&target).as_deref(), Some("1.2.3"));
|
||||
assert!(target.join("example-skill").join("SKILL.md").is_file());
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
//! Integration tests for state persistence (test-plan SP-1..SP-3).
|
||||
//!
|
||||
//! These tests exercise `ExtensionStateStore` and the underlying file I/O
|
||||
//! as black-box functionality: saving states after enable/disable, restoring
|
||||
//! states across "restarts", and default-enabled behaviour on first launch.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use nomifun_extension::{ExtensionState, ExtensionStateStore, load_states_from_file, save_states_to_file};
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn make_state(name: &str, version: &str, enabled: bool) -> ExtensionState {
|
||||
ExtensionState {
|
||||
name: name.to_string(),
|
||||
version: version.to_string(),
|
||||
enabled,
|
||||
installed_at: Some(1_700_000_000_000),
|
||||
last_activated_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SP-1: State save — enable/disable persists to extension-states.json
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn sp1_state_saved_after_enable_disable() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("extension-states.json");
|
||||
let store = ExtensionStateStore::new(path.clone());
|
||||
|
||||
store.load().await.unwrap();
|
||||
|
||||
// Enable an extension.
|
||||
store.set(make_state("my-ext", "1.0.0", true)).await;
|
||||
|
||||
// Disable another.
|
||||
store.set(make_state("other-ext", "2.0.0", false)).await;
|
||||
|
||||
// Flush to disk immediately.
|
||||
store.flush().await.unwrap();
|
||||
|
||||
// Verify the file contains both states.
|
||||
let loaded = load_states_from_file(&path).unwrap();
|
||||
assert_eq!(loaded.len(), 2);
|
||||
|
||||
assert!(loaded["my-ext"].enabled);
|
||||
assert_eq!(loaded["my-ext"].version, "1.0.0");
|
||||
|
||||
assert!(!loaded["other-ext"].enabled);
|
||||
assert_eq!(loaded["other-ext"].version, "2.0.0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn sp1_state_contains_timestamps() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("extension-states.json");
|
||||
let store = ExtensionStateStore::new(path.clone());
|
||||
|
||||
store.load().await.unwrap();
|
||||
store.set(make_state("my-ext", "1.0.0", true)).await;
|
||||
store.flush().await.unwrap();
|
||||
|
||||
let loaded = load_states_from_file(&path).unwrap();
|
||||
assert!(loaded["my-ext"].installed_at.is_some());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SP-2: State restore — app restart restores previous enabled/disabled state
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn sp2_state_restored_after_restart() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("extension-states.json");
|
||||
|
||||
// "First session": save states.
|
||||
{
|
||||
let store = ExtensionStateStore::new(path.clone());
|
||||
store.load().await.unwrap();
|
||||
store.set(make_state("ext-a", "1.0.0", true)).await;
|
||||
store.set(make_state("ext-b", "2.0.0", false)).await;
|
||||
store.flush().await.unwrap();
|
||||
}
|
||||
|
||||
// "Second session": load from the same file.
|
||||
{
|
||||
let store = ExtensionStateStore::new(path.clone());
|
||||
let states = store.load().await.unwrap();
|
||||
|
||||
assert_eq!(states.len(), 2);
|
||||
assert!(states["ext-a"].enabled);
|
||||
assert!(!states["ext-b"].enabled);
|
||||
|
||||
// Also verify in-memory read.
|
||||
let a = store.get("ext-a").await.unwrap();
|
||||
assert!(a.enabled);
|
||||
assert_eq!(a.version, "1.0.0");
|
||||
|
||||
let b = store.get("ext-b").await.unwrap();
|
||||
assert!(!b.enabled);
|
||||
assert_eq!(b.version, "2.0.0");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SP-3: No state file on first launch → all extensions default to enabled
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn sp3_no_state_file_returns_empty_map() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("nonexistent-states.json");
|
||||
let store = ExtensionStateStore::new(path);
|
||||
|
||||
let states = store.load().await.unwrap();
|
||||
assert!(states.is_empty());
|
||||
// An empty map means no overrides — the loader creates extensions with
|
||||
// enabled=true by default (verified in extension_loading_test.rs).
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sp3_load_states_from_nonexistent_file_returns_empty() {
|
||||
let states = load_states_from_file(std::path::Path::new("/nonexistent/states.json")).unwrap();
|
||||
assert!(states.is_empty());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Additional edge cases
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
async fn state_update_overwrites_previous() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("extension-states.json");
|
||||
let store = ExtensionStateStore::new(path.clone());
|
||||
|
||||
store.load().await.unwrap();
|
||||
|
||||
// Enable, then disable.
|
||||
store.set(make_state("my-ext", "1.0.0", true)).await;
|
||||
store.set(make_state("my-ext", "1.0.0", false)).await;
|
||||
|
||||
store.flush().await.unwrap();
|
||||
|
||||
let loaded = load_states_from_file(&path).unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert!(!loaded["my-ext"].enabled);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn set_all_replaces_entire_state() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("extension-states.json");
|
||||
let store = ExtensionStateStore::new(path.clone());
|
||||
|
||||
store.load().await.unwrap();
|
||||
store.set(make_state("old-ext", "1.0.0", true)).await;
|
||||
|
||||
let mut new_states = HashMap::new();
|
||||
new_states.insert("new-ext".to_string(), make_state("new-ext", "2.0.0", false));
|
||||
store.set_all(new_states).await;
|
||||
|
||||
store.flush().await.unwrap();
|
||||
|
||||
let loaded = load_states_from_file(&path).unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert!(loaded.contains_key("new-ext"));
|
||||
assert!(!loaded.contains_key("old-ext"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remove_state() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("extension-states.json");
|
||||
let store = ExtensionStateStore::new(path.clone());
|
||||
|
||||
store.load().await.unwrap();
|
||||
store.set(make_state("ext-a", "1.0.0", true)).await;
|
||||
store.set(make_state("ext-b", "1.0.0", true)).await;
|
||||
store.remove("ext-a").await;
|
||||
store.flush().await.unwrap();
|
||||
|
||||
let loaded = load_states_from_file(&path).unwrap();
|
||||
assert_eq!(loaded.len(), 1);
|
||||
assert!(loaded.contains_key("ext-b"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_and_load_file_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!(loaded["ext-a"].enabled);
|
||||
assert!(!loaded["ext-b"].enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_creates_parent_dirs() {
|
||||
let tmp = TempDir::new().unwrap();
|
||||
let path = tmp.path().join("nested").join("dir").join("states.json");
|
||||
|
||||
save_states_to_file(&path, &HashMap::new()).unwrap();
|
||||
assert!(path.exists());
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Integration tests for template resolution (test-plan MV-5 through MV-9).
|
||||
//!
|
||||
//! These test the public API surface of env var and @file: template resolution.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use nomifun_extension::{resolve_env_map, resolve_env_templates, resolve_file_reference};
|
||||
|
||||
// -- MV-5: env var template resolution --
|
||||
|
||||
#[test]
|
||||
fn mv5_env_var_resolved() {
|
||||
unsafe { std::env::set_var("_IT_MY_API_KEY", "test123") };
|
||||
let result = resolve_env_templates("${_IT_MY_API_KEY}", false).unwrap();
|
||||
assert_eq!(result, "test123");
|
||||
unsafe { std::env::remove_var("_IT_MY_API_KEY") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mv5_env_var_in_map() {
|
||||
unsafe { std::env::set_var("_IT_MAP_KEY", "secret_value") };
|
||||
let mut env = HashMap::new();
|
||||
env.insert("API_KEY".into(), "${_IT_MAP_KEY}".into());
|
||||
|
||||
let resolved = resolve_env_map(&env, false).unwrap();
|
||||
assert_eq!(resolved["API_KEY"], "secret_value");
|
||||
|
||||
unsafe { std::env::remove_var("_IT_MAP_KEY") };
|
||||
}
|
||||
|
||||
// -- MV-6: strict mode undefined variable error --
|
||||
|
||||
#[test]
|
||||
fn mv6_strict_mode_undefined_var_error() {
|
||||
let err = resolve_env_templates("${_IT_UNDEFINED_VAR}", true).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("_IT_UNDEFINED_VAR"));
|
||||
}
|
||||
|
||||
// -- MV-7: lenient mode undefined variable -> empty string --
|
||||
|
||||
#[test]
|
||||
fn mv7_lenient_mode_undefined_var_empty() {
|
||||
let result = resolve_env_templates("prefix_${_IT_UNDEFINED_VAR}_suffix", false).unwrap();
|
||||
assert_eq!(result, "prefix__suffix");
|
||||
}
|
||||
|
||||
// -- MV-8: @file: reference resolved --
|
||||
|
||||
#[test]
|
||||
fn mv8_file_reference_resolved() {
|
||||
let dir = std::env::temp_dir().join("ext_it_file_ref");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let prompt_dir = dir.join("prompts");
|
||||
std::fs::create_dir_all(&prompt_dir).unwrap();
|
||||
std::fs::write(prompt_dir.join("system.md"), "You are a helpful assistant.").unwrap();
|
||||
|
||||
let result = resolve_file_reference("@file:prompts/system.md", &dir).unwrap();
|
||||
assert_eq!(result, "You are a helpful assistant.");
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
// -- MV-9: @file: reference file not found --
|
||||
|
||||
#[test]
|
||||
fn mv9_file_reference_not_found() {
|
||||
let dir = std::env::temp_dir().join("ext_it_file_ref_missing");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let err = resolve_file_reference("@file:nonexistent.md", &dir).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("nonexistent.md"));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
|
||||
// -- Edge cases --
|
||||
|
||||
#[test]
|
||||
fn non_file_reference_passes_through() {
|
||||
let result = resolve_file_reference("Just a normal system prompt", std::path::Path::new("/tmp")).unwrap();
|
||||
assert_eq!(result, "Just a normal system prompt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_env_vars_in_one_string() {
|
||||
unsafe { std::env::set_var("_IT_HOST", "localhost") };
|
||||
unsafe { std::env::set_var("_IT_PORT", "8080") };
|
||||
let result = resolve_env_templates("http://${_IT_HOST}:${_IT_PORT}/api", false).unwrap();
|
||||
assert_eq!(result, "http://localhost:8080/api");
|
||||
unsafe { std::env::remove_var("_IT_HOST") };
|
||||
unsafe { std::env::remove_var("_IT_PORT") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn env_map_strict_mode_error() {
|
||||
let mut env = HashMap::new();
|
||||
env.insert("KEY".into(), "${_IT_STRICT_MISSING}".into());
|
||||
assert!(resolve_env_map(&env, true).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dollar_without_brace_is_literal() {
|
||||
let result = resolve_env_templates("price is $50 each", false).unwrap();
|
||||
assert_eq!(result, "price is $50 each");
|
||||
}
|
||||
|
||||
// -- Path traversal protection --
|
||||
|
||||
#[test]
|
||||
fn path_traversal_with_dotdot_blocked() {
|
||||
let dir = std::env::temp_dir().join("ext_it_traversal");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let outside = std::env::temp_dir().join("ext_it_traversal_outside.txt");
|
||||
std::fs::write(&outside, "should not be readable").unwrap();
|
||||
|
||||
let err = resolve_file_reference("@file:../ext_it_traversal_outside.txt", &dir).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("traversal") || msg.contains("Path traversal"));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
std::fs::remove_file(&outside).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_traversal_nested_dotdot_blocked() {
|
||||
let dir = std::env::temp_dir().join("ext_it_traversal_nested");
|
||||
let sub = dir.join("deep");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
|
||||
let outside = std::env::temp_dir().join("ext_it_traversal_nested_secret.txt");
|
||||
std::fs::write(&outside, "nested secret").unwrap();
|
||||
|
||||
let err = resolve_file_reference("@file:deep/../../ext_it_traversal_nested_secret.txt", &dir).unwrap_err();
|
||||
let msg = err.to_string();
|
||||
assert!(msg.contains("traversal") || msg.contains("Path traversal"));
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
std::fs::remove_file(&outside).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_subdirectory_reference_allowed() {
|
||||
let dir = std::env::temp_dir().join("ext_it_valid_subdir");
|
||||
let sub = dir.join("data");
|
||||
std::fs::create_dir_all(&sub).unwrap();
|
||||
std::fs::write(sub.join("config.json"), r#"{"key": "value"}"#).unwrap();
|
||||
|
||||
let result = resolve_file_reference("@file:data/config.json", &dir).unwrap();
|
||||
assert_eq!(result, r#"{"key": "value"}"#);
|
||||
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
Reference in New Issue
Block a user