mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat(ux): complete UX overhaul — design system, boot screen, onboarding, web polish Shared design system: CSS custom properties for spacing, typography, transitions, and color tokens used across web UI and boot screen. Boot screen: compact feature-tags line showing enabled subsystems (db, tools, routines, heartbeat, skills, sandbox, embeddings) at a glance. Downgrade startup info logs (libSQL, webhook, workspace seed) to debug level since the boot screen now covers this. Onboarding wizard: model picker with live API fetch, provider-aware auth flow, improved error recovery and progress display. Web UI: ARIA attributes, welcome card, streaming debounce, connection status banner, skeleton loaders, send cooldown. CLI: doctor command enhancements, status command cleanup, REPL banner consolidation, shared fmt module. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(ux): Apple-level design refinements — spring physics, glass morphism, chat polish Merge staging theme support (dark/light/system toggle) and layer UX polish on top: spring-physics motion, glass morphism depth, chat experience improvements, and responsive mobile refinements. Design system: - Restore and extend design token system (spacing, typography, timing, easing) with legacy aliases for theme compatibility - Add shadow tiers, accent glow, glass morphism, spring easing tokens - Tokens defined in both dark (:root) and light ([data-theme="light"]) Micro-interactions (Phase 2): - Spring-overshoot message entry animation (slideUp) - Spring-scale button press on all interactive buttons - Tab crossfade animation, tool card smooth accordion (max-height) - Modal scale(0.95) + blur(8px) entry, toast spring slide - Sidebar width crossfade, card hover lift Visual depth (Phase 3): - Tab bar glass morphism + surface highlight + sliding indicator - Active tab accent background pill - Assistant message accent left border, user message bubble tail - Floating input area (rounded + shadow + margin) Chat polish (Phase 4): - Smooth streaming cursor (cursorPulse), message hover timestamps - Time separators (Today/Yesterday/date) - Textarea smooth auto-expand, send button glow Settings & forms (Phase 5): - iOS-style toggle switches for boolean settings - Input focus glow, save feedback spring animation - Welcome card with gradient background + proper spacing - Sticky settings group headers with glass backdrop Accessibility & mobile (Phase 6): - Animated focus ring, prefers-reduced-motion global kill-switch - Touch target audit (44px min), mobile bottom-sheet modals - Mobile bottom tab bar, toast redesign (icon + border + countdown) - Thread hover translateX, badge in_progress pulse Bug fixes: - Gateway/TEE popover z-index (tab-bar z-index: 200, popovers 500) - Connection lost banner as fixed top bar instead of flex child - Sidebar collapse keeps toggle + new thread buttons visible - Downgrade noisy startup logs (db, webhook, vector) to debug - Remove green dot pulse animation on connected status - Deduplicate confirm-modal in HTML, add tab-indicator div Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(web): mobile layout improvements — sidebar toggle, settings drill-down, tab bar polish - Fix mobile sidebar toggle: use expanded-mobile class instead of collapsed, add backdrop overlay, auto-close on thread select, outside-click dismiss - Settings: replace cramped horizontal tabs with drill-down navigation (category list → detail view → back button) - Bottom tab bar: add glass morphism, hide theme toggle, flip tab indicator to top edge - Keep thread toggle button visible in collapsed 36px sidebar strip Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(repl): interactive approval selector and transient status lines - Replace ASCII-art approval box with clean horizontal rule card - Add inquire-based interactive selector for tool approvals (↑↓ + Enter) - Selector runs directly from send_status via spawn_blocking, with stdin_locked flag to prevent readline from competing for stdin - Transient thinking/tool-started lines: each replaces the previous, all erased before final output (no clutter left in scrollback) - Esc in selector sends denial so agent never gets stuck Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: widen TurnCost token fields to u64 and remove unused variable - Change input_tokens/output_tokens from u32 to u64 in StatusUpdate::TurnCost, SseEvent::TurnCost, and the thread_ops emit site to avoid truncation on large conversations - Remove unused _routine_engine_for_loop binding in agent_loop.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: reduce startup log noise — demote info to debug Demote routine startup messages (builder, WASM tools, tunnel, WASM channels) from info to debug so the default log output stays clean. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(web): allow CDN scripts in CSP connect-src directive Add cdn.jsdelivr.net and cdnjs.cloudflare.com to connect-src so the browser can fetch marked.js and DOMPurify without CSP violations. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: fix cargo fmt in repl.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(web): gate turn_cost SSE handler on current thread Prevents cost badge from attaching to the wrong message when switching threads or receiving events from background threads. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * ci: retrigger CI * fix: add missing extension_manager to webhook EngineContext The webhook trigger path added in #736 was missing the extension_manager field introduced by #1453. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: ignore RUSTSEC-2026-0049 rustls-webpki CRL advisory Low impact — requires compromised CA to exploit. Tracked for upstream rustls-webpki upgrade. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(routines): use fields.join for cron normalization Use split_whitespace fields instead of re-trimming the original string to avoid preserving extra internal whitespace in cron expressions. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(repl): Apple-style approval card — clean vertical flow - Drop verbose tool description (the command IS the decision surface) - Unified vertical pipe layout: ◆ header → │ params → │ selector - Selector options show keyboard shortcuts inline: Approve (y) - Compact help message, answered state uses └ to close the flow - No horizontal rules, no blank-line padding — just breathing room Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(repl): replace inquire with crossterm for approval selector Drop the inquire dependency (which pulled in crossterm 0.25, duplicating the existing 0.28). The 3-option approval selector is now built directly with crossterm raw mode — same UX, zero new dependencies. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore(deps): upgrade crossterm 0.28 → 0.29, eliminate duplication termimad (via crokey) uses crossterm 0.29. Upgrading our direct dependency from 0.28 to 0.29 collapses to a single crossterm version in the dependency tree. Also migrated termimad::crossterm:: references to the direct crossterm import. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review comments — box_top off-by-one, smart_truncate overflow, mobile theme toggle - Fix box_top() fill calculation: was off-by-one, producing boxes 1 char too wide (fmt.rs) - Fix smart_truncate(): account for "..." in the budget so output never exceeds max_chars (repl.rs) - Move theme toggle to settings sidebar on mobile instead of display:none, so mobile users can still switch themes (style.css, index.html, app.js) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: cargo fmt repl.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review — retry duplication, CSP connect-src, deny color - Remove failed message before retry to prevent duplicate user messages - Revert connect-src to 'self' — CDN hosts only need script-src - Use red for Deny confirmation in REPL approval selector Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
1152 lines
39 KiB
Rust
1152 lines
39 KiB
Rust
//! Generic WASM tool loader for loading tools from files or directories.
|
|
//!
|
|
//! This module provides a way to load WASM tools dynamically at runtime from:
|
|
//! - A directory containing `<name>.wasm` and `<name>.capabilities.json`
|
|
//! - Build artifacts in `tools-src/` (dev mode, auto-detected)
|
|
//! - Database storage (via [`WasmToolStore`])
|
|
//!
|
|
//! # Example: Loading from Directory
|
|
//!
|
|
//! ```text
|
|
//! ~/.ironclaw/tools/
|
|
//! ├── slack.wasm
|
|
//! ├── slack.capabilities.json
|
|
//! ├── github.wasm
|
|
//! └── github.capabilities.json
|
|
//! ```
|
|
//!
|
|
//! ```ignore
|
|
//! let loader = WasmToolLoader::new(runtime, registry);
|
|
//! loader.load_from_dir(Path::new("~/.ironclaw/tools/")).await?;
|
|
//! ```
|
|
//!
|
|
//! # Dev Mode
|
|
//!
|
|
//! When `load_dev_tools()` is called, the loader scans `tools-src/*/` for build
|
|
//! artifacts. Tools found there are loaded directly from the build output,
|
|
//! skipping the install directory. This means during development you just
|
|
//! rebuild the WASM and restart the host, no manual copy step needed.
|
|
//!
|
|
//! # Security
|
|
//!
|
|
//! Tools loaded from files are assigned `TrustLevel::User` by default, meaning
|
|
//! they run with the most restrictive permissions. Only tools explicitly marked
|
|
//! as `verified` or `system` in the database get elevated trust.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
use tokio::fs;
|
|
|
|
use crate::secrets::SecretsStore;
|
|
use crate::tools::registry::{ToolRegistry, WasmRegistrationError, WasmToolRegistration};
|
|
use crate::tools::wasm::capabilities_schema::CapabilitiesFile;
|
|
use crate::tools::wasm::{
|
|
Capabilities, OAuthRefreshConfig, WasmError, WasmStorageError, WasmToolRuntime, WasmToolStore,
|
|
};
|
|
|
|
/// Error during WASM tool loading.
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum WasmLoadError {
|
|
#[error("IO error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
|
|
#[error("WASM file not found: {0}")]
|
|
WasmNotFound(PathBuf),
|
|
|
|
#[error("Capabilities file not found: {0}")]
|
|
CapabilitiesNotFound(PathBuf),
|
|
|
|
#[error("Invalid capabilities JSON: {0}")]
|
|
InvalidCapabilities(String),
|
|
|
|
#[error("WASM compilation error: {0}")]
|
|
Compilation(#[from] WasmError),
|
|
|
|
#[error("Storage error: {0}")]
|
|
Storage(#[from] WasmStorageError),
|
|
|
|
#[error("Registration error: {0}")]
|
|
Registration(#[from] WasmRegistrationError),
|
|
|
|
#[error("Invalid tool name: {0}")]
|
|
InvalidName(String),
|
|
|
|
#[error("WIT version mismatch: {0}")]
|
|
WitVersionMismatch(String),
|
|
}
|
|
|
|
/// Loads WASM tools from files or storage into the registry.
|
|
pub struct WasmToolLoader {
|
|
runtime: Arc<WasmToolRuntime>,
|
|
registry: Arc<ToolRegistry>,
|
|
secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
|
|
}
|
|
|
|
impl WasmToolLoader {
|
|
/// Create a new loader with the given runtime and registry.
|
|
pub fn new(runtime: Arc<WasmToolRuntime>, registry: Arc<ToolRegistry>) -> Self {
|
|
Self {
|
|
runtime,
|
|
registry,
|
|
secrets_store: None,
|
|
}
|
|
}
|
|
|
|
/// Set the secrets store for credential injection in WASM tools.
|
|
pub fn with_secrets_store(mut self, store: Arc<dyn SecretsStore + Send + Sync>) -> Self {
|
|
self.secrets_store = Some(store);
|
|
self
|
|
}
|
|
|
|
/// Load a single WASM tool from a file pair.
|
|
///
|
|
/// Expects:
|
|
/// - `wasm_path`: Path to the `.wasm` file
|
|
/// - `capabilities_path`: Path to the `.capabilities.json` file (optional)
|
|
///
|
|
/// If no capabilities file is provided, the tool gets no capabilities (default deny).
|
|
pub async fn load_from_files(
|
|
&self,
|
|
name: &str,
|
|
wasm_path: &Path,
|
|
capabilities_path: Option<&Path>,
|
|
) -> Result<(), WasmLoadError> {
|
|
if name.is_empty() || name.contains('/') || name.contains('\\') {
|
|
return Err(WasmLoadError::InvalidName(name.to_string()));
|
|
}
|
|
|
|
// Read WASM bytes
|
|
if !wasm_path.exists() {
|
|
return Err(WasmLoadError::WasmNotFound(wasm_path.to_path_buf()));
|
|
}
|
|
let wasm_bytes = fs::read(wasm_path).await?;
|
|
|
|
// Read capabilities (optional) and extract OAuth refresh config,
|
|
// tool description, and parameter schema.
|
|
let (capabilities, oauth_refresh, description, schema) =
|
|
if let Some(cap_path) = capabilities_path {
|
|
if cap_path.exists() {
|
|
let cap_bytes = fs::read(cap_path).await?;
|
|
let cap_file = CapabilitiesFile::from_bytes(&cap_bytes)
|
|
.map_err(|e| WasmLoadError::InvalidCapabilities(e.to_string()))?;
|
|
cap_file.validate(name);
|
|
|
|
// Check WIT version compatibility
|
|
check_wit_version_compat(
|
|
name,
|
|
cap_file.wit_version.as_deref(),
|
|
crate::tools::wasm::WIT_TOOL_VERSION,
|
|
)?;
|
|
|
|
let caps = cap_file.to_capabilities();
|
|
let oauth = resolve_oauth_refresh_config(&cap_file);
|
|
let desc = cap_file.description.clone();
|
|
// Validate parameters schema before accepting it.
|
|
let params = cap_file.parameters.clone().and_then(|p| {
|
|
let errors = crate::tools::validate_tool_schema(&p, name);
|
|
if errors.is_empty() {
|
|
Some(p)
|
|
} else {
|
|
tracing::warn!(
|
|
tool = name,
|
|
?errors,
|
|
"Invalid parameters schema in capabilities.json, \
|
|
using permissive fallback"
|
|
);
|
|
None
|
|
}
|
|
});
|
|
if desc.is_none() {
|
|
tracing::warn!(
|
|
tool = name,
|
|
path = %cap_path.display(),
|
|
"Capabilities file missing \"description\" field; \
|
|
tool will use generic fallback description"
|
|
);
|
|
}
|
|
if params.is_none() && cap_file.parameters.is_none() {
|
|
tracing::warn!(
|
|
tool = name,
|
|
path = %cap_path.display(),
|
|
"Capabilities file missing \"parameters\" field; \
|
|
tool will accept any JSON object (permissive fallback)"
|
|
);
|
|
}
|
|
(caps, oauth, desc, params)
|
|
} else {
|
|
tracing::warn!(
|
|
path = %cap_path.display(),
|
|
"Capabilities file not found, using default (no permissions)"
|
|
);
|
|
(Capabilities::default(), None, None, None)
|
|
}
|
|
} else {
|
|
tracing::warn!(
|
|
tool = name,
|
|
"No capabilities file for WASM tool; \
|
|
tool will use generic fallback description and accept any JSON object"
|
|
);
|
|
(Capabilities::default(), None, None, None)
|
|
};
|
|
|
|
// Register the tool
|
|
self.registry
|
|
.register_wasm(WasmToolRegistration {
|
|
name,
|
|
wasm_bytes: &wasm_bytes,
|
|
runtime: &self.runtime,
|
|
capabilities,
|
|
limits: None,
|
|
description: description.as_deref(),
|
|
schema,
|
|
secrets_store: self.secrets_store.clone(),
|
|
oauth_refresh,
|
|
})
|
|
.await?;
|
|
|
|
tracing::debug!(
|
|
name = name,
|
|
wasm_path = %wasm_path.display(),
|
|
"Loaded WASM tool from file"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Load all WASM tools from a directory.
|
|
///
|
|
/// Scans the directory for `*.wasm` files and loads each one, looking for
|
|
/// a matching `*.capabilities.json` sidecar file.
|
|
///
|
|
/// # Directory Layout
|
|
///
|
|
/// ```text
|
|
/// tools/
|
|
/// ├── slack.wasm <- Tool WASM component
|
|
/// ├── slack.capabilities.json <- Capabilities (optional)
|
|
/// ├── github.wasm
|
|
/// └── github.capabilities.json
|
|
/// ```
|
|
///
|
|
/// Tools without a capabilities file get no permissions (default deny).
|
|
pub async fn load_from_dir(&self, dir: &Path) -> Result<LoadResults, WasmLoadError> {
|
|
match fs::metadata(dir).await {
|
|
Ok(meta) if meta.is_dir() => {}
|
|
Ok(_) => {
|
|
return Err(WasmLoadError::Io(std::io::Error::new(
|
|
std::io::ErrorKind::NotADirectory,
|
|
format!("{} is not a directory", dir.display()),
|
|
)));
|
|
}
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
return Ok(LoadResults::default());
|
|
}
|
|
Err(e) => return Err(WasmLoadError::Io(e)),
|
|
}
|
|
|
|
// Handle TOCTOU: if read_dir fails with NotFound, treat as empty
|
|
let mut entries = match fs::read_dir(dir).await {
|
|
Ok(entries) => entries,
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
|
return Ok(LoadResults::default());
|
|
}
|
|
Err(e) => return Err(WasmLoadError::Io(e)),
|
|
};
|
|
|
|
let mut results = LoadResults::default();
|
|
let mut tool_entries = Vec::new();
|
|
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
let path = entry.path();
|
|
|
|
if path.extension().and_then(|e| e.to_str()) != Some("wasm") {
|
|
continue;
|
|
}
|
|
|
|
let name = match path.file_stem().and_then(|s| s.to_str()) {
|
|
Some(n) => n.to_string(),
|
|
None => {
|
|
results.errors.push((
|
|
path.clone(),
|
|
WasmLoadError::InvalidName("invalid filename".to_string()),
|
|
));
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let cap_path = path.with_extension("capabilities.json");
|
|
let has_cap = cap_path.exists();
|
|
tool_entries.push((name, path, if has_cap { Some(cap_path) } else { None }));
|
|
}
|
|
|
|
// Load all tools in parallel (file I/O + WASM compilation + registration)
|
|
let load_futures = tool_entries
|
|
.iter()
|
|
.map(|(name, path, cap_path)| self.load_from_files(name, path, cap_path.as_deref()));
|
|
|
|
let load_results = futures::future::join_all(load_futures).await;
|
|
|
|
for ((name, path, _), result) in tool_entries.into_iter().zip(load_results) {
|
|
match result {
|
|
Ok(()) => {
|
|
results.loaded.push(name);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
name = name,
|
|
path = %path.display(),
|
|
error = %e,
|
|
"Failed to load WASM tool"
|
|
);
|
|
results.errors.push((path, e));
|
|
}
|
|
}
|
|
}
|
|
|
|
if !results.loaded.is_empty() {
|
|
tracing::debug!(
|
|
count = results.loaded.len(),
|
|
tools = ?results.loaded,
|
|
"Loaded WASM tools from directory"
|
|
);
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
/// Load a WASM tool from database storage.
|
|
///
|
|
/// This is a convenience wrapper around [`ToolRegistry::register_wasm_from_storage`].
|
|
pub async fn load_from_storage(
|
|
&self,
|
|
store: &dyn WasmToolStore,
|
|
user_id: &str,
|
|
tool_name: &str,
|
|
) -> Result<(), WasmLoadError> {
|
|
self.registry
|
|
.register_wasm_from_storage(store, &self.runtime, user_id, tool_name)
|
|
.await?;
|
|
|
|
tracing::info!(
|
|
user_id = user_id,
|
|
name = tool_name,
|
|
"Loaded WASM tool from storage"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Load all active WASM tools for a user from storage.
|
|
pub async fn load_all_from_storage(
|
|
&self,
|
|
store: &dyn WasmToolStore,
|
|
user_id: &str,
|
|
) -> Result<LoadResults, WasmLoadError> {
|
|
let tools = store.list(user_id).await?;
|
|
let mut results = LoadResults::default();
|
|
|
|
for tool in tools {
|
|
// Skip non-active tools
|
|
if tool.status != crate::tools::wasm::ToolStatus::Active {
|
|
continue;
|
|
}
|
|
|
|
match self.load_from_storage(store, user_id, &tool.name).await {
|
|
Ok(()) => {
|
|
results.loaded.push(tool.name);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
name = tool.name,
|
|
user_id = user_id,
|
|
error = %e,
|
|
"Failed to load WASM tool from storage"
|
|
);
|
|
results.errors.push((PathBuf::from(&tool.name), e));
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
}
|
|
|
|
/// Check that a declared WIT version is compatible with the host WIT version.
|
|
///
|
|
/// Compatibility rules (semver):
|
|
/// - Same major version required (0.x is special: same minor required)
|
|
/// - Extension WIT version must not be greater than host version
|
|
///
|
|
/// If `declared` is `None`, the check is skipped (pre-versioning extension).
|
|
pub fn check_wit_version_compat(
|
|
name: &str,
|
|
declared: Option<&str>,
|
|
host_version: &str,
|
|
) -> Result<(), WasmLoadError> {
|
|
let Some(declared_str) = declared else {
|
|
return Ok(());
|
|
};
|
|
|
|
let declared = semver::Version::parse(declared_str).map_err(|e| {
|
|
WasmLoadError::WitVersionMismatch(format!(
|
|
"Extension '{name}' has invalid wit_version '{declared_str}': {e}"
|
|
))
|
|
})?;
|
|
|
|
let host = semver::Version::parse(host_version).map_err(|e| {
|
|
WasmLoadError::WitVersionMismatch(format!(
|
|
"Host WIT version '{host_version}' is invalid: {e}"
|
|
))
|
|
})?;
|
|
|
|
// Major version must match
|
|
if declared.major != host.major {
|
|
return Err(WasmLoadError::WitVersionMismatch(format!(
|
|
"Extension '{name}' compiled against WIT {declared}, but host supports WIT {host}. \
|
|
Major version mismatch — rebuild the extension."
|
|
)));
|
|
}
|
|
|
|
// For 0.x versions, minor must also match (semver: 0.x.y has no compatibility guarantees)
|
|
if declared.major == 0 && declared.minor != host.minor {
|
|
return Err(WasmLoadError::WitVersionMismatch(format!(
|
|
"Extension '{name}' compiled against WIT {declared}, but host supports WIT {host}. \
|
|
Rebuild the extension against the current WIT."
|
|
)));
|
|
}
|
|
|
|
// Extension cannot be newer than host
|
|
if declared > host {
|
|
return Err(WasmLoadError::WitVersionMismatch(format!(
|
|
"Extension '{name}' compiled against WIT {declared}, but host only supports WIT {host}. \
|
|
Update the host or rebuild with an older WIT."
|
|
)));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Extract OAuth refresh configuration from a parsed capabilities file.
|
|
///
|
|
/// Returns `None` if there's no `auth.oauth` section or if the client_id
|
|
/// can't be resolved from any source (inline, env var, or built-in defaults).
|
|
///
|
|
/// Fallback chain for client_id:
|
|
/// `oauth.client_id` > env var (`oauth.client_id_env`) > `builtin_credentials()`
|
|
fn resolve_oauth_refresh_config(cap_file: &CapabilitiesFile) -> Option<OAuthRefreshConfig> {
|
|
let auth = cap_file.auth.as_ref()?;
|
|
let oauth = auth.oauth.as_ref()?;
|
|
|
|
let builtin = crate::cli::oauth_defaults::builtin_credentials(&auth.secret_name);
|
|
|
|
let client_id = oauth
|
|
.client_id
|
|
.clone()
|
|
.or_else(|| {
|
|
oauth
|
|
.client_id_env
|
|
.as_ref()
|
|
.and_then(|env| std::env::var(env).ok())
|
|
})
|
|
.or_else(|| builtin.as_ref().map(|c| c.client_id.to_string()))?;
|
|
|
|
let client_secret = oauth
|
|
.client_secret
|
|
.clone()
|
|
.or_else(|| {
|
|
oauth
|
|
.client_secret_env
|
|
.as_ref()
|
|
.and_then(|env| std::env::var(env).ok())
|
|
})
|
|
.or_else(|| builtin.as_ref().map(|c| c.client_secret.to_string()));
|
|
|
|
Some(OAuthRefreshConfig {
|
|
token_url: oauth.token_url.clone(),
|
|
client_id,
|
|
client_secret,
|
|
secret_name: auth.secret_name.clone(),
|
|
provider: auth.provider.clone(),
|
|
})
|
|
}
|
|
|
|
/// Results from loading multiple tools.
|
|
#[derive(Debug, Default)]
|
|
pub struct LoadResults {
|
|
/// Names of successfully loaded tools.
|
|
pub loaded: Vec<String>,
|
|
|
|
/// Errors encountered (path/name, error).
|
|
pub errors: Vec<(PathBuf, WasmLoadError)>,
|
|
}
|
|
|
|
impl LoadResults {
|
|
/// Check if all tools loaded successfully.
|
|
pub fn all_succeeded(&self) -> bool {
|
|
self.errors.is_empty()
|
|
}
|
|
|
|
/// Get the count of successfully loaded tools.
|
|
pub fn success_count(&self) -> usize {
|
|
self.loaded.len()
|
|
}
|
|
|
|
/// Get the count of failed tools.
|
|
pub fn error_count(&self) -> usize {
|
|
self.errors.len()
|
|
}
|
|
}
|
|
|
|
/// Compile-time project root, used to locate tools-src/ in dev builds.
|
|
const CARGO_MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR");
|
|
|
|
/// Resolve the WASM target directory for a given crate directory.
|
|
///
|
|
/// Checks (in order):
|
|
/// 1. `CARGO_TARGET_DIR` env var (shared target dir)
|
|
/// 2. `<crate_dir>/target/` (default per-crate layout)
|
|
pub fn resolve_wasm_target_dir(crate_dir: &Path) -> PathBuf {
|
|
crate::registry::artifacts::resolve_target_dir(crate_dir)
|
|
}
|
|
|
|
/// Return the expected path to a compiled WASM artifact for a given crate.
|
|
///
|
|
/// Combines [`resolve_wasm_target_dir`] with the `wasm32-wasip2/release/` subdirectory
|
|
/// and the binary name without extension (e.g. `slack_tool`).
|
|
///
|
|
/// `binary_name` should not include the `.wasm` extension; it is appended automatically.
|
|
///
|
|
/// This is a convenience function for callers that know the exact triple (wasip2)
|
|
/// and binary name. For multi-triple search, use
|
|
/// [`crate::registry::artifacts::find_wasm_artifact`] instead.
|
|
pub fn wasm_artifact_path(crate_dir: &Path, binary_name: &str) -> PathBuf {
|
|
resolve_wasm_target_dir(crate_dir)
|
|
.join("wasm32-wasip2/release")
|
|
.join(format!("{}.wasm", binary_name))
|
|
}
|
|
|
|
/// Resolve the tools source directory.
|
|
///
|
|
/// Checks (in order):
|
|
/// 1. `IRONCLAW_TOOLS_SRC` env var
|
|
/// 2. `<CARGO_MANIFEST_DIR>/tools-src/` (dev builds)
|
|
fn tools_src_dir() -> PathBuf {
|
|
if let Ok(dir) = std::env::var("IRONCLAW_TOOLS_SRC") {
|
|
return PathBuf::from(dir);
|
|
}
|
|
PathBuf::from(CARGO_MANIFEST_DIR).join("tools-src")
|
|
}
|
|
|
|
/// Discover WASM tools available as build artifacts in `tools-src/`.
|
|
///
|
|
/// Scans each subdirectory for:
|
|
/// - `tools-src/<name>/target/wasm32-wasip2/release/<crate_name>_tool.wasm`
|
|
/// - `tools-src/<name>/<name>-tool.capabilities.json`
|
|
///
|
|
/// Returns a map of install-name (e.g. "gmail-tool") to paths.
|
|
pub async fn discover_dev_tools() -> Result<HashMap<String, DiscoveredTool>, std::io::Error> {
|
|
let src_dir = tools_src_dir();
|
|
let mut tools = HashMap::new();
|
|
|
|
if !src_dir.is_dir() {
|
|
return Ok(tools);
|
|
}
|
|
|
|
let mut entries = fs::read_dir(&src_dir).await?;
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
let path = entry.path();
|
|
if !path.is_dir() {
|
|
continue;
|
|
}
|
|
|
|
let dir_name = match path.file_name().and_then(|n| n.to_str()) {
|
|
Some(n) => n.to_string(),
|
|
None => continue,
|
|
};
|
|
|
|
// Convention: crate name uses underscores, directory uses hyphens
|
|
let crate_name = dir_name.replace('-', "_");
|
|
let install_name = format!("{}-tool", dir_name);
|
|
|
|
let wasm_path = wasm_artifact_path(&path, &format!("{}_tool", crate_name));
|
|
|
|
if !wasm_path.exists() {
|
|
continue;
|
|
}
|
|
|
|
let caps_path = path.join(format!("{}-tool.capabilities.json", dir_name));
|
|
|
|
tools.insert(
|
|
install_name,
|
|
DiscoveredTool {
|
|
wasm_path,
|
|
capabilities_path: if caps_path.exists() {
|
|
Some(caps_path)
|
|
} else {
|
|
None
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
Ok(tools)
|
|
}
|
|
|
|
/// Load WASM tools from build artifacts in `tools-src/`.
|
|
///
|
|
/// In dev mode, tools can be loaded directly from their build output without
|
|
/// needing to install them to `~/.ironclaw/tools/` first. Build artifacts
|
|
/// that are newer than installed copies take priority.
|
|
///
|
|
/// Set `IRONCLAW_TOOLS_SRC` env var to override the source directory.
|
|
pub async fn load_dev_tools(
|
|
loader: &WasmToolLoader,
|
|
install_dir: &Path,
|
|
) -> Result<LoadResults, WasmLoadError> {
|
|
let dev_tools = discover_dev_tools().await?;
|
|
let mut results = LoadResults::default();
|
|
|
|
if dev_tools.is_empty() {
|
|
return Ok(results);
|
|
}
|
|
|
|
for (name, discovered) in &dev_tools {
|
|
// Check if the build artifact is newer than the installed copy
|
|
let installed_path = install_dir.join(format!("{}.wasm", name));
|
|
let should_load = if installed_path.exists() {
|
|
// Compare modification times: prefer fresher build artifact
|
|
match (
|
|
fs::metadata(&discovered.wasm_path).await,
|
|
fs::metadata(&installed_path).await,
|
|
) {
|
|
(Ok(dev_meta), Ok(inst_meta)) => {
|
|
let dev_modified = dev_meta.modified().unwrap_or(std::time::UNIX_EPOCH);
|
|
let inst_modified = inst_meta.modified().unwrap_or(std::time::UNIX_EPOCH);
|
|
dev_modified > inst_modified
|
|
}
|
|
_ => true,
|
|
}
|
|
} else {
|
|
true
|
|
};
|
|
|
|
if !should_load {
|
|
continue;
|
|
}
|
|
|
|
tracing::info!(
|
|
name = name,
|
|
wasm_path = %discovered.wasm_path.display(),
|
|
"Loading dev tool from build artifacts (newer than installed)"
|
|
);
|
|
|
|
match loader
|
|
.load_from_files(
|
|
name,
|
|
&discovered.wasm_path,
|
|
discovered.capabilities_path.as_deref(),
|
|
)
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
results.loaded.push(name.clone());
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
name = name,
|
|
error = %e,
|
|
"Failed to load dev tool"
|
|
);
|
|
results.errors.push((discovered.wasm_path.clone(), e));
|
|
}
|
|
}
|
|
}
|
|
|
|
if !results.loaded.is_empty() {
|
|
tracing::info!(
|
|
count = results.loaded.len(),
|
|
tools = ?results.loaded,
|
|
"Loaded dev tools from build artifacts"
|
|
);
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
/// Discover WASM tool files in a directory without loading them.
|
|
///
|
|
/// Returns a map of tool name -> (wasm_path, capabilities_path).
|
|
pub async fn discover_tools(dir: &Path) -> Result<HashMap<String, DiscoveredTool>, std::io::Error> {
|
|
let mut tools = HashMap::new();
|
|
|
|
if !dir.is_dir() {
|
|
return Ok(tools);
|
|
}
|
|
|
|
let mut entries = fs::read_dir(dir).await?;
|
|
|
|
while let Some(entry) = entries.next_entry().await? {
|
|
let path = entry.path();
|
|
|
|
if path.extension().and_then(|e| e.to_str()) != Some("wasm") {
|
|
continue;
|
|
}
|
|
|
|
let name = match path.file_stem().and_then(|s| s.to_str()) {
|
|
Some(n) => n.to_string(),
|
|
None => continue,
|
|
};
|
|
|
|
let cap_path = path.with_extension("capabilities.json");
|
|
|
|
tools.insert(
|
|
name,
|
|
DiscoveredTool {
|
|
wasm_path: path,
|
|
capabilities_path: if cap_path.exists() {
|
|
Some(cap_path)
|
|
} else {
|
|
None
|
|
},
|
|
},
|
|
);
|
|
}
|
|
|
|
Ok(tools)
|
|
}
|
|
|
|
/// A discovered WASM tool (not yet loaded).
|
|
#[derive(Debug)]
|
|
pub struct DiscoveredTool {
|
|
/// Path to the WASM file.
|
|
pub wasm_path: PathBuf,
|
|
|
|
/// Path to the capabilities file (if present).
|
|
pub capabilities_path: Option<PathBuf>,
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::io::Write;
|
|
|
|
use tempfile::TempDir;
|
|
|
|
use crate::testing::credentials::{TEST_OAUTH_CLIENT_ID, TEST_OAUTH_CLIENT_SECRET};
|
|
use crate::tools::wasm::loader::{WasmLoadError, check_wit_version_compat, discover_tools};
|
|
|
|
#[test]
|
|
fn wit_version_compat_none_is_ok() {
|
|
// Pre-versioning extensions (no wit_version declared) should always pass
|
|
assert!(check_wit_version_compat("test", None, "0.2.0").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn wit_version_compat_exact_match() {
|
|
assert!(check_wit_version_compat("test", Some("0.2.0"), "0.2.0").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn wit_version_compat_patch_older_ok() {
|
|
// Extension on older patch of same minor is compatible
|
|
assert!(check_wit_version_compat("test", Some("0.2.0"), "0.2.1").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn wit_version_compat_minor_mismatch_0x() {
|
|
// For 0.x, different minor is breaking
|
|
assert!(check_wit_version_compat("test", Some("0.1.0"), "0.2.0").is_err());
|
|
assert!(check_wit_version_compat("test", Some("0.3.0"), "0.2.0").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn wit_version_compat_major_mismatch() {
|
|
assert!(check_wit_version_compat("test", Some("1.0.0"), "2.0.0").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn wit_version_compat_extension_newer_than_host() {
|
|
assert!(check_wit_version_compat("test", Some("0.2.1"), "0.2.0").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn wit_version_compat_invalid_version() {
|
|
assert!(check_wit_version_compat("test", Some("not-a-version"), "0.2.0").is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_discover_tools_empty_dir() {
|
|
let dir = TempDir::new().unwrap();
|
|
let tools = discover_tools(dir.path()).await.unwrap();
|
|
assert!(tools.is_empty());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_discover_tools_with_wasm() {
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
// Create a fake .wasm file
|
|
let wasm_path = dir.path().join("test_tool.wasm");
|
|
std::fs::File::create(&wasm_path).unwrap();
|
|
|
|
let tools = discover_tools(dir.path()).await.unwrap();
|
|
assert_eq!(tools.len(), 1);
|
|
assert!(tools.contains_key("test_tool"));
|
|
assert!(tools["test_tool"].capabilities_path.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_discover_tools_with_capabilities() {
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
// Create wasm and capabilities files
|
|
std::fs::File::create(dir.path().join("slack.wasm")).unwrap();
|
|
let mut cap_file =
|
|
std::fs::File::create(dir.path().join("slack.capabilities.json")).unwrap();
|
|
cap_file.write_all(b"{}").unwrap();
|
|
|
|
let tools = discover_tools(dir.path()).await.unwrap();
|
|
assert_eq!(tools.len(), 1);
|
|
assert!(tools["slack"].capabilities_path.is_some());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_discover_tools_ignores_non_wasm() {
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
// Create non-wasm files
|
|
std::fs::File::create(dir.path().join("readme.md")).unwrap();
|
|
std::fs::File::create(dir.path().join("config.json")).unwrap();
|
|
std::fs::File::create(dir.path().join("tool.wasm")).unwrap();
|
|
|
|
let tools = discover_tools(dir.path()).await.unwrap();
|
|
assert_eq!(tools.len(), 1);
|
|
assert!(tools.contains_key("tool"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_load_error_display() {
|
|
let err = WasmLoadError::InvalidName("bad/name".to_string());
|
|
assert!(err.to_string().contains("bad/name"));
|
|
|
|
let err = WasmLoadError::WasmNotFound(std::path::PathBuf::from("/foo/bar.wasm"));
|
|
assert!(err.to_string().contains("/foo/bar.wasm"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_tools_src_dir_default() {
|
|
let dir = super::tools_src_dir();
|
|
assert!(dir.ends_with("tools-src"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_discover_dev_tools_finds_build_artifacts() {
|
|
// This test relies on the actual tools-src/ directory in the repo.
|
|
// If build artifacts exist, they should be discovered.
|
|
let tools = super::discover_dev_tools().await.unwrap();
|
|
|
|
// If any tools have been built, they should appear with "-tool" suffix
|
|
for (name, discovered) in &tools {
|
|
assert!(
|
|
name.ends_with("-tool"),
|
|
"Dev tool name should end with -tool: {}",
|
|
name
|
|
);
|
|
assert!(
|
|
discovered.wasm_path.exists(),
|
|
"WASM should exist: {:?}",
|
|
discovered.wasm_path
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_oauth_refresh_config_with_oauth() {
|
|
use crate::tools::wasm::capabilities_schema::{
|
|
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
|
|
};
|
|
|
|
let caps = CapabilitiesFile {
|
|
auth: Some(AuthCapabilitySchema {
|
|
secret_name: "google_oauth_token".to_string(),
|
|
provider: Some("google".to_string()),
|
|
oauth: Some(OAuthConfigSchema {
|
|
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
|
|
token_url: "https://oauth2.googleapis.com/token".to_string(),
|
|
client_id: Some(TEST_OAUTH_CLIENT_ID.to_string()),
|
|
client_secret: Some(TEST_OAUTH_CLIENT_SECRET.to_string()),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
|
|
let config = super::resolve_oauth_refresh_config(&caps);
|
|
assert!(config.is_some());
|
|
|
|
let config = config.unwrap();
|
|
assert_eq!(config.token_url, "https://oauth2.googleapis.com/token");
|
|
assert_eq!(config.client_id, TEST_OAUTH_CLIENT_ID);
|
|
assert_eq!(
|
|
config.client_secret,
|
|
Some(TEST_OAUTH_CLIENT_SECRET.to_string())
|
|
);
|
|
assert_eq!(config.secret_name, "google_oauth_token");
|
|
assert_eq!(config.provider, Some("google".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_oauth_refresh_config_no_auth() {
|
|
use crate::tools::wasm::capabilities_schema::CapabilitiesFile;
|
|
|
|
let caps = CapabilitiesFile::default();
|
|
let config = super::resolve_oauth_refresh_config(&caps);
|
|
assert!(config.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_oauth_refresh_config_no_oauth() {
|
|
use crate::tools::wasm::capabilities_schema::{AuthCapabilitySchema, CapabilitiesFile};
|
|
|
|
let caps = CapabilitiesFile {
|
|
auth: Some(AuthCapabilitySchema {
|
|
secret_name: "manual_token".to_string(),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
|
|
let config = super::resolve_oauth_refresh_config(&caps);
|
|
assert!(config.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_oauth_refresh_config_no_client_id() {
|
|
use crate::tools::wasm::capabilities_schema::{
|
|
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
|
|
};
|
|
|
|
// A non-Google provider with no client_id anywhere should return None
|
|
let caps = CapabilitiesFile {
|
|
auth: Some(AuthCapabilitySchema {
|
|
secret_name: "unknown_provider_token".to_string(),
|
|
oauth: Some(OAuthConfigSchema {
|
|
authorization_url: "https://example.com/auth".to_string(),
|
|
token_url: "https://example.com/token".to_string(),
|
|
// No client_id, no client_id_env, no builtin
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
|
|
let config = super::resolve_oauth_refresh_config(&caps);
|
|
assert!(config.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_resolve_oauth_refresh_config_builtin_google() {
|
|
use crate::tools::wasm::capabilities_schema::{
|
|
AuthCapabilitySchema, CapabilitiesFile, OAuthConfigSchema,
|
|
};
|
|
|
|
// google_oauth_token should fall back to built-in credentials
|
|
let caps = CapabilitiesFile {
|
|
auth: Some(AuthCapabilitySchema {
|
|
secret_name: "google_oauth_token".to_string(),
|
|
provider: Some("google".to_string()),
|
|
oauth: Some(OAuthConfigSchema {
|
|
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
|
|
token_url: "https://oauth2.googleapis.com/token".to_string(),
|
|
// No inline client_id, should fall back to builtin
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
}),
|
|
..Default::default()
|
|
};
|
|
|
|
let config = super::resolve_oauth_refresh_config(&caps);
|
|
assert!(config.is_some());
|
|
let config = config.unwrap();
|
|
assert!(!config.client_id.is_empty());
|
|
assert!(config.client_secret.is_some());
|
|
}
|
|
|
|
// ---------------------------------------------------------------
|
|
// Security regression tests
|
|
// ---------------------------------------------------------------
|
|
|
|
use std::sync::Arc;
|
|
|
|
use crate::tools::registry::ToolRegistry;
|
|
use crate::tools::wasm::{WasmRuntimeConfig, WasmToolRuntime};
|
|
|
|
/// Helper: create a WasmToolLoader backed by a real runtime + registry.
|
|
fn make_loader() -> super::WasmToolLoader {
|
|
let runtime = Arc::new(
|
|
WasmToolRuntime::new(WasmRuntimeConfig::for_testing())
|
|
.expect("failed to create WASM runtime for test"),
|
|
);
|
|
let registry = Arc::new(ToolRegistry::new());
|
|
super::WasmToolLoader::new(runtime, registry)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_tool_name_rejects_path_separators() {
|
|
let dir = TempDir::new().unwrap();
|
|
// Create a valid wasm file so the name check is the only failure path
|
|
let wasm_path = dir.path().join("dummy.wasm");
|
|
std::fs::File::create(&wasm_path).unwrap();
|
|
|
|
let loader = make_loader();
|
|
|
|
for bad_name in &["../evil", "foo/bar", "foo\\bar"] {
|
|
let result = loader.load_from_files(bad_name, &wasm_path, None).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"Expected error for name {:?}, got Ok",
|
|
bad_name
|
|
);
|
|
let err = result.unwrap_err();
|
|
assert!(
|
|
matches!(err, WasmLoadError::InvalidName(_)),
|
|
"Expected InvalidName for {:?}, got: {}",
|
|
bad_name,
|
|
err
|
|
);
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_tool_name_rejects_empty() {
|
|
let dir = TempDir::new().unwrap();
|
|
let wasm_path = dir.path().join("dummy.wasm");
|
|
std::fs::File::create(&wasm_path).unwrap();
|
|
|
|
let loader = make_loader();
|
|
let result = loader.load_from_files("", &wasm_path, None).await;
|
|
|
|
assert!(result.is_err(), "Expected error for empty name, got Ok");
|
|
let err = result.unwrap_err();
|
|
assert!(
|
|
matches!(err, WasmLoadError::InvalidName(_)),
|
|
"Expected InvalidName for empty string, got: {}",
|
|
err
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_load_nonexistent_wasm_file() {
|
|
let loader = make_loader();
|
|
let bogus_path = std::path::PathBuf::from("/tmp/nonexistent_tool_12345.wasm");
|
|
|
|
let result = loader.load_from_files("bogus", &bogus_path, None).await;
|
|
assert!(
|
|
result.is_err(),
|
|
"Expected error for nonexistent file, got Ok"
|
|
);
|
|
let err = result.unwrap_err();
|
|
assert!(
|
|
matches!(err, WasmLoadError::WasmNotFound(_)),
|
|
"Expected WasmNotFound, got: {}",
|
|
err
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_load_invalid_wasm_bytes() {
|
|
let dir = TempDir::new().unwrap();
|
|
let wasm_path = dir.path().join("invalid.wasm");
|
|
|
|
// Write random invalid bytes (not a valid WASM module)
|
|
let mut f = std::fs::File::create(&wasm_path).unwrap();
|
|
f.write_all(b"this is not a valid wasm module at all")
|
|
.unwrap();
|
|
|
|
let loader = make_loader();
|
|
let result = loader.load_from_files("invalid", &wasm_path, None).await;
|
|
|
|
assert!(
|
|
result.is_err(),
|
|
"Expected error for invalid WASM bytes, got Ok"
|
|
);
|
|
// The error should come from WASM compilation or registration, not name validation
|
|
let err = result.unwrap_err();
|
|
assert!(
|
|
!matches!(err, WasmLoadError::InvalidName(_)),
|
|
"Got InvalidName instead of a compilation/registration error: {}",
|
|
err
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_discover_skips_dotfiles() {
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
// Create a dotfile .wasm and a normal .wasm
|
|
std::fs::File::create(dir.path().join(".hidden.wasm")).unwrap();
|
|
std::fs::File::create(dir.path().join("visible.wasm")).unwrap();
|
|
|
|
let tools = discover_tools(dir.path()).await.unwrap();
|
|
|
|
// The current implementation discovers ALL .wasm files including dotfiles.
|
|
// This test documents the current behavior: .hidden.wasm IS discovered
|
|
// with the stem ".hidden". A future hardening pass could add dotfile
|
|
// filtering, at which point this assertion should be updated.
|
|
assert!(
|
|
tools.contains_key("visible"),
|
|
"visible.wasm should be discovered"
|
|
);
|
|
assert!(
|
|
tools.contains_key(".hidden"),
|
|
"dotfile .hidden.wasm is currently discovered (no dotfile filter yet)"
|
|
);
|
|
assert_eq!(tools.len(), 2);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_discover_tools_ignores_subdirectories() {
|
|
let dir = TempDir::new().unwrap();
|
|
|
|
// Create a top-level wasm file
|
|
std::fs::File::create(dir.path().join("top_level.wasm")).unwrap();
|
|
|
|
// Create a subdirectory with a wasm file inside
|
|
let sub_dir = dir.path().join("subdir");
|
|
std::fs::create_dir(&sub_dir).unwrap();
|
|
std::fs::File::create(sub_dir.join("nested.wasm")).unwrap();
|
|
|
|
let tools = discover_tools(dir.path()).await.unwrap();
|
|
|
|
// Only top-level files should be discovered (read_dir is not recursive)
|
|
assert_eq!(tools.len(), 1, "Only top-level .wasm files should be found");
|
|
assert!(
|
|
tools.contains_key("top_level"),
|
|
"top_level.wasm should be discovered"
|
|
);
|
|
assert!(
|
|
!tools.contains_key("nested"),
|
|
"nested.wasm inside subdir should NOT be discovered"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn load_from_dir_returns_empty_when_dir_missing() {
|
|
let loader = make_loader();
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
let missing = dir.path().join("nonexistent_tools_dir");
|
|
|
|
let results = loader.load_from_dir(&missing).await;
|
|
|
|
// Must succeed with empty results, not error
|
|
let results = results.expect("missing dir should return Ok, not Err");
|
|
assert!(results.loaded.is_empty());
|
|
assert!(results.errors.is_empty());
|
|
}
|
|
}
|