feat: hot-activate WASM channels, channel-first prompts, unified artifact resolution (#297)

* refactor: unify WASM artifact resolution into registry/artifacts.rs

Consolidate duplicated WASM find/build/install logic from 5+ files into
a single src/registry/artifacts.rs module. This fixes two bugs:
- registry/installer.rs now respects CARGO_TARGET_DIR (was hardcoded)
- channels/wasm/bundled.rs now searches all WASM triples (was wasip2 only)

Also includes: extension manager hot-activation for WASM channels,
extension guidance in LLM prompts, channel manager hot-add support,
webhook router channel lookup, and minor cleanups.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: send approval prompts as messages on WASM channels (Telegram, Slack)

WASM channels mapped ApprovalNeeded status to a typing indicator,
so users on Telegram never saw tool approval prompts — the agent
got stuck in AwaitingApproval and all subsequent messages failed
with "Waiting for approval".

- Intercept ApprovalNeeded in WasmChannel::handle_status_update and
  send the prompt as an actual message via call_on_respond, showing
  tool name, description, parameters, and yes/no/always instructions
- Guard against empty LLM responses after clean_response() strips
  reasoning_content think-tags (defense-in-depth for reasoning models)
- Add reasoning_content fallback to NearAiChatProvider::complete()
  for consistency with complete_with_tools()
- Add debug logging when empty responses are suppressed
- Improve error logging for channel respond() failures
- Register WASM channel webhook routes before credential checks so
  platforms don't deactivate webhook URLs with 404s

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: address PR #297 review comments

- ChannelManager::add: use async write().await instead of try_write()
- resolve_target_dir: resolve relative CARGO_TARGET_DIR against crate_dir
- install_wasm_files: log warning on capabilities copy failure
- refresh_active_channel: load capabilities file for webhook secret name
- activate_wasm_channel: validate name against path traversal
- Fix cargo fmt formatting in nearai_chat.rs

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: wire up channel runtime for hot-activation and address PR review round 2

- Wire up set_channel_runtime() in main.rs so hot-activation actually works
  (with_channel_runtime was never called — hot-activation was dead code)
- Change ExtensionManager channel runtime fields to RwLock<Option<...>>
  interior mutability so set_channel_runtime(&self) works after Arc wrapping
- Fix artifact tests to use resolve_target_dir() instead of hardcoding
  "target/" (breaks when CARGO_TARGET_DIR is set)
- Fix bundled.rs build hint: cargo component build (not cargo build --target)
- Fix wasm_artifact_path doc: binary_name should not include .wasm extension

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix: use char-aware truncation to prevent UTF-8 panic in approval prompt

&s[..77] panics on multi-byte UTF-8 (CJK, emoji). Use s.chars().take(77)
for safe truncation at character boundaries.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
Illia Polosukhin
2026-02-22 08:09:56 +00:00
committed by GitHub
co-authored by Claude Opus 4.6
parent a320f265b3
commit ea57447649
29 changed files with 1411 additions and 369 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
//! Online extension discovery for finding MCP servers not in the built-in registry.
//! Online extension discovery for finding extensions not in the built-in registry.
//!
//! Multi-tier search strategy:
//! 1. Probe well-known URL patterns (mcp.{service}.com, {service}.com/mcp)
+616 -26
View File
@@ -1,8 +1,8 @@
//! Central extension manager that dispatches operations by ExtensionKind.
//!
//! Holds references to MCP infrastructure, WASM tool runtime, secrets store,
//! and tool registry. All extension operations (search, install, auth, activate,
//! list, remove) flow through here.
//! Holds references to channel runtime, WASM tool runtime, MCP infrastructure,
//! secrets store, and tool registry. All extension operations (search, install,
//! auth, activate, list, remove) flow through here.
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
@@ -10,6 +10,10 @@ use std::sync::Arc;
use tokio::sync::RwLock;
use crate::channels::ChannelManager;
use crate::channels::wasm::{
RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter, WasmChannelRuntime,
};
use crate::extensions::discovery::OnlineDiscovery;
use crate::extensions::registry::ExtensionRegistry;
use crate::extensions::{
@@ -17,6 +21,7 @@ use crate::extensions::{
InstalledExtension, RegistryEntry, ResultSource, SearchResult,
};
use crate::hooks::HookRegistry;
use crate::pairing::PairingStore;
use crate::secrets::{CreateSecretParams, SecretsStore};
use crate::tools::ToolRegistry;
use crate::tools::mcp::McpClient;
@@ -35,6 +40,18 @@ struct PendingAuth {
created_at: std::time::Instant,
}
/// Runtime infrastructure needed for hot-activating WASM channels.
///
/// Set after construction via [`ExtensionManager::set_channel_runtime`] once the
/// channel manager, WASM runtime, pairing store, and webhook router are available.
struct ChannelRuntimeState {
channel_manager: Arc<ChannelManager>,
wasm_channel_runtime: Arc<WasmChannelRuntime>,
pairing_store: Arc<PairingStore>,
wasm_channel_router: Arc<WasmChannelRouter>,
telegram_owner_id: Option<i64>,
}
/// Central manager for extension lifecycle operations.
pub struct ExtensionManager {
registry: ExtensionRegistry,
@@ -50,13 +67,16 @@ pub struct ExtensionManager {
wasm_tools_dir: PathBuf,
wasm_channels_dir: PathBuf,
// WASM channel hot-activation infrastructure (set post-construction)
channel_runtime: RwLock<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 remote OAuth callbacks (used in future iterations).
_tunnel_url: Option<String>,
/// 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>>,
@@ -92,17 +112,40 @@ impl ExtensionManager {
wasm_tool_runtime,
wasm_tools_dir,
wasm_channels_dir,
channel_runtime: RwLock::new(None),
secrets,
tool_registry,
hooks,
pending_auth: RwLock::new(HashMap::new()),
_tunnel_url: tunnel_url,
tunnel_url,
user_id,
store,
active_channel_names: RwLock::new(HashSet::new()),
}
}
/// Configure the channel runtime infrastructure for hot-activating WASM channels.
///
/// Call after construction (and after wrapping in `Arc`) once the channel
/// manager, WASM runtime, pairing store, and webhook router are available.
/// Without this, channel activation returns an error.
pub async fn set_channel_runtime(
&self,
channel_manager: Arc<ChannelManager>,
wasm_channel_runtime: Arc<WasmChannelRuntime>,
pairing_store: Arc<PairingStore>,
wasm_channel_router: Arc<WasmChannelRouter>,
telegram_owner_id: Option<i64>,
) {
*self.channel_runtime.write().await = Some(ChannelRuntimeState {
channel_manager,
wasm_channel_runtime,
pairing_store,
wasm_channel_router,
telegram_owner_id,
});
}
/// Register channel names that were loaded at startup.
/// Called after WASM channels are loaded so `list()` reports accurate active status.
pub async fn set_active_channels(&self, names: Vec<String>) {
@@ -207,14 +250,18 @@ impl ExtensionManager {
match kind {
ExtensionKind::McpServer => self.activate_mcp(name).await,
ExtensionKind::WasmTool => self.activate_wasm_tool(name).await,
ExtensionKind::WasmChannel => Err(ExtensionError::ChannelNeedsRestart),
ExtensionKind::WasmChannel => self.activate_wasm_channel(name).await,
}
}
/// List all installed extensions with their status.
/// List extensions with their status.
///
/// When `include_available` is `true`, registry entries that are not yet
/// installed are appended with `installed: false`.
pub async fn list(
&self,
kind_filter: Option<ExtensionKind>,
include_available: bool,
) -> Result<Vec<InstalledExtension>, ExtensionError> {
let mut extensions = Vec::new();
@@ -249,6 +296,7 @@ impl ExtensionManager {
active,
tools,
needs_setup: false,
installed: true,
});
}
}
@@ -276,6 +324,7 @@ impl ExtensionManager {
active,
tools: if active { vec![name] } else { Vec::new() },
needs_setup: false,
installed: true,
});
}
}
@@ -305,6 +354,7 @@ impl ExtensionManager {
active,
tools: Vec::new(),
needs_setup,
installed: true,
});
}
}
@@ -314,6 +364,36 @@ impl ExtensionManager {
}
}
// Append available-but-not-installed registry entries
if include_available {
let installed_names: std::collections::HashSet<(String, ExtensionKind)> = extensions
.iter()
.map(|e| (e.name.clone(), e.kind))
.collect();
for entry in self.registry.all_entries().await {
if let Some(filter) = kind_filter
&& entry.kind != filter
{
continue;
}
if installed_names.contains(&(entry.name.clone(), entry.kind)) {
continue;
}
extensions.push(InstalledExtension {
name: entry.name,
kind: entry.kind,
description: Some(entry.description),
url: None,
authenticated: false,
active: false,
tools: Vec::new(),
needs_setup: false,
installed: false,
});
}
}
Ok(extensions)
}
@@ -491,12 +571,19 @@ impl ExtensionManager {
)
.await
}
ExtensionSource::WasmBuildable { .. } => {
Err(ExtensionError::InstallFailed(format!(
"'{}' requires building from source. Run `ironclaw registry install {}` \
from the CLI (requires cargo-component).",
entry.name, entry.name
)))
ExtensionSource::WasmBuildable {
build_dir,
crate_name,
..
} => {
self.install_wasm_from_buildable(
&entry.name,
build_dir.as_deref(),
crate_name.as_deref(),
&self.wasm_tools_dir,
ExtensionKind::WasmTool,
)
.await
}
_ => Err(ExtensionError::InstallFailed(
"WASM tool entry has no download URL".to_string(),
@@ -514,12 +601,19 @@ impl ExtensionManager {
)
.await
}
ExtensionSource::WasmBuildable { .. } => {
Err(ExtensionError::InstallFailed(format!(
"'{}' requires building from source. Run `ironclaw registry install {}` \
from the CLI (requires cargo-component).",
entry.name, entry.name
)))
ExtensionSource::WasmBuildable {
build_dir,
crate_name,
..
} => {
self.install_wasm_from_buildable(
&entry.name,
build_dir.as_deref(),
crate_name.as_deref(),
&self.wasm_channels_dir,
ExtensionKind::WasmChannel,
)
.await
}
_ => Err(ExtensionError::InstallFailed(
"WASM channel entry has no download URL".to_string(),
@@ -597,9 +691,8 @@ impl ExtensionManager {
name: name.to_string(),
kind: ExtensionKind::WasmChannel,
message: format!(
"WASM channel '{}' installed to {}. Restart to activate.",
"WASM channel '{}' installed. Run activate to start it.",
name,
self.wasm_channels_dir.display()
),
})
}
@@ -826,6 +919,115 @@ impl ExtensionManager {
Ok(())
}
#[allow(dead_code)] // Used by upcoming hot-activation flow
async fn install_bundled_channel_from_artifacts(
&self,
name: &str,
) -> Result<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,
@@ -1449,6 +1651,332 @@ impl ExtensionManager {
})
}
/// Activate a WASM channel at runtime without restarting.
///
/// Loads the channel from its WASM file, injects credentials and config,
/// registers it with the webhook router, and hot-adds it to the channel manager
/// so its stream feeds into the agent loop.
async fn activate_wasm_channel(&self, name: &str) -> Result<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,
telegram_owner_id,
) = {
let rt_guard = self.channel_runtime.read().await;
let rt = rt_guard.as_ref().ok_or_else(|| {
ExtensionError::ActivationFailed(
"WASM channel runtime not configured. Restart IronClaw to activate."
.to_string(),
)
})?;
(
Arc::clone(&rt.wasm_channel_runtime),
Arc::clone(&rt.channel_manager),
Arc::clone(&rt.pairing_store),
Arc::clone(&rt.wasm_channel_router),
rt.telegram_owner_id,
)
};
// Check auth status first
let (authenticated, _needs_setup) = self.check_channel_auth_status(name).await;
if !authenticated {
return Err(ExtensionError::ActivationFailed(format!(
"Channel '{}' requires configuration. Use the setup form to provide credentials.",
name
)));
}
// Validate name to prevent path traversal
if name.contains('/') || name.contains('\\') || name.contains("..") || name.contains('\0') {
return Err(ExtensionError::ActivationFailed(format!(
"Invalid channel name '{}': contains path separator or traversal characters",
name
)));
}
// Load the channel from files
let wasm_path = self.wasm_channels_dir.join(format!("{}.wasm", name));
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
let cap_path_option = if cap_path.exists() {
Some(cap_path.as_path())
} else {
None
};
let loader =
WasmChannelLoader::new(Arc::clone(&channel_runtime), Arc::clone(&pairing_store));
let loaded = loader
.load_from_files(name, &wasm_path, cap_path_option)
.await
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
let channel_name = loaded.name().to_string();
let webhook_secret_name = loaded.webhook_secret_name();
let secret_header = loaded.webhook_secret_header().map(|s| s.to_string());
// Get webhook secret from secrets store
let webhook_secret = self
.secrets
.get_decrypted(&self.user_id, &webhook_secret_name)
.await
.ok()
.map(|s| s.expose().to_string());
let channel_arc = Arc::new(loaded.channel);
// Inject runtime config (tunnel_url, webhook_secret, owner_id)
{
let mut config_updates = std::collections::HashMap::new();
if let Some(ref tunnel_url) = self.tunnel_url {
config_updates.insert(
"tunnel_url".to_string(),
serde_json::Value::String(tunnel_url.clone()),
);
}
if let Some(ref secret) = webhook_secret {
config_updates.insert(
"webhook_secret".to_string(),
serde_json::Value::String(secret.clone()),
);
}
if channel_name == "telegram"
&& let Some(owner_id) = telegram_owner_id
{
config_updates.insert("owner_id".to_string(), serde_json::json!(owner_id));
}
if !config_updates.is_empty() {
channel_arc.update_config(config_updates).await;
tracing::info!(
channel = %channel_name,
has_tunnel = self.tunnel_url.is_some(),
has_webhook_secret = webhook_secret.is_some(),
"Injected runtime config into hot-activated channel"
);
}
}
// Register with webhook router
{
let webhook_path = format!("/webhook/{}", channel_name);
let endpoints = vec![RegisteredEndpoint {
channel_name: channel_name.clone(),
path: webhook_path,
methods: vec!["POST".to_string()],
require_secret: webhook_secret.is_some(),
}];
wasm_channel_router
.register(
Arc::clone(&channel_arc),
endpoints,
webhook_secret,
secret_header,
)
.await;
tracing::info!(channel = %channel_name, "Registered hot-activated channel with webhook router");
}
// Inject credentials
match crate::extensions::manager::inject_channel_credentials_from_secrets(
&channel_arc,
self.secrets.as_ref(),
&channel_name,
&self.user_id,
)
.await
{
Ok(count) => {
if count > 0 {
tracing::info!(
channel = %channel_name,
credentials_injected = count,
"Credentials injected into hot-activated channel"
);
}
}
Err(e) => {
tracing::error!(
channel = %channel_name,
error = %e,
"Failed to inject credentials into hot-activated channel"
);
}
}
// Hot-add the channel to the running agent
channel_manager
.hot_add(Box::new(SharedWasmChannel::new(channel_arc)))
.await
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
// Mark as active
self.active_channel_names
.write()
.await
.insert(channel_name.clone());
tracing::info!(channel = %channel_name, "Hot-activated WASM channel");
Ok(ActivateResult {
name: channel_name,
kind: ExtensionKind::WasmChannel,
tools_loaded: Vec::new(),
message: format!("Channel '{}' activated and running", name),
})
}
/// Refresh credentials and webhook secret on an already-active channel.
///
/// Called when the user saves new secrets via the setup form for a channel
/// that was loaded at startup (possibly without credentials).
async fn refresh_active_channel(&self, name: &str) -> Result<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
}
};
// Also refresh the webhook secret in the router
// Load capabilities file to get the correct secret name (may be overridden)
let webhook_secret_name = {
let cap_path = self
.wasm_channels_dir
.join(format!("{}.capabilities.json", name));
match tokio::fs::read(&cap_path).await {
Ok(bytes) => crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&bytes)
.map(|f| f.webhook_secret_name())
.unwrap_or_else(|_| format!("{}_webhook_secret", name)),
Err(_) => format!("{}_webhook_secret", name),
}
};
if let Ok(secret) = self
.secrets
.get_decrypted(&self.user_id, &webhook_secret_name)
.await
{
router
.update_secret(name, secret.expose().to_string())
.await;
// Also inject the webhook_secret into the channel's runtime config
let mut config_updates = std::collections::HashMap::new();
config_updates.insert(
"webhook_secret".to_string(),
serde_json::Value::String(secret.expose().to_string()),
);
existing_channel.update_config(config_updates).await;
}
// Refresh tunnel_url in case it wasn't set at startup
if let Some(ref tunnel_url) = self.tunnel_url {
let mut config_updates = std::collections::HashMap::new();
config_updates.insert(
"tunnel_url".to_string(),
serde_json::Value::String(tunnel_url.clone()),
);
existing_channel.update_config(config_updates).await;
}
// Re-call on_start() to trigger webhook registration with the
// now-available credentials (e.g., setWebhook for Telegram).
if cred_count > 0 {
match existing_channel.call_on_start().await {
Ok(_config) => {
tracing::info!(
channel = %name,
"Re-ran on_start after credential refresh (webhook re-registered)"
);
}
Err(e) => {
tracing::warn!(
channel = %name,
error = %e,
"on_start failed after credential refresh"
);
}
}
}
tracing::info!(
channel = %name,
credentials_refreshed = cred_count,
"Refreshed credentials and config on already-active channel"
);
Ok(ActivateResult {
name: name.to_string(),
kind: ExtensionKind::WasmChannel,
tools_loaded: Vec::new(),
message: format!(
"Channel '{}' is already active; refreshed {} credential(s)",
name, cred_count
),
})
}
/// Determine what kind of installed extension this is.
async fn determine_installed_kind(&self, name: &str) -> Result<ExtensionKind, ExtensionError> {
// Check MCP servers first
@@ -1607,10 +2135,25 @@ impl ExtensionManager {
}
}
Ok(format!(
"Configuration saved for '{}'. Restart IronClaw for changes to take effect.",
name
))
// Try to hot-activate the channel now that secrets are saved
match self.activate_wasm_channel(name).await {
Ok(result) => Ok(format!(
"Configuration saved and channel '{}' activated. {}",
name, result.message
)),
Err(e) => {
tracing::warn!(
channel = name,
error = %e,
"Saved configuration but hot-activation failed, restart may be needed"
);
Ok(format!(
"Configuration saved for '{}'. \
Automatic activation failed ({}), restart IronClaw to activate.",
name, e
))
}
}
}
async fn unregister_hook_prefix(&self, prefix: &str) -> usize {
@@ -1629,6 +2172,53 @@ impl ExtensionManager {
}
}
/// Inject credentials for a channel based on naming convention.
///
/// Looks for secrets matching the pattern `{channel_name}_*` and injects them
/// as credential placeholders (e.g., `telegram_bot_token` -> `{TELEGRAM_BOT_TOKEN}`).
///
/// Returns the number of credentials injected.
async fn inject_channel_credentials_from_secrets(
channel: &Arc<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") {
+24 -14
View File
@@ -1,16 +1,19 @@
//! Unified extension system for discovering, installing, authenticating, and activating
//! MCP servers and WASM tools through conversational agent interactions.
//! Lifecycle management for extensions: discovery, installation, authentication,
//! and activation of channels, tools, and MCP servers.
//!
//! Extensions are the user-facing abstraction over MCP servers and WASM tools. The agent
//! can search a built-in registry (or discover online), install, authenticate, and activate
//! extensions at runtime without CLI commands.
//! Extensions are the user-facing abstraction that unifies three runtime kinds:
//! - **Channels** (Telegram, Slack, Discord) — messaging integrations (WASM)
//! - **Tools** — sandboxed capabilities (WASM)
//! - **MCP servers** — external API integrations via Model Context Protocol
//!
//! The agent can search a built-in registry (or discover online), install,
//! authenticate, and activate extensions at runtime without CLI commands.
//!
//! ```text
//! User: "add notion"
//! -> tool_search("notion") -> finds MCP server in registry
//! -> tool_install("notion") -> saves config to mcp-servers.json
//! -> tool_auth("notion") -> OAuth 2.1 flow, returns URL
//! -> tool_activate("notion") -> connects, registers tools
//! User: "add telegram"
//! -> tool_search("telegram") -> finds channel in registry
//! -> tool_install("telegram") -> copies bundled WASM to channels dir
//! -> tool_activate("telegram") -> configures credentials, starts channel
//! ```
pub mod discovery;
@@ -31,7 +34,7 @@ pub enum ExtensionKind {
McpServer,
/// Sandboxed WASM module, file-based, capabilities auth.
WasmTool,
/// WASM channel module (future: dynamic activation, currently needs restart).
/// WASM channel module with hot-activation support.
WasmChannel,
}
@@ -82,6 +85,9 @@ pub enum ExtensionSource {
repo_url: String,
#[serde(default)]
build_dir: Option<String>,
/// Crate name used to locate the build artifact binary.
#[serde(default)]
crate_name: Option<String>,
},
/// Discovered online (not yet validated for a specific source type).
Discovered { url: String },
@@ -169,6 +175,10 @@ pub struct ActivateResult {
pub message: String,
}
fn default_true() -> bool {
true
}
/// An installed extension with its current status.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledExtension {
@@ -187,6 +197,9 @@ pub struct InstalledExtension {
/// Whether this extension has a setup schema (required_secrets) that can be configured.
#[serde(default)]
pub needs_setup: bool,
/// Whether this extension is installed locally (false = available in registry but not installed).
#[serde(default = "default_true")]
pub installed: bool,
}
/// Error type for extension operations.
@@ -222,9 +235,6 @@ pub enum ExtensionError {
#[error("Config error: {0}")]
Config(String),
#[error("Channels require restart to activate")]
ChannelNeedsRestart,
#[error("{0}")]
Other(String),
}
+19 -2
View File
@@ -1,7 +1,7 @@
//! Curated in-memory catalog of known extensions with fuzzy search.
//!
//! The registry holds well-known MCP servers and WASM tools that can be installed
//! via conversational commands. Online discoveries are cached here too.
//! The registry holds well-known channels, tools, and MCP servers that can be
//! installed via conversational commands. Online discoveries are cached here too.
use tokio::sync::RwLock;
@@ -116,6 +116,21 @@ impl ExtensionRegistry {
cache.iter().find(|e| e.name == name).cloned()
}
/// Return all registry entries (builtins + cached discoveries).
pub async fn all_entries(&self) -> Vec<RegistryEntry> {
let mut entries = self.entries.clone();
let cache = self.discovery_cache.read().await;
for entry in cache.iter() {
if !entries
.iter()
.any(|e| e.name == entry.name && e.kind == entry.kind)
{
entries.push(entry.clone());
}
}
entries
}
/// Add discovered entries to the cache.
pub async fn cache_discovered(&self, entries: Vec<RegistryEntry>) {
let mut cache = self.discovery_cache.write().await;
@@ -578,6 +593,7 @@ mod tests {
source: ExtensionSource::WasmBuildable {
repo_url: "channels-src/telegram".to_string(),
build_dir: Some("channels-src/telegram".to_string()),
crate_name: Some("telegram-channel".to_string()),
},
auth_hint: AuthHint::CapabilitiesAuth,
},
@@ -591,6 +607,7 @@ mod tests {
source: ExtensionSource::WasmBuildable {
repo_url: "tools-src/slack".to_string(),
build_dir: Some("tools-src/slack".to_string()),
crate_name: Some("slack-tool".to_string()),
},
auth_hint: AuthHint::CapabilitiesAuth,
},