diff --git a/channels-src/telegram/Cargo.toml b/channels-src/telegram/Cargo.toml index 06cd9de5..1964e327 100644 --- a/channels-src/telegram/Cargo.toml +++ b/channels-src/telegram/Cargo.toml @@ -25,5 +25,3 @@ opt-level = "s" lto = true strip = true codegen-units = 1 - -[workspace] diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index dec0cbaa..bc0c1447 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -98,7 +98,7 @@ impl Agent { pub fn new( config: AgentConfig, deps: AgentDeps, - channels: ChannelManager, + channels: Arc, heartbeat_config: Option, hygiene_config: Option, routine_config: Option, @@ -123,7 +123,7 @@ impl Agent { Self { config, deps, - channels: Arc::new(channels), + channels, context_manager, scheduler, router: Router::new(), @@ -499,21 +499,41 @@ impl Agent { Ok(crate::hooks::HookOutcome::Continue { modified: Some(new_content), }) => { - let _ = self + if let Err(e) = self .channels .respond(&message, OutgoingResponse::text(new_content)) - .await; + .await + { + tracing::error!( + channel = %message.channel, + error = %e, + "Failed to send response to channel" + ); + } } _ => { - let _ = self + if let Err(e) = self .channels .respond(&message, OutgoingResponse::text(response)) - .await; + .await + { + tracing::error!( + channel = %message.channel, + error = %e, + "Failed to send response to channel" + ); + } } } } - Ok(Some(_)) => { + Ok(Some(empty)) => { // Empty response, nothing to send (e.g. approval handled via send_status) + tracing::debug!( + channel = %message.channel, + user = %message.user_id, + empty_len = empty.len(), + "Suppressed empty response (not sent to channel)" + ); } Ok(None) => { // Shutdown signal received (/quit, /exit, /shutdown) @@ -522,10 +542,17 @@ impl Agent { } Err(e) => { tracing::error!("Error handling message: {}", e); - let _ = self + if let Err(send_err) = self .channels .respond(&message, OutgoingResponse::text(format!("Error: {}", e))) - .await; + .await + { + tracing::error!( + channel = %message.channel, + error = %send_err, + "Failed to send error response to channel" + ); + } } } diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs index 1fecf803..7e906e12 100644 --- a/src/agent/dispatcher.rs +++ b/src/agent/dispatcher.rs @@ -891,7 +891,7 @@ mod tests { auto_approve_tools: false, }, deps, - ChannelManager::new(), + Arc::new(ChannelManager::new()), None, None, None, diff --git a/src/channels/manager.rs b/src/channels/manager.rs index 5cdc2f99..d316b90d 100644 --- a/src/channels/manager.rs +++ b/src/channels/manager.rs @@ -40,16 +40,39 @@ impl ChannelManager { } /// Add a channel to the manager. - pub fn add(&mut self, channel: Box) { + pub async fn add(&self, channel: Box) { let name = channel.name().to_string(); - // We need to get the inner HashMap to insert - // Since we're in a sync context during setup, we'll use try_write - if let Ok(mut channels) = self.channels.try_write() { - channels.insert(name.clone(), channel); - tracing::debug!("Added channel: {}", name); - } else { - tracing::error!("Failed to add channel: {} (lock contention)", name); - } + self.channels.write().await.insert(name.clone(), channel); + tracing::debug!("Added channel: {}", name); + } + + /// Hot-add a channel to a running agent. + /// + /// Starts the channel, registers it in the channels map for `respond()`/`broadcast()`, + /// and spawns a task that forwards its stream messages through `inject_tx` into + /// the agent loop. + pub async fn hot_add(&self, channel: Box) -> Result<(), ChannelError> { + let name = channel.name().to_string(); + let stream = channel.start().await?; + + // Register for respond/broadcast/send_status + self.channels.write().await.insert(name.clone(), channel); + + // Forward stream messages through inject_tx + let tx = self.inject_tx.clone(); + tokio::spawn(async move { + use futures::StreamExt; + let mut stream = stream; + while let Some(msg) = stream.next().await { + if tx.send(msg).await.is_err() { + tracing::warn!(channel = %name, "Inject channel closed, stopping hot-added channel"); + break; + } + } + tracing::info!(channel = %name, "Hot-added channel stream ended"); + }); + + Ok(()) } /// Start all channels and return a merged stream of messages. diff --git a/src/channels/wasm/bundled.rs b/src/channels/wasm/bundled.rs index 63720a38..eb3675b7 100644 --- a/src/channels/wasm/bundled.rs +++ b/src/channels/wasm/bundled.rs @@ -65,24 +65,28 @@ fn locate_channel_artifacts(name: &str) -> Result<(PathBuf, PathBuf), String> { return Ok((flat_wasm, caps_path)); } - // Fall back to build tree layout (dev builds) - let build_wasm = channel_dir - .join("target/wasm32-wasip2/release") - .join(format!("{}.wasm", crate_name)); - - if build_wasm.exists() && caps_path.exists() { + // Fall back to build tree layout (dev builds) — search across all WASM triples + if let Some(build_wasm) = + crate::registry::artifacts::find_wasm_artifact(&channel_dir, crate_name, "release") + && caps_path.exists() + { return Ok((build_wasm, caps_path)); } + // Provide a helpful error with the paths we checked + let expected_build = crate::registry::artifacts::resolve_target_dir(&channel_dir) + .join("wasm32-wasip2/release") + .join(format!("{}.wasm", crate_name)); + Err(format!( "Channel '{}' WASM not found. Checked:\n \ - {} (flat/packaged)\n \ - - {} (build tree)\n \ + - {} (build tree, and other triples)\n \ Build it first:\n \ - cd {} && cargo build --target wasm32-wasip2 --release", + cd {} && cargo component build --release", name, flat_wasm.display(), - build_wasm.display(), + expected_build.display(), channel_dir.display() )) } diff --git a/src/channels/wasm/router.rs b/src/channels/wasm/router.rs index f5ab72da..4fc20f3a 100644 --- a/src/channels/wasm/router.rs +++ b/src/channels/wasm/router.rs @@ -110,6 +110,21 @@ impl WasmChannelRouter { .unwrap_or_else(|| "X-Webhook-Secret".to_string()) } + /// Update the webhook secret for an already-registered channel. + /// + /// This is used when credentials are saved after a channel was registered + /// without a secret (e.g., loaded at startup before the user configured it). + pub async fn update_secret(&self, channel_name: &str, secret: String) { + self.secrets + .write() + .await + .insert(channel_name.to_string(), secret); + tracing::info!( + channel = %channel_name, + "Updated webhook secret for channel" + ); + } + /// Unregister a channel and its endpoints. pub async fn unregister(&self, channel_name: &str) { self.channels.write().await.remove(channel_name); diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index f546da85..91bf655f 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -780,7 +780,12 @@ impl WasmChannel { /// Execute the on_start callback. /// /// Returns the channel configuration for HTTP endpoint registration. - async fn call_on_start(&self) -> Result { + /// Call the WASM module's `on_start` callback. + /// + /// Typically called once during `start()`, but can be called again after + /// credentials are refreshed to re-trigger webhook registration and + /// other one-time setup that depends on credentials. + pub async fn call_on_start(&self) -> Result { // If no WASM bytes, return default config (for testing) if self.prepared.component().is_none() { tracing::info!( @@ -1437,6 +1442,72 @@ impl WasmChannel { StatusUpdate::StreamChunk(_) => { // No-op, too noisy } + StatusUpdate::ApprovalNeeded { + tool_name, + description, + parameters, + .. + } => { + // WASM channels (Telegram, Slack, etc.) cannot render + // interactive approval overlays. Send the approval prompt + // as an actual message so the user can reply yes/no. + self.cancel_typing_task().await; + + let params_preview = parameters + .as_object() + .map(|obj| { + obj.iter() + .map(|(k, v)| { + let val = match v { + serde_json::Value::String(s) => { + if s.chars().count() > 80 { + let truncated: String = s.chars().take(77).collect(); + format!("\"{}...\"", truncated) + } else { + format!("\"{}\"", s) + } + } + other => { + let s = other.to_string(); + if s.chars().count() > 80 { + let truncated: String = s.chars().take(77).collect(); + format!("{}...", truncated) + } else { + s + } + } + }; + format!(" {}: {}", k, val) + }) + .collect::>() + .join("\n") + }) + .unwrap_or_default(); + + let prompt = format!( + "Approval needed: {tool_name}\n\ + {description}\n\ + \n\ + Parameters:\n\ + {params_preview}\n\ + \n\ + Reply \"yes\" to approve, \"no\" to deny, or \"always\" to auto-approve." + ); + + let metadata_json = serde_json::to_string(metadata).unwrap_or_default(); + if let Err(e) = self + .call_on_respond(uuid::Uuid::new_v4(), &prompt, None, &metadata_json) + .await + { + tracing::warn!( + channel = %self.name, + error = %e, + "Failed to send approval prompt via on_respond, falling back to on_status" + ); + // Fall back to status update (typing indicator) + let _ = self.call_on_status(&status, metadata).await; + } + } _ => { // Done, Interrupted, Status, ToolStarted, ToolCompleted: cancel and fire once self.cancel_typing_task().await; diff --git a/src/channels/web/handlers/extensions.rs b/src/channels/web/handlers/extensions.rs index 7860184a..c2c87055 100644 --- a/src/channels/web/handlers/extensions.rs +++ b/src/channels/web/handlers/extensions.rs @@ -20,7 +20,7 @@ pub async fn extensions_list_handler( ))?; let installed = ext_mgr - .list(None) + .list(None, false) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index 1c1d9c21..d8f73671 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -1704,7 +1704,7 @@ async fn extensions_list_handler( ))?; let installed = ext_mgr - .list(None) + .list(None, false) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -1955,7 +1955,7 @@ async fn extensions_registry_handler( let installed: std::collections::HashSet<(String, String)> = if let Some(ext_mgr) = state.extension_manager.as_ref() { ext_mgr - .list(None) + .list(None, false) .await .unwrap_or_default() .into_iter() @@ -1998,7 +1998,7 @@ async fn extensions_setup_handler( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let kind = ext_mgr - .list(None) + .list(None, false) .await .ok() .and_then(|list| list.into_iter().find(|e| e.name == name)) diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 19739150..fe225c0a 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -1455,18 +1455,11 @@ function renderExtensionCard(ext) { actions.className = 'ext-actions'; if (!ext.active) { - if (ext.kind === 'wasm_channel') { - const restartLabel = document.createElement('span'); - restartLabel.className = 'ext-restart-label'; - restartLabel.textContent = 'Restart to activate'; - actions.appendChild(restartLabel); - } else { - const activateBtn = document.createElement('button'); - activateBtn.className = 'btn-ext activate'; - activateBtn.textContent = 'Activate'; - activateBtn.addEventListener('click', () => activateExtension(ext.name)); - actions.appendChild(activateBtn); - } + const activateBtn = document.createElement('button'); + activateBtn.className = 'btn-ext activate'; + activateBtn.textContent = 'Activate'; + activateBtn.addEventListener('click', () => activateExtension(ext.name)); + actions.appendChild(activateBtn); } else { const activeLabel = document.createElement('span'); activeLabel.className = 'ext-active-label'; @@ -1490,8 +1483,10 @@ function renderExtensionCard(ext) { card.appendChild(actions); - // For active WASM channels, check for pending pairing requests - if (ext.active && ext.kind === 'wasm_channel') { + // For WASM channels, check for pending pairing requests. + // Show even when inactive — pairing requests can arrive via webhooks + // before the channel is fully activated. + if (ext.kind === 'wasm_channel') { const pairingSection = document.createElement('div'); pairingSection.className = 'ext-pairing'; card.appendChild(pairingSection); diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index f890fdae..7c263ab2 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -1936,12 +1936,6 @@ body { font-weight: 500; } -.ext-restart-label { - font-size: 12px; - color: var(--text-secondary); - font-style: italic; -} - .btn-ext { padding: 4px 10px; border-radius: var(--radius); diff --git a/src/cli/tool.rs b/src/cli/tool.rs index 42ffa428..eb4aef04 100644 --- a/src/cli/tool.rs +++ b/src/cli/tool.rs @@ -4,7 +4,6 @@ use std::io::Write; use std::path::{Path, PathBuf}; -use std::process::Command as ProcessCommand; use std::sync::Arc; use clap::Subcommand; @@ -155,11 +154,18 @@ async fn install_tool( }; // Build the WASM component if not skipping + let profile = if release { "release" } else { "debug" }; let wasm_path = if skip_build { // Look for existing wasm file - find_wasm_artifact(&path, &tool_name, release)? + crate::registry::artifacts::find_wasm_artifact(&path, &tool_name, profile) + .or_else(|| crate::registry::artifacts::find_any_wasm_artifact(&path, profile)) + .ok_or_else(|| { + anyhow::anyhow!( + "No .wasm artifact found. Run without --skip-build to build first." + ) + })? } else { - build_wasm_component(&path, release)? + crate::registry::artifacts::build_wasm_component_sync(&path, release)? }; // Look for capabilities file @@ -253,169 +259,6 @@ async fn install_tool( Ok(()) } -/// Build a WASM component using cargo-component. -fn build_wasm_component(source_dir: &Path, release: bool) -> anyhow::Result { - println!("Building WASM component in {}...", source_dir.display()); - - // Check if cargo-component is available - let check = ProcessCommand::new("cargo") - .args(["component", "--version"]) - .output(); - - if check.is_err() || !check.unwrap().status.success() { - anyhow::bail!( - "cargo-component not found. Install with: cargo install cargo-component\n\ - Or use --skip-build with an existing .wasm file." - ); - } - - // Build command - let mut cmd = ProcessCommand::new("cargo"); - cmd.current_dir(source_dir).args(["component", "build"]); - - if release { - cmd.arg("--release"); - } - - println!( - " Running: cargo component build{}", - if release { " --release" } else { "" } - ); - - let output = cmd.output()?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - anyhow::bail!("Build failed:\n{}", stderr); - } - - // Find the output wasm file - // cargo-component may output to wasm32-wasip1 or wasm32-wasip2 depending on version - let profile = if release { "release" } else { "debug" }; - let candidates = [ - source_dir - .join("target") - .join("wasm32-wasip1") - .join(profile), - source_dir - .join("target") - .join("wasm32-wasip2") - .join(profile), - source_dir - .join("target") - .join("wasm32-unknown-unknown") - .join(profile), - ]; - - let target_dir = candidates.iter().find(|p| p.exists()).ok_or_else(|| { - anyhow::anyhow!( - "No WASM target directory found. Expected one of: {}", - candidates - .iter() - .map(|p| p.display().to_string()) - .collect::>() - .join(", ") - ) - })?; - - // Look for .wasm files in target dir - let entries: Vec<_> = std::fs::read_dir(target_dir)? - .filter_map(|e| e.ok()) - .filter(|e| { - e.path() - .extension() - .map(|ext| ext == "wasm") - .unwrap_or(false) - }) - .collect(); - - if entries.is_empty() { - anyhow::bail!( - "No .wasm file found in {}. Build may have failed.", - target_dir.display() - ); - } - - if entries.len() > 1 { - println!( - " Warning: Multiple .wasm files found, using first: {}", - entries[0].path().display() - ); - } - - let wasm_path = entries[0].path(); - println!(" Built: {}", wasm_path.display()); - - Ok(wasm_path) -} - -/// Find an existing WASM artifact without building. -fn find_wasm_artifact(source_dir: &Path, name: &str, release: bool) -> anyhow::Result { - let profile = if release { "release" } else { "debug" }; - - // cargo-component may output to wasm32-wasip1 or wasm32-wasip2 depending on version - let target_dirs = [ - source_dir - .join("target") - .join("wasm32-wasip1") - .join(profile), - source_dir - .join("target") - .join("wasm32-wasip2") - .join(profile), - source_dir - .join("target") - .join("wasm32-unknown-unknown") - .join(profile), - ]; - - let snake_name = name.replace('-', "_"); - - // Try exact name match in any target dir first - for target_dir in &target_dirs { - let candidates = [ - target_dir.join(format!("{}.wasm", name)), - target_dir.join(format!("{}.wasm", snake_name)), - ]; - for candidate in &candidates { - if candidate.exists() { - return Ok(candidate.clone()); - } - } - } - - // Find a target dir that exists - let target_dir = target_dirs.iter().find(|p| p.exists()).ok_or_else(|| { - anyhow::anyhow!("No target directory found. Run without --skip-build to build first.") - })?; - - // Fall back to any .wasm file - let entries: Vec<_> = std::fs::read_dir(target_dir) - .map_err(|_| { - anyhow::anyhow!( - "Target directory not found: {}. Run without --skip-build.", - target_dir.display() - ) - })? - .filter_map(|e| e.ok()) - .filter(|e| { - e.path() - .extension() - .map(|ext| ext == "wasm") - .unwrap_or(false) - }) - .collect(); - - if entries.is_empty() { - anyhow::bail!( - "No .wasm file found in {}. Build the project first or remove --skip-build.", - target_dir.display() - ); - } - - Ok(entries[0].path()) -} - /// Extract crate name from Cargo.toml. async fn extract_crate_name(cargo_toml: &Path) -> anyhow::Result { let content = fs::read_to_string(cargo_toml).await?; diff --git a/src/extensions/discovery.rs b/src/extensions/discovery.rs index b40815e7..51123dc3 100644 --- a/src/extensions/discovery.rs +++ b/src/extensions/discovery.rs @@ -1,4 +1,4 @@ -//! Online extension discovery for finding MCP servers not in the built-in registry. +//! Online extension discovery for finding extensions not in the built-in registry. //! //! Multi-tier search strategy: //! 1. Probe well-known URL patterns (mcp.{service}.com, {service}.com/mcp) diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index ccadb8dd..884b9706 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -1,8 +1,8 @@ //! Central extension manager that dispatches operations by ExtensionKind. //! -//! Holds references to MCP infrastructure, WASM tool runtime, secrets store, -//! and tool registry. All extension operations (search, install, auth, activate, -//! list, remove) flow through here. +//! Holds references to channel runtime, WASM tool runtime, MCP infrastructure, +//! secrets store, and tool registry. All extension operations (search, install, +//! auth, activate, list, remove) flow through here. use std::collections::{HashMap, HashSet}; use std::path::PathBuf; @@ -10,6 +10,10 @@ use std::sync::Arc; use tokio::sync::RwLock; +use crate::channels::ChannelManager; +use crate::channels::wasm::{ + RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter, WasmChannelRuntime, +}; use crate::extensions::discovery::OnlineDiscovery; use crate::extensions::registry::ExtensionRegistry; use crate::extensions::{ @@ -17,6 +21,7 @@ use crate::extensions::{ InstalledExtension, RegistryEntry, ResultSource, SearchResult, }; use crate::hooks::HookRegistry; +use crate::pairing::PairingStore; use crate::secrets::{CreateSecretParams, SecretsStore}; use crate::tools::ToolRegistry; use crate::tools::mcp::McpClient; @@ -35,6 +40,18 @@ struct PendingAuth { created_at: std::time::Instant, } +/// Runtime infrastructure needed for hot-activating WASM channels. +/// +/// Set after construction via [`ExtensionManager::set_channel_runtime`] once the +/// channel manager, WASM runtime, pairing store, and webhook router are available. +struct ChannelRuntimeState { + channel_manager: Arc, + wasm_channel_runtime: Arc, + pairing_store: Arc, + wasm_channel_router: Arc, + telegram_owner_id: Option, +} + /// Central manager for extension lifecycle operations. pub struct ExtensionManager { registry: ExtensionRegistry, @@ -50,13 +67,16 @@ pub struct ExtensionManager { wasm_tools_dir: PathBuf, wasm_channels_dir: PathBuf, + // WASM channel hot-activation infrastructure (set post-construction) + channel_runtime: RwLock>, + // Shared secrets: Arc, tool_registry: Arc, hooks: Option>, pending_auth: RwLock>, - /// Tunnel URL for remote OAuth callbacks (used in future iterations). - _tunnel_url: Option, + /// Tunnel URL for webhook configuration and remote OAuth callbacks. + tunnel_url: Option, user_id: String, /// Optional database store for DB-backed MCP config. store: Option>, @@ -92,17 +112,40 @@ impl ExtensionManager { wasm_tool_runtime, wasm_tools_dir, wasm_channels_dir, + channel_runtime: RwLock::new(None), secrets, tool_registry, hooks, pending_auth: RwLock::new(HashMap::new()), - _tunnel_url: tunnel_url, + tunnel_url, user_id, store, active_channel_names: RwLock::new(HashSet::new()), } } + /// Configure the channel runtime infrastructure for hot-activating WASM channels. + /// + /// Call after construction (and after wrapping in `Arc`) once the channel + /// manager, WASM runtime, pairing store, and webhook router are available. + /// Without this, channel activation returns an error. + pub async fn set_channel_runtime( + &self, + channel_manager: Arc, + wasm_channel_runtime: Arc, + pairing_store: Arc, + wasm_channel_router: Arc, + telegram_owner_id: Option, + ) { + *self.channel_runtime.write().await = Some(ChannelRuntimeState { + channel_manager, + wasm_channel_runtime, + pairing_store, + wasm_channel_router, + telegram_owner_id, + }); + } + /// Register channel names that were loaded at startup. /// Called after WASM channels are loaded so `list()` reports accurate active status. pub async fn set_active_channels(&self, names: Vec) { @@ -207,14 +250,18 @@ impl ExtensionManager { match kind { ExtensionKind::McpServer => self.activate_mcp(name).await, ExtensionKind::WasmTool => self.activate_wasm_tool(name).await, - ExtensionKind::WasmChannel => Err(ExtensionError::ChannelNeedsRestart), + ExtensionKind::WasmChannel => self.activate_wasm_channel(name).await, } } - /// List all installed extensions with their status. + /// List extensions with their status. + /// + /// When `include_available` is `true`, registry entries that are not yet + /// installed are appended with `installed: false`. pub async fn list( &self, kind_filter: Option, + include_available: bool, ) -> Result, ExtensionError> { let mut extensions = Vec::new(); @@ -249,6 +296,7 @@ impl ExtensionManager { active, tools, needs_setup: false, + installed: true, }); } } @@ -276,6 +324,7 @@ impl ExtensionManager { active, tools: if active { vec![name] } else { Vec::new() }, needs_setup: false, + installed: true, }); } } @@ -305,6 +354,7 @@ impl ExtensionManager { active, tools: Vec::new(), needs_setup, + installed: true, }); } } @@ -314,6 +364,36 @@ impl ExtensionManager { } } + // Append available-but-not-installed registry entries + if include_available { + let installed_names: std::collections::HashSet<(String, ExtensionKind)> = extensions + .iter() + .map(|e| (e.name.clone(), e.kind)) + .collect(); + + for entry in self.registry.all_entries().await { + if let Some(filter) = kind_filter + && entry.kind != filter + { + continue; + } + if installed_names.contains(&(entry.name.clone(), entry.kind)) { + continue; + } + extensions.push(InstalledExtension { + name: entry.name, + kind: entry.kind, + description: Some(entry.description), + url: None, + authenticated: false, + active: false, + tools: Vec::new(), + needs_setup: false, + installed: false, + }); + } + } + Ok(extensions) } @@ -491,12 +571,19 @@ impl ExtensionManager { ) .await } - ExtensionSource::WasmBuildable { .. } => { - Err(ExtensionError::InstallFailed(format!( - "'{}' requires building from source. Run `ironclaw registry install {}` \ - from the CLI (requires cargo-component).", - entry.name, entry.name - ))) + ExtensionSource::WasmBuildable { + build_dir, + crate_name, + .. + } => { + self.install_wasm_from_buildable( + &entry.name, + build_dir.as_deref(), + crate_name.as_deref(), + &self.wasm_tools_dir, + ExtensionKind::WasmTool, + ) + .await } _ => Err(ExtensionError::InstallFailed( "WASM tool entry has no download URL".to_string(), @@ -514,12 +601,19 @@ impl ExtensionManager { ) .await } - ExtensionSource::WasmBuildable { .. } => { - Err(ExtensionError::InstallFailed(format!( - "'{}' requires building from source. Run `ironclaw registry install {}` \ - from the CLI (requires cargo-component).", - entry.name, entry.name - ))) + ExtensionSource::WasmBuildable { + build_dir, + crate_name, + .. + } => { + self.install_wasm_from_buildable( + &entry.name, + build_dir.as_deref(), + crate_name.as_deref(), + &self.wasm_channels_dir, + ExtensionKind::WasmChannel, + ) + .await } _ => Err(ExtensionError::InstallFailed( "WASM channel entry has no download URL".to_string(), @@ -597,9 +691,8 @@ impl ExtensionManager { name: name.to_string(), kind: ExtensionKind::WasmChannel, message: format!( - "WASM channel '{}' installed to {}. Restart to activate.", + "WASM channel '{}' installed. Run activate to start it.", name, - self.wasm_channels_dir.display() ), }) } @@ -826,6 +919,115 @@ impl ExtensionManager { Ok(()) } + #[allow(dead_code)] // Used by upcoming hot-activation flow + async fn install_bundled_channel_from_artifacts( + &self, + name: &str, + ) -> Result { + // Check if already installed + let channel_wasm = self.wasm_channels_dir.join(format!("{}.wasm", name)); + if channel_wasm.exists() { + return Err(ExtensionError::AlreadyInstalled(name.to_string())); + } + + crate::channels::wasm::install_bundled_channel(name, &self.wasm_channels_dir, false) + .await + .map_err(ExtensionError::InstallFailed)?; + + tracing::info!( + "Installed bundled channel '{}' to {}", + name, + self.wasm_channels_dir.display() + ); + + Ok(InstallResult { + name: name.to_string(), + kind: ExtensionKind::WasmChannel, + message: format!( + "Channel '{}' installed. \ + Run tool_auth('{}') to configure authentication, then activate.", + name, name, + ), + }) + } + + /// Install a WASM extension from local build artifacts (WasmBuildable source). + /// + /// Resolves the build directory (relative to `CARGO_MANIFEST_DIR` or absolute), + /// looks for the compiled WASM artifact, and copies it (plus capabilities.json) + /// to the install directory. Falls back to an error if artifacts don't exist. + async fn install_wasm_from_buildable( + &self, + name: &str, + build_dir: Option<&str>, + crate_name: Option<&str>, + target_dir: &std::path::Path, + kind: ExtensionKind, + ) -> Result { + let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")); + + // Resolve build directory + let resolved_dir = match build_dir { + Some(dir) => { + let p = std::path::Path::new(dir); + if p.is_absolute() { + p.to_path_buf() + } else { + manifest_dir.join(dir) + } + } + None => manifest_dir.to_path_buf(), + }; + + // Determine the binary name to look for + let binary_name = crate_name.unwrap_or(name); + + let wasm_src = + crate::registry::artifacts::find_wasm_artifact(&resolved_dir, binary_name, "release") + .ok_or_else(|| { + ExtensionError::InstallFailed(format!( + "'{}' requires building from source. Build artifact not found. \ + Run `cargo component build --release` in {} first, \ + or use `ironclaw registry install {}`.", + name, + resolved_dir.display(), + name, + )) + })?; + + let wasm_dst = crate::registry::artifacts::install_wasm_files( + &wasm_src, + &resolved_dir, + name, + target_dir, + true, + ) + .await + .map_err(|e| ExtensionError::InstallFailed(e.to_string()))?; + + let kind_label = match kind { + ExtensionKind::WasmTool => "WASM tool", + ExtensionKind::WasmChannel => "WASM channel", + ExtensionKind::McpServer => "MCP server", + }; + + tracing::info!( + "Installed {} '{}' from build artifacts at {}", + kind_label, + name, + wasm_dst.display(), + ); + + Ok(InstallResult { + name: name.to_string(), + kind, + message: format!( + "{} '{}' installed from local build artifacts. Run activate to load it.", + kind_label, name, + ), + }) + } + async fn auth_mcp( &self, name: &str, @@ -1449,6 +1651,332 @@ impl ExtensionManager { }) } + /// Activate a WASM channel at runtime without restarting. + /// + /// Loads the channel from its WASM file, injects credentials and config, + /// registers it with the webhook router, and hot-adds it to the channel manager + /// so its stream feeds into the agent loop. + async fn activate_wasm_channel(&self, name: &str) -> Result { + // If already active, re-inject credentials and refresh webhook secret. + // Handles the case where a channel was loaded at startup before the + // user saved secrets via the web UI. + { + let active = self.active_channel_names.read().await; + if active.contains(name) { + return self.refresh_active_channel(name).await; + } + } + + // Verify runtime infrastructure is available and clone Arcs so we don't + // hold the RwLock guard across awaits. + let ( + channel_runtime, + channel_manager, + pairing_store, + wasm_channel_router, + telegram_owner_id, + ) = { + let rt_guard = self.channel_runtime.read().await; + let rt = rt_guard.as_ref().ok_or_else(|| { + ExtensionError::ActivationFailed( + "WASM channel runtime not configured. Restart IronClaw to activate." + .to_string(), + ) + })?; + ( + Arc::clone(&rt.wasm_channel_runtime), + Arc::clone(&rt.channel_manager), + Arc::clone(&rt.pairing_store), + Arc::clone(&rt.wasm_channel_router), + rt.telegram_owner_id, + ) + }; + + // Check auth status first + let (authenticated, _needs_setup) = self.check_channel_auth_status(name).await; + if !authenticated { + return Err(ExtensionError::ActivationFailed(format!( + "Channel '{}' requires configuration. Use the setup form to provide credentials.", + name + ))); + } + + // Validate name to prevent path traversal + if name.contains('/') || name.contains('\\') || name.contains("..") || name.contains('\0') { + return Err(ExtensionError::ActivationFailed(format!( + "Invalid channel name '{}': contains path separator or traversal characters", + name + ))); + } + + // Load the channel from files + let wasm_path = self.wasm_channels_dir.join(format!("{}.wasm", name)); + let cap_path = self + .wasm_channels_dir + .join(format!("{}.capabilities.json", name)); + let cap_path_option = if cap_path.exists() { + Some(cap_path.as_path()) + } else { + None + }; + + let loader = + WasmChannelLoader::new(Arc::clone(&channel_runtime), Arc::clone(&pairing_store)); + let loaded = loader + .load_from_files(name, &wasm_path, cap_path_option) + .await + .map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?; + + let channel_name = loaded.name().to_string(); + let webhook_secret_name = loaded.webhook_secret_name(); + let secret_header = loaded.webhook_secret_header().map(|s| s.to_string()); + + // Get webhook secret from secrets store + let webhook_secret = self + .secrets + .get_decrypted(&self.user_id, &webhook_secret_name) + .await + .ok() + .map(|s| s.expose().to_string()); + + let channel_arc = Arc::new(loaded.channel); + + // Inject runtime config (tunnel_url, webhook_secret, owner_id) + { + let mut config_updates = std::collections::HashMap::new(); + + if let Some(ref tunnel_url) = self.tunnel_url { + config_updates.insert( + "tunnel_url".to_string(), + serde_json::Value::String(tunnel_url.clone()), + ); + } + + if let Some(ref secret) = webhook_secret { + config_updates.insert( + "webhook_secret".to_string(), + serde_json::Value::String(secret.clone()), + ); + } + + if channel_name == "telegram" + && let Some(owner_id) = telegram_owner_id + { + config_updates.insert("owner_id".to_string(), serde_json::json!(owner_id)); + } + + if !config_updates.is_empty() { + channel_arc.update_config(config_updates).await; + tracing::info!( + channel = %channel_name, + has_tunnel = self.tunnel_url.is_some(), + has_webhook_secret = webhook_secret.is_some(), + "Injected runtime config into hot-activated channel" + ); + } + } + + // Register with webhook router + { + let webhook_path = format!("/webhook/{}", channel_name); + let endpoints = vec![RegisteredEndpoint { + channel_name: channel_name.clone(), + path: webhook_path, + methods: vec!["POST".to_string()], + require_secret: webhook_secret.is_some(), + }]; + + wasm_channel_router + .register( + Arc::clone(&channel_arc), + endpoints, + webhook_secret, + secret_header, + ) + .await; + tracing::info!(channel = %channel_name, "Registered hot-activated channel with webhook router"); + } + + // Inject credentials + match crate::extensions::manager::inject_channel_credentials_from_secrets( + &channel_arc, + self.secrets.as_ref(), + &channel_name, + &self.user_id, + ) + .await + { + Ok(count) => { + if count > 0 { + tracing::info!( + channel = %channel_name, + credentials_injected = count, + "Credentials injected into hot-activated channel" + ); + } + } + Err(e) => { + tracing::error!( + channel = %channel_name, + error = %e, + "Failed to inject credentials into hot-activated channel" + ); + } + } + + // Hot-add the channel to the running agent + channel_manager + .hot_add(Box::new(SharedWasmChannel::new(channel_arc))) + .await + .map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?; + + // Mark as active + self.active_channel_names + .write() + .await + .insert(channel_name.clone()); + + tracing::info!(channel = %channel_name, "Hot-activated WASM channel"); + + Ok(ActivateResult { + name: channel_name, + kind: ExtensionKind::WasmChannel, + tools_loaded: Vec::new(), + message: format!("Channel '{}' activated and running", name), + }) + } + + /// Refresh credentials and webhook secret on an already-active channel. + /// + /// Called when the user saves new secrets via the setup form for a channel + /// that was loaded at startup (possibly without credentials). + async fn refresh_active_channel(&self, name: &str) -> Result { + let router = { + let rt_guard = self.channel_runtime.read().await; + match rt_guard.as_ref() { + Some(rt) => Arc::clone(&rt.wasm_channel_router), + None => { + return Ok(ActivateResult { + name: name.to_string(), + kind: ExtensionKind::WasmChannel, + tools_loaded: Vec::new(), + message: format!("Channel '{}' is already active", name), + }); + } + } + }; + + let webhook_path = format!("/webhook/{}", name); + let existing_channel = match router.get_channel_for_path(&webhook_path).await { + Some(ch) => ch, + None => { + return Ok(ActivateResult { + name: name.to_string(), + kind: ExtensionKind::WasmChannel, + tools_loaded: Vec::new(), + message: format!("Channel '{}' is already active", name), + }); + } + }; + + // Re-inject credentials from secrets store into the running channel + let cred_count = match inject_channel_credentials_from_secrets( + &existing_channel, + self.secrets.as_ref(), + name, + &self.user_id, + ) + .await + { + Ok(count) => count, + Err(e) => { + tracing::warn!( + channel = %name, + error = %e, + "Failed to refresh credentials on already-active channel" + ); + 0 + } + }; + + // Also refresh the webhook secret in the router + // Load capabilities file to get the correct secret name (may be overridden) + let webhook_secret_name = { + let cap_path = self + .wasm_channels_dir + .join(format!("{}.capabilities.json", name)); + match tokio::fs::read(&cap_path).await { + Ok(bytes) => crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&bytes) + .map(|f| f.webhook_secret_name()) + .unwrap_or_else(|_| format!("{}_webhook_secret", name)), + Err(_) => format!("{}_webhook_secret", name), + } + }; + if let Ok(secret) = self + .secrets + .get_decrypted(&self.user_id, &webhook_secret_name) + .await + { + router + .update_secret(name, secret.expose().to_string()) + .await; + + // Also inject the webhook_secret into the channel's runtime config + let mut config_updates = std::collections::HashMap::new(); + config_updates.insert( + "webhook_secret".to_string(), + serde_json::Value::String(secret.expose().to_string()), + ); + existing_channel.update_config(config_updates).await; + } + + // Refresh tunnel_url in case it wasn't set at startup + if let Some(ref tunnel_url) = self.tunnel_url { + let mut config_updates = std::collections::HashMap::new(); + config_updates.insert( + "tunnel_url".to_string(), + serde_json::Value::String(tunnel_url.clone()), + ); + existing_channel.update_config(config_updates).await; + } + + // Re-call on_start() to trigger webhook registration with the + // now-available credentials (e.g., setWebhook for Telegram). + if cred_count > 0 { + match existing_channel.call_on_start().await { + Ok(_config) => { + tracing::info!( + channel = %name, + "Re-ran on_start after credential refresh (webhook re-registered)" + ); + } + Err(e) => { + tracing::warn!( + channel = %name, + error = %e, + "on_start failed after credential refresh" + ); + } + } + } + + tracing::info!( + channel = %name, + credentials_refreshed = cred_count, + "Refreshed credentials and config on already-active channel" + ); + + Ok(ActivateResult { + name: name.to_string(), + kind: ExtensionKind::WasmChannel, + tools_loaded: Vec::new(), + message: format!( + "Channel '{}' is already active; refreshed {} credential(s)", + name, cred_count + ), + }) + } + /// Determine what kind of installed extension this is. async fn determine_installed_kind(&self, name: &str) -> Result { // Check MCP servers first @@ -1607,10 +2135,25 @@ impl ExtensionManager { } } - Ok(format!( - "Configuration saved for '{}'. Restart IronClaw for changes to take effect.", - name - )) + // Try to hot-activate the channel now that secrets are saved + match self.activate_wasm_channel(name).await { + Ok(result) => Ok(format!( + "Configuration saved and channel '{}' activated. {}", + name, result.message + )), + Err(e) => { + tracing::warn!( + channel = name, + error = %e, + "Saved configuration but hot-activation failed, restart may be needed" + ); + Ok(format!( + "Configuration saved for '{}'. \ + Automatic activation failed ({}), restart IronClaw to activate.", + name, e + )) + } + } } async fn unregister_hook_prefix(&self, prefix: &str) -> usize { @@ -1629,6 +2172,53 @@ impl ExtensionManager { } } +/// Inject credentials for a channel based on naming convention. +/// +/// Looks for secrets matching the pattern `{channel_name}_*` and injects them +/// as credential placeholders (e.g., `telegram_bot_token` -> `{TELEGRAM_BOT_TOKEN}`). +/// +/// Returns the number of credentials injected. +async fn inject_channel_credentials_from_secrets( + channel: &Arc, + secrets: &dyn SecretsStore, + channel_name: &str, + user_id: &str, +) -> Result { + let all_secrets = secrets + .list(user_id) + .await + .map_err(|e| format!("Failed to list secrets: {}", e))?; + + let prefix = format!("{}_", channel_name); + let mut count = 0; + + for secret_meta in all_secrets { + if !secret_meta.name.starts_with(&prefix) { + continue; + } + + let decrypted = match secrets.get_decrypted(user_id, &secret_meta.name).await { + Ok(d) => d, + Err(e) => { + tracing::warn!( + secret = %secret_meta.name, + error = %e, + "Failed to decrypt secret for channel credential injection" + ); + continue; + } + }; + + let placeholder = secret_meta.name.to_uppercase(); + channel + .set_credential(&placeholder, decrypted.expose().to_string()) + .await; + count += 1; + } + + Ok(count) +} + /// Infer the extension kind from a URL. fn infer_kind_from_url(url: &str) -> ExtensionKind { if url.ends_with(".wasm") || url.ends_with(".tar.gz") { diff --git a/src/extensions/mod.rs b/src/extensions/mod.rs index 4098f68d..d9b36291 100644 --- a/src/extensions/mod.rs +++ b/src/extensions/mod.rs @@ -1,16 +1,19 @@ -//! Unified extension system for discovering, installing, authenticating, and activating -//! MCP servers and WASM tools through conversational agent interactions. +//! Lifecycle management for extensions: discovery, installation, authentication, +//! and activation of channels, tools, and MCP servers. //! -//! Extensions are the user-facing abstraction over MCP servers and WASM tools. The agent -//! can search a built-in registry (or discover online), install, authenticate, and activate -//! extensions at runtime without CLI commands. +//! Extensions are the user-facing abstraction that unifies three runtime kinds: +//! - **Channels** (Telegram, Slack, Discord) — messaging integrations (WASM) +//! - **Tools** — sandboxed capabilities (WASM) +//! - **MCP servers** — external API integrations via Model Context Protocol +//! +//! The agent can search a built-in registry (or discover online), install, +//! authenticate, and activate extensions at runtime without CLI commands. //! //! ```text -//! User: "add notion" -//! -> tool_search("notion") -> finds MCP server in registry -//! -> tool_install("notion") -> saves config to mcp-servers.json -//! -> tool_auth("notion") -> OAuth 2.1 flow, returns URL -//! -> tool_activate("notion") -> connects, registers tools +//! User: "add telegram" +//! -> tool_search("telegram") -> finds channel in registry +//! -> tool_install("telegram") -> copies bundled WASM to channels dir +//! -> tool_activate("telegram") -> configures credentials, starts channel //! ``` pub mod discovery; @@ -31,7 +34,7 @@ pub enum ExtensionKind { McpServer, /// Sandboxed WASM module, file-based, capabilities auth. WasmTool, - /// WASM channel module (future: dynamic activation, currently needs restart). + /// WASM channel module with hot-activation support. WasmChannel, } @@ -82,6 +85,9 @@ pub enum ExtensionSource { repo_url: String, #[serde(default)] build_dir: Option, + /// Crate name used to locate the build artifact binary. + #[serde(default)] + crate_name: Option, }, /// Discovered online (not yet validated for a specific source type). Discovered { url: String }, @@ -169,6 +175,10 @@ pub struct ActivateResult { pub message: String, } +fn default_true() -> bool { + true +} + /// An installed extension with its current status. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct InstalledExtension { @@ -187,6 +197,9 @@ pub struct InstalledExtension { /// Whether this extension has a setup schema (required_secrets) that can be configured. #[serde(default)] pub needs_setup: bool, + /// Whether this extension is installed locally (false = available in registry but not installed). + #[serde(default = "default_true")] + pub installed: bool, } /// Error type for extension operations. @@ -222,9 +235,6 @@ pub enum ExtensionError { #[error("Config error: {0}")] Config(String), - #[error("Channels require restart to activate")] - ChannelNeedsRestart, - #[error("{0}")] Other(String), } diff --git a/src/extensions/registry.rs b/src/extensions/registry.rs index 114d02a4..5f925427 100644 --- a/src/extensions/registry.rs +++ b/src/extensions/registry.rs @@ -1,7 +1,7 @@ //! Curated in-memory catalog of known extensions with fuzzy search. //! -//! The registry holds well-known MCP servers and WASM tools that can be installed -//! via conversational commands. Online discoveries are cached here too. +//! The registry holds well-known channels, tools, and MCP servers that can be +//! installed via conversational commands. Online discoveries are cached here too. use tokio::sync::RwLock; @@ -116,6 +116,21 @@ impl ExtensionRegistry { cache.iter().find(|e| e.name == name).cloned() } + /// Return all registry entries (builtins + cached discoveries). + pub async fn all_entries(&self) -> Vec { + let mut entries = self.entries.clone(); + let cache = self.discovery_cache.read().await; + for entry in cache.iter() { + if !entries + .iter() + .any(|e| e.name == entry.name && e.kind == entry.kind) + { + entries.push(entry.clone()); + } + } + entries + } + /// Add discovered entries to the cache. pub async fn cache_discovered(&self, entries: Vec) { let mut cache = self.discovery_cache.write().await; @@ -578,6 +593,7 @@ mod tests { source: ExtensionSource::WasmBuildable { repo_url: "channels-src/telegram".to_string(), build_dir: Some("channels-src/telegram".to_string()), + crate_name: Some("telegram-channel".to_string()), }, auth_hint: AuthHint::CapabilitiesAuth, }, @@ -591,6 +607,7 @@ mod tests { source: ExtensionSource::WasmBuildable { repo_url: "tools-src/slack".to_string(), build_dir: Some("tools-src/slack".to_string()), + crate_name: Some("slack-tool".to_string()), }, auth_hint: AuthHint::CapabilitiesAuth, }, diff --git a/src/llm/nearai_chat.rs b/src/llm/nearai_chat.rs index cc4d13fc..cac8d4a8 100644 --- a/src/llm/nearai_chat.rs +++ b/src/llm/nearai_chat.rs @@ -422,7 +422,13 @@ impl LlmProvider for NearAiChatProvider { reason: "No choices in response".to_string(), })?; - let content = choice.message.content.unwrap_or_default(); + // Fall back to reasoning_content when content is null (same as + // complete_with_tools — reasoning models may put the answer there). + let content = choice + .message + .content + .or(choice.message.reasoning_content) + .unwrap_or_default(); let finish_reason = match choice.finish_reason.as_deref() { Some("stop") => FinishReason::Stop, Some("length") => FinishReason::Length, @@ -493,7 +499,9 @@ impl LlmProvider for NearAiChatProvider { reason: "No choices in response".to_string(), })?; - let content = choice.message.content; + // Fall back to reasoning_content when content is null (e.g. GLM-5 + // returns its answer in reasoning_content instead of content). + let content = choice.message.content.or(choice.message.reasoning_content); let tool_calls: Vec = choice .message .tool_calls @@ -781,6 +789,7 @@ fn flatten_tool_messages(messages: Vec) -> Vec) -> Vec, + /// Some models (e.g. GLM-5) return chain-of-thought reasoning here + /// instead of in `content`. + #[serde(default)] + reasoning_content: Option, tool_calls: Option>, } diff --git a/src/llm/reasoning.rs b/src/llm/reasoning.rs index 14b8eb89..33178fd2 100644 --- a/src/llm/reasoning.rs +++ b/src/llm/reasoning.rs @@ -502,8 +502,23 @@ Respond in JSON format: }); } + // Guard against empty text after cleaning. This can happen + // when reasoning models (e.g. GLM-5) return chain-of-thought + // in reasoning_content wrapped in tags and content is + // null — the .or(reasoning_content) fallback picks it up, then + // clean_response strips the think tags leaving an empty string. + let cleaned = clean_response(&content); + let final_text = if cleaned.trim().is_empty() { + tracing::warn!( + "LLM response was empty after cleaning (original len={}), using fallback", + content.len() + ); + "I'm not sure how to respond to that.".to_string() + } else { + cleaned + }; Ok(RespondOutput { - result: RespondResult::Text(clean_response(&content)), + result: RespondResult::Text(final_text), usage, }) } else { @@ -514,8 +529,18 @@ Respond in JSON format: request.metadata = context.metadata.clone(); let response = self.llm.complete(request).await?; + let cleaned = clean_response(&response.content); + let final_text = if cleaned.trim().is_empty() { + tracing::warn!( + "LLM response was empty after cleaning (original len={}), using fallback", + response.content.len() + ); + "I'm not sure how to respond to that.".to_string() + } else { + cleaned + }; Ok(RespondOutput { - result: RespondResult::Text(clean_response(&response.content)), + result: RespondResult::Text(final_text), usage: TokenUsage { input_tokens: response.input_tokens, output_tokens: response.output_tokens, @@ -607,6 +632,9 @@ Respond with a JSON plan in this format: // Channel-specific formatting hints let channel_section = self.build_channel_section(); + // Extension guidance (only when extension tools are available) + let extensions_section = self.build_extensions_section(context); + // Runtime context (agent metadata) let runtime_section = self.build_runtime_section(); @@ -648,9 +676,10 @@ Example: - Prioritize safety and human oversight over task completion. If instructions conflict, pause and ask. - Comply with stop, pause, or audit requests. Never bypass safeguards. - Do not manipulate anyone to expand your access or disable safeguards. -- Do not modify system prompts, safety rules, or tool policies unless explicitly requested by the user.{}{}{}{} +- Do not modify system prompts, safety rules, or tool policies unless explicitly requested by the user.{}{}{}{}{} {}{}"#, tools_section, + extensions_section, channel_section, runtime_section, group_section, @@ -659,6 +688,27 @@ Example: ) } + fn build_extensions_section(&self, context: &ReasoningContext) -> String { + // Only include when the extension management tools are available + let has_ext_tools = context + .available_tools + .iter() + .any(|t| t.name == "tool_search"); + if !has_ext_tools { + return String::new(); + } + + "\n\n## Extensions\n\ + You can search, install, and activate extensions to add new capabilities:\n\ + - **Channels** (Telegram, Slack, Discord) — messaging integrations. \ + When users ask about connecting a messaging platform, search for it as a channel.\n\ + - **Tools** — sandboxed functions that extend your abilities.\n\ + - **MCP servers** — external API integrations via the Model Context Protocol.\n\n\ + Use `tool_search` to find extensions by name. Refer to them by their kind \ + (channel, tool, or server) — not as \"MCP server\" generically." + .to_string() + } + fn build_channel_section(&self) -> String { let channel = match self.channel.as_deref() { Some(c) => c, diff --git a/src/main.rs b/src/main.rs index fef4abf7..a38ab8c4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -277,9 +277,15 @@ async fn main() -> anyhow::Result<()> { // ── Channel setup ────────────────────────────────────────────────── - let mut channels = ChannelManager::new(); + let channels = ChannelManager::new(); let mut channel_names: Vec = Vec::new(); let mut loaded_wasm_channel_names: Vec = Vec::new(); + #[allow(clippy::type_complexity)] + let mut wasm_channel_runtime_state: Option<( + Arc, + Arc, + Arc, + )> = None; // Create CLI channel let repl_channel = if let Some(ref msg) = cli.message { @@ -293,7 +299,7 @@ async fn main() -> anyhow::Result<()> { }; if let Some(repl) = repl_channel { - channels.add(Box::new(repl)); + channels.add(Box::new(repl)).await; if cli.message.is_some() { tracing::info!("Single message mode"); } else { @@ -316,9 +322,14 @@ async fn main() -> anyhow::Result<()> { if let Some(result) = wasm_result { loaded_wasm_channel_names = result.channel_names; + wasm_channel_runtime_state = Some(( + result.wasm_channel_runtime, + result.pairing_store, + result.wasm_channel_router, + )); for (name, channel) in result.channels { channel_names.push(name); - channels.add(channel); + channels.add(channel).await; } if let Some(routes) = result.webhook_routes { webhook_routes.push(routes); @@ -340,7 +351,7 @@ async fn main() -> anyhow::Result<()> { .expect("HttpConfig host:port must be a valid SocketAddr"), ); channel_names.push("http".to_string()); - channels.add(Box::new(http_channel)); + channels.add(Box::new(http_channel)).await; tracing::info!( "HTTP channel enabled on {}:{}", http_config.host, @@ -466,7 +477,7 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Web UI: http://{}:{}/", gw_config.host, gw_config.port); channel_names.push("gateway".to_string()); - channels.add(Box::new(gw)); + channels.add(Box::new(gw)).await; } // ── Boot screen ──────────────────────────────────────────────────── @@ -516,6 +527,24 @@ async fn main() -> anyhow::Result<()> { // ── Run the agent ────────────────────────────────────────────────── + let channels = Arc::new(channels); + + // Wire up channel runtime for hot-activation of WASM channels. + if let Some(ref ext_mgr) = components.extension_manager + && let Some((rt, ps, router)) = wasm_channel_runtime_state.take() + { + ext_mgr + .set_channel_runtime( + Arc::clone(&channels), + rt, + ps, + router, + config.channels.telegram_owner_id, + ) + .await; + tracing::info!("Channel runtime wired into extension manager for hot-activation"); + } + let deps = AgentDeps { store: components.db, llm: components.llm, @@ -529,6 +558,7 @@ async fn main() -> anyhow::Result<()> { hooks: components.hooks, cost_guard: components.cost_guard, }; + let agent = Agent::new( config.agent.clone(), deps, @@ -733,6 +763,10 @@ struct WasmChannelSetup { channels: Vec<(String, Box)>, channel_names: Vec, webhook_routes: Option, + /// Runtime objects needed for hot-activation via ExtensionManager. + wasm_channel_runtime: Arc, + pairing_store: Arc, + wasm_channel_router: Arc, } /// Load WASM channels and register their webhook routes. @@ -750,7 +784,7 @@ async fn setup_wasm_channels( }; let pairing_store = Arc::new(PairingStore::new()); - let loader = WasmChannelLoader::new(Arc::clone(&runtime), pairing_store); + let loader = WasmChannelLoader::new(Arc::clone(&runtime), Arc::clone(&pairing_store)); let results = match loader .load_from_dir(&config.channels.wasm_channels_dir) @@ -879,7 +913,7 @@ async fn setup_wasm_channels( let webhook_routes = if has_webhook_channels { Some(create_wasm_channel_router( - wasm_router, + Arc::clone(&wasm_router), extension_manager.map(Arc::clone), )) } else { @@ -890,6 +924,9 @@ async fn setup_wasm_channels( channels, channel_names, webhook_routes, + wasm_channel_runtime: runtime, + pairing_store, + wasm_channel_router: wasm_router, }) } diff --git a/src/registry/artifacts.rs b/src/registry/artifacts.rs new file mode 100644 index 00000000..7d2a511c --- /dev/null +++ b/src/registry/artifacts.rs @@ -0,0 +1,377 @@ +//! Unified WASM artifact resolution: find, build, and install WASM components. +//! +//! This module consolidates all WASM artifact logic that was previously duplicated +//! across `cli/tool.rs`, `registry/installer.rs`, `extensions/manager.rs`, +//! `channels/wasm/bundled.rs`, and `tools/wasm/loader.rs`. +//! +//! # Functions +//! +//! - [`resolve_target_dir`] — resolve the cargo target directory for a crate +//! - [`find_wasm_artifact`] — find a compiled `.wasm` by crate name across all triples +//! - [`find_any_wasm_artifact`] — find any `.wasm` file (fallback when name is unknown) +//! - [`build_wasm_component`] — async build via `cargo component build` +//! - [`build_wasm_component_sync`] — sync build for CLI use +//! - [`install_wasm_files`] — copy `.wasm` + optional `.capabilities.json` to install dir + +use std::path::{Path, PathBuf}; + +use tokio::fs; + +/// WASM target triples to search, in priority order. +const WASM_TRIPLES: &[&str] = &[ + "wasm32-wasip1", + "wasm32-wasip2", + "wasm32-wasi", + "wasm32-unknown-unknown", +]; + +/// Resolve the cargo target directory for a crate. +/// +/// Checks (in order): +/// 1. `CARGO_TARGET_DIR` env var (shared target dir) +/// 2. `/target/` (default per-crate layout) +pub fn resolve_target_dir(crate_dir: &Path) -> PathBuf { + if let Ok(dir) = std::env::var("CARGO_TARGET_DIR") { + let p = PathBuf::from(dir); + // Resolve relative CARGO_TARGET_DIR against crate_dir + if p.is_relative() { + return crate_dir.join(p); + } + return p; + } + crate_dir.join("target") +} + +/// Find a compiled WASM artifact by searching across all target triples. +/// +/// Tries exact name match first (with hyphen-to-underscore normalization), +/// then falls back to searching in whichever target directory exists. +/// `profile` is `"release"` or `"debug"`. +pub fn find_wasm_artifact(crate_dir: &Path, crate_name: &str, profile: &str) -> Option { + let target_base = resolve_target_dir(crate_dir); + let snake_name = crate_name.replace('-', "_"); + + // Try exact name match in each target triple directory + for triple in WASM_TRIPLES { + let dir = target_base.join(triple).join(profile); + let candidates = [ + dir.join(format!("{}.wasm", crate_name)), + dir.join(format!("{}.wasm", snake_name)), + ]; + for candidate in &candidates { + if candidate.exists() { + return Some(candidate.clone()); + } + } + } + + None +} + +/// Find any `.wasm` file in the target dirs (fallback when crate name is unknown). +/// +/// Returns the first `.wasm` found across target triples. +pub fn find_any_wasm_artifact(crate_dir: &Path, profile: &str) -> Option { + let target_base = resolve_target_dir(crate_dir); + + for triple in WASM_TRIPLES { + let dir = target_base.join(triple).join(profile); + if !dir.is_dir() { + continue; + } + if let Ok(entries) = std::fs::read_dir(&dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().map(|ext| ext == "wasm").unwrap_or(false) { + return Some(path); + } + } + } + } + + None +} + +/// Build a WASM component using `cargo-component` (async). +/// +/// Streams build output to the terminal. Returns the path to the built artifact. +pub async fn build_wasm_component( + source_dir: &Path, + crate_name: &str, + release: bool, +) -> anyhow::Result { + use tokio::process::Command; + + // Check cargo-component availability + let check = Command::new("cargo") + .args(["component", "--version"]) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .await; + + if check.is_err() || !check.as_ref().map(|s| s.success()).unwrap_or(false) { + anyhow::bail!("cargo-component not found. Install with: cargo install cargo-component"); + } + + let mut cmd = Command::new("cargo"); + cmd.current_dir(source_dir).args(["component", "build"]); + + if release { + cmd.arg("--release"); + } + + // Use status() with inherited stdio so build output streams to the terminal. + let status = cmd.status().await?; + + if !status.success() { + anyhow::bail!("Build failed (exit code: {})", status); + } + + let profile = if release { "release" } else { "debug" }; + let wasm_filename = format!("{}.wasm", crate_name.replace('-', "_")); + + // Look for the specific crate's WASM file across target triples + find_wasm_artifact(source_dir, wasm_filename.trim_end_matches(".wasm"), profile) + .or_else(|| { + // Fall back: search by crate_name directly + find_wasm_artifact(source_dir, crate_name, profile) + }) + .or_else(|| find_any_wasm_artifact(source_dir, profile)) + .ok_or_else(|| { + anyhow::anyhow!( + "Could not find {} in {}/target/*/{}/ after build", + wasm_filename, + source_dir.display(), + profile, + ) + }) +} + +/// Build a WASM component using `cargo-component` (sync, for CLI use). +/// +/// Returns the path to the built artifact. +pub fn build_wasm_component_sync(source_dir: &Path, release: bool) -> anyhow::Result { + use std::process::Command; + + println!("Building WASM component in {}...", source_dir.display()); + + // Check if cargo-component is available + let check = Command::new("cargo") + .args(["component", "--version"]) + .output(); + + if check.is_err() || !check.as_ref().map(|o| o.status.success()).unwrap_or(false) { + anyhow::bail!( + "cargo-component not found. Install with: cargo install cargo-component\n\ + Or use --skip-build with an existing .wasm file." + ); + } + + let mut cmd = Command::new("cargo"); + cmd.current_dir(source_dir).args(["component", "build"]); + + if release { + cmd.arg("--release"); + } + + println!( + " Running: cargo component build{}", + if release { " --release" } else { "" } + ); + + let output = cmd.output()?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + anyhow::bail!("Build failed:\n{}", stderr); + } + + let profile = if release { "release" } else { "debug" }; + + // Find the built artifact + find_any_wasm_artifact(source_dir, profile).ok_or_else(|| { + anyhow::anyhow!( + "No .wasm file found after build in {}/target/*/{}", + source_dir.display(), + profile, + ) + }) +} + +/// Copy WASM binary + optional `capabilities.json` sidecar to an install directory. +/// +/// Looks for capabilities files in `source_dir` matching several naming conventions. +/// Returns the destination wasm path. +pub async fn install_wasm_files( + wasm_src: &Path, + source_dir: &Path, + name: &str, + target_dir: &Path, + force: bool, +) -> anyhow::Result { + fs::create_dir_all(target_dir).await?; + + let wasm_dst = target_dir.join(format!("{}.wasm", name)); + let caps_dst = target_dir.join(format!("{}.capabilities.json", name)); + + if wasm_dst.exists() && !force { + anyhow::bail!( + "Tool '{}' already exists at {}. Use --force to overwrite.", + name, + wasm_dst.display() + ); + } + + // Copy WASM binary + fs::copy(wasm_src, &wasm_dst).await?; + + // Look for capabilities.json sidecar in the source directory + let caps_candidates = [ + source_dir.join(format!("{}.capabilities.json", name)), + source_dir.join(format!("{}-tool.capabilities.json", name)), + source_dir.join("capabilities.json"), + ]; + for caps_src in &caps_candidates { + if caps_src.exists() { + if let Err(e) = fs::copy(caps_src, &caps_dst).await { + tracing::warn!( + "Failed to copy capabilities sidecar {} -> {}: {}", + caps_src.display(), + caps_dst.display(), + e, + ); + } + break; + } + } + + Ok(wasm_dst) +} + +#[cfg(test)] +mod tests { + use tempfile::TempDir; + + use super::*; + + #[test] + fn test_resolve_target_dir_default() { + // When CARGO_TARGET_DIR is not set, should return /target + let dir = Path::new("/some/crate"); + let result = resolve_target_dir(dir); + assert!(result.ends_with("target")); + } + + #[test] + fn test_find_wasm_artifact_not_found() { + let dir = TempDir::new().unwrap(); + assert!(find_wasm_artifact(dir.path(), "nonexistent", "release").is_none()); + } + + #[test] + fn test_find_wasm_artifact_found() { + let dir = TempDir::new().unwrap(); + let target_base = resolve_target_dir(dir.path()); + let wasm_dir = target_base.join("wasm32-wasip2/release"); + std::fs::create_dir_all(&wasm_dir).unwrap(); + std::fs::File::create(wasm_dir.join("my_tool.wasm")).unwrap(); + + let result = find_wasm_artifact(dir.path(), "my_tool", "release"); + assert!(result.is_some()); + assert!(result.unwrap().ends_with("my_tool.wasm")); + } + + #[test] + fn test_find_wasm_artifact_hyphen_to_underscore() { + let dir = TempDir::new().unwrap(); + let target_base = resolve_target_dir(dir.path()); + let wasm_dir = target_base.join("wasm32-wasip1/release"); + std::fs::create_dir_all(&wasm_dir).unwrap(); + std::fs::File::create(wasm_dir.join("my_tool.wasm")).unwrap(); + + // Search with hyphens, should find underscore version + let result = find_wasm_artifact(dir.path(), "my-tool", "release"); + assert!(result.is_some()); + } + + #[test] + fn test_find_any_wasm_artifact_found() { + let dir = TempDir::new().unwrap(); + let target_base = resolve_target_dir(dir.path()); + let wasm_dir = target_base.join("wasm32-wasip2/release"); + std::fs::create_dir_all(&wasm_dir).unwrap(); + std::fs::File::create(wasm_dir.join("something.wasm")).unwrap(); + + let result = find_any_wasm_artifact(dir.path(), "release"); + assert!(result.is_some()); + } + + #[test] + fn test_find_any_wasm_artifact_not_found() { + let dir = TempDir::new().unwrap(); + assert!(find_any_wasm_artifact(dir.path(), "release").is_none()); + } + + #[tokio::test] + async fn test_install_wasm_files_copies() { + let src_dir = TempDir::new().unwrap(); + let target_dir = TempDir::new().unwrap(); + + let wasm_src = src_dir.path().join("test.wasm"); + tokio::fs::write(&wasm_src, b"\0asm\x01\x00\x00\x00") + .await + .unwrap(); + + // Create a capabilities file + let caps_src = src_dir.path().join("mytool.capabilities.json"); + tokio::fs::write(&caps_src, b"{}").await.unwrap(); + + let result = install_wasm_files( + &wasm_src, + src_dir.path(), + "mytool", + target_dir.path(), + false, + ) + .await; + + assert!(result.is_ok()); + let wasm_dst = result.unwrap(); + assert!(wasm_dst.exists()); + assert!(target_dir.path().join("mytool.capabilities.json").exists()); + } + + #[tokio::test] + async fn test_install_wasm_files_refuses_overwrite() { + let src_dir = TempDir::new().unwrap(); + let target_dir = TempDir::new().unwrap(); + + let wasm_src = src_dir.path().join("test.wasm"); + tokio::fs::write(&wasm_src, b"\0asm").await.unwrap(); + + // Pre-create the target + let existing = target_dir.path().join("mytool.wasm"); + tokio::fs::write(&existing, b"existing").await.unwrap(); + + let result = install_wasm_files( + &wasm_src, + src_dir.path(), + "mytool", + target_dir.path(), + false, + ) + .await; + + assert!(result.is_err()); + } + + #[test] + fn test_wasm_triples_order() { + // Verify the order is as documented + assert_eq!(WASM_TRIPLES[0], "wasm32-wasip1"); + assert_eq!(WASM_TRIPLES[1], "wasm32-wasip2"); + assert_eq!(WASM_TRIPLES[2], "wasm32-wasi"); + assert_eq!(WASM_TRIPLES[3], "wasm32-unknown-unknown"); + } +} diff --git a/src/registry/installer.rs b/src/registry/installer.rs index 0f7f84f0..8dfe3908 100644 --- a/src/registry/installer.rs +++ b/src/registry/installer.rs @@ -94,12 +94,13 @@ impl RegistryInstaller { source_dir.display() ); let crate_name = &manifest.source.crate_name; - let wasm_path = build_wasm_component(&source_dir, crate_name) - .await - .map_err(|e| RegistryError::ManifestRead { - path: source_dir.clone(), - reason: format!("build failed: {}", e), - })?; + let wasm_path = + crate::registry::artifacts::build_wasm_component(&source_dir, crate_name, true) + .await + .map_err(|e| RegistryError::ManifestRead { + path: source_dir.clone(), + reason: format!("build failed: {}", e), + })?; // Copy WASM binary println!(" Installing to {}", target_wasm.display()); @@ -353,64 +354,6 @@ impl RegistryInstaller { } } -/// Build a WASM component from a source directory using `cargo component build --release`. -/// -/// Uses `tokio::process::Command` with inherited stdio so build progress is visible. -/// Looks for the specific `{crate_name}.wasm` in the release directory rather than -/// picking the first `.wasm` file found. -async fn build_wasm_component(source_dir: &Path, crate_name: &str) -> anyhow::Result { - use tokio::process::Command; - - // Check cargo-component availability - let check = Command::new("cargo") - .args(["component", "--version"]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .await; - - if check.is_err() || !check.as_ref().map(|s| s.success()).unwrap_or(false) { - anyhow::bail!("cargo-component not found. Install with: cargo install cargo-component"); - } - - // Use status() with inherited stdio so build output streams to the terminal. - let status = Command::new("cargo") - .current_dir(source_dir) - .args(["component", "build", "--release"]) - .status() - .await?; - - if !status.success() { - anyhow::bail!("Build failed (exit code: {})", status); - } - - // Look for the specific crate's WASM file (Cargo uses underscores in artifact names). - let wasm_filename = format!("{}.wasm", crate_name.replace('-', "_")); - let target_base = source_dir.join("target"); - let candidates = [ - "wasm32-wasip1", - "wasm32-wasip2", - "wasm32-wasi", - "wasm32-unknown-unknown", - ]; - - for target in &candidates { - let wasm_path = target_base - .join(target) - .join("release") - .join(&wasm_filename); - if wasm_path.exists() { - return Ok(wasm_path); - } - } - - anyhow::bail!( - "Could not find {} in {}/target/*/release/", - wasm_filename, - source_dir.display() - ) -} - /// Download an artifact from a URL. async fn download_artifact(url: &str) -> Result { let response = reqwest::get(url) diff --git a/src/registry/manifest.rs b/src/registry/manifest.rs index 2e93ed4e..d5b3fedf 100644 --- a/src/registry/manifest.rs +++ b/src/registry/manifest.rs @@ -165,12 +165,14 @@ impl ExtensionManifest { ExtensionSource::WasmBuildable { repo_url: self.source.dir.clone(), build_dir: Some(self.source.dir.clone()), + crate_name: Some(self.source.crate_name.clone()), } } } else { ExtensionSource::WasmBuildable { repo_url: self.source.dir.clone(), build_dir: Some(self.source.dir.clone()), + crate_name: Some(self.source.crate_name.clone()), } }; diff --git a/src/registry/mod.rs b/src/registry/mod.rs index b223c130..5649f821 100644 --- a/src/registry/mod.rs +++ b/src/registry/mod.rs @@ -11,6 +11,7 @@ //! └── _bundles.json <- Bundle definitions (google, messaging, default) //! ``` +pub mod artifacts; pub mod catalog; pub mod embedded; pub mod installer; diff --git a/src/tools/builder/core.rs b/src/tools/builder/core.rs index 4e8dcdb8..415c0955 100644 --- a/src/tools/builder/core.rs +++ b/src/tools/builder/core.rs @@ -798,10 +798,10 @@ Create alongside the .wasm file to grant capabilities: match (&requirement.software_type, &requirement.language) { (SoftwareType::WasmTool, Language::Rust) => { // WASM output location - project_dir.join(format!( - "target/wasm32-wasip2/release/{}.wasm", - requirement.name.replace('-', "_") - )) + crate::tools::wasm::wasm_artifact_path( + project_dir, + &requirement.name.replace('-', "_"), + ) } (SoftwareType::CliBinary, Language::Rust) => project_dir.join(format!( "target/release/{}", diff --git a/src/tools/builtin/extension_tools.rs b/src/tools/builtin/extension_tools.rs index 876e8ace..0fcfbda3 100644 --- a/src/tools/builtin/extension_tools.rs +++ b/src/tools/builtin/extension_tools.rs @@ -30,7 +30,8 @@ impl Tool for ToolSearchTool { } fn description(&self) -> &str { - "Search for available extensions (MCP servers, WASM tools, WASM channels) to add. \ + "Search for available extensions to add new capabilities. Extensions include \ + channels (Telegram, Slack, Discord — for messaging), tools, and MCP servers. \ Use discover:true to search online if the built-in registry has no results." } @@ -100,7 +101,7 @@ impl Tool for ToolInstallTool { } fn description(&self) -> &str { - "Install an extension (MCP server, WASM tool, or WASM channel). \ + "Install an extension (channel, tool, or MCP server). \ Use the name from tool_search results, or provide an explicit URL." } @@ -278,7 +279,7 @@ impl Tool for ToolActivateTool { } fn description(&self) -> &str { - "Activate an installed extension, connecting to MCP servers or loading WASM tools into the runtime." + "Activate an installed extension — starts channels, loads tools, or connects to MCP servers." } fn parameters_schema(&self) -> serde_json::Value { @@ -372,7 +373,8 @@ impl Tool for ToolListTool { } fn description(&self) -> &str { - "List all installed extensions with their authentication and activation status." + "List extensions with their authentication and activation status. \ + Set include_available:true to also show registry entries not yet installed." } fn parameters_schema(&self) -> serde_json::Value { @@ -383,6 +385,11 @@ impl Tool for ToolListTool { "type": "string", "enum": ["mcp_server", "wasm_tool", "wasm_channel"], "description": "Filter by extension type (omit to list all)" + }, + "include_available": { + "type": "boolean", + "description": "If true, also include registry entries that are not yet installed", + "default": false } } }) @@ -405,9 +412,14 @@ impl Tool for ToolListTool { _ => None, }); + let include_available = params + .get("include_available") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let extensions = self .manager - .list(kind_filter) + .list(kind_filter, include_available) .await .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?; @@ -439,7 +451,7 @@ impl Tool for ToolRemoveTool { } fn description(&self) -> &str { - "Remove an installed extension (MCP server or WASM tool). \ + "Remove an installed extension (channel, tool, or MCP server). \ Unregisters tools and deletes configuration." } diff --git a/src/tools/wasm/loader.rs b/src/tools/wasm/loader.rs index aee3bdf6..d0cb4687 100644 --- a/src/tools/wasm/loader.rs +++ b/src/tools/wasm/loader.rs @@ -383,6 +383,31 @@ impl LoadResults { /// 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. `/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): @@ -426,9 +451,7 @@ pub async fn discover_dev_tools() -> Result, std let crate_name = dir_name.replace('-', "_"); let install_name = format!("{}-tool", dir_name); - let wasm_path = path - .join("target/wasm32-wasip2/release") - .join(format!("{}_tool.wasm", crate_name)); + let wasm_path = wasm_artifact_path(&path, &format!("{}_tool", crate_name)); if !wasm_path.exists() { continue; diff --git a/src/tools/wasm/mod.rs b/src/tools/wasm/mod.rs index 632e5ba1..42e9f150 100644 --- a/src/tools/wasm/mod.rs +++ b/src/tools/wasm/mod.rs @@ -123,7 +123,7 @@ pub use storage::{ // Loader pub use loader::{ DiscoveredTool, LoadResults, WasmLoadError, WasmToolLoader, discover_dev_tools, discover_tools, - load_dev_tools, + load_dev_tools, resolve_wasm_target_dir, wasm_artifact_path, }; // Capabilities schema (for parsing *.capabilities.json files) diff --git a/tests/html_to_markdown.rs b/tests/html_to_markdown.rs index 03324851..f9fa1eb4 100644 --- a/tests/html_to_markdown.rs +++ b/tests/html_to_markdown.rs @@ -27,10 +27,8 @@ fn normalize(s: &str) -> String { /// Normalize typographic/smart punctuation to ASCII so tests match converter output /// regardless of apostrophe/quote variants (e.g. U+2019 ' → U+0027 '). fn normalize_smart_punctuation(s: &str) -> String { - s.replace('\u{2019}', "'") // RIGHT SINGLE QUOTATION MARK - .replace('\u{2018}', "'") // LEFT SINGLE QUOTATION MARK - .replace('\u{201C}', "\"") // LEFT DOUBLE QUOTATION MARK - .replace('\u{201D}', "\"") // RIGHT DOUBLE QUOTATION MARK + s.replace(['\u{2019}', '\u{2018}'], "'") + .replace(['\u{201C}', '\u{201D}'], "\"") } #[test] @@ -58,15 +56,13 @@ fn convert_test_pages_to_markdown() { .unwrap_or("unknown"); let default_url = format!("https://example.com/test-pages/{}/", dir_name); - let metadata: PageMetadata = path - .join("metadata.json") - .is_file() - .then(|| { - let raw = std::fs::read_to_string(path.join("metadata.json")) - .expect("read metadata.json"); - serde_json::from_str(&raw).expect("invalid metadata.json") - }) - .unwrap_or_default(); + let metadata: PageMetadata = if path.join("metadata.json").is_file() { + let raw = + std::fs::read_to_string(path.join("metadata.json")).expect("read metadata.json"); + serde_json::from_str(&raw).expect("invalid metadata.json") + } else { + Default::default() + }; let url = metadata.url.as_deref().unwrap_or(&default_url).to_string(); diff --git a/tools-src/okta/src/api.rs b/tools-src/okta/src/api.rs index 684947fd..1d28248f 100644 --- a/tools-src/okta/src/api.rs +++ b/tools-src/okta/src/api.rs @@ -33,7 +33,7 @@ fn okta_api_call(method: &str, url: &str, body: Option<&str>) -> Result= 300 { let body_text = String::from_utf8_lossy(&response.body); @@ -224,7 +224,7 @@ fn okta_api_call_with_headers( &format!("Okta API: {} {}", method, url), ); - let response = host::http_request(method, url, headers, body_bytes.as_deref())?; + let response = host::http_request(method, url, headers, body_bytes.as_deref(), None)?; if response.status < 200 || response.status >= 300 { let body_text = String::from_utf8_lossy(&response.body);