From 6831a5479304a9d90734ed6f36f98d80e2b4bbdc Mon Sep 17 00:00:00 2001 From: "firat.sertgoz" Date: Sat, 7 Feb 2026 10:56:01 +0400 Subject: [PATCH] Onboarding: select bundled Telegram channel and auto-install (#3) Co-authored-by: Firat Sertgoz --- src/channels/wasm/bundled.rs | 104 +++++++++++++ src/channels/wasm/mod.rs | 2 + src/secrets/types.rs | 2 + src/setup/wizard.rs | 207 +++++++++++++++++++++----- src/tools/wasm/capabilities_schema.rs | 35 +++++ src/tools/wasm/credential_injector.rs | 4 + 6 files changed, 314 insertions(+), 40 deletions(-) create mode 100644 src/channels/wasm/bundled.rs diff --git a/src/channels/wasm/bundled.rs b/src/channels/wasm/bundled.rs new file mode 100644 index 00000000..9825b72b --- /dev/null +++ b/src/channels/wasm/bundled.rs @@ -0,0 +1,104 @@ +//! Bundled WASM channels that can be installed locally. + +use std::path::Path; + +use tokio::fs; + +#[derive(Clone, Copy)] +struct BundledChannel { + name: &'static str, + wasm: &'static [u8], + capabilities: &'static [u8], +} + +/// Names of bundled channels shipped with IronClaw. +pub fn bundled_channel_names() -> &'static [&'static str] { + &["telegram"] +} + +/// Install a bundled channel into a channels directory. +pub async fn install_bundled_channel( + name: &str, + target_dir: &Path, + force: bool, +) -> Result<(), String> { + let channel = bundled_channel(name) + .ok_or_else(|| format!("Unknown bundled channel '{}'", name.to_lowercase()))?; + + fs::create_dir_all(target_dir) + .await + .map_err(|e| format!("Failed to create channels directory: {}", e))?; + + let wasm_path = target_dir.join(format!("{}.wasm", channel.name)); + let caps_path = target_dir.join(format!("{}.capabilities.json", channel.name)); + + let has_existing = wasm_path.exists() || caps_path.exists(); + if has_existing && !force { + return Err(format!( + "Channel '{}' already exists at {}", + channel.name, + target_dir.display() + )); + } + + fs::write(&wasm_path, channel.wasm) + .await + .map_err(|e| format!("Failed to write {}: {}", wasm_path.display(), e))?; + fs::write(&caps_path, channel.capabilities) + .await + .map_err(|e| format!("Failed to write {}: {}", caps_path.display(), e))?; + + Ok(()) +} + +fn bundled_channel(name: &str) -> Option { + if name.eq_ignore_ascii_case("telegram") { + Some(BundledChannel { + name: "telegram", + wasm: include_bytes!("../../../channels-src/telegram/telegram.wasm"), + capabilities: include_bytes!( + "../../../channels-src/telegram/telegram.capabilities.json" + ), + }) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use tempfile::tempdir; + use tokio::fs; + + use super::*; + + #[test] + fn test_bundled_channel_names_contains_telegram() { + assert!(bundled_channel_names().contains(&"telegram")); + } + + #[tokio::test] + async fn test_install_bundled_channel_writes_files() { + let dir = tempdir().unwrap(); + + install_bundled_channel("telegram", dir.path(), false) + .await + .unwrap(); + + assert!(dir.path().join("telegram.wasm").exists()); + assert!(dir.path().join("telegram.capabilities.json").exists()); + } + + #[tokio::test] + async fn test_install_bundled_channel_refuses_overwrite_without_force() { + let dir = tempdir().unwrap(); + let wasm_path = dir.path().join("telegram.wasm"); + fs::write(&wasm_path, b"custom").await.unwrap(); + + let result = install_bundled_channel("telegram", dir.path(), false).await; + assert!(result.is_err()); + + let existing = fs::read(&wasm_path).await.unwrap(); + assert_eq!(existing, b"custom"); + } +} diff --git a/src/channels/wasm/mod.rs b/src/channels/wasm/mod.rs index e6f4f0d8..9f7b7c37 100644 --- a/src/channels/wasm/mod.rs +++ b/src/channels/wasm/mod.rs @@ -78,6 +78,7 @@ //! } //! ``` +mod bundled; mod capabilities; mod error; mod host; @@ -88,6 +89,7 @@ mod schema; mod wrapper; // Core types +pub use bundled::{bundled_channel_names, install_bundled_channel}; pub use capabilities::{ChannelCapabilities, EmitRateLimitConfig, HttpEndpointConfig, PollConfig}; pub use error::WasmChannelError; pub use host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage}; diff --git a/src/secrets/types.rs b/src/secrets/types.rs index eb259a69..d493fe73 100644 --- a/src/secrets/types.rs +++ b/src/secrets/types.rs @@ -205,6 +205,8 @@ pub enum CredentialLocation { }, /// Inject as a query parameter QueryParam { name: String }, + /// Inject by replacing a placeholder in URL or body templates + UrlPath { placeholder: String }, } impl Default for CredentialLocation { diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 30f23d9f..666f2626 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -9,13 +9,16 @@ //! 6. Channel configuration //! 7. Heartbeat (background tasks) +use std::collections::{HashMap, HashSet}; use std::sync::Arc; use deadpool_postgres::{Config as PoolConfig, Runtime}; use secrecy::SecretString; use tokio_postgres::NoTls; -use crate::channels::wasm::ChannelCapabilitiesFile; +use crate::channels::wasm::{ + ChannelCapabilitiesFile, bundled_channel_names, install_bundled_channel, +}; use crate::llm::{SessionConfig, SessionManager}; use crate::secrets::SecretsCrypto; use crate::settings::{KeySource, Settings}; @@ -535,6 +538,8 @@ impl SetupWizard { .ok_or_else(|| SetupError::Config("Database URL not configured".to_string()))?; self.test_database_connection(&url).await?; + // Ensure secrets-related tables exist for channels-only onboarding flows. + self.run_migrations().await?; self.db_pool.clone().unwrap() }; @@ -583,7 +588,12 @@ impl SetupWizard { .unwrap_or_default() .join(".ironclaw/channels"); - let discovered_channels = discover_wasm_channels(&channels_dir).await; + let mut discovered_channels = discover_wasm_channels(&channels_dir).await; + let installed_names: HashSet = discovered_channels + .iter() + .map(|(name, _)| name.clone()) + .collect(); + let wasm_channel_names = wasm_channel_option_names(&discovered_channels); // Build options list dynamically let mut options: Vec<(String, bool)> = vec![ @@ -594,8 +604,8 @@ impl SetupWizard { ), ]; - // Add discovered WASM channels - for (name, _) in &discovered_channels { + // Add available WASM channels (installed + bundled) + for name in &wasm_channel_names { let is_enabled = self.settings.channels.wasm_channels.contains(name); let display_name = format!("{} (WASM)", capitalize_first(name)); options.push((display_name, is_enabled)); @@ -607,8 +617,33 @@ impl SetupWizard { let selected = select_many("Which channels do you want to enable?", &options_refs) .map_err(SetupError::Io)?; + let selected_wasm_channels: Vec = wasm_channel_names + .iter() + .enumerate() + .filter_map(|(idx, name)| { + if selected.contains(&(idx + 2)) { + Some(name.clone()) + } else { + None + } + }) + .collect(); + + if let Some(installed) = install_selected_bundled_channels( + &channels_dir, + &selected_wasm_channels, + &installed_names, + ) + .await? + { + if !installed.is_empty() { + print_success(&format!("Installed channels: {}", installed.join(", "))); + discovered_channels = discover_wasm_channels(&channels_dir).await; + } + } + // Determine if we need secrets context - let needs_secrets = selected.iter().any(|&i| i >= 1); + let needs_secrets = selected.contains(&1) || !selected_wasm_channels.is_empty(); let secrets = if needs_secrets { match self.init_secrets_context().await { Ok(ctx) => Some(ctx), @@ -638,53 +673,53 @@ impl SetupWizard { self.settings.channels.http_enabled = false; } - // Process WASM channels (index 2 and above) - let mut enabled_wasm_channels = Vec::new(); - for (idx, (channel_name, cap_file)) in discovered_channels.iter().enumerate() { - let option_idx = idx + 2; // Offset for CLI and HTTP + let discovered_by_name: HashMap = + discovered_channels.into_iter().collect(); - if selected.contains(&option_idx) { - println!(); - if let Some(ref ctx) = secrets { - // Use setup schema from capabilities if available - let result = if !cap_file.setup.required_secrets.is_empty() { - setup_wasm_channel(ctx, channel_name, &cap_file.setup) + // Process selected WASM channels + let mut enabled_wasm_channels = Vec::new(); + for channel_name in selected_wasm_channels { + println!(); + if let Some(ref ctx) = secrets { + let result = if let Some(cap_file) = discovered_by_name.get(&channel_name) { + if !cap_file.setup.required_secrets.is_empty() { + setup_wasm_channel(ctx, &channel_name, &cap_file.setup) .await .map_err(SetupError::Channel)? + } else if channel_name == "telegram" { + let telegram_result = setup_telegram(ctx).await.map_err(SetupError::Channel)?; + crate::setup::channels::WasmChannelSetupResult { + enabled: telegram_result.enabled, + channel_name: "telegram".to_string(), + } } else { - // Fall back to legacy Telegram setup for backwards compatibility - if channel_name == "telegram" { - let telegram_result = - setup_telegram(ctx).await.map_err(SetupError::Channel)?; - crate::setup::channels::WasmChannelSetupResult { - enabled: telegram_result.enabled, - channel_name: "telegram".to_string(), - } - } else { - print_info(&format!( - "No setup configuration found for {}", - channel_name - )); - crate::setup::channels::WasmChannelSetupResult { - enabled: true, - channel_name: channel_name.to_string(), - } + print_info(&format!("No setup configuration found for {}", channel_name)); + crate::setup::channels::WasmChannelSetupResult { + enabled: true, + channel_name: channel_name.clone(), } - }; - - if result.enabled { - enabled_wasm_channels.push(result.channel_name); } } else { - // No secrets context, just enable the channel print_info(&format!( - "{} enabled (configure tokens via environment)", - capitalize_first(channel_name) + "Channel '{}' is selected but not available on disk.", + channel_name )); - enabled_wasm_channels.push(channel_name.clone()); + continue; + }; + + if result.enabled { + enabled_wasm_channels.push(result.channel_name); } + } else { + // No secrets context, just enable the channel + print_info(&format!( + "{} enabled (configure tokens via environment)", + capitalize_first(&channel_name) + )); + enabled_wasm_channels.push(channel_name.clone()); } } + self.settings.channels.wasm_channels = enabled_wasm_channels; Ok(()) @@ -932,8 +967,73 @@ fn capitalize_first(s: &str) -> String { } } +#[cfg(test)] +async fn install_missing_bundled_channels( + channels_dir: &std::path::Path, + already_installed: &HashSet, +) -> Result, SetupError> { + let mut installed = Vec::new(); + + for name in bundled_channel_names().iter().copied() { + if already_installed.contains(name) { + continue; + } + + install_bundled_channel(name, channels_dir, false) + .await + .map_err(SetupError::Channel)?; + installed.push(name.to_string()); + } + + Ok(installed) +} + +fn wasm_channel_option_names(discovered: &[(String, ChannelCapabilitiesFile)]) -> Vec { + let mut names: Vec = discovered.iter().map(|(name, _)| name.clone()).collect(); + + for bundled in bundled_channel_names().iter().copied() { + if !names.iter().any(|name| name == bundled) { + names.push(bundled.to_string()); + } + } + + names +} + +async fn install_selected_bundled_channels( + channels_dir: &std::path::Path, + selected_channels: &[String], + already_installed: &HashSet, +) -> Result>, SetupError> { + let bundled: HashSet<&str> = bundled_channel_names().iter().copied().collect(); + let selected_missing: HashSet = selected_channels + .iter() + .filter(|name| bundled.contains(name.as_str()) && !already_installed.contains(*name)) + .cloned() + .collect(); + + if selected_missing.is_empty() { + return Ok(None); + } + + let mut installed = Vec::new(); + for name in selected_missing { + install_bundled_channel(&name, channels_dir, false) + .await + .map_err(SetupError::Channel)?; + installed.push(name); + } + + installed.sort(); + Ok(Some(installed)) +} + #[cfg(test)] mod tests { + use std::collections::HashSet; + + use tempfile::tempdir; + use super::*; #[test] @@ -973,4 +1073,31 @@ mod tests { assert_eq!(capitalize_first("CAPS"), "CAPS"); assert_eq!(capitalize_first(""), ""); } + + #[tokio::test] + async fn test_install_missing_bundled_channels_installs_telegram() { + let dir = tempdir().unwrap(); + let installed = HashSet::::new(); + + install_missing_bundled_channels(dir.path(), &installed) + .await + .unwrap(); + + assert!(dir.path().join("telegram.wasm").exists()); + assert!(dir.path().join("telegram.capabilities.json").exists()); + } + + #[test] + fn test_wasm_channel_option_names_includes_bundled_when_missing() { + let discovered = Vec::new(); + let options = wasm_channel_option_names(&discovered); + assert_eq!(options, vec!["telegram".to_string()]); + } + + #[test] + fn test_wasm_channel_option_names_dedupes_bundled() { + let discovered = vec![(String::from("telegram"), ChannelCapabilitiesFile::default())]; + let options = wasm_channel_option_names(&discovered); + assert_eq!(options, vec!["telegram".to_string()]); + } } diff --git a/src/tools/wasm/capabilities_schema.rs b/src/tools/wasm/capabilities_schema.rs index e2f404d9..fe201c34 100644 --- a/src/tools/wasm/capabilities_schema.rs +++ b/src/tools/wasm/capabilities_schema.rs @@ -241,6 +241,9 @@ pub enum CredentialLocationSchema { /// Query parameter. QueryParam { name: String }, + + /// URL/path placeholder replacement. + UrlPath { placeholder: String }, } impl CredentialLocationSchema { @@ -259,6 +262,9 @@ impl CredentialLocationSchema { CredentialLocationSchema::QueryParam { name } => { CredentialLocation::QueryParam { name: name.clone() } } + CredentialLocationSchema::UrlPath { placeholder } => CredentialLocation::UrlPath { + placeholder: placeholder.clone(), + }, } } } @@ -565,6 +571,35 @@ mod tests { } } + #[test] + fn test_parse_url_path_credential() { + let json = r#"{ + "http": { + "allowlist": [{ "host": "api.telegram.org" }], + "credentials": { + "telegram_bot": { + "secret_name": "telegram_bot_token", + "location": { + "type": "url_path", + "placeholder": "{TELEGRAM_BOT_TOKEN}" + }, + "host_patterns": ["api.telegram.org"] + } + } + } + }"#; + + let caps = CapabilitiesFile::from_json(json).unwrap(); + let http = caps.http.unwrap(); + let cred = http.credentials.get("telegram_bot").unwrap(); + match &cred.location { + CredentialLocationSchema::UrlPath { placeholder } => { + assert_eq!(placeholder, "{TELEGRAM_BOT_TOKEN}"); + } + _ => panic!("Expected UrlPath location"), + } + } + #[test] fn test_parse_secrets_capability() { let json = r#"{ diff --git a/src/tools/wasm/credential_injector.rs b/src/tools/wasm/credential_injector.rs index 32327e3d..0d878bbb 100644 --- a/src/tools/wasm/credential_injector.rs +++ b/src/tools/wasm/credential_injector.rs @@ -200,6 +200,10 @@ fn inject_credential( .query_params .insert(name.clone(), secret.expose().to_string()); } + CredentialLocation::UrlPath { .. } => { + // URL placeholder replacement is handled by channel/tool wrappers + // that substitute {PLACEHOLDER} values in templated strings. + } } }