mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
* feat: add inbound attachment support to WASM channel system Add attachment record to WIT interface and implement inbound media parsing across all four channel implementations (Telegram, Slack, WhatsApp, Discord). Attachments flow from WASM channels through EmittedMessage to IncomingMessage with validation (size limits, MIME allowlist, count caps) at the host boundary. - Add `attachment` record to `emitted-message` in wit/channel.wit - Add `IncomingAttachment` struct to channel.rs and re-export - Add host-side validation (20MB total, 10 max, MIME allowlist) - Telegram: parse photo, document, audio, video, voice, sticker - Slack: parse file attachments with url_private - WhatsApp: parse image, audio, video, document with captions - Discord: backward-compatible empty attachments - Update FEATURE_PARITY.md section 7 - Add fixture-based tests per channel and host integration tests [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: integrate outbound attachment support and reconcile WIT types (#409) Reconcile PR #409's outbound attachment work with our inbound attachment support into a unified design: WIT type split: - `inbound-attachment` in channel-host: metadata-only (id, mime_type, filename, size_bytes, source_url, storage_key, extracted_text) - `attachment` in channel: raw bytes (filename, mime_type, data) on agent-response for outbound sending Outbound features (from PR #409): - `on-broadcast` WIT export for proactive messages without prior inbound - Telegram: multipart sendPhoto/sendDocument with auto photo→document fallback for files >10MB - wrapper.rs: `call_on_broadcast`, `read_attachments` from disk, attachment params threaded through `call_on_respond` - HTTP tool: `save_to` param for binary downloads to /tmp/ (50MB limit, path traversal protection, SSRF-safe redirect following) - Message tool: allow /tmp/ paths for attachments alongside base_dir - Credential env var fallback in inject_channel_credentials Channel updates: - All 4 channels implement on_broadcast (Telegram full, others stub) - Telegram: polling_enabled config, adjusted poll timeout - Inbound attachment types renamed to InboundAttachment in all channels Tests: 1965 passing (9 new), 0 clippy warnings [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add audio transcription pipeline and extensible WIT attachment design Add host-side transcription middleware (OpenAI Whisper) that detects audio attachments with inline data on incoming messages and transcribes them automatically. Refactor WIT inbound-attachment to use extras-json and a store-attachment-data host function instead of typed fields, so future attachment properties (dimensions, codec, etc.) don't require WIT changes that invalidate all channel plugins. - Add src/transcription/ module: TranscriptionProvider trait, TranscriptionMiddleware, AudioFormat enum, OpenAI Whisper provider - Add src/config/transcription.rs: TRANSCRIPTION_ENABLED/MODEL/BASE_URL - Wire middleware into agent message loop via AgentDeps - WIT: replace data + duration-secs with extras-json + store-attachment-data - Host: parse extras-json for well-known keys, merge stored binary data - Telegram: download voice files via store-attachment-data, add duration to extras-json, add /file/bot to HTTP allowlist, voice-only placeholder - Add reqwest multipart feature for Whisper API uploads - 5 regression tests for transcription middleware Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: wire attachment processing into LLM pipeline with multimodal image support Attachments on incoming messages are now augmented into user text via XML tags before entering the turn system, and images with data are passed as multimodal content parts (base64 data URIs) to LLM providers. This enables audio transcripts, document text, and image content to reach the LLM without changes to ChatMessage serialization or provider interfaces. - Add src/agent/attachments.rs with augment_with_attachments() and 9 unit tests - Add ContentPart/ImageUrl types to llm::provider with OpenAI-compatible serde - Carry image_content_parts transiently on Turn (skipped in serialization) - Update nearai_chat and rig_adapter to serialize multimodal content - Add 3 e2e tests verifying attachments flow through the full agent loop Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: CI failures — formatting, version bumps, and Telegram voice test - Fix cargo fmt formatting in attachments.rs, nearai_chat.rs, rig_adapter.rs, e2e_attachments.rs - Bump channel registry versions 0.1.0 → 0.2.0 (discord, slack, telegram, whatsapp) to satisfy version-bump CI check - Fix Telegram test_extract_attachments_voice: add missing required `duration` field to voice fixture JSON Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: bump WIT channel version to 0.3.0, fix Telegram voice test, add pre-commit hook - Bump wit/channel.wit package version 0.2.0 → 0.3.0 (interface changed with store-attachment-data) - Update WIT_CHANNEL_VERSION constant and registry wit_version fields to match - Fix Telegram test_extract_attachments_voice: gate voice download behind #[cfg(target_arch = "wasm32")] so host functions aren't called in native tests, update assertions for generated filename and extras_json duration - Add @0.3.0 linker stubs in wit_compat.rs - Add .githooks/pre-commit hook that runs scripts/check-version-bumps.sh when WIT or extension sources are staged - Symlink commit-msg regression hook into .githooks/ [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * refactor: extract voice download from extract_attachments into handle_message Move download_voice_file + store_attachment_data calls out of extract_attachments into a separate download_and_store_voice function called from handle_message. This keeps extract_attachments as a pure data-mapping function with no host calls, making it fully testable in native unit tests without #[cfg(target_arch)] gates. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review comments — security, correctness, and code quality Security fixes: - Add path validation to read_attachments (restrict to /tmp/) preventing arbitrary file reads from compromised tools - Escape XML special characters in attachment filenames, MIME types, and extracted text to prevent prompt injection via tag spoofing - Percent-encode file_id in Telegram getFile URL to prevent query injection - Clone SecretString directly instead of expose_secret().to_string() Correctness fixes: - Fix store_attachment_data overwrite accounting: subtract old entry size before adding new to prevent inflated totals and false rejections - Use max(reported, stored_size) for attachment size accounting to prevent WASM channels from under-reporting size_bytes to bypass limits - Add application/octet-stream to MIME allowlist (channels default unknown types to this) Code quality: - Extract send_response helper in Telegram, deduplicating on_respond and on_broadcast - Rename misleading Discord test to test_parse_slash_command_interaction - Fix .githooks/commit-msg to use relative symlink (portable across machines) [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add tool_upgrade command + fix TOCTOU in save_to path validation Add `tool_upgrade` — a new extension management tool that automatically detects and reinstalls WASM extensions with outdated WIT versions. Preserves authentication secrets during upgrade. Supports upgrading a single extension by name or all installed WASM tools/channels at once. Fix TOCTOU in `validate_save_to_path`: validate the path *before* creating parent directories, so traversal paths like `/tmp/../../etc/` cannot cause filesystem mutations outside /tmp before being rejected. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: unify WIT package version to 0.3.0 across tool.wit and all capabilities tool.wit and channel.wit share the `near:agent` package namespace, so they must declare the same version. Bumps tool.wit from 0.2.0 to 0.3.0 and updates all capabilities files and registry entries to match. Fixes `cargo component build` failure: "package identifier near:[email protected] does not match previous package name of near:[email protected]" [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: move WIT file comments after package declaration WIT treats `//` comments before `package` as doc comments. When both tool.wit and channel.wit had header comments, the parser rejected them as "doc comments on multiple 'package' items". Move comments after the package declaration in both files. Also bumps tool registry versions to 0.2.0 to match the WIT 0.3.0 bump. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: display extension versions in gateway Extensions tab Add version field to InstalledExtension and RegistryEntry types, pipe through the web API (ExtensionInfo, RegistryEntryInfo), and render as a badge in the gateway UI for both installed and available extensions. For installed WASM extensions, version is read from the capabilities file with a fallback to the registry entry when the local file has no version (old installations). Bump all extension Cargo.toml and registry JSON versions from 0.1.0 to 0.2.0 to keep them in sync. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: add document text extraction middleware for PDF, Office, and text files Extract text from document attachments (PDF, DOCX, PPTX, XLSX, RTF, plain text, code files) so the LLM can reason about uploaded documents. Uses pdf-extract for PDFs, zip+XML parsing for Office XML formats, and UTF-8 decode for text files. Wired into the agent loop after transcription middleware. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: download document files in Telegram channel for text extraction The DocumentExtractionMiddleware needs file bytes in the attachment `data` field, but only voice files were being downloaded. Document attachments (PDFs, DOCX, etc.) had empty `data` and a source_url with a credential placeholder that only works inside the WASM host's http_request. Add `download_and_store_documents()` that downloads non-voice, non-image, non-audio attachments via the existing two-step getFile→download flow and stores bytes via `store_attachment_data` for host-side extraction. Also rename `download_voice_file` → `download_telegram_file` since it's generic for any file_id. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: allow Office MIME types and increase file download limit for Telegram Two issues preventing document extraction from Telegram: 1. PPTX/DOCX/XLSX MIME types (application/vnd.*) were dropped by the WASM host attachment allowlist — add application/vnd., application/msword, and application/rtf prefixes. 2. Telegram file downloads over 10 MB failed with "Response body too large" — set max_response_bytes to 20 MB in Telegram capabilities. [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: report document extraction errors back to user instead of silently skipping - Bump max_response_bytes to 50 MB for Telegram file downloads - When document extraction fails (too large, download error, parse error), set extracted_text to a user-friendly error message instead of leaving it None. This ensures the LLM tells the user what went wrong. - On Telegram download failure, set extracted_text with the error so the user sees feedback even when the file never reaches the extraction middleware. Co-Authored-By: Claude Opus 4.6 <[email protected]> * feat: store extracted document text in workspace memory for search/recall After document extraction succeeds, write the extracted text to workspace memory at `documents/{date}/{filename}`. This enables: - Full-text and semantic search over past uploaded documents - Cross-conversation recall ("what did that PDF say?") - Automatic chunking and embedding via the workspace pipeline Documents are stored with metadata header (uploader, channel, date, MIME type). Error messages (extraction failures) are not stored — only successful extractions. Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: CI failures — formatting, unused assignment warning - Run cargo fmt on document_extraction and agent_loop modules - Suppress unused_assignments warning on trace_llm_ref (used only behind #[cfg(feature = "libsql")]) [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address PR review comments — security, correctness, and code quality Security fixes: - Remove SSRF-prone download() from DocumentExtractionMiddleware (#13) - Sanitize filenames in workspace path to prevent directory traversal (#11) - Pre-check file size before reading in WASM wrapper to prevent OOM (#2) - Percent-encode file_id in Telegram source URLs (#7) Correctness fixes: - Clear image_content_parts on turn end to prevent memory leak (#1) - Find first *successful* transcription instead of first overall (#3) - Enforce data.len() size limit in document extraction (#10) - Use UTF-8 safe truncation with char_indices() (#12) Robustness & code quality: - Add 120s timeout to OpenAI Whisper HTTP client (#5) - Trim trailing slash from Whisper base_url (#6) - Allow ~/.ironclaw/ paths in WASM wrapper (#8) - Return error from on_broadcast in Slack/Discord/WhatsApp (#9) - Fix doc comment in HTTP tool (#4) Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: formatting — cargo fmt Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: address latest PR review — doc comments, error messages, version bumps - Fix DocumentExtractionMiddleware doc comment (no longer downloads from source_url) - Fix error message: "no inline data" instead of "no download URL" - Log error + fallback instead of silent unwrap_or_default on Whisper HTTP client - Bump all capabilities.json versions from 0.1.0 to 0.2.0 to match Cargo.toml Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: remove unsupported profile: minimal from CI workflows [skip-regression-check] dtolnay/rust-toolchain@stable does not accept the 'profile' input (it was a parameter for the deprecated actions-rs/toolchain action). Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix: merge with latest main — resolve compilation errors and PR review nits - Add version: None to RegistryEntry/InstalledExtension test constructors - Fix MessageContent type mismatches in nearai_chat tests (String → MessageContent::Text) - Fix .contains() calls on MessageContent — use .as_text().unwrap() - Remove redundant trace_llm_ref = None assignment in test_rig - Check data size before clone in document extraction to avoid unnecessary allocation [skip-regression-check] Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
3964 lines
150 KiB
Rust
3964 lines
150 KiB
Rust
//! Central extension manager that dispatches operations by ExtensionKind.
|
|
//!
|
|
//! Holds references to channel runtime, WASM tool runtime, MCP infrastructure,
|
|
//! secrets store, and tool registry. All extension operations (search, install,
|
|
//! auth, activate, list, remove) flow through here.
|
|
|
|
use std::collections::{HashMap, HashSet};
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use tokio::sync::RwLock;
|
|
|
|
use crate::channels::ChannelManager;
|
|
use crate::channels::wasm::{
|
|
RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter, WasmChannelRuntime,
|
|
};
|
|
use crate::extensions::discovery::OnlineDiscovery;
|
|
use crate::extensions::registry::ExtensionRegistry;
|
|
use crate::extensions::{
|
|
ActivateResult, AuthResult, ExtensionError, ExtensionKind, ExtensionSource, InstallResult,
|
|
InstalledExtension, RegistryEntry, ResultSource, SearchResult, ToolAuthState, UpgradeOutcome,
|
|
UpgradeResult,
|
|
};
|
|
use crate::hooks::HookRegistry;
|
|
use crate::pairing::PairingStore;
|
|
use crate::secrets::{CreateSecretParams, SecretsStore};
|
|
use crate::tools::ToolRegistry;
|
|
use crate::tools::mcp::McpClient;
|
|
use crate::tools::mcp::auth::{
|
|
PkceChallenge, authorize_mcp_server, build_authorization_url, discover_full_oauth_metadata,
|
|
find_available_port, is_authenticated, register_client,
|
|
};
|
|
use crate::tools::mcp::config::McpServerConfig;
|
|
use crate::tools::mcp::session::McpSessionManager;
|
|
use crate::tools::wasm::{WasmToolLoader, WasmToolRuntime, discover_tools};
|
|
|
|
/// Pending OAuth authorization state.
|
|
struct PendingAuth {
|
|
_name: String,
|
|
_kind: ExtensionKind,
|
|
created_at: std::time::Instant,
|
|
/// Background task listening for the OAuth callback.
|
|
/// Aborted when a new auth flow starts for the same extension.
|
|
task_handle: Option<tokio::task::JoinHandle<()>>,
|
|
}
|
|
|
|
/// Runtime infrastructure needed for hot-activating WASM channels.
|
|
///
|
|
/// Set after construction via [`ExtensionManager::set_channel_runtime`] once the
|
|
/// channel manager, WASM runtime, pairing store, and webhook router are available.
|
|
struct ChannelRuntimeState {
|
|
channel_manager: Arc<ChannelManager>,
|
|
wasm_channel_runtime: Arc<WasmChannelRuntime>,
|
|
pairing_store: Arc<PairingStore>,
|
|
wasm_channel_router: Arc<WasmChannelRouter>,
|
|
wasm_channel_owner_ids: std::collections::HashMap<String, i64>,
|
|
}
|
|
|
|
/// Result of saving setup secrets and attempting activation.
|
|
pub struct SetupResult {
|
|
/// Human-readable status message.
|
|
pub message: String,
|
|
/// Whether the channel was successfully activated after saving secrets.
|
|
pub activated: bool,
|
|
/// OAuth authorization URL for the UI to open (if OAuth flow was started).
|
|
pub auth_url: Option<String>,
|
|
}
|
|
|
|
/// Central manager for extension lifecycle operations.
|
|
pub struct ExtensionManager {
|
|
registry: ExtensionRegistry,
|
|
discovery: OnlineDiscovery,
|
|
|
|
// MCP infrastructure
|
|
mcp_session_manager: Arc<McpSessionManager>,
|
|
/// Active MCP clients keyed by server name.
|
|
mcp_clients: RwLock<HashMap<String, Arc<McpClient>>>,
|
|
|
|
// WASM tool infrastructure
|
|
wasm_tool_runtime: Option<Arc<WasmToolRuntime>>,
|
|
wasm_tools_dir: PathBuf,
|
|
wasm_channels_dir: PathBuf,
|
|
|
|
// WASM channel hot-activation infrastructure (set post-construction)
|
|
channel_runtime: RwLock<Option<ChannelRuntimeState>>,
|
|
|
|
// Shared
|
|
secrets: Arc<dyn SecretsStore + Send + Sync>,
|
|
tool_registry: Arc<ToolRegistry>,
|
|
hooks: Option<Arc<HookRegistry>>,
|
|
pending_auth: RwLock<HashMap<String, PendingAuth>>,
|
|
/// Tunnel URL for webhook configuration and remote OAuth callbacks.
|
|
tunnel_url: Option<String>,
|
|
user_id: String,
|
|
/// Optional database store for DB-backed MCP config.
|
|
store: Option<Arc<dyn crate::db::Database>>,
|
|
/// Names of WASM channels that were successfully loaded at startup.
|
|
active_channel_names: RwLock<HashSet<String>>,
|
|
/// Last activation error for each WASM channel (ephemeral, cleared on success).
|
|
activation_errors: RwLock<HashMap<String, String>>,
|
|
/// SSE broadcast sender (set post-construction via `set_sse_sender()`).
|
|
sse_sender:
|
|
RwLock<Option<tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>>>,
|
|
/// Shared registry of pending OAuth flows for gateway-routed callbacks.
|
|
///
|
|
/// Keyed by CSRF `state` parameter. Populated in `start_wasm_oauth()`
|
|
/// when running in gateway mode, consumed by the web gateway's
|
|
/// `/oauth/callback` handler.
|
|
pending_oauth_flows: crate::cli::oauth_defaults::PendingOAuthRegistry,
|
|
/// Gateway auth token for authenticating with the platform token exchange proxy.
|
|
/// Read once at construction from `GATEWAY_AUTH_TOKEN` env var.
|
|
gateway_token: Option<String>,
|
|
}
|
|
|
|
impl ExtensionManager {
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub fn new(
|
|
mcp_session_manager: Arc<McpSessionManager>,
|
|
secrets: Arc<dyn SecretsStore + Send + Sync>,
|
|
tool_registry: Arc<ToolRegistry>,
|
|
hooks: Option<Arc<HookRegistry>>,
|
|
wasm_tool_runtime: Option<Arc<WasmToolRuntime>>,
|
|
wasm_tools_dir: PathBuf,
|
|
wasm_channels_dir: PathBuf,
|
|
tunnel_url: Option<String>,
|
|
user_id: String,
|
|
store: Option<Arc<dyn crate::db::Database>>,
|
|
catalog_entries: Vec<RegistryEntry>,
|
|
) -> Self {
|
|
let registry = if catalog_entries.is_empty() {
|
|
ExtensionRegistry::new()
|
|
} else {
|
|
ExtensionRegistry::new_with_catalog(catalog_entries)
|
|
};
|
|
Self {
|
|
registry,
|
|
discovery: OnlineDiscovery::new(),
|
|
mcp_session_manager,
|
|
mcp_clients: RwLock::new(HashMap::new()),
|
|
wasm_tool_runtime,
|
|
wasm_tools_dir,
|
|
wasm_channels_dir,
|
|
channel_runtime: RwLock::new(None),
|
|
secrets,
|
|
tool_registry,
|
|
hooks,
|
|
pending_auth: RwLock::new(HashMap::new()),
|
|
tunnel_url,
|
|
user_id,
|
|
store,
|
|
active_channel_names: RwLock::new(HashSet::new()),
|
|
activation_errors: RwLock::new(HashMap::new()),
|
|
sse_sender: RwLock::new(None),
|
|
pending_oauth_flows: crate::cli::oauth_defaults::new_pending_oauth_registry(),
|
|
gateway_token: std::env::var("GATEWAY_AUTH_TOKEN").ok(),
|
|
}
|
|
}
|
|
|
|
/// Configure the channel runtime infrastructure for hot-activating WASM channels.
|
|
///
|
|
/// Call after construction (and after wrapping in `Arc`) once the channel
|
|
/// manager, WASM runtime, pairing store, and webhook router are available.
|
|
/// Without this, channel activation returns an error.
|
|
pub async fn set_channel_runtime(
|
|
&self,
|
|
channel_manager: Arc<ChannelManager>,
|
|
wasm_channel_runtime: Arc<WasmChannelRuntime>,
|
|
pairing_store: Arc<PairingStore>,
|
|
wasm_channel_router: Arc<WasmChannelRouter>,
|
|
wasm_channel_owner_ids: std::collections::HashMap<String, i64>,
|
|
) {
|
|
*self.channel_runtime.write().await = Some(ChannelRuntimeState {
|
|
channel_manager,
|
|
wasm_channel_runtime,
|
|
pairing_store,
|
|
wasm_channel_router,
|
|
wasm_channel_owner_ids,
|
|
});
|
|
}
|
|
|
|
/// Register channel names that were loaded at startup.
|
|
/// Called after WASM channels are loaded so `list()` reports accurate active status.
|
|
pub async fn set_active_channels(&self, names: Vec<String>) {
|
|
let mut active = self.active_channel_names.write().await;
|
|
active.extend(names);
|
|
}
|
|
|
|
/// Persist the set of active channel names to the settings store.
|
|
///
|
|
/// Saved under key `activated_channels` so channels auto-activate on restart.
|
|
async fn persist_active_channels(&self) {
|
|
let Some(ref store) = self.store else {
|
|
return;
|
|
};
|
|
let names: Vec<String> = self
|
|
.active_channel_names
|
|
.read()
|
|
.await
|
|
.iter()
|
|
.cloned()
|
|
.collect();
|
|
let value = serde_json::json!(names);
|
|
if let Err(e) = store
|
|
.set_setting(&self.user_id, "activated_channels", &value)
|
|
.await
|
|
{
|
|
tracing::warn!(error = %e, "Failed to persist activated_channels setting");
|
|
}
|
|
}
|
|
|
|
/// Load previously activated channel names from the settings store.
|
|
///
|
|
/// Returns channel names that were activated in a prior session so they can
|
|
/// be auto-activated at startup.
|
|
pub async fn load_persisted_active_channels(&self) -> Vec<String> {
|
|
let Some(ref store) = self.store else {
|
|
return Vec::new();
|
|
};
|
|
match store.get_setting(&self.user_id, "activated_channels").await {
|
|
Ok(Some(value)) => match serde_json::from_value(value) {
|
|
Ok(names) => names,
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "Failed to deserialize activated_channels");
|
|
Vec::new()
|
|
}
|
|
},
|
|
Ok(None) => Vec::new(),
|
|
Err(e) => {
|
|
tracing::warn!(error = %e, "Failed to load activated_channels setting");
|
|
Vec::new()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Set the SSE broadcast sender for pushing extension status events to the web UI.
|
|
pub async fn set_sse_sender(
|
|
&self,
|
|
sender: tokio::sync::broadcast::Sender<crate::channels::web::types::SseEvent>,
|
|
) {
|
|
*self.sse_sender.write().await = Some(sender);
|
|
}
|
|
|
|
/// Returns the pending OAuth flow registry for sharing with the web gateway.
|
|
///
|
|
/// The gateway's `/oauth/callback` handler uses this to look up pending flows
|
|
/// by CSRF `state` parameter and complete the token exchange.
|
|
pub fn pending_oauth_flows(&self) -> &crate::cli::oauth_defaults::PendingOAuthRegistry {
|
|
&self.pending_oauth_flows
|
|
}
|
|
|
|
/// Broadcast an extension status change to the web UI via SSE.
|
|
async fn broadcast_extension_status(&self, name: &str, status: &str, message: Option<&str>) {
|
|
if let Some(ref sender) = *self.sse_sender.read().await {
|
|
let _ = sender.send(crate::channels::web::types::SseEvent::ExtensionStatus {
|
|
extension_name: name.to_string(),
|
|
status: status.to_string(),
|
|
message: message.map(|m| m.to_string()),
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Search for extensions. If `discover` is true, also searches online.
|
|
pub async fn search(
|
|
&self,
|
|
query: &str,
|
|
discover: bool,
|
|
) -> Result<Vec<SearchResult>, ExtensionError> {
|
|
let mut results = self.registry.search(query).await;
|
|
|
|
if discover && results.is_empty() {
|
|
tracing::info!("No built-in results for '{}', searching online...", query);
|
|
let discovered = self.discovery.discover(query).await;
|
|
|
|
if !discovered.is_empty() {
|
|
// Cache for future lookups
|
|
self.registry.cache_discovered(discovered.clone()).await;
|
|
|
|
// Add to results
|
|
for entry in discovered {
|
|
results.push(SearchResult {
|
|
entry,
|
|
source: ResultSource::Discovered,
|
|
validated: true,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(results)
|
|
}
|
|
|
|
/// Install an extension by name (from registry) or by explicit URL.
|
|
pub async fn install(
|
|
&self,
|
|
name: &str,
|
|
url: Option<&str>,
|
|
kind_hint: Option<ExtensionKind>,
|
|
) -> Result<InstallResult, ExtensionError> {
|
|
tracing::info!(extension = %name, url = ?url, kind = ?kind_hint, "Installing extension");
|
|
Self::validate_extension_name(name)?;
|
|
|
|
// If we have a registry entry, use it (prefer kind_hint to resolve collisions)
|
|
if let Some(entry) = self.registry.get_with_kind(name, kind_hint).await {
|
|
return self.install_from_entry(&entry).await.map_err(|e| {
|
|
tracing::error!(extension = %name, error = %e, "Extension install failed");
|
|
e
|
|
});
|
|
}
|
|
|
|
// If a URL was provided, determine kind and install
|
|
if let Some(url) = url {
|
|
let kind = kind_hint.unwrap_or_else(|| infer_kind_from_url(url));
|
|
return match kind {
|
|
ExtensionKind::McpServer => self.install_mcp_from_url(name, url).await,
|
|
ExtensionKind::WasmTool => self.install_wasm_tool_from_url(name, url).await,
|
|
ExtensionKind::WasmChannel => {
|
|
self.install_wasm_channel_from_url(name, url, None).await
|
|
}
|
|
}
|
|
.map_err(|e| {
|
|
tracing::error!(extension = %name, url = %url, error = %e, "Extension install from URL failed");
|
|
e
|
|
});
|
|
}
|
|
|
|
let err = ExtensionError::NotFound(format!(
|
|
"'{}' not found in registry. Try searching with discover:true or provide a URL.",
|
|
name
|
|
));
|
|
tracing::warn!(extension = %name, "Extension not found in registry");
|
|
Err(err)
|
|
}
|
|
|
|
/// Authenticate an installed extension.
|
|
pub async fn auth(
|
|
&self,
|
|
name: &str,
|
|
token: Option<&str>,
|
|
) -> Result<AuthResult, ExtensionError> {
|
|
// Clean up expired pending auths
|
|
self.cleanup_expired_auths().await;
|
|
|
|
// Determine what kind of extension this is
|
|
let kind = self.determine_installed_kind(name).await?;
|
|
|
|
match kind {
|
|
ExtensionKind::McpServer => self.auth_mcp(name, token).await,
|
|
ExtensionKind::WasmTool => self.auth_wasm_tool(name, token).await,
|
|
ExtensionKind::WasmChannel => self.auth_wasm_channel(name, token).await,
|
|
}
|
|
}
|
|
|
|
/// Activate an installed (and optionally authenticated) extension.
|
|
pub async fn activate(&self, name: &str) -> Result<ActivateResult, ExtensionError> {
|
|
Self::validate_extension_name(name)?;
|
|
let kind = self.determine_installed_kind(name).await?;
|
|
|
|
match kind {
|
|
ExtensionKind::McpServer => self.activate_mcp(name).await,
|
|
ExtensionKind::WasmTool => self.activate_wasm_tool(name).await,
|
|
ExtensionKind::WasmChannel => self.activate_wasm_channel(name).await,
|
|
}
|
|
}
|
|
|
|
/// List extensions with their status.
|
|
///
|
|
/// When `include_available` is `true`, registry entries that are not yet
|
|
/// installed are appended with `installed: false`.
|
|
pub async fn list(
|
|
&self,
|
|
kind_filter: Option<ExtensionKind>,
|
|
include_available: bool,
|
|
) -> Result<Vec<InstalledExtension>, ExtensionError> {
|
|
let mut extensions = Vec::new();
|
|
|
|
// List MCP servers
|
|
if kind_filter.is_none() || kind_filter == Some(ExtensionKind::McpServer) {
|
|
match self.load_mcp_servers().await {
|
|
Ok(servers) => {
|
|
for server in &servers.servers {
|
|
let authenticated =
|
|
is_authenticated(server, &self.secrets, &self.user_id).await;
|
|
let clients = self.mcp_clients.read().await;
|
|
let active = clients.contains_key(&server.name);
|
|
|
|
// Get tool names if active
|
|
let tools = if active {
|
|
self.tool_registry
|
|
.list()
|
|
.await
|
|
.into_iter()
|
|
.filter(|t| t.starts_with(&format!("{}_", server.name)))
|
|
.collect()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
let display_name = self
|
|
.registry
|
|
.get_with_kind(&server.name, Some(ExtensionKind::McpServer))
|
|
.await
|
|
.map(|e| e.display_name);
|
|
extensions.push(InstalledExtension {
|
|
name: server.name.clone(),
|
|
kind: ExtensionKind::McpServer,
|
|
display_name,
|
|
description: server.description.clone(),
|
|
url: Some(server.url.clone()),
|
|
authenticated,
|
|
active,
|
|
tools,
|
|
needs_setup: false,
|
|
has_auth: false,
|
|
installed: true,
|
|
activation_error: None,
|
|
version: None,
|
|
});
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::debug!("Failed to load MCP servers for listing: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// List WASM tools
|
|
if (kind_filter.is_none() || kind_filter == Some(ExtensionKind::WasmTool))
|
|
&& self.wasm_tools_dir.exists()
|
|
{
|
|
match discover_tools(&self.wasm_tools_dir).await {
|
|
Ok(tools) => {
|
|
for (name, discovered) in tools {
|
|
let active = self.tool_registry.has(&name).await;
|
|
|
|
let registry_entry = self
|
|
.registry
|
|
.get_with_kind(&name, Some(ExtensionKind::WasmTool))
|
|
.await;
|
|
let display_name = registry_entry.as_ref().map(|e| e.display_name.clone());
|
|
let auth_state = self.check_tool_auth_status(&name).await;
|
|
let version = if let Some(ref cap_path) = discovered.capabilities_path {
|
|
tokio::fs::read(cap_path)
|
|
.await
|
|
.ok()
|
|
.and_then(|bytes| {
|
|
crate::tools::wasm::CapabilitiesFile::from_bytes(&bytes).ok()
|
|
})
|
|
.and_then(|cap| cap.version)
|
|
} else {
|
|
None
|
|
};
|
|
let version =
|
|
version.or_else(|| registry_entry.and_then(|e| e.version.clone()));
|
|
extensions.push(InstalledExtension {
|
|
name: name.clone(),
|
|
kind: ExtensionKind::WasmTool,
|
|
display_name,
|
|
description: None,
|
|
url: None,
|
|
authenticated: auth_state == ToolAuthState::Ready,
|
|
active,
|
|
tools: if active { vec![name] } else { Vec::new() },
|
|
needs_setup: auth_state == ToolAuthState::NeedsSetup,
|
|
has_auth: auth_state != ToolAuthState::NoAuth,
|
|
installed: true,
|
|
activation_error: None,
|
|
version,
|
|
});
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::debug!("Failed to discover WASM tools for listing: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// List WASM channels
|
|
if (kind_filter.is_none() || kind_filter == Some(ExtensionKind::WasmChannel))
|
|
&& self.wasm_channels_dir.exists()
|
|
{
|
|
match crate::channels::wasm::discover_channels(&self.wasm_channels_dir).await {
|
|
Ok(channels) => {
|
|
let active_names = self.active_channel_names.read().await;
|
|
let errors = self.activation_errors.read().await;
|
|
for (name, discovered) in channels {
|
|
let active = active_names.contains(&name);
|
|
let auth_state = self.check_channel_auth_status(&name).await;
|
|
let activation_error = errors.get(&name).cloned();
|
|
let registry_entry = self
|
|
.registry
|
|
.get_with_kind(&name, Some(ExtensionKind::WasmChannel))
|
|
.await;
|
|
let display_name = registry_entry.as_ref().map(|e| e.display_name.clone());
|
|
let version = if let Some(ref cap_path) = discovered.capabilities_path {
|
|
tokio::fs::read(cap_path)
|
|
.await
|
|
.ok()
|
|
.and_then(|bytes| {
|
|
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(
|
|
&bytes,
|
|
)
|
|
.ok()
|
|
})
|
|
.and_then(|cap| cap.version)
|
|
} else {
|
|
None
|
|
};
|
|
let version =
|
|
version.or_else(|| registry_entry.and_then(|e| e.version.clone()));
|
|
extensions.push(InstalledExtension {
|
|
name,
|
|
kind: ExtensionKind::WasmChannel,
|
|
display_name,
|
|
description: None,
|
|
url: None,
|
|
authenticated: auth_state == ToolAuthState::Ready,
|
|
active,
|
|
tools: Vec::new(),
|
|
needs_setup: auth_state == ToolAuthState::NeedsSetup,
|
|
has_auth: false,
|
|
installed: true,
|
|
activation_error,
|
|
version,
|
|
});
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::debug!("Failed to discover WASM channels for listing: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Append available-but-not-installed registry entries
|
|
if include_available {
|
|
let installed_names: std::collections::HashSet<(String, ExtensionKind)> = extensions
|
|
.iter()
|
|
.map(|e| (e.name.clone(), e.kind))
|
|
.collect();
|
|
|
|
for entry in self.registry.all_entries().await {
|
|
if let Some(filter) = kind_filter
|
|
&& entry.kind != filter
|
|
{
|
|
continue;
|
|
}
|
|
if installed_names.contains(&(entry.name.clone(), entry.kind)) {
|
|
continue;
|
|
}
|
|
extensions.push(InstalledExtension {
|
|
name: entry.name,
|
|
kind: entry.kind,
|
|
display_name: Some(entry.display_name),
|
|
description: Some(entry.description),
|
|
url: None,
|
|
authenticated: false,
|
|
active: false,
|
|
tools: Vec::new(),
|
|
needs_setup: false,
|
|
has_auth: false,
|
|
installed: false,
|
|
activation_error: None,
|
|
version: entry.version,
|
|
});
|
|
}
|
|
}
|
|
|
|
Ok(extensions)
|
|
}
|
|
|
|
/// Remove an installed extension.
|
|
pub async fn remove(&self, name: &str) -> Result<String, ExtensionError> {
|
|
Self::validate_extension_name(name)?;
|
|
let kind = self.determine_installed_kind(name).await?;
|
|
|
|
match kind {
|
|
ExtensionKind::McpServer => {
|
|
// Unregister tools with this server's prefix
|
|
let tool_names: Vec<String> = self
|
|
.tool_registry
|
|
.list()
|
|
.await
|
|
.into_iter()
|
|
.filter(|t| t.starts_with(&format!("{}_", name)))
|
|
.collect();
|
|
|
|
for tool_name in &tool_names {
|
|
self.tool_registry.unregister(tool_name).await;
|
|
}
|
|
|
|
// Remove MCP client
|
|
self.mcp_clients.write().await.remove(name);
|
|
|
|
// Remove from config
|
|
self.remove_mcp_server(name)
|
|
.await
|
|
.map_err(|e| ExtensionError::Config(e.to_string()))?;
|
|
|
|
Ok(format!(
|
|
"Removed MCP server '{}' and {} tool(s)",
|
|
name,
|
|
tool_names.len()
|
|
))
|
|
}
|
|
ExtensionKind::WasmTool => {
|
|
// Unregister from tool registry
|
|
self.tool_registry.unregister(name).await;
|
|
|
|
// Revoke credential mappings from the shared registry
|
|
let cap_path = self
|
|
.wasm_tools_dir
|
|
.join(format!("{}.capabilities.json", name));
|
|
self.revoke_credential_mappings(&cap_path).await;
|
|
|
|
// Unregister hooks registered from this plugin source.
|
|
let removed_hooks = self
|
|
.unregister_hook_prefix(&format!("plugin.tool:{}::", name))
|
|
.await
|
|
+ self
|
|
.unregister_hook_prefix(&format!("plugin.dev_tool:{}::", name))
|
|
.await;
|
|
if removed_hooks > 0 {
|
|
tracing::info!(
|
|
extension = name,
|
|
removed_hooks = removed_hooks,
|
|
"Removed plugin hooks for WASM tool"
|
|
);
|
|
}
|
|
|
|
// Delete files
|
|
let wasm_path = self.wasm_tools_dir.join(format!("{}.wasm", name));
|
|
|
|
if wasm_path.exists() {
|
|
tokio::fs::remove_file(&wasm_path)
|
|
.await
|
|
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
|
}
|
|
if cap_path.exists() {
|
|
let _ = tokio::fs::remove_file(&cap_path).await;
|
|
}
|
|
|
|
Ok(format!("Removed WASM tool '{}'", name))
|
|
}
|
|
ExtensionKind::WasmChannel => {
|
|
// Remove from active set and persist
|
|
self.active_channel_names.write().await.remove(name);
|
|
self.persist_active_channels().await;
|
|
|
|
// Delete channel files
|
|
let wasm_path = self.wasm_channels_dir.join(format!("{}.wasm", name));
|
|
let cap_path = self
|
|
.wasm_channels_dir
|
|
.join(format!("{}.capabilities.json", name));
|
|
|
|
// Revoke credential mappings before deleting the capabilities file
|
|
self.revoke_credential_mappings(&cap_path).await;
|
|
|
|
if wasm_path.exists() {
|
|
tokio::fs::remove_file(&wasm_path)
|
|
.await
|
|
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
|
}
|
|
if cap_path.exists() {
|
|
let _ = tokio::fs::remove_file(&cap_path).await;
|
|
}
|
|
|
|
Ok(format!(
|
|
"Removed channel '{}'. Restart IronClaw for the change to take effect.",
|
|
name
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Upgrade installed WASM extensions to match the current host WIT version.
|
|
///
|
|
/// If `name` is `Some`, upgrades only that extension. If `None`, checks all
|
|
/// installed WASM tools and channels and upgrades any that are outdated.
|
|
///
|
|
/// The upgrade preserves authentication secrets — only the `.wasm` binary
|
|
/// (and `.capabilities.json`) are replaced.
|
|
pub async fn upgrade(&self, name: Option<&str>) -> Result<UpgradeResult, ExtensionError> {
|
|
// Collect extensions to check
|
|
let mut candidates: Vec<(String, ExtensionKind)> = Vec::new();
|
|
|
|
if let Some(name) = name {
|
|
Self::validate_extension_name(name)?;
|
|
let kind = self.determine_installed_kind(name).await?;
|
|
if kind == ExtensionKind::McpServer {
|
|
return Err(ExtensionError::Other(
|
|
"MCP servers don't have WIT versions and cannot be upgraded this way"
|
|
.to_string(),
|
|
));
|
|
}
|
|
candidates.push((name.to_string(), kind));
|
|
} else {
|
|
// Discover all installed WASM tools
|
|
if self.wasm_tools_dir.exists()
|
|
&& let Ok(tools) = discover_tools(&self.wasm_tools_dir).await
|
|
{
|
|
for (tool_name, _) in tools {
|
|
candidates.push((tool_name, ExtensionKind::WasmTool));
|
|
}
|
|
}
|
|
// Discover all installed WASM channels
|
|
if self.wasm_channels_dir.exists()
|
|
&& let Ok(channels) =
|
|
crate::channels::wasm::discover_channels(&self.wasm_channels_dir).await
|
|
{
|
|
for (ch_name, _) in channels {
|
|
candidates.push((ch_name, ExtensionKind::WasmChannel));
|
|
}
|
|
}
|
|
}
|
|
|
|
if candidates.is_empty() {
|
|
return Ok(UpgradeResult {
|
|
results: Vec::new(),
|
|
message: "No WASM extensions installed.".to_string(),
|
|
});
|
|
}
|
|
|
|
let mut outcomes = Vec::new();
|
|
|
|
for (ext_name, kind) in &candidates {
|
|
let outcome = self.upgrade_one(ext_name, *kind).await;
|
|
outcomes.push(outcome);
|
|
}
|
|
|
|
let upgraded = outcomes.iter().filter(|o| o.status == "upgraded").count();
|
|
let up_to_date = outcomes
|
|
.iter()
|
|
.filter(|o| o.status == "already_up_to_date")
|
|
.count();
|
|
let failed = outcomes.iter().filter(|o| o.status == "failed").count();
|
|
|
|
let message = format!(
|
|
"{} extension(s) checked: {} upgraded, {} already up to date, {} failed",
|
|
outcomes.len(),
|
|
upgraded,
|
|
up_to_date,
|
|
failed
|
|
);
|
|
|
|
Ok(UpgradeResult {
|
|
results: outcomes,
|
|
message,
|
|
})
|
|
}
|
|
|
|
/// Upgrade a single WASM extension if its WIT version is outdated.
|
|
async fn upgrade_one(&self, name: &str, kind: ExtensionKind) -> UpgradeOutcome {
|
|
let (cap_dir, host_wit) = match kind {
|
|
ExtensionKind::WasmTool => (&self.wasm_tools_dir, crate::tools::wasm::WIT_TOOL_VERSION),
|
|
ExtensionKind::WasmChannel => (
|
|
&self.wasm_channels_dir,
|
|
crate::tools::wasm::WIT_CHANNEL_VERSION,
|
|
),
|
|
ExtensionKind::McpServer => {
|
|
return UpgradeOutcome {
|
|
name: name.to_string(),
|
|
kind,
|
|
status: "failed".to_string(),
|
|
detail: "MCP servers cannot be upgraded this way".to_string(),
|
|
};
|
|
}
|
|
};
|
|
|
|
// Read current WIT version from capabilities
|
|
let cap_path = cap_dir.join(format!("{}.capabilities.json", name));
|
|
let declared_wit = if cap_path.exists() {
|
|
match tokio::fs::read(&cap_path).await {
|
|
Ok(bytes) => {
|
|
let wit: Option<String> = match kind {
|
|
ExtensionKind::WasmTool => {
|
|
crate::tools::wasm::CapabilitiesFile::from_bytes(&bytes)
|
|
.ok()
|
|
.and_then(|c| c.wit_version)
|
|
}
|
|
ExtensionKind::WasmChannel => {
|
|
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&bytes)
|
|
.ok()
|
|
.and_then(|c| c.wit_version)
|
|
}
|
|
ExtensionKind::McpServer => None,
|
|
};
|
|
wit
|
|
}
|
|
Err(_) => None,
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Check if upgrade is needed
|
|
let needs_upgrade =
|
|
crate::tools::wasm::check_wit_version_compat(name, declared_wit.as_deref(), host_wit)
|
|
.is_err();
|
|
|
|
if !needs_upgrade {
|
|
return UpgradeOutcome {
|
|
name: name.to_string(),
|
|
kind,
|
|
status: "already_up_to_date".to_string(),
|
|
detail: format!(
|
|
"WIT {} matches host WIT {}",
|
|
declared_wit.as_deref().unwrap_or("unknown"),
|
|
host_wit
|
|
),
|
|
};
|
|
}
|
|
|
|
// Check registry for a newer version
|
|
let entry = self.registry.get_with_kind(name, Some(kind)).await;
|
|
let Some(entry) = entry else {
|
|
return UpgradeOutcome {
|
|
name: name.to_string(),
|
|
kind,
|
|
status: "not_in_registry".to_string(),
|
|
detail: format!(
|
|
"Extension '{}' has outdated WIT {} (host: {}), \
|
|
but is not in the registry. Reinstall manually with a URL.",
|
|
name,
|
|
declared_wit.as_deref().unwrap_or("unknown"),
|
|
host_wit
|
|
),
|
|
};
|
|
};
|
|
|
|
// Delete old .wasm file (keep secrets intact)
|
|
let wasm_path = cap_dir.join(format!("{}.wasm", name));
|
|
if wasm_path.exists()
|
|
&& let Err(e) = tokio::fs::remove_file(&wasm_path).await
|
|
{
|
|
return UpgradeOutcome {
|
|
name: name.to_string(),
|
|
kind,
|
|
status: "failed".to_string(),
|
|
detail: format!("Failed to remove old WASM binary: {}", e),
|
|
};
|
|
}
|
|
// Also remove old capabilities so install_from_entry can write the new one
|
|
if cap_path.exists() {
|
|
let _ = tokio::fs::remove_file(&cap_path).await;
|
|
}
|
|
|
|
// Reinstall from registry
|
|
match self.install_from_entry(&entry).await {
|
|
Ok(_) => {
|
|
tracing::info!(
|
|
extension = %name,
|
|
old_wit = ?declared_wit,
|
|
new_host_wit = %host_wit,
|
|
"Upgraded WASM extension"
|
|
);
|
|
UpgradeOutcome {
|
|
name: name.to_string(),
|
|
kind,
|
|
status: "upgraded".to_string(),
|
|
detail: format!(
|
|
"Upgraded from WIT {} to host WIT {}. Restart to activate.",
|
|
declared_wit.as_deref().unwrap_or("unknown"),
|
|
host_wit
|
|
),
|
|
}
|
|
}
|
|
Err(e) => UpgradeOutcome {
|
|
name: name.to_string(),
|
|
kind,
|
|
status: "failed".to_string(),
|
|
detail: format!("Reinstall failed: {}. Old files were removed.", e),
|
|
},
|
|
}
|
|
}
|
|
|
|
/// Get detailed info about an installed extension (version, wit_version, host compatibility).
|
|
pub async fn extension_info(&self, name: &str) -> Result<serde_json::Value, ExtensionError> {
|
|
Self::validate_extension_name(name)?;
|
|
let kind = self.determine_installed_kind(name).await?;
|
|
|
|
match kind {
|
|
ExtensionKind::WasmTool => {
|
|
let cap_path = self
|
|
.wasm_tools_dir
|
|
.join(format!("{}.capabilities.json", name));
|
|
let wasm_path = self.wasm_tools_dir.join(format!("{}.wasm", name));
|
|
|
|
let mut info = serde_json::json!({
|
|
"name": name,
|
|
"kind": "wasm_tool",
|
|
"installed": wasm_path.exists(),
|
|
});
|
|
|
|
if cap_path.exists()
|
|
&& let Ok(bytes) = tokio::fs::read(&cap_path).await
|
|
&& let Ok(cap) = crate::tools::wasm::CapabilitiesFile::from_bytes(&bytes)
|
|
{
|
|
info["version"] =
|
|
serde_json::json!(cap.version.unwrap_or_else(|| "unknown".into()));
|
|
info["wit_version"] =
|
|
serde_json::json!(cap.wit_version.unwrap_or_else(|| "unknown".into()));
|
|
}
|
|
|
|
info["host_wit_version"] = serde_json::json!(crate::tools::wasm::WIT_TOOL_VERSION);
|
|
|
|
Ok(info)
|
|
}
|
|
ExtensionKind::WasmChannel => {
|
|
let cap_path = self
|
|
.wasm_channels_dir
|
|
.join(format!("{}.capabilities.json", name));
|
|
let wasm_path = self.wasm_channels_dir.join(format!("{}.wasm", name));
|
|
|
|
let mut info = serde_json::json!({
|
|
"name": name,
|
|
"kind": "wasm_channel",
|
|
"installed": wasm_path.exists(),
|
|
"active": self.active_channel_names.read().await.contains(name),
|
|
});
|
|
|
|
if cap_path.exists()
|
|
&& let Ok(bytes) = tokio::fs::read(&cap_path).await
|
|
&& let Ok(cap) =
|
|
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&bytes)
|
|
{
|
|
info["version"] =
|
|
serde_json::json!(cap.version.unwrap_or_else(|| "unknown".into()));
|
|
info["wit_version"] =
|
|
serde_json::json!(cap.wit_version.unwrap_or_else(|| "unknown".into()));
|
|
}
|
|
|
|
info["host_wit_version"] =
|
|
serde_json::json!(crate::tools::wasm::WIT_CHANNEL_VERSION);
|
|
|
|
Ok(info)
|
|
}
|
|
ExtensionKind::McpServer => {
|
|
let info = serde_json::json!({
|
|
"name": name,
|
|
"kind": "mcp_server",
|
|
"connected": self.mcp_clients.read().await.contains_key(name),
|
|
});
|
|
Ok(info)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── MCP config helpers (DB with disk fallback) ─────────────────────
|
|
|
|
async fn load_mcp_servers(
|
|
&self,
|
|
) -> Result<crate::tools::mcp::config::McpServersFile, crate::tools::mcp::config::ConfigError>
|
|
{
|
|
if let Some(ref store) = self.store {
|
|
crate::tools::mcp::config::load_mcp_servers_from_db(store.as_ref(), &self.user_id).await
|
|
} else {
|
|
crate::tools::mcp::config::load_mcp_servers().await
|
|
}
|
|
}
|
|
|
|
async fn get_mcp_server(
|
|
&self,
|
|
name: &str,
|
|
) -> Result<McpServerConfig, crate::tools::mcp::config::ConfigError> {
|
|
let servers = self.load_mcp_servers().await?;
|
|
servers.get(name).cloned().ok_or_else(|| {
|
|
crate::tools::mcp::config::ConfigError::ServerNotFound {
|
|
name: name.to_string(),
|
|
}
|
|
})
|
|
}
|
|
|
|
async fn add_mcp_server(
|
|
&self,
|
|
config: McpServerConfig,
|
|
) -> Result<(), crate::tools::mcp::config::ConfigError> {
|
|
config.validate()?;
|
|
if let Some(ref store) = self.store {
|
|
crate::tools::mcp::config::add_mcp_server_db(store.as_ref(), &self.user_id, config)
|
|
.await
|
|
} else {
|
|
crate::tools::mcp::config::add_mcp_server(config).await
|
|
}
|
|
}
|
|
|
|
async fn remove_mcp_server(
|
|
&self,
|
|
name: &str,
|
|
) -> Result<(), crate::tools::mcp::config::ConfigError> {
|
|
if let Some(ref store) = self.store {
|
|
crate::tools::mcp::config::remove_mcp_server_db(store.as_ref(), &self.user_id, name)
|
|
.await
|
|
} else {
|
|
crate::tools::mcp::config::remove_mcp_server(name).await
|
|
}
|
|
}
|
|
|
|
// ── Private helpers ──────────────────────────────────────────────────
|
|
|
|
async fn install_from_entry(
|
|
&self,
|
|
entry: &RegistryEntry,
|
|
) -> Result<InstallResult, ExtensionError> {
|
|
let primary_result = self.try_install_from_source(entry, &entry.source).await;
|
|
match fallback_decision(&primary_result, &entry.fallback_source) {
|
|
FallbackDecision::Return => primary_result,
|
|
FallbackDecision::TryFallback => {
|
|
let primary_err = primary_result.unwrap_err();
|
|
let fallback = entry.fallback_source.as_ref().unwrap();
|
|
tracing::info!(
|
|
extension = %entry.name,
|
|
primary_error = %primary_err,
|
|
"Primary install failed, trying fallback source"
|
|
);
|
|
match self.try_install_from_source(entry, fallback).await {
|
|
Ok(result) => Ok(result),
|
|
Err(fallback_err) => {
|
|
tracing::error!(
|
|
extension = %entry.name,
|
|
fallback_error = %fallback_err,
|
|
"Fallback install also failed"
|
|
);
|
|
Err(combine_install_errors(primary_err, fallback_err))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Attempt to install an extension using a specific source.
|
|
async fn try_install_from_source(
|
|
&self,
|
|
entry: &RegistryEntry,
|
|
source: &ExtensionSource,
|
|
) -> Result<InstallResult, ExtensionError> {
|
|
match entry.kind {
|
|
ExtensionKind::McpServer => {
|
|
let url = match source {
|
|
ExtensionSource::McpUrl { url } => url.clone(),
|
|
ExtensionSource::Discovered { url } => url.clone(),
|
|
_ => {
|
|
return Err(ExtensionError::InstallFailed(
|
|
"Registry entry for MCP server has no URL".to_string(),
|
|
));
|
|
}
|
|
};
|
|
self.install_mcp_from_url(&entry.name, &url).await
|
|
}
|
|
ExtensionKind::WasmTool => match source {
|
|
ExtensionSource::WasmDownload {
|
|
wasm_url,
|
|
capabilities_url,
|
|
} => {
|
|
self.install_wasm_tool_from_url_with_caps(
|
|
&entry.name,
|
|
wasm_url,
|
|
capabilities_url.as_deref(),
|
|
)
|
|
.await
|
|
}
|
|
ExtensionSource::WasmBuildable {
|
|
build_dir,
|
|
crate_name,
|
|
..
|
|
} => {
|
|
self.install_wasm_from_buildable(
|
|
&entry.name,
|
|
build_dir.as_deref(),
|
|
crate_name.as_deref(),
|
|
&self.wasm_tools_dir,
|
|
ExtensionKind::WasmTool,
|
|
)
|
|
.await
|
|
}
|
|
_ => Err(ExtensionError::InstallFailed(
|
|
"WASM tool entry has no download URL or build info".to_string(),
|
|
)),
|
|
},
|
|
ExtensionKind::WasmChannel => match source {
|
|
ExtensionSource::WasmDownload {
|
|
wasm_url,
|
|
capabilities_url,
|
|
} => {
|
|
self.install_wasm_channel_from_url(
|
|
&entry.name,
|
|
wasm_url,
|
|
capabilities_url.as_deref(),
|
|
)
|
|
.await
|
|
}
|
|
ExtensionSource::WasmBuildable {
|
|
build_dir,
|
|
crate_name,
|
|
..
|
|
} => {
|
|
self.install_wasm_from_buildable(
|
|
&entry.name,
|
|
build_dir.as_deref(),
|
|
crate_name.as_deref(),
|
|
&self.wasm_channels_dir,
|
|
ExtensionKind::WasmChannel,
|
|
)
|
|
.await
|
|
}
|
|
_ => Err(ExtensionError::InstallFailed(
|
|
"WASM channel entry has no download URL or build info".to_string(),
|
|
)),
|
|
},
|
|
}
|
|
}
|
|
|
|
async fn install_mcp_from_url(
|
|
&self,
|
|
name: &str,
|
|
url: &str,
|
|
) -> Result<InstallResult, ExtensionError> {
|
|
// Check if already installed
|
|
if self.get_mcp_server(name).await.is_ok() {
|
|
return Err(ExtensionError::AlreadyInstalled(name.to_string()));
|
|
}
|
|
|
|
let config = McpServerConfig::new(name, url);
|
|
config
|
|
.validate()
|
|
.map_err(|e| ExtensionError::InvalidUrl(e.to_string()))?;
|
|
|
|
self.add_mcp_server(config)
|
|
.await
|
|
.map_err(|e| ExtensionError::Config(e.to_string()))?;
|
|
|
|
tracing::info!("Installed MCP server '{}' at {}", name, url);
|
|
|
|
Ok(InstallResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::McpServer,
|
|
message: format!(
|
|
"MCP server '{}' installed. Run auth next to authenticate.",
|
|
name
|
|
),
|
|
})
|
|
}
|
|
|
|
async fn install_wasm_tool_from_url(
|
|
&self,
|
|
name: &str,
|
|
url: &str,
|
|
) -> Result<InstallResult, ExtensionError> {
|
|
self.install_wasm_tool_from_url_with_caps(name, url, None)
|
|
.await
|
|
}
|
|
|
|
async fn install_wasm_tool_from_url_with_caps(
|
|
&self,
|
|
name: &str,
|
|
url: &str,
|
|
capabilities_url: Option<&str>,
|
|
) -> Result<InstallResult, ExtensionError> {
|
|
self.download_and_install_wasm(name, url, capabilities_url, &self.wasm_tools_dir)
|
|
.await?;
|
|
|
|
Ok(InstallResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::WasmTool,
|
|
message: format!("WASM tool '{}' installed. Run activate to load it.", name),
|
|
})
|
|
}
|
|
|
|
async fn install_wasm_channel_from_url(
|
|
&self,
|
|
name: &str,
|
|
url: &str,
|
|
capabilities_url: Option<&str>,
|
|
) -> Result<InstallResult, ExtensionError> {
|
|
self.download_and_install_wasm(name, url, capabilities_url, &self.wasm_channels_dir)
|
|
.await?;
|
|
|
|
Ok(InstallResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::WasmChannel,
|
|
message: format!(
|
|
"WASM channel '{}' installed. Run activate to start it.",
|
|
name,
|
|
),
|
|
})
|
|
}
|
|
|
|
/// Download a WASM extension (tool or channel) from URL and install to target directory.
|
|
///
|
|
/// Handles both tar.gz bundles (containing `.wasm` + `.capabilities.json`) and bare
|
|
/// `.wasm` files. Validates HTTPS, size limits, and file format.
|
|
async fn download_and_install_wasm(
|
|
&self,
|
|
name: &str,
|
|
url: &str,
|
|
capabilities_url: Option<&str>,
|
|
target_dir: &std::path::Path,
|
|
) -> Result<(), ExtensionError> {
|
|
// Require HTTPS to prevent downgrade attacks
|
|
if !url.starts_with("https://") {
|
|
return Err(ExtensionError::InstallFailed(
|
|
"Only HTTPS URLs are allowed for extension downloads".to_string(),
|
|
));
|
|
}
|
|
|
|
// 50 MB cap to prevent disk-fill DoS
|
|
const MAX_DOWNLOAD_SIZE: usize = 50 * 1024 * 1024;
|
|
|
|
let client = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(60))
|
|
.build()
|
|
.map_err(|e| ExtensionError::DownloadFailed(e.to_string()))?;
|
|
|
|
tracing::debug!(extension = %name, url = %url, "Downloading WASM extension");
|
|
|
|
let response = client.get(url).send().await.map_err(|e| {
|
|
tracing::error!(extension = %name, url = %url, error = %e, "Download request failed");
|
|
ExtensionError::DownloadFailed(e.to_string())
|
|
})?;
|
|
|
|
if !response.status().is_success() {
|
|
let status = response.status();
|
|
tracing::error!(
|
|
extension = %name,
|
|
url = %url,
|
|
status = %status,
|
|
"Download returned non-success HTTP status"
|
|
);
|
|
return Err(ExtensionError::DownloadFailed(format!(
|
|
"HTTP {} from {}",
|
|
status, url
|
|
)));
|
|
}
|
|
|
|
// Check Content-Length header before downloading the full body
|
|
if let Some(len) = response.content_length()
|
|
&& len as usize > MAX_DOWNLOAD_SIZE
|
|
{
|
|
return Err(ExtensionError::InstallFailed(format!(
|
|
"Download too large ({} bytes, max {} bytes)",
|
|
len, MAX_DOWNLOAD_SIZE
|
|
)));
|
|
}
|
|
|
|
let bytes = response
|
|
.bytes()
|
|
.await
|
|
.map_err(|e| ExtensionError::DownloadFailed(e.to_string()))?;
|
|
|
|
if bytes.len() > MAX_DOWNLOAD_SIZE {
|
|
return Err(ExtensionError::InstallFailed(format!(
|
|
"Download too large ({} bytes, max {} bytes)",
|
|
bytes.len(),
|
|
MAX_DOWNLOAD_SIZE
|
|
)));
|
|
}
|
|
|
|
// Ensure target directory exists
|
|
tokio::fs::create_dir_all(target_dir)
|
|
.await
|
|
.map_err(|e| ExtensionError::InstallFailed(e.to_string()))?;
|
|
|
|
let wasm_path = target_dir.join(format!("{}.wasm", name));
|
|
let caps_path = target_dir.join(format!("{}.capabilities.json", name));
|
|
|
|
// Detect format: gzip (tar.gz bundle) or bare WASM
|
|
if bytes.len() >= 2 && bytes[0] == 0x1f && bytes[1] == 0x8b {
|
|
// tar.gz bundle: extract {name}.wasm and {name}.capabilities.json
|
|
self.extract_wasm_tar_gz(name, &bytes, &wasm_path, &caps_path)?;
|
|
} else {
|
|
// Bare WASM file: validate magic number
|
|
if bytes.len() < 4 || &bytes[..4] != b"\0asm" {
|
|
return Err(ExtensionError::InstallFailed(
|
|
"Downloaded file is not a valid WASM binary (bad magic number)".to_string(),
|
|
));
|
|
}
|
|
|
|
tokio::fs::write(&wasm_path, &bytes)
|
|
.await
|
|
.map_err(|e| ExtensionError::InstallFailed(e.to_string()))?;
|
|
|
|
// Download capabilities separately if URL provided
|
|
if let Some(caps_url) = capabilities_url {
|
|
const MAX_CAPS_SIZE: usize = 1024 * 1024; // 1 MB
|
|
match client.get(caps_url).send().await {
|
|
Ok(resp) if resp.status().is_success() => match resp.bytes().await {
|
|
Ok(caps_bytes) if caps_bytes.len() <= MAX_CAPS_SIZE => {
|
|
if let Err(e) = tokio::fs::write(&caps_path, &caps_bytes).await {
|
|
tracing::warn!(
|
|
"Failed to write capabilities for '{}': {}",
|
|
name,
|
|
e
|
|
);
|
|
}
|
|
}
|
|
Ok(caps_bytes) => {
|
|
tracing::warn!(
|
|
"Capabilities file for '{}' too large ({} bytes, max {})",
|
|
name,
|
|
caps_bytes.len(),
|
|
MAX_CAPS_SIZE
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("Failed to download capabilities for '{}': {}", name, e);
|
|
}
|
|
},
|
|
_ => {
|
|
tracing::warn!(
|
|
"Failed to download capabilities for '{}' from {}",
|
|
name,
|
|
caps_url
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
tracing::info!(
|
|
"Installed WASM extension '{}' from {} to {}",
|
|
name,
|
|
url,
|
|
wasm_path.display()
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
/// Extract a tar.gz bundle into the WASM tools directory.
|
|
fn extract_wasm_tar_gz(
|
|
&self,
|
|
name: &str,
|
|
bytes: &[u8],
|
|
target_wasm: &std::path::Path,
|
|
target_caps: &std::path::Path,
|
|
) -> Result<(), ExtensionError> {
|
|
use flate2::read::GzDecoder;
|
|
use tar::Archive;
|
|
|
|
use std::io::Read as _;
|
|
|
|
let decoder = GzDecoder::new(bytes);
|
|
let mut archive = Archive::new(decoder);
|
|
// Defense-in-depth: do not preserve permissions or extended attributes
|
|
archive.set_preserve_permissions(false);
|
|
#[cfg(any(unix, target_os = "redox"))]
|
|
archive.set_unpack_xattrs(false);
|
|
|
|
// 100 MB cap on decompressed entry size to prevent decompression bombs
|
|
const MAX_ENTRY_SIZE: u64 = 100 * 1024 * 1024;
|
|
|
|
let wasm_filename = format!("{}.wasm", name);
|
|
let caps_filename = format!("{}.capabilities.json", name);
|
|
let mut found_wasm = false;
|
|
|
|
let entries = archive
|
|
.entries()
|
|
.map_err(|e| ExtensionError::InstallFailed(format!("Bad tar.gz archive: {}", e)))?;
|
|
|
|
for entry in entries {
|
|
let mut entry = entry
|
|
.map_err(|e| ExtensionError::InstallFailed(format!("Bad tar.gz entry: {}", e)))?;
|
|
|
|
if entry.size() > MAX_ENTRY_SIZE {
|
|
return Err(ExtensionError::InstallFailed(format!(
|
|
"Archive entry too large ({} bytes, max {} bytes)",
|
|
entry.size(),
|
|
MAX_ENTRY_SIZE
|
|
)));
|
|
}
|
|
|
|
let entry_path = entry
|
|
.path()
|
|
.map_err(|e| {
|
|
ExtensionError::InstallFailed(format!("Invalid path in tar.gz: {}", e))
|
|
})?
|
|
.to_path_buf();
|
|
|
|
let filename = entry_path
|
|
.file_name()
|
|
.and_then(|n| n.to_str())
|
|
.unwrap_or("");
|
|
|
|
if filename == wasm_filename {
|
|
let mut data = Vec::with_capacity(entry.size() as usize);
|
|
std::io::Read::read_to_end(&mut entry.by_ref().take(MAX_ENTRY_SIZE), &mut data)
|
|
.map_err(|e| ExtensionError::InstallFailed(e.to_string()))?;
|
|
std::fs::write(target_wasm, &data)
|
|
.map_err(|e| ExtensionError::InstallFailed(e.to_string()))?;
|
|
found_wasm = true;
|
|
} else if filename == caps_filename {
|
|
let mut data = Vec::with_capacity(entry.size() as usize);
|
|
std::io::Read::read_to_end(&mut entry.by_ref().take(MAX_ENTRY_SIZE), &mut data)
|
|
.map_err(|e| ExtensionError::InstallFailed(e.to_string()))?;
|
|
std::fs::write(target_caps, &data)
|
|
.map_err(|e| ExtensionError::InstallFailed(e.to_string()))?;
|
|
}
|
|
}
|
|
|
|
if !found_wasm {
|
|
return Err(ExtensionError::InstallFailed(format!(
|
|
"tar.gz archive does not contain '{}'",
|
|
wasm_filename
|
|
)));
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(dead_code)] // Used by upcoming hot-activation flow
|
|
async fn install_bundled_channel_from_artifacts(
|
|
&self,
|
|
name: &str,
|
|
) -> Result<InstallResult, ExtensionError> {
|
|
// Check if already installed
|
|
let channel_wasm = self.wasm_channels_dir.join(format!("{}.wasm", name));
|
|
if channel_wasm.exists() {
|
|
return Err(ExtensionError::AlreadyInstalled(name.to_string()));
|
|
}
|
|
|
|
crate::channels::wasm::install_bundled_channel(name, &self.wasm_channels_dir, false)
|
|
.await
|
|
.map_err(ExtensionError::InstallFailed)?;
|
|
|
|
tracing::info!(
|
|
"Installed bundled channel '{}' to {}",
|
|
name,
|
|
self.wasm_channels_dir.display()
|
|
);
|
|
|
|
Ok(InstallResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::WasmChannel,
|
|
message: format!(
|
|
"Channel '{}' installed. \
|
|
Run tool_auth('{}') to configure authentication, then activate.",
|
|
name, name,
|
|
),
|
|
})
|
|
}
|
|
|
|
/// Install a WASM extension from local build artifacts (WasmBuildable source).
|
|
///
|
|
/// Resolves the build directory (relative to `CARGO_MANIFEST_DIR` or absolute),
|
|
/// looks for the compiled WASM artifact, and copies it (plus capabilities.json)
|
|
/// to the install directory. Falls back to an error if artifacts don't exist.
|
|
async fn install_wasm_from_buildable(
|
|
&self,
|
|
name: &str,
|
|
build_dir: Option<&str>,
|
|
crate_name: Option<&str>,
|
|
target_dir: &std::path::Path,
|
|
kind: ExtensionKind,
|
|
) -> Result<InstallResult, ExtensionError> {
|
|
let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
|
|
|
|
// Resolve build directory
|
|
let resolved_dir = match build_dir {
|
|
Some(dir) => {
|
|
let p = std::path::Path::new(dir);
|
|
if p.is_absolute() {
|
|
p.to_path_buf()
|
|
} else {
|
|
manifest_dir.join(dir)
|
|
}
|
|
}
|
|
None => manifest_dir.to_path_buf(),
|
|
};
|
|
|
|
// Determine the binary name to look for
|
|
let binary_name = crate_name.unwrap_or(name);
|
|
|
|
let wasm_src =
|
|
crate::registry::artifacts::find_wasm_artifact(&resolved_dir, binary_name, "release")
|
|
.ok_or_else(|| {
|
|
ExtensionError::InstallFailed(format!(
|
|
"'{}' requires building from source. Build artifact not found. \
|
|
Run `cargo component build --release` in {} first, \
|
|
or use `ironclaw registry install {}`.",
|
|
name,
|
|
resolved_dir.display(),
|
|
name,
|
|
))
|
|
})?;
|
|
|
|
let wasm_dst = crate::registry::artifacts::install_wasm_files(
|
|
&wasm_src,
|
|
&resolved_dir,
|
|
name,
|
|
target_dir,
|
|
true,
|
|
)
|
|
.await
|
|
.map_err(|e| ExtensionError::InstallFailed(e.to_string()))?;
|
|
|
|
let kind_label = match kind {
|
|
ExtensionKind::WasmTool => "WASM tool",
|
|
ExtensionKind::WasmChannel => "WASM channel",
|
|
ExtensionKind::McpServer => "MCP server",
|
|
};
|
|
|
|
tracing::info!(
|
|
"Installed {} '{}' from build artifacts at {}",
|
|
kind_label,
|
|
name,
|
|
wasm_dst.display(),
|
|
);
|
|
|
|
Ok(InstallResult {
|
|
name: name.to_string(),
|
|
kind,
|
|
message: format!(
|
|
"{} '{}' installed from local build artifacts. Run activate to load it.",
|
|
kind_label, name,
|
|
),
|
|
})
|
|
}
|
|
|
|
async fn auth_mcp(
|
|
&self,
|
|
name: &str,
|
|
token: Option<&str>,
|
|
) -> Result<AuthResult, ExtensionError> {
|
|
let server = self
|
|
.get_mcp_server(name)
|
|
.await
|
|
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
|
|
|
|
// If a token was provided directly, store it and we're done.
|
|
if let Some(token_value) = token {
|
|
let secret_name = server.token_secret_name();
|
|
let params =
|
|
CreateSecretParams::new(&secret_name, token_value).with_provider(name.to_string());
|
|
self.secrets
|
|
.create(&self.user_id, params)
|
|
.await
|
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
|
|
|
tracing::info!("MCP server '{}' authenticated via manual token", name);
|
|
return Ok(AuthResult::authenticated(name, ExtensionKind::McpServer));
|
|
}
|
|
|
|
// Check if already authenticated
|
|
if is_authenticated(&server, &self.secrets, &self.user_id).await {
|
|
return Ok(AuthResult::authenticated(name, ExtensionKind::McpServer));
|
|
}
|
|
|
|
// Run the full OAuth flow (opens browser, waits for callback)
|
|
match authorize_mcp_server(&server, &self.secrets, &self.user_id).await {
|
|
Ok(_token) => {
|
|
tracing::info!("MCP server '{}' authenticated via OAuth", name);
|
|
Ok(AuthResult::authenticated(name, ExtensionKind::McpServer))
|
|
}
|
|
Err(crate::tools::mcp::auth::AuthError::NotSupported) => {
|
|
// Server doesn't support OAuth, try building a URL first
|
|
match self.auth_mcp_build_url(name, &server).await {
|
|
Ok(result) => Ok(result),
|
|
Err(_) => {
|
|
// No OAuth, no DCR: fall back to manual token entry
|
|
Ok(AuthResult::awaiting_token(
|
|
name,
|
|
ExtensionKind::McpServer,
|
|
format!(
|
|
"Server '{}' does not support OAuth. \
|
|
Please provide an API token/key for this server.",
|
|
name
|
|
),
|
|
None,
|
|
))
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
// OAuth failed for some other reason, fall back to manual token
|
|
Ok(AuthResult::awaiting_token(
|
|
name,
|
|
ExtensionKind::McpServer,
|
|
format!(
|
|
"OAuth failed for '{}': {}. \
|
|
Please provide an API token/key manually.",
|
|
name, e
|
|
),
|
|
None,
|
|
))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Build an auth URL for cases where non-interactive auth is needed
|
|
/// (e.g., running via Telegram where we can't open a browser).
|
|
async fn auth_mcp_build_url(
|
|
&self,
|
|
name: &str,
|
|
server: &McpServerConfig,
|
|
) -> Result<AuthResult, ExtensionError> {
|
|
// Try to discover OAuth metadata and build a URL the user can open manually
|
|
let metadata = discover_full_oauth_metadata(&server.url)
|
|
.await
|
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
|
|
|
// Try DCR if no client_id configured
|
|
let (client_id, redirect_uri) = if let Some(ref oauth) = server.oauth {
|
|
let port = find_available_port()
|
|
.await
|
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
|
let redirect = format!("http://localhost:{}/callback", port.1);
|
|
(oauth.client_id.clone(), redirect)
|
|
} else if let Some(ref reg_endpoint) = metadata.registration_endpoint {
|
|
let port = find_available_port()
|
|
.await
|
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
|
let redirect = format!("http://localhost:{}/callback", port.1);
|
|
|
|
let registration = register_client(reg_endpoint, &redirect)
|
|
.await
|
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
|
|
|
(registration.client_id, redirect)
|
|
} else {
|
|
return Err(ExtensionError::AuthFailed(
|
|
"Server doesn't support OAuth or Dynamic Client Registration".to_string(),
|
|
));
|
|
};
|
|
|
|
let pkce = PkceChallenge::generate();
|
|
let auth_url = build_authorization_url(
|
|
&metadata.authorization_endpoint,
|
|
&client_id,
|
|
&redirect_uri,
|
|
&metadata.scopes_supported,
|
|
Some(&pkce),
|
|
&std::collections::HashMap::new(),
|
|
);
|
|
|
|
// Store pending auth for later callback handling
|
|
self.pending_auth.write().await.insert(
|
|
name.to_string(),
|
|
PendingAuth {
|
|
_name: name.to_string(),
|
|
_kind: ExtensionKind::McpServer,
|
|
created_at: std::time::Instant::now(),
|
|
task_handle: None,
|
|
},
|
|
);
|
|
|
|
Ok(AuthResult::awaiting_authorization(
|
|
name,
|
|
ExtensionKind::McpServer,
|
|
auth_url,
|
|
"local".to_string(),
|
|
))
|
|
}
|
|
|
|
async fn auth_wasm_tool(
|
|
&self,
|
|
name: &str,
|
|
token: Option<&str>,
|
|
) -> Result<AuthResult, ExtensionError> {
|
|
// Read the capabilities file to get auth config
|
|
let cap_path = self
|
|
.wasm_tools_dir
|
|
.join(format!("{}.capabilities.json", name));
|
|
|
|
if !cap_path.exists() {
|
|
return Ok(AuthResult::no_auth_required(name, ExtensionKind::WasmTool));
|
|
}
|
|
|
|
let cap_bytes = tokio::fs::read(&cap_path)
|
|
.await
|
|
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
|
|
|
let cap_file = crate::tools::wasm::CapabilitiesFile::from_bytes(&cap_bytes)
|
|
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
|
|
|
let auth = match cap_file.auth {
|
|
Some(auth) => auth,
|
|
None => {
|
|
return Ok(AuthResult::no_auth_required(name, ExtensionKind::WasmTool));
|
|
}
|
|
};
|
|
|
|
// Check env var first
|
|
if let Some(ref env_var) = auth.env_var
|
|
&& let Ok(value) = std::env::var(env_var)
|
|
{
|
|
// Store the env var value as a secret
|
|
let params =
|
|
CreateSecretParams::new(&auth.secret_name, &value).with_provider(name.to_string());
|
|
self.secrets
|
|
.create(&self.user_id, params)
|
|
.await
|
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
|
|
|
return Ok(AuthResult::authenticated(name, ExtensionKind::WasmTool));
|
|
}
|
|
|
|
// Check if already authenticated (with scope expansion detection)
|
|
let token_exists = self
|
|
.secrets
|
|
.exists(&self.user_id, &auth.secret_name)
|
|
.await
|
|
.unwrap_or(false);
|
|
|
|
if token_exists {
|
|
// If this tool has OAuth config, check whether new scopes are needed
|
|
let needs_reauth = if let Some(ref oauth) = auth.oauth {
|
|
let merged = self
|
|
.collect_shared_scopes(&auth.secret_name, &oauth.scopes)
|
|
.await;
|
|
let needs = self.needs_scope_expansion(&auth.secret_name, &merged).await;
|
|
tracing::debug!(
|
|
tool = name,
|
|
secret_name = %auth.secret_name,
|
|
merged_scopes = ?merged,
|
|
needs_reauth = needs,
|
|
"Scope expansion check"
|
|
);
|
|
needs
|
|
} else {
|
|
false
|
|
};
|
|
|
|
if !needs_reauth {
|
|
return Ok(AuthResult::authenticated(name, ExtensionKind::WasmTool));
|
|
}
|
|
// Fall through to OAuth branch for scope expansion
|
|
}
|
|
|
|
// If a token was provided, store it
|
|
if let Some(token_value) = token {
|
|
let params = CreateSecretParams::new(&auth.secret_name, token_value)
|
|
.with_provider(name.to_string());
|
|
self.secrets
|
|
.create(&self.user_id, params)
|
|
.await
|
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
|
|
|
return Ok(AuthResult::authenticated(name, ExtensionKind::WasmTool));
|
|
}
|
|
|
|
// OAuth flow: if the tool has OAuth config, start the browser-based flow.
|
|
// But only if credentials are available — if the tool has setup secrets
|
|
// for client_id/secret that aren't configured yet, return needs_setup.
|
|
if let Some(ref oauth) = auth.oauth {
|
|
if self.needs_setup_credentials(name, &auth, oauth).await {
|
|
let display = auth.display_name.as_deref().unwrap_or(name);
|
|
return Ok(AuthResult::needs_setup(
|
|
name,
|
|
ExtensionKind::WasmTool,
|
|
format!(
|
|
"Configure OAuth credentials for {} in the Setup tab.",
|
|
display
|
|
),
|
|
auth.setup_url.clone(),
|
|
));
|
|
}
|
|
|
|
return self
|
|
.start_wasm_oauth(name, &auth, oauth)
|
|
.await
|
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()));
|
|
}
|
|
|
|
// Return instructions for manual token entry
|
|
let display = auth.display_name.unwrap_or_else(|| name.to_string());
|
|
let instructions = auth
|
|
.instructions
|
|
.unwrap_or_else(|| format!("Please provide your {} API token/key.", display));
|
|
|
|
Ok(AuthResult::awaiting_token(
|
|
name,
|
|
ExtensionKind::WasmTool,
|
|
instructions,
|
|
auth.setup_url,
|
|
))
|
|
}
|
|
|
|
/// Determine the auth readiness of a WASM channel.
|
|
async fn check_channel_auth_status(&self, name: &str) -> ToolAuthState {
|
|
let cap_path = self
|
|
.wasm_channels_dir
|
|
.join(format!("{}.capabilities.json", name));
|
|
let Ok(cap_bytes) = tokio::fs::read(&cap_path).await else {
|
|
return ToolAuthState::NoAuth;
|
|
};
|
|
let Ok(cap_file) = crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
|
|
else {
|
|
return ToolAuthState::NoAuth;
|
|
};
|
|
|
|
let required: Vec<_> = cap_file
|
|
.setup
|
|
.required_secrets
|
|
.iter()
|
|
.filter(|s| !s.optional)
|
|
.collect();
|
|
if required.is_empty() {
|
|
return ToolAuthState::NoAuth;
|
|
}
|
|
|
|
let all_provided = futures::future::join_all(
|
|
required
|
|
.iter()
|
|
.map(|s| self.secrets.exists(&self.user_id, &s.name)),
|
|
)
|
|
.await
|
|
.into_iter()
|
|
.all(|r| r.unwrap_or(false));
|
|
|
|
if all_provided {
|
|
ToolAuthState::Ready
|
|
} else {
|
|
ToolAuthState::NeedsSetup
|
|
}
|
|
}
|
|
|
|
/// Load and parse a WASM tool's capabilities file.
|
|
///
|
|
/// Returns `None` if the file doesn't exist or can't be parsed.
|
|
async fn load_tool_capabilities(
|
|
&self,
|
|
name: &str,
|
|
) -> Option<crate::tools::wasm::CapabilitiesFile> {
|
|
let cap_path = self
|
|
.wasm_tools_dir
|
|
.join(format!("{}.capabilities.json", name));
|
|
let cap_bytes = tokio::fs::read(&cap_path).await.ok()?;
|
|
crate::tools::wasm::CapabilitiesFile::from_bytes(&cap_bytes).ok()
|
|
}
|
|
|
|
/// Collect merged OAuth scopes from all installed tools sharing the same secret_name.
|
|
///
|
|
/// When multiple tools share an OAuth provider (e.g., google-calendar and google-drive
|
|
/// both use `google_oauth_token`), we request all their scopes in a single OAuth flow
|
|
/// so one login covers everything.
|
|
async fn collect_shared_scopes(
|
|
&self,
|
|
secret_name: &str,
|
|
base_scopes: &[String],
|
|
) -> Vec<String> {
|
|
let mut all_scopes: std::collections::BTreeSet<String> =
|
|
base_scopes.iter().cloned().collect();
|
|
|
|
if let Ok(tools) = discover_tools(&self.wasm_tools_dir).await {
|
|
for tool_name in tools.keys() {
|
|
if let Some(cap) = self.load_tool_capabilities(tool_name).await
|
|
&& let Some(auth) = &cap.auth
|
|
&& auth.secret_name == secret_name
|
|
&& let Some(oauth) = &auth.oauth
|
|
{
|
|
all_scopes.extend(oauth.scopes.iter().cloned());
|
|
}
|
|
}
|
|
}
|
|
|
|
all_scopes.into_iter().collect()
|
|
}
|
|
|
|
/// Check whether the stored scopes are insufficient for the merged scopes.
|
|
async fn needs_scope_expansion(&self, secret_name: &str, merged_scopes: &[String]) -> bool {
|
|
if merged_scopes.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
let scopes_key = format!("{}_scopes", secret_name);
|
|
let stored_scopes: std::collections::HashSet<String> =
|
|
match self.secrets.get_decrypted(&self.user_id, &scopes_key).await {
|
|
Ok(secret) => {
|
|
let scopes: std::collections::HashSet<String> = secret
|
|
.expose()
|
|
.split_whitespace()
|
|
.map(String::from)
|
|
.collect();
|
|
tracing::debug!(
|
|
secret_name,
|
|
stored_scopes = ?scopes,
|
|
"Loaded stored scopes for expansion check"
|
|
);
|
|
scopes
|
|
}
|
|
Err(_) => {
|
|
// No stored scopes record — this is a legacy token created before
|
|
// scope tracking. Force re-auth to ensure all required scopes are granted.
|
|
tracing::debug!(
|
|
secret_name,
|
|
"No stored scopes record, forcing re-auth for legacy token"
|
|
);
|
|
return true;
|
|
}
|
|
};
|
|
|
|
// Check if any merged scope is missing from stored scopes
|
|
merged_scopes
|
|
.iter()
|
|
.any(|scope| !stored_scopes.contains(scope))
|
|
}
|
|
|
|
/// Find the setup secret names for OAuth client_id and client_secret.
|
|
///
|
|
/// Scans `setup.required_secrets` for names containing "client_id" and "client_secret".
|
|
/// Returns `(Option<(name, optional)>, Option<(name, optional)>)`.
|
|
async fn find_setup_credential_names(
|
|
&self,
|
|
tool_name: &str,
|
|
) -> (Option<(String, bool)>, Option<(String, bool)>) {
|
|
let Some(cap) = self.load_tool_capabilities(tool_name).await else {
|
|
return (None, None);
|
|
};
|
|
let Some(setup) = &cap.setup else {
|
|
return (None, None);
|
|
};
|
|
|
|
let mut client_id_entry = None;
|
|
let mut client_secret_entry = None;
|
|
for secret in &setup.required_secrets {
|
|
let lower = secret.name.to_lowercase();
|
|
if lower.ends_with("client_id") || lower == "client_id" {
|
|
client_id_entry = Some((secret.name.clone(), secret.optional));
|
|
} else if lower.ends_with("client_secret") || lower == "client_secret" {
|
|
client_secret_entry = Some((secret.name.clone(), secret.optional));
|
|
}
|
|
}
|
|
(client_id_entry, client_secret_entry)
|
|
}
|
|
|
|
/// Check if OAuth client credentials (client_id / client_secret) require
|
|
/// user input via the Setup tab. Returns `true` when at least one required
|
|
/// credential cannot be resolved through the full chain:
|
|
/// secrets store → inline → env var → builtin.
|
|
async fn needs_setup_credentials(
|
|
&self,
|
|
name: &str,
|
|
auth: &crate::tools::wasm::AuthCapabilitySchema,
|
|
oauth: &crate::tools::wasm::OAuthConfigSchema,
|
|
) -> bool {
|
|
let builtin = crate::cli::oauth_defaults::builtin_credentials(&auth.secret_name);
|
|
let (id_entry, secret_entry) = self.find_setup_credential_names(name).await;
|
|
|
|
for (entry, inline, env, fallback) in [
|
|
(
|
|
&id_entry,
|
|
&oauth.client_id,
|
|
&oauth.client_id_env,
|
|
builtin.as_ref().map(|c| c.client_id),
|
|
),
|
|
(
|
|
&secret_entry,
|
|
&oauth.client_secret,
|
|
&oauth.client_secret_env,
|
|
builtin.as_ref().map(|c| c.client_secret),
|
|
),
|
|
] {
|
|
let Some((ref setup_name, optional)) = *entry else {
|
|
continue;
|
|
};
|
|
if optional {
|
|
continue;
|
|
}
|
|
let resolved = self
|
|
.resolve_oauth_credential(inline, env, fallback, Some(setup_name))
|
|
.await
|
|
.is_some();
|
|
if !resolved {
|
|
return true;
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
/// Resolve an OAuth credential value via: secrets store → inline → env var → builtin.
|
|
///
|
|
/// For web gateway users, the secrets store is checked first because client_id/secret
|
|
/// may have been entered via the Setup tab (stored as setup secrets).
|
|
async fn resolve_oauth_credential(
|
|
&self,
|
|
inline_value: &Option<String>,
|
|
env_var_name: &Option<String>,
|
|
builtin_value: Option<&str>,
|
|
setup_secret_name: Option<&str>,
|
|
) -> Option<String> {
|
|
// 1. Check secrets store (entered via Setup tab)
|
|
if let Some(secret_name) = setup_secret_name
|
|
&& let Ok(secret) = self.secrets.get_decrypted(&self.user_id, secret_name).await
|
|
{
|
|
let val = secret.expose();
|
|
if !val.is_empty() {
|
|
return Some(val.to_string());
|
|
}
|
|
}
|
|
|
|
// 2. Inline value from capabilities.json
|
|
if let Some(val) = inline_value {
|
|
return Some(val.clone());
|
|
}
|
|
|
|
// 3. Runtime environment variable
|
|
if let Some(env) = env_var_name
|
|
&& let Ok(val) = std::env::var(env)
|
|
{
|
|
return Some(val);
|
|
}
|
|
|
|
// 4. Built-in defaults
|
|
builtin_value.map(String::from)
|
|
}
|
|
|
|
/// Start the OAuth browser flow for a WASM tool.
|
|
///
|
|
/// Binds a callback listener, builds the authorization URL, spawns a background
|
|
/// task to wait for the callback and exchange the code, then returns the auth URL
|
|
/// immediately so the web UI can open it.
|
|
async fn start_wasm_oauth(
|
|
&self,
|
|
name: &str,
|
|
auth: &crate::tools::wasm::AuthCapabilitySchema,
|
|
oauth: &crate::tools::wasm::OAuthConfigSchema,
|
|
) -> Result<AuthResult, String> {
|
|
use crate::cli::oauth_defaults;
|
|
|
|
let builtin = oauth_defaults::builtin_credentials(&auth.secret_name);
|
|
|
|
// Find setup secret names for client_id and client_secret from capabilities.
|
|
// These are the actual names used in the Setup tab (e.g., "google_oauth_client_id"),
|
|
// which may differ from "{secret_name}_client_id".
|
|
let (setup_client_id_entry, setup_client_secret_entry) =
|
|
self.find_setup_credential_names(name).await;
|
|
let setup_client_id_name = setup_client_id_entry.map(|(n, _)| n);
|
|
let setup_client_secret_name = setup_client_secret_entry.map(|(n, _)| n);
|
|
|
|
// Resolve client_id: setup secrets → inline → env var → builtin
|
|
let client_id = self
|
|
.resolve_oauth_credential(
|
|
&oauth.client_id,
|
|
&oauth.client_id_env,
|
|
builtin.as_ref().map(|c| c.client_id),
|
|
setup_client_id_name.as_deref(),
|
|
)
|
|
.await
|
|
.ok_or_else(|| {
|
|
let env_name = oauth
|
|
.client_id_env
|
|
.as_deref()
|
|
.unwrap_or("the client_id env var");
|
|
let mut msg = format!(
|
|
"OAuth client_id not configured for '{}'. \
|
|
Enter it in the Setup tab or set {} env var",
|
|
name, env_name
|
|
);
|
|
// Only mention the Google-specific build flag for Google providers
|
|
if auth.secret_name.to_lowercase().contains("google") {
|
|
msg.push_str(", or build with IRONCLAW_GOOGLE_CLIENT_ID");
|
|
}
|
|
msg.push('.');
|
|
msg
|
|
})?;
|
|
|
|
// Resolve client_secret (optional for PKCE-only flows)
|
|
let client_secret = self
|
|
.resolve_oauth_credential(
|
|
&oauth.client_secret,
|
|
&oauth.client_secret_env,
|
|
builtin.as_ref().map(|c| c.client_secret),
|
|
setup_client_secret_name.as_deref(),
|
|
)
|
|
.await;
|
|
|
|
// Cancel any existing pending auth for this tool (frees port 9876 in TCP mode)
|
|
{
|
|
let mut pending = self.pending_auth.write().await;
|
|
if let Some(old) = pending.remove(name)
|
|
&& let Some(handle) = old.task_handle
|
|
{
|
|
handle.abort();
|
|
}
|
|
}
|
|
// Also clean up any gateway-mode pending flows for this tool
|
|
{
|
|
let mut flows = self.pending_oauth_flows.write().await;
|
|
flows.retain(|_, flow| flow.extension_name != name);
|
|
}
|
|
|
|
let redirect_uri = format!("{}/callback", oauth_defaults::callback_url());
|
|
|
|
// Merge scopes from all tools sharing this provider
|
|
let merged_scopes = self
|
|
.collect_shared_scopes(&auth.secret_name, &oauth.scopes)
|
|
.await;
|
|
|
|
// Build authorization URL with CSRF state
|
|
let oauth_result = oauth_defaults::build_oauth_url(
|
|
&oauth.authorization_url,
|
|
&client_id,
|
|
&redirect_uri,
|
|
&merged_scopes,
|
|
oauth.use_pkce,
|
|
&oauth.extra_params,
|
|
);
|
|
let auth_url = oauth_result.url.clone();
|
|
let code_verifier = oauth_result.code_verifier;
|
|
let expected_state = oauth_result.state;
|
|
|
|
let display_name = auth
|
|
.display_name
|
|
.clone()
|
|
.unwrap_or_else(|| name.to_string());
|
|
|
|
if oauth_defaults::use_gateway_callback() {
|
|
// Gateway mode: store pending flow state for the web gateway's
|
|
// `/oauth/callback` handler to complete the exchange. No TCP listener
|
|
// needed — the OAuth provider redirects to the gateway URL.
|
|
oauth_defaults::sweep_expired_flows(&self.pending_oauth_flows).await;
|
|
|
|
// Wrap the CSRF nonce with instance name for platform routing.
|
|
// Nginx at auth.DOMAIN parses `instance:nonce` to route the callback
|
|
// to the correct container. The flow is keyed by the raw nonce.
|
|
let platform_state = oauth_defaults::build_platform_state(&expected_state);
|
|
let auth_url = if platform_state != expected_state {
|
|
auth_url.replace(
|
|
&format!("state={}", urlencoding::encode(&expected_state)),
|
|
&format!("state={}", urlencoding::encode(&platform_state)),
|
|
)
|
|
} else {
|
|
auth_url
|
|
};
|
|
|
|
let flow = oauth_defaults::PendingOAuthFlow {
|
|
extension_name: name.to_string(),
|
|
display_name: display_name.clone(),
|
|
token_url: oauth.token_url.clone(),
|
|
client_id: client_id.clone(),
|
|
client_secret: client_secret.clone(),
|
|
redirect_uri: redirect_uri.clone(),
|
|
code_verifier,
|
|
access_token_field: oauth.access_token_field.clone(),
|
|
secret_name: auth.secret_name.clone(),
|
|
provider: auth.provider.clone(),
|
|
validation_endpoint: auth.validation_endpoint.clone(),
|
|
scopes: merged_scopes,
|
|
user_id: self.user_id.clone(),
|
|
secrets: Arc::clone(&self.secrets),
|
|
sse_sender: self.sse_sender.read().await.clone(),
|
|
gateway_token: self.gateway_token.clone(),
|
|
created_at: std::time::Instant::now(),
|
|
};
|
|
|
|
// Key by raw nonce (without instance prefix) — the callback handler
|
|
// strips the prefix before lookup.
|
|
self.pending_oauth_flows
|
|
.write()
|
|
.await
|
|
.insert(expected_state, flow);
|
|
|
|
// Register pending auth without a task handle (gateway handles completion)
|
|
self.pending_auth.write().await.insert(
|
|
name.to_string(),
|
|
PendingAuth {
|
|
_name: name.to_string(),
|
|
_kind: ExtensionKind::WasmTool,
|
|
created_at: std::time::Instant::now(),
|
|
task_handle: None,
|
|
},
|
|
);
|
|
|
|
Ok(AuthResult::awaiting_authorization(
|
|
name,
|
|
ExtensionKind::WasmTool,
|
|
auth_url,
|
|
"gateway".to_string(),
|
|
))
|
|
} else {
|
|
// TCP listener mode: bind port 9876 and spawn a background task
|
|
// to wait for the callback. This is the original flow for local/desktop use.
|
|
let listener = oauth_defaults::bind_callback_listener()
|
|
.await
|
|
.map_err(|e| format!("Failed to start OAuth callback listener: {}", e))?;
|
|
|
|
let token_url = oauth.token_url.clone();
|
|
let access_token_field = oauth.access_token_field.clone();
|
|
let secret_name = auth.secret_name.clone();
|
|
let provider = auth.provider.clone();
|
|
let validation_endpoint = auth.validation_endpoint.clone();
|
|
let user_id = self.user_id.clone();
|
|
let secrets = Arc::clone(&self.secrets);
|
|
let sse_sender = self.sse_sender.read().await.clone();
|
|
let ext_name = name.to_string();
|
|
|
|
let task_handle = tokio::spawn(async move {
|
|
let result: Result<(), String> = async {
|
|
let code = oauth_defaults::wait_for_callback(
|
|
listener,
|
|
"/callback",
|
|
"code",
|
|
&display_name,
|
|
Some(&expected_state),
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
let token_response = oauth_defaults::exchange_oauth_code(
|
|
&token_url,
|
|
&client_id,
|
|
client_secret.as_deref(),
|
|
&code,
|
|
&redirect_uri,
|
|
code_verifier.as_deref(),
|
|
&access_token_field,
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
// Validate the token before storing (catches wrong account, etc.)
|
|
if let Some(ref validation) = validation_endpoint {
|
|
oauth_defaults::validate_oauth_token(
|
|
&token_response.access_token,
|
|
validation,
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
}
|
|
|
|
oauth_defaults::store_oauth_tokens(
|
|
secrets.as_ref(),
|
|
&user_id,
|
|
&secret_name,
|
|
provider.as_deref(),
|
|
&token_response.access_token,
|
|
token_response.refresh_token.as_deref(),
|
|
token_response.expires_in,
|
|
&merged_scopes,
|
|
)
|
|
.await
|
|
.map_err(|e| e.to_string())?;
|
|
|
|
Ok(())
|
|
}
|
|
.await;
|
|
|
|
// Broadcast SSE event
|
|
let (success, message) = match result {
|
|
Ok(()) => (true, format!("{} authenticated successfully", display_name)),
|
|
Err(ref e) => (
|
|
false,
|
|
format!("{} authentication failed: {}", display_name, e),
|
|
),
|
|
};
|
|
|
|
match &result {
|
|
Ok(()) => {
|
|
tracing::info!(
|
|
tool = %ext_name,
|
|
"OAuth completed successfully"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
tool = %ext_name,
|
|
error = %e,
|
|
"WASM tool OAuth failed"
|
|
);
|
|
}
|
|
}
|
|
|
|
if let Some(ref sender) = sse_sender {
|
|
let _ = sender.send(crate::channels::web::types::SseEvent::AuthCompleted {
|
|
extension_name: ext_name,
|
|
success,
|
|
message,
|
|
});
|
|
}
|
|
});
|
|
|
|
// Store pending auth with task handle
|
|
self.pending_auth.write().await.insert(
|
|
name.to_string(),
|
|
PendingAuth {
|
|
_name: name.to_string(),
|
|
_kind: ExtensionKind::WasmTool,
|
|
created_at: std::time::Instant::now(),
|
|
task_handle: Some(task_handle),
|
|
},
|
|
);
|
|
|
|
Ok(AuthResult::awaiting_authorization(
|
|
name,
|
|
ExtensionKind::WasmTool,
|
|
auth_url,
|
|
"local".to_string(),
|
|
))
|
|
}
|
|
}
|
|
|
|
/// Returns `true` if a setup secret is an OAuth credential (client_id or client_secret)
|
|
/// that can be resolved without user input — via inline capabilities, env var, or
|
|
/// builtin defaults.
|
|
///
|
|
/// Used by `check_tool_auth_status()` and `get_setup_schema()` to hide setup fields
|
|
/// that the user doesn't need to fill (e.g., Google tools with builtin credentials).
|
|
fn is_auto_resolved_oauth_field(
|
|
secret_name: &str,
|
|
cap_file: &crate::tools::wasm::CapabilitiesFile,
|
|
) -> bool {
|
|
let lower = secret_name.to_lowercase();
|
|
let is_client_id = lower.ends_with("client_id") || lower == "client_id";
|
|
let is_client_secret = lower.ends_with("client_secret") || lower == "client_secret";
|
|
if !is_client_id && !is_client_secret {
|
|
return false;
|
|
}
|
|
let Some(ref auth) = cap_file.auth else {
|
|
return false;
|
|
};
|
|
let Some(ref oauth) = auth.oauth else {
|
|
return false;
|
|
};
|
|
let builtin = crate::cli::oauth_defaults::builtin_credentials(&auth.secret_name);
|
|
|
|
if is_client_id {
|
|
oauth.client_id.is_some()
|
|
|| oauth
|
|
.client_id_env
|
|
.as_ref()
|
|
.is_some_and(|e| std::env::var(e).is_ok())
|
|
|| builtin.is_some()
|
|
} else {
|
|
oauth.client_secret.is_some()
|
|
|| oauth
|
|
.client_secret_env
|
|
.as_ref()
|
|
.is_some_and(|e| std::env::var(e).is_ok())
|
|
|| builtin.is_some()
|
|
}
|
|
}
|
|
|
|
/// Determine the auth readiness of a WASM tool.
|
|
async fn check_tool_auth_status(&self, name: &str) -> ToolAuthState {
|
|
let Some(cap_file) = self.load_tool_capabilities(name).await else {
|
|
return ToolAuthState::NoAuth;
|
|
};
|
|
|
|
// If the tool declares an auth section, the access token is the
|
|
// authoritative signal — setup secrets (client_id/secret) are
|
|
// intermediate and may be auto-resolved via builtins.
|
|
if let Some(ref auth) = cap_file.auth {
|
|
let has_token = self
|
|
.secrets
|
|
.exists(&self.user_id, &auth.secret_name)
|
|
.await
|
|
.unwrap_or(false)
|
|
|| auth
|
|
.env_var
|
|
.as_ref()
|
|
.is_some_and(|v| std::env::var(v).is_ok());
|
|
return if has_token {
|
|
ToolAuthState::Ready
|
|
} else if auth.oauth.is_some() {
|
|
ToolAuthState::NeedsAuth
|
|
} else {
|
|
ToolAuthState::NeedsSetup
|
|
};
|
|
}
|
|
|
|
// No auth section — fall back to checking setup.required_secrets.
|
|
let Some(setup) = &cap_file.setup else {
|
|
return ToolAuthState::NoAuth;
|
|
};
|
|
if setup.required_secrets.is_empty() {
|
|
return ToolAuthState::NoAuth;
|
|
}
|
|
|
|
let all_provided = futures::future::join_all(
|
|
setup
|
|
.required_secrets
|
|
.iter()
|
|
.filter(|s| !s.optional)
|
|
.filter(|s| !Self::is_auto_resolved_oauth_field(&s.name, &cap_file))
|
|
.map(|s| self.secrets.exists(&self.user_id, &s.name)),
|
|
)
|
|
.await
|
|
.into_iter()
|
|
.all(|r| r.unwrap_or(false));
|
|
|
|
if all_provided {
|
|
ToolAuthState::Ready
|
|
} else {
|
|
ToolAuthState::NeedsSetup
|
|
}
|
|
}
|
|
|
|
async fn auth_wasm_channel(
|
|
&self,
|
|
name: &str,
|
|
token: Option<&str>,
|
|
) -> Result<AuthResult, ExtensionError> {
|
|
let cap_path = self
|
|
.wasm_channels_dir
|
|
.join(format!("{}.capabilities.json", name));
|
|
|
|
if !cap_path.exists() {
|
|
return Ok(AuthResult::no_auth_required(
|
|
name,
|
|
ExtensionKind::WasmChannel,
|
|
));
|
|
}
|
|
|
|
let cap_bytes = tokio::fs::read(&cap_path)
|
|
.await
|
|
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
|
|
|
let cap_file = crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
|
|
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
|
|
|
// Get required secrets from the setup section
|
|
let required_secrets = &cap_file.setup.required_secrets;
|
|
if required_secrets.is_empty() {
|
|
return Ok(AuthResult::no_auth_required(
|
|
name,
|
|
ExtensionKind::WasmChannel,
|
|
));
|
|
}
|
|
|
|
// Find the first non-optional secret that isn't yet stored
|
|
let mut missing = Vec::new();
|
|
for secret in required_secrets {
|
|
if secret.optional {
|
|
continue;
|
|
}
|
|
if !self
|
|
.secrets
|
|
.exists(&self.user_id, &secret.name)
|
|
.await
|
|
.unwrap_or(false)
|
|
{
|
|
missing.push(secret);
|
|
}
|
|
}
|
|
|
|
if missing.is_empty() {
|
|
return Ok(AuthResult::authenticated(name, ExtensionKind::WasmChannel));
|
|
}
|
|
|
|
// If a token was provided, store it for the first missing secret
|
|
if let Some(token_value) = token {
|
|
let secret = &missing[0];
|
|
let params =
|
|
CreateSecretParams::new(&secret.name, token_value).with_provider(name.to_string());
|
|
self.secrets
|
|
.create(&self.user_id, params)
|
|
.await
|
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
|
|
|
// Check if there are more missing secrets
|
|
if missing.len() <= 1 {
|
|
return Ok(AuthResult::authenticated(name, ExtensionKind::WasmChannel));
|
|
}
|
|
|
|
// More secrets needed; prompt for the next one
|
|
let next = &missing[1];
|
|
return Ok(AuthResult::awaiting_token(
|
|
name,
|
|
ExtensionKind::WasmChannel,
|
|
next.prompt.clone(),
|
|
cap_file.setup.setup_url.clone(),
|
|
));
|
|
}
|
|
|
|
// Prompt for the first missing secret
|
|
let secret = &missing[0];
|
|
Ok(AuthResult::awaiting_token(
|
|
name,
|
|
ExtensionKind::WasmChannel,
|
|
secret.prompt.clone(),
|
|
cap_file.setup.setup_url.clone(),
|
|
))
|
|
}
|
|
|
|
async fn activate_mcp(&self, name: &str) -> Result<ActivateResult, ExtensionError> {
|
|
// Check if already activated
|
|
{
|
|
let clients = self.mcp_clients.read().await;
|
|
if clients.contains_key(name) {
|
|
// Already connected, just return the tool names
|
|
let tools: Vec<String> = self
|
|
.tool_registry
|
|
.list()
|
|
.await
|
|
.into_iter()
|
|
.filter(|t| t.starts_with(&format!("{}_", name)))
|
|
.collect();
|
|
|
|
return Ok(ActivateResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::McpServer,
|
|
tools_loaded: tools,
|
|
message: format!("MCP server '{}' already active", name),
|
|
});
|
|
}
|
|
}
|
|
|
|
let server = self
|
|
.get_mcp_server(name)
|
|
.await
|
|
.map_err(|e| ExtensionError::NotInstalled(e.to_string()))?;
|
|
|
|
let has_tokens = is_authenticated(&server, &self.secrets, &self.user_id).await;
|
|
|
|
let client = if has_tokens || server.requires_auth() {
|
|
McpClient::new_authenticated(
|
|
server.clone(),
|
|
Arc::clone(&self.mcp_session_manager),
|
|
Arc::clone(&self.secrets),
|
|
&self.user_id,
|
|
)
|
|
} else {
|
|
McpClient::new_with_name(&server.name, &server.url)
|
|
};
|
|
|
|
// Try to list and create tools
|
|
let mcp_tools = client
|
|
.list_tools()
|
|
.await
|
|
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
|
|
|
|
let tool_impls = client
|
|
.create_tools()
|
|
.await
|
|
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
|
|
|
|
let tool_names: Vec<String> = mcp_tools
|
|
.iter()
|
|
.map(|t| format!("{}_{}", name, t.name))
|
|
.collect();
|
|
|
|
for tool in tool_impls {
|
|
self.tool_registry.register(tool).await;
|
|
}
|
|
|
|
// Store the client
|
|
self.mcp_clients
|
|
.write()
|
|
.await
|
|
.insert(name.to_string(), Arc::new(client));
|
|
|
|
tracing::info!(
|
|
"Activated MCP server '{}' with {} tools",
|
|
name,
|
|
tool_names.len()
|
|
);
|
|
|
|
Ok(ActivateResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::McpServer,
|
|
tools_loaded: tool_names,
|
|
message: format!("Connected to '{}' and loaded tools", name),
|
|
})
|
|
}
|
|
|
|
async fn activate_wasm_tool(&self, name: &str) -> Result<ActivateResult, ExtensionError> {
|
|
// Check if already active
|
|
if self.tool_registry.has(name).await {
|
|
return Ok(ActivateResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::WasmTool,
|
|
tools_loaded: vec![name.to_string()],
|
|
message: format!("WASM tool '{}' already active", name),
|
|
});
|
|
}
|
|
|
|
let runtime = self.wasm_tool_runtime.as_ref().ok_or_else(|| {
|
|
ExtensionError::ActivationFailed("WASM runtime not available".to_string())
|
|
})?;
|
|
|
|
let wasm_path = self.wasm_tools_dir.join(format!("{}.wasm", name));
|
|
if !wasm_path.exists() {
|
|
return Err(ExtensionError::NotInstalled(format!(
|
|
"WASM tool '{}' not found at {}",
|
|
name,
|
|
wasm_path.display()
|
|
)));
|
|
}
|
|
|
|
let cap_path = self
|
|
.wasm_tools_dir
|
|
.join(format!("{}.capabilities.json", name));
|
|
let cap_path_option = if cap_path.exists() {
|
|
Some(cap_path.as_path())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let loader = WasmToolLoader::new(Arc::clone(runtime), Arc::clone(&self.tool_registry))
|
|
.with_secrets_store(Arc::clone(&self.secrets));
|
|
loader
|
|
.load_from_files(name, &wasm_path, cap_path_option)
|
|
.await
|
|
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
|
|
|
|
if let Some(ref hooks) = self.hooks
|
|
&& let Some(cap_path) = cap_path_option
|
|
{
|
|
let source = format!("plugin.tool:{}", name);
|
|
let registration =
|
|
crate::hooks::bootstrap::register_plugin_bundle_from_capabilities_file(
|
|
hooks, &source, cap_path,
|
|
)
|
|
.await;
|
|
|
|
if registration.total_registered() > 0 {
|
|
tracing::info!(
|
|
extension = name,
|
|
hooks = registration.hooks,
|
|
outbound_webhooks = registration.outbound_webhooks,
|
|
"Registered plugin hooks for activated WASM tool"
|
|
);
|
|
}
|
|
|
|
if registration.errors > 0 {
|
|
tracing::warn!(
|
|
extension = name,
|
|
errors = registration.errors,
|
|
"Some plugin hooks failed to register"
|
|
);
|
|
}
|
|
}
|
|
|
|
tracing::info!("Activated WASM tool '{}'", name);
|
|
|
|
Ok(ActivateResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::WasmTool,
|
|
tools_loaded: vec![name.to_string()],
|
|
message: format!("WASM tool '{}' loaded and ready", name),
|
|
})
|
|
}
|
|
|
|
/// Activate a WASM channel at runtime without restarting.
|
|
///
|
|
/// Loads the channel from its WASM file, injects credentials and config,
|
|
/// registers it with the webhook router, and hot-adds it to the channel manager
|
|
/// so its stream feeds into the agent loop.
|
|
async fn activate_wasm_channel(&self, name: &str) -> Result<ActivateResult, ExtensionError> {
|
|
// If already active, re-inject credentials and refresh webhook secret.
|
|
// Handles the case where a channel was loaded at startup before the
|
|
// user saved secrets via the web UI.
|
|
{
|
|
let active = self.active_channel_names.read().await;
|
|
if active.contains(name) {
|
|
return self.refresh_active_channel(name).await;
|
|
}
|
|
}
|
|
|
|
// Verify runtime infrastructure is available and clone Arcs so we don't
|
|
// hold the RwLock guard across awaits.
|
|
let (
|
|
channel_runtime,
|
|
channel_manager,
|
|
pairing_store,
|
|
wasm_channel_router,
|
|
wasm_channel_owner_ids,
|
|
) = {
|
|
let rt_guard = self.channel_runtime.read().await;
|
|
let rt = rt_guard.as_ref().ok_or_else(|| {
|
|
ExtensionError::ActivationFailed("WASM channel runtime not configured".to_string())
|
|
})?;
|
|
(
|
|
Arc::clone(&rt.wasm_channel_runtime),
|
|
Arc::clone(&rt.channel_manager),
|
|
Arc::clone(&rt.pairing_store),
|
|
Arc::clone(&rt.wasm_channel_router),
|
|
rt.wasm_channel_owner_ids.clone(),
|
|
)
|
|
};
|
|
|
|
// Check auth status first
|
|
let auth_state = self.check_channel_auth_status(name).await;
|
|
if auth_state != ToolAuthState::Ready && auth_state != ToolAuthState::NoAuth {
|
|
return Err(ExtensionError::ActivationFailed(format!(
|
|
"Channel '{}' requires configuration. Use the setup form to provide credentials.",
|
|
name
|
|
)));
|
|
}
|
|
|
|
// Load the channel from files
|
|
let wasm_path = self.wasm_channels_dir.join(format!("{}.wasm", name));
|
|
let cap_path = self
|
|
.wasm_channels_dir
|
|
.join(format!("{}.capabilities.json", name));
|
|
let cap_path_option = if cap_path.exists() {
|
|
Some(cap_path.as_path())
|
|
} else {
|
|
None
|
|
};
|
|
|
|
let settings_store: Option<Arc<dyn crate::db::SettingsStore>> =
|
|
self.store.as_ref().map(|db| Arc::clone(db) as _);
|
|
let loader = WasmChannelLoader::new(
|
|
Arc::clone(&channel_runtime),
|
|
Arc::clone(&pairing_store),
|
|
settings_store,
|
|
)
|
|
.with_secrets_store(Arc::clone(&self.secrets));
|
|
let loaded = loader
|
|
.load_from_files(name, &wasm_path, cap_path_option)
|
|
.await
|
|
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
|
|
|
|
let channel_name = loaded.name().to_string();
|
|
let webhook_secret_name = loaded.webhook_secret_name();
|
|
let secret_header = loaded.webhook_secret_header().map(|s| s.to_string());
|
|
let sig_key_secret_name = loaded.signature_key_secret_name();
|
|
let hmac_secret_name = loaded.hmac_secret_name();
|
|
|
|
// Get webhook secret from secrets store
|
|
let webhook_secret = self
|
|
.secrets
|
|
.get_decrypted(&self.user_id, &webhook_secret_name)
|
|
.await
|
|
.ok()
|
|
.map(|s| s.expose().to_string());
|
|
|
|
let channel_arc = Arc::new(loaded.channel);
|
|
|
|
// Inject runtime config (tunnel_url, webhook_secret, owner_id)
|
|
{
|
|
let mut config_updates = std::collections::HashMap::new();
|
|
|
|
if let Some(ref tunnel_url) = self.tunnel_url {
|
|
config_updates.insert(
|
|
"tunnel_url".to_string(),
|
|
serde_json::Value::String(tunnel_url.clone()),
|
|
);
|
|
}
|
|
|
|
if let Some(ref secret) = webhook_secret {
|
|
config_updates.insert(
|
|
"webhook_secret".to_string(),
|
|
serde_json::Value::String(secret.clone()),
|
|
);
|
|
}
|
|
|
|
if let Some(&owner_id) = wasm_channel_owner_ids.get(channel_name.as_str()) {
|
|
config_updates.insert("owner_id".to_string(), serde_json::json!(owner_id));
|
|
}
|
|
|
|
if !config_updates.is_empty() {
|
|
channel_arc.update_config(config_updates).await;
|
|
tracing::info!(
|
|
channel = %channel_name,
|
|
has_tunnel = self.tunnel_url.is_some(),
|
|
has_webhook_secret = webhook_secret.is_some(),
|
|
"Injected runtime config into hot-activated channel"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Register with webhook router
|
|
{
|
|
let webhook_path = format!("/webhook/{}", channel_name);
|
|
let endpoints = vec![RegisteredEndpoint {
|
|
channel_name: channel_name.clone(),
|
|
path: webhook_path,
|
|
methods: vec!["POST".to_string()],
|
|
require_secret: webhook_secret.is_some(),
|
|
}];
|
|
|
|
wasm_channel_router
|
|
.register(
|
|
Arc::clone(&channel_arc),
|
|
endpoints,
|
|
webhook_secret,
|
|
secret_header,
|
|
)
|
|
.await;
|
|
tracing::info!(channel = %channel_name, "Registered hot-activated channel with webhook router");
|
|
|
|
// Register Ed25519 signature key if declared in capabilities
|
|
if let Some(ref sig_key_name) = sig_key_secret_name
|
|
&& let Ok(key_secret) = self
|
|
.secrets
|
|
.get_decrypted(&self.user_id, sig_key_name)
|
|
.await
|
|
{
|
|
match wasm_channel_router
|
|
.register_signature_key(&channel_name, key_secret.expose())
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
tracing::info!(channel = %channel_name, "Registered signature key for hot-activated channel")
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(channel = %channel_name, error = %e, "Failed to register signature key")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Register HMAC signing secret if declared in capabilities
|
|
if let Some(hmac_name) = &hmac_secret_name {
|
|
match self.secrets.get_decrypted(&self.user_id, hmac_name).await {
|
|
Ok(secret) => {
|
|
wasm_channel_router
|
|
.register_hmac_secret(&channel_name, secret.expose())
|
|
.await;
|
|
tracing::info!(channel = %channel_name, "Registered HMAC signing secret for hot-activated channel");
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(channel = %channel_name, error = %e, "HMAC secret not found");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Inject credentials
|
|
match crate::extensions::manager::inject_channel_credentials_from_secrets(
|
|
&channel_arc,
|
|
self.secrets.as_ref(),
|
|
&channel_name,
|
|
&self.user_id,
|
|
)
|
|
.await
|
|
{
|
|
Ok(count) => {
|
|
if count > 0 {
|
|
tracing::info!(
|
|
channel = %channel_name,
|
|
credentials_injected = count,
|
|
"Credentials injected into hot-activated channel"
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
channel = %channel_name,
|
|
error = %e,
|
|
"Failed to inject credentials into hot-activated channel"
|
|
);
|
|
}
|
|
}
|
|
|
|
// Hot-add the channel to the running agent
|
|
channel_manager
|
|
.hot_add(Box::new(SharedWasmChannel::new(channel_arc)))
|
|
.await
|
|
.map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?;
|
|
|
|
// Mark as active
|
|
self.active_channel_names
|
|
.write()
|
|
.await
|
|
.insert(channel_name.clone());
|
|
|
|
// Persist activation state so the channel auto-activates on restart
|
|
self.persist_active_channels().await;
|
|
|
|
tracing::info!(channel = %channel_name, "Hot-activated WASM channel");
|
|
|
|
Ok(ActivateResult {
|
|
name: channel_name,
|
|
kind: ExtensionKind::WasmChannel,
|
|
tools_loaded: Vec::new(),
|
|
message: format!("Channel '{}' activated and running", name),
|
|
})
|
|
}
|
|
|
|
/// Refresh credentials and webhook secret on an already-active channel.
|
|
///
|
|
/// Called when the user saves new secrets via the setup form for a channel
|
|
/// that was loaded at startup (possibly without credentials).
|
|
async fn refresh_active_channel(&self, name: &str) -> Result<ActivateResult, ExtensionError> {
|
|
let router = {
|
|
let rt_guard = self.channel_runtime.read().await;
|
|
match rt_guard.as_ref() {
|
|
Some(rt) => Arc::clone(&rt.wasm_channel_router),
|
|
None => {
|
|
return Ok(ActivateResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::WasmChannel,
|
|
tools_loaded: Vec::new(),
|
|
message: format!("Channel '{}' is already active", name),
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
let webhook_path = format!("/webhook/{}", name);
|
|
let existing_channel = match router.get_channel_for_path(&webhook_path).await {
|
|
Some(ch) => ch,
|
|
None => {
|
|
return Ok(ActivateResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::WasmChannel,
|
|
tools_loaded: Vec::new(),
|
|
message: format!("Channel '{}' is already active", name),
|
|
});
|
|
}
|
|
};
|
|
|
|
// Re-inject credentials from secrets store into the running channel
|
|
let cred_count = match inject_channel_credentials_from_secrets(
|
|
&existing_channel,
|
|
self.secrets.as_ref(),
|
|
name,
|
|
&self.user_id,
|
|
)
|
|
.await
|
|
{
|
|
Ok(count) => count,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
channel = %name,
|
|
error = %e,
|
|
"Failed to refresh credentials on already-active channel"
|
|
);
|
|
0
|
|
}
|
|
};
|
|
|
|
// Load capabilities file once to extract all secret names
|
|
let cap_path = self
|
|
.wasm_channels_dir
|
|
.join(format!("{}.capabilities.json", name));
|
|
let capabilities_file = match tokio::fs::read(&cap_path).await {
|
|
Ok(bytes) => crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&bytes).ok(),
|
|
Err(_) => None,
|
|
};
|
|
|
|
// Extract all secret names from the capabilities file
|
|
let webhook_secret_name = capabilities_file
|
|
.as_ref()
|
|
.map(|f| f.webhook_secret_name())
|
|
.unwrap_or_else(|| format!("{}_webhook_secret", name));
|
|
|
|
let sig_key_secret_name = capabilities_file
|
|
.as_ref()
|
|
.and_then(|f| f.signature_key_secret_name().map(|s| s.to_string()));
|
|
|
|
let hmac_secret_name = capabilities_file
|
|
.as_ref()
|
|
.and_then(|f| f.hmac_secret_name().map(|s| s.to_string()));
|
|
|
|
// Refresh webhook secret
|
|
if let Ok(secret) = self
|
|
.secrets
|
|
.get_decrypted(&self.user_id, &webhook_secret_name)
|
|
.await
|
|
{
|
|
router
|
|
.update_secret(name, secret.expose().to_string())
|
|
.await;
|
|
|
|
// Also inject the webhook_secret into the channel's runtime config
|
|
let mut config_updates = std::collections::HashMap::new();
|
|
config_updates.insert(
|
|
"webhook_secret".to_string(),
|
|
serde_json::Value::String(secret.expose().to_string()),
|
|
);
|
|
existing_channel.update_config(config_updates).await;
|
|
}
|
|
|
|
// Refresh signature key
|
|
if let Some(ref sig_key_name) = sig_key_secret_name
|
|
&& let Ok(key_secret) = self
|
|
.secrets
|
|
.get_decrypted(&self.user_id, sig_key_name)
|
|
.await
|
|
{
|
|
match router
|
|
.register_signature_key(name, key_secret.expose())
|
|
.await
|
|
{
|
|
Ok(()) => {
|
|
tracing::info!(channel = %name, "Refreshed signature verification key")
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(channel = %name, error = %e, "Failed to refresh signature key")
|
|
}
|
|
}
|
|
}
|
|
|
|
// Refresh HMAC signing secret
|
|
if let Some(ref hmac_secret_name_ref) = hmac_secret_name {
|
|
match self
|
|
.secrets
|
|
.get_decrypted(&self.user_id, hmac_secret_name_ref)
|
|
.await
|
|
{
|
|
Ok(secret) => {
|
|
router.register_hmac_secret(name, secret.expose()).await;
|
|
tracing::info!(channel = %name, "Refreshed HMAC signing secret");
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(channel = %name, error = %e, "HMAC secret not found");
|
|
}
|
|
}
|
|
}
|
|
|
|
// Refresh tunnel_url in case it wasn't set at startup
|
|
if let Some(ref tunnel_url) = self.tunnel_url {
|
|
let mut config_updates = std::collections::HashMap::new();
|
|
config_updates.insert(
|
|
"tunnel_url".to_string(),
|
|
serde_json::Value::String(tunnel_url.clone()),
|
|
);
|
|
existing_channel.update_config(config_updates).await;
|
|
}
|
|
|
|
// Re-call on_start() to trigger webhook registration with the
|
|
// now-available credentials (e.g., setWebhook for Telegram).
|
|
if cred_count > 0 {
|
|
match existing_channel.call_on_start().await {
|
|
Ok(_config) => {
|
|
tracing::info!(
|
|
channel = %name,
|
|
"Re-ran on_start after credential refresh (webhook re-registered)"
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
channel = %name,
|
|
error = %e,
|
|
"on_start failed after credential refresh"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
tracing::info!(
|
|
channel = %name,
|
|
credentials_refreshed = cred_count,
|
|
"Refreshed credentials and config on already-active channel"
|
|
);
|
|
|
|
Ok(ActivateResult {
|
|
name: name.to_string(),
|
|
kind: ExtensionKind::WasmChannel,
|
|
tools_loaded: Vec::new(),
|
|
message: format!(
|
|
"Channel '{}' is already active; refreshed {} credential(s)",
|
|
name, cred_count
|
|
),
|
|
})
|
|
}
|
|
|
|
/// Determine what kind of installed extension this is.
|
|
async fn determine_installed_kind(&self, name: &str) -> Result<ExtensionKind, ExtensionError> {
|
|
// Check MCP servers first
|
|
if self.get_mcp_server(name).await.is_ok() {
|
|
return Ok(ExtensionKind::McpServer);
|
|
}
|
|
|
|
// Check WASM tools
|
|
let wasm_path = self.wasm_tools_dir.join(format!("{}.wasm", name));
|
|
if wasm_path.exists() {
|
|
return Ok(ExtensionKind::WasmTool);
|
|
}
|
|
|
|
// Check WASM channels
|
|
let channel_path = self.wasm_channels_dir.join(format!("{}.wasm", name));
|
|
if channel_path.exists() {
|
|
return Ok(ExtensionKind::WasmChannel);
|
|
}
|
|
|
|
Err(ExtensionError::NotInstalled(format!(
|
|
"'{}' is not installed as an MCP server, WASM tool, or WASM channel",
|
|
name
|
|
)))
|
|
}
|
|
|
|
/// Reject names containing path separators or traversal sequences.
|
|
fn validate_extension_name(name: &str) -> Result<(), ExtensionError> {
|
|
if name.contains('/') || name.contains('\\') || name.contains("..") || name.contains('\0') {
|
|
return Err(ExtensionError::InstallFailed(format!(
|
|
"Invalid extension name '{}': contains path separator or traversal characters",
|
|
name
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn cleanup_expired_auths(&self) {
|
|
let mut pending = self.pending_auth.write().await;
|
|
pending.retain(|_, auth| {
|
|
let expired = auth.created_at.elapsed() >= std::time::Duration::from_secs(300);
|
|
if expired {
|
|
// Abort the background listener task to free port 9876
|
|
if let Some(ref handle) = auth.task_handle {
|
|
handle.abort();
|
|
}
|
|
}
|
|
!expired
|
|
});
|
|
}
|
|
|
|
/// Get the setup schema for an extension (secret fields and their status).
|
|
pub async fn get_setup_schema(
|
|
&self,
|
|
name: &str,
|
|
) -> Result<Vec<crate::channels::web::types::SecretFieldInfo>, ExtensionError> {
|
|
let kind = self.determine_installed_kind(name).await?;
|
|
match kind {
|
|
ExtensionKind::WasmChannel => {
|
|
let cap_path = self
|
|
.wasm_channels_dir
|
|
.join(format!("{}.capabilities.json", name));
|
|
if !cap_path.exists() {
|
|
return Ok(Vec::new());
|
|
}
|
|
let cap_bytes = tokio::fs::read(&cap_path)
|
|
.await
|
|
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
|
let cap_file =
|
|
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
|
|
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
|
|
|
let mut fields = Vec::new();
|
|
for secret in &cap_file.setup.required_secrets {
|
|
let provided = self
|
|
.secrets
|
|
.exists(&self.user_id, &secret.name)
|
|
.await
|
|
.unwrap_or(false);
|
|
fields.push(crate::channels::web::types::SecretFieldInfo {
|
|
name: secret.name.clone(),
|
|
prompt: secret.prompt.clone(),
|
|
optional: secret.optional,
|
|
provided,
|
|
auto_generate: secret.auto_generate.is_some(),
|
|
});
|
|
}
|
|
Ok(fields)
|
|
}
|
|
ExtensionKind::WasmTool => {
|
|
let Some(cap_file) = self.load_tool_capabilities(name).await else {
|
|
return Ok(Vec::new());
|
|
};
|
|
|
|
let mut fields = Vec::new();
|
|
if let Some(setup) = &cap_file.setup {
|
|
for secret in &setup.required_secrets {
|
|
// Skip OAuth client_id/secret fields that resolve automatically
|
|
if Self::is_auto_resolved_oauth_field(&secret.name, &cap_file) {
|
|
continue;
|
|
}
|
|
let provided = self
|
|
.secrets
|
|
.exists(&self.user_id, &secret.name)
|
|
.await
|
|
.unwrap_or(false);
|
|
fields.push(crate::channels::web::types::SecretFieldInfo {
|
|
name: secret.name.clone(),
|
|
prompt: secret.prompt.clone(),
|
|
optional: secret.optional,
|
|
provided,
|
|
auto_generate: false,
|
|
});
|
|
}
|
|
}
|
|
Ok(fields)
|
|
}
|
|
_ => Ok(Vec::new()),
|
|
}
|
|
}
|
|
|
|
/// Save setup secrets for an extension, validating names against the capabilities schema.
|
|
///
|
|
/// After saving, attempts to hot-activate the channel. Returns a [`SetupResult`]
|
|
/// indicating whether activation succeeded (so the frontend can show appropriate UI).
|
|
pub async fn save_setup_secrets(
|
|
&self,
|
|
name: &str,
|
|
secrets: &std::collections::HashMap<String, String>,
|
|
) -> Result<SetupResult, ExtensionError> {
|
|
let kind = self.determine_installed_kind(name).await?;
|
|
|
|
// Load allowed secret names from the extension's capabilities file
|
|
let allowed: std::collections::HashSet<String> = match kind {
|
|
ExtensionKind::WasmChannel => {
|
|
let cap_path = self
|
|
.wasm_channels_dir
|
|
.join(format!("{}.capabilities.json", name));
|
|
if !cap_path.exists() {
|
|
return Err(ExtensionError::Other(format!(
|
|
"Capabilities file not found for '{}'",
|
|
name
|
|
)));
|
|
}
|
|
let cap_bytes = tokio::fs::read(&cap_path)
|
|
.await
|
|
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
|
let cap_file =
|
|
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
|
|
.map_err(|e| ExtensionError::Other(e.to_string()))?;
|
|
cap_file
|
|
.setup
|
|
.required_secrets
|
|
.iter()
|
|
.map(|s| s.name.clone())
|
|
.collect()
|
|
}
|
|
ExtensionKind::WasmTool => {
|
|
let cap_file = self.load_tool_capabilities(name).await.ok_or_else(|| {
|
|
ExtensionError::Other(format!("Capabilities file not found for '{}'", name))
|
|
})?;
|
|
match cap_file.setup {
|
|
Some(s) => s.required_secrets.iter().map(|s| s.name.clone()).collect(),
|
|
None => {
|
|
return Err(ExtensionError::Other(format!(
|
|
"Tool '{}' has no setup schema — no secrets to configure",
|
|
name
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
_ => {
|
|
return Err(ExtensionError::Other(
|
|
"Setup is only supported for WASM channels and tools".to_string(),
|
|
));
|
|
}
|
|
};
|
|
|
|
// For Telegram, validate the bot token against the API before storing it.
|
|
// This catches bad tokens immediately (both on first setup and reconfigure),
|
|
// before the channel activates and potentially shows as active with a bad token.
|
|
if name == "telegram"
|
|
&& let Some(token_value) = secrets.get("telegram_bot_token")
|
|
{
|
|
let token = token_value.trim();
|
|
if !token.is_empty() {
|
|
let encoded_token =
|
|
url::form_urlencoded::byte_serialize(token.as_bytes()).collect::<String>();
|
|
let url = format!("https://api.telegram.org/bot{}/getMe", encoded_token);
|
|
let resp = reqwest::Client::builder()
|
|
.timeout(std::time::Duration::from_secs(10))
|
|
.build()
|
|
.map_err(|e| ExtensionError::Other(e.to_string()))?
|
|
.get(&url)
|
|
.send()
|
|
.await
|
|
.map_err(|e| {
|
|
ExtensionError::Other(format!("Failed to validate bot token: {}", e))
|
|
})?;
|
|
if !resp.status().is_success() {
|
|
return Err(ExtensionError::Other(format!(
|
|
"Invalid bot token (Telegram API returned {})",
|
|
resp.status()
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
|
|
// Validate and store each submitted secret
|
|
for (secret_name, secret_value) in secrets {
|
|
if !allowed.contains(secret_name.as_str()) {
|
|
return Err(ExtensionError::Other(format!(
|
|
"Unknown secret '{}' for extension '{}'",
|
|
secret_name, name
|
|
)));
|
|
}
|
|
if secret_value.trim().is_empty() {
|
|
continue;
|
|
}
|
|
let params =
|
|
CreateSecretParams::new(secret_name, secret_value).with_provider(name.to_string());
|
|
self.secrets
|
|
.create(&self.user_id, params)
|
|
.await
|
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
|
}
|
|
|
|
// Auto-generate any missing secrets (channel-only feature)
|
|
if kind == ExtensionKind::WasmChannel {
|
|
let cap_path = self
|
|
.wasm_channels_dir
|
|
.join(format!("{}.capabilities.json", name));
|
|
if let Ok(cap_bytes) = tokio::fs::read(&cap_path).await
|
|
&& let Ok(cap_file) =
|
|
crate::channels::wasm::ChannelCapabilitiesFile::from_bytes(&cap_bytes)
|
|
{
|
|
for secret_def in &cap_file.setup.required_secrets {
|
|
if let Some(ref auto_gen) = secret_def.auto_generate {
|
|
let already_provided = secrets
|
|
.get(&secret_def.name)
|
|
.is_some_and(|v| !v.trim().is_empty());
|
|
let already_stored = self
|
|
.secrets
|
|
.exists(&self.user_id, &secret_def.name)
|
|
.await
|
|
.unwrap_or(false);
|
|
if !already_provided && !already_stored {
|
|
use rand::RngCore;
|
|
use rand::rngs::OsRng;
|
|
let mut bytes = vec![0u8; auto_gen.length];
|
|
OsRng.fill_bytes(&mut bytes);
|
|
let hex_value: String =
|
|
bytes.iter().map(|b| format!("{b:02x}")).collect();
|
|
let params = CreateSecretParams::new(&secret_def.name, &hex_value)
|
|
.with_provider(name.to_string());
|
|
self.secrets
|
|
.create(&self.user_id, params)
|
|
.await
|
|
.map_err(|e| ExtensionError::AuthFailed(e.to_string()))?;
|
|
tracing::info!(
|
|
"Auto-generated secret '{}' for channel '{}'",
|
|
secret_def.name,
|
|
name
|
|
);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// For tools, save and attempt auto-activation, then check auth.
|
|
if kind == ExtensionKind::WasmTool {
|
|
match self.activate_wasm_tool(name).await {
|
|
Ok(result) => {
|
|
// Delete existing OAuth token so auth() starts a fresh flow.
|
|
// Done AFTER activation succeeds to avoid losing tokens on failure.
|
|
// This covers Reconfigure: user wants to re-auth (switch account, update creds).
|
|
if let Some(cap) = self.load_tool_capabilities(name).await
|
|
&& let Some(ref auth_cfg) = cap.auth
|
|
&& auth_cfg.oauth.is_some()
|
|
{
|
|
let _ = self
|
|
.secrets
|
|
.delete(&self.user_id, &auth_cfg.secret_name)
|
|
.await;
|
|
let _ = self
|
|
.secrets
|
|
.delete(&self.user_id, &format!("{}_scopes", auth_cfg.secret_name))
|
|
.await;
|
|
let _ = self
|
|
.secrets
|
|
.delete(
|
|
&self.user_id,
|
|
&format!("{}_refresh_token", auth_cfg.secret_name),
|
|
)
|
|
.await;
|
|
}
|
|
|
|
// Check if auth is needed (OAuth or manual token).
|
|
// This is safe to call here — cancel-and-retry prevents port conflicts.
|
|
let mut auth_url = None;
|
|
if let Ok(auth_result) = self.auth(name, None).await {
|
|
auth_url = auth_result.auth_url().map(String::from);
|
|
}
|
|
let message = if auth_url.is_some() {
|
|
format!(
|
|
"Configuration saved and tool '{}' activated. Complete OAuth in your browser.",
|
|
name
|
|
)
|
|
} else {
|
|
format!(
|
|
"Configuration saved and tool '{}' activated. {}",
|
|
name, result.message
|
|
)
|
|
};
|
|
return Ok(SetupResult {
|
|
message,
|
|
activated: true,
|
|
auth_url,
|
|
});
|
|
}
|
|
Err(e) => {
|
|
tracing::debug!(
|
|
"Auto-activation of tool '{}' after setup failed: {}",
|
|
name,
|
|
e
|
|
);
|
|
return Ok(SetupResult {
|
|
message: format!("Configuration saved for '{}'.", name),
|
|
activated: false,
|
|
auth_url: None,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// Try to hot-activate the channel now that secrets are saved
|
|
match self.activate_wasm_channel(name).await {
|
|
Ok(result) => {
|
|
self.activation_errors.write().await.remove(name);
|
|
self.broadcast_extension_status(name, "active", None).await;
|
|
Ok(SetupResult {
|
|
message: format!(
|
|
"Configuration saved and channel '{}' activated. {}",
|
|
name, result.message
|
|
),
|
|
activated: true,
|
|
auth_url: None,
|
|
})
|
|
}
|
|
Err(e) => {
|
|
let error_msg = e.to_string();
|
|
tracing::warn!(
|
|
channel = name,
|
|
error = %e,
|
|
"Saved configuration but hot-activation failed"
|
|
);
|
|
self.activation_errors
|
|
.write()
|
|
.await
|
|
.insert(name.to_string(), error_msg.clone());
|
|
self.broadcast_extension_status(name, "failed", Some(&error_msg))
|
|
.await;
|
|
Ok(SetupResult {
|
|
message: format!(
|
|
"Configuration saved for '{}'. Activation failed: {}",
|
|
name, e
|
|
),
|
|
activated: false,
|
|
auth_url: None,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read a capabilities.json file and revoke its credential mappings from
|
|
/// the shared credential registry, so removed extensions lose injection
|
|
/// authority immediately.
|
|
async fn revoke_credential_mappings(&self, cap_path: &std::path::Path) {
|
|
if !cap_path.exists() {
|
|
return;
|
|
}
|
|
let Ok(bytes) = tokio::fs::read(cap_path).await else {
|
|
return;
|
|
};
|
|
// Extract secret names from the capabilities JSON.
|
|
// Structure: { "http": { "credentials": { "<key>": { "secret_name": "..." } } } }
|
|
let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
|
|
return;
|
|
};
|
|
let secret_names: Vec<String> = json
|
|
.get("http")
|
|
.and_then(|h| h.get("credentials"))
|
|
.and_then(|c| c.as_object())
|
|
.map(|creds| {
|
|
creds
|
|
.values()
|
|
.filter_map(|v| v.get("secret_name").and_then(|s| s.as_str()))
|
|
.map(String::from)
|
|
.collect()
|
|
})
|
|
.unwrap_or_default();
|
|
|
|
if secret_names.is_empty() {
|
|
return;
|
|
}
|
|
|
|
if let Some(cr) = self.tool_registry.credential_registry() {
|
|
cr.remove_mappings_for_secrets(&secret_names);
|
|
tracing::info!(
|
|
secrets = ?secret_names,
|
|
"Revoked credential mappings for removed extension"
|
|
);
|
|
}
|
|
}
|
|
|
|
async fn unregister_hook_prefix(&self, prefix: &str) -> usize {
|
|
let Some(ref hooks) = self.hooks else {
|
|
return 0;
|
|
};
|
|
|
|
let names = hooks.list().await;
|
|
let mut removed = 0;
|
|
for hook_name in names {
|
|
if hook_name.starts_with(prefix) && hooks.unregister(&hook_name).await {
|
|
removed += 1;
|
|
}
|
|
}
|
|
removed
|
|
}
|
|
}
|
|
|
|
/// Inject credentials for a channel based on naming convention.
|
|
///
|
|
/// Looks for secrets matching the pattern `{channel_name}_*` and injects them
|
|
/// as credential placeholders (e.g., `telegram_bot_token` -> `{TELEGRAM_BOT_TOKEN}`).
|
|
///
|
|
/// Returns the number of credentials injected.
|
|
async fn inject_channel_credentials_from_secrets(
|
|
channel: &Arc<crate::channels::wasm::WasmChannel>,
|
|
secrets: &dyn SecretsStore,
|
|
channel_name: &str,
|
|
user_id: &str,
|
|
) -> Result<usize, String> {
|
|
let all_secrets = secrets
|
|
.list(user_id)
|
|
.await
|
|
.map_err(|e| format!("Failed to list secrets: {}", e))?;
|
|
|
|
let prefix = format!("{}_", channel_name);
|
|
let mut count = 0;
|
|
|
|
for secret_meta in all_secrets {
|
|
if !secret_meta.name.starts_with(&prefix) {
|
|
continue;
|
|
}
|
|
|
|
let decrypted = match secrets.get_decrypted(user_id, &secret_meta.name).await {
|
|
Ok(d) => d,
|
|
Err(e) => {
|
|
tracing::warn!(
|
|
secret = %secret_meta.name,
|
|
error = %e,
|
|
"Failed to decrypt secret for channel credential injection"
|
|
);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let placeholder = secret_meta.name.to_uppercase();
|
|
channel
|
|
.set_credential(&placeholder, decrypted.expose().to_string())
|
|
.await;
|
|
count += 1;
|
|
}
|
|
|
|
Ok(count)
|
|
}
|
|
|
|
/// Infer the extension kind from a URL.
|
|
fn infer_kind_from_url(url: &str) -> ExtensionKind {
|
|
if url.ends_with(".wasm") || url.ends_with(".tar.gz") {
|
|
ExtensionKind::WasmTool
|
|
} else {
|
|
ExtensionKind::McpServer
|
|
}
|
|
}
|
|
|
|
/// Decision from `fallback_decision`: should we try the fallback source or
|
|
/// return the primary result as-is?
|
|
enum FallbackDecision {
|
|
/// Return the primary result directly (success or non-retriable error).
|
|
Return,
|
|
/// Primary failed with a retriable error and a fallback source is available.
|
|
TryFallback,
|
|
}
|
|
|
|
/// Decide whether to attempt a fallback install based on the primary result
|
|
/// and the availability of a fallback source.
|
|
fn fallback_decision(
|
|
primary_result: &Result<InstallResult, ExtensionError>,
|
|
fallback_source: &Option<Box<ExtensionSource>>,
|
|
) -> FallbackDecision {
|
|
match (primary_result, fallback_source) {
|
|
// Success — no fallback needed
|
|
(Ok(_), _) => FallbackDecision::Return,
|
|
// AlreadyInstalled — don't try building from source
|
|
(Err(ExtensionError::AlreadyInstalled(_)), _) => FallbackDecision::Return,
|
|
// Failed with a fallback available — try it
|
|
(Err(_), Some(_)) => FallbackDecision::TryFallback,
|
|
// Failed with no fallback — return the error
|
|
(Err(_), None) => FallbackDecision::Return,
|
|
}
|
|
}
|
|
|
|
/// Combine primary and fallback errors into a single error.
|
|
///
|
|
/// Preserves `AlreadyInstalled` from the fallback directly; otherwise wraps
|
|
/// both errors into the structured `ExtensionError::FallbackFailed` variant.
|
|
fn combine_install_errors(
|
|
primary_err: ExtensionError,
|
|
fallback_err: ExtensionError,
|
|
) -> ExtensionError {
|
|
if matches!(fallback_err, ExtensionError::AlreadyInstalled(_)) {
|
|
return fallback_err;
|
|
}
|
|
ExtensionError::FallbackFailed {
|
|
primary: Box::new(primary_err),
|
|
fallback: Box::new(fallback_err),
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use std::sync::Arc;
|
|
|
|
use crate::extensions::ExtensionManager;
|
|
use crate::extensions::manager::{
|
|
FallbackDecision, combine_install_errors, fallback_decision, infer_kind_from_url,
|
|
};
|
|
use crate::extensions::{ExtensionError, ExtensionKind, ExtensionSource, InstallResult};
|
|
|
|
#[test]
|
|
fn test_infer_kind_from_url() {
|
|
assert_eq!(
|
|
infer_kind_from_url("https://example.com/tool.wasm"),
|
|
ExtensionKind::WasmTool
|
|
);
|
|
assert_eq!(
|
|
infer_kind_from_url("https://example.com/tool-wasm32-wasip2.tar.gz"),
|
|
ExtensionKind::WasmTool
|
|
);
|
|
assert_eq!(
|
|
infer_kind_from_url("https://mcp.notion.com"),
|
|
ExtensionKind::McpServer
|
|
);
|
|
assert_eq!(
|
|
infer_kind_from_url("https://example.com/mcp"),
|
|
ExtensionKind::McpServer
|
|
);
|
|
}
|
|
|
|
// ---- fallback install logic tests ----
|
|
|
|
fn make_ok_result() -> Result<InstallResult, ExtensionError> {
|
|
Ok(InstallResult {
|
|
name: "test".to_string(),
|
|
kind: ExtensionKind::WasmTool,
|
|
message: "Installed".to_string(),
|
|
})
|
|
}
|
|
|
|
fn make_fallback_source() -> Option<Box<ExtensionSource>> {
|
|
Some(Box::new(ExtensionSource::WasmBuildable {
|
|
source_dir: "tools-src/test".to_string(),
|
|
build_dir: Some("tools-src/test".to_string()),
|
|
crate_name: Some("test-tool".to_string()),
|
|
}))
|
|
}
|
|
|
|
#[test]
|
|
fn test_fallback_decision_success_returns_directly() {
|
|
let result = make_ok_result();
|
|
let fallback = make_fallback_source();
|
|
assert!(matches!(
|
|
fallback_decision(&result, &fallback),
|
|
FallbackDecision::Return
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_fallback_decision_already_installed_skips_fallback() {
|
|
let result: Result<InstallResult, ExtensionError> =
|
|
Err(ExtensionError::AlreadyInstalled("test".to_string()));
|
|
let fallback = make_fallback_source();
|
|
assert!(matches!(
|
|
fallback_decision(&result, &fallback),
|
|
FallbackDecision::Return
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_fallback_decision_download_failed_triggers_fallback() {
|
|
let result: Result<InstallResult, ExtensionError> =
|
|
Err(ExtensionError::DownloadFailed("404 Not Found".to_string()));
|
|
let fallback = make_fallback_source();
|
|
assert!(matches!(
|
|
fallback_decision(&result, &fallback),
|
|
FallbackDecision::TryFallback
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_fallback_decision_error_without_fallback_returns() {
|
|
let result: Result<InstallResult, ExtensionError> =
|
|
Err(ExtensionError::DownloadFailed("404 Not Found".to_string()));
|
|
let fallback = None;
|
|
assert!(matches!(
|
|
fallback_decision(&result, &fallback),
|
|
FallbackDecision::Return
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn test_combine_errors_includes_both_messages() {
|
|
let primary = ExtensionError::DownloadFailed("404 Not Found".to_string());
|
|
let fallback = ExtensionError::InstallFailed("cargo not found".to_string());
|
|
let combined = combine_install_errors(primary, fallback);
|
|
assert!(
|
|
matches!(combined, ExtensionError::FallbackFailed { .. }),
|
|
"Expected FallbackFailed, got: {combined:?}"
|
|
);
|
|
let msg = combined.to_string();
|
|
assert!(msg.contains("404 Not Found"), "missing primary: {msg}");
|
|
assert!(msg.contains("cargo not found"), "missing fallback: {msg}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_combine_errors_forwards_already_installed_from_fallback() {
|
|
let primary = ExtensionError::DownloadFailed("404".to_string());
|
|
let fallback = ExtensionError::AlreadyInstalled("test".to_string());
|
|
let combined = combine_install_errors(primary, fallback);
|
|
assert!(
|
|
matches!(combined, ExtensionError::AlreadyInstalled(ref name) if name == "test"),
|
|
"Expected AlreadyInstalled, got: {combined:?}"
|
|
);
|
|
}
|
|
|
|
// === QA Plan P2 - 2.4: Extension registry collision tests (filesystem) ===
|
|
|
|
#[test]
|
|
fn test_tool_and_channel_paths_are_separate() {
|
|
// Verify that a WASM tool named "telegram" and a WASM channel named
|
|
// "telegram" use different filesystem paths and don't overwrite each other.
|
|
let dir = tempfile::tempdir().expect("temp dir");
|
|
let tools_dir = dir.path().join("tools");
|
|
let channels_dir = dir.path().join("channels");
|
|
std::fs::create_dir_all(&tools_dir).unwrap();
|
|
std::fs::create_dir_all(&channels_dir).unwrap();
|
|
|
|
let name = "telegram";
|
|
let tool_wasm = tools_dir.join(format!("{}.wasm", name));
|
|
let channel_wasm = channels_dir.join(format!("{}.wasm", name));
|
|
|
|
// Simulate installing both.
|
|
std::fs::write(&tool_wasm, b"tool-payload").unwrap();
|
|
std::fs::write(&channel_wasm, b"channel-payload").unwrap();
|
|
|
|
// Both files exist and contain distinct content.
|
|
assert!(tool_wasm.exists());
|
|
assert!(channel_wasm.exists());
|
|
assert_ne!(
|
|
std::fs::read(&tool_wasm).unwrap(),
|
|
std::fs::read(&channel_wasm).unwrap(),
|
|
"Tool and channel files must be independent"
|
|
);
|
|
|
|
// Removing one doesn't affect the other.
|
|
std::fs::remove_file(&tool_wasm).unwrap();
|
|
assert!(!tool_wasm.exists());
|
|
assert!(
|
|
channel_wasm.exists(),
|
|
"Removing tool must not affect channel"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_determine_kind_priority_tools_before_channels() {
|
|
// When a name exists in both tools and channels dirs,
|
|
// determine_installed_kind checks tools first (wasm_tools_dir).
|
|
// This test documents the priority order.
|
|
let dir = tempfile::tempdir().expect("temp dir");
|
|
let tools_dir = dir.path().join("tools");
|
|
let channels_dir = dir.path().join("channels");
|
|
std::fs::create_dir_all(&tools_dir).unwrap();
|
|
std::fs::create_dir_all(&channels_dir).unwrap();
|
|
|
|
let name = "ambiguous";
|
|
let tool_wasm = tools_dir.join(format!("{}.wasm", name));
|
|
let channel_wasm = channels_dir.join(format!("{}.wasm", name));
|
|
|
|
// Only channel exists → channel kind.
|
|
std::fs::write(&channel_wasm, b"channel").unwrap();
|
|
assert!(!tool_wasm.exists());
|
|
assert!(channel_wasm.exists());
|
|
|
|
// Both exist → tools dir checked first.
|
|
std::fs::write(&tool_wasm, b"tool").unwrap();
|
|
assert!(tool_wasm.exists());
|
|
assert!(channel_wasm.exists());
|
|
// This documents the determine_installed_kind priority:
|
|
// tools are checked before channels.
|
|
|
|
// Only tool exists → tool kind.
|
|
std::fs::remove_file(&channel_wasm).unwrap();
|
|
assert!(tool_wasm.exists());
|
|
assert!(!channel_wasm.exists());
|
|
}
|
|
|
|
// === WASM runtime availability tests ===
|
|
//
|
|
// Regression tests for a bug where the WASM runtime was only created at
|
|
// startup when the tools directory already existed. Extensions installed
|
|
// after startup (e.g. via the web UI) would fail with "WASM runtime not
|
|
// available" because the ExtensionManager had `wasm_tool_runtime: None`.
|
|
|
|
/// Build a minimal ExtensionManager suitable for unit tests.
|
|
fn make_test_manager(
|
|
wasm_runtime: Option<Arc<crate::tools::wasm::WasmToolRuntime>>,
|
|
tools_dir: std::path::PathBuf,
|
|
) -> crate::extensions::manager::ExtensionManager {
|
|
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
|
use crate::tools::mcp::session::McpSessionManager;
|
|
|
|
let key = secrecy::SecretString::from(crate::secrets::keychain::generate_master_key_hex());
|
|
let crypto = Arc::new(SecretsCrypto::new(key).expect("crypto"));
|
|
let secrets: Arc<dyn crate::secrets::SecretsStore + Send + Sync> =
|
|
Arc::new(InMemorySecretsStore::new(crypto));
|
|
let tools = Arc::new(crate::tools::ToolRegistry::new());
|
|
let mcp = Arc::new(McpSessionManager::new());
|
|
|
|
crate::extensions::manager::ExtensionManager::new(
|
|
mcp,
|
|
secrets,
|
|
tools,
|
|
None, // hooks
|
|
wasm_runtime,
|
|
tools_dir.clone(),
|
|
tools_dir, // channels dir (unused here)
|
|
None, // tunnel_url
|
|
"test".to_string(),
|
|
None, // db
|
|
vec![],
|
|
)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_activate_wasm_tool_with_runtime_passes_runtime_check() {
|
|
// When the ExtensionManager has a WASM runtime, activation should get
|
|
// past the "WASM runtime not available" check. It will still fail
|
|
// because no .wasm file exists on disk — but the error message should
|
|
// be "not found", NOT "WASM runtime not available".
|
|
let dir = tempfile::tempdir().expect("temp dir");
|
|
let config = crate::tools::wasm::WasmRuntimeConfig::for_testing();
|
|
let runtime = Arc::new(crate::tools::wasm::WasmToolRuntime::new(config).expect("runtime"));
|
|
let mgr = make_test_manager(Some(runtime), dir.path().to_path_buf());
|
|
|
|
let err = mgr.activate("nonexistent").await.unwrap_err();
|
|
let msg = err.to_string();
|
|
assert!(
|
|
!msg.contains("WASM runtime not available"),
|
|
"Should not fail on runtime check, got: {msg}"
|
|
);
|
|
assert!(
|
|
msg.contains("not found")
|
|
|| msg.contains("not installed")
|
|
|| msg.contains("Not installed"),
|
|
"Should fail on missing file, got: {msg}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_activate_wasm_tool_without_runtime_fails_with_runtime_error() {
|
|
// When the ExtensionManager has no WASM runtime (None), activation
|
|
// must fail with the "WASM runtime not available" message.
|
|
let dir = tempfile::tempdir().expect("temp dir");
|
|
// Write a fake .wasm file so we don't fail on "not found" first.
|
|
std::fs::write(dir.path().join("fake.wasm"), b"not-a-real-wasm").unwrap();
|
|
|
|
let mgr = make_test_manager(None, dir.path().to_path_buf());
|
|
|
|
let err = mgr.activate("fake").await.unwrap_err();
|
|
let msg = err.to_string();
|
|
assert!(
|
|
msg.contains("WASM runtime not available"),
|
|
"Expected runtime not available error, got: {msg}"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_capabilities_files_also_separate() {
|
|
// capabilities.json files for tools and channels should also be separate.
|
|
let dir = tempfile::tempdir().expect("temp dir");
|
|
let tools_dir = dir.path().join("tools");
|
|
let channels_dir = dir.path().join("channels");
|
|
std::fs::create_dir_all(&tools_dir).unwrap();
|
|
std::fs::create_dir_all(&channels_dir).unwrap();
|
|
|
|
let name = "telegram";
|
|
let tool_cap = tools_dir.join(format!("{}.capabilities.json", name));
|
|
let channel_cap = channels_dir.join(format!("{}.capabilities.json", name));
|
|
|
|
let tool_caps = r#"{"required_secrets":["TELEGRAM_API_KEY"]}"#;
|
|
let channel_caps = r#"{"required_secrets":["TELEGRAM_BOT_TOKEN"]}"#;
|
|
|
|
std::fs::write(&tool_cap, tool_caps).unwrap();
|
|
std::fs::write(&channel_cap, channel_caps).unwrap();
|
|
|
|
// Both exist with distinct content.
|
|
assert_eq!(std::fs::read_to_string(&tool_cap).unwrap(), tool_caps);
|
|
assert_eq!(std::fs::read_to_string(&channel_cap).unwrap(), channel_caps);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_upgrade_no_installed_extensions() {
|
|
let manager = make_manager_with_temp_dirs();
|
|
let result = manager.upgrade(None).await.unwrap();
|
|
assert!(result.results.is_empty());
|
|
assert!(result.message.contains("No WASM extensions installed"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_upgrade_mcp_server_rejected() {
|
|
let manager = make_manager_with_temp_dirs();
|
|
// MCP servers can't be upgraded via tool_upgrade
|
|
let err = manager.upgrade(Some("some-mcp")).await;
|
|
// It will fail with NotInstalled because there's no MCP server named "some-mcp",
|
|
// but if it were installed, the MCP code path would be rejected.
|
|
assert!(err.is_err());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_upgrade_up_to_date_extension() {
|
|
let dir = tempfile::tempdir().expect("temp dir");
|
|
let channels_dir = dir.path().join("channels");
|
|
std::fs::create_dir_all(&channels_dir).unwrap();
|
|
|
|
// Write a fake .wasm file and capabilities with current WIT version
|
|
let wasm_path = channels_dir.join("test-channel.wasm");
|
|
std::fs::write(&wasm_path, b"\0asm fake").unwrap();
|
|
|
|
let cap_path = channels_dir.join("test-channel.capabilities.json");
|
|
let caps = serde_json::json!({
|
|
"type": "channel",
|
|
"name": "test-channel",
|
|
"wit_version": crate::tools::wasm::WIT_CHANNEL_VERSION,
|
|
});
|
|
std::fs::write(&cap_path, serde_json::to_string(&caps).unwrap()).unwrap();
|
|
|
|
let manager = make_manager_custom_dirs(dir.path().join("tools"), channels_dir);
|
|
|
|
let result = manager.upgrade(Some("test-channel")).await.unwrap();
|
|
assert_eq!(result.results.len(), 1);
|
|
assert_eq!(result.results[0].status, "already_up_to_date");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_upgrade_outdated_not_in_registry() {
|
|
let dir = tempfile::tempdir().expect("temp dir");
|
|
let channels_dir = dir.path().join("channels");
|
|
std::fs::create_dir_all(&channels_dir).unwrap();
|
|
|
|
// Write a fake .wasm file and capabilities with OLD WIT version
|
|
let wasm_path = channels_dir.join("custom-channel.wasm");
|
|
std::fs::write(&wasm_path, b"\0asm fake").unwrap();
|
|
|
|
let cap_path = channels_dir.join("custom-channel.capabilities.json");
|
|
let caps = serde_json::json!({
|
|
"type": "channel",
|
|
"name": "custom-channel",
|
|
"wit_version": "0.1.0",
|
|
});
|
|
std::fs::write(&cap_path, serde_json::to_string(&caps).unwrap()).unwrap();
|
|
|
|
let manager = make_manager_custom_dirs(dir.path().join("tools"), channels_dir);
|
|
|
|
let result = manager.upgrade(Some("custom-channel")).await.unwrap();
|
|
assert_eq!(result.results.len(), 1);
|
|
assert_eq!(result.results[0].status, "not_in_registry");
|
|
}
|
|
|
|
fn make_manager_with_temp_dirs() -> ExtensionManager {
|
|
let dir = tempfile::tempdir().expect("temp dir");
|
|
make_manager_custom_dirs(dir.path().join("tools"), dir.path().join("channels"))
|
|
}
|
|
|
|
fn make_manager_custom_dirs(
|
|
tools_dir: std::path::PathBuf,
|
|
channels_dir: std::path::PathBuf,
|
|
) -> ExtensionManager {
|
|
use crate::secrets::{InMemorySecretsStore, SecretsCrypto};
|
|
use crate::tools::ToolRegistry;
|
|
use crate::tools::mcp::session::McpSessionManager;
|
|
|
|
std::fs::create_dir_all(&tools_dir).ok();
|
|
std::fs::create_dir_all(&channels_dir).ok();
|
|
|
|
let master_key =
|
|
secrecy::SecretString::from("0123456789abcdef0123456789abcdef".to_string());
|
|
let crypto = Arc::new(SecretsCrypto::new(master_key).unwrap());
|
|
|
|
ExtensionManager::new(
|
|
Arc::new(McpSessionManager::new()),
|
|
Arc::new(InMemorySecretsStore::new(crypto)),
|
|
Arc::new(ToolRegistry::new()),
|
|
None,
|
|
None,
|
|
tools_dir,
|
|
channels_dir,
|
|
None,
|
|
"test".to_string(),
|
|
None,
|
|
Vec::new(),
|
|
)
|
|
}
|
|
}
|