Files
optimclaw/src/extensions/manager.rs
T
Nick PismenkovandGitHub 14de4c1b57 feat: Add HMAC-SHA256 webhook signature validation for Slack (#588)
* feat: Add HMAC-SHA256 webhook signature validation for Slack

* review fixes
2026-03-05 19:27:10 -08:00

3553 lines
134 KiB
Rust

//! Central extension manager that dispatches operations by ExtensionKind.
//!
//! 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;
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::{
ActivateResult, AuthResult, ExtensionError, ExtensionKind, ExtensionSource, InstallResult,
InstalledExtension, RegistryEntry, ResultSource, SearchResult, ToolAuthState,
};
use crate::hooks::HookRegistry;
use crate::pairing::PairingStore;
use crate::secrets::{CreateSecretParams, SecretsStore};
use crate::tools::ToolRegistry;
use crate::tools::mcp::McpClient;
use crate::tools::mcp::auth::{
PkceChallenge, authorize_mcp_server, build_authorization_url, discover_full_oauth_metadata,
find_available_port, is_authenticated, register_client,
};
use crate::tools::mcp::config::McpServerConfig;
use crate::tools::mcp::session::McpSessionManager;
use crate::tools::wasm::{WasmToolLoader, WasmToolRuntime, discover_tools};
/// Pending OAuth authorization state.
struct PendingAuth {
_name: String,
_kind: ExtensionKind,
created_at: std::time::Instant,
/// Background task listening for the OAuth callback.
/// Aborted when a new auth flow starts for the same extension.
task_handle: Option<tokio::task::JoinHandle<()>>,
}
/// 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<ChannelManager>,
wasm_channel_runtime: Arc<WasmChannelRuntime>,
pairing_store: Arc<PairingStore>,
wasm_channel_router: Arc<WasmChannelRouter>,
wasm_channel_owner_ids: std::collections::HashMap<String, i64>,
}
/// Result of saving setup secrets and attempting activation.
pub struct SetupResult {
/// Human-readable status message.
pub message: String,
/// Whether the channel was successfully activated after saving secrets.
pub activated: bool,
/// OAuth authorization URL for the UI to open (if OAuth flow was started).
pub auth_url: Option<String>,
}
/// Central manager for extension lifecycle operations.
pub struct ExtensionManager {
registry: ExtensionRegistry,
discovery: OnlineDiscovery,
// MCP infrastructure
mcp_session_manager: Arc<McpSessionManager>,
/// Active MCP clients keyed by server name.
mcp_clients: RwLock<HashMap<String, Arc<McpClient>>>,
// WASM tool infrastructure
wasm_tool_runtime: Option<Arc<WasmToolRuntime>>,
wasm_tools_dir: PathBuf,
wasm_channels_dir: PathBuf,
// WASM channel hot-activation infrastructure (set post-construction)
channel_runtime: RwLock<Option<ChannelRuntimeState>>,
// Shared
secrets: Arc<dyn SecretsStore + Send + Sync>,
tool_registry: Arc<ToolRegistry>,
hooks: Option<Arc<HookRegistry>>,
pending_auth: RwLock<HashMap<String, PendingAuth>>,
/// Tunnel URL for webhook configuration and remote OAuth callbacks.
tunnel_url: Option<String>,
user_id: String,
/// Optional database store for DB-backed MCP config.
store: Option<Arc<dyn crate::db::Database>>,
/// Names of WASM channels that were successfully loaded at startup.
active_channel_names: RwLock<HashSet<String>>,
/// Last activation error for each WASM channel (ephemeral, cleared on success).
activation_errors: RwLock<HashMap<String, String>>,
/// SSE broadcast sender (set post-construction via `set_sse_sender()`).
sse_sender:
RwLock<Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>>,
/// Shared registry of pending OAuth flows for gateway-routed callbacks.
///
/// Keyed by CSRF `state` parameter. Populated in `start_wasm_oauth()`
/// when running in gateway mode, consumed by the web gateway's
/// `/oauth/callback` handler.
pending_oauth_flows: crate::cli::oauth_defaults::PendingOAuthRegistry,
/// Gateway auth token for authenticating with the platform token exchange proxy.
/// Read once at construction from `GATEWAY_AUTH_TOKEN` env var.
gateway_token: Option<String>,
}
impl ExtensionManager {
#[allow(clippy::too_many_arguments)]
pub fn new(
mcp_session_manager: Arc<McpSessionManager>,
secrets: Arc<dyn SecretsStore + Send + Sync>,
tool_registry: Arc<ToolRegistry>,
hooks: Option<Arc<HookRegistry>>,
wasm_tool_runtime: Option<Arc<WasmToolRuntime>>,
wasm_tools_dir: PathBuf,
wasm_channels_dir: PathBuf,
tunnel_url: Option<String>,
user_id: String,
store: Option<Arc<dyn crate::db::Database>>,
catalog_entries: Vec<RegistryEntry>,
) -> Self {
let registry = if catalog_entries.is_empty() {
ExtensionRegistry::new()
} else {
ExtensionRegistry::new_with_catalog(catalog_entries)
};
Self {
registry,
discovery: OnlineDiscovery::new(),
mcp_session_manager,
mcp_clients: RwLock::new(HashMap::new()),
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,
user_id,
store,
active_channel_names: RwLock::new(HashSet::new()),
activation_errors: RwLock::new(HashMap::new()),
sse_sender: RwLock::new(None),
pending_oauth_flows: crate::cli::oauth_defaults::new_pending_oauth_registry(),
gateway_token: std::env::var("GATEWAY_AUTH_TOKEN").ok(),
}
}
/// 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<ChannelManager>,
wasm_channel_runtime: Arc<WasmChannelRuntime>,
pairing_store: Arc<PairingStore>,
wasm_channel_router: Arc<WasmChannelRouter>,
wasm_channel_owner_ids: std::collections::HashMap<String, i64>,
) {
*self.channel_runtime.write().await = Some(ChannelRuntimeState {
channel_manager,
wasm_channel_runtime,
pairing_store,
wasm_channel_router,
wasm_channel_owner_ids,
});
}
/// 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<String>) {
let mut active = self.active_channel_names.write().await;
active.extend(names);
}
/// Persist the set of active channel names to the settings store.
///
/// Saved under key `activated_channels` so channels auto-activate on restart.
async fn persist_active_channels(&self) {
let Some(ref store) = self.store else {
return;
};
let names: Vec<String> = self
.active_channel_names
.read()
.await
.iter()
.cloned()
.collect();
let value = serde_json::json!(names);
if let Err(e) = store
.set_setting(&self.user_id, "activated_channels", &value)
.await
{
tracing::warn!(error = %e, "Failed to persist activated_channels setting");
}
}
/// Load previously activated channel names from the settings store.
///
/// Returns channel names that were activated in a prior session so they can
/// be auto-activated at startup.
pub async fn load_persisted_active_channels(&self) -> Vec<String> {
let Some(ref store) = self.store else {
return Vec::new();
};
match store.get_setting(&self.user_id, "activated_channels").await {
Ok(Some(value)) => match serde_json::from_value(value) {
Ok(names) => names,
Err(e) => {
tracing::warn!(error = %e, "Failed to deserialize activated_channels");
Vec::new()
}
},
Ok(None) => Vec::new(),
Err(e) => {
tracing::warn!(error = %e, "Failed to load activated_channels setting");
Vec::new()
}
}
}
/// Set the SSE broadcast sender for pushing extension status events to the web UI.
pub async fn set_sse_sender(
&self,
sender: tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>,
) {
*self.sse_sender.write().await = Some(sender);
}
/// Returns the pending OAuth flow registry for sharing with the web gateway.
///
/// The gateway's `/oauth/callback` handler uses this to look up pending flows
/// by CSRF `state` parameter and complete the token exchange.
pub fn pending_oauth_flows(&self) -> &crate::cli::oauth_defaults::PendingOAuthRegistry {
&self.pending_oauth_flows
}
/// Broadcast an extension status change to the web UI via SSE.
async fn broadcast_extension_status(&self, name: &str, status: &str, message: Option<&str>) {
if let Some(ref sender) = *self.sse_sender.read().await {
let _ = sender.send(crate::channels::web::types::SseEvent::ExtensionStatus {
extension_name: name.to_string(),
status: status.to_string(),
message: message.map(|m| m.to_string()),
});
}
}
/// Search for extensions. If `discover` is true, also searches online.
pub async fn search(
&self,
query: &str,
discover: bool,
) -> Result<Vec<SearchResult>, ExtensionError> {
let mut results = self.registry.search(query).await;
if discover && results.is_empty() {
tracing::info!("No built-in results for '{}', searching online...", query);
let discovered = self.discovery.discover(query).await;
if !discovered.is_empty() {
// Cache for future lookups
self.registry.cache_discovered(discovered.clone()).await;
// Add to results
for entry in discovered {
results.push(SearchResult {
entry,
source: ResultSource::Discovered,
validated: true,
});
}
}
}
Ok(results)
}
/// Install an extension by name (from registry) or by explicit URL.
pub async fn install(
&self,
name: &str,
url: Option<&str>,
kind_hint: Option<ExtensionKind>,
) -> Result<InstallResult, ExtensionError> {
tracing::info!(extension = %name, url = ?url, kind = ?kind_hint, "Installing extension");
Self::validate_extension_name(name)?;
// If we have a registry entry, use it (prefer kind_hint to resolve collisions)
if let Some(entry) = self.registry.get_with_kind(name, kind_hint).await {
return self.install_from_entry(&entry).await.map_err(|e| {
tracing::error!(extension = %name, error = %e, "Extension install failed");
e
});
}
// If a URL was provided, determine kind and install
if let Some(url) = url {
let kind = kind_hint.unwrap_or_else(|| infer_kind_from_url(url));
return match kind {
ExtensionKind::McpServer => self.install_mcp_from_url(name, url).await,
ExtensionKind::WasmTool => self.install_wasm_tool_from_url(name, url).await,
ExtensionKind::WasmChannel => {
self.install_wasm_channel_from_url(name, url, None).await
}
}
.map_err(|e| {
tracing::error!(extension = %name, url = %url, error = %e, "Extension install from URL failed");
e
});
}
let err = ExtensionError::NotFound(format!(
"'{}' not found in registry. Try searching with discover:true or provide a URL.",
name
));
tracing::warn!(extension = %name, "Extension not found in registry");
Err(err)
}
/// Authenticate an installed extension.
pub async fn auth(
&self,
name: &str,
token: Option<&str>,
) -> Result<AuthResult, ExtensionError> {
// Clean up expired pending auths
self.cleanup_expired_auths().await;
// Determine what kind of extension this is
let kind = self.determine_installed_kind(name).await?;
match kind {
ExtensionKind::McpServer => self.auth_mcp(name, token).await,
ExtensionKind::WasmTool => self.auth_wasm_tool(name, token).await,
ExtensionKind::WasmChannel => self.auth_wasm_channel(name, token).await,
}
}
/// Activate an installed (and optionally authenticated) extension.
pub async fn activate(&self, name: &str) -> Result<ActivateResult, ExtensionError> {
Self::validate_extension_name(name)?;
let kind = self.determine_installed_kind(name).await?;
match kind {
ExtensionKind::McpServer => self.activate_mcp(name).await,
ExtensionKind::WasmTool => self.activate_wasm_tool(name).await,
ExtensionKind::WasmChannel => self.activate_wasm_channel(name).await,
}
}
/// 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<ExtensionKind>,
include_available: bool,
) -> Result<Vec<InstalledExtension>, ExtensionError> {
let mut extensions = Vec::new();
// List MCP servers
if kind_filter.is_none() || kind_filter == Some(ExtensionKind::McpServer) {
match self.load_mcp_servers().await {
Ok(servers) => {
for server in &servers.servers {
let authenticated =
is_authenticated(server, &self.secrets, &self.user_id).await;
let clients = self.mcp_clients.read().await;
let active = clients.contains_key(&server.name);
// Get tool names if active
let tools = if active {
self.tool_registry
.list()
.await
.into_iter()
.filter(|t| t.starts_with(&format!("{}_", server.name)))
.collect()
} else {
Vec::new()
};
let display_name = self
.registry
.get_with_kind(&server.name, Some(ExtensionKind::McpServer))
.await
.map(|e| e.display_name);
extensions.push(InstalledExtension {
name: server.name.clone(),
kind: ExtensionKind::McpServer,
display_name,
description: server.description.clone(),
url: Some(server.url.clone()),
authenticated,
active,
tools,
needs_setup: false,
has_auth: false,
installed: true,
activation_error: None,
});
}
}
Err(e) => {
tracing::debug!("Failed to load MCP servers for listing: {}", e);
}
}
}
// List WASM tools
if (kind_filter.is_none() || kind_filter == Some(ExtensionKind::WasmTool))
&& self.wasm_tools_dir.exists()
{
match discover_tools(&self.wasm_tools_dir).await {
Ok(tools) => {
for (name, _discovered) in tools {
let active = self.tool_registry.has(&name).await;
let display_name = self
.registry
.get_with_kind(&name, Some(ExtensionKind::WasmTool))
.await
.map(|e| e.display_name);
let auth_state = self.check_tool_auth_status(&name).await;
extensions.push(InstalledExtension {
name: name.clone(),
kind: ExtensionKind::WasmTool,
display_name,
description: None,
url: None,
authenticated: auth_state == ToolAuthState::Ready,
active,
tools: if active { vec![name] } else { Vec::new() },
needs_setup: auth_state == ToolAuthState::NeedsSetup,
has_auth: auth_state != ToolAuthState::NoAuth,
installed: true,
activation_error: None,
});
}
}
Err(e) => {
tracing::debug!("Failed to discover WASM tools for listing: {}", e);
}
}
}
// List WASM channels
if (kind_filter.is_none() || kind_filter == Some(ExtensionKind::WasmChannel))
&& self.wasm_channels_dir.exists()
{
match crate::channels::wasm::discover_channels(&self.wasm_channels_dir).await {
Ok(channels) => {
let active_names = self.active_channel_names.read().await;
let errors = self.activation_errors.read().await;
for (name, _discovered) in channels {
let active = active_names.contains(&name);
let auth_state = self.check_channel_auth_status(&name).await;
let activation_error = errors.get(&name).cloned();
let display_name = self
.registry
.get_with_kind(&name, Some(ExtensionKind::WasmChannel))
.await
.map(|e| e.display_name);
extensions.push(InstalledExtension {
name,
kind: ExtensionKind::WasmChannel,
display_name,
description: None,
url: None,
authenticated: auth_state == ToolAuthState::Ready,
active,
tools: Vec::new(),
needs_setup: auth_state == ToolAuthState::NeedsSetup,
has_auth: false,
installed: true,
activation_error,
});
}
}
Err(e) => {
tracing::debug!("Failed to discover WASM channels for listing: {}", e);
}
}
}
// 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,
display_name: Some(entry.display_name),
description: Some(entry.description),
url: None,
authenticated: false,
active: false,
tools: Vec::new(),
needs_setup: false,
has_auth: false,
installed: false,
activation_error: None,
});
}
}
Ok(extensions)
}
/// Remove an installed extension.
pub async fn remove(&self, name: &str) -> Result<String, ExtensionError> {
Self::validate_extension_name(name)?;
let kind = self.determine_installed_kind(name).await?;
match kind {
ExtensionKind::McpServer => {
// Unregister tools with this server's prefix
let tool_names: Vec<String> = self
.tool_registry
.list()
.await
.into_iter()
.filter(|t| t.starts_with(&format!("{}_", name)))
.collect();
for tool_name in &tool_names {
self.tool_registry.unregister(tool_name).await;
}
// Remove MCP client
self.mcp_clients.write().await.remove(name);
// Remove from config
self.remove_mcp_server(name)
.await
.map_err(|e| ExtensionError::Config(e.to_string()))?;
Ok(format!(
"Removed MCP server '{}' and {} tool(s)",
name,
tool_names.len()
))
}
ExtensionKind::WasmTool => {
// Unregister from tool registry
self.tool_registry.unregister(name).await;
// Revoke credential mappings from the shared registry
let cap_path = self
.wasm_tools_dir
.join(format!("{}.capabilities.json", name));
self.revoke_credential_mappings(&cap_path).await;
// Unregister hooks registered from this plugin source.
let removed_hooks = self
.unregister_hook_prefix(&format!("plugin.tool:{}::", name))
.await
+ self
.unregister_hook_prefix(&format!("plugin.dev_tool:{}::", name))
.await;
if removed_hooks > 0 {
tracing::info!(
extension = name,
removed_hooks = removed_hooks,
"Removed plugin hooks for WASM tool"
);
}
// Delete files
let wasm_path = self.wasm_tools_dir.join(format!("{}.wasm", name));
if wasm_path.exists() {
tokio::fs::remove_file(&wasm_path)
.await
.map_err(|e| ExtensionError::Other(e.to_string()))?;
}
if cap_path.exists() {
let _ = tokio::fs::remove_file(&cap_path).await;
}
Ok(format!("Removed WASM tool '{}'", name))
}
ExtensionKind::WasmChannel => {
// Remove from active set and persist
self.active_channel_names.write().await.remove(name);
self.persist_active_channels().await;
// Delete channel files
let wasm_path = self.wasm_channels_dir.join(format!("{}.wasm", name));
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
// Revoke credential mappings before deleting the capabilities file
self.revoke_credential_mappings(&cap_path).await;
if wasm_path.exists() {
tokio::fs::remove_file(&wasm_path)
.await
.map_err(|e| ExtensionError::Other(e.to_string()))?;
}
if cap_path.exists() {
let _ = tokio::fs::remove_file(&cap_path).await;
}
Ok(format!(
"Removed channel '{}'. Restart IronClaw for the change to take effect.",
name
))
}
}
}
// ── MCP config helpers (DB with disk fallback) ─────────────────────
async fn load_mcp_servers(
&self,
) -> Result<crate::tools::mcp::config::McpServersFile, crate::tools::mcp::config::ConfigError>
{
if let Some(ref store) = self.store {
crate::tools::mcp::config::load_mcp_servers_from_db(store.as_ref(), &self.user_id).await
} else {
crate::tools::mcp::config::load_mcp_servers().await
}
}
async fn get_mcp_server(
&self,
name: &str,
) -> Result<McpServerConfig, crate::tools::mcp::config::ConfigError> {
let servers = self.load_mcp_servers().await?;
servers.get(name).cloned().ok_or_else(|| {
crate::tools::mcp::config::ConfigError::ServerNotFound {
name: name.to_string(),
}
})
}
async fn add_mcp_server(
&self,
config: McpServerConfig,
) -> Result<(), crate::tools::mcp::config::ConfigError> {
config.validate()?;
if let Some(ref store) = self.store {
crate::tools::mcp::config::add_mcp_server_db(store.as_ref(), &self.user_id, config)
.await
} else {
crate::tools::mcp::config::add_mcp_server(config).await
}
}
async fn remove_mcp_server(
&self,
name: &str,
) -> Result<(), crate::tools::mcp::config::ConfigError> {
if let Some(ref store) = self.store {
crate::tools::mcp::config::remove_mcp_server_db(store.as_ref(), &self.user_id, name)
.await
} else {
crate::tools::mcp::config::remove_mcp_server(name).await
}
}
// ── Private helpers ──────────────────────────────────────────────────
async fn install_from_entry(
&self,
entry: &RegistryEntry,
) -> Result<InstallResult, ExtensionError> {
let primary_result = self.try_install_from_source(entry, &entry.source).await;
match fallback_decision(&primary_result, &entry.fallback_source) {
FallbackDecision::Return => primary_result,
FallbackDecision::TryFallback => {
let primary_err = primary_result.unwrap_err();
let fallback = entry.fallback_source.as_ref().unwrap();
tracing::info!(
extension = %entry.name,
primary_error = %primary_err,
"Primary install failed, trying fallback source"
);
match self.try_install_from_source(entry, fallback).await {
Ok(result) => Ok(result),
Err(fallback_err) => {
tracing::error!(
extension = %entry.name,
fallback_error = %fallback_err,
"Fallback install also failed"
);
Err(combine_install_errors(primary_err, fallback_err))
}
}
}
}
}
/// Attempt to install an extension using a specific source.
async fn try_install_from_source(
&self,
entry: &RegistryEntry,
source: &ExtensionSource,
) -> Result<InstallResult, ExtensionError> {
match entry.kind {
ExtensionKind::McpServer => {
let url = match source {
ExtensionSource::McpUrl { url } => url.clone(),
ExtensionSource::Discovered { url } => url.clone(),
_ => {
return Err(ExtensionError::InstallFailed(
"Registry entry for MCP server has no URL".to_string(),
));
}
};
self.install_mcp_from_url(&entry.name, &url).await
}
ExtensionKind::WasmTool => match source {
ExtensionSource::WasmDownload {
wasm_url,
capabilities_url,
} => {
self.install_wasm_tool_from_url_with_caps(
&entry.name,
wasm_url,
capabilities_url.as_deref(),
)
.await
}
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 or build info".to_string(),
)),
},
ExtensionKind::WasmChannel => match source {
ExtensionSource::WasmDownload {
wasm_url,
capabilities_url,
} => {
self.install_wasm_channel_from_url(
&entry.name,
wasm_url,
capabilities_url.as_deref(),
)
.await
}
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 or build info".to_string(),
)),
},
}
}
async fn install_mcp_from_url(
&self,
name: &str,
url: &str,
) -> Result<InstallResult, ExtensionError> {
// Check if already installed
if self.get_mcp_server(name).await.is_ok() {
return Err(ExtensionError::AlreadyInstalled(name.to_string()));
}
let config = McpServerConfig::new(name, url);
config
.validate()
.map_err(|e| ExtensionError::InvalidUrl(e.to_string()))?;
self.add_mcp_server(config)
.await
.map_err(|e| ExtensionError::Config(e.to_string()))?;
tracing::info!("Installed MCP server '{}' at {}", name, url);
Ok(InstallResult {
name: name.to_string(),
kind: ExtensionKind::McpServer,
message: format!(
"MCP server '{}' installed. Run auth next to authenticate.",
name
),
})
}
async fn install_wasm_tool_from_url(
&self,
name: &str,
url: &str,
) -> Result<InstallResult, ExtensionError> {
self.install_wasm_tool_from_url_with_caps(name, url, None)
.await
}
async fn install_wasm_tool_from_url_with_caps(
&self,
name: &str,
url: &str,
capabilities_url: Option<&str>,
) -> Result<InstallResult, ExtensionError> {
self.download_and_install_wasm(name, url, capabilities_url, &self.wasm_tools_dir)
.await?;
Ok(InstallResult {
name: name.to_string(),
kind: ExtensionKind::WasmTool,
message: format!("WASM tool '{}' installed. Run activate to load it.", name),
})
}
async fn install_wasm_channel_from_url(
&self,
name: &str,
url: &str,
capabilities_url: Option<&str>,
) -> Result<InstallResult, ExtensionError> {
self.download_and_install_wasm(name, url, capabilities_url, &self.wasm_channels_dir)
.await?;
Ok(InstallResult {
name: name.to_string(),
kind: ExtensionKind::WasmChannel,
message: format!(
"WASM channel '{}' installed. Run activate to start it.",
name,
),
})
}
/// Download a WASM extension (tool or channel) from URL and install to target directory.
///
/// Handles both tar.gz bundles (containing `.wasm` + `.capabilities.json`) and bare
/// `.wasm` files. Validates HTTPS, size limits, and file format.
async fn download_and_install_wasm(
&self,
name: &str,
url: &str,
capabilities_url: Option<&str>,
target_dir: &std::path::Path,
) -> Result<(), ExtensionError> {
// Require HTTPS to prevent downgrade attacks
if !url.starts_with("https://") {
return Err(ExtensionError::InstallFailed(
"Only HTTPS URLs are allowed for extension downloads".to_string(),
));
}
// 50 MB cap to prevent disk-fill DoS
const MAX_DOWNLOAD_SIZE: usize = 50 * 1024 * 1024;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| ExtensionError::DownloadFailed(e.to_string()))?;
tracing::debug!(extension = %name, url = %url, "Downloading WASM extension");
let response = client.get(url).send().await.map_err(|e| {
tracing::error!(extension = %name, url = %url, error = %e, "Download request failed");
ExtensionError::DownloadFailed(e.to_string())
})?;
if !response.status().is_success() {
let status = response.status();
tracing::error!(
extension = %name,
url = %url,
status = %status,
"Download returned non-success HTTP status"
);
return Err(ExtensionError::DownloadFailed(format!(
"HTTP {} from {}",
status, url
)));
}
// Check Content-Length header before downloading the full body
if let Some(len) = response.content_length()
&& len as usize > MAX_DOWNLOAD_SIZE
{
return Err(ExtensionError::InstallFailed(format!(
"Download too large ({} bytes, max {} bytes)",
len, MAX_DOWNLOAD_SIZE
)));
}
let bytes = response
.bytes()
.await
.map_err(|e| ExtensionError::DownloadFailed(e.to_string()))?;
if bytes.len() > MAX_DOWNLOAD_SIZE {
return Err(ExtensionError::InstallFailed(format!(
"Download too large ({} bytes, max {} bytes)",
bytes.len(),
MAX_DOWNLOAD_SIZE
)));
}
// Ensure target directory exists
tokio::fs::create_dir_all(target_dir)
.await
.map_err(|e| ExtensionError::InstallFailed(e.to_string()))?;
let wasm_path = target_dir.join(format!("{}.wasm", name));
let caps_path = target_dir.join(format!("{}.capabilities.json", name));
// Detect format: gzip (tar.gz bundle) or bare WASM
if bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b {
// tar.gz bundle: extract {name}.wasm and {name}.capabilities.json
self.extract_wasm_tar_gz(name, &bytes, &wasm_path, &caps_path)?;
} else {
// Bare WASM file: validate magic number
if bytes.len() < 4 || &bytes[..4] != b"\0asm" {
return Err(ExtensionError::InstallFailed(
"Downloaded file is not a valid WASM binary (bad magic number)".to_string(),
));
}
tokio::fs::write(&wasm_path, &bytes)
.await
.map_err(|e| ExtensionError::InstallFailed(e.to_string()))?;
// Download capabilities separately if URL provided
if let Some(caps_url) = capabilities_url {
const MAX_CAPS_SIZE: usize = 1024 * 1024; // 1 MB
match client.get(caps_url).send().await {
Ok(resp) if resp.status().is_success() => match resp.bytes().await {
Ok(caps_bytes) if caps_bytes.len() <= MAX_CAPS_SIZE => {
if let Err(e) = tokio::fs::write(&caps_path, &caps_bytes).await {
tracing::warn!(
"Failed to write capabilities for '{}': {}",
name,
e
);
}
}
Ok(caps_bytes) => {
tracing::warn!(
"Capabilities file for '{}' too large ({} bytes, max {})",
name,
caps_bytes.len(),
MAX_CAPS_SIZE
);
}
Err(e) => {
tracing::warn!("Failed to download capabilities for '{}': {}", name, e);
}
},
_ => {
tracing::warn!(
"Failed to download capabilities for '{}' from {}",
name,
caps_url
);
}
}
}
}
tracing::info!(
"Installed WASM extension '{}' from {} to {}",
name,
url,
wasm_path.display()
);
Ok(())
}
/// Extract a tar.gz bundle into the WASM tools directory.
fn extract_wasm_tar_gz(
&self,
name: &str,
bytes: &[u8],
target_wasm: &std::path::Path,
target_caps: &std::path::Path,
) -> Result<(), ExtensionError> {
use flate2::read::GzDecoder;
use tar::Archive;
use std::io::Read as _;
let decoder = GzDecoder::new(bytes);
let mut archive = Archive::new(decoder);
// Defense-in-depth: do not preserve permissions or extended attributes
archive.set_preserve_permissions(false);
#[cfg(any(unix, target_os = "redox"))]
archive.set_unpack_xattrs(false);
// 100 MB cap on decompressed entry size to prevent decompression bombs
const MAX_ENTRY_SIZE: u64 = 100 * 1024 * 1024;
let wasm_filename = format!("{}.wasm", name);
let caps_filename = format!("{}.capabilities.json", name);
let mut found_wasm = false;
let entries = archive
.entries()
.map_err(|e| ExtensionError::InstallFailed(format!("Bad tar.gz archive: {}", e)))?;
for entry in entries {
let mut entry = entry
.map_err(|e| ExtensionError::InstallFailed(format!("Bad tar.gz entry: {}", e)))?;
if entry.size() > MAX_ENTRY_SIZE {
return Err(ExtensionError::InstallFailed(format!(
"Archive entry too large ({} bytes, max {} bytes)",
entry.size(),
MAX_ENTRY_SIZE
)));
}
let entry_path = entry
.path()
.map_err(|e| {
ExtensionError::InstallFailed(format!("Invalid path in tar.gz: {}", e))
})?
.to_path_buf();
let filename = entry_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("");
if filename == wasm_filename {
let mut data = Vec::with_capacity(entry.size() as usize);
std::io::Read::read_to_end(&mut entry.by_ref().take(MAX_ENTRY_SIZE), &mut data)
.map_err(|e| ExtensionError::InstallFailed(e.to_string()))?;
std::fs::write(target_wasm, &data)
.map_err(|e| ExtensionError::InstallFailed(e.to_string()))?;
found_wasm = true;
} else if filename == caps_filename {
let mut data = Vec::with_capacity(entry.size() as usize);
std::io::Read::read_to_end(&mut entry.by_ref().take(MAX_ENTRY_SIZE), &mut data)
.map_err(|e| ExtensionError::InstallFailed(e.to_string()))?;
std::fs::write(target_caps, &data)
.map_err(|e| ExtensionError::InstallFailed(e.to_string()))?;
}
}
if !found_wasm {
return Err(ExtensionError::InstallFailed(format!(
"tar.gz archive does not contain '{}'",
wasm_filename
)));
}
Ok(())
}
#[allow(dead_code)] // Used by upcoming hot-activation flow
async fn install_bundled_channel_from_artifacts(
&self,
name: &str,
) -> Result<InstallResult, ExtensionError> {
// 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<InstallResult, ExtensionError> {
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,
token: Option<&str>,
) -> Result<AuthResult, ExtensionError> {
let server = self
.get_mcp_server(name)
.await
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
// If a token was provided directly, store it and we're done.
if let Some(token_value) = token {
let secret_name = server.token_secret_name();
let params =
CreateSecretParams::new(&secret_name, token_value).with_provider(name.to_string());
self.secrets
.create(&self.user_id, params)
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
tracing::info!("MCP server '{}' authenticated via manual token", name);
return Ok(AuthResult::authenticated(name, ExtensionKind::McpServer));
}
// Check if already authenticated
if is_authenticated(&server, &self.secrets, &self.user_id).await {
return Ok(AuthResult::authenticated(name, ExtensionKind::McpServer));
}
// Run the full OAuth flow (opens browser, waits for callback)
match authorize_mcp_server(&server, &self.secrets, &self.user_id).await {
Ok(_token) => {
tracing::info!("MCP server '{}' authenticated via OAuth", name);
Ok(AuthResult::authenticated(name, ExtensionKind::McpServer))
}
Err(crate::tools::mcp::auth::AuthError::NotSupported) => {
// Server doesn't support OAuth, try building a URL first
match self.auth_mcp_build_url(name, &server).await {
Ok(result) => Ok(result),
Err(_) => {
// No OAuth, no DCR: fall back to manual token entry
Ok(AuthResult::awaiting_token(
name,
ExtensionKind::McpServer,
format!(
"Server '{}' does not support OAuth. \
Please provide an API token/key for this server.",
name
),
None,
))
}
}
}
Err(e) => {
// OAuth failed for some other reason, fall back to manual token
Ok(AuthResult::awaiting_token(
name,
ExtensionKind::McpServer,
format!(
"OAuth failed for '{}': {}. \
Please provide an API token/key manually.",
name, e
),
None,
))
}
}
}
/// Build an auth URL for cases where non-interactive auth is needed
/// (e.g., running via Telegram where we can't open a browser).
async fn auth_mcp_build_url(
&self,
name: &str,
server: &McpServerConfig,
) -> Result<AuthResult, ExtensionError> {
// Try to discover OAuth metadata and build a URL the user can open manually
let metadata = discover_full_oauth_metadata(&server.url)
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
// Try DCR if no client_id configured
let (client_id, redirect_uri) = if let Some(ref oauth) = server.oauth {
let port = find_available_port()
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
let redirect = format!("http://localhost:{}/callback", port.1);
(oauth.client_id.clone(), redirect)
} else if let Some(ref reg_endpoint) = metadata.registration_endpoint {
let port = find_available_port()
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
let redirect = format!("http://localhost:{}/callback", port.1);
let registration = register_client(reg_endpoint, &redirect)
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
(registration.client_id, redirect)
} else {
return Err(ExtensionError::AuthFailed(
"Server doesn't support OAuth or Dynamic Client Registration".to_string(),
));
};
let pkce = PkceChallenge::generate();
let auth_url = build_authorization_url(
&metadata.authorization_endpoint,
&client_id,
&redirect_uri,
&metadata.scopes_supported,
Some(&pkce),
&std::collections::HashMap::new(),
);
// Store pending auth for later callback handling
self.pending_auth.write().await.insert(
name.to_string(),
PendingAuth {
_name: name.to_string(),
_kind: ExtensionKind::McpServer,
created_at: std::time::Instant::now(),
task_handle: None,
},
);
Ok(AuthResult::awaiting_authorization(
name,
ExtensionKind::McpServer,
auth_url,
"local".to_string(),
))
}
async fn auth_wasm_tool(
&self,
name: &str,
token: Option<&str>,
) -> Result<AuthResult, ExtensionError> {
// Read the capabilities file to get auth config
let cap_path = self
.wasm_tools_dir
.join(format!("{}.capabilities.json", name));
if !cap_path.exists() {
return Ok(AuthResult::no_auth_required(name, ExtensionKind::WasmTool));
}
let cap_bytes = tokio::fs::read(&cap_path)
.await
.map_err(|e| ExtensionError::Other(e.to_string()))?;
let cap_file = crate::tools::wasm::CapabilitiesFile::from_bytes(&cap_bytes)
.map_err(|e| ExtensionError::Other(e.to_string()))?;
let auth = match cap_file.auth {
Some(auth) => auth,
None => {
return Ok(AuthResult::no_auth_required(name, ExtensionKind::WasmTool));
}
};
// Check env var first
if let Some(ref env_var) = auth.env_var
&& let Ok(value) = std::env::var(env_var)
{
// Store the env var value as a secret
let params =
CreateSecretParams::new(&auth.secret_name, &value).with_provider(name.to_string());
self.secrets
.create(&self.user_id, params)
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
return Ok(AuthResult::authenticated(name, ExtensionKind::WasmTool));
}
// Check if already authenticated (with scope expansion detection)
let token_exists = self
.secrets
.exists(&self.user_id, &auth.secret_name)
.await
.unwrap_or(false);
if token_exists {
// If this tool has OAuth config, check whether new scopes are needed
let needs_reauth = if let Some(ref oauth) = auth.oauth {
let merged = self
.collect_shared_scopes(&auth.secret_name, &oauth.scopes)
.await;
let needs = self.needs_scope_expansion(&auth.secret_name, &merged).await;
tracing::debug!(
tool = name,
secret_name = %auth.secret_name,
merged_scopes = ?merged,
needs_reauth = needs,
"Scope expansion check"
);
needs
} else {
false
};
if !needs_reauth {
return Ok(AuthResult::authenticated(name, ExtensionKind::WasmTool));
}
// Fall through to OAuth branch for scope expansion
}
// If a token was provided, store it
if let Some(token_value) = token {
let params = CreateSecretParams::new(&auth.secret_name, token_value)
.with_provider(name.to_string());
self.secrets
.create(&self.user_id, params)
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
return Ok(AuthResult::authenticated(name, ExtensionKind::WasmTool));
}
// OAuth flow: if the tool has OAuth config, start the browser-based flow.
// But only if credentials are available — if the tool has setup secrets
// for client_id/secret that aren't configured yet, return needs_setup.
if let Some(ref oauth) = auth.oauth {
if self.needs_setup_credentials(name, &auth, oauth).await {
let display = auth.display_name.as_deref().unwrap_or(name);
return Ok(AuthResult::needs_setup(
name,
ExtensionKind::WasmTool,
format!(
"Configure OAuth credentials for {} in the Setup tab.",
display
),
auth.setup_url.clone(),
));
}
return self
.start_wasm_oauth(name, &auth, oauth)
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()));
}
// Return instructions for manual token entry
let display = auth.display_name.unwrap_or_else(|| name.to_string());
let instructions = auth
.instructions
.unwrap_or_else(|| format!("Please provide your {} API token/key.", display));
Ok(AuthResult::awaiting_token(
name,
ExtensionKind::WasmTool,
instructions,
auth.setup_url,
))
}
/// Determine the auth readiness of a WASM channel.
async fn check_channel_auth_status(&self, name: &str) -> ToolAuthState {
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
let Ok(cap_bytes) = tokio::fs::read(&cap_path).await else {
return ToolAuthState::NoAuth;
};
let Ok(cap_file) = crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
else {
return ToolAuthState::NoAuth;
};
let required: Vec<_> = cap_file
.setup
.required_secrets
.iter()
.filter(|s| !s.optional)
.collect();
if required.is_empty() {
return ToolAuthState::NoAuth;
}
let all_provided = futures::future::join_all(
required
.iter()
.map(|s| self.secrets.exists(&self.user_id, &s.name)),
)
.await
.into_iter()
.all(|r| r.unwrap_or(false));
if all_provided {
ToolAuthState::Ready
} else {
ToolAuthState::NeedsSetup
}
}
/// Load and parse a WASM tool's capabilities file.
///
/// Returns `None` if the file doesn't exist or can't be parsed.
async fn load_tool_capabilities(
&self,
name: &str,
) -> Option<crate::tools::wasm::CapabilitiesFile> {
let cap_path = self
.wasm_tools_dir
.join(format!("{}.capabilities.json", name));
let cap_bytes = tokio::fs::read(&cap_path).await.ok()?;
crate::tools::wasm::CapabilitiesFile::from_bytes(&cap_bytes).ok()
}
/// Collect merged OAuth scopes from all installed tools sharing the same secret_name.
///
/// When multiple tools share an OAuth provider (e.g., google-calendar and google-drive
/// both use `google_oauth_token`), we request all their scopes in a single OAuth flow
/// so one login covers everything.
async fn collect_shared_scopes(
&self,
secret_name: &str,
base_scopes: &[String],
) -> Vec<String> {
let mut all_scopes: std::collections::BTreeSet<String> =
base_scopes.iter().cloned().collect();
if let Ok(tools) = discover_tools(&self.wasm_tools_dir).await {
for tool_name in tools.keys() {
if let Some(cap) = self.load_tool_capabilities(tool_name).await
&& let Some(auth) = &cap.auth
&& auth.secret_name == secret_name
&& let Some(oauth) = &auth.oauth
{
all_scopes.extend(oauth.scopes.iter().cloned());
}
}
}
all_scopes.into_iter().collect()
}
/// Check whether the stored scopes are insufficient for the merged scopes.
async fn needs_scope_expansion(&self, secret_name: &str, merged_scopes: &[String]) -> bool {
if merged_scopes.is_empty() {
return false;
}
let scopes_key = format!("{}_scopes", secret_name);
let stored_scopes: std::collections::HashSet<String> =
match self.secrets.get_decrypted(&self.user_id, &scopes_key).await {
Ok(secret) => {
let scopes: std::collections::HashSet<String> = secret
.expose()
.split_whitespace()
.map(String::from)
.collect();
tracing::debug!(
secret_name,
stored_scopes = ?scopes,
"Loaded stored scopes for expansion check"
);
scopes
}
Err(_) => {
// No stored scopes record — this is a legacy token created before
// scope tracking. Force re-auth to ensure all required scopes are granted.
tracing::debug!(
secret_name,
"No stored scopes record, forcing re-auth for legacy token"
);
return true;
}
};
// Check if any merged scope is missing from stored scopes
merged_scopes
.iter()
.any(|scope| !stored_scopes.contains(scope))
}
/// Find the setup secret names for OAuth client_id and client_secret.
///
/// Scans `setup.required_secrets` for names containing "client_id" and "client_secret".
/// Returns `(Option<(name, optional)>, Option<(name, optional)>)`.
async fn find_setup_credential_names(
&self,
tool_name: &str,
) -> (Option<(String, bool)>, Option<(String, bool)>) {
let Some(cap) = self.load_tool_capabilities(tool_name).await else {
return (None, None);
};
let Some(setup) = &cap.setup else {
return (None, None);
};
let mut client_id_entry = None;
let mut client_secret_entry = None;
for secret in &setup.required_secrets {
let lower = secret.name.to_lowercase();
if lower.ends_with("client_id") || lower == "client_id" {
client_id_entry = Some((secret.name.clone(), secret.optional));
} else if lower.ends_with("client_secret") || lower == "client_secret" {
client_secret_entry = Some((secret.name.clone(), secret.optional));
}
}
(client_id_entry, client_secret_entry)
}
/// Check if OAuth client credentials (client_id / client_secret) require
/// user input via the Setup tab. Returns `true` when at least one required
/// credential cannot be resolved through the full chain:
/// secrets store → inline → env var → builtin.
async fn needs_setup_credentials(
&self,
name: &str,
auth: &crate::tools::wasm::AuthCapabilitySchema,
oauth: &crate::tools::wasm::OAuthConfigSchema,
) -> bool {
let builtin = crate::cli::oauth_defaults::builtin_credentials(&auth.secret_name);
let (id_entry, secret_entry) = self.find_setup_credential_names(name).await;
for (entry, inline, env, fallback) in [
(
&id_entry,
&oauth.client_id,
&oauth.client_id_env,
builtin.as_ref().map(|c| c.client_id),
),
(
&secret_entry,
&oauth.client_secret,
&oauth.client_secret_env,
builtin.as_ref().map(|c| c.client_secret),
),
] {
let Some((ref setup_name, optional)) = *entry else {
continue;
};
if optional {
continue;
}
let resolved = self
.resolve_oauth_credential(inline, env, fallback, Some(setup_name))
.await
.is_some();
if !resolved {
return true;
}
}
false
}
/// Resolve an OAuth credential value via: secrets store → inline → env var → builtin.
///
/// For web gateway users, the secrets store is checked first because client_id/secret
/// may have been entered via the Setup tab (stored as setup secrets).
async fn resolve_oauth_credential(
&self,
inline_value: &Option<String>,
env_var_name: &Option<String>,
builtin_value: Option<&str>,
setup_secret_name: Option<&str>,
) -> Option<String> {
// 1. Check secrets store (entered via Setup tab)
if let Some(secret_name) = setup_secret_name
&& let Ok(secret) = self.secrets.get_decrypted(&self.user_id, secret_name).await
{
let val = secret.expose();
if !val.is_empty() {
return Some(val.to_string());
}
}
// 2. Inline value from capabilities.json
if let Some(val) = inline_value {
return Some(val.clone());
}
// 3. Runtime environment variable
if let Some(env) = env_var_name
&& let Ok(val) = std::env::var(env)
{
return Some(val);
}
// 4. Built-in defaults
builtin_value.map(String::from)
}
/// Start the OAuth browser flow for a WASM tool.
///
/// Binds a callback listener, builds the authorization URL, spawns a background
/// task to wait for the callback and exchange the code, then returns the auth URL
/// immediately so the web UI can open it.
async fn start_wasm_oauth(
&self,
name: &str,
auth: &crate::tools::wasm::AuthCapabilitySchema,
oauth: &crate::tools::wasm::OAuthConfigSchema,
) -> Result<AuthResult, String> {
use crate::cli::oauth_defaults;
let builtin = oauth_defaults::builtin_credentials(&auth.secret_name);
// Find setup secret names for client_id and client_secret from capabilities.
// These are the actual names used in the Setup tab (e.g., "google_oauth_client_id"),
// which may differ from "{secret_name}_client_id".
let (setup_client_id_entry, setup_client_secret_entry) =
self.find_setup_credential_names(name).await;
let setup_client_id_name = setup_client_id_entry.map(|(n, _)| n);
let setup_client_secret_name = setup_client_secret_entry.map(|(n, _)| n);
// Resolve client_id: setup secrets → inline → env var → builtin
let client_id = self
.resolve_oauth_credential(
&oauth.client_id,
&oauth.client_id_env,
builtin.as_ref().map(|c| c.client_id),
setup_client_id_name.as_deref(),
)
.await
.ok_or_else(|| {
let env_name = oauth
.client_id_env
.as_deref()
.unwrap_or("the client_id env var");
let mut msg = format!(
"OAuth client_id not configured for '{}'. \
Enter it in the Setup tab or set {} env var",
name, env_name
);
// Only mention the Google-specific build flag for Google providers
if auth.secret_name.to_lowercase().contains("google") {
msg.push_str(", or build with IRONCLAW_GOOGLE_CLIENT_ID");
}
msg.push('.');
msg
})?;
// Resolve client_secret (optional for PKCE-only flows)
let client_secret = self
.resolve_oauth_credential(
&oauth.client_secret,
&oauth.client_secret_env,
builtin.as_ref().map(|c| c.client_secret),
setup_client_secret_name.as_deref(),
)
.await;
// Cancel any existing pending auth for this tool (frees port 9876 in TCP mode)
{
let mut pending = self.pending_auth.write().await;
if let Some(old) = pending.remove(name)
&& let Some(handle) = old.task_handle
{
handle.abort();
}
}
// Also clean up any gateway-mode pending flows for this tool
{
let mut flows = self.pending_oauth_flows.write().await;
flows.retain(|_, flow| flow.extension_name != name);
}
let redirect_uri = format!("{}/callback", oauth_defaults::callback_url());
// Merge scopes from all tools sharing this provider
let merged_scopes = self
.collect_shared_scopes(&auth.secret_name, &oauth.scopes)
.await;
// Build authorization URL with CSRF state
let oauth_result = oauth_defaults::build_oauth_url(
&oauth.authorization_url,
&client_id,
&redirect_uri,
&merged_scopes,
oauth.use_pkce,
&oauth.extra_params,
);
let auth_url = oauth_result.url.clone();
let code_verifier = oauth_result.code_verifier;
let expected_state = oauth_result.state;
let display_name = auth
.display_name
.clone()
.unwrap_or_else(|| name.to_string());
if oauth_defaults::use_gateway_callback() {
// Gateway mode: store pending flow state for the web gateway's
// `/oauth/callback` handler to complete the exchange. No TCP listener
// needed — the OAuth provider redirects to the gateway URL.
oauth_defaults::sweep_expired_flows(&self.pending_oauth_flows).await;
// Wrap the CSRF nonce with instance name for platform routing.
// Nginx at auth.DOMAIN parses `instance:nonce` to route the callback
// to the correct container. The flow is keyed by the raw nonce.
let platform_state = oauth_defaults::build_platform_state(&expected_state);
let auth_url = if platform_state != expected_state {
auth_url.replace(
&format!("state={}", urlencoding::encode(&expected_state)),
&format!("state={}", urlencoding::encode(&platform_state)),
)
} else {
auth_url
};
let flow = oauth_defaults::PendingOAuthFlow {
extension_name: name.to_string(),
display_name: display_name.clone(),
token_url: oauth.token_url.clone(),
client_id: client_id.clone(),
client_secret: client_secret.clone(),
redirect_uri: redirect_uri.clone(),
code_verifier,
access_token_field: oauth.access_token_field.clone(),
secret_name: auth.secret_name.clone(),
provider: auth.provider.clone(),
validation_endpoint: auth.validation_endpoint.clone(),
scopes: merged_scopes,
user_id: self.user_id.clone(),
secrets: Arc::clone(&self.secrets),
sse_sender: self.sse_sender.read().await.clone(),
gateway_token: self.gateway_token.clone(),
created_at: std::time::Instant::now(),
};
// Key by raw nonce (without instance prefix) — the callback handler
// strips the prefix before lookup.
self.pending_oauth_flows
.write()
.await
.insert(expected_state, flow);
// Register pending auth without a task handle (gateway handles completion)
self.pending_auth.write().await.insert(
name.to_string(),
PendingAuth {
_name: name.to_string(),
_kind: ExtensionKind::WasmTool,
created_at: std::time::Instant::now(),
task_handle: None,
},
);
Ok(AuthResult::awaiting_authorization(
name,
ExtensionKind::WasmTool,
auth_url,
"gateway".to_string(),
))
} else {
// TCP listener mode: bind port 9876 and spawn a background task
// to wait for the callback. This is the original flow for local/desktop use.
let listener = oauth_defaults::bind_callback_listener()
.await
.map_err(|e| format!("Failed to start OAuth callback listener: {}", e))?;
let token_url = oauth.token_url.clone();
let access_token_field = oauth.access_token_field.clone();
let secret_name = auth.secret_name.clone();
let provider = auth.provider.clone();
let validation_endpoint = auth.validation_endpoint.clone();
let user_id = self.user_id.clone();
let secrets = Arc::clone(&self.secrets);
let sse_sender = self.sse_sender.read().await.clone();
let ext_name = name.to_string();
let task_handle = tokio::spawn(async move {
let result: Result<(), String> = async {
let code = oauth_defaults::wait_for_callback(
listener,
"/callback",
"code",
&display_name,
Some(&expected_state),
)
.await
.map_err(|e| e.to_string())?;
let token_response = oauth_defaults::exchange_oauth_code(
&token_url,
&client_id,
client_secret.as_deref(),
&code,
&redirect_uri,
code_verifier.as_deref(),
&access_token_field,
)
.await
.map_err(|e| e.to_string())?;
// Validate the token before storing (catches wrong account, etc.)
if let Some(ref validation) = validation_endpoint {
oauth_defaults::validate_oauth_token(
&token_response.access_token,
validation,
)
.await
.map_err(|e| e.to_string())?;
}
oauth_defaults::store_oauth_tokens(
secrets.as_ref(),
&user_id,
&secret_name,
provider.as_deref(),
&token_response.access_token,
token_response.refresh_token.as_deref(),
token_response.expires_in,
&merged_scopes,
)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
.await;
// Broadcast SSE event
let (success, message) = match result {
Ok(()) => (true, format!("{} authenticated successfully", display_name)),
Err(ref e) => (
false,
format!("{} authentication failed: {}", display_name, e),
),
};
match &result {
Ok(()) => {
tracing::info!(
tool = %ext_name,
"OAuth completed successfully"
);
}
Err(e) => {
tracing::warn!(
tool = %ext_name,
error = %e,
"WASM tool OAuth failed"
);
}
}
if let Some(ref sender) = sse_sender {
let _ = sender.send(crate::channels::web::types::SseEvent::AuthCompleted {
extension_name: ext_name,
success,
message,
});
}
});
// Store pending auth with task handle
self.pending_auth.write().await.insert(
name.to_string(),
PendingAuth {
_name: name.to_string(),
_kind: ExtensionKind::WasmTool,
created_at: std::time::Instant::now(),
task_handle: Some(task_handle),
},
);
Ok(AuthResult::awaiting_authorization(
name,
ExtensionKind::WasmTool,
auth_url,
"local".to_string(),
))
}
}
/// Returns `true` if a setup secret is an OAuth credential (client_id or client_secret)
/// that can be resolved without user input — via inline capabilities, env var, or
/// builtin defaults.
///
/// Used by `check_tool_auth_status()` and `get_setup_schema()` to hide setup fields
/// that the user doesn't need to fill (e.g., Google tools with builtin credentials).
fn is_auto_resolved_oauth_field(
secret_name: &str,
cap_file: &crate::tools::wasm::CapabilitiesFile,
) -> bool {
let lower = secret_name.to_lowercase();
let is_client_id = lower.ends_with("client_id") || lower == "client_id";
let is_client_secret = lower.ends_with("client_secret") || lower == "client_secret";
if !is_client_id && !is_client_secret {
return false;
}
let Some(ref auth) = cap_file.auth else {
return false;
};
let Some(ref oauth) = auth.oauth else {
return false;
};
let builtin = crate::cli::oauth_defaults::builtin_credentials(&auth.secret_name);
if is_client_id {
oauth.client_id.is_some()
|| oauth
.client_id_env
.as_ref()
.is_some_and(|e| std::env::var(e).is_ok())
|| builtin.is_some()
} else {
oauth.client_secret.is_some()
|| oauth
.client_secret_env
.as_ref()
.is_some_and(|e| std::env::var(e).is_ok())
|| builtin.is_some()
}
}
/// Determine the auth readiness of a WASM tool.
async fn check_tool_auth_status(&self, name: &str) -> ToolAuthState {
let Some(cap_file) = self.load_tool_capabilities(name).await else {
return ToolAuthState::NoAuth;
};
// If the tool declares an auth section, the access token is the
// authoritative signal — setup secrets (client_id/secret) are
// intermediate and may be auto-resolved via builtins.
if let Some(ref auth) = cap_file.auth {
let has_token = self
.secrets
.exists(&self.user_id, &auth.secret_name)
.await
.unwrap_or(false)
|| auth
.env_var
.as_ref()
.is_some_and(|v| std::env::var(v).is_ok());
return if has_token {
ToolAuthState::Ready
} else if auth.oauth.is_some() {
ToolAuthState::NeedsAuth
} else {
ToolAuthState::NeedsSetup
};
}
// No auth section — fall back to checking setup.required_secrets.
let Some(setup) = &cap_file.setup else {
return ToolAuthState::NoAuth;
};
if setup.required_secrets.is_empty() {
return ToolAuthState::NoAuth;
}
let all_provided = futures::future::join_all(
setup
.required_secrets
.iter()
.filter(|s| !s.optional)
.filter(|s| !Self::is_auto_resolved_oauth_field(&s.name, &cap_file))
.map(|s| self.secrets.exists(&self.user_id, &s.name)),
)
.await
.into_iter()
.all(|r| r.unwrap_or(false));
if all_provided {
ToolAuthState::Ready
} else {
ToolAuthState::NeedsSetup
}
}
async fn auth_wasm_channel(
&self,
name: &str,
token: Option<&str>,
) -> Result<AuthResult, ExtensionError> {
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
if !cap_path.exists() {
return Ok(AuthResult::no_auth_required(
name,
ExtensionKind::WasmChannel,
));
}
let cap_bytes = tokio::fs::read(&cap_path)
.await
.map_err(|e| ExtensionError::Other(e.to_string()))?;
let cap_file = crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
.map_err(|e| ExtensionError::Other(e.to_string()))?;
// Get required secrets from the setup section
let required_secrets = &cap_file.setup.required_secrets;
if required_secrets.is_empty() {
return Ok(AuthResult::no_auth_required(
name,
ExtensionKind::WasmChannel,
));
}
// Find the first non-optional secret that isn't yet stored
let mut missing = Vec::new();
for secret in required_secrets {
if secret.optional {
continue;
}
if !self
.secrets
.exists(&self.user_id, &secret.name)
.await
.unwrap_or(false)
{
missing.push(secret);
}
}
if missing.is_empty() {
return Ok(AuthResult::authenticated(name, ExtensionKind::WasmChannel));
}
// If a token was provided, store it for the first missing secret
if let Some(token_value) = token {
let secret = &missing[0];
let params =
CreateSecretParams::new(&secret.name, token_value).with_provider(name.to_string());
self.secrets
.create(&self.user_id, params)
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
// Check if there are more missing secrets
if missing.len() <= 1 {
return Ok(AuthResult::authenticated(name, ExtensionKind::WasmChannel));
}
// More secrets needed; prompt for the next one
let next = &missing[1];
return Ok(AuthResult::awaiting_token(
name,
ExtensionKind::WasmChannel,
next.prompt.clone(),
cap_file.setup.setup_url.clone(),
));
}
// Prompt for the first missing secret
let secret = &missing[0];
Ok(AuthResult::awaiting_token(
name,
ExtensionKind::WasmChannel,
secret.prompt.clone(),
cap_file.setup.setup_url.clone(),
))
}
async fn activate_mcp(&self, name: &str) -> Result<ActivateResult, ExtensionError> {
// Check if already activated
{
let clients = self.mcp_clients.read().await;
if clients.contains_key(name) {
// Already connected, just return the tool names
let tools: Vec<String> = self
.tool_registry
.list()
.await
.into_iter()
.filter(|t| t.starts_with(&format!("{}_", name)))
.collect();
return Ok(ActivateResult {
name: name.to_string(),
kind: ExtensionKind::McpServer,
tools_loaded: tools,
message: format!("MCP server '{}' already active", name),
});
}
}
let server = self
.get_mcp_server(name)
.await
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
let has_tokens = is_authenticated(&server, &self.secrets, &self.user_id).await;
let client = if has_tokens || server.requires_auth() {
McpClient::new_authenticated(
server.clone(),
Arc::clone(&self.mcp_session_manager),
Arc::clone(&self.secrets),
&self.user_id,
)
} else {
McpClient::new_with_name(&server.name, &server.url)
};
// Try to list and create tools
let mcp_tools = client
.list_tools()
.await
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
let tool_impls = client
.create_tools()
.await
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
let tool_names: Vec<String> = mcp_tools
.iter()
.map(|t| format!("{}_{}", name, t.name))
.collect();
for tool in tool_impls {
self.tool_registry.register(tool).await;
}
// Store the client
self.mcp_clients
.write()
.await
.insert(name.to_string(), Arc::new(client));
tracing::info!(
"Activated MCP server '{}' with {} tools",
name,
tool_names.len()
);
Ok(ActivateResult {
name: name.to_string(),
kind: ExtensionKind::McpServer,
tools_loaded: tool_names,
message: format!("Connected to '{}' and loaded tools", name),
})
}
async fn activate_wasm_tool(&self, name: &str) -> Result<ActivateResult, ExtensionError> {
// Check if already active
if self.tool_registry.has(name).await {
return Ok(ActivateResult {
name: name.to_string(),
kind: ExtensionKind::WasmTool,
tools_loaded: vec![name.to_string()],
message: format!("WASM tool '{}' already active", name),
});
}
let runtime = self.wasm_tool_runtime.as_ref().ok_or_else(|| {
ExtensionError::ActivationFailed("WASM runtime not available".to_string())
})?;
let wasm_path = self.wasm_tools_dir.join(format!("{}.wasm", name));
if !wasm_path.exists() {
return Err(ExtensionError::NotInstalled(format!(
"WASM tool '{}' not found at {}",
name,
wasm_path.display()
)));
}
let cap_path = self
.wasm_tools_dir
.join(format!("{}.capabilities.json", name));
let cap_path_option = if cap_path.exists() {
Some(cap_path.as_path())
} else {
None
};
let loader = WasmToolLoader::new(Arc::clone(runtime), Arc::clone(&self.tool_registry))
.with_secrets_store(Arc::clone(&self.secrets));
loader
.load_from_files(name, &wasm_path, cap_path_option)
.await
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
if let Some(ref hooks) = self.hooks
&& let Some(cap_path) = cap_path_option
{
let source = format!("plugin.tool:{}", name);
let registration =
crate::hooks::bootstrap::register_plugin_bundle_from_capabilities_file(
hooks, &source, cap_path,
)
.await;
if registration.total_registered() > 0 {
tracing::info!(
extension = name,
hooks = registration.hooks,
outbound_webhooks = registration.outbound_webhooks,
"Registered plugin hooks for activated WASM tool"
);
}
if registration.errors > 0 {
tracing::warn!(
extension = name,
errors = registration.errors,
"Some plugin hooks failed to register"
);
}
}
tracing::info!("Activated WASM tool '{}'", name);
Ok(ActivateResult {
name: name.to_string(),
kind: ExtensionKind::WasmTool,
tools_loaded: vec![name.to_string()],
message: format!("WASM tool '{}' loaded and ready", name),
})
}
/// 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<ActivateResult, ExtensionError> {
// 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,
wasm_channel_owner_ids,
) = {
let rt_guard = self.channel_runtime.read().await;
let rt = rt_guard.as_ref().ok_or_else(|| {
ExtensionError::ActivationFailed("WASM channel runtime not configured".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.wasm_channel_owner_ids.clone(),
)
};
// Check auth status first
let auth_state = self.check_channel_auth_status(name).await;
if auth_state != ToolAuthState::Ready && auth_state != ToolAuthState::NoAuth {
return Err(ExtensionError::ActivationFailed(format!(
"Channel '{}' requires configuration. Use the setup form to provide credentials.",
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 settings_store: Option<Arc<dyn crate::db::SettingsStore>> =
self.store.as_ref().map(|db| Arc::clone(db) as _);
let loader = WasmChannelLoader::new(
Arc::clone(&channel_runtime),
Arc::clone(&pairing_store),
settings_store,
)
.with_secrets_store(Arc::clone(&self.secrets));
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());
let sig_key_secret_name = loaded.signature_key_secret_name();
let hmac_secret_name = loaded.hmac_secret_name();
// 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 let Some(&owner_id) = wasm_channel_owner_ids.get(channel_name.as_str()) {
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");
// Register Ed25519 signature key if declared in capabilities
if let Some(ref sig_key_name) = sig_key_secret_name
&& let Ok(key_secret) = self
.secrets
.get_decrypted(&self.user_id, sig_key_name)
.await
{
match wasm_channel_router
.register_signature_key(&channel_name, key_secret.expose())
.await
{
Ok(()) => {
tracing::info!(channel = %channel_name, "Registered signature key for hot-activated channel")
}
Err(e) => {
tracing::error!(channel = %channel_name, error = %e, "Failed to register signature key")
}
}
}
// Register HMAC signing secret if declared in capabilities
if let Some(hmac_name) = &hmac_secret_name {
match self.secrets.get_decrypted(&self.user_id, hmac_name).await {
Ok(secret) => {
wasm_channel_router
.register_hmac_secret(&channel_name, secret.expose())
.await;
tracing::info!(channel = %channel_name, "Registered HMAC signing secret for hot-activated channel");
}
Err(e) => {
tracing::warn!(channel = %channel_name, error = %e, "HMAC secret not found");
}
}
}
}
// 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());
// Persist activation state so the channel auto-activates on restart
self.persist_active_channels().await;
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<ActivateResult, ExtensionError> {
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
}
};
// Load capabilities file once to extract all secret names
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
let capabilities_file = match tokio::fs::read(&cap_path).await {
Ok(bytes) => crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&bytes).ok(),
Err(_) => None,
};
// Extract all secret names from the capabilities file
let webhook_secret_name = capabilities_file
.as_ref()
.map(|f| f.webhook_secret_name())
.unwrap_or_else(|| format!("{}_webhook_secret", name));
let sig_key_secret_name = capabilities_file
.as_ref()
.and_then(|f| f.signature_key_secret_name().map(|s| s.to_string()));
let hmac_secret_name = capabilities_file
.as_ref()
.and_then(|f| f.hmac_secret_name().map(|s| s.to_string()));
// Refresh webhook secret
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 signature key
if let Some(ref sig_key_name) = sig_key_secret_name
&& let Ok(key_secret) = self
.secrets
.get_decrypted(&self.user_id, sig_key_name)
.await
{
match router
.register_signature_key(name, key_secret.expose())
.await
{
Ok(()) => {
tracing::info!(channel = %name, "Refreshed signature verification key")
}
Err(e) => {
tracing::error!(channel = %name, error = %e, "Failed to refresh signature key")
}
}
}
// Refresh HMAC signing secret
if let Some(ref hmac_secret_name_ref) = hmac_secret_name {
match self
.secrets
.get_decrypted(&self.user_id, hmac_secret_name_ref)
.await
{
Ok(secret) => {
router.register_hmac_secret(name, secret.expose()).await;
tracing::info!(channel = %name, "Refreshed HMAC signing secret");
}
Err(e) => {
tracing::warn!(channel = %name, error = %e, "HMAC secret not found");
}
}
}
// 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<ExtensionKind, ExtensionError> {
// Check MCP servers first
if self.get_mcp_server(name).await.is_ok() {
return Ok(ExtensionKind::McpServer);
}
// Check WASM tools
let wasm_path = self.wasm_tools_dir.join(format!("{}.wasm", name));
if wasm_path.exists() {
return Ok(ExtensionKind::WasmTool);
}
// Check WASM channels
let channel_path = self.wasm_channels_dir.join(format!("{}.wasm", name));
if channel_path.exists() {
return Ok(ExtensionKind::WasmChannel);
}
Err(ExtensionError::NotInstalled(format!(
"'{}' is not installed as an MCP server, WASM tool, or WASM channel",
name
)))
}
/// Reject names containing path separators or traversal sequences.
fn validate_extension_name(name: &str) -> Result<(), ExtensionError> {
if name.contains('/') || name.contains('\\') || name.contains("..") || name.contains('\0') {
return Err(ExtensionError::InstallFailed(format!(
"Invalid extension name '{}': contains path separator or traversal characters",
name
)));
}
Ok(())
}
async fn cleanup_expired_auths(&self) {
let mut pending = self.pending_auth.write().await;
pending.retain(|_, auth| {
let expired = auth.created_at.elapsed() >= std::time::Duration::from_secs(300);
if expired {
// Abort the background listener task to free port 9876
if let Some(ref handle) = auth.task_handle {
handle.abort();
}
}
!expired
});
}
/// Get the setup schema for an extension (secret fields and their status).
pub async fn get_setup_schema(
&self,
name: &str,
) -> Result<Vec<crate::channels::web::types::SecretFieldInfo>, ExtensionError> {
let kind = self.determine_installed_kind(name).await?;
match kind {
ExtensionKind::WasmChannel => {
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
if !cap_path.exists() {
return Ok(Vec::new());
}
let cap_bytes = tokio::fs::read(&cap_path)
.await
.map_err(|e| ExtensionError::Other(e.to_string()))?;
let cap_file =
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
.map_err(|e| ExtensionError::Other(e.to_string()))?;
let mut fields = Vec::new();
for secret in &cap_file.setup.required_secrets {
let provided = self
.secrets
.exists(&self.user_id, &secret.name)
.await
.unwrap_or(false);
fields.push(crate::channels::web::types::SecretFieldInfo {
name: secret.name.clone(),
prompt: secret.prompt.clone(),
optional: secret.optional,
provided,
auto_generate: secret.auto_generate.is_some(),
});
}
Ok(fields)
}
ExtensionKind::WasmTool => {
let Some(cap_file) = self.load_tool_capabilities(name).await else {
return Ok(Vec::new());
};
let mut fields = Vec::new();
if let Some(setup) = &cap_file.setup {
for secret in &setup.required_secrets {
// Skip OAuth client_id/secret fields that resolve automatically
if Self::is_auto_resolved_oauth_field(&secret.name, &cap_file) {
continue;
}
let provided = self
.secrets
.exists(&self.user_id, &secret.name)
.await
.unwrap_or(false);
fields.push(crate::channels::web::types::SecretFieldInfo {
name: secret.name.clone(),
prompt: secret.prompt.clone(),
optional: secret.optional,
provided,
auto_generate: false,
});
}
}
Ok(fields)
}
_ => Ok(Vec::new()),
}
}
/// Save setup secrets for an extension, validating names against the capabilities schema.
///
/// After saving, attempts to hot-activate the channel. Returns a [`SetupResult`]
/// indicating whether activation succeeded (so the frontend can show appropriate UI).
pub async fn save_setup_secrets(
&self,
name: &str,
secrets: &std::collections::HashMap<String, String>,
) -> Result<SetupResult, ExtensionError> {
let kind = self.determine_installed_kind(name).await?;
// Load allowed secret names from the extension's capabilities file
let allowed: std::collections::HashSet<String> = match kind {
ExtensionKind::WasmChannel => {
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
if !cap_path.exists() {
return Err(ExtensionError::Other(format!(
"Capabilities file not found for '{}'",
name
)));
}
let cap_bytes = tokio::fs::read(&cap_path)
.await
.map_err(|e| ExtensionError::Other(e.to_string()))?;
let cap_file =
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
.map_err(|e| ExtensionError::Other(e.to_string()))?;
cap_file
.setup
.required_secrets
.iter()
.map(|s| s.name.clone())
.collect()
}
ExtensionKind::WasmTool => {
let cap_file = self.load_tool_capabilities(name).await.ok_or_else(|| {
ExtensionError::Other(format!("Capabilities file not found for '{}'", name))
})?;
match cap_file.setup {
Some(s) => s.required_secrets.iter().map(|s| s.name.clone()).collect(),
None => {
return Err(ExtensionError::Other(format!(
"Tool '{}' has no setup schema — no secrets to configure",
name
)));
}
}
}
_ => {
return Err(ExtensionError::Other(
"Setup is only supported for WASM channels and tools".to_string(),
));
}
};
// For Telegram, validate the bot token against the API before storing it.
// This catches bad tokens immediately (both on first setup and reconfigure),
// before the channel activates and potentially shows as active with a bad token.
if name == "telegram"
&& let Some(token_value) = secrets.get("telegram_bot_token")
{
let token = token_value.trim();
if !token.is_empty() {
let encoded_token =
url::form_urlencoded::byte_serialize(token.as_bytes()).collect::<String>();
let url = format!("https://api.telegram.org/bot{}/getMe", encoded_token);
let resp = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| ExtensionError::Other(e.to_string()))?
.get(&url)
.send()
.await
.map_err(|e| {
ExtensionError::Other(format!("Failed to validate bot token: {}", e))
})?;
if !resp.status().is_success() {
return Err(ExtensionError::Other(format!(
"Invalid bot token (Telegram API returned {})",
resp.status()
)));
}
}
}
// Validate and store each submitted secret
for (secret_name, secret_value) in secrets {
if !allowed.contains(secret_name.as_str()) {
return Err(ExtensionError::Other(format!(
"Unknown secret '{}' for extension '{}'",
secret_name, name
)));
}
if secret_value.trim().is_empty() {
continue;
}
let params =
CreateSecretParams::new(secret_name, secret_value).with_provider(name.to_string());
self.secrets
.create(&self.user_id, params)
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
}
// Auto-generate any missing secrets (channel-only feature)
if kind == ExtensionKind::WasmChannel {
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
if let Ok(cap_bytes) = tokio::fs::read(&cap_path).await
&& let Ok(cap_file) =
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
{
for secret_def in &cap_file.setup.required_secrets {
if let Some(ref auto_gen) = secret_def.auto_generate {
let already_provided = secrets
.get(&secret_def.name)
.is_some_and(|v| !v.trim().is_empty());
let already_stored = self
.secrets
.exists(&self.user_id, &secret_def.name)
.await
.unwrap_or(false);
if !already_provided && !already_stored {
use rand::RngCore;
use rand::rngs::OsRng;
let mut bytes = vec![0u8; auto_gen.length];
OsRng.fill_bytes(&mut bytes);
let hex_value: String =
bytes.iter().map(|b| format!("{b:02x}")).collect();
let params = CreateSecretParams::new(&secret_def.name, &hex_value)
.with_provider(name.to_string());
self.secrets
.create(&self.user_id, params)
.await
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
tracing::info!(
"Auto-generated secret '{}' for channel '{}'",
secret_def.name,
name
);
}
}
}
}
}
// For tools, save and attempt auto-activation, then check auth.
if kind == ExtensionKind::WasmTool {
match self.activate_wasm_tool(name).await {
Ok(result) => {
// Delete existing OAuth token so auth() starts a fresh flow.
// Done AFTER activation succeeds to avoid losing tokens on failure.
// This covers Reconfigure: user wants to re-auth (switch account, update creds).
if let Some(cap) = self.load_tool_capabilities(name).await
&& let Some(ref auth_cfg) = cap.auth
&& auth_cfg.oauth.is_some()
{
let _ = self
.secrets
.delete(&self.user_id, &auth_cfg.secret_name)
.await;
let _ = self
.secrets
.delete(&self.user_id, &format!("{}_scopes", auth_cfg.secret_name))
.await;
let _ = self
.secrets
.delete(
&self.user_id,
&format!("{}_refresh_token", auth_cfg.secret_name),
)
.await;
}
// Check if auth is needed (OAuth or manual token).
// This is safe to call here — cancel-and-retry prevents port conflicts.
let mut auth_url = None;
if let Ok(auth_result) = self.auth(name, None).await {
auth_url = auth_result.auth_url().map(String::from);
}
let message = if auth_url.is_some() {
format!(
"Configuration saved and tool '{}' activated. Complete OAuth in your browser.",
name
)
} else {
format!(
"Configuration saved and tool '{}' activated. {}",
name, result.message
)
};
return Ok(SetupResult {
message,
activated: true,
auth_url,
});
}
Err(e) => {
tracing::debug!(
"Auto-activation of tool '{}' after setup failed: {}",
name,
e
);
return Ok(SetupResult {
message: format!("Configuration saved for '{}'.", name),
activated: false,
auth_url: None,
});
}
}
}
// Try to hot-activate the channel now that secrets are saved
match self.activate_wasm_channel(name).await {
Ok(result) => {
self.activation_errors.write().await.remove(name);
self.broadcast_extension_status(name, "active", None).await;
Ok(SetupResult {
message: format!(
"Configuration saved and channel '{}' activated. {}",
name, result.message
),
activated: true,
auth_url: None,
})
}
Err(e) => {
let error_msg = e.to_string();
tracing::warn!(
channel = name,
error = %e,
"Saved configuration but hot-activation failed"
);
self.activation_errors
.write()
.await
.insert(name.to_string(), error_msg.clone());
self.broadcast_extension_status(name, "failed", Some(&error_msg))
.await;
Ok(SetupResult {
message: format!(
"Configuration saved for '{}'. Activation failed: {}",
name, e
),
activated: false,
auth_url: None,
})
}
}
}
/// Read a capabilities.json file and revoke its credential mappings from
/// the shared credential registry, so removed extensions lose injection
/// authority immediately.
async fn revoke_credential_mappings(&self, cap_path: &std::path::Path) {
if !cap_path.exists() {
return;
}
let Ok(bytes) = tokio::fs::read(cap_path).await else {
return;
};
// Extract secret names from the capabilities JSON.
// Structure: { "http": { "credentials": { "<key>": { "secret_name": "..." } } } }
let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
return;
};
let secret_names: Vec<String> = json
.get("http")
.and_then(|h| h.get("credentials"))
.and_then(|c| c.as_object())
.map(|creds| {
creds
.values()
.filter_map(|v| v.get("secret_name").and_then(|s| s.as_str()))
.map(String::from)
.collect()
})
.unwrap_or_default();
if secret_names.is_empty() {
return;
}
if let Some(cr) = self.tool_registry.credential_registry() {
cr.remove_mappings_for_secrets(&secret_names);
tracing::info!(
secrets = ?secret_names,
"Revoked credential mappings for removed extension"
);
}
}
async fn unregister_hook_prefix(&self, prefix: &str) -> usize {
let Some(ref hooks) = self.hooks else {
return 0;
};
let names = hooks.list().await;
let mut removed = 0;
for hook_name in names {
if hook_name.starts_with(prefix) && hooks.unregister(&hook_name).await {
removed += 1;
}
}
removed
}
}
/// 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<crate::channels::wasm::WasmChannel>,
secrets: &dyn SecretsStore,
channel_name: &str,
user_id: &str,
) -> Result<usize, String> {
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") {
ExtensionKind::WasmTool
} else {
ExtensionKind::McpServer
}
}
/// Decision from `fallback_decision`: should we try the fallback source or
/// return the primary result as-is?
enum FallbackDecision {
/// Return the primary result directly (success or non-retriable error).
Return,
/// Primary failed with a retriable error and a fallback source is available.
TryFallback,
}
/// Decide whether to attempt a fallback install based on the primary result
/// and the availability of a fallback source.
fn fallback_decision(
primary_result: &Result<InstallResult, ExtensionError>,
fallback_source: &Option<Box<ExtensionSource>>,
) -> FallbackDecision {
match (primary_result, fallback_source) {
// Success — no fallback needed
(Ok(_), _) => FallbackDecision::Return,
// AlreadyInstalled — don't try building from source
(Err(ExtensionError::AlreadyInstalled(_)), _) => FallbackDecision::Return,
// Failed with a fallback available — try it
(Err(_), Some(_)) => FallbackDecision::TryFallback,
// Failed with no fallback — return the error
(Err(_), None) => FallbackDecision::Return,
}
}
/// Combine primary and fallback errors into a single error.
///
/// Preserves `AlreadyInstalled` from the fallback directly; otherwise wraps
/// both errors into the structured `ExtensionError::FallbackFailed` variant.
fn combine_install_errors(
primary_err: ExtensionError,
fallback_err: ExtensionError,
) -> ExtensionError {
if matches!(fallback_err, ExtensionError::AlreadyInstalled(_)) {
return fallback_err;
}
ExtensionError::FallbackFailed {
primary: Box::new(primary_err),
fallback: Box::new(fallback_err),
}
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use crate::extensions::manager::{
FallbackDecision, combine_install_errors, fallback_decision, infer_kind_from_url,
};
use crate::extensions::{ExtensionError, ExtensionKind, ExtensionSource, InstallResult};
#[test]
fn test_infer_kind_from_url() {
assert_eq!(
infer_kind_from_url("https://example.com/tool.wasm"),
ExtensionKind::WasmTool
);
assert_eq!(
infer_kind_from_url("https://example.com/tool-wasm32-wasip2.tar.gz"),
ExtensionKind::WasmTool
);
assert_eq!(
infer_kind_from_url("https://mcp.notion.com"),
ExtensionKind::McpServer
);
assert_eq!(
infer_kind_from_url("https://example.com/mcp"),
ExtensionKind::McpServer
);
}
// ---- fallback install logic tests ----
fn make_ok_result() -> Result<InstallResult, ExtensionError> {
Ok(InstallResult {
name: "test".to_string(),
kind: ExtensionKind::WasmTool,
message: "Installed".to_string(),
})
}
fn make_fallback_source() -> Option<Box<ExtensionSource>> {
Some(Box::new(ExtensionSource::WasmBuildable {
source_dir: "tools-src/test".to_string(),
build_dir: Some("tools-src/test".to_string()),
crate_name: Some("test-tool".to_string()),
}))
}
#[test]
fn test_fallback_decision_success_returns_directly() {
let result = make_ok_result();
let fallback = make_fallback_source();
assert!(matches!(
fallback_decision(&result, &fallback),
FallbackDecision::Return
));
}
#[test]
fn test_fallback_decision_already_installed_skips_fallback() {
let result: Result<InstallResult, ExtensionError> =
Err(ExtensionError::AlreadyInstalled("test".to_string()));
let fallback = make_fallback_source();
assert!(matches!(
fallback_decision(&result, &fallback),
FallbackDecision::Return
));
}
#[test]
fn test_fallback_decision_download_failed_triggers_fallback() {
let result: Result<InstallResult, ExtensionError> =
Err(ExtensionError::DownloadFailed("404 Not Found".to_string()));
let fallback = make_fallback_source();
assert!(matches!(
fallback_decision(&result, &fallback),
FallbackDecision::TryFallback
));
}
#[test]
fn test_fallback_decision_error_without_fallback_returns() {
let result: Result<InstallResult, ExtensionError> =
Err(ExtensionError::DownloadFailed("404 Not Found".to_string()));
let fallback = None;
assert!(matches!(
fallback_decision(&result, &fallback),
FallbackDecision::Return
));
}
#[test]
fn test_combine_errors_includes_both_messages() {
let primary = ExtensionError::DownloadFailed("404 Not Found".to_string());
let fallback = ExtensionError::InstallFailed("cargo not found".to_string());
let combined = combine_install_errors(primary, fallback);
assert!(
matches!(combined, ExtensionError::FallbackFailed { .. }),
"Expected FallbackFailed, got: {combined:?}"
);
let msg = combined.to_string();
assert!(msg.contains("404 Not Found"), "missing primary: {msg}");
assert!(msg.contains("cargo not found"), "missing fallback: {msg}");
}
#[test]
fn test_combine_errors_forwards_already_installed_from_fallback() {
let primary = ExtensionError::DownloadFailed("404".to_string());
let fallback = ExtensionError::AlreadyInstalled("test".to_string());
let combined = combine_install_errors(primary, fallback);
assert!(
matches!(combined, ExtensionError::AlreadyInstalled(ref name) if name == "test"),
"Expected AlreadyInstalled, got: {combined:?}"
);
}
// === QA Plan P2 - 2.4: Extension registry collision tests (filesystem) ===
#[test]
fn test_tool_and_channel_paths_are_separate() {
// Verify that a WASM tool named "telegram" and a WASM channel named
// "telegram" use different filesystem paths and don't overwrite each other.
let dir = tempfile::tempdir().expect("temp dir");
let tools_dir = dir.path().join("tools");
let channels_dir = dir.path().join("channels");
std::fs::create_dir_all(&tools_dir).unwrap();
std::fs::create_dir_all(&channels_dir).unwrap();
let name = "telegram";
let tool_wasm = tools_dir.join(format!("{}.wasm", name));
let channel_wasm = channels_dir.join(format!("{}.wasm", name));
// Simulate installing both.
std::fs::write(&tool_wasm, b"tool-payload").unwrap();
std::fs::write(&channel_wasm, b"channel-payload").unwrap();
// Both files exist and contain distinct content.
assert!(tool_wasm.exists());
assert!(channel_wasm.exists());
assert_ne!(
std::fs::read(&tool_wasm).unwrap(),
std::fs::read(&channel_wasm).unwrap(),
"Tool and channel files must be independent"
);
// Removing one doesn't affect the other.
std::fs::remove_file(&tool_wasm).unwrap();
assert!(!tool_wasm.exists());
assert!(
channel_wasm.exists(),
"Removing tool must not affect channel"
);
}
#[test]
fn test_determine_kind_priority_tools_before_channels() {
// When a name exists in both tools and channels dirs,
// determine_installed_kind checks tools first (wasm_tools_dir).
// This test documents the priority order.
let dir = tempfile::tempdir().expect("temp dir");
let tools_dir = dir.path().join("tools");
let channels_dir = dir.path().join("channels");
std::fs::create_dir_all(&tools_dir).unwrap();
std::fs::create_dir_all(&channels_dir).unwrap();
let name = "ambiguous";
let tool_wasm = tools_dir.join(format!("{}.wasm", name));
let channel_wasm = channels_dir.join(format!("{}.wasm", name));
// Only channel exists → channel kind.
std::fs::write(&channel_wasm, b"channel").unwrap();
assert!(!tool_wasm.exists());
assert!(channel_wasm.exists());
// Both exist → tools dir checked first.
std::fs::write(&tool_wasm, b"tool").unwrap();
assert!(tool_wasm.exists());
assert!(channel_wasm.exists());
// This documents the determine_installed_kind priority:
// tools are checked before channels.
// Only tool exists → tool kind.
std::fs::remove_file(&channel_wasm).unwrap();
assert!(tool_wasm.exists());
assert!(!channel_wasm.exists());
}
// === WASM runtime availability tests ===
//
// Regression tests for a bug where the WASM runtime was only created at
// startup when the tools directory already existed. Extensions installed
// after startup (e.g. via the web UI) would fail with "WASM runtime not
// available" because the ExtensionManager had `wasm_tool_runtime: None`.
/// Build a minimal ExtensionManager suitable for unit tests.
fn make_test_manager(
wasm_runtime: Option<Arc<crate::tools::wasm::WasmToolRuntime>>,
tools_dir: std::path::PathBuf,
) -> crate::extensions::manager::ExtensionManager {
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
use crate::tools::mcp::session::McpSessionManager;
let key = secrecy::SecretString::from(crate::secrets::keychain::generate_master_key_hex());
let crypto = Arc::new(SecretsCrypto::new(key).expect("crypto"));
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
Arc::new(InMemorySecretsStore::new(crypto));
let tools = Arc::new(crate::tools::ToolRegistry::new());
let mcp = Arc::new(McpSessionManager::new());
crate::extensions::manager::ExtensionManager::new(
mcp,
secrets,
tools,
None, // hooks
wasm_runtime,
tools_dir.clone(),
tools_dir, // channels dir (unused here)
None, // tunnel_url
"test".to_string(),
None, // db
vec![],
)
}
#[tokio::test]
async fn test_activate_wasm_tool_with_runtime_passes_runtime_check() {
// When the ExtensionManager has a WASM runtime, activation should get
// past the "WASM runtime not available" check. It will still fail
// because no .wasm file exists on disk — but the error message should
// be "not found", NOT "WASM runtime not available".
let dir = tempfile::tempdir().expect("temp dir");
let config = crate::tools::wasm::WasmRuntimeConfig::for_testing();
let runtime = Arc::new(crate::tools::wasm::WasmToolRuntime::new(config).expect("runtime"));
let mgr = make_test_manager(Some(runtime), dir.path().to_path_buf());
let err = mgr.activate("nonexistent").await.unwrap_err();
let msg = err.to_string();
assert!(
!msg.contains("WASM runtime not available"),
"Should not fail on runtime check, got: {msg}"
);
assert!(
msg.contains("not found")
|| msg.contains("not installed")
|| msg.contains("Not installed"),
"Should fail on missing file, got: {msg}"
);
}
#[tokio::test]
async fn test_activate_wasm_tool_without_runtime_fails_with_runtime_error() {
// When the ExtensionManager has no WASM runtime (None), activation
// must fail with the "WASM runtime not available" message.
let dir = tempfile::tempdir().expect("temp dir");
// Write a fake .wasm file so we don't fail on "not found" first.
std::fs::write(dir.path().join("fake.wasm"), b"not-a-real-wasm").unwrap();
let mgr = make_test_manager(None, dir.path().to_path_buf());
let err = mgr.activate("fake").await.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("WASM runtime not available"),
"Expected runtime not available error, got: {msg}"
);
}
#[test]
fn test_capabilities_files_also_separate() {
// capabilities.json files for tools and channels should also be separate.
let dir = tempfile::tempdir().expect("temp dir");
let tools_dir = dir.path().join("tools");
let channels_dir = dir.path().join("channels");
std::fs::create_dir_all(&tools_dir).unwrap();
std::fs::create_dir_all(&channels_dir).unwrap();
let name = "telegram";
let tool_cap = tools_dir.join(format!("{}.capabilities.json", name));
let channel_cap = channels_dir.join(format!("{}.capabilities.json", name));
let tool_caps = r#"{"required_secrets":["TELEGRAM_API_KEY"]}"#;
let channel_caps = r#"{"required_secrets":["TELEGRAM_BOT_TOKEN"]}"#;
std::fs::write(&tool_cap, tool_caps).unwrap();
std::fs::write(&channel_cap, channel_caps).unwrap();
// Both exist with distinct content.
assert_eq!(std::fs::read_to_string(&tool_cap).unwrap(), tool_caps);
assert_eq!(std::fs::read_to_string(&channel_cap).unwrap(), channel_caps);
}
}