mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
co-authored by
Claude Opus 4.6
parent
a320f265b3
commit
ea57447649
@@ -25,5 +25,3 @@ opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
|
||||
[workspace]
|
||||
|
||||
+36
-9
@@ -98,7 +98,7 @@ impl Agent {
|
||||
pub fn new(
|
||||
config: AgentConfig,
|
||||
deps: AgentDeps,
|
||||
channels: ChannelManager,
|
||||
channels: Arc<ChannelManager>,
|
||||
heartbeat_config: Option<HeartbeatConfig>,
|
||||
hygiene_config: Option<crate::config::HygieneConfig>,
|
||||
routine_config: Option<RoutineConfig>,
|
||||
@@ -123,7 +123,7 @@ impl Agent {
|
||||
Self {
|
||||
config,
|
||||
deps,
|
||||
channels: Arc::new(channels),
|
||||
channels,
|
||||
context_manager,
|
||||
scheduler,
|
||||
router: Router::new(),
|
||||
@@ -499,21 +499,41 @@ impl Agent {
|
||||
Ok(crate::hooks::HookOutcome::Continue {
|
||||
modified: Some(new_content),
|
||||
}) => {
|
||||
let _ = self
|
||||
if let Err(e) = self
|
||||
.channels
|
||||
.respond(&message, OutgoingResponse::text(new_content))
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
channel = %message.channel,
|
||||
error = %e,
|
||||
"Failed to send response to channel"
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let _ = self
|
||||
if let Err(e) = self
|
||||
.channels
|
||||
.respond(&message, OutgoingResponse::text(response))
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
channel = %message.channel,
|
||||
error = %e,
|
||||
"Failed to send response to channel"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Some(_)) => {
|
||||
Ok(Some(empty)) => {
|
||||
// Empty response, nothing to send (e.g. approval handled via send_status)
|
||||
tracing::debug!(
|
||||
channel = %message.channel,
|
||||
user = %message.user_id,
|
||||
empty_len = empty.len(),
|
||||
"Suppressed empty response (not sent to channel)"
|
||||
);
|
||||
}
|
||||
Ok(None) => {
|
||||
// Shutdown signal received (/quit, /exit, /shutdown)
|
||||
@@ -522,10 +542,17 @@ impl Agent {
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error handling message: {}", e);
|
||||
let _ = self
|
||||
if let Err(send_err) = self
|
||||
.channels
|
||||
.respond(&message, OutgoingResponse::text(format!("Error: {}", e)))
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
tracing::error!(
|
||||
channel = %message.channel,
|
||||
error = %send_err,
|
||||
"Failed to send error response to channel"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -891,7 +891,7 @@ mod tests {
|
||||
auto_approve_tools: false,
|
||||
},
|
||||
deps,
|
||||
ChannelManager::new(),
|
||||
Arc::new(ChannelManager::new()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
|
||||
+32
-9
@@ -40,16 +40,39 @@ impl ChannelManager {
|
||||
}
|
||||
|
||||
/// Add a channel to the manager.
|
||||
pub fn add(&mut self, channel: Box<dyn Channel>) {
|
||||
pub async fn add(&self, channel: Box<dyn Channel>) {
|
||||
let name = channel.name().to_string();
|
||||
// We need to get the inner HashMap to insert
|
||||
// Since we're in a sync context during setup, we'll use try_write
|
||||
if let Ok(mut channels) = self.channels.try_write() {
|
||||
channels.insert(name.clone(), channel);
|
||||
tracing::debug!("Added channel: {}", name);
|
||||
} else {
|
||||
tracing::error!("Failed to add channel: {} (lock contention)", name);
|
||||
}
|
||||
self.channels.write().await.insert(name.clone(), channel);
|
||||
tracing::debug!("Added channel: {}", name);
|
||||
}
|
||||
|
||||
/// Hot-add a channel to a running agent.
|
||||
///
|
||||
/// Starts the channel, registers it in the channels map for `respond()`/`broadcast()`,
|
||||
/// and spawns a task that forwards its stream messages through `inject_tx` into
|
||||
/// the agent loop.
|
||||
pub async fn hot_add(&self, channel: Box<dyn Channel>) -> Result<(), ChannelError> {
|
||||
let name = channel.name().to_string();
|
||||
let stream = channel.start().await?;
|
||||
|
||||
// Register for respond/broadcast/send_status
|
||||
self.channels.write().await.insert(name.clone(), channel);
|
||||
|
||||
// Forward stream messages through inject_tx
|
||||
let tx = self.inject_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
use futures::StreamExt;
|
||||
let mut stream = stream;
|
||||
while let Some(msg) = stream.next().await {
|
||||
if tx.send(msg).await.is_err() {
|
||||
tracing::warn!(channel = %name, "Inject channel closed, stopping hot-added channel");
|
||||
break;
|
||||
}
|
||||
}
|
||||
tracing::info!(channel = %name, "Hot-added channel stream ended");
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start all channels and return a merged stream of messages.
|
||||
|
||||
@@ -65,24 +65,28 @@ fn locate_channel_artifacts(name: &str) -> Result<(PathBuf, PathBuf), String> {
|
||||
return Ok((flat_wasm, caps_path));
|
||||
}
|
||||
|
||||
// Fall back to build tree layout (dev builds)
|
||||
let build_wasm = channel_dir
|
||||
.join("target/wasm32-wasip2/release")
|
||||
.join(format!("{}.wasm", crate_name));
|
||||
|
||||
if build_wasm.exists() && caps_path.exists() {
|
||||
// Fall back to build tree layout (dev builds) — search across all WASM triples
|
||||
if let Some(build_wasm) =
|
||||
crate::registry::artifacts::find_wasm_artifact(&channel_dir, crate_name, "release")
|
||||
&& caps_path.exists()
|
||||
{
|
||||
return Ok((build_wasm, caps_path));
|
||||
}
|
||||
|
||||
// Provide a helpful error with the paths we checked
|
||||
let expected_build = crate::registry::artifacts::resolve_target_dir(&channel_dir)
|
||||
.join("wasm32-wasip2/release")
|
||||
.join(format!("{}.wasm", crate_name));
|
||||
|
||||
Err(format!(
|
||||
"Channel '{}' WASM not found. Checked:\n \
|
||||
- {} (flat/packaged)\n \
|
||||
- {} (build tree)\n \
|
||||
- {} (build tree, and other triples)\n \
|
||||
Build it first:\n \
|
||||
cd {} && cargo build --target wasm32-wasip2 --release",
|
||||
cd {} && cargo component build --release",
|
||||
name,
|
||||
flat_wasm.display(),
|
||||
build_wasm.display(),
|
||||
expected_build.display(),
|
||||
channel_dir.display()
|
||||
))
|
||||
}
|
||||
|
||||
@@ -110,6 +110,21 @@ impl WasmChannelRouter {
|
||||
.unwrap_or_else(|| "X-Webhook-Secret".to_string())
|
||||
}
|
||||
|
||||
/// Update the webhook secret for an already-registered channel.
|
||||
///
|
||||
/// This is used when credentials are saved after a channel was registered
|
||||
/// without a secret (e.g., loaded at startup before the user configured it).
|
||||
pub async fn update_secret(&self, channel_name: &str, secret: String) {
|
||||
self.secrets
|
||||
.write()
|
||||
.await
|
||||
.insert(channel_name.to_string(), secret);
|
||||
tracing::info!(
|
||||
channel = %channel_name,
|
||||
"Updated webhook secret for channel"
|
||||
);
|
||||
}
|
||||
|
||||
/// Unregister a channel and its endpoints.
|
||||
pub async fn unregister(&self, channel_name: &str) {
|
||||
self.channels.write().await.remove(channel_name);
|
||||
|
||||
@@ -780,7 +780,12 @@ impl WasmChannel {
|
||||
/// Execute the on_start callback.
|
||||
///
|
||||
/// Returns the channel configuration for HTTP endpoint registration.
|
||||
async fn call_on_start(&self) -> Result<ChannelConfig, WasmChannelError> {
|
||||
/// Call the WASM module's `on_start` callback.
|
||||
///
|
||||
/// Typically called once during `start()`, but can be called again after
|
||||
/// credentials are refreshed to re-trigger webhook registration and
|
||||
/// other one-time setup that depends on credentials.
|
||||
pub async fn call_on_start(&self) -> Result<ChannelConfig, WasmChannelError> {
|
||||
// If no WASM bytes, return default config (for testing)
|
||||
if self.prepared.component().is_none() {
|
||||
tracing::info!(
|
||||
@@ -1437,6 +1442,72 @@ impl WasmChannel {
|
||||
StatusUpdate::StreamChunk(_) => {
|
||||
// No-op, too noisy
|
||||
}
|
||||
StatusUpdate::ApprovalNeeded {
|
||||
tool_name,
|
||||
description,
|
||||
parameters,
|
||||
..
|
||||
} => {
|
||||
// WASM channels (Telegram, Slack, etc.) cannot render
|
||||
// interactive approval overlays. Send the approval prompt
|
||||
// as an actual message so the user can reply yes/no.
|
||||
self.cancel_typing_task().await;
|
||||
|
||||
let params_preview = parameters
|
||||
.as_object()
|
||||
.map(|obj| {
|
||||
obj.iter()
|
||||
.map(|(k, v)| {
|
||||
let val = match v {
|
||||
serde_json::Value::String(s) => {
|
||||
if s.chars().count() > 80 {
|
||||
let truncated: String = s.chars().take(77).collect();
|
||||
format!("\"{}...\"", truncated)
|
||||
} else {
|
||||
format!("\"{}\"", s)
|
||||
}
|
||||
}
|
||||
other => {
|
||||
let s = other.to_string();
|
||||
if s.chars().count() > 80 {
|
||||
let truncated: String = s.chars().take(77).collect();
|
||||
format!("{}...", truncated)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
};
|
||||
format!(" {}: {}", k, val)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let prompt = format!(
|
||||
"Approval needed: {tool_name}\n\
|
||||
{description}\n\
|
||||
\n\
|
||||
Parameters:\n\
|
||||
{params_preview}\n\
|
||||
\n\
|
||||
Reply \"yes\" to approve, \"no\" to deny, or \"always\" to auto-approve."
|
||||
);
|
||||
|
||||
let metadata_json = serde_json::to_string(metadata).unwrap_or_default();
|
||||
if let Err(e) = self
|
||||
.call_on_respond(uuid::Uuid::new_v4(), &prompt, None, &metadata_json)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
channel = %self.name,
|
||||
error = %e,
|
||||
"Failed to send approval prompt via on_respond, falling back to on_status"
|
||||
);
|
||||
// Fall back to status update (typing indicator)
|
||||
let _ = self.call_on_status(&status, metadata).await;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Done, Interrupted, Status, ToolStarted, ToolCompleted: cancel and fire once
|
||||
self.cancel_typing_task().await;
|
||||
|
||||
@@ -20,7 +20,7 @@ pub async fn extensions_list_handler(
|
||||
))?;
|
||||
|
||||
let installed = ext_mgr
|
||||
.list(None)
|
||||
.list(None, false)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
|
||||
@@ -1704,7 +1704,7 @@ async fn extensions_list_handler(
|
||||
))?;
|
||||
|
||||
let installed = ext_mgr
|
||||
.list(None)
|
||||
.list(None, false)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
@@ -1955,7 +1955,7 @@ async fn extensions_registry_handler(
|
||||
let installed: std::collections::HashSet<(String, String)> =
|
||||
if let Some(ext_mgr) = state.extension_manager.as_ref() {
|
||||
ext_mgr
|
||||
.list(None)
|
||||
.list(None, false)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
@@ -1998,7 +1998,7 @@ async fn extensions_setup_handler(
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let kind = ext_mgr
|
||||
.list(None)
|
||||
.list(None, false)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|list| list.into_iter().find(|e| e.name == name))
|
||||
|
||||
@@ -1455,18 +1455,11 @@ function renderExtensionCard(ext) {
|
||||
actions.className = 'ext-actions';
|
||||
|
||||
if (!ext.active) {
|
||||
if (ext.kind === 'wasm_channel') {
|
||||
const restartLabel = document.createElement('span');
|
||||
restartLabel.className = 'ext-restart-label';
|
||||
restartLabel.textContent = 'Restart to activate';
|
||||
actions.appendChild(restartLabel);
|
||||
} else {
|
||||
const activateBtn = document.createElement('button');
|
||||
activateBtn.className = 'btn-ext activate';
|
||||
activateBtn.textContent = 'Activate';
|
||||
activateBtn.addEventListener('click', () => activateExtension(ext.name));
|
||||
actions.appendChild(activateBtn);
|
||||
}
|
||||
const activateBtn = document.createElement('button');
|
||||
activateBtn.className = 'btn-ext activate';
|
||||
activateBtn.textContent = 'Activate';
|
||||
activateBtn.addEventListener('click', () => activateExtension(ext.name));
|
||||
actions.appendChild(activateBtn);
|
||||
} else {
|
||||
const activeLabel = document.createElement('span');
|
||||
activeLabel.className = 'ext-active-label';
|
||||
@@ -1490,8 +1483,10 @@ function renderExtensionCard(ext) {
|
||||
|
||||
card.appendChild(actions);
|
||||
|
||||
// For active WASM channels, check for pending pairing requests
|
||||
if (ext.active && ext.kind === 'wasm_channel') {
|
||||
// For WASM channels, check for pending pairing requests.
|
||||
// Show even when inactive — pairing requests can arrive via webhooks
|
||||
// before the channel is fully activated.
|
||||
if (ext.kind === 'wasm_channel') {
|
||||
const pairingSection = document.createElement('div');
|
||||
pairingSection.className = 'ext-pairing';
|
||||
card.appendChild(pairingSection);
|
||||
|
||||
@@ -1936,12 +1936,6 @@ body {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.ext-restart-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.btn-ext {
|
||||
padding: 4px 10px;
|
||||
border-radius: var(--radius);
|
||||
|
||||
+9
-166
@@ -4,7 +4,6 @@
|
||||
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command as ProcessCommand;
|
||||
use std::sync::Arc;
|
||||
|
||||
use clap::Subcommand;
|
||||
@@ -155,11 +154,18 @@ async fn install_tool(
|
||||
};
|
||||
|
||||
// Build the WASM component if not skipping
|
||||
let profile = if release { "release" } else { "debug" };
|
||||
let wasm_path = if skip_build {
|
||||
// Look for existing wasm file
|
||||
find_wasm_artifact(&path, &tool_name, release)?
|
||||
crate::registry::artifacts::find_wasm_artifact(&path, &tool_name, profile)
|
||||
.or_else(|| crate::registry::artifacts::find_any_wasm_artifact(&path, profile))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No .wasm artifact found. Run without --skip-build to build first."
|
||||
)
|
||||
})?
|
||||
} else {
|
||||
build_wasm_component(&path, release)?
|
||||
crate::registry::artifacts::build_wasm_component_sync(&path, release)?
|
||||
};
|
||||
|
||||
// Look for capabilities file
|
||||
@@ -253,169 +259,6 @@ async fn install_tool(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build a WASM component using cargo-component.
|
||||
fn build_wasm_component(source_dir: &Path, release: bool) -> anyhow::Result<PathBuf> {
|
||||
println!("Building WASM component in {}...", source_dir.display());
|
||||
|
||||
// Check if cargo-component is available
|
||||
let check = ProcessCommand::new("cargo")
|
||||
.args(["component", "--version"])
|
||||
.output();
|
||||
|
||||
if check.is_err() || !check.unwrap().status.success() {
|
||||
anyhow::bail!(
|
||||
"cargo-component not found. Install with: cargo install cargo-component\n\
|
||||
Or use --skip-build with an existing .wasm file."
|
||||
);
|
||||
}
|
||||
|
||||
// Build command
|
||||
let mut cmd = ProcessCommand::new("cargo");
|
||||
cmd.current_dir(source_dir).args(["component", "build"]);
|
||||
|
||||
if release {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
|
||||
println!(
|
||||
" Running: cargo component build{}",
|
||||
if release { " --release" } else { "" }
|
||||
);
|
||||
|
||||
let output = cmd.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("Build failed:\n{}", stderr);
|
||||
}
|
||||
|
||||
// Find the output wasm file
|
||||
// cargo-component may output to wasm32-wasip1 or wasm32-wasip2 depending on version
|
||||
let profile = if release { "release" } else { "debug" };
|
||||
let candidates = [
|
||||
source_dir
|
||||
.join("target")
|
||||
.join("wasm32-wasip1")
|
||||
.join(profile),
|
||||
source_dir
|
||||
.join("target")
|
||||
.join("wasm32-wasip2")
|
||||
.join(profile),
|
||||
source_dir
|
||||
.join("target")
|
||||
.join("wasm32-unknown-unknown")
|
||||
.join(profile),
|
||||
];
|
||||
|
||||
let target_dir = candidates.iter().find(|p| p.exists()).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No WASM target directory found. Expected one of: {}",
|
||||
candidates
|
||||
.iter()
|
||||
.map(|p| p.display().to_string())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})?;
|
||||
|
||||
// Look for .wasm files in target dir
|
||||
let entries: Vec<_> = std::fs::read_dir(target_dir)?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.map(|ext| ext == "wasm")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if entries.is_empty() {
|
||||
anyhow::bail!(
|
||||
"No .wasm file found in {}. Build may have failed.",
|
||||
target_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
if entries.len() > 1 {
|
||||
println!(
|
||||
" Warning: Multiple .wasm files found, using first: {}",
|
||||
entries[0].path().display()
|
||||
);
|
||||
}
|
||||
|
||||
let wasm_path = entries[0].path();
|
||||
println!(" Built: {}", wasm_path.display());
|
||||
|
||||
Ok(wasm_path)
|
||||
}
|
||||
|
||||
/// Find an existing WASM artifact without building.
|
||||
fn find_wasm_artifact(source_dir: &Path, name: &str, release: bool) -> anyhow::Result<PathBuf> {
|
||||
let profile = if release { "release" } else { "debug" };
|
||||
|
||||
// cargo-component may output to wasm32-wasip1 or wasm32-wasip2 depending on version
|
||||
let target_dirs = [
|
||||
source_dir
|
||||
.join("target")
|
||||
.join("wasm32-wasip1")
|
||||
.join(profile),
|
||||
source_dir
|
||||
.join("target")
|
||||
.join("wasm32-wasip2")
|
||||
.join(profile),
|
||||
source_dir
|
||||
.join("target")
|
||||
.join("wasm32-unknown-unknown")
|
||||
.join(profile),
|
||||
];
|
||||
|
||||
let snake_name = name.replace('-', "_");
|
||||
|
||||
// Try exact name match in any target dir first
|
||||
for target_dir in &target_dirs {
|
||||
let candidates = [
|
||||
target_dir.join(format!("{}.wasm", name)),
|
||||
target_dir.join(format!("{}.wasm", snake_name)),
|
||||
];
|
||||
for candidate in &candidates {
|
||||
if candidate.exists() {
|
||||
return Ok(candidate.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find a target dir that exists
|
||||
let target_dir = target_dirs.iter().find(|p| p.exists()).ok_or_else(|| {
|
||||
anyhow::anyhow!("No target directory found. Run without --skip-build to build first.")
|
||||
})?;
|
||||
|
||||
// Fall back to any .wasm file
|
||||
let entries: Vec<_> = std::fs::read_dir(target_dir)
|
||||
.map_err(|_| {
|
||||
anyhow::anyhow!(
|
||||
"Target directory not found: {}. Run without --skip-build.",
|
||||
target_dir.display()
|
||||
)
|
||||
})?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.path()
|
||||
.extension()
|
||||
.map(|ext| ext == "wasm")
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if entries.is_empty() {
|
||||
anyhow::bail!(
|
||||
"No .wasm file found in {}. Build the project first or remove --skip-build.",
|
||||
target_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(entries[0].path())
|
||||
}
|
||||
|
||||
/// Extract crate name from Cargo.toml.
|
||||
async fn extract_crate_name(cargo_toml: &Path) -> anyhow::Result<String> {
|
||||
let content = fs::read_to_string(cargo_toml).await?;
|
||||
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
+16
-2
@@ -422,7 +422,13 @@ impl LlmProvider for NearAiChatProvider {
|
||||
reason: "No choices in response".to_string(),
|
||||
})?;
|
||||
|
||||
let content = choice.message.content.unwrap_or_default();
|
||||
// Fall back to reasoning_content when content is null (same as
|
||||
// complete_with_tools — reasoning models may put the answer there).
|
||||
let content = choice
|
||||
.message
|
||||
.content
|
||||
.or(choice.message.reasoning_content)
|
||||
.unwrap_or_default();
|
||||
let finish_reason = match choice.finish_reason.as_deref() {
|
||||
Some("stop") => FinishReason::Stop,
|
||||
Some("length") => FinishReason::Length,
|
||||
@@ -493,7 +499,9 @@ impl LlmProvider for NearAiChatProvider {
|
||||
reason: "No choices in response".to_string(),
|
||||
})?;
|
||||
|
||||
let content = choice.message.content;
|
||||
// Fall back to reasoning_content when content is null (e.g. GLM-5
|
||||
// returns its answer in reasoning_content instead of content).
|
||||
let content = choice.message.content.or(choice.message.reasoning_content);
|
||||
let tool_calls: Vec<ToolCall> = choice
|
||||
.message
|
||||
.tool_calls
|
||||
@@ -781,6 +789,7 @@ fn flatten_tool_messages(messages: Vec<ChatCompletionMessage>) -> Vec<ChatComple
|
||||
ChatCompletionMessage {
|
||||
role: "assistant".to_string(),
|
||||
content: Some(parts.join("\n")),
|
||||
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
@@ -792,6 +801,7 @@ fn flatten_tool_messages(messages: Vec<ChatCompletionMessage>) -> Vec<ChatComple
|
||||
ChatCompletionMessage {
|
||||
role: "user".to_string(),
|
||||
content: Some(format!("[Tool `{}` returned: {}]", tool_name, result)),
|
||||
|
||||
tool_call_id: None,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
@@ -879,6 +889,10 @@ struct ChatCompletionResponseMessage {
|
||||
#[allow(dead_code)]
|
||||
role: String,
|
||||
content: Option<String>,
|
||||
/// Some models (e.g. GLM-5) return chain-of-thought reasoning here
|
||||
/// instead of in `content`.
|
||||
#[serde(default)]
|
||||
reasoning_content: Option<String>,
|
||||
tool_calls: Option<Vec<ChatCompletionToolCall>>,
|
||||
}
|
||||
|
||||
|
||||
+53
-3
@@ -502,8 +502,23 @@ Respond in JSON format:
|
||||
});
|
||||
}
|
||||
|
||||
// Guard against empty text after cleaning. This can happen
|
||||
// when reasoning models (e.g. GLM-5) return chain-of-thought
|
||||
// in reasoning_content wrapped in <think> tags and content is
|
||||
// null — the .or(reasoning_content) fallback picks it up, then
|
||||
// clean_response strips the think tags leaving an empty string.
|
||||
let cleaned = clean_response(&content);
|
||||
let final_text = if cleaned.trim().is_empty() {
|
||||
tracing::warn!(
|
||||
"LLM response was empty after cleaning (original len={}), using fallback",
|
||||
content.len()
|
||||
);
|
||||
"I'm not sure how to respond to that.".to_string()
|
||||
} else {
|
||||
cleaned
|
||||
};
|
||||
Ok(RespondOutput {
|
||||
result: RespondResult::Text(clean_response(&content)),
|
||||
result: RespondResult::Text(final_text),
|
||||
usage,
|
||||
})
|
||||
} else {
|
||||
@@ -514,8 +529,18 @@ Respond in JSON format:
|
||||
request.metadata = context.metadata.clone();
|
||||
|
||||
let response = self.llm.complete(request).await?;
|
||||
let cleaned = clean_response(&response.content);
|
||||
let final_text = if cleaned.trim().is_empty() {
|
||||
tracing::warn!(
|
||||
"LLM response was empty after cleaning (original len={}), using fallback",
|
||||
response.content.len()
|
||||
);
|
||||
"I'm not sure how to respond to that.".to_string()
|
||||
} else {
|
||||
cleaned
|
||||
};
|
||||
Ok(RespondOutput {
|
||||
result: RespondResult::Text(clean_response(&response.content)),
|
||||
result: RespondResult::Text(final_text),
|
||||
usage: TokenUsage {
|
||||
input_tokens: response.input_tokens,
|
||||
output_tokens: response.output_tokens,
|
||||
@@ -607,6 +632,9 @@ Respond with a JSON plan in this format:
|
||||
// Channel-specific formatting hints
|
||||
let channel_section = self.build_channel_section();
|
||||
|
||||
// Extension guidance (only when extension tools are available)
|
||||
let extensions_section = self.build_extensions_section(context);
|
||||
|
||||
// Runtime context (agent metadata)
|
||||
let runtime_section = self.build_runtime_section();
|
||||
|
||||
@@ -648,9 +676,10 @@ Example:
|
||||
- Prioritize safety and human oversight over task completion. If instructions conflict, pause and ask.
|
||||
- Comply with stop, pause, or audit requests. Never bypass safeguards.
|
||||
- Do not manipulate anyone to expand your access or disable safeguards.
|
||||
- Do not modify system prompts, safety rules, or tool policies unless explicitly requested by the user.{}{}{}{}
|
||||
- Do not modify system prompts, safety rules, or tool policies unless explicitly requested by the user.{}{}{}{}{}
|
||||
{}{}"#,
|
||||
tools_section,
|
||||
extensions_section,
|
||||
channel_section,
|
||||
runtime_section,
|
||||
group_section,
|
||||
@@ -659,6 +688,27 @@ Example:
|
||||
)
|
||||
}
|
||||
|
||||
fn build_extensions_section(&self, context: &ReasoningContext) -> String {
|
||||
// Only include when the extension management tools are available
|
||||
let has_ext_tools = context
|
||||
.available_tools
|
||||
.iter()
|
||||
.any(|t| t.name == "tool_search");
|
||||
if !has_ext_tools {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
"\n\n## Extensions\n\
|
||||
You can search, install, and activate extensions to add new capabilities:\n\
|
||||
- **Channels** (Telegram, Slack, Discord) — messaging integrations. \
|
||||
When users ask about connecting a messaging platform, search for it as a channel.\n\
|
||||
- **Tools** — sandboxed functions that extend your abilities.\n\
|
||||
- **MCP servers** — external API integrations via the Model Context Protocol.\n\n\
|
||||
Use `tool_search` to find extensions by name. Refer to them by their kind \
|
||||
(channel, tool, or server) — not as \"MCP server\" generically."
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn build_channel_section(&self) -> String {
|
||||
let channel = match self.channel.as_deref() {
|
||||
Some(c) => c,
|
||||
|
||||
+44
-7
@@ -277,9 +277,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
// ── Channel setup ──────────────────────────────────────────────────
|
||||
|
||||
let mut channels = ChannelManager::new();
|
||||
let channels = ChannelManager::new();
|
||||
let mut channel_names: Vec<String> = Vec::new();
|
||||
let mut loaded_wasm_channel_names: Vec<String> = Vec::new();
|
||||
#[allow(clippy::type_complexity)]
|
||||
let mut wasm_channel_runtime_state: Option<(
|
||||
Arc<WasmChannelRuntime>,
|
||||
Arc<PairingStore>,
|
||||
Arc<WasmChannelRouter>,
|
||||
)> = None;
|
||||
|
||||
// Create CLI channel
|
||||
let repl_channel = if let Some(ref msg) = cli.message {
|
||||
@@ -293,7 +299,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
};
|
||||
|
||||
if let Some(repl) = repl_channel {
|
||||
channels.add(Box::new(repl));
|
||||
channels.add(Box::new(repl)).await;
|
||||
if cli.message.is_some() {
|
||||
tracing::info!("Single message mode");
|
||||
} else {
|
||||
@@ -316,9 +322,14 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
if let Some(result) = wasm_result {
|
||||
loaded_wasm_channel_names = result.channel_names;
|
||||
wasm_channel_runtime_state = Some((
|
||||
result.wasm_channel_runtime,
|
||||
result.pairing_store,
|
||||
result.wasm_channel_router,
|
||||
));
|
||||
for (name, channel) in result.channels {
|
||||
channel_names.push(name);
|
||||
channels.add(channel);
|
||||
channels.add(channel).await;
|
||||
}
|
||||
if let Some(routes) = result.webhook_routes {
|
||||
webhook_routes.push(routes);
|
||||
@@ -340,7 +351,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
.expect("HttpConfig host:port must be a valid SocketAddr"),
|
||||
);
|
||||
channel_names.push("http".to_string());
|
||||
channels.add(Box::new(http_channel));
|
||||
channels.add(Box::new(http_channel)).await;
|
||||
tracing::info!(
|
||||
"HTTP channel enabled on {}:{}",
|
||||
http_config.host,
|
||||
@@ -466,7 +477,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
tracing::info!("Web UI: http://{}:{}/", gw_config.host, gw_config.port);
|
||||
|
||||
channel_names.push("gateway".to_string());
|
||||
channels.add(Box::new(gw));
|
||||
channels.add(Box::new(gw)).await;
|
||||
}
|
||||
|
||||
// ── Boot screen ────────────────────────────────────────────────────
|
||||
@@ -516,6 +527,24 @@ async fn main() -> anyhow::Result<()> {
|
||||
|
||||
// ── Run the agent ──────────────────────────────────────────────────
|
||||
|
||||
let channels = Arc::new(channels);
|
||||
|
||||
// Wire up channel runtime for hot-activation of WASM channels.
|
||||
if let Some(ref ext_mgr) = components.extension_manager
|
||||
&& let Some((rt, ps, router)) = wasm_channel_runtime_state.take()
|
||||
{
|
||||
ext_mgr
|
||||
.set_channel_runtime(
|
||||
Arc::clone(&channels),
|
||||
rt,
|
||||
ps,
|
||||
router,
|
||||
config.channels.telegram_owner_id,
|
||||
)
|
||||
.await;
|
||||
tracing::info!("Channel runtime wired into extension manager for hot-activation");
|
||||
}
|
||||
|
||||
let deps = AgentDeps {
|
||||
store: components.db,
|
||||
llm: components.llm,
|
||||
@@ -529,6 +558,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
hooks: components.hooks,
|
||||
cost_guard: components.cost_guard,
|
||||
};
|
||||
|
||||
let agent = Agent::new(
|
||||
config.agent.clone(),
|
||||
deps,
|
||||
@@ -733,6 +763,10 @@ struct WasmChannelSetup {
|
||||
channels: Vec<(String, Box<dyn ironclaw::channels::Channel>)>,
|
||||
channel_names: Vec<String>,
|
||||
webhook_routes: Option<axum::Router>,
|
||||
/// Runtime objects needed for hot-activation via ExtensionManager.
|
||||
wasm_channel_runtime: Arc<WasmChannelRuntime>,
|
||||
pairing_store: Arc<PairingStore>,
|
||||
wasm_channel_router: Arc<WasmChannelRouter>,
|
||||
}
|
||||
|
||||
/// Load WASM channels and register their webhook routes.
|
||||
@@ -750,7 +784,7 @@ async fn setup_wasm_channels(
|
||||
};
|
||||
|
||||
let pairing_store = Arc::new(PairingStore::new());
|
||||
let loader = WasmChannelLoader::new(Arc::clone(&runtime), pairing_store);
|
||||
let loader = WasmChannelLoader::new(Arc::clone(&runtime), Arc::clone(&pairing_store));
|
||||
|
||||
let results = match loader
|
||||
.load_from_dir(&config.channels.wasm_channels_dir)
|
||||
@@ -879,7 +913,7 @@ async fn setup_wasm_channels(
|
||||
|
||||
let webhook_routes = if has_webhook_channels {
|
||||
Some(create_wasm_channel_router(
|
||||
wasm_router,
|
||||
Arc::clone(&wasm_router),
|
||||
extension_manager.map(Arc::clone),
|
||||
))
|
||||
} else {
|
||||
@@ -890,6 +924,9 @@ async fn setup_wasm_channels(
|
||||
channels,
|
||||
channel_names,
|
||||
webhook_routes,
|
||||
wasm_channel_runtime: runtime,
|
||||
pairing_store,
|
||||
wasm_channel_router: wasm_router,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,377 @@
|
||||
//! Unified WASM artifact resolution: find, build, and install WASM components.
|
||||
//!
|
||||
//! This module consolidates all WASM artifact logic that was previously duplicated
|
||||
//! across `cli/tool.rs`, `registry/installer.rs`, `extensions/manager.rs`,
|
||||
//! `channels/wasm/bundled.rs`, and `tools/wasm/loader.rs`.
|
||||
//!
|
||||
//! # Functions
|
||||
//!
|
||||
//! - [`resolve_target_dir`] — resolve the cargo target directory for a crate
|
||||
//! - [`find_wasm_artifact`] — find a compiled `.wasm` by crate name across all triples
|
||||
//! - [`find_any_wasm_artifact`] — find any `.wasm` file (fallback when name is unknown)
|
||||
//! - [`build_wasm_component`] — async build via `cargo component build`
|
||||
//! - [`build_wasm_component_sync`] — sync build for CLI use
|
||||
//! - [`install_wasm_files`] — copy `.wasm` + optional `.capabilities.json` to install dir
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tokio::fs;
|
||||
|
||||
/// WASM target triples to search, in priority order.
|
||||
const WASM_TRIPLES: &[&str] = &[
|
||||
"wasm32-wasip1",
|
||||
"wasm32-wasip2",
|
||||
"wasm32-wasi",
|
||||
"wasm32-unknown-unknown",
|
||||
];
|
||||
|
||||
/// Resolve the cargo target directory for a crate.
|
||||
///
|
||||
/// Checks (in order):
|
||||
/// 1. `CARGO_TARGET_DIR` env var (shared target dir)
|
||||
/// 2. `<crate_dir>/target/` (default per-crate layout)
|
||||
pub fn resolve_target_dir(crate_dir: &Path) -> PathBuf {
|
||||
if let Ok(dir) = std::env::var("CARGO_TARGET_DIR") {
|
||||
let p = PathBuf::from(dir);
|
||||
// Resolve relative CARGO_TARGET_DIR against crate_dir
|
||||
if p.is_relative() {
|
||||
return crate_dir.join(p);
|
||||
}
|
||||
return p;
|
||||
}
|
||||
crate_dir.join("target")
|
||||
}
|
||||
|
||||
/// Find a compiled WASM artifact by searching across all target triples.
|
||||
///
|
||||
/// Tries exact name match first (with hyphen-to-underscore normalization),
|
||||
/// then falls back to searching in whichever target directory exists.
|
||||
/// `profile` is `"release"` or `"debug"`.
|
||||
pub fn find_wasm_artifact(crate_dir: &Path, crate_name: &str, profile: &str) -> Option<PathBuf> {
|
||||
let target_base = resolve_target_dir(crate_dir);
|
||||
let snake_name = crate_name.replace('-', "_");
|
||||
|
||||
// Try exact name match in each target triple directory
|
||||
for triple in WASM_TRIPLES {
|
||||
let dir = target_base.join(triple).join(profile);
|
||||
let candidates = [
|
||||
dir.join(format!("{}.wasm", crate_name)),
|
||||
dir.join(format!("{}.wasm", snake_name)),
|
||||
];
|
||||
for candidate in &candidates {
|
||||
if candidate.exists() {
|
||||
return Some(candidate.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Find any `.wasm` file in the target dirs (fallback when crate name is unknown).
|
||||
///
|
||||
/// Returns the first `.wasm` found across target triples.
|
||||
pub fn find_any_wasm_artifact(crate_dir: &Path, profile: &str) -> Option<PathBuf> {
|
||||
let target_base = resolve_target_dir(crate_dir);
|
||||
|
||||
for triple in WASM_TRIPLES {
|
||||
let dir = target_base.join(triple).join(profile);
|
||||
if !dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
if let Ok(entries) = std::fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().map(|ext| ext == "wasm").unwrap_or(false) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Build a WASM component using `cargo-component` (async).
|
||||
///
|
||||
/// Streams build output to the terminal. Returns the path to the built artifact.
|
||||
pub async fn build_wasm_component(
|
||||
source_dir: &Path,
|
||||
crate_name: &str,
|
||||
release: bool,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
use tokio::process::Command;
|
||||
|
||||
// Check cargo-component availability
|
||||
let check = Command::new("cargo")
|
||||
.args(["component", "--version"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.await;
|
||||
|
||||
if check.is_err() || !check.as_ref().map(|s| s.success()).unwrap_or(false) {
|
||||
anyhow::bail!("cargo-component not found. Install with: cargo install cargo-component");
|
||||
}
|
||||
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.current_dir(source_dir).args(["component", "build"]);
|
||||
|
||||
if release {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
|
||||
// Use status() with inherited stdio so build output streams to the terminal.
|
||||
let status = cmd.status().await?;
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!("Build failed (exit code: {})", status);
|
||||
}
|
||||
|
||||
let profile = if release { "release" } else { "debug" };
|
||||
let wasm_filename = format!("{}.wasm", crate_name.replace('-', "_"));
|
||||
|
||||
// Look for the specific crate's WASM file across target triples
|
||||
find_wasm_artifact(source_dir, wasm_filename.trim_end_matches(".wasm"), profile)
|
||||
.or_else(|| {
|
||||
// Fall back: search by crate_name directly
|
||||
find_wasm_artifact(source_dir, crate_name, profile)
|
||||
})
|
||||
.or_else(|| find_any_wasm_artifact(source_dir, profile))
|
||||
.ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"Could not find {} in {}/target/*/{}/ after build",
|
||||
wasm_filename,
|
||||
source_dir.display(),
|
||||
profile,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Build a WASM component using `cargo-component` (sync, for CLI use).
|
||||
///
|
||||
/// Returns the path to the built artifact.
|
||||
pub fn build_wasm_component_sync(source_dir: &Path, release: bool) -> anyhow::Result<PathBuf> {
|
||||
use std::process::Command;
|
||||
|
||||
println!("Building WASM component in {}...", source_dir.display());
|
||||
|
||||
// Check if cargo-component is available
|
||||
let check = Command::new("cargo")
|
||||
.args(["component", "--version"])
|
||||
.output();
|
||||
|
||||
if check.is_err() || !check.as_ref().map(|o| o.status.success()).unwrap_or(false) {
|
||||
anyhow::bail!(
|
||||
"cargo-component not found. Install with: cargo install cargo-component\n\
|
||||
Or use --skip-build with an existing .wasm file."
|
||||
);
|
||||
}
|
||||
|
||||
let mut cmd = Command::new("cargo");
|
||||
cmd.current_dir(source_dir).args(["component", "build"]);
|
||||
|
||||
if release {
|
||||
cmd.arg("--release");
|
||||
}
|
||||
|
||||
println!(
|
||||
" Running: cargo component build{}",
|
||||
if release { " --release" } else { "" }
|
||||
);
|
||||
|
||||
let output = cmd.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
anyhow::bail!("Build failed:\n{}", stderr);
|
||||
}
|
||||
|
||||
let profile = if release { "release" } else { "debug" };
|
||||
|
||||
// Find the built artifact
|
||||
find_any_wasm_artifact(source_dir, profile).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"No .wasm file found after build in {}/target/*/{}",
|
||||
source_dir.display(),
|
||||
profile,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Copy WASM binary + optional `capabilities.json` sidecar to an install directory.
|
||||
///
|
||||
/// Looks for capabilities files in `source_dir` matching several naming conventions.
|
||||
/// Returns the destination wasm path.
|
||||
pub async fn install_wasm_files(
|
||||
wasm_src: &Path,
|
||||
source_dir: &Path,
|
||||
name: &str,
|
||||
target_dir: &Path,
|
||||
force: bool,
|
||||
) -> anyhow::Result<PathBuf> {
|
||||
fs::create_dir_all(target_dir).await?;
|
||||
|
||||
let wasm_dst = target_dir.join(format!("{}.wasm", name));
|
||||
let caps_dst = target_dir.join(format!("{}.capabilities.json", name));
|
||||
|
||||
if wasm_dst.exists() && !force {
|
||||
anyhow::bail!(
|
||||
"Tool '{}' already exists at {}. Use --force to overwrite.",
|
||||
name,
|
||||
wasm_dst.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Copy WASM binary
|
||||
fs::copy(wasm_src, &wasm_dst).await?;
|
||||
|
||||
// Look for capabilities.json sidecar in the source directory
|
||||
let caps_candidates = [
|
||||
source_dir.join(format!("{}.capabilities.json", name)),
|
||||
source_dir.join(format!("{}-tool.capabilities.json", name)),
|
||||
source_dir.join("capabilities.json"),
|
||||
];
|
||||
for caps_src in &caps_candidates {
|
||||
if caps_src.exists() {
|
||||
if let Err(e) = fs::copy(caps_src, &caps_dst).await {
|
||||
tracing::warn!(
|
||||
"Failed to copy capabilities sidecar {} -> {}: {}",
|
||||
caps_src.display(),
|
||||
caps_dst.display(),
|
||||
e,
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(wasm_dst)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_resolve_target_dir_default() {
|
||||
// When CARGO_TARGET_DIR is not set, should return <crate_dir>/target
|
||||
let dir = Path::new("/some/crate");
|
||||
let result = resolve_target_dir(dir);
|
||||
assert!(result.ends_with("target"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_wasm_artifact_not_found() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
assert!(find_wasm_artifact(dir.path(), "nonexistent", "release").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_wasm_artifact_found() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let target_base = resolve_target_dir(dir.path());
|
||||
let wasm_dir = target_base.join("wasm32-wasip2/release");
|
||||
std::fs::create_dir_all(&wasm_dir).unwrap();
|
||||
std::fs::File::create(wasm_dir.join("my_tool.wasm")).unwrap();
|
||||
|
||||
let result = find_wasm_artifact(dir.path(), "my_tool", "release");
|
||||
assert!(result.is_some());
|
||||
assert!(result.unwrap().ends_with("my_tool.wasm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_wasm_artifact_hyphen_to_underscore() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let target_base = resolve_target_dir(dir.path());
|
||||
let wasm_dir = target_base.join("wasm32-wasip1/release");
|
||||
std::fs::create_dir_all(&wasm_dir).unwrap();
|
||||
std::fs::File::create(wasm_dir.join("my_tool.wasm")).unwrap();
|
||||
|
||||
// Search with hyphens, should find underscore version
|
||||
let result = find_wasm_artifact(dir.path(), "my-tool", "release");
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_any_wasm_artifact_found() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let target_base = resolve_target_dir(dir.path());
|
||||
let wasm_dir = target_base.join("wasm32-wasip2/release");
|
||||
std::fs::create_dir_all(&wasm_dir).unwrap();
|
||||
std::fs::File::create(wasm_dir.join("something.wasm")).unwrap();
|
||||
|
||||
let result = find_any_wasm_artifact(dir.path(), "release");
|
||||
assert!(result.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_any_wasm_artifact_not_found() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
assert!(find_any_wasm_artifact(dir.path(), "release").is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_wasm_files_copies() {
|
||||
let src_dir = TempDir::new().unwrap();
|
||||
let target_dir = TempDir::new().unwrap();
|
||||
|
||||
let wasm_src = src_dir.path().join("test.wasm");
|
||||
tokio::fs::write(&wasm_src, b"\0asm\x01\x00\x00\x00")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Create a capabilities file
|
||||
let caps_src = src_dir.path().join("mytool.capabilities.json");
|
||||
tokio::fs::write(&caps_src, b"{}").await.unwrap();
|
||||
|
||||
let result = install_wasm_files(
|
||||
&wasm_src,
|
||||
src_dir.path(),
|
||||
"mytool",
|
||||
target_dir.path(),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let wasm_dst = result.unwrap();
|
||||
assert!(wasm_dst.exists());
|
||||
assert!(target_dir.path().join("mytool.capabilities.json").exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_install_wasm_files_refuses_overwrite() {
|
||||
let src_dir = TempDir::new().unwrap();
|
||||
let target_dir = TempDir::new().unwrap();
|
||||
|
||||
let wasm_src = src_dir.path().join("test.wasm");
|
||||
tokio::fs::write(&wasm_src, b"\0asm").await.unwrap();
|
||||
|
||||
// Pre-create the target
|
||||
let existing = target_dir.path().join("mytool.wasm");
|
||||
tokio::fs::write(&existing, b"existing").await.unwrap();
|
||||
|
||||
let result = install_wasm_files(
|
||||
&wasm_src,
|
||||
src_dir.path(),
|
||||
"mytool",
|
||||
target_dir.path(),
|
||||
false,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wasm_triples_order() {
|
||||
// Verify the order is as documented
|
||||
assert_eq!(WASM_TRIPLES[0], "wasm32-wasip1");
|
||||
assert_eq!(WASM_TRIPLES[1], "wasm32-wasip2");
|
||||
assert_eq!(WASM_TRIPLES[2], "wasm32-wasi");
|
||||
assert_eq!(WASM_TRIPLES[3], "wasm32-unknown-unknown");
|
||||
}
|
||||
}
|
||||
@@ -94,12 +94,13 @@ impl RegistryInstaller {
|
||||
source_dir.display()
|
||||
);
|
||||
let crate_name = &manifest.source.crate_name;
|
||||
let wasm_path = build_wasm_component(&source_dir, crate_name)
|
||||
.await
|
||||
.map_err(|e| RegistryError::ManifestRead {
|
||||
path: source_dir.clone(),
|
||||
reason: format!("build failed: {}", e),
|
||||
})?;
|
||||
let wasm_path =
|
||||
crate::registry::artifacts::build_wasm_component(&source_dir, crate_name, true)
|
||||
.await
|
||||
.map_err(|e| RegistryError::ManifestRead {
|
||||
path: source_dir.clone(),
|
||||
reason: format!("build failed: {}", e),
|
||||
})?;
|
||||
|
||||
// Copy WASM binary
|
||||
println!(" Installing to {}", target_wasm.display());
|
||||
@@ -353,64 +354,6 @@ impl RegistryInstaller {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a WASM component from a source directory using `cargo component build --release`.
|
||||
///
|
||||
/// Uses `tokio::process::Command` with inherited stdio so build progress is visible.
|
||||
/// Looks for the specific `{crate_name}.wasm` in the release directory rather than
|
||||
/// picking the first `.wasm` file found.
|
||||
async fn build_wasm_component(source_dir: &Path, crate_name: &str) -> anyhow::Result<PathBuf> {
|
||||
use tokio::process::Command;
|
||||
|
||||
// Check cargo-component availability
|
||||
let check = Command::new("cargo")
|
||||
.args(["component", "--version"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
.await;
|
||||
|
||||
if check.is_err() || !check.as_ref().map(|s| s.success()).unwrap_or(false) {
|
||||
anyhow::bail!("cargo-component not found. Install with: cargo install cargo-component");
|
||||
}
|
||||
|
||||
// Use status() with inherited stdio so build output streams to the terminal.
|
||||
let status = Command::new("cargo")
|
||||
.current_dir(source_dir)
|
||||
.args(["component", "build", "--release"])
|
||||
.status()
|
||||
.await?;
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!("Build failed (exit code: {})", status);
|
||||
}
|
||||
|
||||
// Look for the specific crate's WASM file (Cargo uses underscores in artifact names).
|
||||
let wasm_filename = format!("{}.wasm", crate_name.replace('-', "_"));
|
||||
let target_base = source_dir.join("target");
|
||||
let candidates = [
|
||||
"wasm32-wasip1",
|
||||
"wasm32-wasip2",
|
||||
"wasm32-wasi",
|
||||
"wasm32-unknown-unknown",
|
||||
];
|
||||
|
||||
for target in &candidates {
|
||||
let wasm_path = target_base
|
||||
.join(target)
|
||||
.join("release")
|
||||
.join(&wasm_filename);
|
||||
if wasm_path.exists() {
|
||||
return Ok(wasm_path);
|
||||
}
|
||||
}
|
||||
|
||||
anyhow::bail!(
|
||||
"Could not find {} in {}/target/*/release/",
|
||||
wasm_filename,
|
||||
source_dir.display()
|
||||
)
|
||||
}
|
||||
|
||||
/// Download an artifact from a URL.
|
||||
async fn download_artifact(url: &str) -> Result<bytes::Bytes, RegistryError> {
|
||||
let response = reqwest::get(url)
|
||||
|
||||
@@ -165,12 +165,14 @@ impl ExtensionManifest {
|
||||
ExtensionSource::WasmBuildable {
|
||||
repo_url: self.source.dir.clone(),
|
||||
build_dir: Some(self.source.dir.clone()),
|
||||
crate_name: Some(self.source.crate_name.clone()),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ExtensionSource::WasmBuildable {
|
||||
repo_url: self.source.dir.clone(),
|
||||
build_dir: Some(self.source.dir.clone()),
|
||||
crate_name: Some(self.source.crate_name.clone()),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
//! └── _bundles.json <- Bundle definitions (google, messaging, default)
|
||||
//! ```
|
||||
|
||||
pub mod artifacts;
|
||||
pub mod catalog;
|
||||
pub mod embedded;
|
||||
pub mod installer;
|
||||
|
||||
@@ -798,10 +798,10 @@ Create alongside the .wasm file to grant capabilities:
|
||||
match (&requirement.software_type, &requirement.language) {
|
||||
(SoftwareType::WasmTool, Language::Rust) => {
|
||||
// WASM output location
|
||||
project_dir.join(format!(
|
||||
"target/wasm32-wasip2/release/{}.wasm",
|
||||
requirement.name.replace('-', "_")
|
||||
))
|
||||
crate::tools::wasm::wasm_artifact_path(
|
||||
project_dir,
|
||||
&requirement.name.replace('-', "_"),
|
||||
)
|
||||
}
|
||||
(SoftwareType::CliBinary, Language::Rust) => project_dir.join(format!(
|
||||
"target/release/{}",
|
||||
|
||||
@@ -30,7 +30,8 @@ impl Tool for ToolSearchTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Search for available extensions (MCP servers, WASM tools, WASM channels) to add. \
|
||||
"Search for available extensions to add new capabilities. Extensions include \
|
||||
channels (Telegram, Slack, Discord — for messaging), tools, and MCP servers. \
|
||||
Use discover:true to search online if the built-in registry has no results."
|
||||
}
|
||||
|
||||
@@ -100,7 +101,7 @@ impl Tool for ToolInstallTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Install an extension (MCP server, WASM tool, or WASM channel). \
|
||||
"Install an extension (channel, tool, or MCP server). \
|
||||
Use the name from tool_search results, or provide an explicit URL."
|
||||
}
|
||||
|
||||
@@ -278,7 +279,7 @@ impl Tool for ToolActivateTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Activate an installed extension, connecting to MCP servers or loading WASM tools into the runtime."
|
||||
"Activate an installed extension — starts channels, loads tools, or connects to MCP servers."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
@@ -372,7 +373,8 @@ impl Tool for ToolListTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"List all installed extensions with their authentication and activation status."
|
||||
"List extensions with their authentication and activation status. \
|
||||
Set include_available:true to also show registry entries not yet installed."
|
||||
}
|
||||
|
||||
fn parameters_schema(&self) -> serde_json::Value {
|
||||
@@ -383,6 +385,11 @@ impl Tool for ToolListTool {
|
||||
"type": "string",
|
||||
"enum": ["mcp_server", "wasm_tool", "wasm_channel"],
|
||||
"description": "Filter by extension type (omit to list all)"
|
||||
},
|
||||
"include_available": {
|
||||
"type": "boolean",
|
||||
"description": "If true, also include registry entries that are not yet installed",
|
||||
"default": false
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -405,9 +412,14 @@ impl Tool for ToolListTool {
|
||||
_ => None,
|
||||
});
|
||||
|
||||
let include_available = params
|
||||
.get("include_available")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
let extensions = self
|
||||
.manager
|
||||
.list(kind_filter)
|
||||
.list(kind_filter, include_available)
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
|
||||
|
||||
@@ -439,7 +451,7 @@ impl Tool for ToolRemoveTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &str {
|
||||
"Remove an installed extension (MCP server or WASM tool). \
|
||||
"Remove an installed extension (channel, tool, or MCP server). \
|
||||
Unregisters tools and deletes configuration."
|
||||
}
|
||||
|
||||
|
||||
@@ -383,6 +383,31 @@ impl LoadResults {
|
||||
/// Compile-time project root, used to locate tools-src/ in dev builds.
|
||||
const CARGO_MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR");
|
||||
|
||||
/// Resolve the WASM target directory for a given crate directory.
|
||||
///
|
||||
/// Checks (in order):
|
||||
/// 1. `CARGO_TARGET_DIR` env var (shared target dir)
|
||||
/// 2. `<crate_dir>/target/` (default per-crate layout)
|
||||
pub fn resolve_wasm_target_dir(crate_dir: &Path) -> PathBuf {
|
||||
crate::registry::artifacts::resolve_target_dir(crate_dir)
|
||||
}
|
||||
|
||||
/// Return the expected path to a compiled WASM artifact for a given crate.
|
||||
///
|
||||
/// Combines [`resolve_wasm_target_dir`] with the `wasm32-wasip2/release/` subdirectory
|
||||
/// and the binary name without extension (e.g. `slack_tool`).
|
||||
///
|
||||
/// `binary_name` should not include the `.wasm` extension; it is appended automatically.
|
||||
///
|
||||
/// This is a convenience function for callers that know the exact triple (wasip2)
|
||||
/// and binary name. For multi-triple search, use
|
||||
/// [`crate::registry::artifacts::find_wasm_artifact`] instead.
|
||||
pub fn wasm_artifact_path(crate_dir: &Path, binary_name: &str) -> PathBuf {
|
||||
resolve_wasm_target_dir(crate_dir)
|
||||
.join("wasm32-wasip2/release")
|
||||
.join(format!("{}.wasm", binary_name))
|
||||
}
|
||||
|
||||
/// Resolve the tools source directory.
|
||||
///
|
||||
/// Checks (in order):
|
||||
@@ -426,9 +451,7 @@ pub async fn discover_dev_tools() -> Result<HashMap<String, DiscoveredTool>, std
|
||||
let crate_name = dir_name.replace('-', "_");
|
||||
let install_name = format!("{}-tool", dir_name);
|
||||
|
||||
let wasm_path = path
|
||||
.join("target/wasm32-wasip2/release")
|
||||
.join(format!("{}_tool.wasm", crate_name));
|
||||
let wasm_path = wasm_artifact_path(&path, &format!("{}_tool", crate_name));
|
||||
|
||||
if !wasm_path.exists() {
|
||||
continue;
|
||||
|
||||
@@ -123,7 +123,7 @@ pub use storage::{
|
||||
// Loader
|
||||
pub use loader::{
|
||||
DiscoveredTool, LoadResults, WasmLoadError, WasmToolLoader, discover_dev_tools, discover_tools,
|
||||
load_dev_tools,
|
||||
load_dev_tools, resolve_wasm_target_dir, wasm_artifact_path,
|
||||
};
|
||||
|
||||
// Capabilities schema (for parsing *.capabilities.json files)
|
||||
|
||||
@@ -27,10 +27,8 @@ fn normalize(s: &str) -> String {
|
||||
/// Normalize typographic/smart punctuation to ASCII so tests match converter output
|
||||
/// regardless of apostrophe/quote variants (e.g. U+2019 ' → U+0027 ').
|
||||
fn normalize_smart_punctuation(s: &str) -> String {
|
||||
s.replace('\u{2019}', "'") // RIGHT SINGLE QUOTATION MARK
|
||||
.replace('\u{2018}', "'") // LEFT SINGLE QUOTATION MARK
|
||||
.replace('\u{201C}', "\"") // LEFT DOUBLE QUOTATION MARK
|
||||
.replace('\u{201D}', "\"") // RIGHT DOUBLE QUOTATION MARK
|
||||
s.replace(['\u{2019}', '\u{2018}'], "'")
|
||||
.replace(['\u{201C}', '\u{201D}'], "\"")
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -58,15 +56,13 @@ fn convert_test_pages_to_markdown() {
|
||||
.unwrap_or("unknown");
|
||||
let default_url = format!("https://example.com/test-pages/{}/", dir_name);
|
||||
|
||||
let metadata: PageMetadata = path
|
||||
.join("metadata.json")
|
||||
.is_file()
|
||||
.then(|| {
|
||||
let raw = std::fs::read_to_string(path.join("metadata.json"))
|
||||
.expect("read metadata.json");
|
||||
serde_json::from_str(&raw).expect("invalid metadata.json")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let metadata: PageMetadata = if path.join("metadata.json").is_file() {
|
||||
let raw =
|
||||
std::fs::read_to_string(path.join("metadata.json")).expect("read metadata.json");
|
||||
serde_json::from_str(&raw).expect("invalid metadata.json")
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
|
||||
let url = metadata.url.as_deref().unwrap_or(&default_url).to_string();
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ fn okta_api_call(method: &str, url: &str, body: Option<&str>) -> Result<String,
|
||||
&format!("Okta API: {} {}", method, url),
|
||||
);
|
||||
|
||||
let response = host::http_request(method, url, headers, body_bytes.as_deref())?;
|
||||
let response = host::http_request(method, url, headers, body_bytes.as_deref(), None)?;
|
||||
|
||||
if response.status < 200 || response.status >= 300 {
|
||||
let body_text = String::from_utf8_lossy(&response.body);
|
||||
@@ -224,7 +224,7 @@ fn okta_api_call_with_headers(
|
||||
&format!("Okta API: {} {}", method, url),
|
||||
);
|
||||
|
||||
let response = host::http_request(method, url, headers, body_bytes.as_deref())?;
|
||||
let response = host::http_request(method, url, headers, body_bytes.as_deref(), None)?;
|
||||
|
||||
if response.status < 200 || response.status >= 300 {
|
||||
let body_text = String::from_utf8_lossy(&response.body);
|
||||
|
||||
Reference in New Issue
Block a user