From 7955c9742e7cf10c01a69f386ea4bd36af78d599 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Wed, 4 Feb 2026 21:19:42 -0800 Subject: [PATCH] Add Telegram webhook support with credential injection Enable instant message delivery for Telegram via webhooks instead of polling. Key changes: - Add tunnel URL configuration for local development (ngrok, cloudflare) - Auto-register webhook with Telegram API on startup using setWebhook - Implement webhook secret validation via X-Telegram-Bot-Api-Secret-Token header - Add credential injection for bot token via URL placeholder substitution - Fix metadata preservation in respond() to route replies correctly - Fix serde flatten with Option issue in capabilities schema parsing The credential injection pattern replaces {TELEGRAM_BOT_TOKEN} placeholders in URLs with the actual token from the secrets store, keeping credentials out of WASM module memory until the HTTP request is made. Co-Authored-By: Claude Opus 4.5 --- Cargo.lock | 293 ++++++- Cargo.toml | 1 + channels-src/telegram/src/lib.rs | 169 +++- channels-src/telegram/telegram.wasm | Bin 190063 -> 220467 bytes src/channels/wasm/error.rs | 6 + src/channels/wasm/loader.rs | 21 + src/channels/wasm/mod.rs | 2 +- src/channels/wasm/router.rs | 77 +- src/channels/wasm/schema.rs | 10 +- src/channels/wasm/wrapper.rs | 1217 +++++++++++++++++---------- src/config.rs | 103 +++ src/main.rs | 221 ++++- src/settings.rs | 14 + src/setup/channels.rs | 106 ++- src/setup/mod.rs | 4 +- src/setup/wizard.rs | 27 +- src/tools/wasm/limits.rs | 7 +- 17 files changed, 1769 insertions(+), 509 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8c96241c..864042d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -84,6 +84,12 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ambient-authority" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9d4ee0d472d1cd2e28c97dfa124b3d8d992e10eb0a035f33f5d12e3a177ba3b" + [[package]] name = "android_system_properties" version = "0.1.5" @@ -426,6 +432,84 @@ version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3" +[[package]] +name = "cap-fs-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5528f85b1e134ae811704e41ef80930f56e795923f866813255bc342cc20654" +dependencies = [ + "cap-primitives", + "cap-std", + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "cap-net-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20a158160765c6a7d0d8c072a53d772e4cb243f38b04bfcf6b4939cfbe7482e7" +dependencies = [ + "cap-primitives", + "cap-std", + "rustix 1.1.3", + "smallvec", +] + +[[package]] +name = "cap-primitives" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cf3aea8a5081171859ef57bc1606b1df6999df4f1110f8eef68b30098d1d3a" +dependencies = [ + "ambient-authority", + "fs-set-times", + "io-extras", + "io-lifetimes", + "ipnet", + "maybe-owned", + "rustix 1.1.3", + "rustix-linux-procfs", + "windows-sys 0.59.0", + "winx", +] + +[[package]] +name = "cap-rand" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8144c22e24bbcf26ade86cb6501a0916c46b7e4787abdb0045a467eb1645a1d" +dependencies = [ + "ambient-authority", + "rand 0.8.5", +] + +[[package]] +name = "cap-std" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6dc3090992a735d23219de5c204927163d922f42f575a0189b005c62d37549a" +dependencies = [ + "cap-primitives", + "io-extras", + "io-lifetimes", + "rustix 1.1.3", +] + +[[package]] +name = "cap-time-ext" +version = "3.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "def102506ce40c11710a9b16e614af0cde8e76ae51b1f48c04b8d79f4b671a80" +dependencies = [ + "ambient-authority", + "cap-primitives", + "iana-time-zone", + "once_cell", + "rustix 1.1.3", + "winx", +] + [[package]] name = "cassowary" version = "0.3.0" @@ -936,13 +1020,33 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users 0.4.6", + "winapi", ] [[package]] @@ -1074,6 +1178,17 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" +[[package]] +name = "fd-lock" +version = "4.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ce92ff622d6dadf7349484f42c93271a0d49b7cc4d466a936405bacbe10aa78" +dependencies = [ + "cfg-if", + "rustix 1.1.3", + "windows-sys 0.59.0", +] + [[package]] name = "filetime" version = "0.2.27" @@ -1112,6 +1227,17 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs-set-times" +version = "0.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e7099f6313ecacbe1256e8ff9d617b75d1bcb16a6fddef94866d225a01a14a" +dependencies = [ + "io-lifetimes", + "rustix 1.1.3", + "windows-sys 0.59.0", +] + [[package]] name = "funty" version = "2.0.0" @@ -1698,6 +1824,22 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "io-extras" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2285ddfe3054097ef4b2fe909ef8c3bcd1ea52a8f0d274416caebeef39f04a65" +dependencies = [ + "io-lifetimes", + "windows-sys 0.59.0", +] + +[[package]] +name = "io-lifetimes" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06432fb54d3be7964ecd3649233cddf80db2832f47fec34c01f65b3d9d774983" + [[package]] name = "ipnet" version = "2.11.0" @@ -1916,6 +2058,12 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" +[[package]] +name = "maybe-owned" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4facc753ae494aeb6e3c22f839b158aebd4f9270f55cd3c79906c45476c47ab4" + [[package]] name = "md-5" version = "0.10.6" @@ -1973,7 +2121,7 @@ dependencies = [ "clap", "crossterm", "deadpool-postgres", - "dirs", + "dirs 6.0.0", "dotenvy", "futures", "hkdf", @@ -2007,6 +2155,7 @@ dependencies = [ "uuid", "wasmparser 0.220.1", "wasmtime", + "wasmtime-wasi", ] [[package]] @@ -2856,6 +3005,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustix-linux-procfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc84bf7e9aa16c4f2c758f27412dc9841341e16aa682d9c7ac308fe3ee12056" +dependencies = [ + "once_cell", + "rustix 1.1.3", +] + [[package]] name = "rustls" version = "0.23.36" @@ -3158,6 +3317,15 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shellexpand" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ccc8076840c4da029af4f87e4e8daeb0fca6b87bbb02e10cb60b791450e11e4" +dependencies = [ + "dirs 4.0.0", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3360,6 +3528,22 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "system-interface" +version = "0.27.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4592f674ce18521c2a81483873a49596655b179f71c5e05d10c1fe66c78745" +dependencies = [ + "bitflags 2.10.0", + "cap-fs-ext", + "cap-std", + "fd-lock", + "io-lifetimes", + "rustix 0.38.44", + "windows-sys 0.59.0", + "winx", +] + [[package]] name = "tap" version = "1.0.1" @@ -4383,6 +4567,36 @@ dependencies = [ "syn 2.0.114", ] +[[package]] +name = "wasmtime-wasi" +version = "28.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a8e04b9a4c68ad018b330a4f4914b82b01dc3582d715ce21a93564c7f26b19f" +dependencies = [ + "anyhow", + "async-trait", + "bitflags 2.10.0", + "bytes", + "cap-fs-ext", + "cap-net-ext", + "cap-rand", + "cap-std", + "cap-time-ext", + "fs-set-times", + "futures", + "io-extras", + "io-lifetimes", + "rustix 0.38.44", + "system-interface", + "thiserror 1.0.69", + "tokio", + "tracing", + "url", + "wasmtime", + "wiggle", + "windows-sys 0.59.0", +] + [[package]] name = "wasmtime-winch" version = "28.0.1" @@ -4412,6 +4626,15 @@ dependencies = [ "wit-parser", ] +[[package]] +name = "wast" +version = "35.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ef140f1b49946586078353a453a1d28ba90adfc54dde75710bc1931de204d68" +dependencies = [ + "leb128", +] + [[package]] name = "wast" version = "244.0.0" @@ -4431,7 +4654,7 @@ version = "1.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbf35b87ed352f9ab6cd0732abde5a67dd6153dfd02c493e61459218b19456fa" dependencies = [ - "wast", + "wast 244.0.0", ] [[package]] @@ -4474,6 +4697,48 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wiggle" +version = "28.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b23e3dc273d1e35cab9f38a5f76487aeeedcfa6a3fb594e209ee7b6f8b41dcc" +dependencies = [ + "anyhow", + "async-trait", + "bitflags 2.10.0", + "thiserror 1.0.69", + "tracing", + "wasmtime", + "wiggle-macro", +] + +[[package]] +name = "wiggle-generate" +version = "28.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8738c5a7ef3a9de0fae10f8b84091a2aa4e059d8fef23de202ab689812b6bc6e" +dependencies = [ + "anyhow", + "heck", + "proc-macro2", + "quote", + "shellexpand", + "syn 2.0.114", + "witx", +] + +[[package]] +name = "wiggle-macro" +version = "28.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e882267ac583e013a38a5aaeb83a49b219456ba3aa6e6772440f7213b176e8ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.114", + "wiggle-generate", +] + [[package]] name = "winapi" version = "0.3.9" @@ -4821,6 +5086,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winx" +version = "0.36.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f3fd376f71958b862e7afb20cfe5a22830e1963462f3a17f49d82a6c1d1f42d" +dependencies = [ + "bitflags 2.10.0", + "windows-sys 0.59.0", +] + [[package]] name = "wit-bindgen" version = "0.46.0" @@ -4845,6 +5120,18 @@ dependencies = [ "wasmparser 0.221.3", ] +[[package]] +name = "witx" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e366f27a5cabcddb2706a78296a40b8fcc451e1a6aba2fc1d94b4a01bdaaef4b" +dependencies = [ + "anyhow", + "log", + "thiserror 1.0.69", + "wast 35.0.2", +] + [[package]] name = "writeable" version = "0.6.2" diff --git a/Cargo.toml b/Cargo.toml index 200bac58..8b05502f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -79,6 +79,7 @@ pgvector = { version = "0.4", features = ["postgres"] } # WASM sandbox for untrusted tool execution wasmtime = { version = "28", features = ["component-model"] } +wasmtime-wasi = "28" # WASI support for component model wasmparser = "0.220" # WASM binary parsing for validation # Cryptography for secrets management diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs index e4d97f88..013f37e7 100644 --- a/channels-src/telegram/src/lib.rs +++ b/channels-src/telegram/src/lib.rs @@ -154,6 +154,9 @@ struct SentMessage { message_id: i64, } +/// Workspace path for storing polling state. +const POLLING_STATE_PATH: &str = "state/last_update_id"; + // ============================================================================ // Channel Metadata // ============================================================================ @@ -186,12 +189,23 @@ struct TelegramConfig { respond_to_all_group_messages: bool, /// Whether to use polling instead of webhooks. + /// Automatically disabled if tunnel_url is set. #[serde(default)] polling_enabled: bool, /// Polling interval in milliseconds (if polling enabled). #[serde(default = "default_poll_interval")] poll_interval_ms: u32, + + /// Public tunnel URL for webhook mode (e.g., "https://abc123.ngrok.io"). + /// When set, webhook mode is enabled and polling is disabled. + #[serde(default)] + tunnel_url: Option, + + /// Secret token for webhook validation. + /// Telegram will include this in the X-Telegram-Bot-Api-Secret-Token header. + #[serde(default)] + webhook_secret: Option, } fn default_poll_interval() -> u32 { @@ -218,8 +232,29 @@ impl Guest for TelegramChannel { ); } - // Configure polling if enabled - let poll = if config.polling_enabled { + // Determine mode: webhook or polling + // Webhook mode is enabled if tunnel_url is set, which disables polling + let webhook_mode = config.tunnel_url.is_some(); + + if webhook_mode { + channel_host::log( + channel_host::LogLevel::Info, + "Webhook mode enabled (polling disabled)", + ); + if let Some(ref url) = config.tunnel_url { + channel_host::log( + channel_host::LogLevel::Info, + &format!("Tunnel URL: {}", url), + ); + } + } + + // Configure polling only if not in webhook mode and polling is enabled + let poll = if !webhook_mode && config.polling_enabled { + channel_host::log( + channel_host::LogLevel::Info, + &format!("Polling enabled (interval: {}ms)", config.poll_interval_ms.max(30000)), + ); Some(PollConfig { interval_ms: config.poll_interval_ms.max(30000), // Enforce minimum enabled: true, @@ -228,18 +263,37 @@ impl Guest for TelegramChannel { None }; + // Webhook secret validation is handled by the host (X-Telegram-Bot-Api-Secret-Token header) + // The require_secret flag tells the host to validate the secret_validated field + let require_secret = config.webhook_secret.is_some(); + Ok(ChannelConfig { display_name: "Telegram".to_string(), http_endpoints: vec![HttpEndpointConfig { path: "/webhook/telegram".to_string(), methods: vec!["POST".to_string()], - require_secret: false, // Telegram doesn't use signing secrets by default + require_secret, }], poll, }) } fn on_http_request(req: IncomingHttpRequest) -> OutgoingHttpResponse { + // Check if webhook secret validation passed (if required) + // The host validates X-Telegram-Bot-Api-Secret-Token header and sets secret_validated + // If require_secret was true in config but validation failed, secret_validated will be false + if !req.secret_validated { + // This means require_secret was set but the secret didn't match + // We still check the field even though the host should have already rejected invalid requests + // This is defense in depth + channel_host::log( + channel_host::LogLevel::Warn, + "Webhook request with invalid or missing secret token", + ); + // Return 401 but Telegram will keep retrying, so this is just for logging + // In practice, the host should reject these before they reach us + } + // Parse the request body as UTF-8 let body_str = match std::str::from_utf8(&req.body) { Ok(s) => s, @@ -269,12 +323,98 @@ impl Guest for TelegramChannel { } fn on_poll() { - // Polling mode: call getUpdates API - // For now, we focus on webhook mode. Polling can be added later. + // Read last offset from workspace storage + let offset = match channel_host::workspace_read(POLLING_STATE_PATH) { + Some(s) => s.parse::().unwrap_or(0), + None => 0, + }; + channel_host::log( channel_host::LogLevel::Debug, - "Polling tick (not implemented yet)", + &format!("Polling getUpdates with offset {}", offset), ); + + // Build getUpdates URL with parameters + // - offset: Identifier of the first update to be returned + // - timeout: Long polling timeout in seconds (Telegram recommends 30+) + // - allowed_updates: Only get message updates + let url = format!( + "https://api.telegram.org/bot{{TELEGRAM_BOT_TOKEN}}/getUpdates?offset={}&timeout=30&allowed_updates=[\"message\",\"edited_message\"]", + offset + ); + + let headers = serde_json::json!({}); + + let result = channel_host::http_request("GET", &url, &headers.to_string(), None); + + match result { + Ok(response) => { + if response.status != 200 { + let body_str = String::from_utf8_lossy(&response.body); + channel_host::log( + channel_host::LogLevel::Error, + &format!("getUpdates returned {}: {}", response.status, body_str), + ); + return; + } + + // Parse response + let api_response: Result>, _> = + serde_json::from_slice(&response.body); + + match api_response { + Ok(resp) if resp.ok => { + if let Some(updates) = resp.result { + let mut new_offset = offset; + + for update in updates { + // Track highest update_id for next poll + if update.update_id >= new_offset { + new_offset = update.update_id + 1; + } + + // Process the update (emits messages) + handle_update(update); + } + + // Save new offset if it changed + if new_offset != offset { + if let Err(e) = channel_host::workspace_write( + POLLING_STATE_PATH, + &new_offset.to_string(), + ) { + channel_host::log( + channel_host::LogLevel::Error, + &format!("Failed to save polling offset: {}", e), + ); + } + } + } + } + Ok(resp) => { + channel_host::log( + channel_host::LogLevel::Error, + &format!( + "Telegram API error: {}", + resp.description.unwrap_or_else(|| "unknown".to_string()) + ), + ); + } + Err(e) => { + channel_host::log( + channel_host::LogLevel::Error, + &format!("Failed to parse getUpdates response: {}", e), + ); + } + } + } + Err(e) => { + channel_host::log( + channel_host::LogLevel::Error, + &format!("getUpdates request failed: {}", e), + ); + } + } } fn on_respond(response: AgentResponse) -> Result<(), String> { @@ -320,14 +460,15 @@ impl Guest for TelegramChannel { // Parse Telegram response let api_response: TelegramApiResponse = - serde_json::from_slice(&http_response.body).map_err(|e| { - format!("Failed to parse Telegram response: {}", e) - })?; + serde_json::from_slice(&http_response.body) + .map_err(|e| format!("Failed to parse Telegram response: {}", e))?; if !api_response.ok { return Err(format!( "Telegram API error: {}", - api_response.description.unwrap_or_else(|| "unknown".to_string()) + api_response + .description + .unwrap_or_else(|| "unknown".to_string()) )); } @@ -347,7 +488,10 @@ impl Guest for TelegramChannel { } fn on_shutdown() { - channel_host::log(channel_host::LogLevel::Info, "Telegram channel shutting down"); + channel_host::log( + channel_host::LogLevel::Info, + "Telegram channel shutting down", + ); } } @@ -422,8 +566,7 @@ fn handle_message(message: TelegramMessage) { is_private, }; - let metadata_json = - serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string()); + let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string()); // Clean the message text (strip bot mentions and commands) let cleaned_text = clean_message_text(&text); diff --git a/channels-src/telegram/telegram.wasm b/channels-src/telegram/telegram.wasm index be4c20b97ec92c5bdaff097f37e3bb5f361341f4..14791860fd464018c7e2d9ec1bcb00c9dca95d1a 100644 GIT binary patch delta 74916 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 delta 48425 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 for WasmChannelError { diff --git a/src/channels/wasm/loader.rs b/src/channels/wasm/loader.rs index 2d91e288..fc2df29d 100644 --- a/src/channels/wasm/loader.rs +++ b/src/channels/wasm/loader.rs @@ -59,7 +59,28 @@ impl WasmChannelLoader { let cap_file = ChannelCapabilitiesFile::from_bytes(&cap_bytes) .map_err(|e| WasmChannelError::InvalidCapabilities(e.to_string()))?; + // Debug: log raw capabilities + tracing::debug!( + channel = name, + raw_capabilities = ?cap_file.capabilities, + "Parsed capabilities file" + ); + let caps = cap_file.to_capabilities(); + + // Debug: log resulting capabilities + tracing::info!( + channel = name, + http_allowed = caps.tool_capabilities.http.is_some(), + http_allowlist_count = caps + .tool_capabilities + .http + .as_ref() + .map(|h| h.allowlist.len()) + .unwrap_or(0), + "Channel capabilities loaded" + ); + let config = cap_file.config_json(); let desc = cap_file.description.clone(); diff --git a/src/channels/wasm/mod.rs b/src/channels/wasm/mod.rs index 757969f9..55fafafd 100644 --- a/src/channels/wasm/mod.rs +++ b/src/channels/wasm/mod.rs @@ -99,4 +99,4 @@ pub use router::{ }; pub use runtime::{PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig}; pub use schema::{ChannelCapabilitiesFile, ChannelConfig}; -pub use wrapper::{HttpResponse, WasmChannel}; +pub use wrapper::{HttpResponse, SharedWasmChannel, WasmChannel}; diff --git a/src/channels/wasm/router.rs b/src/channels/wasm/router.rs index e7d66f6a..609ea9e9 100644 --- a/src/channels/wasm/router.rs +++ b/src/channels/wasm/router.rs @@ -180,7 +180,6 @@ async fn health_handler(State(state): State) -> impl IntoResponse { } /// Generic webhook handler that routes to the appropriate WASM channel. -#[allow(dead_code)] async fn webhook_handler( State(state): State, method: Method, @@ -191,10 +190,21 @@ async fn webhook_handler( ) -> impl IntoResponse { let full_path = format!("/webhook/{}", path); + tracing::info!( + method = %method, + path = %full_path, + body_len = body.len(), + "Webhook request received" + ); + // Find the channel for this path let channel = match state.router.get_channel_for_path(&full_path).await { Some(c) => c, None => { + tracing::warn!( + path = %full_path, + "No channel registered for webhook path" + ); return ( StatusCode::NOT_FOUND, Json(serde_json::json!({ @@ -205,21 +215,48 @@ async fn webhook_handler( } }; + tracing::info!( + channel = %channel.channel_name(), + "Found channel for webhook" + ); + let channel_name = channel.channel_name(); // Check if secret is required if state.router.requires_secret(channel_name).await { // Try to get secret from query param or header - let provided_secret = query.get("secret").cloned().or_else(|| { - headers - .get("X-Webhook-Secret") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()) - }); + // Telegram uses X-Telegram-Bot-Api-Secret-Token header + let provided_secret = query + .get("secret") + .cloned() + .or_else(|| { + headers + .get("X-Telegram-Bot-Api-Secret-Token") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + }) + .or_else(|| { + // Fallback to generic header + headers + .get("X-Webhook-Secret") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + }); + + tracing::debug!( + channel = %channel_name, + has_provided_secret = provided_secret.is_some(), + provided_secret_len = provided_secret.as_ref().map(|s| s.len()), + "Checking webhook secret" + ); match provided_secret { Some(secret) => { if !state.router.validate_secret(channel_name, &secret).await { + tracing::warn!( + channel = %channel_name, + "Webhook secret validation failed" + ); return ( StatusCode::UNAUTHORIZED, Json(serde_json::json!({ @@ -227,8 +264,13 @@ async fn webhook_handler( })), ); } + tracing::debug!(channel = %channel_name, "Webhook secret validated"); } None => { + tracing::warn!( + channel = %channel_name, + "Webhook secret required but not provided" + ); return ( StatusCode::UNAUTHORIZED, Json(serde_json::json!({ @@ -251,6 +293,13 @@ async fn webhook_handler( // Call the WASM channel let secret_validated = state.router.requires_secret(channel_name).await; + + tracing::info!( + channel = %channel_name, + secret_validated = secret_validated, + "Calling WASM channel on_http_request" + ); + match channel .call_on_http_request( method.as_str(), @@ -266,6 +315,13 @@ async fn webhook_handler( let status = StatusCode::from_u16(response.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + tracing::info!( + channel = %channel_name, + status = %status, + body_len = response.body.len(), + "WASM channel on_http_request completed successfully" + ); + // Build response with headers let body_json: serde_json::Value = serde_json::from_slice(&response.body) .unwrap_or_else(|_| { @@ -296,25 +352,22 @@ async fn webhook_handler( /// Create an Axum router for WASM channel webhooks. /// /// This router can be merged with the existing HTTP channel router. -#[allow(dead_code)] pub fn create_wasm_channel_router(router: Arc) -> Router { let state = RouterState::new(router); Router::new() .route("/wasm-channels/health", get(health_handler)) // Catch-all for webhook paths - .route("/webhook/*path", get(webhook_handler)) - .route("/webhook/*path", post(webhook_handler)) + .route("/webhook/{*path}", get(webhook_handler)) + .route("/webhook/{*path}", post(webhook_handler)) .with_state(state) } /// HTTP server for WASM channel webhooks. -#[allow(dead_code)] pub struct WasmChannelServer { router: Arc, } -#[allow(dead_code)] impl WasmChannelServer { /// Create a new server. pub fn new(router: Arc) -> Self { diff --git a/src/channels/wasm/schema.rs b/src/channels/wasm/schema.rs index 228c0214..06876822 100644 --- a/src/channels/wasm/schema.rs +++ b/src/channels/wasm/schema.rs @@ -101,8 +101,10 @@ impl ChannelCapabilitiesFile { #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct ChannelCapabilitiesSchema { /// Tool capabilities (HTTP, secrets, workspace_read). + /// Note: Using the struct directly (not Option) because #[serde(flatten)] + /// with Option doesn't work correctly when T has all-optional fields. #[serde(flatten)] - pub tool: Option, + pub tool: ToolCapabilitiesFile, /// Channel-specific capabilities. #[serde(default)] @@ -112,11 +114,7 @@ pub struct ChannelCapabilitiesSchema { impl ChannelCapabilitiesSchema { /// Convert to runtime ChannelCapabilities. pub fn to_channel_capabilities(&self, channel_name: &str) -> ChannelCapabilities { - let tool_caps = self - .tool - .as_ref() - .map(|t| t.to_capabilities()) - .unwrap_or_default(); + let tool_caps = self.tool.to_capabilities(); let mut caps = ChannelCapabilities::for_channel(channel_name).with_tool_capabilities(tool_caps); diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index 1996b7af..2bb1db40 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -37,7 +37,8 @@ use tokio::sync::{RwLock, mpsc, oneshot}; use tokio_stream::wrappers::ReceiverStream; use uuid::Uuid; use wasmtime::Store; -use wasmtime::component::{Component, Linker, Val}; +use wasmtime::component::{Component, Linker}; +use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiView}; use crate::channels::wasm::capabilities::ChannelCapabilities; use crate::channels::wasm::error::WasmChannelError; @@ -50,19 +51,285 @@ use crate::error::ChannelError; use crate::tools::wasm::LogLevel; use crate::tools::wasm::WasmResourceLimiter; +// Generate component model bindings from the WIT file +wasmtime::component::bindgen!({ + path: "wit/channel.wit", + world: "sandboxed-channel", + async: false, + with: { + // Use our own store data type + }, +}); + /// Store data for WASM channel execution. /// -/// Contains the resource limiter and channel-specific host state. +/// Contains the resource limiter, channel-specific host state, and WASI context. struct ChannelStoreData { limiter: WasmResourceLimiter, host_state: ChannelHostState, + wasi: WasiCtx, + table: ResourceTable, + /// Injected credentials for URL substitution (e.g., bot tokens). + /// Keys are placeholder names like "TELEGRAM_BOT_TOKEN". + credentials: HashMap, } impl ChannelStoreData { - fn new(memory_limit: u64, channel_name: &str, capabilities: ChannelCapabilities) -> Self { + fn new( + memory_limit: u64, + channel_name: &str, + capabilities: ChannelCapabilities, + credentials: HashMap, + ) -> Self { + // Create a minimal WASI context (no filesystem, no env vars for security) + let wasi = WasiCtxBuilder::new().build(); + Self { limiter: WasmResourceLimiter::new(memory_limit), host_state: ChannelHostState::new(channel_name, capabilities), + wasi, + table: ResourceTable::new(), + credentials, + } + } + + /// Inject credentials into a URL by replacing placeholders. + /// + /// Replaces patterns like `{TELEGRAM_BOT_TOKEN}` with actual values from + /// the injected credentials map. This allows WASM channels to reference + /// credentials without ever seeing the actual values. + fn inject_credentials_into_url(&self, url: &str) -> String { + let mut result = url.to_string(); + + tracing::debug!( + url = %url, + credential_count = self.credentials.len(), + credential_names = ?self.credentials.keys().collect::>(), + "Injecting credentials into URL" + ); + + // Replace all known placeholders from the credentials map + for (name, value) in &self.credentials { + let placeholder = format!("{{{}}}", name); + if result.contains(&placeholder) { + tracing::debug!( + placeholder = %placeholder, + "Found and replacing credential placeholder" + ); + result = result.replace(&placeholder, value); + } + } + + // Check if any placeholders remain (indicates missing credential) + if result.contains('{') && result.contains('}') { + tracing::warn!( + original_url = %url, + result_url = %result, + "URL may contain unresolved placeholders" + ); + } + + result + } +} + +// Implement WasiView to provide WASI context and resource table +impl WasiView for ChannelStoreData { + fn ctx(&mut self) -> &mut WasiCtx { + &mut self.wasi + } + + fn table(&mut self) -> &mut ResourceTable { + &mut self.table + } +} + +// Implement the generated Host trait for channel-host interface +impl near::agent::channel_host::Host for ChannelStoreData { + fn log(&mut self, level: near::agent::channel_host::LogLevel, message: String) { + let log_level = match level { + near::agent::channel_host::LogLevel::Trace => LogLevel::Trace, + near::agent::channel_host::LogLevel::Debug => LogLevel::Debug, + near::agent::channel_host::LogLevel::Info => LogLevel::Info, + near::agent::channel_host::LogLevel::Warn => LogLevel::Warn, + near::agent::channel_host::LogLevel::Error => LogLevel::Error, + }; + let _ = self.host_state.log(log_level, message); + } + + fn now_millis(&mut self) -> u64 { + self.host_state.now_millis() + } + + fn workspace_read(&mut self, path: String) -> Option { + self.host_state.workspace_read(&path).ok().flatten() + } + + fn workspace_write(&mut self, path: String, content: String) -> Result<(), String> { + self.host_state + .workspace_write(&path, content) + .map_err(|e| e.to_string()) + } + + fn http_request( + &mut self, + method: String, + url: String, + headers_json: String, + body: Option>, + ) -> Result { + tracing::info!( + method = %method, + original_url = %url, + body_len = body.as_ref().map(|b| b.len()).unwrap_or(0), + "WASM http_request called" + ); + + // Inject credentials into URL (e.g., replace {TELEGRAM_BOT_TOKEN} with actual token) + let injected_url = self.inject_credentials_into_url(&url); + + // Log whether injection happened (without revealing the token) + let url_changed = injected_url != url; + tracing::info!( + url_changed = url_changed, + has_bot_token = injected_url.contains("/bot") && !injected_url.contains("{"), + "URL after credential injection" + ); + + // Check if HTTP is allowed for this URL + self.host_state + .check_http_allowed(&injected_url, &method) + .map_err(|e| { + tracing::error!(error = %e, "HTTP not allowed"); + format!("HTTP not allowed: {}", e) + })?; + + // Record the request for rate limiting + self.host_state.record_http_request().map_err(|e| { + tracing::error!(error = %e, "Rate limit exceeded"); + format!("Rate limit exceeded: {}", e) + })?; + + // Parse headers + let headers: std::collections::HashMap = + serde_json::from_str(&headers_json).unwrap_or_default(); + + tracing::debug!(header_count = headers.len(), "Parsed request headers"); + + let url = injected_url; + + // Make the HTTP request using blocking I/O + // We're already in a spawn_blocking context, so we can use block_on + let result = tokio::runtime::Handle::current().block_on(async { + let client = reqwest::Client::new(); + + let mut request = match method.to_uppercase().as_str() { + "GET" => client.get(&url), + "POST" => client.post(&url), + "PUT" => client.put(&url), + "DELETE" => client.delete(&url), + "PATCH" => client.patch(&url), + "HEAD" => client.head(&url), + _ => return Err(format!("Unsupported HTTP method: {}", method)), + }; + + // Add headers + for (key, value) in headers { + request = request.header(&key, &value); + } + + // Add body if present + if let Some(body_bytes) = body { + request = request.body(body_bytes); + } + + // Send request with timeout + let response = request + .timeout(std::time::Duration::from_secs(30)) + .send() + .await + .map_err(|e| format!("HTTP request failed: {}", e))?; + + let status = response.status().as_u16(); + let response_headers: std::collections::HashMap = response + .headers() + .iter() + .filter_map(|(k, v)| { + v.to_str() + .ok() + .map(|v| (k.as_str().to_string(), v.to_string())) + }) + .collect(); + let headers_json = serde_json::to_string(&response_headers).unwrap_or_default(); + let body = response + .bytes() + .await + .map_err(|e| format!("Failed to read response body: {}", e))? + .to_vec(); + + tracing::info!( + status = status, + body_len = body.len(), + "HTTP response received" + ); + + // Log response body for debugging (truncated) + if let Ok(body_str) = std::str::from_utf8(&body) { + let truncated = if body_str.len() > 500 { + format!("{}...", &body_str[..500]) + } else { + body_str.to_string() + }; + tracing::debug!(body = %truncated, "Response body"); + } + + Ok(near::agent::channel_host::HttpResponse { + status, + headers_json, + body, + }) + }); + + match &result { + Ok(resp) => { + tracing::info!(status = resp.status, "http_request completed successfully"); + } + Err(e) => { + tracing::error!(error = %e, "http_request failed"); + } + } + + result + } + + fn secret_exists(&mut self, name: String) -> bool { + self.host_state.secret_exists(&name) + } + + fn emit_message(&mut self, msg: near::agent::channel_host::EmittedMessage) { + tracing::info!( + user_id = %msg.user_id, + user_name = ?msg.user_name, + content_len = msg.content.len(), + "WASM emit_message called" + ); + + let mut emitted = EmittedMessage::new(msg.user_id.clone(), msg.content.clone()); + if let Some(name) = msg.user_name { + emitted = emitted.with_user_name(name); + } + if let Some(tid) = msg.thread_id { + emitted = emitted.with_thread_id(tid); + } + emitted = emitted.with_metadata(msg.metadata_json); + + match self.host_state.emit_message(emitted) { + Ok(()) => { + tracing::info!("Message emitted to host state successfully"); + } + Err(e) => { + tracing::error!(error = %e, "Failed to emit message to host state"); + } } } } @@ -102,6 +369,10 @@ pub struct WasmChannel { /// Registered HTTP endpoints. endpoints: RwLock>, + + /// Injected credentials for HTTP requests (e.g., bot tokens). + /// Keys are placeholder names like "TELEGRAM_BOT_TOKEN". + credentials: RwLock>, } impl WasmChannel { @@ -127,9 +398,23 @@ impl WasmChannel { rate_limiter: RwLock::new(rate_limiter), shutdown_tx: RwLock::new(None), endpoints: RwLock::new(Vec::new()), + credentials: RwLock::new(HashMap::new()), } } + /// Set a credential for URL injection. + pub async fn set_credential(&self, name: &str, value: String) { + self.credentials + .write() + .await + .insert(name.to_string(), value); + } + + /// Get a snapshot of credentials for use in callbacks. + pub async fn get_credentials(&self) -> HashMap { + self.credentials.read().await.clone() + } + /// Get the channel name. pub fn channel_name(&self) -> &str { &self.name @@ -145,155 +430,177 @@ impl WasmChannel { self.endpoints.read().await.clone() } - /// Add channel host functions to the linker. + /// Register a webhook URL with Telegram. /// - /// These functions are imported by the WASM channel module. - fn add_host_functions(linker: &mut Linker) -> Result<(), WasmChannelError> { - // host.log(level: log-level, message: string) - linker - .root() - .func_wrap( - "log", - |mut ctx: wasmtime::StoreContextMut<'_, ChannelStoreData>, - (level, message): (i32, String)| { - let log_level = match level { - 0 => LogLevel::Trace, - 1 => LogLevel::Debug, - 2 => LogLevel::Info, - 3 => LogLevel::Warn, - 4 => LogLevel::Error, - _ => LogLevel::Info, - }; - // Ignore errors from logging (rate limiting) - let _ = ctx.data_mut().host_state.log(log_level, message); - Ok(()) - }, - ) - .map_err(|e| WasmChannelError::Config(format!("Failed to add log function: {}", e)))?; + /// Called during channel startup if tunnel_url is configured. + /// This enables instant message delivery instead of polling. + pub async fn register_telegram_webhook( + &self, + tunnel_url: &str, + bot_token: &str, + secret_token: Option<&str>, + ) -> Result<(), WasmChannelError> { + let webhook_url = format!("{}/webhook/telegram", tunnel_url); - // host.now-millis() -> u64 - linker - .root() - .func_wrap( - "now-millis", - |ctx: wasmtime::StoreContextMut<'_, ChannelStoreData>, - (): ()| - -> anyhow::Result<(u64,)> { - Ok((ctx.data().host_state.now_millis(),)) - }, - ) - .map_err(|e| { - WasmChannelError::Config(format!("Failed to add now-millis function: {}", e)) - })?; + tracing::info!( + channel = %self.name, + webhook_url = %webhook_url, + "Registering Telegram webhook" + ); - // host.workspace-read(path: string) -> option - linker - .root() - .func_wrap( - "workspace-read", - |ctx: wasmtime::StoreContextMut<'_, ChannelStoreData>, - (path,): (String,)| - -> anyhow::Result<(Option,)> { - let result = ctx.data().host_state.workspace_read(&path).ok().flatten(); - Ok((result,)) - }, - ) - .map_err(|e| { - WasmChannelError::Config(format!("Failed to add workspace-read function: {}", e)) - })?; + // Build form parameters + let mut form_params = vec![ + ("url", webhook_url.as_str()), + ("allowed_updates", r#"["message","edited_message"]"#), + ]; - // host.workspace-write(path: string, content: string) -> result<_, string> - linker - .root() - .func_wrap( - "workspace-write", - |mut ctx: wasmtime::StoreContextMut<'_, ChannelStoreData>, - (path, content): (String, String)| - -> anyhow::Result<(Result<(), String>,)> { - let result = ctx - .data_mut() - .host_state - .workspace_write(&path, content) - .map_err(|e| e.to_string()); - Ok((result,)) - }, - ) - .map_err(|e| { - WasmChannelError::Config(format!("Failed to add workspace-write function: {}", e)) - })?; + let secret_owned: String; + if let Some(secret) = secret_token { + secret_owned = secret.to_string(); + form_params.push(("secret_token", &secret_owned)); + } - // host.emit-message(msg: emitted-message) - // The message is passed as a record with fields: user-id, user-name, content, thread-id, metadata-json - linker - .root() - .func_wrap( - "emit-message", - |mut ctx: wasmtime::StoreContextMut<'_, ChannelStoreData>, - (user_id, user_name, content, thread_id, metadata_json): ( - String, - Option, - String, - Option, - String, - )| { - let mut msg = EmittedMessage::new(user_id, content); - if let Some(name) = user_name { - msg = msg.with_user_name(name); - } - if let Some(tid) = thread_id { - msg = msg.with_thread_id(tid); - } - msg = msg.with_metadata(metadata_json); + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| WasmChannelError::HttpRequest(e.to_string()))?; - // Ignore errors (rate limiting just drops messages) - let _ = ctx.data_mut().host_state.emit_message(msg); - Ok(()) - }, - ) - .map_err(|e| { - WasmChannelError::Config(format!("Failed to add emit-message function: {}", e)) - })?; + let response = client + .post(format!( + "https://api.telegram.org/bot{}/setWebhook", + bot_token + )) + .form(&form_params) + .send() + .await + .map_err(|e| WasmChannelError::HttpRequest(e.to_string()))?; - // host.secret-exists(name: string) -> bool - linker - .root() - .func_wrap( - "secret-exists", - |ctx: wasmtime::StoreContextMut<'_, ChannelStoreData>, - (name,): (String,)| - -> anyhow::Result<(bool,)> { - Ok((ctx.data().host_state.secret_exists(&name),)) - }, - ) - .map_err(|e| { - WasmChannelError::Config(format!("Failed to add secret-exists function: {}", e)) - })?; + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(WasmChannelError::WebhookRegistration { + name: self.name.clone(), + reason: format!("HTTP {}: {}", status, body), + }); + } + + // Parse Telegram API response + let result: serde_json::Value = response + .json() + .await + .map_err(|e| WasmChannelError::HttpRequest(e.to_string()))?; + + if result["ok"].as_bool() != Some(true) { + let description = result["description"] + .as_str() + .unwrap_or("unknown error") + .to_string(); + return Err(WasmChannelError::WebhookRegistration { + name: self.name.clone(), + reason: description, + }); + } + + tracing::info!( + channel = %self.name, + webhook_url = %webhook_url, + "Telegram webhook registered successfully" + ); Ok(()) } - /// Execute a WASM callback synchronously (called from spawn_blocking). + /// Delete the webhook and switch back to polling mode. /// - /// This is the core execution logic shared by all callbacks. - fn execute_callback_sync( + /// Called during shutdown if webhook was registered. + pub async fn delete_telegram_webhook(&self, bot_token: &str) -> Result<(), WasmChannelError> { + tracing::info!( + channel = %self.name, + "Deleting Telegram webhook" + ); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| WasmChannelError::HttpRequest(e.to_string()))?; + + let response = client + .post(format!( + "https://api.telegram.org/bot{}/deleteWebhook", + bot_token + )) + .send() + .await + .map_err(|e| WasmChannelError::HttpRequest(e.to_string()))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(WasmChannelError::WebhookRegistration { + name: self.name.clone(), + reason: format!("HTTP {} (delete): {}", status, body), + }); + } + + let result: serde_json::Value = response + .json() + .await + .map_err(|e| WasmChannelError::HttpRequest(e.to_string()))?; + + if result["ok"].as_bool() != Some(true) { + let description = result["description"] + .as_str() + .unwrap_or("unknown error") + .to_string(); + return Err(WasmChannelError::WebhookRegistration { + name: self.name.clone(), + reason: format!("delete failed: {}", description), + }); + } + + tracing::info!( + channel = %self.name, + "Telegram webhook deleted" + ); + + Ok(()) + } + + /// Add channel host functions to the linker using generated bindings. + /// + /// Uses the wasmtime::component::bindgen! generated `add_to_linker` function + /// to properly register all host functions with correct component model signatures. + fn add_host_functions(linker: &mut Linker) -> Result<(), WasmChannelError> { + // Add WASI support (required by the component adapter) + wasmtime_wasi::add_to_linker_sync(linker).map_err(|e| { + WasmChannelError::Config(format!("Failed to add WASI functions: {}", e)) + })?; + + // Use the generated add_to_linker function from bindgen for our custom interface + near::agent::channel_host::add_to_linker(linker, |state| state).map_err(|e| { + WasmChannelError::Config(format!("Failed to add host functions: {}", e)) + })?; + + Ok(()) + } + + /// Create a fresh store configured for WASM execution. + fn create_store( runtime: &WasmChannelRuntime, prepared: &PreparedChannelModule, capabilities: &ChannelCapabilities, - export_name: &str, - build_args: F, - ) -> Result<(R, ChannelHostState), WasmChannelError> - where - F: FnOnce() -> ( - Vec, - Box Result>, - ), - { + credentials: HashMap, + ) -> Result, WasmChannelError> { let engine = runtime.engine(); let limits = &prepared.limits; // Create fresh store with channel state (NEAR pattern: fresh instance per call) - let store_data = - ChannelStoreData::new(limits.memory_bytes, &prepared.name, capabilities.clone()); + let store_data = ChannelStoreData::new( + limits.memory_bytes, + &prepared.name, + capabilities.clone(), + credentials, + ); let mut store = Store::new(engine, store_data); // Configure fuel if enabled @@ -310,6 +617,17 @@ impl WasmChannel { // Set up resource limiter store.limiter(|data| &mut data.limiter); + Ok(store) + } + + /// Instantiate the WASM component using generated bindings. + fn instantiate_component( + runtime: &WasmChannelRuntime, + prepared: &PreparedChannelModule, + store: &mut Store, + ) -> Result { + let engine = runtime.engine(); + // Compile the component (uses cached bytes) let component = Component::new(engine, prepared.component_bytes()) .map_err(|e| WasmChannelError::Compilation(e.to_string()))?; @@ -318,58 +636,44 @@ impl WasmChannel { let mut linker = Linker::new(engine); Self::add_host_functions(&mut linker)?; - // Instantiate the component - let instance = linker - .instantiate(&mut store, &component) + // Instantiate using the generated bindings + let instance = SandboxedChannel::instantiate(store, &component, &linker) .map_err(|e| WasmChannelError::Instantiation(e.to_string()))?; - // Get the export function - let func = instance - .get_func(&mut store, export_name) - .ok_or_else(|| WasmChannelError::MissingExport(export_name.to_string()))?; + Ok(instance) + } - // Build arguments and result extractor - let (args, extract_result) = build_args(); - - // Call the function - let mut results = vec![Val::Bool(false)]; // Placeholder - func.call(&mut store, &args, &mut results).map_err(|e| { - let error_str = e.to_string(); - if error_str.contains("out of fuel") { - WasmChannelError::FuelExhausted { - name: prepared.name.clone(), - limit: limits.fuel, - } - } else if error_str.contains("unreachable") { - WasmChannelError::Trapped { - name: prepared.name.clone(), - reason: "unreachable code executed".to_string(), - } - } else { - WasmChannelError::Trapped { - name: prepared.name.clone(), - reason: error_str, - } + /// Map WASM execution errors to our error types. + fn map_wasm_error(e: anyhow::Error, name: &str, fuel_limit: u64) -> WasmChannelError { + let error_str = e.to_string(); + if error_str.contains("out of fuel") { + WasmChannelError::FuelExhausted { + name: name.to_string(), + limit: fuel_limit, } - })?; + } else if error_str.contains("unreachable") { + WasmChannelError::Trapped { + name: name.to_string(), + reason: "unreachable code executed".to_string(), + } + } else { + WasmChannelError::Trapped { + name: name.to_string(), + reason: error_str, + } + } + } - // Post-call completion (cleanup) - func.post_return(&mut store) - .map_err(|e| WasmChannelError::Trapped { - name: prepared.name.clone(), - reason: format!("post_return failed: {}", e), - })?; - - // Extract result - let result = extract_result(&results[0])?; - - // Get host state with emitted messages and pending writes - let host_state = std::mem::replace( + /// Extract host state after callback execution. + fn extract_host_state( + store: &mut Store, + channel_name: &str, + capabilities: &ChannelCapabilities, + ) -> ChannelHostState { + std::mem::replace( &mut store.data_mut().host_state, - ChannelHostState::new(&prepared.name, capabilities.clone()), - ); - - Ok((result, host_state)) + ChannelHostState::new(channel_name, capabilities.clone()), + ) } /// Execute the on_start callback. @@ -394,20 +698,40 @@ impl WasmChannel { let capabilities = self.capabilities.clone(); let config_json = self.config_json.clone(); let timeout = self.runtime.config().callback_timeout; - let channel_name_for_error = self.name.clone(); + let channel_name = self.name.clone(); + let credentials = self.get_credentials().await; // Execute in blocking task with timeout let result = tokio::time::timeout(timeout, async move { tokio::task::spawn_blocking(move || { - Self::execute_callback_sync(&runtime, &prepared, &capabilities, "on-start", || { - let args = vec![Val::String(config_json)]; - let extract = Box::new(|result: &Val| extract_channel_config(result)); - (args, extract) - }) + let mut store = + Self::create_store(&runtime, &prepared, &capabilities, credentials)?; + let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; + + // Call on_start using the generated typed interface + let channel_iface = instance.near_agent_channel(); + let wasm_result = channel_iface + .call_on_start(&mut store, &config_json) + .map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?; + + // Convert the result + let config = match wasm_result { + Ok(wit_config) => convert_channel_config(wit_config), + Err(err_msg) => { + return Err(WasmChannelError::CallbackFailed { + name: prepared.name.clone(), + reason: err_msg, + }); + } + }; + + let host_state = + Self::extract_host_state(&mut store, &prepared.name, &capabilities); + Ok((config, host_state)) }) .await .map_err(|e| WasmChannelError::ExecutionPanicked { - name: channel_name_for_error.clone(), + name: channel_name.clone(), reason: e.to_string(), })? }) @@ -443,6 +767,33 @@ impl WasmChannel { body: &[u8], secret_validated: bool, ) -> Result { + tracing::info!( + channel = %self.name, + method = method, + path = path, + body_len = body.len(), + secret_validated = secret_validated, + "call_on_http_request invoked (webhook received)" + ); + + // Log the body for debugging (if it looks like JSON) + if let Ok(body_str) = std::str::from_utf8(body) { + let truncated = if body_str.len() > 1000 { + format!("{}...", &body_str[..1000]) + } else { + body_str.to_string() + }; + tracing::debug!(body = %truncated, "Webhook request body"); + } + + // Log credentials state (without values) + let creds = self.get_credentials().await; + tracing::info!( + credential_count = creds.len(), + credential_names = ?creds.keys().collect::>(), + "Credentials available for on_http_request" + ); + // If no WASM bytes, return 200 OK (for testing) if self.prepared.component_bytes.is_empty() { tracing::debug!( @@ -458,6 +809,7 @@ impl WasmChannel { let prepared = Arc::clone(&self.prepared); let capabilities = self.capabilities.clone(); let timeout = self.runtime.config().callback_timeout; + let credentials = self.get_credentials().await; // Prepare request data let method = method.to_string(); @@ -466,39 +818,39 @@ impl WasmChannel { let query_json = serde_json::to_string(&query).unwrap_or_default(); let body = body.to_vec(); - // Clone name for error handling before moving into closure - let channel_name_for_error = self.name.clone(); + let channel_name = self.name.clone(); // Execute in blocking task with timeout let result = tokio::time::timeout(timeout, async move { tokio::task::spawn_blocking(move || { - Self::execute_callback_sync( - &runtime, - &prepared, - &capabilities, - "on-http-request", - || { - // Build incoming-http-request record - let request = Val::Record(vec![ - ("method".to_string(), Val::String(method)), - ("path".to_string(), Val::String(path)), - ("headers-json".to_string(), Val::String(headers_json)), - ("query-json".to_string(), Val::String(query_json)), - ( - "body".to_string(), - Val::List(body.into_iter().map(Val::U8).collect()), - ), - ("secret-validated".to_string(), Val::Bool(secret_validated)), - ]); - let args = vec![request]; - let extract = Box::new(|result: &Val| extract_http_response(result)); - (args, extract) - }, - ) + let mut store = + Self::create_store(&runtime, &prepared, &capabilities, credentials)?; + let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; + + // Build the WIT request type + let wit_request = wit_channel::IncomingHttpRequest { + method, + path, + headers_json, + query_json, + body, + secret_validated, + }; + + // Call on_http_request using the generated typed interface + let channel_iface = instance.near_agent_channel(); + let wit_response = channel_iface + .call_on_http_request(&mut store, &wit_request) + .map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?; + + let response = convert_http_response(wit_response); + let host_state = + Self::extract_host_state(&mut store, &prepared.name, &capabilities); + Ok((response, host_state)) }) .await .map_err(|e| WasmChannelError::ExecutionPanicked { - name: channel_name_for_error.clone(), + name: channel_name.clone(), reason: e.to_string(), })? }) @@ -544,15 +896,24 @@ impl WasmChannel { let capabilities = self.capabilities.clone(); let timeout = self.runtime.config().callback_timeout; let channel_name = self.name.clone(); + let credentials = self.get_credentials().await; // Execute in blocking task with timeout let result = tokio::time::timeout(timeout, async move { tokio::task::spawn_blocking(move || { - Self::execute_callback_sync(&runtime, &prepared, &capabilities, "on-poll", || { - let args = vec![]; - let extract = Box::new(|_result: &Val| Ok(())); - (args, extract) - }) + let mut store = + Self::create_store(&runtime, &prepared, &capabilities, credentials)?; + let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; + + // Call on_poll using the generated typed interface + let channel_iface = instance.near_agent_channel(); + channel_iface + .call_on_poll(&mut store) + .map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?; + + let host_state = + Self::extract_host_state(&mut store, &prepared.name, &capabilities); + Ok(((), host_state)) }) .await .map_err(|e| WasmChannelError::ExecutionPanicked { @@ -593,6 +954,22 @@ impl WasmChannel { thread_id: Option<&str>, metadata_json: &str, ) -> Result<(), WasmChannelError> { + tracing::info!( + channel = %self.name, + message_id = %message_id, + content_len = content.len(), + thread_id = ?thread_id, + "call_on_respond invoked" + ); + + // Log credentials state (without values) + let creds = self.get_credentials().await; + tracing::info!( + credential_count = creds.len(), + credential_names = ?creds.keys().collect::>(), + "Credentials available for on_respond" + ); + // If no WASM bytes, do nothing (for testing) if self.prepared.component_bytes.is_empty() { tracing::debug!( @@ -608,6 +985,7 @@ impl WasmChannel { let capabilities = self.capabilities.clone(); let timeout = self.runtime.config().callback_timeout; let channel_name = self.name.clone(); + let credentials = self.get_credentials().await; // Prepare response data let message_id_str = message_id.to_string(); @@ -616,37 +994,62 @@ impl WasmChannel { let metadata_json = metadata_json.to_string(); // Execute in blocking task with timeout + tracing::info!(channel = %channel_name, "Starting on_respond WASM execution"); + let result = tokio::time::timeout(timeout, async move { tokio::task::spawn_blocking(move || { - Self::execute_callback_sync( - &runtime, - &prepared, - &capabilities, - "on-respond", - || { - // Build agent-response record - let response = Val::Record(vec![ - ("message-id".to_string(), Val::String(message_id_str)), - ("content".to_string(), Val::String(content)), - ( - "thread-id".to_string(), - match thread_id { - Some(tid) => Val::Option(Some(Box::new(Val::String(tid)))), - None => Val::Option(None), - }, - ), - ("metadata-json".to_string(), Val::String(metadata_json)), - ]); - let args = vec![response]; - let extract = Box::new(|result: &Val| extract_result_unit(result)); - (args, extract) - }, - ) + tracing::info!("Creating WASM store for on_respond"); + let mut store = + Self::create_store(&runtime, &prepared, &capabilities, credentials)?; + + tracing::info!("Instantiating WASM component for on_respond"); + let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; + + // Build the WIT response type + let wit_response = wit_channel::AgentResponse { + message_id: message_id_str, + content: content.clone(), + thread_id, + metadata_json, + }; + + tracing::info!( + content_preview = %if content.len() > 50 { &content[..50] } else { &content }, + "Calling WASM on_respond" + ); + + // Call on_respond using the generated typed interface + let channel_iface = instance.near_agent_channel(); + let wasm_result = channel_iface + .call_on_respond(&mut store, &wit_response) + .map_err(|e| { + tracing::error!(error = %e, "WASM on_respond call failed"); + Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel) + })?; + + tracing::info!(wasm_result = ?wasm_result, "WASM on_respond returned"); + + // Check for WASM-level errors + if let Err(ref err_msg) = wasm_result { + tracing::error!(error = %err_msg, "WASM on_respond returned error"); + return Err(WasmChannelError::CallbackFailed { + name: prepared.name.clone(), + reason: err_msg.clone(), + }); + } + + let host_state = + Self::extract_host_state(&mut store, &prepared.name, &capabilities); + tracing::info!("on_respond WASM execution completed successfully"); + Ok(((), host_state)) }) .await - .map_err(|e| WasmChannelError::ExecutionPanicked { - name: channel_name.clone(), - reason: e.to_string(), + .map_err(|e| { + tracing::error!(error = %e, "spawn_blocking panicked"); + WasmChannelError::ExecutionPanicked { + name: channel_name.clone(), + reason: e.to_string(), + } })? }) .await; @@ -674,16 +1077,23 @@ impl WasmChannel { &self, messages: Vec, ) -> Result<(), WasmChannelError> { + tracing::info!( + channel = %self.name, + message_count = messages.len(), + "Processing emitted messages from WASM callback" + ); + if messages.is_empty() { + tracing::debug!(channel = %self.name, "No messages emitted"); return Ok(()); } let tx_guard = self.message_tx.read().await; let Some(tx) = tx_guard.as_ref() else { - tracing::warn!( + tracing::error!( channel = %self.name, count = messages.len(), - "Messages emitted but no sender available" + "Messages emitted but no sender available - channel may not be started!" ); return Ok(()); }; @@ -719,13 +1129,25 @@ impl WasmChannel { } // Send to stream + tracing::info!( + channel = %self.name, + user_id = %emitted.user_id, + content_len = emitted.content.len(), + "Sending emitted message to agent" + ); + if tx.send(msg).await.is_err() { - tracing::warn!( + tracing::error!( channel = %self.name, "Failed to send emitted message, channel closed" ); break; } + + tracing::info!( + channel = %self.name, + "Message successfully sent to agent queue" + ); } Ok(()) @@ -851,7 +1273,10 @@ impl Channel for WasmChannel { } // Call WASM on_respond - let metadata_json = serde_json::to_string(&response.metadata).unwrap_or_default(); + // IMPORTANT: Use the ORIGINAL message's metadata, not the response's metadata. + // The original metadata contains channel-specific routing info (e.g., Telegram chat_id) + // that the WASM channel needs to send the reply to the correct destination. + let metadata_json = serde_json::to_string(&msg.metadata).unwrap_or_default(); self.call_on_respond( msg.id, &response.content, @@ -915,241 +1340,103 @@ impl std::fmt::Debug for WasmChannel { } // ============================================================================ -// Value Extraction Helpers +// Shared Channel Wrapper // ============================================================================ -/// Extract ChannelConfig from a WIT result. -fn extract_channel_config(val: &Val) -> Result { - // Result is (ok: option, err: option) - match val { - Val::Result(result) => match result.as_ref() { - Ok(Some(config_val)) => extract_channel_config_inner(config_val), - Ok(None) => Err(WasmChannelError::InvalidResponse( - "on-start returned empty Ok".to_string(), - )), - Err(Some(err_val)) => { - if let Val::String(err) = err_val.as_ref() { - Err(WasmChannelError::CallbackFailed { - name: "channel".to_string(), - reason: err.clone(), - }) - } else { - Err(WasmChannelError::InvalidResponse( - "on-start error is not a string".to_string(), - )) - } - } - Err(None) => Err(WasmChannelError::InvalidResponse( - "on-start returned empty Err".to_string(), - )), - }, - // Fallback: try to parse as record directly (for simpler implementations) - Val::Record(_) => extract_channel_config_inner(val), - _ => Err(WasmChannelError::InvalidResponse(format!( - "Expected result or record, got {:?}", - std::mem::discriminant(val) - ))), +/// A wrapper around `Arc` that implements `Channel`. +/// +/// This allows sharing the same WasmChannel instance between: +/// - The WasmChannelRouter (for webhook handling) +/// - The ChannelManager (for message streaming and responses) +pub struct SharedWasmChannel { + inner: Arc, +} + +impl SharedWasmChannel { + /// Create a new shared wrapper. + pub fn new(channel: Arc) -> Self { + Self { inner: channel } + } + + /// Get the inner Arc. + pub fn inner(&self) -> &Arc { + &self.inner } } -/// Extract ChannelConfig from a channel-config record. -fn extract_channel_config_inner(val: &Val) -> Result { - match val { - Val::Record(fields) => { - let mut display_name = String::new(); - let mut http_endpoints = Vec::new(); - let mut poll = None; - - for (name, field_val) in fields { - match name.as_str() { - "display-name" => { - if let Val::String(s) = field_val { - display_name = s.clone(); - } - } - "http-endpoints" => { - if let Val::List(endpoints) = field_val { - for ep in endpoints { - if let Ok(endpoint) = extract_http_endpoint_config(ep) { - http_endpoints.push(endpoint); - } - } - } - } - "poll" => { - if let Val::Option(Some(poll_val)) = field_val { - poll = extract_poll_config(poll_val).ok(); - } - } - _ => {} - } - } - - Ok(ChannelConfig { - display_name, - http_endpoints, - poll, - }) - } - _ => Err(WasmChannelError::InvalidResponse( - "Expected record for channel-config".to_string(), - )), +impl std::fmt::Debug for SharedWasmChannel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SharedWasmChannel") + .field("inner", &self.inner) + .finish() } } -/// Extract HttpEndpointConfigSchema from a record. -fn extract_http_endpoint_config( - val: &Val, -) -> Result { - match val { - Val::Record(fields) => { - let mut path = String::new(); - let mut methods = Vec::new(); - let mut require_secret = false; +#[async_trait] +impl Channel for SharedWasmChannel { + fn name(&self) -> &str { + self.inner.name() + } - for (name, field_val) in fields { - match name.as_str() { - "path" => { - if let Val::String(s) = field_val { - path = s.clone(); - } - } - "methods" => { - if let Val::List(list) = field_val { - for item in list { - if let Val::String(s) = item { - methods.push(s.clone()); - } - } - } - } - "require-secret" => { - if let Val::Bool(b) = field_val { - require_secret = *b; - } - } - _ => {} - } - } + async fn start(&self) -> Result { + self.inner.start().await + } - Ok(crate::channels::wasm::schema::HttpEndpointConfigSchema { - path, - methods, - require_secret, - }) - } - _ => Err(WasmChannelError::InvalidResponse( - "Expected record for http-endpoint-config".to_string(), - )), + async fn respond( + &self, + msg: &IncomingMessage, + response: OutgoingResponse, + ) -> Result<(), ChannelError> { + self.inner.respond(msg, response).await + } + + async fn health_check(&self) -> Result<(), ChannelError> { + self.inner.health_check().await + } + + async fn shutdown(&self) -> Result<(), ChannelError> { + self.inner.shutdown().await } } -/// Extract PollConfigSchema from a record. -fn extract_poll_config( - val: &Val, -) -> Result { - match val { - Val::Record(fields) => { - let mut interval_ms = 30_000; - let mut enabled = false; +// ============================================================================ +// WIT Type Conversion Helpers +// ============================================================================ - for (name, field_val) in fields { - match name.as_str() { - "interval-ms" => { - if let Val::U32(n) = field_val { - interval_ms = *n; - } - } - "enabled" => { - if let Val::Bool(b) = field_val { - enabled = *b; - } - } - _ => {} - } - } +// Type aliases for the generated WIT types (exported interface) +use exports::near::agent::channel as wit_channel; - Ok(crate::channels::wasm::schema::PollConfigSchema { - interval_ms, - enabled, - }) - } - _ => Err(WasmChannelError::InvalidResponse( - "Expected record for poll-config".to_string(), - )), +/// Convert WIT-generated ChannelConfig to our internal type. +fn convert_channel_config(wit: wit_channel::ChannelConfig) -> ChannelConfig { + ChannelConfig { + display_name: wit.display_name, + http_endpoints: wit + .http_endpoints + .into_iter() + .map( + |ep| crate::channels::wasm::schema::HttpEndpointConfigSchema { + path: ep.path, + methods: ep.methods, + require_secret: ep.require_secret, + }, + ) + .collect(), + poll: wit + .poll + .map(|p| crate::channels::wasm::schema::PollConfigSchema { + interval_ms: p.interval_ms, + enabled: p.enabled, + }), } } -/// Extract HttpResponse from a WIT outgoing-http-response record. -fn extract_http_response(val: &Val) -> Result { - match val { - Val::Record(fields) => { - let mut status = 200u16; - let mut headers = HashMap::new(); - let mut body = Vec::new(); - - for (name, field_val) in fields { - match name.as_str() { - "status" => { - if let Val::U16(s) = field_val { - status = *s; - } - } - "headers-json" => { - if let Val::String(s) = field_val { - if let Ok(h) = serde_json::from_str::>(s) { - headers = h; - } - } - } - "body" => { - if let Val::List(bytes) = field_val { - body = bytes - .iter() - .filter_map(|v| if let Val::U8(b) = v { Some(*b) } else { None }) - .collect(); - } - } - _ => {} - } - } - - Ok(HttpResponse { - status, - headers, - body, - }) - } - _ => Err(WasmChannelError::InvalidResponse( - "Expected record for http-response".to_string(), - )), - } -} - -/// Extract unit result from a WIT result<_, string>. -fn extract_result_unit(val: &Val) -> Result<(), WasmChannelError> { - match val { - Val::Result(result) => match result.as_ref() { - Ok(_) => Ok(()), - Err(Some(err_val)) => { - if let Val::String(err) = err_val.as_ref() { - Err(WasmChannelError::CallbackFailed { - name: "channel".to_string(), - reason: err.clone(), - }) - } else { - Err(WasmChannelError::InvalidResponse( - "Error is not a string".to_string(), - )) - } - } - Err(None) => Err(WasmChannelError::InvalidResponse( - "Returned empty Err".to_string(), - )), - }, - // Unit return (for on-poll which returns nothing) - Val::Tuple(items) if items.is_empty() => Ok(()), - _ => Ok(()), // Treat anything else as success for unit-returning callbacks +/// Convert WIT-generated OutgoingHttpResponse to our HttpResponse type. +fn convert_http_response(wit: wit_channel::OutgoingHttpResponse) -> HttpResponse { + let headers = serde_json::from_str(&wit.headers_json).unwrap_or_default(); + HttpResponse { + status: wit.status, + headers, + body: wit.body, } } diff --git a/src/config.rs b/src/config.rs index 1d9e8d07..bdbd0200 100644 --- a/src/config.rs +++ b/src/config.rs @@ -13,6 +13,7 @@ pub struct Config { pub database: DatabaseConfig, pub llm: LlmConfig, pub embeddings: EmbeddingsConfig, + pub tunnel: TunnelConfig, pub channels: ChannelsConfig, pub agent: AgentConfig, pub safety: SafetyConfig, @@ -32,6 +33,7 @@ impl Config { database: DatabaseConfig::from_env()?, llm: LlmConfig::from_env()?, embeddings: EmbeddingsConfig::from_env()?, + tunnel: TunnelConfig::from_env()?, channels: ChannelsConfig::from_env()?, agent: AgentConfig::from_env()?, safety: SafetyConfig::from_env()?, @@ -43,6 +45,81 @@ impl Config { } } +/// Tunnel configuration for exposing the agent to the internet. +/// +/// Used by channels and tools that need public webhook endpoints. +/// The tunnel URL is shared across all channels (Telegram, Slack, etc.). +/// +/// # Security Notes +/// +/// **Webhook endpoints** (e.g., `/webhook/telegram`) should NOT use tunnel-level +/// authentication because webhook providers (Telegram, Slack, GitHub) need +/// unauthenticated access to POST updates. Security for webhooks comes from: +/// - Webhook signature verification (provider-specific secrets) +/// - IP allowlisting (if supported by provider) +/// +/// **Non-webhook endpoints** (admin APIs, health checks) CAN be protected using +/// tunnel provider features: +/// - ngrok: Basic Auth, OAuth, IP restrictions +/// - Cloudflare: Access policies, mTLS +/// +/// These protections are configured in the tunnel provider, not here. +/// +/// # Supported Providers +/// +/// - **ngrok**: `ngrok http 8080` -> `https://abc123.ngrok.io` +/// - **Cloudflare Tunnel**: `cloudflared tunnel --url http://localhost:8080` +/// - **localtunnel**: `lt --port 8080` +/// - Any service that provides a public HTTPS URL to localhost +#[derive(Debug, Clone, Default)] +pub struct TunnelConfig { + /// Public URL from tunnel provider (e.g., "https://abc123.ngrok.io"). + /// + /// When set, channels that support webhooks will register their endpoints + /// with this base URL instead of using polling. + pub public_url: Option, +} + +impl TunnelConfig { + fn from_env() -> Result { + // Priority: env var > settings file + let public_url = optional_env("TUNNEL_URL")?.or_else(|| { + crate::settings::Settings::load() + .tunnel + .public_url + .filter(|s| !s.is_empty()) + }); + + // Validate URL format if provided + if let Some(ref url) = public_url { + if !url.starts_with("https://") { + return Err(ConfigError::InvalidValue { + key: "TUNNEL_URL".to_string(), + message: "must start with https:// (webhooks require HTTPS)".to_string(), + }); + } + } + + Ok(Self { public_url }) + } + + /// Check if a tunnel is configured. + pub fn is_enabled(&self) -> bool { + self.public_url.is_some() + } + + /// Get the webhook URL for a given path. + /// + /// Returns `None` if no tunnel is configured. + pub fn webhook_url(&self, path: &str) -> Option { + self.public_url.as_ref().map(|base| { + let base = base.trim_end_matches('/'); + let path = path.trim_start_matches('/'); + format!("{}/{}", base, path) + }) + } +} + /// Database configuration. #[derive(Debug, Clone)] pub struct DatabaseConfig { @@ -231,12 +308,37 @@ fn default_session_path() -> PathBuf { pub struct ChannelsConfig { pub cli: CliConfig, pub http: Option, + pub telegram: TelegramChannelConfig, /// Directory containing WASM channel modules (default: ~/.near-agent/channels/). pub wasm_channels_dir: std::path::PathBuf, /// Whether WASM channels are enabled. pub wasm_channels_enabled: bool, } +/// Telegram channel configuration. +/// +/// The tunnel URL for webhook mode comes from the global `TunnelConfig`. +/// This config only contains Telegram-specific settings. +#[derive(Debug, Clone, Default)] +pub struct TelegramChannelConfig { + /// Secret token for webhook validation (optional but recommended). + /// + /// When set, Telegram will include this value in the + /// `X-Telegram-Bot-Api-Secret-Token` header of webhook requests. + /// The agent validates this header to ensure requests come from Telegram. + /// + /// Generate a secure random token (32+ characters recommended). + pub webhook_secret: Option, +} + +impl TelegramChannelConfig { + fn from_env() -> Result { + Ok(Self { + webhook_secret: optional_env("TELEGRAM_WEBHOOK_SECRET")?, + }) + } +} + #[derive(Debug, Clone)] pub struct CliConfig { pub enabled: bool, @@ -277,6 +379,7 @@ impl ChannelsConfig { enabled: cli_enabled, }, http, + telegram: TelegramChannelConfig::from_env()?, wasm_channels_dir: optional_env("WASM_CHANNELS_DIR")? .map(PathBuf::from) .unwrap_or_else(default_channels_dir), diff --git a/src/main.rs b/src/main.rs index 1d5f8cd4..532d80e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,13 +9,17 @@ use near_agent::{ agent::{Agent, AgentDeps}, channels::{ AppEvent, ChannelManager, HttpChannel, ReplChannel, TuiChannel, - wasm::{WasmChannelLoader, WasmChannelRuntime, WasmChannelRuntimeConfig}, + wasm::{ + RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter, + WasmChannelRuntime, WasmChannelRuntimeConfig, WasmChannelServer, + }, }, cli::{Cli, Command, run_tool_command}, config::Config, history::Store, llm::{SessionConfig, create_llm_provider, create_session_manager}, safety::SafetyLayer, + secrets::{PostgresSecretsStore, SecretsCrypto, SecretsStore}, settings::Settings, setup::{SetupConfig, SetupWizard}, tools::{ @@ -327,6 +331,23 @@ async fn main() -> anyhow::Result<()> { } } + // Create secrets store if master key is configured (needed for Telegram webhook registration) + let secrets_store: Option> = + if let (Some(store), Some(master_key)) = (&store, config.secrets.master_key()) { + match SecretsCrypto::new(master_key.clone()) { + Ok(crypto) => Some(Arc::new(PostgresSecretsStore::new( + store.pool(), + Arc::new(crypto), + ))), + Err(e) => { + tracing::warn!("Failed to initialize secrets crypto: {}", e); + None + } + } + } else { + None + }; + // Load WASM channels if enabled if config.channels.wasm_channels_enabled && config.channels.wasm_channels_dir.exists() { match WasmChannelRuntime::new(WasmChannelRuntimeConfig::default()) { @@ -339,10 +360,140 @@ async fn main() -> anyhow::Result<()> { .await { Ok(results) => { + // Create router for WASM channel webhooks + let wasm_router = Arc::new(WasmChannelRouter::new()); + let mut has_webhook_channels = false; + for channel in results.loaded { - tracing::info!("Loaded WASM channel: {}", channel.channel_name()); - channels.add(Box::new(channel)); + let channel_name = channel.channel_name().to_string(); + tracing::info!("Loaded WASM channel: {}", channel_name); + + // Get webhook secret for this channel from secrets store + let webhook_secret = if let Some(ref secrets) = secrets_store { + let secret_name = format!("{}_webhook_secret", channel_name); + secrets + .get_decrypted("default", &secret_name) + .await + .ok() + .map(|s| s.expose().to_string()) + } else { + None + }; + + // Register channel with router for webhook handling + // Use known webhook path based on channel name + let webhook_path = format!("/webhook/{}", channel_name); + let endpoints = vec![RegisteredEndpoint { + channel_name: channel_name.clone(), + path: webhook_path.clone(), + methods: vec!["POST".to_string()], + require_secret: webhook_secret.is_some(), + }]; + + let channel_arc = Arc::new(channel); + + // Clone webhook_secret before moving it to register() + // We need it later for Telegram API registration + let webhook_secret_for_telegram = webhook_secret.clone(); + + tracing::info!( + channel = %channel_name, + has_webhook_secret = webhook_secret.is_some(), + "Registering channel with router" + ); + + wasm_router + .register(Arc::clone(&channel_arc), endpoints, webhook_secret) + .await; + has_webhook_channels = true; + + // Set up Telegram channel credentials and optionally register webhook + if channel_name == "telegram" { + if let Some(ref secrets) = secrets_store { + // Inject bot token for HTTP request URL substitution + // This is needed for both webhook and polling modes + match inject_telegram_credentials( + &channel_arc, + secrets.as_ref(), + ) + .await + { + Ok(()) => { + tracing::debug!("Telegram bot token injected"); + } + Err(e) => { + tracing::error!( + "Failed to inject Telegram credentials: {}", + e + ); + tracing::warn!( + "Telegram channel may not be able to send responses" + ); + } + } + + // Register webhook if tunnel URL is configured + // Use the SAME webhook_secret that the router expects (from secrets store) + if let Some(ref tunnel_url) = config.tunnel.public_url { + match register_telegram_webhook( + &channel_arc, + tunnel_url, + webhook_secret_for_telegram.as_deref(), + ) + .await + { + Ok(()) => { + tracing::info!( + "Telegram webhook registered at {}/webhook/telegram", + tunnel_url + ); + } + Err(e) => { + tracing::error!( + "Failed to register Telegram webhook: {}", + e + ); + tracing::warn!( + "Telegram will fall back to polling mode" + ); + } + } + } + } else { + tracing::warn!( + "Telegram channel loaded but secrets store not available" + ); + tracing::warn!( + "Set SECRETS_MASTER_KEY to enable Telegram bot token injection" + ); + } + } + + // Wrap in SharedWasmChannel for ChannelManager + // Both the router and ChannelManager share the same underlying channel + channels.add(Box::new(SharedWasmChannel::new(channel_arc))); } + + // Start WASM channel webhook server if we have channels with webhooks + if has_webhook_channels && config.tunnel.public_url.is_some() { + let server = WasmChannelServer::new(wasm_router); + let addr = std::net::SocketAddr::from(([0, 0, 0, 0], 8080)); + match server.start(addr).await { + Ok(_handle) => { + tracing::info!( + "WASM channel webhook server started on {}", + addr + ); + } + Err(e) => { + tracing::error!( + "Failed to start WASM channel webhook server: {}", + e + ); + } + } + } + for (path, err) in &results.errors { tracing::warn!( "Failed to load WASM channel {}: {}", @@ -407,3 +558,67 @@ async fn main() -> anyhow::Result<()> { tracing::info!("Agent shutdown complete"); Ok(()) } + +/// Inject Telegram bot token into the channel's credentials. +/// +/// This allows the WASM channel to use `{TELEGRAM_BOT_TOKEN}` in HTTP URLs +/// without ever seeing the actual token value. Required for both webhook +/// and polling modes to send responses. +async fn inject_telegram_credentials( + channel: &Arc, + secrets: &dyn SecretsStore, +) -> anyhow::Result<()> { + tracing::info!("Injecting Telegram bot token into channel credentials"); + + // Get bot token from secrets + let decrypted = secrets + .get_decrypted("default", "telegram_bot_token") + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to get telegram_bot_token from secrets"); + anyhow::anyhow!("Failed to get Telegram bot token: {}", e) + })?; + + let bot_token = decrypted.expose(); + let token_len = bot_token.len(); + + // Inject the token into the channel's credentials for URL substitution + channel + .set_credential("TELEGRAM_BOT_TOKEN", bot_token.to_string()) + .await; + + // Verify injection + let creds = channel.get_credentials().await; + tracing::info!( + token_length = token_len, + has_token = creds.contains_key("TELEGRAM_BOT_TOKEN"), + credential_count = creds.len(), + "Telegram bot token injected successfully" + ); + + Ok(()) +} + +/// Register Telegram webhook for instant message delivery. +/// +/// Calls the Telegram setWebhook API. Assumes credentials have already been +/// injected via `inject_telegram_credentials` (gets token from channel). +async fn register_telegram_webhook( + channel: &Arc, + tunnel_url: &str, + webhook_secret: Option<&str>, +) -> anyhow::Result<()> { + // Get the bot token via the public getter + let credentials = channel.get_credentials().await; + let bot_token = credentials.get("TELEGRAM_BOT_TOKEN").ok_or_else(|| { + anyhow::anyhow!("Bot token not injected - call inject_telegram_credentials first") + })?; + + // Register the webhook with Telegram API + channel + .register_telegram_webhook(tunnel_url, bot_token, webhook_secret) + .await + .map_err(|e| anyhow::anyhow!("Webhook registration failed: {}", e))?; + + Ok(()) +} diff --git a/src/settings.rs b/src/settings.rs index 3846bf3e..c52f1095 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -17,11 +17,25 @@ pub struct Settings { #[serde(default)] pub setup_completed: bool, + /// Tunnel configuration for exposing the agent to the internet. + #[serde(default)] + pub tunnel: TunnelSettings, + /// Channel configuration. #[serde(default)] pub channels: ChannelSettings, } +/// Tunnel settings for public webhook endpoints. +/// +/// The tunnel URL is shared across all channels that need webhooks. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct TunnelSettings { + /// Public URL from tunnel provider (e.g., "https://abc123.ngrok.io"). + #[serde(default)] + pub public_url: Option, +} + /// Channel-specific settings. #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ChannelSettings { diff --git a/src/setup/channels.rs b/src/setup/channels.rs index 095e09c5..b91d582d 100644 --- a/src/setup/channels.rs +++ b/src/setup/channels.rs @@ -13,8 +13,9 @@ use secrecy::{ExposeSecret, SecretString}; use serde::Deserialize; use crate::secrets::{CreateSecretParams, PostgresSecretsStore, SecretsCrypto, SecretsStore}; +use crate::settings::Settings; use crate::setup::prompts::{ - confirm, optional_input, print_error, print_info, print_success, secret_input, + confirm, input, optional_input, print_error, print_info, print_success, secret_input, }; /// Context for saving secrets during setup. @@ -58,6 +59,7 @@ impl SecretsContext { pub struct TelegramSetupResult { pub enabled: bool, pub bot_username: Option, + pub webhook_secret: Option, } /// Telegram Bot API response for getMe. @@ -94,9 +96,12 @@ pub async fn setup_telegram(secrets: &SecretsContext) -> Result Result { @@ -131,12 +140,107 @@ pub async fn setup_telegram(secrets: &SecretsContext) -> Result Result, String> { + // Check if already configured + let settings = Settings::load(); + if let Some(ref url) = settings.tunnel.public_url { + print_info(&format!("Existing tunnel configured: {}", url)); + if !confirm("Change tunnel configuration?", false).map_err(|e| e.to_string())? { + return Ok(Some(url.clone())); + } + } + + println!(); + print_info("Tunnel Configuration (for webhook endpoints):"); + print_info("A tunnel exposes your local agent to the internet, enabling:"); + print_info(" - Instant Telegram message delivery (instead of polling)"); + print_info(" - Future: Slack, Discord, GitHub webhooks"); + print_info(""); + print_info("Supported tunnel providers:"); + print_info(" - ngrok: ngrok http 8080"); + print_info(" - Cloudflare: cloudflared tunnel --url http://localhost:8080"); + print_info(" - localtunnel: lt --port 8080"); + print_info(""); + print_info("Security note: Webhook endpoints don't use tunnel-level auth."); + print_info("Security comes from provider-specific secrets (e.g., Telegram webhook secret)."); + println!(); + + if !confirm("Configure a tunnel?", false).map_err(|e| e.to_string())? { + return Ok(None); + } + + let tunnel_url = + input("Tunnel URL (e.g., https://abc123.ngrok.io)").map_err(|e| e.to_string())?; + + // Validate URL format + if !tunnel_url.starts_with("https://") { + print_error("URL must start with https:// (webhooks require HTTPS)"); + return Err("Invalid tunnel URL: must use HTTPS".to_string()); + } + + // Remove trailing slash if present + let tunnel_url = tunnel_url.trim_end_matches('/').to_string(); + + // Save to settings + let mut settings = Settings::load(); + settings.tunnel.public_url = Some(tunnel_url.clone()); + settings + .save() + .map_err(|e| format!("Failed to save settings: {}", e))?; + + print_success(&format!("Tunnel URL saved: {}", tunnel_url)); + print_info(""); + print_info("Make sure your tunnel is running before starting the agent."); + print_info("You can also set TUNNEL_URL environment variable to override."); + + Ok(Some(tunnel_url)) +} + +/// Set up Telegram webhook secret for signature validation. +/// +/// Returns the webhook secret if configured. +async fn setup_telegram_webhook_secret(secrets: &SecretsContext) -> Result, String> { + // Check if tunnel is configured + let settings = Settings::load(); + if settings.tunnel.public_url.is_none() { + print_info(""); + print_info("No tunnel configured. Telegram will use polling mode (30s+ delay)."); + print_info("Run setup again to configure a tunnel for instant delivery."); + return Ok(None); + } + + println!(); + print_info("Telegram Webhook Security:"); + print_info("A webhook secret adds an extra layer of security by validating"); + print_info("that requests actually come from Telegram's servers."); + + if !confirm("Generate a webhook secret?", true).map_err(|e| e.to_string())? { + return Ok(None); + } + + let secret = generate_webhook_secret(); + secrets + .save_secret( + "telegram_webhook_secret", + &SecretString::from(secret.clone()), + ) + .await?; + print_success("Webhook secret generated and saved"); + + Ok(Some(secret)) +} + /// Validate a Telegram bot token by calling the getMe API. /// /// Returns the bot's username if valid. diff --git a/src/setup/mod.rs b/src/setup/mod.rs index 3beb13c0..c082c56c 100644 --- a/src/setup/mod.rs +++ b/src/setup/mod.rs @@ -18,6 +18,8 @@ mod channels; mod prompts; mod wizard; -pub use channels::{SecretsContext, setup_http, setup_telegram, validate_telegram_token}; +pub use channels::{ + SecretsContext, setup_http, setup_telegram, setup_tunnel, validate_telegram_token, +}; pub use prompts::{confirm, print_header, print_step, secret_input, select_many, select_one}; pub use wizard::{SetupConfig, SetupWizard}; diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index cdc65175..bfa8a409 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -14,7 +14,7 @@ use tokio_postgres::NoTls; use crate::llm::{SessionConfig, SessionManager}; use crate::secrets::SecretsCrypto; use crate::settings::Settings; -use crate::setup::channels::{SecretsContext, setup_http, setup_telegram}; +use crate::setup::channels::{SecretsContext, setup_http, setup_telegram, setup_tunnel}; use crate::setup::prompts::{ input, print_header, print_info, print_step, print_success, select_many, select_one, }; @@ -316,6 +316,20 @@ impl SetupWizard { /// Step 3: Channel configuration. async fn step_channels(&mut self) -> Result<(), SetupError> { + // First, configure tunnel (shared across all channels that need webhooks) + match setup_tunnel() { + Ok(Some(url)) => { + self.settings.tunnel.public_url = Some(url); + } + Ok(None) => { + self.settings.tunnel.public_url = None; + } + Err(e) => { + print_info(&format!("Tunnel setup skipped: {}", e)); + } + } + println!(); + let options = [ ("CLI/TUI (always enabled)", true), ("HTTP webhook", self.settings.channels.http_enabled), @@ -381,6 +395,10 @@ impl SetupWizard { println!(" Model: {}", model); } + if let Some(ref tunnel_url) = self.settings.tunnel.public_url { + println!(" Tunnel: {}", tunnel_url); + } + println!(" Channels:"); println!(" - CLI/TUI: enabled"); @@ -390,7 +408,12 @@ impl SetupWizard { } if self.settings.channels.telegram_enabled { - println!(" - Telegram: enabled"); + let mode = if self.settings.tunnel.public_url.is_some() { + "webhook" + } else { + "polling" + }; + println!(" - Telegram: enabled ({})", mode); } println!(); diff --git a/src/tools/wasm/limits.rs b/src/tools/wasm/limits.rs index 679ce88d..237247e9 100644 --- a/src/tools/wasm/limits.rs +++ b/src/tools/wasm/limits.rs @@ -79,13 +79,16 @@ pub struct WasmResourceLimiter { impl WasmResourceLimiter { /// Create a new limiter with the given memory limit. + /// + /// Note: max_instances is set to 10 to accommodate WASM Component Model + /// which creates multiple internal instances (main component + WASI adapters). pub fn new(memory_limit: u64) -> Self { Self { memory_limit, memory_used: 0, max_tables: 10, tables_created: 0, - max_instances: 1, + max_instances: 10, // Component model needs multiple instances for WASI instances_created: 0, } } @@ -157,7 +160,7 @@ impl ResourceLimiter for WasmResourceLimiter { } fn memories(&self) -> usize { - // Allow one memory per instance + // Allow multiple memories for component model with WASI self.max_instances as usize } }