diff --git a/channels-src/discord/src/lib.rs b/channels-src/discord/src/lib.rs index beb856cd889..d4edc44bdc1 100644 --- a/channels-src/discord/src/lib.rs +++ b/channels-src/discord/src/lib.rs @@ -312,6 +312,10 @@ impl Guest for DiscordChannel { fn on_status(_update: StatusUpdate) {} + fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> { + Ok(()) // Not yet implemented + } + fn on_shutdown() { channel_host::log( channel_host::LogLevel::Info, diff --git a/channels-src/slack/src/lib.rs b/channels-src/slack/src/lib.rs index 75d68e68368..11675ada3b9 100644 --- a/channels-src/slack/src/lib.rs +++ b/channels-src/slack/src/lib.rs @@ -306,6 +306,10 @@ impl Guest for SlackChannel { fn on_status(_update: StatusUpdate) {} + fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> { + Ok(()) // Not yet implemented + } + fn on_shutdown() { channel_host::log(channel_host::LogLevel::Info, "Slack channel shutting down"); } diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs index c1f8539a43e..077d004ab2d 100644 --- a/channels-src/telegram/src/lib.rs +++ b/channels-src/telegram/src/lib.rs @@ -30,7 +30,7 @@ use serde::{Deserialize, Serialize}; // Re-export generated types use exports::near::agent::channel::{ - AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest, + AgentResponse, Attachment, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest, OutgoingHttpResponse, PollConfig, StatusType, StatusUpdate, }; use near::agent::channel_host::{self, EmittedMessage}; @@ -236,6 +236,10 @@ struct TelegramConfig { /// Telegram will include this in the X-Telegram-Bot-Api-Secret-Token header. #[serde(default)] webhook_secret: Option, + + /// When true, use polling mode even if tunnel_url is available. + #[serde(default)] + polling_enabled: bool, } // ============================================================================ @@ -363,9 +367,8 @@ impl Guest for TelegramChannel { &config.respond_to_all_group_messages.to_string(), ); - // Mode is determined by whether the host injected a tunnel_url - // If tunnel is configured, use webhooks. Otherwise, use polling. - let webhook_mode = config.tunnel_url.is_some(); + // Mode: use polling if explicitly enabled, otherwise use webhooks when tunnel available. + let webhook_mode = config.tunnel_url.is_some() && !config.polling_enabled; if webhook_mode { channel_host::log( @@ -484,7 +487,7 @@ impl Guest for TelegramChannel { ); let headers_json = serde_json::json!({}).to_string(); - let primary_url = get_updates_url(offset, 30); + let primary_url = get_updates_url(offset, 25); // 35s HTTP timeout outlives Telegram's 30s server-side long-poll. // If the TCP connection drops, retry once immediately with a short poll @@ -588,6 +591,20 @@ impl Guest for TelegramChannel { let metadata: TelegramMessageMetadata = serde_json::from_str(&response.metadata_json) .map_err(|e| format!("Failed to parse metadata: {}", e))?; + // Send attachments first (photos/documents) + for attachment in &response.attachments { + send_attachment( + metadata.chat_id, + attachment, + Some(metadata.message_id), + )?; + } + + // Send text content (skip if empty and we already sent attachments) + if response.content.is_empty() && !response.attachments.is_empty() { + return Ok(()); + } + // Try sending with Markdown first; fall back to plain text if Telegram // can't parse the entities (e.g. model leaked with underscores). let result = send_message( @@ -634,6 +651,33 @@ impl Guest for TelegramChannel { } } + fn on_broadcast(user_id: String, response: AgentResponse) -> Result<(), String> { + let chat_id: i64 = user_id + .parse() + .map_err(|e| format!("Invalid chat_id '{}': {}", user_id, e))?; + + // Send attachments first + for attachment in &response.attachments { + send_attachment(chat_id, attachment, None)?; + } + + // Send text content (skip if empty and we already sent attachments) + if response.content.is_empty() && !response.attachments.is_empty() { + return Ok(()); + } + + // Try Markdown, fall back to plain text + match send_message(chat_id, &response.content, None, Some("Markdown")) { + Ok(_) => Ok(()), + Err(SendError::ParseEntities(_)) => { + send_message(chat_id, &response.content, None, None) + .map(|_| ()) + .map_err(|e| format!("Plain-text retry also failed: {}", e)) + } + Err(e) => Err(e.to_string()), + } + } + fn on_status(update: StatusUpdate) { let action = match classify_status_update(&update) { Some(action) => action, @@ -817,6 +861,200 @@ fn send_message( } } +// ============================================================================ +// Attachment Sending (Photo / Document) +// ============================================================================ + +/// Maximum photo size for Telegram sendPhoto (10 MB). +const MAX_PHOTO_SIZE: usize = 10 * 1024 * 1024; + +/// Write a multipart/form-data text field. +fn write_multipart_field(body: &mut Vec, boundary: &str, name: &str, value: &str) { + body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes()); + body.extend_from_slice( + format!("Content-Disposition: form-data; name=\"{}\"\r\n\r\n", name).as_bytes(), + ); + body.extend_from_slice(value.as_bytes()); + body.extend_from_slice(b"\r\n"); +} + +/// Write a multipart/form-data file field. +fn write_multipart_file( + body: &mut Vec, + boundary: &str, + field: &str, + filename: &str, + content_type: &str, + data: &[u8], +) { + // Sanitize filename: strip quotes, newlines, and non-ASCII to prevent header injection + let safe_filename: String = filename + .chars() + .filter(|c| *c != '"' && *c != '\r' && *c != '\n' && *c != '\\' && c.is_ascii()) + .collect(); + let safe_filename = if safe_filename.is_empty() { + "file".to_string() + } else { + safe_filename + }; + body.extend_from_slice(format!("--{}\r\n", boundary).as_bytes()); + body.extend_from_slice( + format!( + "Content-Disposition: form-data; name=\"{}\"; filename=\"{}\"\r\n", + field, safe_filename + ) + .as_bytes(), + ); + body.extend_from_slice(format!("Content-Type: {}\r\n\r\n", content_type).as_bytes()); + body.extend_from_slice(data); + body.extend_from_slice(b"\r\n"); +} + +/// Send a photo via the Telegram Bot API (multipart upload). +/// +/// Falls back to `send_document()` if the photo exceeds 10 MB. +fn send_photo( + chat_id: i64, + filename: &str, + mime_type: &str, + data: &[u8], + reply_to_message_id: Option, +) -> Result<(), String> { + if data.len() > MAX_PHOTO_SIZE { + channel_host::log( + channel_host::LogLevel::Info, + &format!( + "Photo {} exceeds 10MB ({}), sending as document", + filename, + data.len() + ), + ); + return send_document(chat_id, filename, mime_type, data, reply_to_message_id); + } + + let boundary = format!("ironclaw-{}", channel_host::now_millis()); + let mut body = Vec::new(); + + write_multipart_field(&mut body, &boundary, "chat_id", &chat_id.to_string()); + if let Some(msg_id) = reply_to_message_id { + write_multipart_field(&mut body, &boundary, "reply_to_message_id", &msg_id.to_string()); + } + write_multipart_file(&mut body, &boundary, "photo", filename, mime_type, data); + body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes()); + + let headers = serde_json::json!({ + "Content-Type": format!("multipart/form-data; boundary={}", boundary) + }); + + let result = channel_host::http_request( + "POST", + "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendPhoto", + &headers.to_string(), + Some(&body), + Some(60_000), // 60s timeout for file uploads + ); + + match result { + Ok(resp) if resp.status == 200 => { + channel_host::log( + channel_host::LogLevel::Debug, + &format!("Sent photo '{}' to chat {}", filename, chat_id), + ); + Ok(()) + } + Ok(resp) => { + let body_str = String::from_utf8_lossy(&resp.body); + Err(format!( + "sendPhoto failed (HTTP {}): {}", + resp.status, body_str + )) + } + Err(e) => Err(format!("sendPhoto HTTP request failed: {}", e)), + } +} + +/// Send a document via the Telegram Bot API (multipart upload). +fn send_document( + chat_id: i64, + filename: &str, + mime_type: &str, + data: &[u8], + reply_to_message_id: Option, +) -> Result<(), String> { + let boundary = format!("ironclaw-{}", channel_host::now_millis()); + let mut body = Vec::new(); + + write_multipart_field(&mut body, &boundary, "chat_id", &chat_id.to_string()); + if let Some(msg_id) = reply_to_message_id { + write_multipart_field(&mut body, &boundary, "reply_to_message_id", &msg_id.to_string()); + } + write_multipart_file(&mut body, &boundary, "document", filename, mime_type, data); + body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes()); + + let headers = serde_json::json!({ + "Content-Type": format!("multipart/form-data; boundary={}", boundary) + }); + + let result = channel_host::http_request( + "POST", + "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/sendDocument", + &headers.to_string(), + Some(&body), + Some(60_000), // 60s timeout for file uploads + ); + + match result { + Ok(resp) if resp.status == 200 => { + channel_host::log( + channel_host::LogLevel::Debug, + &format!("Sent document '{}' to chat {}", filename, chat_id), + ); + Ok(()) + } + Ok(resp) => { + let body_str = String::from_utf8_lossy(&resp.body); + Err(format!( + "sendDocument failed (HTTP {}): {}", + resp.status, body_str + )) + } + Err(e) => Err(format!("sendDocument HTTP request failed: {}", e)), + } +} + +/// Image MIME types that Telegram's sendPhoto API supports. +const PHOTO_MIME_TYPES: &[&str] = &[ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", +]; + +/// Send a single attachment, choosing sendPhoto or sendDocument based on MIME type. +fn send_attachment( + chat_id: i64, + attachment: &Attachment, + reply_to_message_id: Option, +) -> Result<(), String> { + if PHOTO_MIME_TYPES.contains(&attachment.mime_type.as_str()) { + send_photo( + chat_id, + &attachment.filename, + &attachment.mime_type, + &attachment.data, + reply_to_message_id, + ) + } else { + send_document( + chat_id, + &attachment.filename, + &attachment.mime_type, + &attachment.data, + reply_to_message_id, + ) + } +} + // ============================================================================ // Webhook Management // ============================================================================ diff --git a/channels-src/whatsapp/src/lib.rs b/channels-src/whatsapp/src/lib.rs index c60fea55c83..80027dd2cef 100644 --- a/channels-src/whatsapp/src/lib.rs +++ b/channels-src/whatsapp/src/lib.rs @@ -476,6 +476,10 @@ impl Guest for WhatsAppChannel { fn on_status(_update: StatusUpdate) {} + fn on_broadcast(_user_id: String, _response: AgentResponse) -> Result<(), String> { + Ok(()) // Not yet implemented + } + fn on_shutdown() { channel_host::log( channel_host::LogLevel::Info, diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index a29da1e773f..0d87f1ed457 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -1191,12 +1191,14 @@ impl WasmChannel { content: &str, thread_id: Option<&str>, metadata_json: &str, + attachments: &[String], ) -> Result<(), WasmChannelError> { tracing::info!( channel = %self.name, message_id = %message_id, content_len = content.len(), thread_id = ?thread_id, + attachment_count = attachments.len(), "call_on_respond invoked" ); @@ -1231,12 +1233,21 @@ impl WasmChannel { let content = content.to_string(); let thread_id = thread_id.map(|s| s.to_string()); let metadata_json = metadata_json.to_string(); + let attachments = attachments.to_vec(); // 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 || { + // Read attachment files from disk before entering WASM + let wit_attachments = read_attachments(&attachments).map_err(|e| { + WasmChannelError::CallbackFailed { + name: prepared.name.clone(), + reason: e, + } + })?; + tracing::info!("Creating WASM store for on_respond"); let mut store = Self::create_store( &runtime, @@ -1255,6 +1266,7 @@ impl WasmChannel { content: content.clone(), thread_id, metadata_json, + attachments: wit_attachments, }; // Truncate at char boundary for logging (avoid panic on multi-byte UTF-8) @@ -1318,6 +1330,120 @@ impl WasmChannel { } } + /// Execute the on_broadcast callback. + /// + /// Called to send a proactive message to a user without a prior incoming message. + pub async fn call_on_broadcast( + &self, + user_id: &str, + content: &str, + thread_id: Option<&str>, + attachments: &[String], + ) -> Result<(), WasmChannelError> { + tracing::info!( + channel = %self.name, + user_id = %user_id, + content_len = content.len(), + attachment_count = attachments.len(), + "call_on_broadcast invoked" + ); + + // If no WASM bytes, do nothing (for testing) + if self.prepared.component().is_none() { + tracing::debug!( + channel = %self.name, + "WASM channel on_broadcast called (no WASM module)" + ); + return Ok(()); + } + + let runtime = Arc::clone(&self.runtime); + let prepared = Arc::clone(&self.prepared); + let capabilities = self.capabilities.clone(); + let timeout = self.runtime.config().callback_timeout; + let channel_name = self.name.clone(); + let credentials = self.get_credentials().await; + let pairing_store = self.pairing_store.clone(); + + let user_id = user_id.to_string(); + let content = content.to_string(); + let thread_id = thread_id.map(|s| s.to_string()); + let attachments = attachments.to_vec(); + + let result = tokio::time::timeout(timeout, async move { + tokio::task::spawn_blocking(move || { + // Read attachment files from disk + let wit_attachments = read_attachments(&attachments).map_err(|e| { + WasmChannelError::CallbackFailed { + name: prepared.name.clone(), + reason: e, + } + })?; + + let mut store = Self::create_store( + &runtime, + &prepared, + &capabilities, + credentials, + pairing_store, + )?; + + let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; + + let wit_response = wit_channel::AgentResponse { + message_id: String::new(), + content: content.clone(), + thread_id, + metadata_json: String::new(), + attachments: wit_attachments, + }; + + let channel_iface = instance.near_agent_channel(); + let wasm_result = channel_iface + .call_on_broadcast(&mut store, &user_id, &wit_response) + .map_err(|e| { + tracing::error!(error = %e, "WASM on_broadcast call failed"); + Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel) + })?; + + if let Err(ref err_msg) = wasm_result { + tracing::error!(error = %err_msg, "WASM on_broadcast 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_broadcast WASM execution completed successfully"); + Ok(((), host_state)) + }) + .await + .map_err(|e| WasmChannelError::ExecutionPanicked { + name: channel_name.clone(), + reason: e.to_string(), + })? + }) + .await; + + let channel_name = self.name.clone(); + match result { + Ok(Ok(((), _host_state))) => { + tracing::debug!( + channel = %channel_name, + "WASM channel on_broadcast completed" + ); + Ok(()) + } + Ok(Err(e)) => Err(e), + Err(_) => Err(WasmChannelError::Timeout { + name: channel_name, + callback: "on_broadcast".to_string(), + }), + } + } + /// Execute the on_status callback. /// /// Called to notify the WASM channel of agent status changes (e.g., typing). @@ -1590,7 +1716,7 @@ impl WasmChannel { let metadata_json = serde_json::to_string(metadata).unwrap_or_default(); if let Err(e) = self - .call_on_respond(uuid::Uuid::new_v4(), &prompt, None, &metadata_json) + .call_on_respond(uuid::Uuid::new_v4(), &prompt, None, &metadata_json, &[]) .await { tracing::warn!( @@ -2090,6 +2216,7 @@ impl Channel for WasmChannel { &response.content, response.thread_id.as_deref(), &metadata_json, + &response.attachments, ) .await .map_err(|e| ChannelError::SendFailed { @@ -2100,26 +2227,26 @@ impl Channel for WasmChannel { Ok(()) } + async fn send_status( + &self, + status: StatusUpdate, + metadata: &serde_json::Value, + ) -> Result<(), ChannelError> { + // Delegate to the typing indicator implementation + self.handle_status_update(status, metadata).await + } + async fn broadcast( &self, - _user_id: &str, + user_id: &str, response: OutgoingResponse, ) -> Result<(), ChannelError> { - let metadata_json = self - .last_broadcast_metadata - .read() - .await - .clone() - .ok_or_else(|| ChannelError::SendFailed { - name: self.name.clone(), - reason: "No messages received yet — no chat_id available for broadcast".into(), - })?; - - self.call_on_respond( - uuid::Uuid::new_v4(), + self.cancel_typing_task().await; + self.call_on_broadcast( + user_id, &response.content, response.thread_id.as_deref(), - &metadata_json, + &response.attachments, ) .await .map_err(|e| ChannelError::SendFailed { @@ -2128,15 +2255,6 @@ impl Channel for WasmChannel { }) } - async fn send_status( - &self, - status: StatusUpdate, - metadata: &serde_json::Value, - ) -> Result<(), ChannelError> { - // Delegate to the typing indicator implementation - self.handle_status_update(status, metadata).await - } - async fn health_check(&self) -> Result<(), ChannelError> { // Check if we have an active message sender if self.message_tx.read().await.is_some() { @@ -2491,6 +2609,61 @@ impl HttpResponse { } } +// ============================================================================ +// Attachment Helpers +// ============================================================================ + +/// Maximum total attachment size (50 MB). +const MAX_TOTAL_ATTACHMENT_BYTES: u64 = 50 * 1024 * 1024; + +/// Detect MIME type from file extension using the `mime_guess` crate. +fn mime_from_extension(path: &str) -> String { + mime_guess::from_path(path) + .first_or_octet_stream() + .to_string() +} + +/// Read attachment files from disk and build WIT attachment records. +/// +/// Validates total size against `MAX_TOTAL_ATTACHMENT_BYTES`. +fn read_attachments(paths: &[String]) -> Result, String> { + if paths.is_empty() { + return Ok(Vec::new()); + } + + let mut attachments = Vec::with_capacity(paths.len()); + let mut total_bytes: u64 = 0; + + for path in paths { + let data = std::fs::read(path) + .map_err(|e| format!("Failed to read attachment '{}': {}", path, e))?; + + total_bytes += data.len() as u64; + if total_bytes > MAX_TOTAL_ATTACHMENT_BYTES { + return Err(format!( + "Total attachment size exceeds {} MB limit", + MAX_TOTAL_ATTACHMENT_BYTES / (1024 * 1024) + )); + } + + let filename = std::path::Path::new(path) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("file") + .to_string(); + + let mime_type = mime_from_extension(path); + + attachments.push(wit_channel::Attachment { + filename, + mime_type, + data, + }); + } + + Ok(attachments) +} + #[cfg(test)] mod tests { use std::sync::Arc; @@ -3561,4 +3734,26 @@ mod tests { // 404 because "000" is not a valid bot token assert_eq!(result, 404); } + + #[test] + fn test_mime_from_extension() { + use super::mime_from_extension; + assert_eq!(mime_from_extension("screenshot.png"), "image/png"); + assert_eq!(mime_from_extension("photo.JPG"), "image/jpeg"); + assert_eq!(mime_from_extension("photo.jpeg"), "image/jpeg"); + assert_eq!(mime_from_extension("animation.gif"), "image/gif"); + assert_eq!(mime_from_extension("doc.pdf"), "application/pdf"); + assert_eq!(mime_from_extension("video.mp4"), "video/mp4"); + assert_eq!(mime_from_extension("data.csv"), "text/csv"); + // .xyz maps to chemical/x-xyz in mime_guess; use a truly unknown extension + assert_eq!( + mime_from_extension("unknown.qqqzzz"), + "application/octet-stream" + ); + assert_eq!(mime_from_extension("noext"), "application/octet-stream"); + assert_eq!( + mime_from_extension("/home/user/.ironclaw/screenshot.png"), + "image/png" + ); + } } diff --git a/src/main.rs b/src/main.rs index ed5d9deed7b..fda762846cf 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1076,6 +1076,9 @@ fn check_onboard_needed() -> Option<&'static str> { /// /// Looks for secrets matching the pattern `{channel_name}_*` and injects them /// as credential placeholders (e.g., `telegram_bot_token` -> `{TELEGRAM_BOT_TOKEN}`). +/// +/// Falls back to environment variables with the uppercase name if not found +/// in the secrets store (e.g., `TELEGRAM_BOT_TOKEN`). async fn inject_channel_credentials( channel: &Arc, secrets: &dyn SecretsStore, @@ -1088,6 +1091,7 @@ async fn inject_channel_credentials( let prefix = format!("{}_", channel_name); let mut count = 0; + let mut injected_placeholders = std::collections::HashSet::new(); for secret_meta in all_secrets { if !secret_meta.name.starts_with(&prefix) { @@ -1118,8 +1122,33 @@ async fn inject_channel_credentials( channel .set_credential(&placeholder, decrypted.expose().to_string()) .await; + injected_placeholders.insert(placeholder); count += 1; } + // Fall back to environment variables for required secrets not found in the store. + // This allows channels to work when configured via env vars (e.g., TELEGRAM_BOT_TOKEN) + // without requiring the setup wizard to have run. + let caps = channel.capabilities(); + if let Some(ref http_cap) = caps.tool_capabilities.http { + for cred_mapping in http_cap.credentials.values() { + let placeholder = cred_mapping.secret_name.to_uppercase(); + if injected_placeholders.contains(&placeholder) { + continue; + } + if let Ok(env_value) = std::env::var(&placeholder) + && !env_value.is_empty() + { + tracing::debug!( + channel = %channel_name, + placeholder = %placeholder, + "Injecting credential from environment variable" + ); + channel.set_credential(&placeholder, env_value).await; + count += 1; + } + } + } + Ok(count) } diff --git a/src/tools/builtin/http.rs b/src/tools/builtin/http.rs index 88a19fec087..a98572c7f94 100644 --- a/src/tools/builtin/http.rs +++ b/src/tools/builtin/http.rs @@ -18,13 +18,19 @@ use crate::tools::wasm::{InjectedCredentials, SharedCredentialRegistry, inject_c #[cfg(feature = "html-to-markdown")] use crate::tools::builtin::convert_html_to_markdown; -/// Maximum response body size (5 MB). +/// Maximum response body size for text responses (5 MB). /// /// 5 MB is large enough for typical JSON API responses and moderate HTML pages, /// but small enough to prevent OOM from malicious or runaway servers. The WASM /// HTTP wrapper uses the same limit for consistency. const MAX_RESPONSE_SIZE: usize = 5 * 1024 * 1024; +/// Maximum response body size when saving to disk via `save_to` (50 MB). +/// +/// Larger limit for file downloads since the body is written to disk, not held +/// in memory for LLM context. Matches the WASM attachment size cap. +const MAX_SAVE_TO_SIZE: usize = 50 * 1024 * 1024; + /// Tool for making HTTP requests. pub struct HttpTool { client: Client, @@ -37,7 +43,46 @@ impl HttpTool { pub fn new() -> Self { let client = Client::builder() .timeout(Duration::from_secs(30)) - .redirect(reqwest::redirect::Policy::none()) + .redirect(reqwest::redirect::Policy::custom(|attempt| { + if attempt.previous().len() >= 10 { + return attempt.error("too many redirects"); + } + // Reject scheme downgrades (https → http) + if attempt.url().scheme() != "https" { + return attempt.error("redirect to non-HTTPS URL is not allowed"); + } + // Extract host info before consuming attempt + let host_owned = attempt.url().host_str().map(|h| h.to_owned()); + let port = attempt.url().port_or_known_default().unwrap_or(443); + + if let Some(host) = host_owned { + let host_lower = host.to_lowercase(); + if host_lower == "localhost" || host_lower.ends_with(".localhost") { + return attempt.error("redirect to localhost is not allowed"); + } + if let Ok(ip) = host.parse::() + && is_disallowed_ip(&ip) + { + return attempt + .error("redirect to private/local IP is not allowed"); + } + // Resolve hostname and check all IPs + let socket_addr = format!("{}:{}", host, port); + if let Ok(addrs) = socket_addr.to_socket_addrs() { + for addr in addrs { + if is_disallowed_ip(&addr.ip()) { + let msg = format!( + "redirect target '{}' resolves to disallowed IP {}", + host, + addr.ip() + ); + return attempt.error(msg); + } + } + } + } + attempt.follow() + })) .build() .expect("Failed to create HTTP client"); @@ -60,6 +105,29 @@ impl HttpTool { } } +/// Validate and resolve a `save_to` path, ensuring it stays under `/tmp/`. +/// +/// Uses `path_utils::validate_path` with `/tmp` as the base directory to catch +/// traversal attacks like `/tmp/../../etc/passwd` and symlink escapes. +/// Creates parent directories as needed before validation. +fn validate_save_to_path(save_to: &str) -> Result { + // Quick prefix check before doing any fs work + if !save_to.starts_with("/tmp/") { + return Err(ToolError::InvalidParameters( + "save_to path must be under /tmp/".to_string(), + )); + } + let save_path = std::path::Path::new(save_to); + if let Some(parent) = save_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| { + ToolError::ExecutionFailed(format!("failed to create directory: {}", e)) + })?; + } + // Delegate to the shared path validator with /tmp as sandbox base + let tmp_base = std::path::Path::new("/tmp"); + crate::tools::builtin::path_utils::validate_path(save_to, Some(tmp_base)) +} + fn validate_url(url: &str) -> Result { let parsed = reqwest::Url::parse(url) .map_err(|e| ToolError::InvalidParameters(format!("invalid URL: {}", e)))?; @@ -201,7 +269,9 @@ impl Tool for HttpTool { } fn description(&self) -> &str { - "Make HTTP requests to external APIs. Supports GET, POST, PUT, DELETE methods." + "Make HTTP requests to external APIs. Supports GET, POST, PUT, DELETE methods. \ + Use save_to to download binary files (images, PDFs, etc.) to a local path, \ + e.g. {\"method\":\"GET\",\"url\":\"https://picsum.photos/800/600\",\"save_to\":\"/tmp/photo.jpg\"}." } fn parameters_schema(&self) -> serde_json::Value { @@ -236,6 +306,10 @@ impl Tool for HttpTool { "timeout_secs": { "type": "integer", "description": "Request timeout in seconds (default: 30)" + }, + "save_to": { + "type": "string", + "description": "Save response body as raw bytes to this file path instead of returning it. Use for binary downloads (images, PDFs, etc.). The path must be under /tmp/." } }, "required": ["method", "url"] @@ -354,13 +428,8 @@ impl Tool for HttpTool { let status = response.status().as_u16(); - // Block redirects: the server tried to send us elsewhere (potential SSRF) - if (300..400).contains(&status) { - return Err(ToolError::NotAuthorized(format!( - "request returned redirect (HTTP {}), which is blocked to prevent SSRF", - status - ))); - } + // Redirects are followed automatically (up to 10 hops). + // If we still see a 3xx here, the chain was too long. let headers: HashMap = response .headers() @@ -368,22 +437,30 @@ impl Tool for HttpTool { .filter_map(|(k, v)| v.to_str().ok().map(|v| (k.to_string(), v.to_string()))) .collect(); + // Use a larger size limit when saving to disk (file downloads) + let saving_to_disk = params.get("save_to").is_some(); + let max_size = if saving_to_disk { + MAX_SAVE_TO_SIZE + } else { + MAX_RESPONSE_SIZE + }; + // Pre-check Content-Length header to reject obviously oversized responses // before downloading anything, preventing OOM from malicious servers. if let Some(content_length) = response.headers().get(reqwest::header::CONTENT_LENGTH) && let Ok(s) = content_length.to_str() && let Ok(len) = s.parse::() - && len > MAX_RESPONSE_SIZE + && len > max_size { tracing::warn!( url = %parsed_url, content_length = len, - max = MAX_RESPONSE_SIZE, + max = max_size, "Rejected HTTP response: Content-Length exceeds limit" ); return Err(ToolError::ExecutionFailed(format!( "Response Content-Length ({} bytes) exceeds maximum allowed size ({} bytes)", - len, MAX_RESPONSE_SIZE + len, max_size ))); } @@ -395,16 +472,39 @@ impl Tool for HttpTool { let chunk = chunk.map_err(|e| { ToolError::ExternalService(format!("failed to read response body: {}", e)) })?; - if body.len() + chunk.len() > MAX_RESPONSE_SIZE { + if body.len() + chunk.len() > max_size { return Err(ToolError::ExecutionFailed(format!( "Response body exceeds maximum allowed size ({} bytes)", - MAX_RESPONSE_SIZE + max_size ))); } body.extend_from_slice(&chunk); } let body_bytes = bytes::Bytes::from(body); + // If save_to is specified, write raw bytes to file and return metadata. + if let Some(save_to) = params.get("save_to").and_then(|v| v.as_str()) { + let save_to_owned = save_to.to_string(); + let bytes_clone = body_bytes.clone(); + tokio::task::spawn_blocking(move || { + let canonical = validate_save_to_path(&save_to_owned)?; + std::fs::write(&canonical, &bytes_clone).map_err(|e| { + ToolError::ExecutionFailed(format!("failed to write file: {}", e)) + })?; + Ok::<_, ToolError>(canonical) + }) + .await + .map_err(|e| ToolError::ExecutionFailed(format!("spawn_blocking failed: {}", e)))? + .map_err(|e: ToolError| e)?; + let result = serde_json::json!({ + "status": status, + "saved_to": save_to, + "size_bytes": body_bytes.len(), + "headers": headers, + }); + return Ok(ToolOutput::success(result, start.elapsed())); + } + let body_text = String::from_utf8_lossy(&body_bytes).into_owned(); #[cfg(feature = "html-to-markdown")] @@ -803,4 +903,65 @@ mod tests { let params = serde_json::json!({"method": "GET"}); assert_eq!(extract_host_from_params(¶ms), None); } + + // ── save_to path validation tests ───────────────────────────────────── + + #[test] + fn test_save_to_rejects_path_outside_tmp() { + let err = validate_save_to_path("/etc/passwd").unwrap_err(); + assert!(err.to_string().contains("must be under /tmp/")); + } + + #[test] + fn test_save_to_rejects_home_dir() { + let err = validate_save_to_path("/home/user/file.txt").unwrap_err(); + assert!(err.to_string().contains("must be under /tmp/")); + } + + #[test] + fn test_save_to_rejects_traversal_via_dotdot() { + // Starts with /tmp/ but traverses out + let err = validate_save_to_path("/tmp/../../etc/passwd").unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("escapes") || msg.contains("resolves outside"), + "expected path traversal rejection, got: {}", + msg + ); + } + + #[test] + fn test_save_to_rejects_deep_traversal() { + let err = validate_save_to_path("/tmp/a/b/../../../../etc/shadow").unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("escapes") || msg.contains("resolves outside"), + "expected path traversal rejection, got: {}", + msg + ); + } + + #[test] + fn test_save_to_accepts_simple_tmp_path() { + let path = validate_save_to_path("/tmp/test_ironclaw_photo.jpg").unwrap(); + assert!(path.starts_with("/tmp")); + // Cleanup + let _ = std::fs::remove_file(&path); + } + + #[test] + fn test_save_to_accepts_nested_tmp_path() { + let path = + validate_save_to_path("/tmp/ironclaw_test_subdir/nested/file.png").unwrap(); + assert!(path.starts_with("/tmp")); + // Cleanup + let _ = std::fs::remove_dir_all("/tmp/ironclaw_test_subdir"); + } + + #[test] + fn test_save_to_rejects_bare_tmp() { + // /tmp without trailing slash should be rejected (not a file under /tmp/) + let err = validate_save_to_path("/tmp").unwrap_err(); + assert!(err.to_string().contains("must be under /tmp/")); + } } diff --git a/src/tools/builtin/message.rs b/src/tools/builtin/message.rs index e2690b02ad9..864de6a347e 100644 --- a/src/tools/builtin/message.rs +++ b/src/tools/builtin/message.rs @@ -62,6 +62,9 @@ impl Tool for MessageTool { fn description(&self) -> &str { "Send a message to a channel. If channel/target omitted, uses the current conversation's \ channel and sender/group. Use to proactively message users on any connected channel. \ + Supports file attachments: first download the file with the http tool using save_to \ + (e.g., http GET https://picsum.photos/800/600 save_to=/tmp/photo.jpg), then pass \ + the file path in the attachments array. Images are sent as photos on Telegram. \ - Signal: target accepts E.164 (+1234567890) or group ID \ - Telegram: target accepts username or chat ID \ - Slack: target accepts channel (#general) or user ID" @@ -135,17 +138,24 @@ impl Tool for MessageTool { let attachment_count = attachments.len(); - // Validate all attachment paths against the sandbox and verify existence + // Validate all attachment paths against the sandbox and verify existence. + // Allow paths under the base_dir (~/.ironclaw) or /tmp/. for path in &attachments { - let resolved = - crate::tools::builtin::path_utils::validate_path(path, Some(&self.base_dir)) - .map_err(|e| { - ToolError::ExecutionFailed(format!( - "Attachment path must be within {}: {}", - self.base_dir.display(), - e - )) - })?; + let tmp_dir = PathBuf::from("/tmp"); + let resolved = crate::tools::builtin::path_utils::validate_path( + path, + Some(&self.base_dir), + ) + .or_else(|_| { + crate::tools::builtin::path_utils::validate_path(path, Some(&tmp_dir)) + }) + .map_err(|e| { + ToolError::ExecutionFailed(format!( + "Attachment path must be within {} or /tmp/: {}", + self.base_dir.display(), + e + )) + })?; if !resolved.exists() { return Err(ToolError::ExecutionFailed(format!( "Attachment file not found: {}", @@ -324,22 +334,22 @@ mod tests { tool.set_context(Some("signal".to_string()), Some("+1234567890".to_string())) .await; - // Execute with attachments outside sandbox + // Execute with attachments outside both sandbox (~/.ironclaw) and /tmp/ let ctx = crate::context::JobContext::new("test", "test description"); let result = tool .execute( serde_json::json!({ "content": "hello", - "attachments": ["/tmp/file1.txt", "/tmp/file2.png"] + "attachments": ["/etc/passwd", "/var/log/syslog"] }), &ctx, ) .await; - // Should fail due to sandbox rejection (paths outside ~/.ironclaw/) + // Should fail due to sandbox rejection (paths outside allowed directories) assert!(result.is_err()); let err = result.unwrap_err().to_string(); - assert!(err.contains("sandbox") || err.contains("escapes")); + assert!(err.contains("sandbox") || err.contains("escapes") || err.contains("must be within")); } #[tokio::test] @@ -375,6 +385,42 @@ mod tests { assert!(err.contains("channel") || err.contains("Channel")); } + #[tokio::test] + async fn message_tool_with_attachments_in_tmp_no_channel() { + use std::fs; + + let tool = MessageTool::new(Arc::new(ChannelManager::new())); + tool.set_context(Some("telegram".to_string()), Some("12345".to_string())) + .await; + + // Create temp files under /tmp (allowed as secondary attachment dir) + let temp_dir = tempfile::tempdir_in("/tmp").unwrap(); + let file1 = temp_dir.path().join("photo.jpg"); + let file2 = temp_dir.path().join("doc.pdf"); + fs::write(&file1, "fake image data").unwrap(); + fs::write(&file2, "fake pdf data").unwrap(); + + let ctx = crate::context::JobContext::new("test", "test description"); + let result = tool + .execute( + serde_json::json!({ + "content": "here are the files", + "attachments": [file1.to_string_lossy(), file2.to_string_lossy()] + }), + &ctx, + ) + .await; + + // Path validation passes for /tmp paths, fails at channel send (no real channel) + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("channel") || err.contains("Channel"), + "expected channel error (path validation should pass), got: {}", + err + ); + } + #[tokio::test] async fn message_tool_requires_content() { let tool = MessageTool::new(Arc::new(ChannelManager::new())); diff --git a/wit/channel.wit b/wit/channel.wit index 6333e3cd757..af18720bb64 100644 --- a/wit/channel.wit +++ b/wit/channel.wit @@ -235,6 +235,16 @@ interface channel { body: list, } + /// A file or image attachment. + record attachment { + /// Original filename (e.g., "screenshot.png"). + filename: string, + /// MIME type (e.g., "image/png"). + mime-type: string, + /// Raw file bytes. + data: list, + } + /// Agent response to be sent back to the channel. record agent-response { /// Unique message ID for correlation. @@ -245,6 +255,8 @@ interface channel { thread-id: option, /// Channel-specific metadata as JSON string. metadata-json: string, + /// File/image attachments to send. + attachments: list, } // ==================== Status Types ==================== @@ -340,6 +352,20 @@ interface channel { /// - update: The status update on-status: func(update: status-update); + /// Send a proactive message to a user without a prior incoming message. + /// + /// Used for broadcasts, alerts, and agent-initiated messages with attachments. + /// The user-id identifies the target user within the channel. + /// + /// Arguments: + /// - user-id: Target user identifier (e.g., Telegram chat_id) + /// - response: The message content and attachments to send + /// + /// Returns: + /// - Ok: Message delivered successfully + /// - Err(string): Delivery failure message + on-broadcast: func(user-id: string, response: agent-response) -> result<_, string>; + /// Clean up channel resources. /// /// Called when the channel is being unloaded.