From c67f32b71330ee3974d515f42558f8a29a67aaa8 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Fri, 13 Mar 2026 13:22:37 -0700 Subject: [PATCH 01/14] refactor: add explicit owner scope across channels --- FEATURE_PARITY.md | 8 +- channels-src/telegram/src/lib.rs | 214 +-- .../V13__owner_scope_notify_targets.sql | 11 + migrations/V6__routines.sql | 2 +- src/agent/agent_loop.rs | 46 +- src/agent/commands.rs | 5 +- src/agent/dispatcher.rs | 4 +- src/agent/heartbeat.rs | 7 +- src/agent/routine.rs | 6 +- src/agent/routine_engine.rs | 6 +- src/agent/thread_ops.rs | 4 +- src/app.rs | 27 +- src/channels/channel.rs | 91 +- src/channels/http.rs | 10 +- src/channels/mod.rs | 2 +- src/channels/repl.rs | 30 +- src/channels/wasm/setup.rs | 23 +- src/channels/wasm/wrapper.rs | 332 +++- src/cli/doctor.rs | 9 +- src/cli/routines.rs | 2 +- src/config/channels.rs | 417 +----- src/config/mod.rs | 73 +- src/context/state.rs | 10 + src/db/libsql/jobs.rs | 1 + src/db/libsql/mod.rs | 27 +- src/db/libsql/routines.rs | 4 +- src/db/libsql_migrations.rs | 66 +- src/extensions/manager.rs | 9 +- src/history/store.rs | 1 + src/main.rs | 19 +- src/settings.rs | 13 + src/setup/wizard.rs | 1333 +++++++++++++---- src/testing/mod.rs | 4 +- src/tools/builtin/message.rs | 48 +- src/tools/builtin/routine.rs | 5 +- src/tools/wasm/wrapper.rs | 8 +- tests/e2e/conftest.py | 65 +- tests/e2e/helpers.py | 24 + tests/e2e/mock_llm.py | 34 + tests/e2e/scenarios/test_owner_scope.py | 226 +++ tests/e2e_routine_heartbeat.rs | 162 +- tests/telegram_auth_integration.rs | 12 +- 42 files changed, 2397 insertions(+), 1003 deletions(-) create mode 100644 migrations/V13__owner_scope_notify_targets.sql create mode 100644 tests/e2e/scenarios/test_owner_scope.py diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index 0cda8caaace..d00ff5e5df8 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -20,9 +20,9 @@ This document tracks feature parity between IronClaw (Rust implementation) and O |---------|----------|----------|-------| | Hub-and-spoke architecture | ✅ | ✅ | Web gateway as central hub | | WebSocket control plane | ✅ | ✅ | Gateway with WebSocket + SSE | -| Single-user system | ✅ | ✅ | | +| Single-user system | ✅ | ✅ | Explicit instance owner scope for persistent routines, secrets, jobs, settings, extensions, and workspace memory | | Multi-agent routing | ✅ | ❌ | Workspace isolation per-agent | -| Session-based messaging | ✅ | ✅ | Per-sender sessions | +| Session-based messaging | ✅ | ✅ | Owner scope is separate from sender identity and conversation scope | | Loopback-first networking | ✅ | ✅ | HTTP binds to 0.0.0.0 but can be configured | ### Owner: _Unassigned_ @@ -66,9 +66,9 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | CLI/TUI | ✅ | ✅ | - | Ratatui-based TUI | | HTTP webhook | ✅ | ✅ | - | axum with secret validation | | REPL (simple) | ✅ | ✅ | - | For testing | -| WASM channels | ❌ | ✅ | - | IronClaw innovation | +| WASM channels | ❌ | ✅ | - | IronClaw innovation; host resolves owner scope vs sender identity | | WhatsApp | ✅ | ❌ | P1 | Baileys (Web), same-phone mode with echo detection | -| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username, DM topics, setup-time owner verification | +| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username, DM topics, setup-time owner verification, owner-scoped persistence | | Discord | ✅ | ❌ | P2 | discord.js, thread parent binding inheritance | | Signal | ✅ | ✅ | P2 | signal-cli daemonPC, SSE listener HTTP/JSON-R, user/group allowlists, DM pairing | | Slack | ✅ | ✅ | - | WASM tool | diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs index 936197bc04d..f845f0ce11a 100644 --- a/channels-src/telegram/src/lib.rs +++ b/channels-src/telegram/src/lib.rs @@ -100,15 +100,6 @@ struct TelegramMessage { /// Sticker. sticker: Option, - - /// Forum topic ID. Present when the message is sent inside a forum topic. - /// https://core.telegram.org/bots/api#message - #[serde(default)] - message_thread_id: Option, - - /// True when this message is sent inside a forum topic. - #[serde(default)] - is_topic_message: Option, } /// Telegram PhotoSize object. @@ -207,10 +198,6 @@ struct TelegramChat { /// Title for groups/channels. title: Option, - /// True when the supergroup has topics (forum mode) enabled. - #[serde(default)] - is_forum: Option, - /// Username for private chats. username: Option, } @@ -303,10 +290,6 @@ struct TelegramMessageMetadata { /// Whether this is a private (DM) chat. is_private: bool, - - /// Forum topic thread ID (for routing replies back to the correct topic). - #[serde(default, skip_serializing_if = "Option::is_none")] - message_thread_id: Option, } /// Channel configuration injected by host. @@ -697,7 +680,7 @@ impl Guest for TelegramChannel { let metadata: TelegramMessageMetadata = serde_json::from_str(&response.metadata_json) .map_err(|e| format!("Failed to parse metadata: {}", e))?; - send_response(metadata.chat_id, &response, Some(metadata.message_id), metadata.message_thread_id) + send_response(metadata.chat_id, &response, Some(metadata.message_id)) } fn on_broadcast(user_id: String, response: AgentResponse) -> Result<(), String> { @@ -705,7 +688,7 @@ impl Guest for TelegramChannel { .parse() .map_err(|e| format!("Invalid chat_id '{}': {}", user_id, e))?; - send_response(chat_id, &response, None, None) + send_response(chat_id, &response, None) } fn on_status(update: StatusUpdate) { @@ -729,17 +712,11 @@ impl Guest for TelegramChannel { match action { TelegramStatusAction::Typing => { // POST /sendChatAction with action "typing" - let mut payload = serde_json::json!({ + let payload = serde_json::json!({ "chat_id": metadata.chat_id, "action": "typing" }); - // sendChatAction requires message_thread_id even for the General - // topic (id=1), unlike sendMessage which rejects it. - if let Some(thread_id) = metadata.message_thread_id { - payload["message_thread_id"] = serde_json::Value::Number(thread_id.into()); - } - let payload_bytes = match serde_json::to_vec(&payload) { Ok(b) => b, Err(_) => return, @@ -767,7 +744,7 @@ impl Guest for TelegramChannel { TelegramStatusAction::Notify(prompt) => { // Send user-visible status updates for actionable events. if let Err(first_err) = - send_message(metadata.chat_id, &prompt, Some(metadata.message_id), None, metadata.message_thread_id) + send_message(metadata.chat_id, &prompt, Some(metadata.message_id), None) { channel_host::log( channel_host::LogLevel::Warn, @@ -777,7 +754,7 @@ impl Guest for TelegramChannel { ), ); - if let Err(retry_err) = send_message(metadata.chat_id, &prompt, None, None, metadata.message_thread_id) { + if let Err(retry_err) = send_message(metadata.chat_id, &prompt, None, None) { channel_host::log( channel_host::LogLevel::Debug, &format!( @@ -820,15 +797,6 @@ impl std::fmt::Display for SendError { } } -/// Normalize `message_thread_id` for outbound API calls. -/// -/// Telegram rejects `sendMessage` (and other send methods) when -/// `message_thread_id = 1` (the "General" topic). Return `None` in that -/// case so the field is omitted from the payload. -fn normalize_thread_id(thread_id: Option) -> Option { - thread_id.filter(|&id| id != 1) -} - /// Send a message via the Telegram Bot API. /// /// Returns the sent message_id on success. When `parse_mode` is set and @@ -839,10 +807,7 @@ fn send_message( text: &str, reply_to_message_id: Option, parse_mode: Option<&str>, - message_thread_id: Option, ) -> Result { - let message_thread_id = normalize_thread_id(message_thread_id); - let mut payload = serde_json::json!({ "chat_id": chat_id, "text": text, @@ -856,10 +821,6 @@ fn send_message( payload["parse_mode"] = serde_json::Value::String(mode.to_string()); } - if let Some(thread_id) = message_thread_id { - payload["message_thread_id"] = serde_json::Value::Number(thread_id.into()); - } - let payload_bytes = serde_json::to_vec(&payload) .map_err(|e| SendError::Other(format!("Failed to serialize payload: {}", e)))?; @@ -1075,10 +1036,7 @@ fn send_photo( mime_type: &str, data: &[u8], reply_to_message_id: Option, - message_thread_id: Option, ) -> Result<(), String> { - let message_thread_id = normalize_thread_id(message_thread_id); - if data.len() > MAX_PHOTO_SIZE { channel_host::log( channel_host::LogLevel::Info, @@ -1088,7 +1046,7 @@ fn send_photo( data.len() ), ); - return send_document(chat_id, filename, mime_type, data, reply_to_message_id, message_thread_id); + return send_document(chat_id, filename, mime_type, data, reply_to_message_id); } let boundary = format!("ironclaw-{}", channel_host::now_millis()); @@ -1098,9 +1056,6 @@ fn send_photo( if let Some(msg_id) = reply_to_message_id { write_multipart_field(&mut body, &boundary, "reply_to_message_id", &msg_id.to_string()); } - if let Some(thread_id) = message_thread_id { - write_multipart_field(&mut body, &boundary, "message_thread_id", &thread_id.to_string()); - } write_multipart_file(&mut body, &boundary, "photo", filename, mime_type, data); body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes()); @@ -1142,10 +1097,7 @@ fn send_document( mime_type: &str, data: &[u8], reply_to_message_id: Option, - message_thread_id: Option, ) -> Result<(), String> { - let message_thread_id = normalize_thread_id(message_thread_id); - let boundary = format!("ironclaw-{}", channel_host::now_millis()); let mut body = Vec::new(); @@ -1153,9 +1105,6 @@ fn send_document( if let Some(msg_id) = reply_to_message_id { write_multipart_field(&mut body, &boundary, "reply_to_message_id", &msg_id.to_string()); } - if let Some(thread_id) = message_thread_id { - write_multipart_field(&mut body, &boundary, "message_thread_id", &thread_id.to_string()); - } write_multipart_file(&mut body, &boundary, "document", filename, mime_type, data); body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes()); @@ -1205,11 +1154,10 @@ fn send_response( chat_id: i64, response: &AgentResponse, reply_to_message_id: Option, - message_thread_id: Option, ) -> Result<(), String> { // Send attachments first (photos/documents) for attachment in &response.attachments { - send_attachment(chat_id, attachment, reply_to_message_id, message_thread_id)?; + send_attachment(chat_id, attachment, reply_to_message_id)?; } // Skip text if empty and we already sent attachments @@ -1218,10 +1166,10 @@ fn send_response( } // Try Markdown, fall back to plain text on parse errors - match send_message(chat_id, &response.content, reply_to_message_id, Some("Markdown"), message_thread_id) { + match send_message(chat_id, &response.content, reply_to_message_id, Some("Markdown")) { Ok(_) => Ok(()), Err(SendError::ParseEntities(_)) => { - send_message(chat_id, &response.content, reply_to_message_id, None, message_thread_id) + send_message(chat_id, &response.content, reply_to_message_id, None) .map(|_| ()) .map_err(|e| format!("Plain-text retry also failed: {}", e)) } @@ -1234,7 +1182,6 @@ fn send_attachment( chat_id: i64, attachment: &Attachment, reply_to_message_id: Option, - message_thread_id: Option, ) -> Result<(), String> { if PHOTO_MIME_TYPES.contains(&attachment.mime_type.as_str()) { send_photo( @@ -1243,7 +1190,6 @@ fn send_attachment( &attachment.mime_type, &attachment.data, reply_to_message_id, - message_thread_id, ) } else { send_document( @@ -1252,7 +1198,6 @@ fn send_attachment( &attachment.mime_type, &attachment.data, reply_to_message_id, - message_thread_id, ) } } @@ -1412,7 +1357,6 @@ fn send_pairing_reply(chat_id: i64, code: &str) -> Result<(), String> { ), None, Some("Markdown"), - None, // Pairing happens in DMs, not forum topics ) .map(|_| ()) .map_err(|e| e.to_string()) @@ -1745,25 +1689,14 @@ fn handle_message(message: TelegramMessage) { let is_private = message.chat.chat_type == "private"; - // Owner validation: when owner_id is set, only that user can message - let owner_id_str = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty()); + let owner_id = channel_host::workspace_read(OWNER_ID_PATH) + .filter(|s| !s.is_empty()) + .and_then(|s| s.parse::().ok()); + let is_owner = owner_id == Some(from.id); - if let Some(ref id_str) = owner_id_str { - if let Ok(owner_id) = id_str.parse::() { - if from.id != owner_id { - channel_host::log( - channel_host::LogLevel::Debug, - &format!( - "Dropping message from non-owner user {} (owner: {})", - from.id, owner_id - ), - ); - return; - } - } - } else { - // No owner_id: apply authorization based on dm_policy and allow_from - // This applies to both private and group chats when owner_id is null + if !is_owner { + // Non-owner senders remain guests. Apply authorization based on + // dm_policy / allow_from before letting them chat in their own scope. let dm_policy = channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string()); @@ -1830,8 +1763,6 @@ fn handle_message(message: TelegramMessage) { } } - let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default(); - // For group chats, only respond if bot was mentioned or respond_to_all is enabled if !is_private { let respond_to_all = channel_host::workspace_read(RESPOND_TO_ALL_GROUP_PATH) @@ -1841,6 +1772,7 @@ fn handle_message(message: TelegramMessage) { if !respond_to_all { let has_command = content.starts_with('/'); + let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default(); let has_bot_mention = if bot_username.is_empty() { content.contains('@') } else { @@ -1871,23 +1803,11 @@ fn handle_message(message: TelegramMessage) { message_id: message.message_id, user_id: from.id, is_private, - message_thread_id: message.message_thread_id, }; let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string()); - // Compute thread_id for forum topics: "chat_id:topic_id" to prevent - // collisions across different groups (topic IDs are only unique per chat). - // Only use message_thread_id when the chat is a forum — non-forum groups - // also carry message_thread_id for reply threads, which are not topics. - let thread_id = if message.chat.is_forum == Some(true) { - message.message_thread_id.map(|topic_id| { - format!("{}:{}", message.chat.id, topic_id) - }) - } else { - None - }; - + let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default(); let content_to_emit = match content_to_emit_for_agent( &content, if bot_username.is_empty() { @@ -1907,7 +1827,7 @@ fn handle_message(message: TelegramMessage) { user_id: from.id.to_string(), user_name: Some(user_name), content: content_to_emit, - thread_id, + thread_id: Some(message.chat.id.to_string()), metadata_json, attachments, }); @@ -2726,100 +2646,4 @@ mod tests { // Verify the constant is 20 MB, matching the Slack channel limit assert_eq!(MAX_DOWNLOAD_SIZE_BYTES, 20 * 1024 * 1024); } - - // === Forum Topics (thread_id) tests === - - #[test] - fn test_parse_forum_message_with_thread_id() { - let json = r#"{ - "message_id": 100, - "message_thread_id": 42, - "is_topic_message": true, - "from": {"id": 1, "is_bot": false, "first_name": "A"}, - "chat": {"id": -1001234567890, "type": "supergroup", "is_forum": true}, - "text": "Hello from a topic" - }"#; - let msg: TelegramMessage = serde_json::from_str(json).unwrap(); - assert_eq!(msg.message_thread_id, Some(42)); - assert_eq!(msg.is_topic_message, Some(true)); - assert_eq!(msg.chat.is_forum, Some(true)); - } - - #[test] - fn test_parse_non_forum_message_backward_compat() { - let json = r#"{ - "message_id": 1, - "from": {"id": 1, "is_bot": false, "first_name": "A"}, - "chat": {"id": 1, "type": "private"}, - "text": "Hello" - }"#; - let msg: TelegramMessage = serde_json::from_str(json).unwrap(); - assert_eq!(msg.message_thread_id, None); - assert_eq!(msg.is_topic_message, None); - assert_eq!(msg.chat.is_forum, None); - } - - #[test] - fn test_metadata_with_message_thread_id() { - let metadata = TelegramMessageMetadata { - chat_id: -1001234567890, - message_id: 100, - user_id: 42, - is_private: false, - message_thread_id: Some(7), - }; - let json = serde_json::to_string(&metadata).unwrap(); - let parsed: TelegramMessageMetadata = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.message_thread_id, Some(7)); - } - - #[test] - fn test_metadata_backward_compat_no_thread_id() { - // Old metadata JSON without message_thread_id should deserialize with None - let json = r#"{"chat_id":123,"message_id":1,"user_id":42,"is_private":true}"#; - let metadata: TelegramMessageMetadata = serde_json::from_str(json).unwrap(); - assert_eq!(metadata.message_thread_id, None); - } - - #[test] - fn test_metadata_thread_id_not_serialized_when_none() { - let metadata = TelegramMessageMetadata { - chat_id: 123, - message_id: 1, - user_id: 42, - is_private: true, - message_thread_id: None, - }; - let json = serde_json::to_string(&metadata).unwrap(); - assert!(!json.contains("message_thread_id")); - } - - #[test] - fn test_thread_id_composition() { - // Verify "chat_id:topic_id" format for forum topics - let chat_id: i64 = -1001234567890; - let topic_id: i64 = 42; - let thread_id = format!("{}:{}", chat_id, topic_id); - assert_eq!(thread_id, "-1001234567890:42"); - } - - #[test] - fn test_normalize_thread_id_general_topic() { - // General topic (id=1) must be omitted — Telegram rejects sendMessage - // with message_thread_id=1. - assert_eq!(normalize_thread_id(Some(1)), None); - } - - #[test] - fn test_normalize_thread_id_regular_topic() { - // Non-General topics pass through unchanged - assert_eq!(normalize_thread_id(Some(42)), Some(42)); - assert_eq!(normalize_thread_id(Some(123)), Some(123)); - } - - #[test] - fn test_normalize_thread_id_none() { - // None stays None - assert_eq!(normalize_thread_id(None), None); - } } diff --git a/migrations/V13__owner_scope_notify_targets.sql b/migrations/V13__owner_scope_notify_targets.sql new file mode 100644 index 00000000000..4c7064fab60 --- /dev/null +++ b/migrations/V13__owner_scope_notify_targets.sql @@ -0,0 +1,11 @@ +-- Remove the legacy 'default' sentinel from routine notifications. +-- A NULL notify_user now means "resolve the configured owner's last-seen +-- channel target at send time." + +ALTER TABLE routines + ALTER COLUMN notify_user DROP NOT NULL, + ALTER COLUMN notify_user DROP DEFAULT; + +UPDATE routines +SET notify_user = NULL +WHERE notify_user = 'default'; diff --git a/migrations/V6__routines.sql b/migrations/V6__routines.sql index 36f63cb2f5e..9697251cc9e 100644 --- a/migrations/V6__routines.sql +++ b/migrations/V6__routines.sql @@ -26,7 +26,7 @@ CREATE TABLE routines ( -- Notification preferences notify_channel TEXT, -- NULL = use default - notify_user TEXT NOT NULL DEFAULT 'default', + notify_user TEXT, notify_on_success BOOLEAN NOT NULL DEFAULT false, notify_on_failure BOOLEAN NOT NULL DEFAULT true, notify_on_attention BOOLEAN NOT NULL DEFAULT true, diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 4b7ed5381f8..b4322340330 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -102,6 +102,14 @@ pub struct Agent { } impl Agent { + pub(super) fn owner_id(&self) -> &str { + self.deps + .workspace + .as_ref() + .map(|workspace| workspace.user_id()) + .unwrap_or("default") + } + /// Create a new agent. /// /// Optionally accepts pre-created `ContextManager` and `SessionManager` for sharing @@ -264,6 +272,7 @@ impl Agent { )); let repair_interval = self.config.repair_check_interval; let repair_channels = self.channels.clone(); + let repair_owner_id = self.owner_id().to_string(); let repair_handle = tokio::spawn(async move { loop { tokio::time::sleep(repair_interval).await; @@ -311,7 +320,9 @@ impl Agent { if let Some(msg) = notification { let response = OutgoingResponse::text(format!("Self-Repair: {}", msg)); - let _ = repair_channels.broadcast_all("default", response).await; + let _ = repair_channels + .broadcast_all(&repair_owner_id, response) + .await; } } @@ -325,7 +336,9 @@ impl Agent { "Self-Repair: Tool '{}' repaired: {}", tool.name, message )); - let _ = repair_channels.broadcast_all("default", response).await; + let _ = repair_channels + .broadcast_all(&repair_owner_id, response) + .await; } Ok(result) => { tracing::info!("Tool repair result: {:?}", result); @@ -362,9 +375,11 @@ impl Agent { .timezone .clone() .or_else(|| Some(self.config.default_timezone.clone())); - if let (Some(user), Some(channel)) = - (&hb_config.notify_user, &hb_config.notify_channel) - { + if let Some(channel) = &hb_config.notify_channel { + let user = hb_config + .notify_user + .clone() + .unwrap_or_else(|| self.owner_id().to_string()); config = config.with_notify(user, channel); } @@ -374,17 +389,18 @@ impl Agent { // Spawn notification forwarder that routes through channel manager let notify_channel = hb_config.notify_channel.clone(); - let notify_user = hb_config.notify_user.clone(); + let notify_user = hb_config + .notify_user + .clone() + .unwrap_or_else(|| self.owner_id().to_string()); let channels = self.channels.clone(); tokio::spawn(async move { while let Some(response) = notify_rx.recv().await { - let user = notify_user.as_deref().unwrap_or("default"); - // Try the configured channel first, fall back to // broadcasting on all channels. let targeted_ok = if let Some(ref channel) = notify_channel { channels - .broadcast(channel, user, response.clone()) + .broadcast(channel, ¬ify_user, response.clone()) .await .is_ok() } else { @@ -392,7 +408,7 @@ impl Agent { }; if !targeted_ok { - let results = channels.broadcast_all(user, response).await; + let results = channels.broadcast_all(¬ify_user, response).await; for (ch, result) in results { if let Err(e) = result { tracing::warn!( @@ -466,6 +482,7 @@ impl Agent { .metadata .get("notify_user") .and_then(|v| v.as_str()) + .or_else(|| response.metadata.get("owner_id").and_then(|v| v.as_str())) .unwrap_or("default") .to_string(); let notify_channel = response @@ -768,10 +785,7 @@ impl Agent { // For Signal, use signal_target from metadata (group:ID or phone number), // otherwise fall back to user_id let target = message - .metadata - .get("signal_target") - .and_then(|v| v.as_str()) - .map(|s| s.to_string()) + .routing_target() .unwrap_or_else(|| message.user_id.clone()); self.tools() .set_message_tool_context(Some(message.channel.clone()), Some(target)) @@ -811,7 +825,7 @@ impl Agent { } // Hydrate thread from DB if it's a historical thread not in memory - if let Some(ref external_thread_id) = message.thread_id { + if let Some(external_thread_id) = message.conversation_scope() { tracing::trace!( message_id = %message.id, thread_id = %external_thread_id, @@ -832,7 +846,7 @@ impl Agent { .resolve_thread( &message.user_id, &message.channel, - message.thread_id.as_deref(), + message.conversation_scope(), ) .await; tracing::debug!( diff --git a/src/agent/commands.rs b/src/agent/commands.rs index 90266d0bab1..75c99359b5f 100644 --- a/src/agent/commands.rs +++ b/src/agent/commands.rs @@ -836,7 +836,10 @@ impl Agent { // 1. Persist to DB if available. if let Some(store) = self.store() { let value = serde_json::Value::String(model.to_string()); - if let Err(e) = store.set_setting("default", "selected_model", &value).await { + if let Err(e) = store + .set_setting(self.owner_id(), "selected_model", &value) + .await + { tracing::warn!("Failed to persist model to DB: {}", e); } } diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs index 9e6747f2b39..9803ac8c7be 100644 --- a/src/agent/dispatcher.rs +++ b/src/agent/dispatcher.rs @@ -139,8 +139,8 @@ impl Agent { } // Create a JobContext for tool execution (chat doesn't have a real job) - let mut job_ctx = - JobContext::with_user(&message.user_id, "chat", "Interactive chat session"); + let mut job_ctx = JobContext::with_user(&message.user_id, "chat", "Interactive chat session") + .with_requester_id(&message.sender_id); job_ctx.http_interceptor = self.deps.http_interceptor.clone(); job_ctx.user_timezone = user_tz.name().to_string(); job_ctx.metadata = serde_json::json!({ diff --git a/src/agent/heartbeat.rs b/src/agent/heartbeat.rs index 77bdeadb0fc..ec4cd5e9ec5 100644 --- a/src/agent/heartbeat.rs +++ b/src/agent/heartbeat.rs @@ -402,7 +402,11 @@ impl HeartbeatRunner { return; }; - let user_id = self.config.notify_user_id.as_deref().unwrap_or("default"); + let user_id = self + .config + .notify_user_id + .as_deref() + .unwrap_or_else(|| self.workspace.user_id()); // Persist to heartbeat conversation and get thread_id let thread_id = if let Some(ref store) = self.store { @@ -431,6 +435,7 @@ impl HeartbeatRunner { attachments: Vec::new(), metadata: serde_json::json!({ "source": "heartbeat", + "owner_id": self.workspace.user_id(), }), }; diff --git a/src/agent/routine.rs b/src/agent/routine.rs index 0389ac1e33b..f3850fa0b14 100644 --- a/src/agent/routine.rs +++ b/src/agent/routine.rs @@ -422,8 +422,8 @@ impl Default for RoutineGuardrails { pub struct NotifyConfig { /// Channel to notify on (None = default/broadcast all). pub channel: Option, - /// User to notify. - pub user: String, + /// Explicit target to notify. None means "resolve the owner's last-seen target". + pub user: Option, /// Notify when routine produces actionable output. pub on_attention: bool, /// Notify when routine errors. @@ -436,7 +436,7 @@ impl Default for NotifyConfig { fn default() -> Self { Self { channel: None, - user: "default".to_string(), + user: None, on_attention: true, on_failure: true, on_success: false, diff --git a/src/agent/routine_engine.rs b/src/agent/routine_engine.rs index c37ba7ce16a..9cbdc4479f9 100644 --- a/src/agent/routine_engine.rs +++ b/src/agent/routine_engine.rs @@ -650,6 +650,7 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun) send_notification( &ctx.notify_tx, &routine.notify, + &routine.user_id, &routine.name, status, summary.as_deref(), @@ -694,7 +695,8 @@ async fn execute_full_job( reason: "scheduler not available".to_string(), })?; - let mut metadata = serde_json::json!({ "max_iterations": max_iterations }); + let mut metadata = + serde_json::json!({ "max_iterations": max_iterations, "owner_id": routine.user_id }); // Carry the routine's notify config in job metadata so the message tool // can resolve channel/target per-job without global state mutation. if let Some(channel) = &routine.notify.channel { @@ -1207,6 +1209,7 @@ async fn execute_routine_tool( async fn send_notification( tx: &mpsc::Sender, notify: &NotifyConfig, + owner_id: &str, routine_name: &str, status: RunStatus, summary: Option<&str>, @@ -1243,6 +1246,7 @@ async fn send_notification( "source": "routine", "routine_name": routine_name, "status": status.to_string(), + "owner_id": owner_id, "notify_user": notify.user, "notify_channel": notify.channel, }), diff --git a/src/agent/thread_ops.rs b/src/agent/thread_ops.rs index 7aa499aec0a..ab66bdcae15 100644 --- a/src/agent/thread_ops.rs +++ b/src/agent/thread_ops.rs @@ -923,8 +923,8 @@ impl Agent { } // Execute the approved tool and continue the loop - let mut job_ctx = - JobContext::with_user(&message.user_id, "chat", "Interactive chat session"); + let mut job_ctx = JobContext::with_user(&message.user_id, "chat", "Interactive chat session") + .with_requester_id(&message.sender_id); job_ctx.http_interceptor = self.deps.http_interceptor.clone(); // Prefer a valid timezone from the approval message, fall back to the // resolved timezone stored when the approval was originally requested. diff --git a/src/app.rs b/src/app.rs index 00804de1471..f435129142c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -140,12 +140,12 @@ impl AppBuilder { self.handles = Some(handles); // Post-init: migrate disk config, reload config from DB, attach session, cleanup - if let Err(e) = crate::bootstrap::migrate_disk_to_db(db.as_ref(), "default").await { + if let Err(e) = crate::bootstrap::migrate_disk_to_db(db.as_ref(), &self.config.owner_id).await { tracing::warn!("Disk-to-DB settings migration failed: {}", e); } let toml_path = self.toml_path.as_deref(); - match Config::from_db_with_toml(db.as_ref(), "default", toml_path).await { + match Config::from_db_with_toml(db.as_ref(), &self.config.owner_id, toml_path).await { Ok(db_config) => { self.config = db_config; tracing::debug!("Configuration reloaded from database"); @@ -158,7 +158,9 @@ impl AppBuilder { } } - self.session.attach_store(db.clone(), "default").await; + self.session + .attach_store(db.clone(), &self.config.owner_id) + .await; // Fire-and-forget housekeeping — no need to block startup. let db_cleanup = db.clone(); @@ -193,9 +195,10 @@ impl AppBuilder { let store: Option<&(dyn crate::db::SettingsStore + Sync)> = self.db.as_ref().map(|db| db.as_ref() as _); let toml_path = self.toml_path.as_deref(); + let owner_id = self.config.owner_id.clone(); if let Err(e) = self .config - .re_resolve_llm(store, "default", toml_path) + .re_resolve_llm(store, &owner_id, toml_path) .await { tracing::warn!( @@ -224,15 +227,17 @@ impl AppBuilder { if let Some(ref secrets) = store { // Inject LLM API keys from encrypted storage - crate::config::inject_llm_keys_from_secrets(secrets.as_ref(), "default").await; + crate::config::inject_llm_keys_from_secrets(secrets.as_ref(), &self.config.owner_id) + .await; // Re-resolve only the LLM config with newly available keys. let store: Option<&(dyn crate::db::SettingsStore + Sync)> = self.db.as_ref().map(|db| db.as_ref() as _); let toml_path = self.toml_path.as_deref(); + let owner_id = self.config.owner_id.clone(); if let Err(e) = self .config - .re_resolve_llm(store, "default", toml_path) + .re_resolve_llm(store, &owner_id, toml_path) .await { tracing::warn!("Failed to re-resolve LLM config after secret injection: {e}"); @@ -304,7 +309,7 @@ impl AppBuilder { // Register memory tools if database is available let workspace = if let Some(ref db) = self.db { - let mut ws = Workspace::new_with_db("default", db.clone()) + let mut ws = Workspace::new_with_db(&self.config.owner_id, db.clone()) .with_search_config(&self.config.search); if let Some(ref emb) = embeddings { ws = ws.with_embeddings(emb.clone()); @@ -469,9 +474,10 @@ impl AppBuilder { let tools = Arc::clone(tools); let mcp_sm = Arc::clone(&mcp_session_manager); let pm = Arc::clone(&mcp_process_manager); + let owner_id = self.config.owner_id.clone(); async move { let servers_result = if let Some(ref d) = db { - load_mcp_servers_from_db(d.as_ref(), "default").await + load_mcp_servers_from_db(d.as_ref(), &owner_id).await } else { crate::tools::mcp::config::load_mcp_servers().await }; @@ -491,6 +497,7 @@ impl AppBuilder { let secrets = secrets_store.clone(); let tools = Arc::clone(&tools); let pm = Arc::clone(&pm); + let owner_id = owner_id.clone(); join_set.spawn(async move { let server_name = server.name.clone(); @@ -500,7 +507,7 @@ impl AppBuilder { &mcp_sm, &pm, secrets, - "default", + &owner_id, ) .await { @@ -642,7 +649,7 @@ impl AppBuilder { self.config.wasm.tools_dir.clone(), self.config.channels.wasm_channels_dir.clone(), self.config.tunnel.public_url.clone(), - "default".to_string(), + self.config.owner_id.clone(), self.db.clone(), catalog_entries.clone(), )); diff --git a/src/channels/channel.rs b/src/channels/channel.rs index ed8c28ff2e5..f6b1a419d89 100644 --- a/src/channels/channel.rs +++ b/src/channels/channel.rs @@ -67,14 +67,24 @@ pub struct IncomingMessage { pub id: Uuid, /// Channel this message came from. pub channel: String, - /// User identifier within the channel. + /// Storage/persistence scope for this interaction. + /// + /// For owner-capable channels this is the stable instance owner ID when the + /// configured owner is speaking; otherwise it can be a guest/sender-scoped + /// identifier to preserve isolation. pub user_id: String, + /// Stable instance owner scope for this IronClaw deployment. + pub owner_id: String, + /// Channel-specific sender/actor identifier. + pub sender_id: String, /// Optional display name. pub user_name: Option, /// Message content. pub content: String, /// Thread/conversation ID for threaded conversations. pub thread_id: Option, + /// Stable channel/chat/thread scope for this conversation. + pub conversation_scope_id: Option, /// When the message was received. pub received_at: DateTime, /// Channel-specific metadata. @@ -83,11 +93,6 @@ pub struct IncomingMessage { pub timezone: Option, /// File or media attachments on this message. pub attachments: Vec, - /// Internal-only flag: message was generated inside the process (e.g. job - /// monitor) and must bypass the normal user-input pipeline. This field is - /// **not** settable via `with_metadata()` — only trusted code paths inside - /// the binary can set it, preventing external channels from spoofing it. - pub(crate) is_internal: bool, } impl IncomingMessage { @@ -97,24 +102,47 @@ impl IncomingMessage { user_id: impl Into, content: impl Into, ) -> Self { + let user_id = user_id.into(); Self { id: Uuid::new_v4(), channel: channel.into(), - user_id: user_id.into(), + owner_id: user_id.clone(), + sender_id: user_id.clone(), + user_id, user_name: None, content: content.into(), thread_id: None, + conversation_scope_id: None, received_at: Utc::now(), metadata: serde_json::Value::Null, timezone: None, attachments: Vec::new(), - is_internal: false, } } /// Set the thread ID. pub fn with_thread(mut self, thread_id: impl Into) -> Self { - self.thread_id = Some(thread_id.into()); + let thread_id = thread_id.into(); + self.conversation_scope_id = Some(thread_id.clone()); + self.thread_id = Some(thread_id); + self + } + + /// Set the stable owner scope for this message. + pub fn with_owner_id(mut self, owner_id: impl Into) -> Self { + self.owner_id = owner_id.into(); + self + } + + /// Set the channel-specific sender/actor identifier. + pub fn with_sender_id(mut self, sender_id: impl Into) -> Self { + self.sender_id = sender_id.into(); + self + } + + /// Set the conversation scope for this message. + pub fn with_conversation_scope(mut self, scope_id: impl Into) -> Self { + self.conversation_scope_id = Some(scope_id.into()); self } @@ -142,11 +170,48 @@ impl IncomingMessage { self } - /// Mark this message as internal (bypasses user-input pipeline). - pub(crate) fn into_internal(mut self) -> Self { - self.is_internal = true; - self + /// Effective conversation scope, falling back to thread_id for legacy callers. + pub fn conversation_scope(&self) -> Option<&str> { + self.conversation_scope_id + .as_deref() + .or(self.thread_id.as_deref()) } + + /// Best-effort routing target for proactive replies on the current channel. + pub fn routing_target(&self) -> Option { + routing_target_from_metadata(&self.metadata).or_else(|| { + if self.sender_id.is_empty() { + None + } else { + Some(self.sender_id.clone()) + } + }) + } +} + +/// Extract a channel-specific proactive routing target from message metadata. +pub fn routing_target_from_metadata(metadata: &serde_json::Value) -> Option { + metadata + .get("signal_target") + .and_then(|value| match value { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + _ => None, + }) + .or_else(|| { + metadata.get("chat_id").and_then(|value| match value { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + _ => None, + }) + }) + .or_else(|| { + metadata.get("target").and_then(|value| match value { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Number(n) => Some(n.to_string()), + _ => None, + }) + }) } /// Stream of incoming messages. diff --git a/src/channels/http.rs b/src/channels/http.rs index 5c173bf2997..d4d1849ad35 100644 --- a/src/channels/http.rs +++ b/src/channels/http.rs @@ -514,11 +514,13 @@ async fn process_authenticated_request( Vec::new() }; - let mut msg = IncomingMessage::new("http", &state.user_id, &req.content).with_metadata( - serde_json::json!({ + let sender_id = req.user_id.clone().unwrap_or_else(|| state.user_id.clone()); + let mut msg = IncomingMessage::new("http", &state.user_id, &req.content) + .with_owner_id(&state.user_id) + .with_sender_id(sender_id) + .with_metadata(serde_json::json!({ "wait_for_response": wait_for_response, - }), - ); + })); if !attachments.is_empty() { msg = msg.with_attachments(attachments); diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 289b64c7be5..c0230692934 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -39,7 +39,7 @@ mod webhook_server; pub use channel::{ AttachmentKind, Channel, ChannelSecretUpdater, IncomingAttachment, IncomingMessage, - MessageStream, OutgoingResponse, StatusUpdate, + MessageStream, OutgoingResponse, StatusUpdate, routing_target_from_metadata, }; pub use http::{HttpChannel, HttpChannelState}; pub use manager::ChannelManager; diff --git a/src/channels/repl.rs b/src/channels/repl.rs index 230d5e92c25..dfeb08de9ca 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -200,6 +200,8 @@ fn format_json_params(params: &serde_json::Value, indent: &str) -> String { /// REPL channel with line editing and markdown rendering. pub struct ReplChannel { + /// Stable owner scope for this REPL instance. + user_id: String, /// Optional single message to send (for -m flag). single_message: Option, /// Debug mode flag (shared with input thread). @@ -213,7 +215,13 @@ pub struct ReplChannel { impl ReplChannel { /// Create a new REPL channel. pub fn new() -> Self { + Self::with_user_id("default") + } + + /// Create a new REPL channel for a specific owner scope. + pub fn with_user_id(user_id: impl Into) -> Self { Self { + user_id: user_id.into(), single_message: None, debug_mode: Arc::new(AtomicBool::new(false)), is_streaming: Arc::new(AtomicBool::new(false)), @@ -223,7 +231,13 @@ impl ReplChannel { /// Create a REPL channel that sends a single message and exits. pub fn with_message(message: String) -> Self { + Self::with_message_for_user("default", message) + } + + /// Create a REPL channel that sends a single message for a specific owner scope and exits. + pub fn with_message_for_user(user_id: impl Into, message: String) -> Self { Self { + user_id: user_id.into(), single_message: Some(message), debug_mode: Arc::new(AtomicBool::new(false)), is_streaming: Arc::new(AtomicBool::new(false)), @@ -292,6 +306,7 @@ impl Channel for ReplChannel { async fn start(&self) -> Result { let (tx, rx) = mpsc::channel(32); let single_message = self.single_message.clone(); + let user_id = self.user_id.clone(); let debug_mode = Arc::clone(&self.debug_mode); let suppress_banner = Arc::clone(&self.suppress_banner); let esc_interrupt_triggered_for_thread = Arc::new(AtomicBool::new(false)); @@ -301,11 +316,12 @@ impl Channel for ReplChannel { // Single message mode: send it and return if let Some(msg) = single_message { - let incoming = IncomingMessage::new("repl", "default", &msg).with_timezone(&sys_tz); + let incoming = + IncomingMessage::new("repl", &user_id, &msg).with_timezone(&sys_tz); let _ = tx.blocking_send(incoming); // Ensure the agent exits after handling exactly one turn in -m mode, // even when other channels (gateway/http) are enabled. - let _ = tx.blocking_send(IncomingMessage::new("repl", "default", "/quit")); + let _ = tx.blocking_send(IncomingMessage::new("repl", &user_id, "/quit")); return; } @@ -366,7 +382,7 @@ impl Channel for ReplChannel { "/quit" | "/exit" => { // Forward shutdown command so the agent loop exits even // when other channels (e.g. web gateway) are still active. - let msg = IncomingMessage::new("repl", "default", "/quit") + let msg = IncomingMessage::new("repl", &user_id, "/quit") .with_timezone(&sys_tz); let _ = tx.blocking_send(msg); break; @@ -389,7 +405,7 @@ impl Channel for ReplChannel { } let msg = - IncomingMessage::new("repl", "default", line).with_timezone(&sys_tz); + IncomingMessage::new("repl", &user_id, line).with_timezone(&sys_tz); if tx.blocking_send(msg).is_err() { break; } @@ -397,14 +413,14 @@ impl Channel for ReplChannel { Err(ReadlineError::Interrupted) => { if esc_interrupt_triggered_for_thread.swap(false, Ordering::Relaxed) { // Esc: interrupt current operation and keep REPL open. - let msg = IncomingMessage::new("repl", "default", "/interrupt") + let msg = IncomingMessage::new("repl", &user_id, "/interrupt") .with_timezone(&sys_tz); if tx.blocking_send(msg).is_err() { break; } } else { // Ctrl+C (VINTR): request graceful shutdown. - let msg = IncomingMessage::new("repl", "default", "/quit") + let msg = IncomingMessage::new("repl", &user_id, "/quit") .with_timezone(&sys_tz); let _ = tx.blocking_send(msg); break; @@ -416,7 +432,7 @@ impl Channel for ReplChannel { // immediately — just drop the REPL thread silently so other // channels (gateway, telegram, …) keep running. if std::io::stdin().is_terminal() { - let msg = IncomingMessage::new("repl", "default", "/quit") + let msg = IncomingMessage::new("repl", &user_id, "/quit") .with_timezone(&sys_tz); let _ = tx.blocking_send(msg); } diff --git a/src/channels/wasm/setup.rs b/src/channels/wasm/setup.rs index 9c0c3f33a46..8a0e34cbf18 100644 --- a/src/channels/wasm/setup.rs +++ b/src/channels/wasm/setup.rs @@ -117,6 +117,11 @@ async fn register_channel( ) -> (String, Box) { let channel_name = loaded.name().to_string(); tracing::info!("Loaded WASM channel: {}", channel_name); + let owner_actor_id = config + .channels + .wasm_channel_owner_ids + .get(channel_name.as_str()) + .map(ToString::to_string); let secret_name = loaded.webhook_secret_name(); let sig_key_secret_name = loaded.signature_key_secret_name(); @@ -124,7 +129,7 @@ async fn register_channel( let webhook_secret = if let Some(secrets) = secrets_store { secrets - .get_decrypted("default", &secret_name) + .get_decrypted(&config.owner_id, &secret_name) .await .ok() .map(|s| s.expose().to_string()) @@ -142,7 +147,11 @@ async fn register_channel( require_secret: webhook_secret.is_some(), }]; - let channel_arc = Arc::new(loaded.channel); + let channel_arc = Arc::new( + loaded + .channel + .with_owner_binding(config.owner_id.clone(), owner_actor_id.clone()), + ); // Inject runtime config (tunnel URL, webhook secret, owner_id). { @@ -216,7 +225,7 @@ async fn register_channel( // Register Ed25519 signature key if declared in capabilities. if let Some(ref sig_key_name) = sig_key_secret_name && let Some(secrets) = secrets_store - && let Ok(key_secret) = secrets.get_decrypted("default", sig_key_name).await + && let Ok(key_secret) = secrets.get_decrypted(&config.owner_id, sig_key_name).await { match wasm_router .register_signature_key(&channel_name, key_secret.expose()) @@ -234,7 +243,7 @@ async fn register_channel( // Register HMAC signing secret if declared in capabilities. if let Some(ref hmac_secret_name) = hmac_secret_name && let Some(secrets) = secrets_store - && let Ok(secret) = secrets.get_decrypted("default", hmac_secret_name).await + && let Ok(secret) = secrets.get_decrypted(&config.owner_id, hmac_secret_name).await { wasm_router .register_hmac_secret(&channel_name, secret.expose()) @@ -249,6 +258,7 @@ async fn register_channel( .as_ref() .map(|s| s.as_ref() as &dyn SecretsStore), &channel_name, + &config.owner_id, ) .await { @@ -286,6 +296,7 @@ pub async fn inject_channel_credentials( channel: &Arc, secrets: Option<&dyn SecretsStore>, channel_name: &str, + owner_id: &str, ) -> anyhow::Result { if channel_name.trim().is_empty() { return Ok(0); @@ -297,7 +308,7 @@ pub async fn inject_channel_credentials( // 1. Try injecting from persistent secrets store if available if let Some(secrets) = secrets { let all_secrets = secrets - .list("default") + .list(owner_id) .await .map_err(|e| anyhow::anyhow!("Failed to list secrets: {}", e))?; @@ -308,7 +319,7 @@ pub async fn inject_channel_credentials( continue; } - let decrypted = match secrets.get_decrypted("default", &secret_meta.name).await { + let decrypted = match secrets.get_decrypted(owner_id, &secret_meta.name).await { Ok(d) => d, Err(e) => { tracing::warn!( diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index 1529da41b42..44845620629 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -709,6 +709,12 @@ pub struct WasmChannel { /// Settings store for persisting broadcast metadata across restarts. settings_store: Option>, + /// Stable owner scope for persistent data and owner-target routing. + owner_scope_id: String, + + /// Channel-specific actor ID that maps to the instance owner on this channel. + owner_actor_id: Option, + /// Secrets store for host-based credential injection. /// Used to pre-resolve credentials before each WASM callback. secrets_store: Option>, @@ -719,6 +725,7 @@ pub struct WasmChannel { /// method and the static polling helper share one implementation. async fn do_update_broadcast_metadata( channel_name: &str, + owner_scope_id: &str, metadata: &str, last_broadcast_metadata: &tokio::sync::RwLock>, settings_store: Option<&Arc>, @@ -731,7 +738,7 @@ async fn do_update_broadcast_metadata( if changed && let Some(store) = settings_store { let key = format!("channel_broadcast_metadata_{}", channel_name); let value = serde_json::Value::String(metadata.to_string()); - if let Err(e) = store.set_setting("default", &key, &value).await { + if let Err(e) = store.set_setting(owner_scope_id, &key, &value).await { tracing::warn!( channel = %channel_name, "Failed to persist broadcast metadata: {}", @@ -741,6 +748,33 @@ async fn do_update_broadcast_metadata( } } +fn resolve_message_scope( + owner_scope_id: &str, + owner_actor_id: Option<&str>, + sender_id: &str, +) -> (String, bool) { + if owner_actor_id.is_some_and(|owner_actor_id| owner_actor_id == sender_id) { + (owner_scope_id.to_string(), true) + } else { + (sender_id.to_string(), false) + } +} + +fn apply_emitted_metadata( + mut msg: IncomingMessage, + metadata_json: &str, +) -> IncomingMessage { + if let Ok(metadata) = serde_json::from_str(metadata_json) { + msg = msg.with_metadata(metadata); + if msg.conversation_scope().is_none() + && let Some(scope_id) = crate::channels::routing_target_from_metadata(&msg.metadata) + { + msg = msg.with_conversation_scope(scope_id); + } + } + msg +} + impl WasmChannel { /// Create a new WASM channel. pub fn new( @@ -773,6 +807,8 @@ impl WasmChannel { workspace_store: Arc::new(ChannelWorkspaceStore::new()), last_broadcast_metadata: Arc::new(tokio::sync::RwLock::new(None)), settings_store, + owner_scope_id: "default".to_string(), + owner_actor_id: None, secrets_store: None, } } @@ -787,6 +823,17 @@ impl WasmChannel { self } + /// Bind this channel to the configured owner scope and external owner actor. + pub fn with_owner_binding( + mut self, + owner_scope_id: impl Into, + owner_actor_id: Option, + ) -> Self { + self.owner_scope_id = owner_scope_id.into(); + self.owner_actor_id = owner_actor_id; + self + } + /// Update the channel config before starting. /// /// Merges the provided values into the existing config JSON. @@ -843,6 +890,7 @@ impl WasmChannel { async fn update_broadcast_metadata(&self, metadata: &str) { do_update_broadcast_metadata( &self.name, + &self.owner_scope_id, metadata, &self.last_broadcast_metadata, self.settings_store.as_ref(), @@ -854,7 +902,7 @@ impl WasmChannel { async fn load_broadcast_metadata(&self) { if let Some(ref store) = self.settings_store { match store - .get_setting("default", &self.broadcast_metadata_key()) + .get_setting(&self.owner_scope_id, &self.broadcast_metadata_key()) .await { Ok(Some(serde_json::Value::String(meta))) => { @@ -864,7 +912,27 @@ impl WasmChannel { "Restored broadcast metadata from settings" ); } - Ok(_) => {} + Ok(_) => { + if self.owner_scope_id != "default" { + match store.get_setting("default", &self.broadcast_metadata_key()).await { + Ok(Some(serde_json::Value::String(meta))) => { + *self.last_broadcast_metadata.write().await = Some(meta); + tracing::debug!( + channel = %self.name, + "Restored legacy owner broadcast metadata from default scope" + ); + } + Ok(_) => {} + Err(e) => { + tracing::warn!( + channel = %self.name, + "Failed to load legacy broadcast metadata: {}", + e + ); + } + } + } + } Err(e) => { tracing::warn!( channel = %self.name, @@ -1065,7 +1133,11 @@ impl WasmChannel { let channel_name = self.name.clone(); let credentials = self.get_credentials().await; let host_credentials = - resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref()) + resolve_channel_host_credentials( + &self.capabilities, + self.secrets_store.as_deref(), + &self.owner_scope_id, + ) .await; let pairing_store = self.pairing_store.clone(); let workspace_store = self.workspace_store.clone(); @@ -1205,7 +1277,11 @@ impl WasmChannel { let timeout = self.runtime.config().callback_timeout; let credentials = self.get_credentials().await; let host_credentials = - resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref()) + resolve_channel_host_credentials( + &self.capabilities, + self.secrets_store.as_deref(), + &self.owner_scope_id, + ) .await; let pairing_store = self.pairing_store.clone(); let workspace_store = self.workspace_store.clone(); @@ -1308,7 +1384,11 @@ impl WasmChannel { let channel_name = self.name.clone(); let credentials = self.get_credentials().await; let host_credentials = - resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref()) + resolve_channel_host_credentials( + &self.capabilities, + self.secrets_store.as_deref(), + &self.owner_scope_id, + ) .await; let pairing_store = self.pairing_store.clone(); let workspace_store = self.workspace_store.clone(); @@ -1415,7 +1495,11 @@ impl WasmChannel { let channel_name = self.name.clone(); let credentials = self.get_credentials().await; let host_credentials = - resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref()) + resolve_channel_host_credentials( + &self.capabilities, + self.secrets_store.as_deref(), + &self.owner_scope_id, + ) .await; let pairing_store = self.pairing_store.clone(); @@ -1556,7 +1640,11 @@ impl WasmChannel { let channel_name = self.name.clone(); let credentials = self.get_credentials().await; let host_credentials = - resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref()) + resolve_channel_host_credentials( + &self.capabilities, + self.secrets_store.as_deref(), + &self.owner_scope_id, + ) .await; let pairing_store = self.pairing_store.clone(); @@ -1660,7 +1748,11 @@ impl WasmChannel { let channel_name = self.name.clone(); let credentials = self.get_credentials().await; let host_credentials = - resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref()) + resolve_channel_host_credentials( + &self.capabilities, + self.secrets_store.as_deref(), + &self.owner_scope_id, + ) .await; let pairing_store = self.pairing_store.clone(); @@ -1831,6 +1923,7 @@ impl WasmChannel { let repeater_host_credentials = resolve_channel_host_credentials( &self.capabilities, self.secrets_store.as_deref(), + &self.owner_scope_id, ) .await; let pairing_store = self.pairing_store.clone(); @@ -2027,8 +2120,16 @@ impl WasmChannel { } } + let (resolved_user_id, is_owner_sender) = resolve_message_scope( + &self.owner_scope_id, + self.owner_actor_id.as_deref(), + &emitted.user_id, + ); + // Convert to IncomingMessage - let mut msg = IncomingMessage::new(&self.name, &emitted.user_id, &emitted.content); + let mut msg = IncomingMessage::new(&self.name, &resolved_user_id, &emitted.content) + .with_owner_id(&self.owner_scope_id) + .with_sender_id(&emitted.user_id); if let Some(name) = emitted.user_name { msg = msg.with_user_name(name); @@ -2060,9 +2161,9 @@ impl WasmChannel { } // Parse metadata JSON - if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) { - msg = msg.with_metadata(metadata); - // Store for broadcast routing (chat_id etc.) + msg = apply_emitted_metadata(msg, &emitted.metadata_json); + if is_owner_sender { + // Store for owner-target routing (chat_id etc.). self.update_broadcast_metadata(&emitted.metadata_json).await; } @@ -2112,6 +2213,8 @@ impl WasmChannel { let last_broadcast_metadata = self.last_broadcast_metadata.clone(); let settings_store = self.settings_store.clone(); let poll_secrets_store = self.secrets_store.clone(); + let owner_scope_id = self.owner_scope_id.clone(); + let owner_actor_id = self.owner_actor_id.clone(); tokio::spawn(async move { let mut interval_timer = tokio::time::interval(interval); @@ -2129,6 +2232,7 @@ impl WasmChannel { let host_credentials = resolve_channel_host_credentials( &poll_capabilities, poll_secrets_store.as_deref(), + &owner_scope_id, ) .await; @@ -2151,6 +2255,8 @@ impl WasmChannel { if !emitted_messages.is_empty() && let Err(e) = Self::dispatch_emitted_messages( &channel_name, + &owner_scope_id, + owner_actor_id.as_deref(), emitted_messages, &message_tx, &rate_limiter, @@ -2278,6 +2384,8 @@ impl WasmChannel { /// access to `&self`. async fn dispatch_emitted_messages( channel_name: &str, + owner_scope_id: &str, + owner_actor_id: Option<&str>, messages: Vec, message_tx: &RwLock>>, rate_limiter: &RwLock, @@ -2319,8 +2427,13 @@ impl WasmChannel { } } + let (resolved_user_id, is_owner_sender) = + resolve_message_scope(owner_scope_id, owner_actor_id, &emitted.user_id); + // Convert to IncomingMessage - let mut msg = IncomingMessage::new(channel_name, &emitted.user_id, &emitted.content); + let mut msg = IncomingMessage::new(channel_name, &resolved_user_id, &emitted.content) + .with_owner_id(owner_scope_id) + .with_sender_id(&emitted.user_id); if let Some(name) = emitted.user_name { msg = msg.with_user_name(name); @@ -2351,12 +2464,12 @@ impl WasmChannel { msg = msg.with_attachments(incoming_attachments); } - // Parse metadata JSON - if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) { - msg = msg.with_metadata(metadata); - // Store for broadcast routing (chat_id etc.) + msg = apply_emitted_metadata(msg, &emitted.metadata_json); + if is_owner_sender { + // Store for owner-target routing (chat_id etc.) do_update_broadcast_metadata( channel_name, + owner_scope_id, &emitted.metadata_json, last_broadcast_metadata, settings_store, @@ -2490,8 +2603,11 @@ impl Channel for WasmChannel { // 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(); - // Store for broadcast routing (chat_id etc.) - self.update_broadcast_metadata(&metadata_json).await; + // Store for owner-target routing (chat_id etc.) only when the configured + // owner is the actor in this conversation. + if msg.user_id == self.owner_scope_id { + self.update_broadcast_metadata(&metadata_json).await; + } self.call_on_respond( msg.id, &response.content, @@ -2514,8 +2630,39 @@ impl Channel for WasmChannel { response: OutgoingResponse, ) -> Result<(), ChannelError> { self.cancel_typing_task().await; + let resolved_target = if user_id == self.owner_scope_id || user_id == "default" { + let metadata = self.last_broadcast_metadata.read().await.clone().ok_or_else(|| { + ChannelError::SendFailed { + name: self.name.clone(), + reason: format!( + "No stored owner routing target for channel '{}'. Send a message from the owner on this channel first.", + self.name + ), + } + })?; + + let metadata: serde_json::Value = serde_json::from_str(&metadata).map_err(|e| { + ChannelError::SendFailed { + name: self.name.clone(), + reason: format!("Invalid stored owner routing metadata: {e}"), + } + })?; + + crate::channels::routing_target_from_metadata(&metadata).ok_or_else(|| { + ChannelError::SendFailed { + name: self.name.clone(), + reason: format!( + "Stored owner routing metadata for channel '{}' is missing a delivery target.", + self.name + ), + } + })? + } else { + user_id.to_string() + }; + self.call_on_broadcast( - user_id, + &resolved_target, &response.content, response.thread_id.as_deref(), &response.attachments, @@ -2931,6 +3078,7 @@ fn extract_host_from_url(url: &str) -> Option { async fn resolve_channel_host_credentials( capabilities: &ChannelCapabilities, store: Option<&(dyn SecretsStore + Send + Sync)>, + owner_scope_id: &str, ) -> Vec { let store = match store { Some(s) => s, @@ -2957,7 +3105,7 @@ async fn resolve_channel_host_credentials( continue; } - let secret = match store.get_decrypted("default", &mapping.secret_name).await { + let secret = match store.get_decrypted(owner_scope_id, &mapping.secret_name).await { Ok(s) => s, Err(e) => { tracing::debug!( @@ -3210,6 +3358,8 @@ mod tests { let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None)); let result = WasmChannel::dispatch_emitted_messages( "test-channel", + "default", + None, messages, &message_tx, &rate_limiter, @@ -3251,6 +3401,8 @@ mod tests { let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None)); let result = WasmChannel::dispatch_emitted_messages( "test-channel", + "default", + None, messages, &message_tx, &rate_limiter, @@ -4256,6 +4408,8 @@ mod tests { let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None)); let result = WasmChannel::dispatch_emitted_messages( "test-channel", + "default", + None, messages, &message_tx, &rate_limiter, @@ -4293,6 +4447,138 @@ mod tests { ); } + #[tokio::test] + async fn test_dispatch_emitted_messages_owner_binding_sets_owner_scope() { + use crate::channels::wasm::host::EmittedMessage; + + let (tx, mut rx) = tokio::sync::mpsc::channel(10); + let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx))); + let rate_limiter = Arc::new(tokio::sync::RwLock::new( + crate::channels::wasm::host::ChannelEmitRateLimiter::new( + crate::channels::wasm::capabilities::EmitRateLimitConfig::default(), + ), + )); + let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None)); + + let messages = vec![EmittedMessage::new("telegram-owner", "Hello from owner") + .with_metadata(r#"{"chat_id":12345}"#)]; + + let result = WasmChannel::dispatch_emitted_messages( + "telegram", + "owner-scope", + Some("telegram-owner"), + messages, + &message_tx, + &rate_limiter, + &last_broadcast_metadata, + None, + ) + .await; + + assert!(result.is_ok()); + + let msg = rx.try_recv().expect("Should receive message"); + assert_eq!(msg.user_id, "owner-scope"); + assert_eq!(msg.owner_id, "owner-scope"); + assert_eq!(msg.sender_id, "telegram-owner"); + assert_eq!(msg.conversation_scope(), Some("12345")); + assert_eq!( + last_broadcast_metadata.read().await.as_deref(), + Some(r#"{"chat_id":12345}"#) + ); + } + + #[tokio::test] + async fn test_dispatch_emitted_messages_guest_sender_stays_isolated() { + use crate::channels::wasm::host::EmittedMessage; + + let (tx, mut rx) = tokio::sync::mpsc::channel(10); + let message_tx = Arc::new(tokio::sync::RwLock::new(Some(tx))); + let rate_limiter = Arc::new(tokio::sync::RwLock::new( + crate::channels::wasm::host::ChannelEmitRateLimiter::new( + crate::channels::wasm::capabilities::EmitRateLimitConfig::default(), + ), + )); + let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None)); + + let messages = vec![ + EmittedMessage::new("guest-42", "Hello from guest").with_metadata(r#"{"chat_id":999}"#), + ]; + + let result = WasmChannel::dispatch_emitted_messages( + "telegram", + "owner-scope", + Some("telegram-owner"), + messages, + &message_tx, + &rate_limiter, + &last_broadcast_metadata, + None, + ) + .await; + + assert!(result.is_ok()); + + let msg = rx.try_recv().expect("Should receive message"); + assert_eq!(msg.user_id, "guest-42"); + assert_eq!(msg.owner_id, "owner-scope"); + assert_eq!(msg.sender_id, "guest-42"); + assert_eq!(msg.conversation_scope(), Some("999")); + assert!(last_broadcast_metadata.read().await.is_none()); + } + + #[tokio::test] + async fn test_broadcast_owner_scope_uses_stored_owner_metadata() { + let channel = + create_test_channel().with_owner_binding("owner-scope", Some("telegram-owner".to_string())); + + *channel.last_broadcast_metadata.write().await = Some(r#"{"chat_id":12345}"#.to_string()); + + let result = channel + .broadcast( + "owner-scope", + crate::channels::OutgoingResponse::text("hello owner"), + ) + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_broadcast_legacy_default_uses_owner_metadata() { + let channel = + create_test_channel().with_owner_binding("owner-scope", Some("telegram-owner".to_string())); + + *channel.last_broadcast_metadata.write().await = Some(r#"{"chat_id":12345}"#.to_string()); + + let result = channel + .broadcast("default", crate::channels::OutgoingResponse::text("legacy hello")) + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_broadcast_owner_scope_requires_stored_metadata() { + let channel = + create_test_channel().with_owner_binding("owner-scope", Some("telegram-owner".to_string())); + + let result = channel + .broadcast( + "owner-scope", + crate::channels::OutgoingResponse::text("hello owner"), + ) + .await; + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + err.contains("Send a message from the owner on this channel first"), + "expected missing owner routing metadata error, got: {}", + err + ); + } + #[tokio::test] async fn test_dispatch_emitted_messages_no_attachments_backward_compat() { use crate::channels::wasm::host::EmittedMessage; @@ -4311,6 +4597,8 @@ mod tests { let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None)); let result = WasmChannel::dispatch_emitted_messages( "test-channel", + "default", + None, messages, &message_tx, &rate_limiter, diff --git a/src/cli/doctor.rs b/src/cli/doctor.rs index ee0b2be8b06..dfc04de7672 100644 --- a/src/cli/doctor.rs +++ b/src/cli/doctor.rs @@ -405,10 +405,11 @@ fn check_routines_config() -> CheckResult { fn check_gateway_config(settings: &Settings) -> CheckResult { // Use the same resolve() path as runtime so invalid env values // (e.g. GATEWAY_PORT=abc) are caught here too. - let tunnel_enabled = crate::config::TunnelConfig::resolve(settings) - .map(|t| t.is_enabled()) - .unwrap_or(false); - match crate::config::ChannelsConfig::resolve(settings, tunnel_enabled) { + let owner_id = match crate::config::resolve_owner_id(settings) { + Ok(owner_id) => owner_id, + Err(e) => return CheckResult::Fail(format!("config error: {e}")), + }; + match crate::config::ChannelsConfig::resolve(settings, &owner_id) { Ok(channels) => match channels.gateway { Some(gw) => { if gw.auth_token.is_some() { diff --git a/src/cli/routines.rs b/src/cli/routines.rs index 852fc41fddf..fd8d8bbdfe4 100644 --- a/src/cli/routines.rs +++ b/src/cli/routines.rs @@ -340,7 +340,7 @@ async fn create( }, notify: NotifyConfig { channel: notify_channel, - user: user_id.to_string(), + user: Some(user_id.to_string()), on_attention: true, on_failure: true, on_success: false, diff --git a/src/config/channels.rs b/src/config/channels.rs index 511f31c73bc..7578d0534fd 100644 --- a/src/config/channels.rs +++ b/src/config/channels.rs @@ -91,87 +91,54 @@ pub struct SignalConfig { } impl ChannelsConfig { - /// Resolve channels config following `env > settings > default` for every field. - pub(crate) fn resolve(settings: &Settings, tunnel_enabled: bool) -> Result { - let cs = &settings.channels; - - // --- HTTP webhook --- - // HTTP is enabled when env vars are set OR settings has it enabled. - let http_enabled_by_env = - optional_env("HTTP_PORT")?.is_some() || optional_env("HTTP_HOST")?.is_some(); - // When a tunnel is configured, default to loopback since external - // traffic arrives through the tunnel. Without a tunnel the webhook - // server needs to accept connections from the network directly. - let default_host = if tunnel_enabled { - "127.0.0.1" - } else { - "0.0.0.0" - }; - let http = if http_enabled_by_env || cs.http_enabled { + pub(crate) fn resolve(settings: &Settings, owner_id: &str) -> Result { + let http = if optional_env("HTTP_PORT")?.is_some() || optional_env("HTTP_HOST")?.is_some() { Some(HttpConfig { - host: optional_env("HTTP_HOST")? - .or_else(|| cs.http_host.clone()) - .unwrap_or_else(|| default_host.to_string()), - port: parse_optional_env("HTTP_PORT", cs.http_port.unwrap_or(8080))?, + host: optional_env("HTTP_HOST")?.unwrap_or_else(|| "0.0.0.0".to_string()), + port: parse_optional_env("HTTP_PORT", 8080)?, webhook_secret: optional_env("HTTP_WEBHOOK_SECRET")?.map(SecretString::from), - user_id: optional_env("HTTP_USER_ID")?.unwrap_or_else(|| "http".to_string()), + user_id: owner_id.to_string(), }) } else { None }; - // --- Web gateway --- - let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", cs.gateway_enabled)?; + let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", true)?; let gateway = if gateway_enabled { Some(GatewayConfig { - host: optional_env("GATEWAY_HOST")? - .or_else(|| cs.gateway_host.clone()) - .unwrap_or_else(|| "127.0.0.1".to_string()), - port: parse_optional_env( - "GATEWAY_PORT", - cs.gateway_port.unwrap_or(DEFAULT_GATEWAY_PORT), - )?, - auth_token: optional_env("GATEWAY_AUTH_TOKEN")? - .or_else(|| cs.gateway_auth_token.clone()), - user_id: optional_env("GATEWAY_USER_ID")? - .or_else(|| cs.gateway_user_id.clone()) - .unwrap_or_else(|| "default".to_string()), + host: optional_env("GATEWAY_HOST")?.unwrap_or_else(|| "127.0.0.1".to_string()), + port: parse_optional_env("GATEWAY_PORT", 3000)?, + auth_token: optional_env("GATEWAY_AUTH_TOKEN")?, + user_id: owner_id.to_string(), }) } else { None }; - // --- Signal --- - let signal_url = optional_env("SIGNAL_HTTP_URL")?.or_else(|| cs.signal_http_url.clone()); - let signal = if let Some(http_url) = signal_url { - let account = optional_env("SIGNAL_ACCOUNT")? - .or_else(|| cs.signal_account.clone()) - .ok_or(ConfigError::InvalidValue { - key: "SIGNAL_ACCOUNT".to_string(), - message: "SIGNAL_ACCOUNT is required when Signal is enabled".to_string(), - })?; - let allow_from_str = - optional_env("SIGNAL_ALLOW_FROM")?.or_else(|| cs.signal_allow_from.clone()); - let allow_from = match allow_from_str { + let signal = if let Some(http_url) = optional_env("SIGNAL_HTTP_URL")? { + let account = optional_env("SIGNAL_ACCOUNT")?.ok_or(ConfigError::InvalidValue { + key: "SIGNAL_ACCOUNT".to_string(), + message: "SIGNAL_ACCOUNT is required when SIGNAL_HTTP_URL is set".to_string(), + })?; + let allow_from = match std::env::var_os("SIGNAL_ALLOW_FROM") { None => vec![account.clone()], - Some(s) => s - .split(',') - .map(|e| e.trim().to_string()) - .filter(|s| !s.is_empty()) - .collect(), + Some(val) => { + let s = val.to_string_lossy(); + s.split(',') + .map(|e| e.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() + } }; - let dm_policy = optional_env("SIGNAL_DM_POLICY")? - .or_else(|| cs.signal_dm_policy.clone()) - .unwrap_or_else(|| "pairing".to_string()); - let group_policy = optional_env("SIGNAL_GROUP_POLICY")? - .or_else(|| cs.signal_group_policy.clone()) - .unwrap_or_else(|| "allowlist".to_string()); + let dm_policy = + optional_env("SIGNAL_DM_POLICY")?.unwrap_or_else(|| "pairing".to_string()); + let group_policy = + optional_env("SIGNAL_GROUP_POLICY")?.unwrap_or_else(|| "allowlist".to_string()); Some(SignalConfig { http_url, account, allow_from, allow_from_groups: optional_env("SIGNAL_ALLOW_FROM_GROUPS")? - .or_else(|| cs.signal_allow_from_groups.clone()) .map(|s| { s.split(',') .map(|e| e.trim().to_string()) @@ -182,7 +149,6 @@ impl ChannelsConfig { dm_policy, group_policy, group_allow_from: optional_env("SIGNAL_GROUP_ALLOW_FROM")? - .or_else(|| cs.signal_group_allow_from.clone()) .map(|s| { s.split(',') .map(|e| e.trim().to_string()) @@ -201,17 +167,9 @@ impl ChannelsConfig { None }; - // --- CLI --- - let cli_enabled = parse_bool_env("CLI_ENABLED", cs.cli_enabled)?; - - // --- WASM channels --- - let wasm_channels_dir = optional_env("WASM_CHANNELS_DIR")? - .map(PathBuf::from) - .or_else(|| cs.wasm_channels_dir.clone()) - .unwrap_or_else(default_channels_dir); - - let wasm_channels_enabled = - parse_bool_env("WASM_CHANNELS_ENABLED", cs.wasm_channels_enabled)?; + let cli_enabled = optional_env("CLI_ENABLED")? + .map(|s| s.to_lowercase() != "false" && s != "0") + .unwrap_or(true); Ok(Self { cli: CliConfig { @@ -220,10 +178,12 @@ impl ChannelsConfig { http, gateway, signal, - wasm_channels_dir, - wasm_channels_enabled, + wasm_channels_dir: optional_env("WASM_CHANNELS_DIR")? + .map(PathBuf::from) + .unwrap_or_else(default_channels_dir), + wasm_channels_enabled: parse_bool_env("WASM_CHANNELS_ENABLED", true)?, wasm_channel_owner_ids: { - let mut ids = cs.wasm_channel_owner_ids.clone(); + let mut ids = settings.channels.wasm_channel_owner_ids.clone(); // Backwards compat: TELEGRAM_OWNER_ID env var if let Some(id_str) = optional_env("TELEGRAM_OWNER_ID")? { let id: i64 = id_str.parse().map_err(|e: std::num::ParseIntError| { @@ -240,10 +200,6 @@ impl ChannelsConfig { } } -/// Default gateway port — used both in `resolve()` and as the fallback in -/// other modules that need to construct a gateway URL. -pub const DEFAULT_GATEWAY_PORT: u16 = 3000; - /// Get the default channels directory (~/.ironclaw/channels/). fn default_channels_dir() -> PathBuf { ironclaw_base_dir().join("channels") @@ -398,69 +354,6 @@ mod tests { assert!(!cfg.wasm_channels_enabled); } - /// When a tunnel is active and HTTP_HOST is not explicitly set, the - /// webhook server should default to loopback to avoid unnecessary exposure. - #[test] - fn http_host_defaults_to_loopback_with_tunnel() { - // Set HTTP_PORT to trigger HttpConfig creation, but leave HTTP_HOST unset - // so the default kicks in. - unsafe { - std::env::set_var("HTTP_PORT", "9999"); - std::env::remove_var("HTTP_HOST"); - } - let settings = crate::settings::Settings::default(); - let cfg = ChannelsConfig::resolve(&settings, true).unwrap(); - unsafe { - std::env::remove_var("HTTP_PORT"); - } - let http = cfg.http.expect("HttpConfig should be present"); - assert_eq!( - http.host, "127.0.0.1", - "tunnel active should default to loopback" - ); - assert_eq!(http.port, 9999); - } - - /// Without a tunnel, the webhook server defaults to 0.0.0.0 so external - /// services can reach it directly. - #[test] - fn http_host_defaults_to_all_interfaces_without_tunnel() { - unsafe { - std::env::set_var("HTTP_PORT", "9998"); - std::env::remove_var("HTTP_HOST"); - } - let settings = crate::settings::Settings::default(); - let cfg = ChannelsConfig::resolve(&settings, false).unwrap(); - unsafe { - std::env::remove_var("HTTP_PORT"); - } - let http = cfg.http.expect("HttpConfig should be present"); - assert_eq!( - http.host, "0.0.0.0", - "no tunnel should default to all interfaces" - ); - } - - /// An explicit HTTP_HOST always wins regardless of tunnel state. - #[test] - fn explicit_http_host_overrides_tunnel_default() { - unsafe { - std::env::set_var("HTTP_PORT", "9997"); - std::env::set_var("HTTP_HOST", "192.168.1.50"); - } - let settings = crate::settings::Settings::default(); - let cfg = ChannelsConfig::resolve(&settings, true).unwrap(); - unsafe { - std::env::remove_var("HTTP_PORT"); - std::env::remove_var("HTTP_HOST"); - } - let http = cfg.http.expect("HttpConfig should be present"); - assert_eq!( - http.host, "192.168.1.50", - "explicit host should override tunnel default" - ); - } - #[test] fn default_channels_dir_ends_with_channels() { let dir = default_channels_dir(); @@ -469,244 +362,4 @@ mod tests { "expected path ending in 'channels', got: {dir:?}" ); } - - #[test] - fn default_gateway_port_constant() { - assert_eq!(DEFAULT_GATEWAY_PORT, 3000); - } - - /// With default settings and no env vars, gateway should use defaults. - #[test] - fn resolve_gateway_defaults_from_settings() { - let _lock = crate::config::helpers::ENV_MUTEX.lock(); - // Clear env vars that would interfere - unsafe { - std::env::remove_var("GATEWAY_ENABLED"); - std::env::remove_var("GATEWAY_HOST"); - std::env::remove_var("GATEWAY_PORT"); - std::env::remove_var("GATEWAY_AUTH_TOKEN"); - std::env::remove_var("GATEWAY_USER_ID"); - std::env::remove_var("HTTP_PORT"); - std::env::remove_var("HTTP_HOST"); - std::env::remove_var("SIGNAL_HTTP_URL"); - std::env::remove_var("CLI_ENABLED"); - std::env::remove_var("WASM_CHANNELS_DIR"); - std::env::remove_var("WASM_CHANNELS_ENABLED"); - std::env::remove_var("TELEGRAM_OWNER_ID"); - } - - let settings = crate::settings::Settings::default(); - let cfg = ChannelsConfig::resolve(&settings, false).unwrap(); - - let gw = cfg.gateway.expect("gateway should be enabled by default"); - assert_eq!(gw.host, "127.0.0.1"); - assert_eq!(gw.port, DEFAULT_GATEWAY_PORT); - assert!(gw.auth_token.is_none()); - assert_eq!(gw.user_id, "default"); - } - - /// Settings values should be used when no env vars are set. - #[test] - fn resolve_gateway_from_settings() { - let _lock = crate::config::helpers::ENV_MUTEX.lock(); - unsafe { - std::env::remove_var("GATEWAY_ENABLED"); - std::env::remove_var("GATEWAY_HOST"); - std::env::remove_var("GATEWAY_PORT"); - std::env::remove_var("GATEWAY_AUTH_TOKEN"); - std::env::remove_var("GATEWAY_USER_ID"); - std::env::remove_var("HTTP_PORT"); - std::env::remove_var("HTTP_HOST"); - std::env::remove_var("SIGNAL_HTTP_URL"); - std::env::remove_var("CLI_ENABLED"); - std::env::remove_var("WASM_CHANNELS_DIR"); - std::env::remove_var("WASM_CHANNELS_ENABLED"); - std::env::remove_var("TELEGRAM_OWNER_ID"); - } - - let mut settings = crate::settings::Settings::default(); - settings.channels.gateway_port = Some(4000); - settings.channels.gateway_host = Some("0.0.0.0".to_string()); - settings.channels.gateway_auth_token = Some("db-token-123".to_string()); - settings.channels.gateway_user_id = Some("myuser".to_string()); - - let cfg = ChannelsConfig::resolve(&settings, false).unwrap(); - let gw = cfg.gateway.expect("gateway should be enabled"); - assert_eq!(gw.port, 4000); - assert_eq!(gw.host, "0.0.0.0"); - assert_eq!(gw.auth_token.as_deref(), Some("db-token-123")); - assert_eq!(gw.user_id, "myuser"); - } - - /// Env vars should override settings values. - #[test] - fn resolve_env_overrides_settings() { - let _lock = crate::config::helpers::ENV_MUTEX.lock(); - unsafe { - std::env::set_var("GATEWAY_PORT", "5000"); - std::env::set_var("GATEWAY_HOST", "10.0.0.1"); - std::env::set_var("GATEWAY_AUTH_TOKEN", "env-token"); - std::env::remove_var("GATEWAY_ENABLED"); - std::env::remove_var("GATEWAY_USER_ID"); - std::env::remove_var("HTTP_PORT"); - std::env::remove_var("HTTP_HOST"); - std::env::remove_var("SIGNAL_HTTP_URL"); - std::env::remove_var("CLI_ENABLED"); - std::env::remove_var("WASM_CHANNELS_DIR"); - std::env::remove_var("WASM_CHANNELS_ENABLED"); - std::env::remove_var("TELEGRAM_OWNER_ID"); - } - - let mut settings = crate::settings::Settings::default(); - settings.channels.gateway_port = Some(4000); - settings.channels.gateway_host = Some("0.0.0.0".to_string()); - settings.channels.gateway_auth_token = Some("db-token".to_string()); - - let cfg = ChannelsConfig::resolve(&settings, false).unwrap(); - let gw = cfg.gateway.expect("gateway should be enabled"); - assert_eq!(gw.port, 5000, "env should override settings"); - assert_eq!(gw.host, "10.0.0.1", "env should override settings"); - assert_eq!( - gw.auth_token.as_deref(), - Some("env-token"), - "env should override settings" - ); - - // Cleanup - unsafe { - std::env::remove_var("GATEWAY_PORT"); - std::env::remove_var("GATEWAY_HOST"); - std::env::remove_var("GATEWAY_AUTH_TOKEN"); - } - } - - /// CLI enabled should fall back to settings. - #[test] - fn resolve_cli_enabled_from_settings() { - let _lock = crate::config::helpers::ENV_MUTEX.lock(); - unsafe { - std::env::remove_var("CLI_ENABLED"); - std::env::remove_var("GATEWAY_ENABLED"); - std::env::remove_var("GATEWAY_HOST"); - std::env::remove_var("GATEWAY_PORT"); - std::env::remove_var("GATEWAY_AUTH_TOKEN"); - std::env::remove_var("GATEWAY_USER_ID"); - std::env::remove_var("HTTP_PORT"); - std::env::remove_var("HTTP_HOST"); - std::env::remove_var("SIGNAL_HTTP_URL"); - std::env::remove_var("WASM_CHANNELS_DIR"); - std::env::remove_var("WASM_CHANNELS_ENABLED"); - std::env::remove_var("TELEGRAM_OWNER_ID"); - } - - let mut settings = crate::settings::Settings::default(); - settings.channels.cli_enabled = false; - - let cfg = ChannelsConfig::resolve(&settings, false).unwrap(); - assert!(!cfg.cli.enabled, "settings should disable CLI"); - } - - /// HTTP channel should activate when settings has it enabled. - #[test] - fn resolve_http_from_settings() { - let _lock = crate::config::helpers::ENV_MUTEX.lock(); - unsafe { - std::env::remove_var("HTTP_PORT"); - std::env::remove_var("HTTP_HOST"); - std::env::remove_var("HTTP_WEBHOOK_SECRET"); - std::env::remove_var("HTTP_USER_ID"); - std::env::remove_var("GATEWAY_ENABLED"); - std::env::remove_var("GATEWAY_HOST"); - std::env::remove_var("GATEWAY_PORT"); - std::env::remove_var("GATEWAY_AUTH_TOKEN"); - std::env::remove_var("GATEWAY_USER_ID"); - std::env::remove_var("SIGNAL_HTTP_URL"); - std::env::remove_var("CLI_ENABLED"); - std::env::remove_var("WASM_CHANNELS_DIR"); - std::env::remove_var("WASM_CHANNELS_ENABLED"); - std::env::remove_var("TELEGRAM_OWNER_ID"); - } - - let mut settings = crate::settings::Settings::default(); - settings.channels.http_enabled = true; - settings.channels.http_port = Some(9090); - settings.channels.http_host = Some("10.0.0.1".to_string()); - - let cfg = ChannelsConfig::resolve(&settings, false).unwrap(); - let http = cfg.http.expect("HTTP should be enabled from settings"); - assert_eq!(http.port, 9090); - assert_eq!(http.host, "10.0.0.1"); - } - - /// Settings round-trip through DB map for new gateway fields. - #[test] - fn settings_gateway_fields_db_roundtrip() { - let mut settings = crate::settings::Settings::default(); - settings.channels.gateway_port = Some(4000); - settings.channels.gateway_host = Some("0.0.0.0".to_string()); - settings.channels.gateway_auth_token = Some("tok-abc".to_string()); - settings.channels.gateway_user_id = Some("myuser".to_string()); - settings.channels.cli_enabled = false; - - let map = settings.to_db_map(); - let restored = crate::settings::Settings::from_db_map(&map); - - assert_eq!(restored.channels.gateway_port, Some(4000)); - assert_eq!(restored.channels.gateway_host.as_deref(), Some("0.0.0.0")); - assert_eq!( - restored.channels.gateway_auth_token.as_deref(), - Some("tok-abc") - ); - assert_eq!(restored.channels.gateway_user_id.as_deref(), Some("myuser")); - assert!(!restored.channels.cli_enabled); - } - - /// Invalid boolean env values must produce errors, not silently degrade. - #[test] - fn resolve_rejects_invalid_bool_env() { - let _lock = crate::config::helpers::ENV_MUTEX.lock(); - let settings = crate::settings::Settings::default(); - - // GATEWAY_ENABLED=maybe should error - unsafe { - std::env::set_var("GATEWAY_ENABLED", "maybe"); - std::env::remove_var("HTTP_PORT"); - std::env::remove_var("HTTP_HOST"); - std::env::remove_var("SIGNAL_HTTP_URL"); - std::env::remove_var("CLI_ENABLED"); - std::env::remove_var("WASM_CHANNELS_ENABLED"); - std::env::remove_var("GATEWAY_PORT"); - std::env::remove_var("GATEWAY_HOST"); - std::env::remove_var("GATEWAY_AUTH_TOKEN"); - std::env::remove_var("GATEWAY_USER_ID"); - std::env::remove_var("WASM_CHANNELS_DIR"); - std::env::remove_var("TELEGRAM_OWNER_ID"); - } - let result = ChannelsConfig::resolve(&settings, false); - assert!(result.is_err(), "GATEWAY_ENABLED=maybe should be rejected"); - - // CLI_ENABLED=on should error - unsafe { - std::env::remove_var("GATEWAY_ENABLED"); - std::env::set_var("CLI_ENABLED", "on"); - } - let result = ChannelsConfig::resolve(&settings, false); - assert!(result.is_err(), "CLI_ENABLED=on should be rejected"); - - // WASM_CHANNELS_ENABLED=yes should error - unsafe { - std::env::remove_var("CLI_ENABLED"); - std::env::set_var("WASM_CHANNELS_ENABLED", "yes"); - } - let result = ChannelsConfig::resolve(&settings, false); - assert!( - result.is_err(), - "WASM_CHANNELS_ENABLED=yes should be rejected" - ); - - // Cleanup - unsafe { - std::env::remove_var("WASM_CHANNELS_ENABLED"); - } - } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 1c81329e110..85b64e1532b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -26,7 +26,7 @@ mod tunnel; mod wasm; use std::collections::HashMap; -use std::sync::{LazyLock, Mutex}; +use std::sync::{LazyLock, Mutex, Once}; use crate::error::ConfigError; use crate::settings::Settings; @@ -34,9 +34,7 @@ use crate::settings::Settings; // Re-export all public types so `crate::config::FooConfig` continues to work. pub use self::agent::AgentConfig; pub use self::builder::BuilderModeConfig; -pub use self::channels::{ - ChannelsConfig, CliConfig, DEFAULT_GATEWAY_PORT, GatewayConfig, HttpConfig, SignalConfig, -}; +pub use self::channels::{ChannelsConfig, CliConfig, GatewayConfig, HttpConfig, SignalConfig}; pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsql_path}; pub use self::embeddings::EmbeddingsConfig; pub use self::heartbeat::HeartbeatConfig; @@ -74,10 +72,12 @@ pub use self::helpers::{env_or_override, set_runtime_env}; /// their data. Whichever runs first initialises the map; the second merges in. static INJECTED_VARS: LazyLock>> = LazyLock::new(|| Mutex::new(HashMap::new())); +static WARNED_EXPLICIT_DEFAULT_OWNER_ID: Once = Once::new(); /// Main configuration for the agent. #[derive(Debug, Clone)] pub struct Config { + pub owner_id: String, pub database: DatabaseConfig, pub llm: LlmConfig, pub embeddings: EmbeddingsConfig, @@ -118,6 +118,7 @@ impl Config { installed_skills_dir: std::path::PathBuf, ) -> Self { Self { + owner_id: "default".to_string(), database: DatabaseConfig { backend: DatabaseBackend::LibSql, url: secrecy::SecretString::from("unused://test".to_string()), @@ -228,13 +229,7 @@ impl Config { pub async fn from_env_with_toml( toml_path: Option<&std::path::Path>, ) -> Result { - let _ = dotenvy::dotenv(); - crate::bootstrap::load_ironclaw_env(); - let mut settings = Settings::load(); - - // Overlay TOML config file (values win over JSON settings) - Self::apply_toml_overlay(&mut settings, toml_path)?; - + let settings = load_bootstrap_settings(toml_path)?; Self::build(&settings).await } @@ -306,26 +301,25 @@ impl Config { /// Build config from settings (shared by from_env and from_db). async fn build(settings: &Settings) -> Result { - // Resolve tunnel first so channels can default to loopback when a - // tunnel handles external exposure (no need to bind 0.0.0.0). - let tunnel = TunnelConfig::resolve(settings)?; + let owner_id = resolve_owner_id(settings)?; Ok(Self { + owner_id: owner_id.clone(), database: DatabaseConfig::resolve()?, llm: LlmConfig::resolve(settings)?, embeddings: EmbeddingsConfig::resolve(settings)?, - channels: ChannelsConfig::resolve(settings, tunnel.is_enabled())?, - tunnel, + tunnel: TunnelConfig::resolve(settings)?, + channels: ChannelsConfig::resolve(settings, &owner_id)?, agent: AgentConfig::resolve(settings)?, - safety: resolve_safety_config(settings)?, - wasm: WasmConfig::resolve(settings)?, + safety: resolve_safety_config()?, + wasm: WasmConfig::resolve()?, secrets: SecretsConfig::resolve().await?, - builder: BuilderModeConfig::resolve(settings)?, + builder: BuilderModeConfig::resolve()?, heartbeat: HeartbeatConfig::resolve(settings)?, hygiene: HygieneConfig::resolve()?, routines: RoutineConfig::resolve()?, - sandbox: SandboxModeConfig::resolve(settings)?, - claude_code: ClaudeCodeConfig::resolve(settings)?, + sandbox: SandboxModeConfig::resolve()?, + claude_code: ClaudeCodeConfig::resolve()?, skills: SkillsConfig::resolve()?, transcription: TranscriptionConfig::resolve(settings)?, search: WorkspaceSearchConfig::resolve()?, @@ -337,6 +331,43 @@ impl Config { } } +pub(crate) fn load_bootstrap_settings( + toml_path: Option<&std::path::Path>, +) -> Result { + let _ = dotenvy::dotenv(); + crate::bootstrap::load_ironclaw_env(); + + let mut settings = Settings::load(); + Config::apply_toml_overlay(&mut settings, toml_path)?; + Ok(settings) +} + +pub(crate) fn resolve_owner_id(settings: &Settings) -> Result { + let env_owner_id = self::helpers::optional_env("IRONCLAW_OWNER_ID")?; + let settings_owner_id = settings.owner_id.clone(); + let configured_owner_id = env_owner_id.clone().or(settings_owner_id.clone()); + + let owner_id = configured_owner_id + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "default".to_string()); + + if owner_id == "default" + && (env_owner_id.is_some() + || settings_owner_id + .as_deref() + .is_some_and(|value| !value.trim().is_empty())) + { + WARNED_EXPLICIT_DEFAULT_OWNER_ID.call_once(|| { + tracing::warn!( + "IRONCLAW_OWNER_ID resolved to the legacy 'default' scope explicitly; durable state will keep legacy owner behavior" + ); + }); + } + + Ok(owner_id) +} + /// Load API keys from the encrypted secrets store into a thread-safe overlay. /// /// This bridges the gap between secrets stored during onboarding and the diff --git a/src/context/state.rs b/src/context/state.rs index 768e4da6b0e..2402fd66b68 100644 --- a/src/context/state.rs +++ b/src/context/state.rs @@ -121,6 +121,9 @@ pub struct JobContext { pub state: JobState, /// User ID that owns this job (for workspace scoping). pub user_id: String, + /// Channel-specific requester/actor ID, when different from the owner scope. + #[serde(skip_serializing_if = "Option::is_none")] + pub requester_id: Option, /// Conversation ID if linked to a conversation. pub conversation_id: Option, /// Job title. @@ -202,6 +205,7 @@ impl JobContext { job_id: Uuid::new_v4(), state: JobState::Pending, user_id: user_id.into(), + requester_id: None, conversation_id: None, title: title.into(), description: description.into(), @@ -233,6 +237,12 @@ impl JobContext { self } + /// Set the channel-specific requester/actor ID. + pub fn with_requester_id(mut self, requester_id: impl Into) -> Self { + self.requester_id = Some(requester_id.into()); + self + } + /// Transition to a new state. pub fn transition_to( &mut self, diff --git a/src/db/libsql/jobs.rs b/src/db/libsql/jobs.rs index 3db3ab30786..208d348b9d6 100644 --- a/src/db/libsql/jobs.rs +++ b/src/db/libsql/jobs.rs @@ -106,6 +106,7 @@ impl JobStore for LibSqlBackend { job_id: get_text(&row, 0).parse().unwrap_or_default(), state, user_id: get_text(&row, 6), + requester_id: None, conversation_id: get_opt_text(&row, 1).and_then(|s| s.parse().ok()), title: get_text(&row, 2), description: get_text(&row, 3), diff --git a/src/db/libsql/mod.rs b/src/db/libsql/mod.rs index dcc5a8b5c4a..6f4d7df12d5 100644 --- a/src/db/libsql/mod.rs +++ b/src/db/libsql/mod.rs @@ -247,6 +247,17 @@ pub(crate) fn opt_text_owned(s: Option) -> libsql::Value { } } +pub(crate) fn normalize_notify_user(value: Option) -> Option { + value.and_then(|value| { + let trimmed = value.trim(); + if trimmed.is_empty() || trimmed == "default" { + None + } else { + Some(trimmed.to_string()) + } + }) +} + /// Extract an i64 column, defaulting to 0. pub(crate) fn get_i64(row: &libsql::Row, idx: i32) -> i64 { row.get::(idx).unwrap_or(0) @@ -378,7 +389,7 @@ pub(crate) fn row_to_routine_libsql(row: &libsql::Row) -> Result, ) -> Result { let channel_name = loaded.name().to_string(); + let owner_actor_id = wasm_channel_owner_ids + .get(channel_name.as_str()) + .map(ToString::to_string); let webhook_secret_name = loaded.webhook_secret_name(); let secret_header = loaded.webhook_secret_header().map(|s| s.to_string()); let sig_key_secret_name = loaded.signature_key_secret_name(); @@ -3475,7 +3478,11 @@ impl ExtensionManager { .ok() .map(|s| s.expose().to_string()); - let channel_arc = Arc::new(loaded.channel); + let channel_arc = Arc::new( + loaded + .channel + .with_owner_binding(self.user_id.clone(), owner_actor_id), + ); // Inject runtime config (tunnel_url, webhook_secret, owner_id) { diff --git a/src/history/store.rs b/src/history/store.rs index 17fa96fd453..04e3167f286 100644 --- a/src/history/store.rs +++ b/src/history/store.rs @@ -227,6 +227,7 @@ impl Store { job_id: row.get("id"), state, user_id: row.get::<_, String>("user_id"), + requester_id: None, conversation_id: row.get("conversation_id"), title: row.get("title"), description: row.get("description"), diff --git a/src/main.rs b/src/main.rs index 574616772d1..916eab31f01 100644 --- a/src/main.rs +++ b/src/main.rs @@ -282,9 +282,12 @@ async fn async_main() -> anyhow::Result<()> { // Create CLI channel let repl_channel = if let Some(ref msg) = cli.message { - Some(ReplChannel::with_message(msg.clone())) + Some(ReplChannel::with_message_for_user( + config.owner_id.clone(), + msg.clone(), + )) } else if config.channels.cli.enabled { - let repl = ReplChannel::new(); + let repl = ReplChannel::with_user_id(config.owner_id.clone()); repl.suppress_banner(); Some(repl) } else { @@ -311,12 +314,7 @@ async fn async_main() -> anyhow::Result<()> { webhook_routes.push(webhooks::routes(ToolWebhookState { tools: Arc::clone(&components.tools), routine_engine: Arc::clone(&shared_routine_engine_slot), - user_id: config - .channels - .gateway - .as_ref() - .map(|g| g.user_id.clone()) - .unwrap_or_else(|| "default".to_string()), + user_id: config.owner_id.clone(), secrets_store: components.secrets_store.clone(), })); @@ -775,6 +773,7 @@ async fn async_main() -> anyhow::Result<()> { let sighup_webhook_server = webhook_server.clone(); let sighup_settings_store_clone = sighup_settings_store.clone(); let sighup_secrets_store = components.secrets_store.clone(); + let sighup_owner_id = config.owner_id.clone(); let mut shutdown_rx = shutdown_tx.subscribe(); tokio::spawn(async move { @@ -805,7 +804,7 @@ async fn async_main() -> anyhow::Result<()> { if let Some(ref secrets_store) = sighup_secrets_store { // Inject HTTP webhook secret from encrypted store if let Ok(webhook_secret) = secrets_store - .get_decrypted("default", "http_webhook_secret") + .get_decrypted(&sighup_owner_id, "http_webhook_secret") .await { // Thread-safe: Uses INJECTED_VARS mutex instead of unsafe std::env::set_var @@ -821,7 +820,7 @@ async fn async_main() -> anyhow::Result<()> { // Reload config (now with secrets injected into environment) let new_config = match &sighup_settings_store_clone { Some(store) => { - ironclaw::config::Config::from_db(store.as_ref(), "default").await + ironclaw::config::Config::from_db(store.as_ref(), &sighup_owner_id).await } None => ironclaw::config::Config::from_env().await, }; diff --git a/src/settings.rs b/src/settings.rs index 2a5b6bbd214..9a0b3942a06 100644 --- a/src/settings.rs +++ b/src/settings.rs @@ -16,6 +16,14 @@ pub struct Settings { #[serde(default, alias = "setup_completed")] pub onboard_completed: bool, + /// Stable owner scope for this IronClaw instance. + /// + /// This is bootstrap configuration loaded from env / disk / TOML. We do + /// not persist it in the per-user DB settings table because the DB lookup + /// itself already requires the owner scope to be known. + #[serde(default)] + pub owner_id: Option, + // === Step 1: Database === /// Database backend: "postgres" or "libsql". #[serde(default)] @@ -733,6 +741,10 @@ impl Settings { let mut settings = Self::default(); for (key, value) in map { + if key == "owner_id" { + continue; + } + // Convert the JSONB value to a string for the existing set() method let value_str = match value { serde_json::Value::String(s) => s.clone(), @@ -772,6 +784,7 @@ impl Settings { let mut map = std::collections::HashMap::new(); collect_settings_json(&json, String::new(), &mut map); + map.remove("owner_id"); map } diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 9437d8279bf..16b2b7382ed 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -14,6 +14,8 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; +#[cfg(feature = "postgres")] +use deadpool_postgres::Config as PoolConfig; use secrecy::{ExposeSecret, SecretString}; use crate::bootstrap::ironclaw_base_dir; @@ -21,12 +23,8 @@ use crate::channels::wasm::{ ChannelCapabilitiesFile, available_channel_names, install_bundled_channel, }; use crate::config::OAUTH_PLACEHOLDER; -use crate::llm::models::{ - build_nearai_model_fetch_config, fetch_anthropic_models, fetch_ollama_models, - fetch_openai_compatible_models, fetch_openai_models, -}; use crate::llm::{SessionConfig, SessionManager}; -use crate::secrets::SecretsCrypto; +use crate::secrets::{SecretsCrypto, SecretsStore}; use crate::settings::{KeySource, Settings}; use crate::setup::channels::{ SecretsContext, setup_http, setup_signal, setup_tunnel, setup_wasm_channel, @@ -86,11 +84,14 @@ pub struct SetupConfig { pub struct SetupWizard { config: SetupConfig, settings: Settings, + owner_id: String, session_manager: Option>, - /// Backend-agnostic database trait object (created during setup). - db: Option>, - /// Backend-specific handles for secrets store and other satellite consumers. - db_handles: Option, + /// Database pool (created during setup, postgres only). + #[cfg(feature = "postgres")] + db_pool: Option, + /// libSQL backend (created during setup, libsql only). + #[cfg(feature = "libsql")] + db_backend: Option, /// Secrets crypto (created during setup). secrets_crypto: Option>, /// Cached API key from provider setup (used by model fetcher without env mutation). @@ -98,30 +99,71 @@ pub struct SetupWizard { } impl SetupWizard { - /// Create a new setup wizard. - pub fn new() -> Self { + fn owner_id(&self) -> &str { + &self.owner_id + } + + fn fallback_with_default_owner( + config: SetupConfig, + settings: Settings, + error: &crate::error::ConfigError, + ) -> Self { + tracing::warn!("Falling back to default owner scope for setup wizard: {error}"); Self { - config: SetupConfig::default(), - settings: Settings::default(), + config, + settings, + owner_id: "default".to_string(), session_manager: None, - db: None, - db_handles: None, + #[cfg(feature = "postgres")] + db_pool: None, + #[cfg(feature = "libsql")] + db_backend: None, secrets_crypto: None, llm_api_key: None, } } - /// Create a wizard with custom configuration. - pub fn with_config(config: SetupConfig) -> Self { - Self { + fn from_bootstrap_settings( + config: SetupConfig, + settings: Settings, + ) -> Result { + let owner_id = crate::config::resolve_owner_id(&settings)?; + Ok(Self { config, - settings: Settings::default(), + settings, + owner_id, session_manager: None, - db: None, - db_handles: None, + #[cfg(feature = "postgres")] + db_pool: None, + #[cfg(feature = "libsql")] + db_backend: None, secrets_crypto: None, llm_api_key: None, - } + }) + } + + /// Create a new setup wizard. + pub fn new() -> Self { + let settings = crate::config::load_bootstrap_settings(None).unwrap_or_default(); + Self::from_bootstrap_settings(SetupConfig::default(), settings.clone()).unwrap_or_else( + |e| Self::fallback_with_default_owner(SetupConfig::default(), settings, &e), + ) + } + + /// Create a wizard with custom configuration. + pub fn with_config(config: SetupConfig) -> Self { + let settings = crate::config::load_bootstrap_settings(None).unwrap_or_default(); + Self::from_bootstrap_settings(config.clone(), settings.clone()) + .unwrap_or_else(|e| Self::fallback_with_default_owner(config, settings, &e)) + } + + /// Create a wizard with custom configuration and bootstrap TOML overlay. + pub fn try_with_config_and_toml( + config: SetupConfig, + toml_path: Option<&std::path::Path>, + ) -> Result { + let settings = crate::config::load_bootstrap_settings(toml_path)?; + Self::from_bootstrap_settings(config, settings) } /// Set the session manager (for reusing existing auth). @@ -252,79 +294,115 @@ impl SetupWizard { /// database connection and the wizard's `self.settings` reflects the /// previously saved configuration. async fn reconnect_existing_db(&mut self) -> Result<(), SetupError> { - use crate::config::DatabaseConfig; + // Determine backend from env (set by bootstrap .env loaded in main). + let backend = std::env::var("DATABASE_BACKEND").unwrap_or_else(|_| "postgres".to_string()); - let db_config = DatabaseConfig::resolve().map_err(|e| { - SetupError::Database(format!( - "Cannot resolve database config. Run full setup first (ironclaw onboard): {}", - e - )) + // Try libsql first if that's the configured backend. + #[cfg(feature = "libsql")] + if backend == "libsql" || backend == "turso" || backend == "sqlite" { + return self.reconnect_libsql().await; + } + + // Try postgres (either explicitly configured or as default). + #[cfg(feature = "postgres")] + { + let _ = &backend; + return self.reconnect_postgres().await; + } + + #[allow(unreachable_code)] + Err(SetupError::Database( + "No database configured. Run full setup first (ironclaw onboard).".to_string(), + )) + } + + /// Reconnect to an existing PostgreSQL database and load settings. + #[cfg(feature = "postgres")] + async fn reconnect_postgres(&mut self) -> Result<(), SetupError> { + let url = std::env::var("DATABASE_URL").map_err(|_| { + SetupError::Database( + "DATABASE_URL not set. Run full setup first (ironclaw onboard).".to_string(), + ) })?; - let backend_name = db_config.backend.to_string(); - let (db, handles) = crate::db::connect_with_handles(&db_config) - .await - .map_err(|e| SetupError::Database(format!("Failed to connect: {}", e)))?; + self.test_database_connection_postgres(&url).await?; + self.settings.database_backend = Some("postgres".to_string()); + self.settings.database_url = Some(url.clone()); - // Load existing settings from DB - if let Ok(map) = db.get_all_settings("default").await { - self.settings = Settings::from_db_map(&map); + // Load existing settings from DB, then restore connection fields that + // may not be persisted in the settings map. + if let Some(ref pool) = self.db_pool { + let store = crate::history::Store::from_pool(pool.clone()); + if let Ok(map) = store.get_all_settings(self.owner_id()).await { + self.settings = Settings::from_db_map(&map); + self.settings.database_backend = Some("postgres".to_string()); + self.settings.database_url = Some(url); + } } - // Restore connection fields that may not be persisted in the settings map - self.settings.database_backend = Some(backend_name); - if let Ok(url) = std::env::var("DATABASE_URL") { - self.settings.database_url = Some(url); - } - if let Ok(path) = std::env::var("LIBSQL_PATH") { - self.settings.libsql_path = Some(path); - } else if db_config.libsql_path.is_some() { - self.settings.libsql_path = db_config - .libsql_path - .as_ref() - .map(|p| p.to_string_lossy().to_string()); - } - if let Ok(url) = std::env::var("LIBSQL_URL") { - self.settings.libsql_url = Some(url); - } + Ok(()) + } + + /// Reconnect to an existing libSQL database and load settings. + #[cfg(feature = "libsql")] + async fn reconnect_libsql(&mut self) -> Result<(), SetupError> { + let path = std::env::var("LIBSQL_PATH").unwrap_or_else(|_| { + crate::config::default_libsql_path() + .to_string_lossy() + .to_string() + }); + let turso_url = std::env::var("LIBSQL_URL").ok(); + let turso_token = std::env::var("LIBSQL_AUTH_TOKEN").ok(); - self.db = Some(db); - self.db_handles = Some(handles); + self.test_database_connection_libsql(&path, turso_url.as_deref(), turso_token.as_deref()) + .await?; + + self.settings.database_backend = Some("libsql".to_string()); + self.settings.libsql_path = Some(path.clone()); + if let Some(ref url) = turso_url { + self.settings.libsql_url = Some(url.clone()); + } + + // Load existing settings from DB, then restore connection fields that + // may not be persisted in the settings map. + if let Some(ref db) = self.db_backend { + use crate::db::SettingsStore as _; + if let Ok(map) = db.get_all_settings(self.owner_id()).await { + self.settings = Settings::from_db_map(&map); + self.settings.database_backend = Some("libsql".to_string()); + self.settings.libsql_path = Some(path); + if let Some(url) = turso_url { + self.settings.libsql_url = Some(url); + } + } + } Ok(()) } /// Step 1: Database connection. - /// - /// Determines the backend at runtime (env var, interactive selection, or - /// compile-time default) and runs the appropriate configuration flow. async fn step_database(&mut self) -> Result<(), SetupError> { - use crate::config::{DatabaseBackend, DatabaseConfig}; - - const POSTGRES_AVAILABLE: bool = cfg!(feature = "postgres"); - const LIBSQL_AVAILABLE: bool = cfg!(feature = "libsql"); - - // Determine backend from env var, interactive selection, or default. - let env_backend = std::env::var("DATABASE_BACKEND").ok(); + // When both features are compiled, let the user choose. + // If DATABASE_BACKEND is already set in the environment, respect it. + #[cfg(all(feature = "postgres", feature = "libsql"))] + { + // Check if a backend is already pinned via env var + let env_backend = std::env::var("DATABASE_BACKEND").ok(); - let backend = if let Some(ref raw) = env_backend { - match raw.parse::() { - Ok(b) => b, - Err(_) => { - let fallback = if POSTGRES_AVAILABLE { - DatabaseBackend::Postgres - } else { - DatabaseBackend::LibSql - }; + if let Some(ref backend) = env_backend { + if backend == "libsql" || backend == "turso" || backend == "sqlite" { + return self.step_database_libsql().await; + } + if backend != "postgres" && backend != "postgresql" { print_info(&format!( - "Unknown DATABASE_BACKEND '{}', defaulting to {}", - raw, fallback + "Unknown DATABASE_BACKEND '{}', defaulting to PostgreSQL", + backend )); - fallback } + return self.step_database_postgres().await; } - } else if POSTGRES_AVAILABLE && LIBSQL_AVAILABLE { - // Both features compiled — offer interactive selection. + + // Interactive selection let pre_selected = self.settings.database_backend.as_deref().map(|b| match b { "libsql" | "turso" | "sqlite" => 1, _ => 0, @@ -350,82 +428,88 @@ impl SetupWizard { self.settings.libsql_url = None; } - if choice == 1 { - DatabaseBackend::LibSql - } else { - DatabaseBackend::Postgres + match choice { + 1 => return self.step_database_libsql().await, + _ => return self.step_database_postgres().await, } - } else if LIBSQL_AVAILABLE { - DatabaseBackend::LibSql - } else { - // Only postgres (or neither, but that won't compile anyway). - DatabaseBackend::Postgres - }; + } - // --- Postgres flow --- - if backend == DatabaseBackend::Postgres { - self.settings.database_backend = Some("postgres".to_string()); + #[cfg(all(feature = "postgres", not(feature = "libsql")))] + { + return self.step_database_postgres().await; + } - let existing_url = std::env::var("DATABASE_URL") - .ok() - .or_else(|| self.settings.database_url.clone()); + #[cfg(all(feature = "libsql", not(feature = "postgres")))] + { + return self.step_database_libsql().await; + } + } - if let Some(ref url) = existing_url { - let display_url = mask_password_in_url(url); - print_info(&format!("Existing database URL: {}", display_url)); + /// Step 1 (postgres): Database connection via PostgreSQL URL. + #[cfg(feature = "postgres")] + async fn step_database_postgres(&mut self) -> Result<(), SetupError> { + self.settings.database_backend = Some("postgres".to_string()); - if confirm("Use this database?", true).map_err(SetupError::Io)? { - let config = DatabaseConfig::from_postgres_url(url, 5); - if let Err(e) = self.test_database_connection(&config).await { - print_error(&format!("Connection failed: {}", e)); - print_info("Let's configure a new database URL."); - } else { - print_success("Database connection successful"); - self.settings.database_url = Some(url.clone()); - return Ok(()); - } + let existing_url = std::env::var("DATABASE_URL") + .ok() + .or_else(|| self.settings.database_url.clone()); + + if let Some(ref url) = existing_url { + let display_url = mask_password_in_url(url); + print_info(&format!("Existing database URL: {}", display_url)); + + if confirm("Use this database?", true).map_err(SetupError::Io)? { + if let Err(e) = self.test_database_connection_postgres(url).await { + print_error(&format!("Connection failed: {}", e)); + print_info("Let's configure a new database URL."); + } else { + print_success("Database connection successful"); + self.settings.database_url = Some(url.clone()); + return Ok(()); } } + } - println!(); - print_info("Enter your PostgreSQL connection URL."); - print_info("Format: postgres://user:password@host:port/database"); - println!(); - - loop { - let url = input("Database URL").map_err(SetupError::Io)?; + println!(); + print_info("Enter your PostgreSQL connection URL."); + print_info("Format: postgres://user:password@host:port/database"); + println!(); - if url.is_empty() { - print_error("Database URL is required."); - continue; - } + loop { + let url = input("Database URL").map_err(SetupError::Io)?; - print_info("Testing connection..."); - let config = DatabaseConfig::from_postgres_url(&url, 5); - match self.test_database_connection(&config).await { - Ok(()) => { - print_success("Database connection successful"); + if url.is_empty() { + print_error("Database URL is required."); + continue; + } - if confirm("Run database migrations?", true).map_err(SetupError::Io)? { - self.run_migrations().await?; - } + print_info("Testing connection..."); + match self.test_database_connection_postgres(&url).await { + Ok(()) => { + print_success("Database connection successful"); - self.settings.database_url = Some(url); - return Ok(()); + if confirm("Run database migrations?", true).map_err(SetupError::Io)? { + self.run_migrations_postgres().await?; } - Err(e) => { - print_error(&format!("Connection failed: {}", e)); - if !confirm("Try again?", true).map_err(SetupError::Io)? { - return Err(SetupError::Database( - "Database connection failed".to_string(), - )); - } + + self.settings.database_url = Some(url); + return Ok(()); + } + Err(e) => { + print_error(&format!("Connection failed: {}", e)); + if !confirm("Try again?", true).map_err(SetupError::Io)? { + return Err(SetupError::Database( + "Database connection failed".to_string(), + )); } } } } + } - // --- libSQL flow --- + /// Step 1 (libsql): Database connection via local file or Turso remote replica. + #[cfg(feature = "libsql")] + async fn step_database_libsql(&mut self) -> Result<(), SetupError> { self.settings.database_backend = Some("libsql".to_string()); let default_path = crate::config::default_libsql_path(); @@ -444,12 +528,14 @@ impl SetupWizard { .or_else(|| self.settings.libsql_url.clone()); let turso_token = std::env::var("LIBSQL_AUTH_TOKEN").ok(); - let config = DatabaseConfig::from_libsql_path( - path, - turso_url.as_deref(), - turso_token.as_deref(), - ); - match self.test_database_connection(&config).await { + match self + .test_database_connection_libsql( + path, + turso_url.as_deref(), + turso_token.as_deref(), + ) + .await + { Ok(()) => { print_success("Database connection successful"); self.settings.libsql_path = Some(path.clone()); @@ -508,17 +594,15 @@ impl SetupWizard { }; print_info("Testing connection..."); - let config = DatabaseConfig::from_libsql_path( - &db_path, - turso_url.as_deref(), - turso_token.as_deref(), - ); - match self.test_database_connection(&config).await { + match self + .test_database_connection_libsql(&db_path, turso_url.as_deref(), turso_token.as_deref()) + .await + { Ok(()) => { print_success("Database connection successful"); // Always run migrations for libsql (they're idempotent) - self.run_migrations().await?; + self.run_migrations_libsql().await?; self.settings.libsql_path = Some(db_path); if let Some(url) = turso_url { @@ -530,39 +614,155 @@ impl SetupWizard { } } - /// Test database connection using the db module factory. + /// Test PostgreSQL connection and store the pool. /// - /// Connects without running migrations and validates PostgreSQL - /// prerequisites (version, pgvector) when using the postgres backend. - async fn test_database_connection( + /// After connecting, validates: + /// 1. PostgreSQL version >= 15 (required for pgvector compatibility) + /// 2. pgvector extension is available (required for embeddings/vector search) + #[cfg(feature = "postgres")] + async fn test_database_connection_postgres(&mut self, url: &str) -> Result<(), SetupError> { + let mut cfg = PoolConfig::new(); + cfg.url = Some(url.to_string()); + cfg.pool = Some(deadpool_postgres::PoolConfig { + max_size: 5, + ..Default::default() + }); + + let pool = crate::db::tls::create_pool(&cfg, crate::config::SslMode::from_env()) + .map_err(|e| SetupError::Database(format!("Failed to create pool: {}", e)))?; + + let client = pool + .get() + .await + .map_err(|e| SetupError::Database(format!("Failed to connect: {}", e)))?; + + // Check PostgreSQL server version (need 15+ for pgvector) + let version_row = client + .query_one("SHOW server_version", &[]) + .await + .map_err(|e| SetupError::Database(format!("Failed to query server version: {}", e)))?; + let version_str: &str = version_row.get(0); + let major_version = version_str + .split('.') + .next() + .and_then(|v| v.parse::().ok()) + .unwrap_or(0); + + const MIN_PG_MAJOR_VERSION: u32 = 15; + + if major_version < MIN_PG_MAJOR_VERSION { + return Err(SetupError::Database(format!( + "PostgreSQL {} detected. IronClaw requires PostgreSQL {} or later for pgvector support.\n\ + Upgrade: https://www.postgresql.org/download/", + version_str, MIN_PG_MAJOR_VERSION + ))); + } + + // Check if pgvector extension is available + let pgvector_row = client + .query_opt( + "SELECT 1 FROM pg_available_extensions WHERE name = 'vector'", + &[], + ) + .await + .map_err(|e| { + SetupError::Database(format!("Failed to check pgvector availability: {}", e)) + })?; + + if pgvector_row.is_none() { + return Err(SetupError::Database(format!( + "pgvector extension not found on your PostgreSQL server.\n\n\ + Install it:\n \ + macOS: brew install pgvector\n \ + Ubuntu: apt install postgresql-{0}-pgvector\n \ + Docker: use the pgvector/pgvector:pg{0} image\n \ + Source: https://github.com/pgvector/pgvector#installation\n\n\ + Then restart PostgreSQL and re-run: ironclaw onboard", + major_version + ))); + } + + self.db_pool = Some(pool); + Ok(()) + } + + /// Test libSQL connection and store the backend. + #[cfg(feature = "libsql")] + async fn test_database_connection_libsql( &mut self, - config: &crate::config::DatabaseConfig, + path: &str, + turso_url: Option<&str>, + turso_token: Option<&str>, ) -> Result<(), SetupError> { - let (db, handles) = crate::db::connect_without_migrations(config) - .await - .map_err(|e| SetupError::Database(e.to_string()))?; + use crate::db::libsql::LibSqlBackend; + use std::path::Path; + + let db_path = Path::new(path); + + let backend = if let (Some(url), Some(token)) = (turso_url, turso_token) { + LibSqlBackend::new_remote_replica(db_path, url, token) + .await + .map_err(|e| SetupError::Database(format!("Failed to connect: {}", e)))? + } else { + LibSqlBackend::new_local(db_path) + .await + .map_err(|e| SetupError::Database(format!("Failed to open database: {}", e)))? + }; - self.db = Some(db); - self.db_handles = Some(handles); + self.db_backend = Some(backend); Ok(()) } - /// Run database migrations on the current connection. - async fn run_migrations(&self) -> Result<(), SetupError> { - if let Some(ref db) = self.db { + /// Run PostgreSQL migrations. + #[cfg(feature = "postgres")] + async fn run_migrations_postgres(&self) -> Result<(), SetupError> { + if let Some(ref pool) = self.db_pool { + use refinery::embed_migrations; + embed_migrations!("migrations"); + if !self.config.quick { print_info("Running migrations..."); } - tracing::debug!("Running database migrations..."); + tracing::debug!("Running PostgreSQL migrations..."); + + let mut client = pool + .get() + .await + .map_err(|e| SetupError::Database(format!("Pool error: {}", e)))?; - db.run_migrations() + migrations::runner() + .run_async(&mut **client) .await .map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?; if !self.config.quick { print_success("Migrations applied"); } - tracing::debug!("Database migrations applied"); + tracing::debug!("PostgreSQL migrations applied"); + } + Ok(()) + } + + /// Run libSQL migrations. + #[cfg(feature = "libsql")] + async fn run_migrations_libsql(&self) -> Result<(), SetupError> { + if let Some(ref backend) = self.db_backend { + use crate::db::Database; + + if !self.config.quick { + print_info("Running migrations..."); + } + tracing::debug!("Running libSQL migrations..."); + + backend + .run_migrations() + .await + .map_err(|e| SetupError::Database(format!("Migration failed: {}", e)))?; + + if !self.config.quick { + print_success("Migrations applied"); + } + tracing::debug!("libSQL migrations applied"); } Ok(()) } @@ -579,19 +779,20 @@ impl SetupWizard { return Ok(()); } - // Try to retrieve existing key from keychain via resolve_master_key - // (checks env var first, then keychain). We skip the env var case - // above, so this will only find a keychain key here. + // Try to retrieve existing key from keychain. We use get_master_key() + // instead of has_master_key() so we can cache the key bytes and build + // SecretsCrypto eagerly, avoiding redundant keychain accesses later + // (each access triggers macOS system dialogs). print_info("Checking OS keychain for existing master key..."); if let Ok(keychain_key_bytes) = crate::secrets::keychain::get_master_key().await { let key_hex: String = keychain_key_bytes .iter() .map(|b| format!("{:02x}", b)) .collect(); - self.secrets_crypto = Some( - crate::secrets::crypto_from_hex(&key_hex) + self.secrets_crypto = Some(Arc::new( + SecretsCrypto::new(SecretString::from(key_hex)) .map_err(|e| SetupError::Config(e.to_string()))?, - ); + )); print_info("Existing master key found in OS keychain."); if confirm("Use existing keychain key?", true).map_err(SetupError::Io)? { @@ -630,11 +831,12 @@ impl SetupWizard { SetupError::Config(format!("Failed to store in keychain: {}", e)) })?; + // Also create crypto instance let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); - self.secrets_crypto = Some( - crate::secrets::crypto_from_hex(&key_hex) + self.secrets_crypto = Some(Arc::new( + SecretsCrypto::new(SecretString::from(key_hex)) .map_err(|e| SetupError::Config(e.to_string()))?, - ); + )); self.settings.secrets_master_key_source = KeySource::Keychain; print_success("Master key generated and stored in OS keychain"); @@ -645,10 +847,10 @@ impl SetupWizard { // Initialize crypto so subsequent wizard steps (channel setup, // API key storage) can encrypt secrets immediately. - self.secrets_crypto = Some( - crate::secrets::crypto_from_hex(&key_hex) + self.secrets_crypto = Some(Arc::new( + SecretsCrypto::new(SecretString::from(key_hex.clone())) .map_err(|e| SetupError::Config(e.to_string()))?, - ); + )); // Make visible to optional_env() for any subsequent config resolution. crate::config::inject_single_var("SECRETS_MASTER_KEY", &key_hex); @@ -681,22 +883,16 @@ impl SetupWizard { /// standard path. Falls back to the interactive `step_database()` only when /// just the postgres feature is compiled (can't auto-default postgres). async fn auto_setup_database(&mut self) -> Result<(), SetupError> { - use crate::config::{DatabaseBackend, DatabaseConfig}; - - const POSTGRES_AVAILABLE: bool = cfg!(feature = "postgres"); - const LIBSQL_AVAILABLE: bool = cfg!(feature = "libsql"); - + // If DATABASE_URL or LIBSQL_PATH already set, respect existing config + #[cfg(feature = "postgres")] let env_backend = std::env::var("DATABASE_BACKEND").ok(); - // If DATABASE_BACKEND=postgres and DATABASE_URL exists: connect+migrate + #[cfg(feature = "postgres")] if let Some(ref backend) = env_backend - && let Ok(DatabaseBackend::Postgres) = backend.parse::() + && (backend == "postgres" || backend == "postgresql") { if let Ok(url) = std::env::var("DATABASE_URL") { print_info("Using existing PostgreSQL configuration"); - let config = DatabaseConfig::from_postgres_url(&url, 5); - self.test_database_connection(&config).await?; - self.run_migrations().await?; self.settings.database_backend = Some("postgres".to_string()); self.settings.database_url = Some(url); return Ok(()); @@ -705,23 +901,17 @@ impl SetupWizard { return self.step_database().await; } - // If DATABASE_URL exists (no explicit backend): connect+migrate as postgres, - // but only when the postgres feature is actually compiled in. - if POSTGRES_AVAILABLE - && env_backend.is_none() - && let Ok(url) = std::env::var("DATABASE_URL") - { + #[cfg(feature = "postgres")] + if let Ok(url) = std::env::var("DATABASE_URL") { print_info("Using existing PostgreSQL configuration"); - let config = DatabaseConfig::from_postgres_url(&url, 5); - self.test_database_connection(&config).await?; - self.run_migrations().await?; self.settings.database_backend = Some("postgres".to_string()); self.settings.database_url = Some(url); return Ok(()); } - // Auto-default to libsql if available - if LIBSQL_AVAILABLE { + // Auto-default to libsql if the feature is compiled + #[cfg(feature = "libsql")] + { self.settings.database_backend = Some("libsql".to_string()); let existing_path = std::env::var("LIBSQL_PATH") @@ -737,13 +927,14 @@ impl SetupWizard { let turso_url = std::env::var("LIBSQL_URL").ok(); let turso_token = std::env::var("LIBSQL_AUTH_TOKEN").ok(); - let config = DatabaseConfig::from_libsql_path( + self.test_database_connection_libsql( &db_path, turso_url.as_deref(), turso_token.as_deref(), - ); - self.test_database_connection(&config).await?; - self.run_migrations().await?; + ) + .await?; + + self.run_migrations_libsql().await?; self.settings.libsql_path = Some(db_path.clone()); if let Some(url) = turso_url { @@ -755,7 +946,10 @@ impl SetupWizard { } // Only postgres feature compiled — can't auto-default, use interactive - self.step_database().await + #[allow(unreachable_code)] + { + self.step_database().await + } } /// Auto-setup security with zero prompts (quick mode). @@ -764,23 +958,26 @@ impl SetupWizard { /// key if available, otherwise generates and stores one automatically /// (keychain on macOS, env var fallback). async fn auto_setup_security(&mut self) -> Result<(), SetupError> { - // Try resolving an existing key from env var or keychain - if let Some(key_hex) = crate::secrets::resolve_master_key().await { - self.secrets_crypto = Some( - crate::secrets::crypto_from_hex(&key_hex) + // Check env var first + if std::env::var("SECRETS_MASTER_KEY").is_ok() { + self.settings.secrets_master_key_source = KeySource::Env; + print_success("Security configured (env var)"); + return Ok(()); + } + + // Try existing keychain key (no prompts — get_master_key may show + // OS dialogs on macOS, but that's unavoidable for keychain access) + if let Ok(keychain_key_bytes) = crate::secrets::keychain::get_master_key().await { + let key_hex: String = keychain_key_bytes + .iter() + .map(|b| format!("{:02x}", b)) + .collect(); + self.secrets_crypto = Some(Arc::new( + SecretsCrypto::new(SecretString::from(key_hex)) .map_err(|e| SetupError::Config(e.to_string()))?, - ); - // Determine source: env var or keychain (filter empty to match resolve_master_key) - let (source, label) = if std::env::var("SECRETS_MASTER_KEY") - .ok() - .is_some_and(|v| !v.is_empty()) - { - (KeySource::Env, "env var") - } else { - (KeySource::Keychain, "keychain") - }; - self.settings.secrets_master_key_source = source; - print_success(&format!("Security configured ({})", label)); + )); + self.settings.secrets_master_key_source = KeySource::Keychain; + print_success("Security configured (keychain)"); return Ok(()); } @@ -792,10 +989,10 @@ impl SetupWizard { .is_ok() { let key_hex: String = key.iter().map(|b| format!("{:02x}", b)).collect(); - self.secrets_crypto = Some( - crate::secrets::crypto_from_hex(&key_hex) + self.secrets_crypto = Some(Arc::new( + SecretsCrypto::new(SecretString::from(key_hex)) .map_err(|e| SetupError::Config(e.to_string()))?, - ); + )); self.settings.secrets_master_key_source = KeySource::Keychain; print_success("Master key stored in OS keychain"); return Ok(()); @@ -803,10 +1000,10 @@ impl SetupWizard { // Keychain unavailable — fall back to env var mode let key_hex = crate::secrets::keychain::generate_master_key_hex(); - self.secrets_crypto = Some( - crate::secrets::crypto_from_hex(&key_hex) + self.secrets_crypto = Some(Arc::new( + SecretsCrypto::new(SecretString::from(key_hex.clone())) .map_err(|e| SetupError::Config(e.to_string()))?, - ); + )); crate::config::inject_single_var("SECRETS_MASTER_KEY", &key_hex); self.settings.secrets_master_key_hex = Some(key_hex); self.settings.secrets_master_key_source = KeySource::Env; @@ -1677,27 +1874,74 @@ impl SetupWizard { /// Initialize secrets context for channel setup. async fn init_secrets_context(&mut self) -> Result { - // Get crypto (should be set from step 2, or resolve from keychain/env) + // Get crypto (should be set from step 2, or load from keychain/env) let crypto = if let Some(ref c) = self.secrets_crypto { Arc::clone(c) } else { - let key_hex = crate::secrets::resolve_master_key().await.ok_or_else(|| { - SetupError::Config( + // Try to load master key from keychain or env + let key = if let Ok(env_key) = std::env::var("SECRETS_MASTER_KEY") { + env_key + } else if let Ok(keychain_key) = crate::secrets::keychain::get_master_key().await { + keychain_key.iter().map(|b| format!("{:02x}", b)).collect() + } else { + return Err(SetupError::Config( "Secrets not configured. Run full setup or set SECRETS_MASTER_KEY.".to_string(), - ) - })?; + )); + }; - let crypto = crate::secrets::crypto_from_hex(&key_hex) - .map_err(|e| SetupError::Config(e.to_string()))?; + let crypto = Arc::new( + SecretsCrypto::new(SecretString::from(key)) + .map_err(|e| SetupError::Config(e.to_string()))?, + ); self.secrets_crypto = Some(Arc::clone(&crypto)); crypto }; - // Create secrets store from existing database handles - if let Some(ref handles) = self.db_handles - && let Some(store) = crate::secrets::create_secrets_store(Arc::clone(&crypto), handles) - { - return Ok(SecretsContext::from_store(store, "default")); + // Create backend-appropriate secrets store. + // Use runtime dispatch based on the user's selected backend. + // Default to whichever backend is compiled in. When only libsql is + // available, we must not default to "postgres" or we'd skip store creation. + let default_backend = { + #[cfg(feature = "postgres")] + { + "postgres" + } + #[cfg(not(feature = "postgres"))] + { + "libsql" + } + }; + let selected_backend = self + .settings + .database_backend + .as_deref() + .unwrap_or(default_backend); + + match selected_backend { + #[cfg(feature = "libsql")] + "libsql" | "turso" | "sqlite" => { + if let Some(store) = self.create_libsql_secrets_store(&crypto)? { + return Ok(SecretsContext::from_store(store, self.owner_id())); + } + // Fallback to postgres if libsql store creation returned None + #[cfg(feature = "postgres")] + if let Some(store) = self.create_postgres_secrets_store(&crypto).await? { + return Ok(SecretsContext::from_store(store, self.owner_id())); + } + } + #[cfg(feature = "postgres")] + _ => { + if let Some(store) = self.create_postgres_secrets_store(&crypto).await? { + return Ok(SecretsContext::from_store(store, self.owner_id())); + } + // Fallback to libsql if postgres store creation returned None + #[cfg(feature = "libsql")] + if let Some(store) = self.create_libsql_secrets_store(&crypto)? { + return Ok(SecretsContext::from_store(store, self.owner_id())); + } + } + #[cfg(not(feature = "postgres"))] + _ => {} } Err(SetupError::Config( @@ -1705,6 +1949,62 @@ impl SetupWizard { )) } + /// Create a PostgreSQL secrets store from the current pool. + #[cfg(feature = "postgres")] + async fn create_postgres_secrets_store( + &mut self, + crypto: &Arc, + ) -> Result>, SetupError> { + let pool = if let Some(ref p) = self.db_pool { + p.clone() + } else { + // Fall back to creating one from settings/env + let url = self + .settings + .database_url + .clone() + .or_else(|| std::env::var("DATABASE_URL").ok()); + + if let Some(url) = url { + self.test_database_connection_postgres(&url).await?; + self.run_migrations_postgres().await?; + match self.db_pool.clone() { + Some(pool) => pool, + None => { + return Err(SetupError::Database( + "Database pool not initialized after connection test".to_string(), + )); + } + } + } else { + return Ok(None); + } + }; + + let store: Arc = Arc::new(crate::secrets::PostgresSecretsStore::new( + pool, + Arc::clone(crypto), + )); + Ok(Some(store)) + } + + /// Create a libSQL secrets store from the current backend. + #[cfg(feature = "libsql")] + fn create_libsql_secrets_store( + &self, + crypto: &Arc, + ) -> Result>, SetupError> { + if let Some(ref backend) = self.db_backend { + let store: Arc = Arc::new(crate::secrets::LibSqlSecretsStore::new( + backend.shared_db(), + Arc::clone(crypto), + )); + Ok(Some(store)) + } else { + Ok(None) + } + } + /// Step 6: Channel configuration. async fn step_channels(&mut self) -> Result<(), SetupError> { // First, configure tunnel (shared across all channels that need webhooks) @@ -2222,15 +2522,45 @@ impl SetupWizard { /// connection is available yet (e.g., before Step 1 completes). async fn persist_settings(&self) -> Result { let db_map = self.settings.to_db_map(); + let saved = false; + + #[cfg(feature = "postgres")] + let saved = if !saved { + if let Some(ref pool) = self.db_pool { + let store = crate::history::Store::from_pool(pool.clone()); + store + .set_all_settings(self.owner_id(), &db_map) + .await + .map_err(|e| { + SetupError::Database(format!("Failed to save settings to database: {}", e)) + })?; + true + } else { + false + } + } else { + saved + }; - if let Some(ref db) = self.db { - db.set_all_settings("default", &db_map).await.map_err(|e| { - SetupError::Database(format!("Failed to save settings to database: {}", e)) - })?; - Ok(true) + #[cfg(feature = "libsql")] + let saved = if !saved { + if let Some(ref backend) = self.db_backend { + use crate::db::SettingsStore as _; + backend + .set_all_settings(self.owner_id(), &db_map) + .await + .map_err(|e| { + SetupError::Database(format!("Failed to save settings to database: {}", e)) + })?; + true + } else { + false + } } else { - Ok(false) - } + saved + }; + + Ok(saved) } /// Write bootstrap environment variables to `~/.ironclaw/.env`. @@ -2406,12 +2736,28 @@ impl SetupWizard { Err(_) => return, }; - if let Some(ref db) = self.db { - if let Err(e) = db - .set_setting("default", "nearai.session_token", &value) + #[cfg(feature = "postgres")] + if let Some(ref pool) = self.db_pool { + let store = crate::history::Store::from_pool(pool.clone()); + if let Err(e) = store + .set_setting(self.owner_id(), "nearai.session_token", &value) .await { - tracing::debug!("Could not persist session token to database: {}", e); + tracing::debug!("Could not persist session token to postgres: {}", e); + } else { + tracing::debug!("Session token persisted to database"); + return; + } + } + + #[cfg(feature = "libsql")] + if let Some(ref backend) = self.db_backend { + use crate::db::SettingsStore as _; + if let Err(e) = backend + .set_setting(self.owner_id(), "nearai.session_token", &value) + .await + { + tracing::debug!("Could not persist session token to libsql: {}", e); } else { tracing::debug!("Session token persisted to database"); } @@ -2448,19 +2794,58 @@ impl SetupWizard { /// prefers the `other` argument's non-default values. Without this, /// stale DB values would overwrite fresh user choices. async fn try_load_existing_settings(&mut self) { - if let Some(ref db) = self.db { - match db.get_all_settings("default").await { - Ok(db_map) if !db_map.is_empty() => { - let existing = Settings::from_db_map(&db_map); - self.settings.merge_from(&existing); - tracing::info!("Loaded {} existing settings from database", db_map.len()); + let loaded = false; + + #[cfg(feature = "postgres")] + let loaded = if !loaded { + if let Some(ref pool) = self.db_pool { + let store = crate::history::Store::from_pool(pool.clone()); + match store.get_all_settings(self.owner_id()).await { + Ok(db_map) if !db_map.is_empty() => { + let existing = Settings::from_db_map(&db_map); + self.settings.merge_from(&existing); + tracing::info!("Loaded {} existing settings from database", db_map.len()); + true + } + Ok(_) => false, + Err(e) => { + tracing::debug!("Could not load existing settings: {}", e); + false + } } - Ok(_) => {} - Err(e) => { - tracing::debug!("Could not load existing settings: {}", e); + } else { + false + } + } else { + loaded + }; + + #[cfg(feature = "libsql")] + let loaded = if !loaded { + if let Some(ref backend) = self.db_backend { + use crate::db::SettingsStore as _; + match backend.get_all_settings(self.owner_id()).await { + Ok(db_map) if !db_map.is_empty() => { + let existing = Settings::from_db_map(&db_map); + self.settings.merge_from(&existing); + tracing::info!("Loaded {} existing settings from database", db_map.len()); + true + } + Ok(_) => false, + Err(e) => { + tracing::debug!("Could not load existing settings: {}", e); + false + } } + } else { + false } - } + } else { + loaded + }; + + // Suppress unused variable warning when only one backend is compiled. + let _ = loaded; } /// Save settings to the database and `~/.ironclaw/.env`, then print summary. @@ -2610,6 +2995,7 @@ impl Default for SetupWizard { } /// Mask password in a database URL for display. +#[cfg(feature = "postgres")] fn mask_password_in_url(url: &str) -> String { // URL format: scheme://user:password@host/database // Find "://" to locate start of credentials @@ -2638,6 +3024,331 @@ fn mask_password_in_url(url: &str) -> String { format!("{}{}:****{}", scheme, username, after_at) } +/// Fetch models from the Anthropic API. +/// +/// Returns `(model_id, display_label)` pairs. Falls back to static defaults on error. +async fn fetch_anthropic_models(cached_key: Option<&str>) -> Vec<(String, String)> { + let static_defaults = vec![ + ( + "claude-opus-4-6".into(), + "Claude Opus 4.6 (latest flagship)".into(), + ), + ("claude-sonnet-4-6".into(), "Claude Sonnet 4.6".into()), + ("claude-opus-4-5".into(), "Claude Opus 4.5".into()), + ("claude-sonnet-4-5".into(), "Claude Sonnet 4.5".into()), + ("claude-haiku-4-5".into(), "Claude Haiku 4.5 (fast)".into()), + ]; + + let api_key = cached_key + .map(String::from) + .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok()) + .filter(|k| !k.is_empty() && k != crate::config::OAUTH_PLACEHOLDER); + + // Fall back to OAuth token if no API key + let oauth_token = if api_key.is_none() { + crate::config::helpers::optional_env("ANTHROPIC_OAUTH_TOKEN") + .ok() + .flatten() + .filter(|t| !t.is_empty()) + } else { + None + }; + + let (key_or_token, is_oauth) = match (api_key, oauth_token) { + (Some(k), _) => (k, false), + (None, Some(t)) => (t, true), + (None, None) => return static_defaults, + }; + + let client = reqwest::Client::new(); + let mut request = client + .get("https://api.anthropic.com/v1/models") + .header("anthropic-version", "2023-06-01") + .timeout(std::time::Duration::from_secs(5)); + + if is_oauth { + request = request + .bearer_auth(&key_or_token) + .header("anthropic-beta", "oauth-2025-04-20"); + } else { + request = request.header("x-api-key", &key_or_token); + } + + let resp = match request.send().await { + Ok(r) if r.status().is_success() => r, + _ => return static_defaults, + }; + + #[derive(serde::Deserialize)] + struct ModelEntry { + id: String, + } + #[derive(serde::Deserialize)] + struct ModelsResponse { + data: Vec, + } + + match resp.json::().await { + Ok(body) => { + let mut models: Vec<(String, String)> = body + .data + .into_iter() + .filter(|m| !m.id.contains("embedding") && !m.id.contains("audio")) + .map(|m| { + let label = m.id.clone(); + (m.id, label) + }) + .collect(); + if models.is_empty() { + return static_defaults; + } + models.sort_by(|a, b| a.0.cmp(&b.0)); + models + } + Err(_) => static_defaults, + } +} + +/// Fetch models from the OpenAI API. +/// +/// Returns `(model_id, display_label)` pairs. Falls back to static defaults on error. +async fn fetch_openai_models(cached_key: Option<&str>) -> Vec<(String, String)> { + let static_defaults = vec![ + ( + "gpt-5.3-codex".into(), + "GPT-5.3 Codex (latest flagship)".into(), + ), + ("gpt-5.2-codex".into(), "GPT-5.2 Codex".into()), + ("gpt-5.2".into(), "GPT-5.2".into()), + ( + "gpt-5.1-codex-mini".into(), + "GPT-5.1 Codex Mini (fast)".into(), + ), + ("gpt-5".into(), "GPT-5".into()), + ("gpt-5-mini".into(), "GPT-5 Mini".into()), + ("gpt-4.1".into(), "GPT-4.1".into()), + ("gpt-4.1-mini".into(), "GPT-4.1 Mini".into()), + ("o4-mini".into(), "o4-mini (fast reasoning)".into()), + ("o3".into(), "o3 (reasoning)".into()), + ]; + + let api_key = cached_key + .map(String::from) + .or_else(|| std::env::var("OPENAI_API_KEY").ok()) + .filter(|k| !k.is_empty()); + + let api_key = match api_key { + Some(k) => k, + None => return static_defaults, + }; + + let client = reqwest::Client::new(); + let resp = match client + .get("https://api.openai.com/v1/models") + .bearer_auth(&api_key) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + { + Ok(r) if r.status().is_success() => r, + _ => return static_defaults, + }; + + #[derive(serde::Deserialize)] + struct ModelEntry { + id: String, + } + #[derive(serde::Deserialize)] + struct ModelsResponse { + data: Vec, + } + + match resp.json::().await { + Ok(body) => { + let mut models: Vec<(String, String)> = body + .data + .into_iter() + .filter(|m| is_openai_chat_model(&m.id)) + .map(|m| { + let label = m.id.clone(); + (m.id, label) + }) + .collect(); + if models.is_empty() { + return static_defaults; + } + sort_openai_models(&mut models); + models + } + Err(_) => static_defaults, + } +} + +fn is_openai_chat_model(model_id: &str) -> bool { + let id = model_id.to_ascii_lowercase(); + + let is_chat_family = id.starts_with("gpt-") + || id.starts_with("chatgpt-") + || id.starts_with("o1") + || id.starts_with("o3") + || id.starts_with("o4") + || id.starts_with("o5"); + + let is_non_chat_variant = id.contains("realtime") + || id.contains("audio") + || id.contains("transcribe") + || id.contains("tts") + || id.contains("embedding") + || id.contains("moderation") + || id.contains("image"); + + is_chat_family && !is_non_chat_variant +} + +fn openai_model_priority(model_id: &str) -> usize { + let id = model_id.to_ascii_lowercase(); + + const EXACT_PRIORITY: &[&str] = &[ + "gpt-5.3-codex", + "gpt-5.2-codex", + "gpt-5.2", + "gpt-5.1-codex-mini", + "gpt-5", + "gpt-5-mini", + "gpt-5-nano", + "o4-mini", + "o3", + "o1", + "gpt-4.1", + "gpt-4.1-mini", + "gpt-4o", + "gpt-4o-mini", + ]; + if let Some(pos) = EXACT_PRIORITY.iter().position(|m| id == *m) { + return pos; + } + + const PREFIX_PRIORITY: &[&str] = &[ + "gpt-5.", "gpt-5-", "o3-", "o4-", "o1-", "gpt-4.1-", "gpt-4o-", "gpt-3.5-", "chatgpt-", + ]; + if let Some(pos) = PREFIX_PRIORITY + .iter() + .position(|prefix| id.starts_with(prefix)) + { + return EXACT_PRIORITY.len() + pos; + } + + EXACT_PRIORITY.len() + PREFIX_PRIORITY.len() + 1 +} + +fn sort_openai_models(models: &mut [(String, String)]) { + models.sort_by(|a, b| { + openai_model_priority(&a.0) + .cmp(&openai_model_priority(&b.0)) + .then_with(|| a.0.cmp(&b.0)) + }); +} + +/// Fetch installed models from a local Ollama instance. +/// +/// Returns `(model_name, display_label)` pairs. Falls back to static defaults on error. +async fn fetch_ollama_models(base_url: &str) -> Vec<(String, String)> { + let static_defaults = vec![ + ("llama3".into(), "llama3".into()), + ("mistral".into(), "mistral".into()), + ("codellama".into(), "codellama".into()), + ]; + + let url = format!("{}/api/tags", base_url.trim_end_matches('/')); + let client = reqwest::Client::new(); + + let resp = match client + .get(&url) + .timeout(std::time::Duration::from_secs(5)) + .send() + .await + { + Ok(r) if r.status().is_success() => r, + Ok(_) => return static_defaults, + Err(_) => { + print_info("Could not connect to Ollama. Is it running?"); + return static_defaults; + } + }; + + #[derive(serde::Deserialize)] + struct ModelEntry { + name: String, + } + #[derive(serde::Deserialize)] + struct TagsResponse { + models: Vec, + } + + match resp.json::().await { + Ok(body) => { + let models: Vec<(String, String)> = body + .models + .into_iter() + .map(|m| { + let label = m.name.clone(); + (m.name, label) + }) + .collect(); + if models.is_empty() { + return static_defaults; + } + models + } + Err(_) => static_defaults, + } +} + +/// Fetch models from a generic OpenAI-compatible /v1/models endpoint. +/// +/// Used for registry providers like Groq, NVIDIA NIM, etc. +async fn fetch_openai_compatible_models( + base_url: &str, + cached_key: Option<&str>, +) -> Vec<(String, String)> { + if base_url.is_empty() { + return vec![]; + } + + let url = format!("{}/models", base_url.trim_end_matches('/')); + let client = reqwest::Client::new(); + let mut req = client.get(&url).timeout(std::time::Duration::from_secs(5)); + if let Some(key) = cached_key { + req = req.bearer_auth(key); + } + + let resp = match req.send().await { + Ok(r) if r.status().is_success() => r, + _ => return vec![], + }; + + #[derive(serde::Deserialize)] + struct Model { + id: String, + } + #[derive(serde::Deserialize)] + struct ModelsResponse { + data: Vec, + } + + match resp.json::().await { + Ok(body) => body + .data + .into_iter() + .map(|m| { + let label = m.id.clone(); + (m.id, label) + }) + .collect(), + Err(_) => vec![], + } +} + /// Discover WASM channels in a directory. /// /// Returns a list of (channel_name, capabilities_file) pairs. @@ -2707,6 +3418,58 @@ async fn discover_wasm_channels(dir: &std::path::Path) -> Vec<(String, ChannelCa /// Mask an API key for display: show first 6 + last 4 chars. /// /// Uses char-based indexing to avoid panicking on multi-byte UTF-8. +/// Build the `LlmConfig` used by `fetch_nearai_models` to list available models. +/// +/// Reads `NEARAI_API_KEY` from the environment so that users who authenticated +/// via Cloud API key (option 4) don't get re-prompted during model selection. +fn build_nearai_model_fetch_config() -> crate::config::LlmConfig { + // If the user authenticated via API key (option 4), the key is stored + // as an env var. Pass it through so `resolve_bearer_token()` doesn't + // re-trigger the interactive auth prompt. + let api_key = std::env::var("NEARAI_API_KEY") + .ok() + .filter(|k| !k.is_empty()) + .map(secrecy::SecretString::from); + + // Match the same base_url logic as LlmConfig::resolve(): use cloud-api + // when an API key is present, private.near.ai for session-token auth. + let default_base = if api_key.is_some() { + "https://cloud-api.near.ai" + } else { + "https://private.near.ai" + }; + let base_url = std::env::var("NEARAI_BASE_URL").unwrap_or_else(|_| default_base.to_string()); + let auth_base_url = + std::env::var("NEARAI_AUTH_URL").unwrap_or_else(|_| "https://private.near.ai".to_string()); + + crate::config::LlmConfig { + backend: "nearai".to_string(), + session: crate::llm::session::SessionConfig { + auth_base_url, + session_path: crate::config::llm::default_session_path(), + }, + nearai: crate::config::NearAiConfig { + model: "dummy".to_string(), + cheap_model: None, + base_url, + api_key, + fallback_model: None, + max_retries: 3, + circuit_breaker_threshold: None, + circuit_breaker_recovery_secs: 30, + response_cache_enabled: false, + response_cache_ttl_secs: 3600, + response_cache_max_entries: 1000, + failover_cooldown_secs: 300, + failover_cooldown_threshold: 3, + smart_routing_cascade: true, + }, + provider: None, + bedrock: None, + request_timeout_secs: 120, + } +} + fn mask_api_key(key: &str) -> String { let chars: Vec = key.chars().collect(); if chars.len() < 12 { @@ -2911,12 +3674,13 @@ async fn install_selected_bundled_channels( #[cfg(test)] mod tests { use std::collections::HashSet; + #[cfg(unix)] + use std::ffi::OsString; use tempfile::tempdir; use super::*; use crate::config::helpers::ENV_MUTEX; - use crate::llm::models::{is_openai_chat_model, sort_openai_models}; #[test] fn test_wizard_creation() { @@ -2938,6 +3702,53 @@ mod tests { } #[test] + fn test_wizard_owner_id_uses_resolved_env_scope() { + let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _owner = EnvGuard::set("IRONCLAW_OWNER_ID", " wizard-owner "); + + let wizard = SetupWizard::new(); + assert_eq!(wizard.owner_id(), "wizard-owner"); // safety: test-only assertion + } + + #[test] + fn test_wizard_owner_id_uses_toml_scope() { + let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let _owner = EnvGuard::clear("IRONCLAW_OWNER_ID"); + let dir = tempdir().unwrap(); // safety: test-only tempdir setup + let path = dir.path().join("config.toml"); + std::fs::write(&path, "owner_id = \"toml-owner\"\n").unwrap(); // safety: test-only fixture write + + let wizard = SetupWizard::try_with_config_and_toml(Default::default(), Some(&path)) + .expect("wizard should load owner_id from TOML"); // safety: test-only assertion + assert_eq!(wizard.owner_id(), "toml-owner"); // safety: test-only assertion + } + + #[test] + #[cfg(unix)] + fn test_try_with_config_and_toml_propagates_invalid_owner_env() { + use std::os::unix::ffi::OsStringExt; + + let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let original = std::env::var_os("IRONCLAW_OWNER_ID"); + unsafe { + std::env::set_var("IRONCLAW_OWNER_ID", OsString::from_vec(vec![0x66, 0x80])); + } + + let result = SetupWizard::try_with_config_and_toml(Default::default(), None); + + unsafe { + if let Some(value) = original { + std::env::set_var("IRONCLAW_OWNER_ID", value); + } else { + std::env::remove_var("IRONCLAW_OWNER_ID"); + } + } + + assert!(result.is_err()); // safety: test-only assertion + } + + #[test] + #[cfg(feature = "postgres")] fn test_mask_password_in_url() { assert_eq!( mask_password_in_url("postgres://user:secret@localhost/db"), @@ -2981,12 +3792,12 @@ mod tests { return; } - let dir = tempdir().unwrap(); + let dir = tempdir().unwrap(); // safety: test-only tempdir setup let installed = HashSet::::new(); install_missing_bundled_channels(dir.path(), &installed) .await - .unwrap(); + .unwrap(); // safety: test-only assertion assert!(dir.path().join("telegram.wasm").exists()); assert!(dir.path().join("telegram.capabilities.json").exists()); @@ -3088,7 +3899,7 @@ mod tests { #[tokio::test] async fn test_discover_wasm_channels_empty_dir() { - let dir = tempdir().unwrap(); + let dir = tempdir().unwrap(); // safety: test-only tempdir setup let channels = discover_wasm_channels(dir.path()).await; assert!(channels.is_empty()); } diff --git a/src/testing/mod.rs b/src/testing/mod.rs index 33702e679f6..19025980f9e 100644 --- a/src/testing/mod.rs +++ b/src/testing/mod.rs @@ -1077,7 +1077,7 @@ mod tests { }, notify: NotifyConfig { channel: None, - user: "user1".to_string(), + user: Some("user1".to_string()), on_attention: true, on_failure: true, on_success: false, @@ -1210,7 +1210,7 @@ mod tests { }, notify: NotifyConfig { channel: None, - user: "user1".to_string(), + user: Some("user1".to_string()), on_attention: false, on_failure: false, on_success: false, diff --git a/src/tools/builtin/message.rs b/src/tools/builtin/message.rs index 53d16e78f1d..cba80d7b5dd 100644 --- a/src/tools/builtin/message.rs +++ b/src/tools/builtin/message.rs @@ -129,21 +129,28 @@ impl Tool for MessageTool { .map(|c| c.to_string()) }; - // Get target: use param → conversation default → job metadata + // Get target: use param → conversation default → job metadata → owner scope + // fallback when a specific channel is known. let target = if let Some(t) = params.get("target").and_then(|v| v.as_str()) { - t.to_string() + Some(t.to_string()) } else if let Some(t) = self .default_target .read() .unwrap_or_else(|e| e.into_inner()) .clone() { - t + Some(t) } else if let Some(t) = ctx.metadata.get("notify_user").and_then(|v| v.as_str()) { - t.to_string() + Some(t.to_string()) + } else if channel.is_some() { + Some(ctx.user_id.clone()) } else { + None + }; + + let Some(target) = target else { return Err(ToolError::ExecutionFailed( - "No target specified and no active conversation. Provide target parameter." + "No target specified and no channel-scoped routing target could be resolved. Provide target parameter." .to_string(), )); }; @@ -659,6 +666,37 @@ mod tests { ); } + #[tokio::test] + async fn message_tool_falls_back_to_ctx_user_when_channel_known() { + // Regression for owner-scoped notifications: a channel can be known + // even when the concrete delivery target is omitted, so the message + // tool should pass ctx.user_id through to the channel layer. + let tool = MessageTool::new(Arc::new(ChannelManager::new())); + + let mut ctx = + crate::context::JobContext::with_user("owner-scope", "routine-job", "price alert"); + ctx.metadata = serde_json::json!({ + "notify_channel": "telegram", + }); + + let result = tool + .execute(serde_json::json!({"content": "NEAR price is $5"}), &ctx) + .await; + + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!( + !err.contains("No target specified"), + "Should not get 'No target specified' when ctx.user_id can be used, got: {}", + err + ); + assert!( + !err.contains("No channel specified"), + "Should not get 'No channel specified' when metadata has notify_channel, got: {}", + err + ); + } + #[tokio::test] async fn message_tool_no_metadata_still_errors() { // When neither conversation context nor metadata is set, should still diff --git a/src/tools/builtin/routine.rs b/src/tools/builtin/routine.rs index 42a771d3baa..347cb4ff07d 100644 --- a/src/tools/builtin/routine.rs +++ b/src/tools/builtin/routine.rs @@ -106,7 +106,7 @@ pub(crate) fn routine_create_parameters_schema() -> serde_json::Value { }, "notify_user": { "type": "string", - "description": "User or destination to notify, for example a username or chat ID." + "description": "Optional explicit user or destination to notify, for example a username or chat ID. Omit it to use the configured owner's last-seen target for that channel." }, "timezone": { "type": "string", @@ -387,8 +387,7 @@ impl Tool for RoutineCreateTool { user: params .get("notify_user") .and_then(|v| v.as_str()) - .unwrap_or("default") - .to_string(), + .map(String::from), ..NotifyConfig::default() }, last_run_at: None, diff --git a/src/tools/wasm/wrapper.rs b/src/tools/wasm/wrapper.rs index bceb9401692..ac1306214ff 100644 --- a/src/tools/wasm/wrapper.rs +++ b/src/tools/wasm/wrapper.rs @@ -841,13 +841,7 @@ impl Tool for WasmToolWrapper { // Pre-resolve host credentials from secrets store (async, before blocking task). // This decrypts the secrets once so the sync http_request() host function // can inject them without needing async access. - // - // BUG FIX: ExtensionManager stores OAuth tokens under user_id "default" - // (hardcoded at construction in app.rs), but this was previously looking - // them up under ctx.user_id — which could be a Telegram user ID, web - // gateway user, etc. — causing credential resolution to silently fail. - // Must match the storage key until per-user credential isolation is added. - let credential_user_id = "default"; + let credential_user_id = &ctx.user_id; let host_credentials = resolve_host_credentials( &self.capabilities, self.secrets_store.as_deref(), diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index b19c77af1a7..56a478c9644 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -15,7 +15,13 @@ import pytest -from helpers import AUTH_TOKEN, wait_for_port_line, wait_for_ready +from helpers import ( + AUTH_TOKEN, + HTTP_WEBHOOK_SECRET, + OWNER_SCOPE_ID, + wait_for_port_line, + wait_for_ready, +) # Project root (two levels up from tests/e2e/) ROOT = Path(__file__).resolve().parent.parent.parent @@ -92,6 +98,21 @@ def _find_free_port() -> int: return s.getsockname()[1] +def _reserve_loopback_sockets(count: int) -> list[socket.socket]: + """Bind loopback sockets and keep them open until the server starts.""" + sockets: list[socket.socket] = [] + try: + while len(sockets) < count: + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.bind(("127.0.0.1", 0)) + sockets.append(sock) + return sockets + except Exception: + for sock in sockets: + sock.close() + raise + + @pytest.fixture(scope="session") def ironclaw_binary(): """Ensure ironclaw binary is built. Returns the binary path.""" @@ -108,6 +129,21 @@ def ironclaw_binary(): return str(binary) +@pytest.fixture(scope="session") +def server_ports(): + """Reserve dynamic ports for the gateway and HTTP webhook channel.""" + reserved = _reserve_loopback_sockets(2) + try: + yield { + "gateway": reserved[0].getsockname()[1], + "http": reserved[1].getsockname()[1], + "sockets": reserved, + } + finally: + for sock in reserved: + sock.close() + + @pytest.fixture(scope="session") async def mock_llm_server(): """Start the mock LLM server. Yields the base URL.""" @@ -177,10 +213,19 @@ def _wasm_build_symlinks(): @pytest.fixture(scope="session") -async def ironclaw_server(ironclaw_binary, mock_llm_server, wasm_tools_dir): +async def ironclaw_server( + ironclaw_binary, + mock_llm_server, + wasm_tools_dir, + server_ports, +): """Start the ironclaw gateway. Yields the base URL.""" - gateway_port = _find_free_port() home_dir = _HOME_TMPDIR.name + gateway_port = server_ports["gateway"] + http_port = server_ports["http"] + for sock in server_ports["sockets"]: + if sock.fileno() != -1: + sock.close() env = { # Minimal env: PATH for process spawning, HOME for Rust/cargo defaults "PATH": os.environ.get("PATH", "/usr/bin:/bin"), @@ -188,11 +233,15 @@ async def ironclaw_server(ironclaw_binary, mock_llm_server, wasm_tools_dir): "IRONCLAW_BASE_DIR": os.path.join(home_dir, ".ironclaw"), "RUST_LOG": "ironclaw=info", "RUST_BACKTRACE": "1", + "IRONCLAW_OWNER_ID": OWNER_SCOPE_ID, "GATEWAY_ENABLED": "true", "GATEWAY_HOST": "127.0.0.1", "GATEWAY_PORT": str(gateway_port), "GATEWAY_AUTH_TOKEN": AUTH_TOKEN, - "GATEWAY_USER_ID": "e2e-tester", + "GATEWAY_USER_ID": "e2e-web-sender", + "HTTP_HOST": "127.0.0.1", + "HTTP_PORT": str(http_port), + "HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET, "CLI_ENABLED": "false", "LLM_BACKEND": "openai_compatible", "LLM_BASE_URL": mock_llm_server, @@ -261,6 +310,14 @@ async def ironclaw_server(ironclaw_binary, mock_llm_server, wasm_tools_dir): proc.kill() +@pytest.fixture(scope="session") +async def http_channel_server(ironclaw_server, server_ports): + """HTTP webhook channel base URL.""" + base_url = f"http://127.0.0.1:{server_ports['http']}" + await wait_for_ready(f"{base_url}/health", timeout=30) + return base_url + + @pytest.fixture(scope="session") async def ironclaw_server_with_webhook_secret(ironclaw_binary, mock_llm_server, wasm_tools_dir): """Start ironclaw with HTTP_WEBHOOK_SECRET configured for webhook tests. diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 629205a147e..1f4f0ae7b96 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -1,6 +1,8 @@ """Shared helpers for E2E tests.""" import asyncio +import hashlib +import hmac import re import time @@ -95,12 +97,21 @@ "toast_success": ".toast.toast-success", "toast_error": ".toast.toast-error", "toast_info": ".toast.toast-info", + # Jobs / routines + "jobs_tbody": "#jobs-tbody", + "job_row": "#jobs-tbody .job-row", + "jobs_empty": "#jobs-empty", + "routines_tbody": "#routines-tbody", + "routine_row": "#routines-tbody .routine-row", + "routines_empty": "#routines-empty", } TABS = ["chat", "memory", "jobs", "routines", "extensions", "skills"] # Auth token used across all tests AUTH_TOKEN = "e2e-test-token" +OWNER_SCOPE_ID = "e2e-owner-scope" +HTTP_WEBHOOK_SECRET = "e2e-http-webhook-secret" async def wait_for_ready(url: str, *, timeout: float = 60, interval: float = 0.5): @@ -162,3 +173,16 @@ async def api_post(base_url: str, path: str, **kwargs) -> httpx.Response: timeout=kwargs.pop("timeout", 10), **kwargs, ) + + +def signed_http_webhook_headers(body: bytes) -> dict[str, str]: + """Return headers for the owner-scoped HTTP webhook channel.""" + digest = hmac.new( + HTTP_WEBHOOK_SECRET.encode("utf-8"), + body, + hashlib.sha256, + ).hexdigest() + return { + "Content-Type": "application/json", + "X-IronClaw-Signature": f"sha256={digest}", + } diff --git a/tests/e2e/mock_llm.py b/tests/e2e/mock_llm.py index 175accf520c..c53da8945da 100644 --- a/tests/e2e/mock_llm.py +++ b/tests/e2e/mock_llm.py @@ -26,6 +26,40 @@ TOOL_CALL_PATTERNS = [ (re.compile(r"echo (.+)", re.IGNORECASE), "echo", lambda m: {"message": m.group(1)}), (re.compile(r"what time|current time", re.IGNORECASE), "time", lambda _: {"operation": "now"}), + ( + re.compile( + r"create lightweight owner routine (?P[a-z0-9][a-z0-9_-]*)", + re.IGNORECASE, + ), + "routine_create", + lambda m: { + "name": m.group("name"), + "description": f"Owner-scope routine {m.group('name')}", + "trigger_type": "manual", + "prompt": f"Confirm that {m.group('name')} executed.", + "action_type": "lightweight", + "use_tools": False, + }, + ), + ( + re.compile( + r"create full[- ]job owner routine (?P[a-z0-9][a-z0-9_-]*)", + re.IGNORECASE, + ), + "routine_create", + lambda m: { + "name": m.group("name"), + "description": f"Owner-scope full-job routine {m.group('name')}", + "trigger_type": "manual", + "prompt": f"Complete the routine job for {m.group('name')}.", + "action_type": "full_job", + }, + ), + ( + re.compile(r"list owner routines", re.IGNORECASE), + "routine_list", + lambda _: {}, + ), ] diff --git a/tests/e2e/scenarios/test_owner_scope.py b/tests/e2e/scenarios/test_owner_scope.py new file mode 100644 index 00000000000..56f3b01ec70 --- /dev/null +++ b/tests/e2e/scenarios/test_owner_scope.py @@ -0,0 +1,226 @@ +"""Owner-scope end-to-end scenarios. + +These tests exercise the explicit owner model across: +- the web gateway chat UI +- the owner-scoped HTTP webhook channel +- routine tools / routines tab +- job creation via routine execution / jobs tab +""" + +import asyncio +import json +import uuid + +import httpx + +from helpers import SEL, AUTH_TOKEN, signed_http_webhook_headers + + +async def _send_and_get_response( + page, + message: str, + *, + expected_fragment: str, + timeout: int = 30000, +) -> str: + """Send a chat message and return the newest assistant response text.""" + chat_input = page.locator(SEL["chat_input"]) + await chat_input.wait_for(state="visible", timeout=5000) + + assistant_sel = SEL["message_assistant"] + before_count = await page.locator(assistant_sel).count() + + await chat_input.fill(message) + await chat_input.press("Enter") + + expected = before_count + 1 + await page.wait_for_function( + """({ assistantSelector, expectedCount, expectedFragment }) => { + const messages = document.querySelectorAll(assistantSelector); + if (messages.length < expectedCount) return false; + const text = (messages[messages.length - 1].innerText || '').trim().toLowerCase(); + return text.includes(expectedFragment.toLowerCase()); + }""", + arg={ + "assistantSelector": assistant_sel, + "expectedCount": expected, + "expectedFragment": expected_fragment, + }, + timeout=timeout, + ) + + return await page.locator(assistant_sel).last.inner_text() + + +async def _post_http_webhook( + http_channel_server: str, + *, + content: str, + sender_id: str, + thread_id: str, +) -> str: + """Send a signed request to the owner-scoped HTTP webhook channel.""" + payload = { + "user_id": sender_id, + "thread_id": thread_id, + "content": content, + "wait_for_response": True, + } + body = json.dumps(payload).encode("utf-8") + + async with httpx.AsyncClient() as client: + response = await client.post( + f"{http_channel_server}/webhook", + content=body, + headers=signed_http_webhook_headers(body), + timeout=90, + ) + + assert response.status_code == 200, ( + f"HTTP webhook failed: {response.status_code} {response.text[:400]}" + ) + data = response.json() + assert data["status"] == "accepted", f"Unexpected webhook response: {data}" + assert data["response"], f"Expected synchronous response body, got: {data}" + return data["response"] + + +async def _open_tab(page, tab: str) -> None: + btn = page.locator(SEL["tab_button"].format(tab=tab)) + await btn.click() + await page.locator(SEL["tab_panel"].format(tab=tab)).wait_for( + state="visible", + timeout=5000, + ) + + +async def _wait_for_routine(base_url: str, name: str, timeout: float = 20.0) -> dict: + """Poll the routines API until the named routine exists.""" + async with httpx.AsyncClient() as client: + for _ in range(int(timeout * 2)): + response = await client.get( + f"{base_url}/api/routines", + headers={"Authorization": f"Bearer {AUTH_TOKEN}"}, + timeout=10, + ) + response.raise_for_status() + routines = response.json()["routines"] + for routine in routines: + if routine["name"] == name: + return routine + await _poll_sleep() + raise AssertionError(f"Routine '{name}' was not created within {timeout}s") + + +async def _wait_for_job(base_url: str, title: str, timeout: float = 30.0) -> dict: + """Poll the jobs API until the named job exists.""" + async with httpx.AsyncClient() as client: + for _ in range(int(timeout * 2)): + response = await client.get( + f"{base_url}/api/jobs", + headers={"Authorization": f"Bearer {AUTH_TOKEN}"}, + timeout=10, + ) + response.raise_for_status() + jobs = response.json()["jobs"] + for job in jobs: + if job["title"] == title: + return job + await _poll_sleep() + raise AssertionError(f"Job '{title}' was not created within {timeout}s") + + +async def _poll_sleep() -> None: + """Small shared backoff for API polling loops.""" + await asyncio.sleep(0.5) + + +async def test_http_channel_created_routine_is_visible_in_web_routines_tab( + page, + ironclaw_server, + http_channel_server, +): + """A routine created from the HTTP channel is visible in the web owner UI.""" + routine_name = f"owner-http-{uuid.uuid4().hex[:8]}" + + response_text = await _post_http_webhook( + http_channel_server, + content=f"create lightweight owner routine {routine_name}", + sender_id="external-sender-alpha", + thread_id="http-owner-routine-thread", + ) + assert routine_name in response_text + + await _wait_for_routine(ironclaw_server, routine_name) + + await _open_tab(page, "routines") + await page.locator(SEL["routine_row"]).filter(has_text=routine_name).first.wait_for( + state="visible", + timeout=15000, + ) + + +async def test_web_created_routine_is_listed_from_http_channel_across_senders( + page, + ironclaw_server, + http_channel_server, +): + """Routines created in web chat remain owner-global across HTTP senders/threads.""" + routine_name = f"owner-web-{uuid.uuid4().hex[:8]}" + + assistant_text = await _send_and_get_response( + page, + f"create lightweight owner routine {routine_name}", + expected_fragment=routine_name, + ) + assert routine_name in assistant_text + + await _wait_for_routine(ironclaw_server, routine_name) + + first_sender_text = await _post_http_webhook( + http_channel_server, + content="list owner routines", + sender_id="http-sender-one", + thread_id="owner-list-thread-a", + ) + second_sender_text = await _post_http_webhook( + http_channel_server, + content="list owner routines", + sender_id="http-sender-two", + thread_id="owner-list-thread-b", + ) + + assert routine_name in first_sender_text, first_sender_text + assert routine_name in second_sender_text, second_sender_text + + +async def test_http_created_full_job_routine_can_be_run_from_web_and_shows_in_jobs( + page, + ironclaw_server, + http_channel_server, +): + """A full-job routine created via HTTP can be run from the web UI and create a job.""" + routine_name = f"owner-job-{uuid.uuid4().hex[:8]}" + + response_text = await _post_http_webhook( + http_channel_server, + content=f"create full-job owner routine {routine_name}", + sender_id="http-job-sender", + thread_id="owner-job-thread", + ) + assert routine_name in response_text + + await _wait_for_routine(ironclaw_server, routine_name) + + await _open_tab(page, "routines") + routine_row = page.locator(SEL["routine_row"]).filter(has_text=routine_name).first + await routine_row.wait_for(state="visible", timeout=15000) + await routine_row.locator('button[data-action="trigger-routine"]').click() + + await _wait_for_job(ironclaw_server, routine_name, timeout=45.0) + + await _open_tab(page, "jobs") + await page.locator(SEL["job_row"]).filter(has_text=routine_name).first.wait_for( + state="visible", + timeout=20000, + ) diff --git a/tests/e2e_routine_heartbeat.rs b/tests/e2e_routine_heartbeat.rs index 6d6deb8becd..c4d06409f79 100644 --- a/tests/e2e_routine_heartbeat.rs +++ b/tests/e2e_routine_heartbeat.rs @@ -218,7 +218,21 @@ mod tests { engine.refresh_event_cache().await; // Positive match: message containing "deploy to production". - let matching_msg = IncomingMessage::new("test", "default", "deploy to production now"); + let matching_msg = IncomingMessage { + id: Uuid::new_v4(), + channel: "test".to_string(), + user_id: "default".to_string(), + owner_id: "default".to_string(), + sender_id: "default".to_string(), + user_name: None, + content: "deploy to production now".to_string(), + thread_id: None, + conversation_scope_id: None, + received_at: Utc::now(), + metadata: serde_json::json!({}), + timezone: None, + attachments: Vec::new(), + }; let fired = engine.check_event_triggers(&matching_msg).await; assert!( fired >= 1, @@ -229,12 +243,138 @@ mod tests { tokio::time::sleep(Duration::from_millis(500)).await; // Negative match: message that doesn't match. - let non_matching_msg = - IncomingMessage::new("test", "default", "check the staging environment"); + let non_matching_msg = IncomingMessage { + id: Uuid::new_v4(), + channel: "test".to_string(), + user_id: "default".to_string(), + owner_id: "default".to_string(), + sender_id: "default".to_string(), + user_name: None, + content: "check the staging environment".to_string(), + thread_id: None, + conversation_scope_id: None, + received_at: Utc::now(), + metadata: serde_json::json!({}), + timezone: None, + attachments: Vec::new(), + }; let fired_neg = engine.check_event_triggers(&non_matching_msg).await; assert_eq!(fired_neg, 0, "Expected 0 routines fired on non-match"); } + #[tokio::test] + async fn event_trigger_respects_message_user_scope() { + let (db, _tmp) = create_test_db().await; + let ws = create_workspace(&db); + + let trace = LlmTrace::single_turn( + "test-event-user-scope", + "deploy", + vec![TraceStep { + request_hint: None, + response: TraceResponse::Text { + content: "Owner event handled".to_string(), + input_tokens: 50, + output_tokens: 8, + }, + expected_tool_results: vec![], + }], + ); + let llm = Arc::new(TraceLlm::from_trace(trace)); + let (notify_tx, _notify_rx) = tokio::sync::mpsc::channel(16); + + let tools = Arc::new(ToolRegistry::new()); + let safety = Arc::new(SafetyLayer::new(&SafetyConfig { + max_output_length: 100_000, + injection_check_enabled: true, + })); + + let engine = Arc::new(RoutineEngine::new( + RoutineConfig::default(), + db.clone(), + llm, + ws, + notify_tx, + None, + tools, + safety, + )); + + let routine = make_routine( + "owner-deploy-watcher", + Trigger::Event { + channel: None, + pattern: "deploy.*production".to_string(), + }, + "Report on deployment.", + ); + db.create_routine(&routine).await.expect("create_routine"); + engine.refresh_event_cache().await; + + let guest_msg = IncomingMessage { + id: Uuid::new_v4(), + channel: "telegram".to_string(), + user_id: "guest".to_string(), + owner_id: "default".to_string(), + sender_id: "guest-sender".to_string(), + user_name: None, + content: "deploy to production now".to_string(), + thread_id: None, + conversation_scope_id: None, + received_at: Utc::now(), + metadata: serde_json::json!({}), + timezone: None, + attachments: Vec::new(), + }; + let guest_fired = engine.check_event_triggers(&guest_msg).await; + assert_eq!( + guest_fired, 0, + "Guest scope must not fire owner event routines" + ); + tokio::time::sleep(Duration::from_millis(200)).await; + + let guest_runs = db + .list_routine_runs(routine.id, 10) + .await + .expect("list_routine_runs after guest message"); + assert!( + guest_runs.is_empty(), + "Guest message should not create routine runs" + ); + + let owner_msg = IncomingMessage { + id: Uuid::new_v4(), + channel: "telegram".to_string(), + user_id: "default".to_string(), + owner_id: "default".to_string(), + sender_id: "owner-sender".to_string(), + user_name: None, + content: "deploy to production now".to_string(), + thread_id: None, + conversation_scope_id: None, + received_at: Utc::now(), + metadata: serde_json::json!({}), + timezone: None, + attachments: Vec::new(), + }; + let owner_fired = engine.check_event_triggers(&owner_msg).await; + assert!( + owner_fired >= 1, + "Owner scope should fire matching owner event routine" + ); + tokio::time::sleep(Duration::from_millis(500)).await; + + let owner_runs = db + .list_routine_runs(routine.id, 10) + .await + .expect("list_routine_runs after owner message"); + assert_eq!( + owner_runs.len(), + 1, + "Owner message should create exactly one run" + ); + } + // ----------------------------------------------------------------------- // Test 3: system_event_trigger_matches_and_filters // ----------------------------------------------------------------------- @@ -434,7 +574,21 @@ mod tests { engine.refresh_event_cache().await; // First fire should work. - let msg = IncomingMessage::new("test", "default", "test-cooldown trigger"); + let msg = IncomingMessage { + id: Uuid::new_v4(), + channel: "test".to_string(), + user_id: "default".to_string(), + owner_id: "default".to_string(), + sender_id: "default".to_string(), + user_name: None, + content: "test-cooldown trigger".to_string(), + thread_id: None, + conversation_scope_id: None, + received_at: Utc::now(), + metadata: serde_json::json!({}), + timezone: None, + attachments: Vec::new(), + }; let fired1 = engine.check_event_triggers(&msg).await; assert!(fired1 >= 1, "First fire should work"); diff --git a/tests/telegram_auth_integration.rs b/tests/telegram_auth_integration.rs index 8b27d8a8c82..9bd6d64c739 100644 --- a/tests/telegram_auth_integration.rs +++ b/tests/telegram_auth_integration.rs @@ -6,7 +6,8 @@ //! 1. When owner_id is null and dm_policy is "allowlist", unauthorized users in //! group chats are dropped even if they @mention the bot //! 2. When owner_id is null and dm_policy is "open", all users can interact -//! 3. When owner_id is set, only that user can interact +//! 3. When owner_id is set, the owner gets instance-global access while +//! non-owner senders remain channel-scoped guests subject to authorization //! 4. Authorization works correctly for both private and group chats use std::collections::HashMap; @@ -261,7 +262,9 @@ async fn test_group_message_with_owner_id_set() { let channel = create_telegram_channel(runtime, &config).await; - // Message from different user (should be dropped) + // Message from different user. In the owner-scope model this sender stays a + // guest, so the webhook still accepts the update and leaves authorization + // decisions to the guest flow rather than hard-dropping on owner mismatch. let update = build_telegram_update( 3, 102, @@ -286,8 +289,9 @@ async fn test_group_message_with_owner_id_set() { assert_eq!(response.status, 200); - // REGRESSION TEST: Non-owner messages are dropped when owner_id is set - // This behavior is consistent and not affected by the fix + // Regression: with owner_id set, non-owner senders should not be hard- + // dropped solely because they are not the owner. Guest isolation happens + // later in the host/runtime layer. } #[tokio::test] From d4efd88435754f74271ffdd4d5e2524f9f90de4f Mon Sep 17 00:00:00 2001 From: Henry Park Date: Fri, 13 Mar 2026 14:21:25 -0700 Subject: [PATCH 02/14] fix: tighten routine owner target routing --- src/agent/agent_loop.rs | 115 +++++++++-- src/channels/wasm/wrapper.rs | 383 +++++++++++++++++++---------------- src/cli/routines.rs | 28 ++- src/db/libsql/mod.rs | 10 +- 4 files changed, 331 insertions(+), 205 deletions(-) diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index b4322340330..e1e62ed76c4 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -22,7 +22,7 @@ use crate::channels::{ChannelManager, IncomingMessage, OutgoingResponse}; use crate::config::{AgentConfig, HeartbeatConfig, RoutineConfig, SkillsConfig}; use crate::context::ContextManager; use crate::db::Database; -use crate::error::Error; +use crate::error::{ChannelError, Error}; use crate::extensions::ExtensionManager; use crate::hooks::HookRegistry; use crate::llm::LlmProvider; @@ -54,6 +54,25 @@ pub(crate) fn truncate_for_preview(output: &str, max_chars: usize) -> String { } } +fn resolve_routine_notification_user(metadata: &serde_json::Value) -> Option { + metadata + .get("notify_user") + .and_then(|value| value.as_str()) + .or_else(|| metadata.get("owner_id").and_then(|value| value.as_str())) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(ToOwned::to_owned) +} + +fn should_fallback_routine_notification(error: &ChannelError) -> bool { + match error { + ChannelError::SendFailed { reason, .. } => { + !reason.contains("owner routing target") && !reason.contains("delivery target") + } + _ => true, + } +} + /// Core dependencies for the agent. /// /// Bundles the shared components to reduce argument count. @@ -478,26 +497,41 @@ impl Agent { let channels = self.channels.clone(); tokio::spawn(async move { while let Some(response) = notify_rx.recv().await { - let user = response - .metadata - .get("notify_user") - .and_then(|v| v.as_str()) - .or_else(|| response.metadata.get("owner_id").and_then(|v| v.as_str())) - .unwrap_or("default") - .to_string(); let notify_channel = response .metadata .get("notify_channel") .and_then(|v| v.as_str()) .map(|s| s.to_string()); + let Some(user) = resolve_routine_notification_user(&response.metadata) + else { + tracing::warn!( + notify_channel = ?notify_channel, + "Skipping routine notification with no explicit target or owner scope" + ); + continue; + }; // Try the configured channel first, fall back to // broadcasting on all channels. let targeted_ok = if let Some(ref channel) = notify_channel { - channels - .broadcast(channel, &user, response.clone()) - .await - .is_ok() + match channels.broadcast(channel, &user, response.clone()).await { + Ok(()) => true, + Err(e) => { + let should_fallback = + should_fallback_routine_notification(&e); + tracing::warn!( + channel = %channel, + user = %user, + error = %e, + should_fallback, + "Failed to send routine notification to configured channel" + ); + if !should_fallback { + continue; + } + false + } + } } else { false }; @@ -999,7 +1033,11 @@ impl Agent { #[cfg(test)] mod tests { - use super::truncate_for_preview; + use super::{ + resolve_routine_notification_user, should_fallback_routine_notification, + truncate_for_preview, + }; + use crate::error::ChannelError; #[test] fn test_truncate_short_input() { @@ -1062,4 +1100,55 @@ mod tests { // 'h','e','l','l','o',' ','世','界' = 8 chars assert_eq!(result, "hello 世界..."); } + + #[test] + fn resolve_routine_notification_user_prefers_explicit_target() { + let metadata = serde_json::json!({ + "notify_user": "12345", + "owner_id": "owner-scope", + }); + + let resolved = resolve_routine_notification_user(&metadata); + assert_eq!(resolved.as_deref(), Some("12345")); // safety: test-only assertion + } + + #[test] + fn resolve_routine_notification_user_falls_back_to_owner_scope() { + let metadata = serde_json::json!({ + "notify_user": null, + "owner_id": "owner-scope", + }); + + let resolved = resolve_routine_notification_user(&metadata); + assert_eq!(resolved.as_deref(), Some("owner-scope")); // safety: test-only assertion + } + + #[test] + fn resolve_routine_notification_user_rejects_missing_values() { + let metadata = serde_json::json!({ + "notify_user": " ", + }); + + assert_eq!(resolve_routine_notification_user(&metadata), None); // safety: test-only assertion + } + + #[test] + fn targeted_routine_notifications_do_not_fallback_without_owner_route() { + let error = ChannelError::SendFailed { + name: "telegram".to_string(), + reason: "No stored owner routing target for channel 'telegram'.".to_string(), + }; + + assert!(!should_fallback_routine_notification(&error)); // safety: test-only assertion + } + + #[test] + fn targeted_routine_notifications_may_fallback_for_other_errors() { + let error = ChannelError::SendFailed { + name: "telegram".to_string(), + reason: "timeout talking to channel".to_string(), + }; + + assert!(should_fallback_routine_notification(&error)); // safety: test-only assertion + } } diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index 44845620629..59325980047 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -760,10 +760,7 @@ fn resolve_message_scope( } } -fn apply_emitted_metadata( - mut msg: IncomingMessage, - metadata_json: &str, -) -> IncomingMessage { +fn apply_emitted_metadata(mut msg: IncomingMessage, metadata_json: &str) -> IncomingMessage { if let Ok(metadata) = serde_json::from_str(metadata_json) { msg = msg.with_metadata(metadata); if msg.conversation_scope().is_none() @@ -914,7 +911,10 @@ impl WasmChannel { } Ok(_) => { if self.owner_scope_id != "default" { - match store.get_setting("default", &self.broadcast_metadata_key()).await { + match store + .get_setting("default", &self.broadcast_metadata_key()) + .await + { Ok(Some(serde_json::Value::String(meta))) => { *self.last_broadcast_metadata.write().await = Some(meta); tracing::debug!( @@ -1132,13 +1132,12 @@ impl WasmChannel { let timeout = self.runtime.config().callback_timeout; let channel_name = self.name.clone(); let credentials = self.get_credentials().await; - let host_credentials = - resolve_channel_host_credentials( - &self.capabilities, - self.secrets_store.as_deref(), - &self.owner_scope_id, - ) - .await; + let host_credentials = resolve_channel_host_credentials( + &self.capabilities, + self.secrets_store.as_deref(), + &self.owner_scope_id, + ) + .await; let pairing_store = self.pairing_store.clone(); let workspace_store = self.workspace_store.clone(); @@ -1276,13 +1275,12 @@ impl WasmChannel { let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store); let timeout = self.runtime.config().callback_timeout; let credentials = self.get_credentials().await; - let host_credentials = - resolve_channel_host_credentials( - &self.capabilities, - self.secrets_store.as_deref(), - &self.owner_scope_id, - ) - .await; + let host_credentials = resolve_channel_host_credentials( + &self.capabilities, + self.secrets_store.as_deref(), + &self.owner_scope_id, + ) + .await; let pairing_store = self.pairing_store.clone(); let workspace_store = self.workspace_store.clone(); @@ -1383,13 +1381,12 @@ impl WasmChannel { let timeout = self.runtime.config().callback_timeout; let channel_name = self.name.clone(); let credentials = self.get_credentials().await; - let host_credentials = - resolve_channel_host_credentials( - &self.capabilities, - self.secrets_store.as_deref(), - &self.owner_scope_id, - ) - .await; + let host_credentials = resolve_channel_host_credentials( + &self.capabilities, + self.secrets_store.as_deref(), + &self.owner_scope_id, + ) + .await; let pairing_store = self.pairing_store.clone(); let workspace_store = self.workspace_store.clone(); @@ -1494,13 +1491,12 @@ impl WasmChannel { let timeout = self.runtime.config().callback_timeout; let channel_name = self.name.clone(); let credentials = self.get_credentials().await; - let host_credentials = - resolve_channel_host_credentials( - &self.capabilities, - self.secrets_store.as_deref(), - &self.owner_scope_id, - ) - .await; + let host_credentials = resolve_channel_host_credentials( + &self.capabilities, + self.secrets_store.as_deref(), + &self.owner_scope_id, + ) + .await; let pairing_store = self.pairing_store.clone(); // Prepare response data @@ -1639,13 +1635,12 @@ impl WasmChannel { let timeout = self.runtime.config().callback_timeout; let channel_name = self.name.clone(); let credentials = self.get_credentials().await; - let host_credentials = - resolve_channel_host_credentials( - &self.capabilities, - self.secrets_store.as_deref(), - &self.owner_scope_id, - ) - .await; + let host_credentials = resolve_channel_host_credentials( + &self.capabilities, + self.secrets_store.as_deref(), + &self.owner_scope_id, + ) + .await; let pairing_store = self.pairing_store.clone(); let user_id = user_id.to_string(); @@ -1747,13 +1742,12 @@ impl WasmChannel { let timeout = self.runtime.config().callback_timeout; let channel_name = self.name.clone(); let credentials = self.get_credentials().await; - let host_credentials = - resolve_channel_host_credentials( - &self.capabilities, - self.secrets_store.as_deref(), - &self.owner_scope_id, - ) - .await; + let host_credentials = resolve_channel_host_credentials( + &self.capabilities, + self.secrets_store.as_deref(), + &self.owner_scope_id, + ) + .await; let pairing_store = self.pairing_store.clone(); let Some(wit_update) = status_to_wit(status, metadata) else { @@ -2254,14 +2248,16 @@ impl WasmChannel { // Process any emitted messages if !emitted_messages.is_empty() && let Err(e) = Self::dispatch_emitted_messages( - &channel_name, - &owner_scope_id, - owner_actor_id.as_deref(), + EmitDispatchContext { + channel_name: &channel_name, + owner_scope_id: &owner_scope_id, + owner_actor_id: owner_actor_id.as_deref(), + message_tx: &message_tx, + rate_limiter: &rate_limiter, + last_broadcast_metadata: &last_broadcast_metadata, + settings_store: settings_store.as_ref(), + }, emitted_messages, - &message_tx, - &rate_limiter, - &last_broadcast_metadata, - settings_store.as_ref(), ).await { tracing::warn!( channel = %channel_name, @@ -2383,27 +2379,21 @@ impl WasmChannel { /// This is a static helper used by the polling loop since it doesn't have /// access to `&self`. async fn dispatch_emitted_messages( - channel_name: &str, - owner_scope_id: &str, - owner_actor_id: Option<&str>, + dispatch: EmitDispatchContext<'_>, messages: Vec, - message_tx: &RwLock>>, - rate_limiter: &RwLock, - last_broadcast_metadata: &tokio::sync::RwLock>, - settings_store: Option<&Arc>, ) -> Result<(), WasmChannelError> { tracing::info!( - channel = %channel_name, + channel = %dispatch.channel_name, message_count = messages.len(), "Processing emitted messages from polling callback" ); // Clone sender to avoid holding RwLock read guard across send().await in the loop let tx = { - let tx_guard = message_tx.read().await; + let tx_guard = dispatch.message_tx.read().await; let Some(tx) = tx_guard.as_ref() else { tracing::error!( - channel = %channel_name, + channel = %dispatch.channel_name, count = messages.len(), "Messages emitted but no sender available - channel may not be started!" ); @@ -2415,25 +2405,29 @@ impl WasmChannel { for emitted in messages { // Check rate limit — acquire and release the write lock before send().await { - let mut limiter = rate_limiter.write().await; + let mut limiter = dispatch.rate_limiter.write().await; if !limiter.check_and_record() { tracing::warn!( - channel = %channel_name, + channel = %dispatch.channel_name, "Message emission rate limited" ); return Err(WasmChannelError::EmitRateLimited { - name: channel_name.to_string(), + name: dispatch.channel_name.to_string(), }); } } - let (resolved_user_id, is_owner_sender) = - resolve_message_scope(owner_scope_id, owner_actor_id, &emitted.user_id); + let (resolved_user_id, is_owner_sender) = resolve_message_scope( + dispatch.owner_scope_id, + dispatch.owner_actor_id, + &emitted.user_id, + ); // Convert to IncomingMessage - let mut msg = IncomingMessage::new(channel_name, &resolved_user_id, &emitted.content) - .with_owner_id(owner_scope_id) - .with_sender_id(&emitted.user_id); + let mut msg = + IncomingMessage::new(dispatch.channel_name, &resolved_user_id, &emitted.content) + .with_owner_id(dispatch.owner_scope_id) + .with_sender_id(&emitted.user_id); if let Some(name) = emitted.user_name { msg = msg.with_user_name(name); @@ -2468,18 +2462,18 @@ impl WasmChannel { if is_owner_sender { // Store for owner-target routing (chat_id etc.) do_update_broadcast_metadata( - channel_name, - owner_scope_id, + dispatch.channel_name, + dispatch.owner_scope_id, &emitted.metadata_json, - last_broadcast_metadata, - settings_store, + dispatch.last_broadcast_metadata, + dispatch.settings_store, ) .await; } // Send to stream — no locks held across this await tracing::info!( - channel = %channel_name, + channel = %dispatch.channel_name, user_id = %emitted.user_id, content_len = emitted.content.len(), attachment_count = msg.attachments.len(), @@ -2488,14 +2482,14 @@ impl WasmChannel { if tx.send(msg).await.is_err() { tracing::error!( - channel = %channel_name, + channel = %dispatch.channel_name, "Failed to send polled message, channel closed" ); break; } tracing::info!( - channel = %channel_name, + channel = %dispatch.channel_name, "Message successfully sent to agent queue" ); } @@ -2504,6 +2498,16 @@ impl WasmChannel { } } +struct EmitDispatchContext<'a> { + channel_name: &'a str, + owner_scope_id: &'a str, + owner_actor_id: Option<&'a str>, + message_tx: &'a RwLock>>, + rate_limiter: &'a RwLock, + last_broadcast_metadata: &'a tokio::sync::RwLock>, + settings_store: Option<&'a Arc>, +} + #[async_trait] impl Channel for WasmChannel { fn name(&self) -> &str { @@ -2641,12 +2645,11 @@ impl Channel for WasmChannel { } })?; - let metadata: serde_json::Value = serde_json::from_str(&metadata).map_err(|e| { - ChannelError::SendFailed { + let metadata: serde_json::Value = + serde_json::from_str(&metadata).map_err(|e| ChannelError::SendFailed { name: self.name.clone(), reason: format!("Invalid stored owner routing metadata: {e}"), - } - })?; + })?; crate::channels::routing_target_from_metadata(&metadata).ok_or_else(|| { ChannelError::SendFailed { @@ -3105,7 +3108,10 @@ async fn resolve_channel_host_credentials( continue; } - let secret = match store.get_decrypted(owner_scope_id, &mapping.secret_name).await { + let secret = match store + .get_decrypted(owner_scope_id, &mapping.secret_name) + .await + { Ok(s) => s, Err(e) => { tracing::debug!( @@ -3224,7 +3230,7 @@ mod tests { use crate::channels::wasm::runtime::{ PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig, }; - use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel}; + use crate::channels::wasm::wrapper::{EmitDispatchContext, HttpResponse, WasmChannel}; use crate::pairing::PairingStore; use crate::testing::credentials::TEST_TELEGRAM_BOT_TOKEN; use crate::tools::wasm::ResourceLimits; @@ -3333,7 +3339,7 @@ mod tests { ) .await; - assert!(result.is_ok()); + assert!(result.is_ok()); // safety: test-only assertion assert!(result.unwrap().is_empty()); } @@ -3357,30 +3363,32 @@ mod tests { let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None)); let result = WasmChannel::dispatch_emitted_messages( - "test-channel", - "default", - None, + EmitDispatchContext { + channel_name: "test-channel", + owner_scope_id: "default", + owner_actor_id: None, + message_tx: &message_tx, + rate_limiter: &rate_limiter, + last_broadcast_metadata: &last_broadcast_metadata, + settings_store: None, + }, messages, - &message_tx, - &rate_limiter, - &last_broadcast_metadata, - None, ) .await; - assert!(result.is_ok()); + assert!(result.is_ok()); // safety: test-only assertion // Verify messages were sent - let msg1 = rx.try_recv().expect("Should receive first message"); - assert_eq!(msg1.user_id, "user1"); - assert_eq!(msg1.content, "Hello from polling!"); + let msg1 = rx.try_recv().expect("Should receive first message"); // safety: test-only assertion + assert_eq!(msg1.user_id, "user1"); // safety: test-only assertion + assert_eq!(msg1.content, "Hello from polling!"); // safety: test-only assertion - let msg2 = rx.try_recv().expect("Should receive second message"); - assert_eq!(msg2.user_id, "user2"); - assert_eq!(msg2.content, "Another message"); + let msg2 = rx.try_recv().expect("Should receive second message"); // safety: test-only assertion + assert_eq!(msg2.user_id, "user2"); // safety: test-only assertion + assert_eq!(msg2.content, "Another message"); // safety: test-only assertion // No more messages - assert!(rx.try_recv().is_err()); + assert!(rx.try_recv().is_err()); // safety: test-only assertion } #[tokio::test] @@ -3400,14 +3408,16 @@ mod tests { // Should return Ok even without a sender (logs warning but doesn't fail) let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None)); let result = WasmChannel::dispatch_emitted_messages( - "test-channel", - "default", - None, + EmitDispatchContext { + channel_name: "test-channel", + owner_scope_id: "default", + owner_actor_id: None, + message_tx: &message_tx, + rate_limiter: &rate_limiter, + last_broadcast_metadata: &last_broadcast_metadata, + settings_store: None, + }, messages, - &message_tx, - &rate_limiter, - &last_broadcast_metadata, - None, ) .await; @@ -4407,44 +4417,46 @@ mod tests { let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None)); let result = WasmChannel::dispatch_emitted_messages( - "test-channel", - "default", - None, + EmitDispatchContext { + channel_name: "test-channel", + owner_scope_id: "default", + owner_actor_id: None, + message_tx: &message_tx, + rate_limiter: &rate_limiter, + last_broadcast_metadata: &last_broadcast_metadata, + settings_store: None, + }, messages, - &message_tx, - &rate_limiter, - &last_broadcast_metadata, - None, ) .await; - assert!(result.is_ok()); + assert!(result.is_ok()); // safety: test-only assertion - let msg = rx.try_recv().expect("Should receive message"); - assert_eq!(msg.content, "Check these files"); - assert_eq!(msg.attachments.len(), 2); + let msg = rx.try_recv().expect("Should receive message"); // safety: test-only assertion + assert_eq!(msg.content, "Check these files"); // safety: test-only assertion + assert_eq!(msg.attachments.len(), 2); // safety: test-only assertion // Verify first attachment - assert_eq!(msg.attachments[0].id, "photo123"); - assert_eq!(msg.attachments[0].mime_type, "image/jpeg"); - assert_eq!(msg.attachments[0].filename, Some("cat.jpg".to_string())); - assert_eq!(msg.attachments[0].size_bytes, Some(50_000)); + assert_eq!(msg.attachments[0].id, "photo123"); // safety: test-only assertion + assert_eq!(msg.attachments[0].mime_type, "image/jpeg"); // safety: test-only assertion + assert_eq!(msg.attachments[0].filename, Some("cat.jpg".to_string())); // safety: test-only assertion + assert_eq!(msg.attachments[0].size_bytes, Some(50_000)); // safety: test-only assertion assert_eq!( msg.attachments[0].source_url, Some("https://api.telegram.org/file/photo123".to_string()) - ); + ); // safety: test-only assertion // Verify second attachment - assert_eq!(msg.attachments[1].id, "doc456"); - assert_eq!(msg.attachments[1].mime_type, "application/pdf"); + assert_eq!(msg.attachments[1].id, "doc456"); // safety: test-only assertion + assert_eq!(msg.attachments[1].mime_type, "application/pdf"); // safety: test-only assertion assert_eq!( msg.attachments[1].extracted_text, Some("Report contents...".to_string()) - ); + ); // safety: test-only assertion assert_eq!( msg.attachments[1].storage_key, Some("store/doc456".to_string()) - ); + ); // safety: test-only assertion } #[tokio::test] @@ -4460,32 +4472,36 @@ mod tests { )); let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None)); - let messages = vec![EmittedMessage::new("telegram-owner", "Hello from owner") - .with_metadata(r#"{"chat_id":12345}"#)]; + let messages = vec![ + EmittedMessage::new("telegram-owner", "Hello from owner") + .with_metadata(r#"{"chat_id":12345}"#), + ]; let result = WasmChannel::dispatch_emitted_messages( - "telegram", - "owner-scope", - Some("telegram-owner"), + EmitDispatchContext { + channel_name: "telegram", + owner_scope_id: "owner-scope", + owner_actor_id: Some("telegram-owner"), + message_tx: &message_tx, + rate_limiter: &rate_limiter, + last_broadcast_metadata: &last_broadcast_metadata, + settings_store: None, + }, messages, - &message_tx, - &rate_limiter, - &last_broadcast_metadata, - None, ) .await; - assert!(result.is_ok()); + assert!(result.is_ok()); // safety: test-only assertion - let msg = rx.try_recv().expect("Should receive message"); - assert_eq!(msg.user_id, "owner-scope"); - assert_eq!(msg.owner_id, "owner-scope"); - assert_eq!(msg.sender_id, "telegram-owner"); - assert_eq!(msg.conversation_scope(), Some("12345")); + let msg = rx.try_recv().expect("Should receive message"); // safety: test-only assertion + assert_eq!(msg.user_id, "owner-scope"); // safety: test-only assertion + assert_eq!(msg.owner_id, "owner-scope"); // safety: test-only assertion + assert_eq!(msg.sender_id, "telegram-owner"); // safety: test-only assertion + assert_eq!(msg.conversation_scope(), Some("12345")); // safety: test-only assertion assert_eq!( last_broadcast_metadata.read().await.as_deref(), Some(r#"{"chat_id":12345}"#) - ); + ); // safety: test-only assertion } #[tokio::test] @@ -4506,31 +4522,33 @@ mod tests { ]; let result = WasmChannel::dispatch_emitted_messages( - "telegram", - "owner-scope", - Some("telegram-owner"), + EmitDispatchContext { + channel_name: "telegram", + owner_scope_id: "owner-scope", + owner_actor_id: Some("telegram-owner"), + message_tx: &message_tx, + rate_limiter: &rate_limiter, + last_broadcast_metadata: &last_broadcast_metadata, + settings_store: None, + }, messages, - &message_tx, - &rate_limiter, - &last_broadcast_metadata, - None, ) .await; - assert!(result.is_ok()); + assert!(result.is_ok()); // safety: test-only assertion - let msg = rx.try_recv().expect("Should receive message"); - assert_eq!(msg.user_id, "guest-42"); - assert_eq!(msg.owner_id, "owner-scope"); - assert_eq!(msg.sender_id, "guest-42"); - assert_eq!(msg.conversation_scope(), Some("999")); - assert!(last_broadcast_metadata.read().await.is_none()); + let msg = rx.try_recv().expect("Should receive message"); // safety: test-only assertion + assert_eq!(msg.user_id, "guest-42"); // safety: test-only assertion + assert_eq!(msg.owner_id, "owner-scope"); // safety: test-only assertion + assert_eq!(msg.sender_id, "guest-42"); // safety: test-only assertion + assert_eq!(msg.conversation_scope(), Some("999")); // safety: test-only assertion + assert!(last_broadcast_metadata.read().await.is_none()); // safety: test-only assertion } #[tokio::test] async fn test_broadcast_owner_scope_uses_stored_owner_metadata() { - let channel = - create_test_channel().with_owner_binding("owner-scope", Some("telegram-owner".to_string())); + let channel = create_test_channel() + .with_owner_binding("owner-scope", Some("telegram-owner".to_string())); *channel.last_broadcast_metadata.write().await = Some(r#"{"chat_id":12345}"#.to_string()); @@ -4541,27 +4559,30 @@ mod tests { ) .await; - assert!(result.is_ok()); + assert!(result.is_ok()); // safety: test-only assertion } #[tokio::test] async fn test_broadcast_legacy_default_uses_owner_metadata() { - let channel = - create_test_channel().with_owner_binding("owner-scope", Some("telegram-owner".to_string())); + let channel = create_test_channel() + .with_owner_binding("owner-scope", Some("telegram-owner".to_string())); *channel.last_broadcast_metadata.write().await = Some(r#"{"chat_id":12345}"#.to_string()); let result = channel - .broadcast("default", crate::channels::OutgoingResponse::text("legacy hello")) + .broadcast( + "default", + crate::channels::OutgoingResponse::text("legacy hello"), + ) .await; - assert!(result.is_ok()); + assert!(result.is_ok()); // safety: test-only assertion } #[tokio::test] async fn test_broadcast_owner_scope_requires_stored_metadata() { - let channel = - create_test_channel().with_owner_binding("owner-scope", Some("telegram-owner".to_string())); + let channel = create_test_channel() + .with_owner_binding("owner-scope", Some("telegram-owner".to_string())); let result = channel .broadcast( @@ -4570,13 +4591,13 @@ mod tests { ) .await; - assert!(result.is_err()); + assert!(result.is_err()); // safety: test-only assertion let err = result.unwrap_err().to_string(); assert!( err.contains("Send a message from the owner on this channel first"), "expected missing owner routing metadata error, got: {}", err - ); + ); // safety: test-only assertion } #[tokio::test] @@ -4596,22 +4617,24 @@ mod tests { let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None)); let result = WasmChannel::dispatch_emitted_messages( - "test-channel", - "default", - None, + EmitDispatchContext { + channel_name: "test-channel", + owner_scope_id: "default", + owner_actor_id: None, + message_tx: &message_tx, + rate_limiter: &rate_limiter, + last_broadcast_metadata: &last_broadcast_metadata, + settings_store: None, + }, messages, - &message_tx, - &rate_limiter, - &last_broadcast_metadata, - None, ) .await; - assert!(result.is_ok()); + assert!(result.is_ok()); // safety: test-only assertion - let msg = rx.try_recv().expect("Should receive message"); - assert_eq!(msg.content, "Just text, no attachments"); - assert!(msg.attachments.is_empty()); + let msg = rx.try_recv().expect("Should receive message"); // safety: test-only assertion + assert_eq!(msg.content, "Just text, no attachments"); // safety: test-only assertion + assert!(msg.attachments.is_empty()); // safety: test-only assertion } #[test] diff --git a/src/cli/routines.rs b/src/cli/routines.rs index fd8d8bbdfe4..dd8a2fa3542 100644 --- a/src/cli/routines.rs +++ b/src/cli/routines.rs @@ -292,6 +292,16 @@ async fn list( // ── Create ────────────────────────────────────────────────── +fn cli_notify_config(notify_channel: Option) -> NotifyConfig { + NotifyConfig { + channel: notify_channel, + user: None, + on_attention: true, + on_failure: true, + on_success: false, + } +} + #[allow(clippy::too_many_arguments)] async fn create( db: &Arc, @@ -338,13 +348,7 @@ async fn create( max_concurrent: 1, dedup_window: None, }, - notify: NotifyConfig { - channel: notify_channel, - user: Some(user_id.to_string()), - on_attention: true, - on_failure: true, - on_success: false, - }, + notify: cli_notify_config(notify_channel), last_run_at: None, next_fire_at: next_fire, run_count: 0, @@ -729,4 +733,14 @@ mod tests { // Must be valid UTF-8 (would have panicked otherwise). assert!(result.is_char_boundary(result.len())); } + + #[test] + fn cli_notify_config_defaults_to_runtime_target_resolution() { + let notify = cli_notify_config(Some("telegram".to_string())); + assert_eq!(notify.channel.as_deref(), Some("telegram")); // safety: test-only assertion + assert_eq!(notify.user, None); // safety: test-only assertion + assert!(notify.on_attention); // safety: test-only assertion + assert!(notify.on_failure); // safety: test-only assertion + assert!(!notify.on_success); // safety: test-only assertion + } } diff --git a/src/db/libsql/mod.rs b/src/db/libsql/mod.rs index 6f4d7df12d5..779a3e54635 100644 --- a/src/db/libsql/mod.rs +++ b/src/db/libsql/mod.rs @@ -434,14 +434,14 @@ mod tests { #[test] fn test_normalize_notify_user_treats_legacy_default_as_missing() { - assert_eq!(normalize_notify_user(None), None); - assert_eq!(normalize_notify_user(Some(String::new())), None); - assert_eq!(normalize_notify_user(Some(" ".to_string())), None); - assert_eq!(normalize_notify_user(Some("default".to_string())), None); + assert_eq!(normalize_notify_user(None), None); // safety: test-only assertion + assert_eq!(normalize_notify_user(Some(String::new())), None); // safety: test-only assertion + assert_eq!(normalize_notify_user(Some(" ".to_string())), None); // safety: test-only assertion + assert_eq!(normalize_notify_user(Some("default".to_string())), None); // safety: test-only assertion assert_eq!( normalize_notify_user(Some("123456789".to_string())), Some("123456789".to_string()) - ); + ); // safety: test-only assertion } #[test] From debb08804eeb307701d8c6111ed7cd08a852c8c3 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Fri, 13 Mar 2026 14:35:22 -0700 Subject: [PATCH 03/14] fix: address owner scope review feedback --- src/agent/agent_loop.rs | 4 +++- src/agent/dispatcher.rs | 8 ++++++-- src/agent/thread_ops.rs | 5 +++-- src/app.rs | 4 +++- src/channels/repl.rs | 3 +-- src/channels/wasm/setup.rs | 4 +++- src/channels/wasm/wrapper.rs | 14 +++++--------- src/db/libsql/mod.rs | 6 ++---- src/db/libsql_migrations.rs | 2 ++ src/main.rs | 1 + src/testing/mod.rs | 1 + src/tools/builtin/message.rs | 16 +++++----------- tests/support/gateway_workflow_harness.rs | 1 + tests/support/test_rig.rs | 1 + 14 files changed, 37 insertions(+), 33 deletions(-) diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index e1e62ed76c4..2c184e08094 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -77,6 +77,8 @@ fn should_fallback_routine_notification(error: &ChannelError) -> bool { /// /// Bundles the shared components to reduce argument count. pub struct AgentDeps { + /// Resolved durable owner scope for the instance. + pub owner_id: String, pub store: Option>, pub llm: Arc, /// Cheap/fast LLM for lightweight tasks (heartbeat, routing, evaluation). @@ -126,7 +128,7 @@ impl Agent { .workspace .as_ref() .map(|workspace| workspace.user_id()) - .unwrap_or("default") + .unwrap_or(&self.deps.owner_id) } /// Create a new agent. diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs index 9803ac8c7be..4301c09af3d 100644 --- a/src/agent/dispatcher.rs +++ b/src/agent/dispatcher.rs @@ -139,8 +139,9 @@ impl Agent { } // Create a JobContext for tool execution (chat doesn't have a real job) - let mut job_ctx = JobContext::with_user(&message.user_id, "chat", "Interactive chat session") - .with_requester_id(&message.sender_id); + let mut job_ctx = + JobContext::with_user(&message.user_id, "chat", "Interactive chat session") + .with_requester_id(&message.sender_id); job_ctx.http_interceptor = self.deps.http_interceptor.clone(); job_ctx.user_timezone = user_tz.name().to_string(); job_ctx.metadata = serde_json::json!({ @@ -1175,6 +1176,7 @@ mod tests { /// Build a minimal `Agent` for unit testing (no DB, no workspace, no extensions). fn make_test_agent() -> Agent { let deps = AgentDeps { + owner_id: "default".to_string(), store: None, llm: Arc::new(StaticLlmProvider), cheap_llm: None, @@ -2014,6 +2016,7 @@ mod tests { /// `max_tool_iterations` override. fn make_test_agent_with_llm(llm: Arc, max_tool_iterations: usize) -> Agent { let deps = AgentDeps { + owner_id: "default".to_string(), store: None, llm, cheap_llm: None, @@ -2127,6 +2130,7 @@ mod tests { let max_iter = 3; let agent = { let deps = AgentDeps { + owner_id: "default".to_string(), store: None, llm, cheap_llm: None, diff --git a/src/agent/thread_ops.rs b/src/agent/thread_ops.rs index ab66bdcae15..e5f2005d255 100644 --- a/src/agent/thread_ops.rs +++ b/src/agent/thread_ops.rs @@ -923,8 +923,9 @@ impl Agent { } // Execute the approved tool and continue the loop - let mut job_ctx = JobContext::with_user(&message.user_id, "chat", "Interactive chat session") - .with_requester_id(&message.sender_id); + let mut job_ctx = + JobContext::with_user(&message.user_id, "chat", "Interactive chat session") + .with_requester_id(&message.sender_id); job_ctx.http_interceptor = self.deps.http_interceptor.clone(); // Prefer a valid timezone from the approval message, fall back to the // resolved timezone stored when the approval was originally requested. diff --git a/src/app.rs b/src/app.rs index f435129142c..0ffe782064e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -140,7 +140,9 @@ impl AppBuilder { self.handles = Some(handles); // Post-init: migrate disk config, reload config from DB, attach session, cleanup - if let Err(e) = crate::bootstrap::migrate_disk_to_db(db.as_ref(), &self.config.owner_id).await { + if let Err(e) = + crate::bootstrap::migrate_disk_to_db(db.as_ref(), &self.config.owner_id).await + { tracing::warn!("Disk-to-DB settings migration failed: {}", e); } diff --git a/src/channels/repl.rs b/src/channels/repl.rs index dfeb08de9ca..40d669198cf 100644 --- a/src/channels/repl.rs +++ b/src/channels/repl.rs @@ -316,8 +316,7 @@ impl Channel for ReplChannel { // Single message mode: send it and return if let Some(msg) = single_message { - let incoming = - IncomingMessage::new("repl", &user_id, &msg).with_timezone(&sys_tz); + let incoming = IncomingMessage::new("repl", &user_id, &msg).with_timezone(&sys_tz); let _ = tx.blocking_send(incoming); // Ensure the agent exits after handling exactly one turn in -m mode, // even when other channels (gateway/http) are enabled. diff --git a/src/channels/wasm/setup.rs b/src/channels/wasm/setup.rs index 8a0e34cbf18..c3dbc56256e 100644 --- a/src/channels/wasm/setup.rs +++ b/src/channels/wasm/setup.rs @@ -243,7 +243,9 @@ async fn register_channel( // Register HMAC signing secret if declared in capabilities. if let Some(ref hmac_secret_name) = hmac_secret_name && let Some(secrets) = secrets_store - && let Ok(secret) = secrets.get_decrypted(&config.owner_id, hmac_secret_name).await + && let Ok(secret) = secrets + .get_decrypted(&config.owner_id, hmac_secret_name) + .await { wasm_router .register_hmac_secret(&channel_name, secret.expose()) diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index 59325980047..45224c65fe2 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -4498,10 +4498,8 @@ mod tests { assert_eq!(msg.owner_id, "owner-scope"); // safety: test-only assertion assert_eq!(msg.sender_id, "telegram-owner"); // safety: test-only assertion assert_eq!(msg.conversation_scope(), Some("12345")); // safety: test-only assertion - assert_eq!( - last_broadcast_metadata.read().await.as_deref(), - Some(r#"{"chat_id":12345}"#) - ); // safety: test-only assertion + let stored_metadata = last_broadcast_metadata.read().await.clone(); + assert_eq!(stored_metadata.as_deref(), Some(r#"{"chat_id":12345}"#)); // safety: test-only assertion } #[tokio::test] @@ -4593,11 +4591,9 @@ mod tests { assert!(result.is_err()); // safety: test-only assertion let err = result.unwrap_err().to_string(); - assert!( - err.contains("Send a message from the owner on this channel first"), - "expected missing owner routing metadata error, got: {}", - err - ); // safety: test-only assertion + let mentions_missing_owner_route = + err.contains("Send a message from the owner on this channel first"); + assert!(mentions_missing_owner_route); // safety: test-only assertion } #[tokio::test] diff --git a/src/db/libsql/mod.rs b/src/db/libsql/mod.rs index 779a3e54635..d19089c1027 100644 --- a/src/db/libsql/mod.rs +++ b/src/db/libsql/mod.rs @@ -438,10 +438,8 @@ mod tests { assert_eq!(normalize_notify_user(Some(String::new())), None); // safety: test-only assertion assert_eq!(normalize_notify_user(Some(" ".to_string())), None); // safety: test-only assertion assert_eq!(normalize_notify_user(Some("default".to_string())), None); // safety: test-only assertion - assert_eq!( - normalize_notify_user(Some("123456789".to_string())), - Some("123456789".to_string()) - ); // safety: test-only assertion + let normalized = normalize_notify_user(Some("123456789".to_string())); + assert_eq!(normalized, Some("123456789".to_string())); // safety: test-only assertion } #[test] diff --git a/src/db/libsql_migrations.rs b/src/db/libsql_migrations.rs index eb7b0dfae5b..96f88c5df7d 100644 --- a/src/db/libsql_migrations.rs +++ b/src/db/libsql_migrations.rs @@ -716,6 +716,8 @@ DROP TABLE routines; ALTER TABLE routines_new RENAME TO routines; CREATE INDEX IF NOT EXISTS idx_routines_user ON routines(user_id); +CREATE INDEX IF NOT EXISTS idx_routines_next_fire ON routines(next_fire_at); +CREATE INDEX IF NOT EXISTS idx_routines_event_triggers ON routines(user_id); PRAGMA foreign_keys=ON; "#, diff --git a/src/main.rs b/src/main.rs index 916eab31f01..1884c5f6f63 100644 --- a/src/main.rs +++ b/src/main.rs @@ -701,6 +701,7 @@ async fn async_main() -> anyhow::Result<()> { .map(|db| Arc::clone(db) as Arc); let deps = AgentDeps { + owner_id: config.owner_id.clone(), store: components.db, llm: components.llm, cheap_llm: components.cheap_llm, diff --git a/src/testing/mod.rs b/src/testing/mod.rs index 19025980f9e..ff522e3ad2c 100644 --- a/src/testing/mod.rs +++ b/src/testing/mod.rs @@ -439,6 +439,7 @@ impl TestHarnessBuilder { }; let deps = AgentDeps { + owner_id: "default".to_string(), store: Some(Arc::clone(&db)), llm, cheap_llm: None, diff --git a/src/tools/builtin/message.rs b/src/tools/builtin/message.rs index cba80d7b5dd..b150c951e15 100644 --- a/src/tools/builtin/message.rs +++ b/src/tools/builtin/message.rs @@ -683,18 +683,12 @@ mod tests { .execute(serde_json::json!({"content": "NEAR price is $5"}), &ctx) .await; - assert!(result.is_err()); + assert!(result.is_err()); // safety: test-only assertion let err = result.unwrap_err().to_string(); - assert!( - !err.contains("No target specified"), - "Should not get 'No target specified' when ctx.user_id can be used, got: {}", - err - ); - assert!( - !err.contains("No channel specified"), - "Should not get 'No channel specified' when metadata has notify_channel, got: {}", - err - ); + let mentions_missing_target = err.contains("No target specified"); + assert!(!mentions_missing_target); // safety: test-only assertion + let mentions_missing_channel = err.contains("No channel specified"); + assert!(!mentions_missing_channel); // safety: test-only assertion } #[tokio::test] diff --git a/tests/support/gateway_workflow_harness.rs b/tests/support/gateway_workflow_harness.rs index c539dad5041..a4d737b52ad 100644 --- a/tests/support/gateway_workflow_harness.rs +++ b/tests/support/gateway_workflow_harness.rs @@ -239,6 +239,7 @@ impl GatewayWorkflowHarness { let mut agent = Agent::new( components.config.agent.clone(), AgentDeps { + owner_id: components.config.owner_id.clone(), store: components.db, llm: components.llm, cheap_llm: components.cheap_llm, diff --git a/tests/support/test_rig.rs b/tests/support/test_rig.rs index 07106e428e9..8549a21cb18 100644 --- a/tests/support/test_rig.rs +++ b/tests/support/test_rig.rs @@ -612,6 +612,7 @@ impl TestRigBuilder { // 7. Construct AgentDeps from AppComponents (mirrors main.rs). let deps = AgentDeps { + owner_id: components.config.owner_id.clone(), store: components.db, llm: components.llm, cheap_llm: components.cheap_llm, From 2c425ac0b3d67a5c67e9f8c2028999777ad47db9 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Fri, 13 Mar 2026 15:32:53 -0700 Subject: [PATCH 04/14] Fix owner-scope onboarding and event trigger isolation --- src/agent/routine_engine.rs | 5 +++++ src/main.rs | 14 +++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/agent/routine_engine.rs b/src/agent/routine_engine.rs index 9cbdc4479f9..519f16c22ae 100644 --- a/src/agent/routine_engine.rs +++ b/src/agent/routine_engine.rs @@ -172,6 +172,11 @@ impl RoutineEngine { EventMatcher::Message { routine, regex } => (routine, regex), EventMatcher::System { .. } => continue, }; + + if routine.user_id != message.user_id { + continue; + } + // Channel filter if let Trigger::Event { channel: Some(ch), .. diff --git a/src/main.rs b/src/main.rs index 1884c5f6f63..ae864bed9b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -153,7 +153,8 @@ async fn async_main() -> anyhow::Result<()> { provider_only: *provider_only, quick: *quick, }; - let mut wizard = SetupWizard::with_config(config); + let mut wizard = + SetupWizard::try_with_config_and_toml(config, cli.config.as_deref())?; wizard.run().await?; } #[cfg(not(any(feature = "postgres", feature = "libsql")))] @@ -195,10 +196,13 @@ async fn async_main() -> anyhow::Result<()> { { println!("Onboarding needed: {}", reason); println!(); - let mut wizard = SetupWizard::with_config(SetupConfig { - quick: true, - ..Default::default() - }); + let mut wizard = SetupWizard::try_with_config_and_toml( + SetupConfig { + quick: true, + ..Default::default() + }, + cli.config.as_deref(), + )?; wizard.run().await?; } From f9fdd6664b7bc6e8d6f66508fe3168c081ea3109 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Fri, 13 Mar 2026 15:49:55 -0700 Subject: [PATCH 05/14] Tighten routing fallback and wizard owner validation --- src/agent/agent_loop.rs | 9 +--- src/channels/wasm/wrapper.rs | 82 +++++++++++++++++------------- src/error.rs | 3 ++ tests/telegram_auth_integration.rs | 46 +++++++++-------- 4 files changed, 77 insertions(+), 63 deletions(-) diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 2c184e08094..614603c1ab6 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -65,12 +65,7 @@ fn resolve_routine_notification_user(metadata: &serde_json::Value) -> Option bool { - match error { - ChannelError::SendFailed { reason, .. } => { - !reason.contains("owner routing target") && !reason.contains("delivery target") - } - _ => true, - } + !matches!(error, ChannelError::MissingRoutingTarget { .. }) } /// Core dependencies for the agent. @@ -1136,7 +1131,7 @@ mod tests { #[test] fn targeted_routine_notifications_do_not_fallback_without_owner_route() { - let error = ChannelError::SendFailed { + let error = ChannelError::MissingRoutingTarget { name: "telegram".to_string(), reason: "No stored owner routing target for channel 'telegram'.".to_string(), }; diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index 45224c65fe2..2ef15a708f4 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -760,6 +760,39 @@ fn resolve_message_scope( } } +fn uses_owner_broadcast_target(user_id: &str, owner_scope_id: &str) -> bool { + user_id == owner_scope_id +} + +fn missing_routing_target_error(name: &str, reason: String) -> ChannelError { + ChannelError::MissingRoutingTarget { + name: name.to_string(), + reason, + } +} + +fn resolve_owner_broadcast_target( + channel_name: &str, + metadata: &str, +) -> Result { + let metadata: serde_json::Value = serde_json::from_str(metadata).map_err(|e| { + missing_routing_target_error( + channel_name, + format!("Invalid stored owner routing metadata: {e}"), + ) + })?; + + crate::channels::routing_target_from_metadata(&metadata).ok_or_else(|| { + missing_routing_target_error( + channel_name, + format!( + "Stored owner routing metadata for channel '{}' is missing a delivery target.", + channel_name + ), + ) + }) +} + fn apply_emitted_metadata(mut msg: IncomingMessage, metadata_json: &str) -> IncomingMessage { if let Ok(metadata) = serde_json::from_str(metadata_json) { msg = msg.with_metadata(metadata); @@ -2634,32 +2667,18 @@ impl Channel for WasmChannel { response: OutgoingResponse, ) -> Result<(), ChannelError> { self.cancel_typing_task().await; - let resolved_target = if user_id == self.owner_scope_id || user_id == "default" { + let resolved_target = if uses_owner_broadcast_target(user_id, &self.owner_scope_id) { let metadata = self.last_broadcast_metadata.read().await.clone().ok_or_else(|| { - ChannelError::SendFailed { - name: self.name.clone(), - reason: format!( + missing_routing_target_error( + &self.name, + format!( "No stored owner routing target for channel '{}'. Send a message from the owner on this channel first.", self.name ), - } + ) })?; - let metadata: serde_json::Value = - serde_json::from_str(&metadata).map_err(|e| ChannelError::SendFailed { - name: self.name.clone(), - reason: format!("Invalid stored owner routing metadata: {e}"), - })?; - - crate::channels::routing_target_from_metadata(&metadata).ok_or_else(|| { - ChannelError::SendFailed { - name: self.name.clone(), - reason: format!( - "Stored owner routing metadata for channel '{}' is missing a delivery target.", - self.name - ), - } - })? + resolve_owner_broadcast_target(&self.name, &metadata)? } else { user_id.to_string() }; @@ -3230,7 +3249,9 @@ mod tests { use crate::channels::wasm::runtime::{ PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig, }; - use crate::channels::wasm::wrapper::{EmitDispatchContext, HttpResponse, WasmChannel}; + use crate::channels::wasm::wrapper::{ + EmitDispatchContext, HttpResponse, WasmChannel, uses_owner_broadcast_target, + }; use crate::pairing::PairingStore; use crate::testing::credentials::TEST_TELEGRAM_BOT_TOKEN; use crate::tools::wasm::ResourceLimits; @@ -4560,21 +4581,10 @@ mod tests { assert!(result.is_ok()); // safety: test-only assertion } - #[tokio::test] - async fn test_broadcast_legacy_default_uses_owner_metadata() { - let channel = create_test_channel() - .with_owner_binding("owner-scope", Some("telegram-owner".to_string())); - - *channel.last_broadcast_metadata.write().await = Some(r#"{"chat_id":12345}"#.to_string()); - - let result = channel - .broadcast( - "default", - crate::channels::OutgoingResponse::text("legacy hello"), - ) - .await; - - assert!(result.is_ok()); // safety: test-only assertion + #[test] + fn test_default_target_is_not_treated_as_owner_scope() { + assert!(!uses_owner_broadcast_target("default", "owner-scope")); // safety: test-only assertion + assert!(uses_owner_broadcast_target("default", "default")); // safety: test-only assertion } #[tokio::test] diff --git a/src/error.rs b/src/error.rs index 9e57a358c8e..11864de783d 100644 --- a/src/error.rs +++ b/src/error.rs @@ -122,6 +122,9 @@ pub enum ChannelError { #[error("Failed to send response on channel {name}: {reason}")] SendFailed { name: String, reason: String }, + #[error("Channel {name} is missing a routing target: {reason}")] + MissingRoutingTarget { name: String, reason: String }, + #[error("Invalid message format: {0}")] InvalidMessage(String), diff --git a/tests/telegram_auth_integration.rs b/tests/telegram_auth_integration.rs index 9bd6d64c739..a796f1bdd45 100644 --- a/tests/telegram_auth_integration.rs +++ b/tests/telegram_auth_integration.rs @@ -98,6 +98,14 @@ async fn load_telegram_module( async fn create_telegram_channel( runtime: Arc, config_json: &str, +) -> WasmChannel { + create_telegram_channel_with_store(runtime, config_json, Arc::new(PairingStore::new())).await +} + +async fn create_telegram_channel_with_store( + runtime: Arc, + config_json: &str, + pairing_store: Arc, ) -> WasmChannel { let module = load_telegram_module(&runtime) .await @@ -108,7 +116,7 @@ async fn create_telegram_channel( module, ChannelCapabilities::for_channel("telegram").with_path("/webhook/telegram"), config_json.to_string(), - Arc::new(PairingStore::new()), + pairing_store, None, ) } @@ -246,33 +254,29 @@ async fn test_group_message_authorized_user_allowed() { } #[tokio::test] -async fn test_group_message_with_owner_id_set() { +async fn test_private_message_with_owner_id_set_uses_guest_pairing_flow() { require_telegram_wasm!(); let runtime = create_test_runtime(); + let dir = tempfile::tempdir().expect("tempdir"); + let pairing_store = Arc::new(PairingStore::with_base_dir(dir.path().to_path_buf())); - // Config: owner_id=123 (only this user can interact) + // Config: owner_id=123, non-owner private DMs should enter the guest + // pairing flow instead of being rejected solely for not being the owner. let config = serde_json::json!({ - "bot_username": "test_bot", + "bot_username": null, "owner_id": 123, - "dm_policy": "allowlist", - "allow_from": ["anyone"], // ignored when owner_id is set + "dm_policy": "pairing", + "allow_from": [], "respond_to_all_group_messages": false }) .to_string(); - let channel = create_telegram_channel(runtime, &config).await; + let channel = create_telegram_channel_with_store(runtime, &config, pairing_store.clone()).await; - // Message from different user. In the owner-scope model this sender stays a - // guest, so the webhook still accepts the update and leaves authorization - // decisions to the guest flow rather than hard-dropping on owner mismatch. + // Non-owner private message should produce a pairing request. let update = build_telegram_update( - 3, - 102, - -123456789, - "group", - 999, // Not the owner - "Other", - "Hey @test_bot hello", + 3, 102, 999, "private", 999, // Not the owner + "Other", "hello", ); let response = channel @@ -289,9 +293,11 @@ async fn test_group_message_with_owner_id_set() { assert_eq!(response.status, 200); - // Regression: with owner_id set, non-owner senders should not be hard- - // dropped solely because they are not the owner. Guest isolation happens - // later in the host/runtime layer. + let pending = pairing_store + .list_pending("telegram") + .expect("pairing store should be readable"); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].id, "999"); } #[tokio::test] From 8bcfb76f637b8781a413171595de54af04de911d Mon Sep 17 00:00:00 2001 From: Henry Park Date: Sat, 14 Mar 2026 17:14:47 -0700 Subject: [PATCH 06/14] fix: address owner-scope follow-up review --- src/channels/http.rs | 6 ++-- src/channels/wasm/loader.rs | 10 ++++-- src/channels/wasm/mod.rs | 2 +- src/channels/wasm/router.rs | 1 + src/channels/wasm/setup.rs | 7 ++-- src/channels/wasm/wrapper.rs | 26 +++++++------- src/extensions/manager.rs | 8 ++--- src/tools/wasm/wrapper.rs | 55 ++++++++++++++++++++++++++++++ tests/telegram_auth_integration.rs | 1 + tests/wasm_channel_integration.rs | 1 + 10 files changed, 90 insertions(+), 27 deletions(-) diff --git a/src/channels/http.rs b/src/channels/http.rs index d4d1849ad35..7ac92c13e16 100644 --- a/src/channels/http.rs +++ b/src/channels/http.rs @@ -133,7 +133,8 @@ impl HttpChannel { #[derive(Debug, Deserialize)] struct WebhookRequest { - /// User or client identifier (ignored, user is fixed by server config). + /// Optional caller or client identifier for sender-scoped routing. + /// The channel owner/storage scope remains fixed by server config. #[serde(default)] user_id: Option, /// Message content. @@ -406,7 +407,8 @@ async fn process_authenticated_request( let _ = req.user_id.as_ref().map(|user_id| { tracing::debug!( provided_user_id = %user_id, - "HTTP webhook request provided user_id, ignoring in favor of configured user_id" + configured_owner_id = %state.user_id, + "HTTP webhook request provided user_id; using it as sender_id while keeping the configured owner scope" ); }); diff --git a/src/channels/wasm/loader.rs b/src/channels/wasm/loader.rs index c261193e7da..6329428fea1 100644 --- a/src/channels/wasm/loader.rs +++ b/src/channels/wasm/loader.rs @@ -27,6 +27,7 @@ pub struct WasmChannelLoader { pairing_store: Arc, settings_store: Option>, secrets_store: Option>, + owner_scope_id: String, } impl WasmChannelLoader { @@ -35,12 +36,14 @@ impl WasmChannelLoader { runtime: Arc, pairing_store: Arc, settings_store: Option>, + owner_scope_id: impl Into, ) -> Self { Self { runtime, pairing_store, settings_store, secrets_store: None, + owner_scope_id: owner_scope_id.into(), } } @@ -149,6 +152,7 @@ impl WasmChannelLoader { self.runtime.clone(), prepared, capabilities, + self.owner_scope_id.clone(), config_json, self.pairing_store.clone(), self.settings_store.clone(), @@ -487,7 +491,8 @@ mod tests { async fn test_loader_invalid_name() { let config = WasmChannelRuntimeConfig::for_testing(); let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap()); - let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()), None); + let loader = + WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()), None, "default"); let dir = TempDir::new().unwrap(); let wasm_path = dir.path().join("test.wasm"); @@ -505,7 +510,8 @@ mod tests { async fn load_from_dir_returns_empty_when_dir_missing() { let config = WasmChannelRuntimeConfig::for_testing(); let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap()); - let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()), None); + let loader = + WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()), None, "default"); let dir = TempDir::new().unwrap(); let missing = dir.path().join("nonexistent_channels_dir"); diff --git a/src/channels/wasm/mod.rs b/src/channels/wasm/mod.rs index dba843417d9..882709a967c 100644 --- a/src/channels/wasm/mod.rs +++ b/src/channels/wasm/mod.rs @@ -69,7 +69,7 @@ //! let runtime = WasmChannelRuntime::new(config)?; //! //! // Load channels from directory -//! let loader = WasmChannelLoader::new(runtime); +//! let loader = WasmChannelLoader::new(runtime, pairing_store, settings_store, owner_scope_id); //! let channels = loader.load_from_dir(Path::new("~/.ironclaw/channels/")).await?; //! //! // Add to channel manager diff --git a/src/channels/wasm/router.rs b/src/channels/wasm/router.rs index 9b0f3da1767..8005ccea56c 100644 --- a/src/channels/wasm/router.rs +++ b/src/channels/wasm/router.rs @@ -672,6 +672,7 @@ mod tests { runtime, prepared, capabilities, + "default", "{}".to_string(), Arc::new(PairingStore::new()), None, diff --git a/src/channels/wasm/setup.rs b/src/channels/wasm/setup.rs index c3dbc56256e..2b9703dc6fe 100644 --- a/src/channels/wasm/setup.rs +++ b/src/channels/wasm/setup.rs @@ -50,6 +50,7 @@ pub async fn setup_wasm_channels( Arc::clone(&runtime), Arc::clone(&pairing_store), settings_store.clone(), + config.owner_id.clone(), ); if let Some(secrets) = secrets_store { loader = loader.with_secrets_store(Arc::clone(secrets)); @@ -147,11 +148,7 @@ async fn register_channel( require_secret: webhook_secret.is_some(), }]; - let channel_arc = Arc::new( - loaded - .channel - .with_owner_binding(config.owner_id.clone(), owner_actor_id.clone()), - ); + let channel_arc = Arc::new(loaded.channel.with_owner_actor_id(owner_actor_id.clone())); // Inject runtime config (tunnel URL, webhook secret, owner_id). { diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index 2ef15a708f4..0be8756b1a4 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -811,6 +811,7 @@ impl WasmChannel { runtime: Arc, prepared: Arc, capabilities: ChannelCapabilities, + owner_scope_id: impl Into, config_json: String, pairing_store: Arc, settings_store: Option>, @@ -837,7 +838,7 @@ impl WasmChannel { workspace_store: Arc::new(ChannelWorkspaceStore::new()), last_broadcast_metadata: Arc::new(tokio::sync::RwLock::new(None)), settings_store, - owner_scope_id: "default".to_string(), + owner_scope_id: owner_scope_id.into(), owner_actor_id: None, secrets_store: None, } @@ -853,13 +854,8 @@ impl WasmChannel { self } - /// Bind this channel to the configured owner scope and external owner actor. - pub fn with_owner_binding( - mut self, - owner_scope_id: impl Into, - owner_actor_id: Option, - ) -> Self { - self.owner_scope_id = owner_scope_id.into(); + /// Bind this channel to the external actor that maps to the configured owner. + pub fn with_owner_actor_id(mut self, owner_actor_id: Option) -> Self { self.owner_actor_id = owner_actor_id; self } @@ -3257,6 +3253,10 @@ mod tests { use crate::tools::wasm::ResourceLimits; fn create_test_channel() -> WasmChannel { + create_test_channel_with_owner_scope("default") + } + + fn create_test_channel_with_owner_scope(owner_scope_id: &str) -> WasmChannel { let config = WasmChannelRuntimeConfig::for_testing(); let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap()); @@ -3273,6 +3273,7 @@ mod tests { runtime, prepared, capabilities, + owner_scope_id, "{}".to_string(), Arc::new(PairingStore::new()), None, @@ -3467,6 +3468,7 @@ mod tests { runtime, prepared, capabilities, + "default", "{}".to_string(), Arc::new(PairingStore::new()), None, @@ -4566,8 +4568,8 @@ mod tests { #[tokio::test] async fn test_broadcast_owner_scope_uses_stored_owner_metadata() { - let channel = create_test_channel() - .with_owner_binding("owner-scope", Some("telegram-owner".to_string())); + let channel = create_test_channel_with_owner_scope("owner-scope") + .with_owner_actor_id(Some("telegram-owner".to_string())); *channel.last_broadcast_metadata.write().await = Some(r#"{"chat_id":12345}"#.to_string()); @@ -4589,8 +4591,8 @@ mod tests { #[tokio::test] async fn test_broadcast_owner_scope_requires_stored_metadata() { - let channel = create_test_channel() - .with_owner_binding("owner-scope", Some("telegram-owner".to_string())); + let channel = create_test_channel_with_owner_scope("owner-scope") + .with_owner_actor_id(Some("telegram-owner".to_string())); let result = channel .broadcast( diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 9fa0bc7d772..2562c1996c7 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -3419,6 +3419,7 @@ impl ExtensionManager { Arc::clone(&channel_runtime), Arc::clone(&pairing_store), settings_store, + self.user_id.clone(), ) .with_secrets_store(Arc::clone(&self.secrets)); loader @@ -3435,6 +3436,7 @@ impl ExtensionManager { Arc::clone(&channel_runtime), Arc::clone(&pairing_store), settings_store, + self.user_id.clone(), ) .with_secrets_store(Arc::clone(&self.secrets)); loader @@ -3478,11 +3480,7 @@ impl ExtensionManager { .ok() .map(|s| s.expose().to_string()); - let channel_arc = Arc::new( - loaded - .channel - .with_owner_binding(self.user_id.clone(), owner_actor_id), - ); + let channel_arc = Arc::new(loaded.channel.with_owner_actor_id(owner_actor_id)); // Inject runtime config (tunnel_url, webhook_secret, owner_id) { diff --git a/src/tools/wasm/wrapper.rs b/src/tools/wasm/wrapper.rs index ac1306214ff..ee00ccd8c78 100644 --- a/src/tools/wasm/wrapper.rs +++ b/src/tools/wasm/wrapper.rs @@ -1159,6 +1159,13 @@ async fn resolve_host_credentials( let secret = match store.get_decrypted(user_id, &mapping.secret_name).await { Ok(s) => Some(s), Err(e) => { + tracing::trace!( + user_id = %user_id, + secret_name = %mapping.secret_name, + error = %e, + "No matching host credential resolved for WASM tool in the requested scope" + ); + // If lookup fails and we're not already looking up "default", try "default" as fallback if user_id != "default" { tracing::debug!( @@ -1685,6 +1692,54 @@ mod tests { ); } + #[tokio::test] + async fn test_resolve_host_credentials_owner_scope_bearer() { + use std::collections::HashMap; + + use crate::context::JobContext; + use crate::secrets::{ + CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore, + }; + use crate::tools::wasm::capabilities::HttpCapability; + use crate::tools::wasm::wrapper::resolve_host_credentials; + + let store = test_secrets_store(); + let ctx = JobContext::with_user("owner-scope", "owner-scope test", "owner-scope test"); + + store + .create( + &ctx.user_id, + CreateSecretParams::new("google_oauth_token", TEST_GOOGLE_OAUTH_TOKEN), + ) + .await + .unwrap(); + + let mut credentials = HashMap::new(); + credentials.insert( + "google_oauth_token".to_string(), + CredentialMapping { + secret_name: "google_oauth_token".to_string(), + location: CredentialLocation::AuthorizationBearer, + host_patterns: vec!["www.googleapis.com".to_string()], + }, + ); + + let caps = Capabilities { + http: Some(HttpCapability { + credentials, + ..Default::default() + }), + ..Default::default() + }; + + let result = resolve_host_credentials(&caps, Some(&store), &ctx.user_id, None).await; + assert_eq!(result.len(), 1); + assert_eq!( + result[0].headers.get("Authorization"), + Some(&format!("Bearer {TEST_GOOGLE_OAUTH_TOKEN}")) + ); + } + #[tokio::test] async fn test_resolve_host_credentials_missing_secret() { use std::collections::HashMap; diff --git a/tests/telegram_auth_integration.rs b/tests/telegram_auth_integration.rs index a796f1bdd45..e04a98108e9 100644 --- a/tests/telegram_auth_integration.rs +++ b/tests/telegram_auth_integration.rs @@ -115,6 +115,7 @@ async fn create_telegram_channel_with_store( runtime, module, ChannelCapabilities::for_channel("telegram").with_path("/webhook/telegram"), + "default", config_json.to_string(), pairing_store, None, diff --git a/tests/wasm_channel_integration.rs b/tests/wasm_channel_integration.rs index b5d1785b949..7e05c0f3972 100644 --- a/tests/wasm_channel_integration.rs +++ b/tests/wasm_channel_integration.rs @@ -43,6 +43,7 @@ fn create_test_channel( runtime, prepared, capabilities, + "default", "{}".to_string(), Arc::new(PairingStore::new()), None, From 5d3e0b5d8301f4b6476e80dc02ef30bf23c3416a Mon Sep 17 00:00:00 2001 From: Henry Park Date: Sat, 14 Mar 2026 17:29:10 -0700 Subject: [PATCH 07/14] fix: tighten owner-scope follow-up details --- src/db/libsql_migrations.rs | 8 +++-- tests/telegram_auth_integration.rs | 55 ++++++++++++++++++++++++++++++ 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/db/libsql_migrations.rs b/src/db/libsql_migrations.rs index 96f88c5df7d..5b42f18ccb0 100644 --- a/src/db/libsql_migrations.rs +++ b/src/db/libsql_migrations.rs @@ -546,7 +546,9 @@ CREATE INDEX IF NOT EXISTS idx_tool_failures_unrepaired ON tool_failures(tool_na -- routines CREATE INDEX IF NOT EXISTS idx_routines_next_fire ON routines(next_fire_at); -CREATE INDEX IF NOT EXISTS idx_routines_event_triggers ON routines(user_id); +CREATE INDEX IF NOT EXISTS idx_routines_event_triggers + ON routines(trigger_type, user_id) + WHERE enabled = 1 AND trigger_type IN ('event', 'system_event'); -- routine_runs CREATE INDEX IF NOT EXISTS idx_routine_runs_status ON routine_runs(status); @@ -717,7 +719,9 @@ ALTER TABLE routines_new RENAME TO routines; CREATE INDEX IF NOT EXISTS idx_routines_user ON routines(user_id); CREATE INDEX IF NOT EXISTS idx_routines_next_fire ON routines(next_fire_at); -CREATE INDEX IF NOT EXISTS idx_routines_event_triggers ON routines(user_id); +CREATE INDEX IF NOT EXISTS idx_routines_event_triggers + ON routines(trigger_type, user_id) + WHERE enabled = 1 AND trigger_type IN ('event', 'system_event'); PRAGMA foreign_keys=ON; "#, diff --git a/tests/telegram_auth_integration.rs b/tests/telegram_auth_integration.rs index e04a98108e9..26475e99daf 100644 --- a/tests/telegram_auth_integration.rs +++ b/tests/telegram_auth_integration.rs @@ -13,11 +13,13 @@ use std::collections::HashMap; use std::sync::Arc; +use futures::StreamExt; use ironclaw::channels::wasm::{ ChannelCapabilities, PreparedChannelModule, WasmChannel, WasmChannelRuntime, WasmChannelRuntimeConfig, }; use ironclaw::pairing::PairingStore; +use tokio::time::{Duration, timeout}; /// Skip the test if the Telegram WASM module hasn't been built. /// In CI (detected via the `CI` env var), panic instead of skipping so a @@ -301,6 +303,59 @@ async fn test_private_message_with_owner_id_set_uses_guest_pairing_flow() { assert_eq!(pending[0].id, "999"); } +#[tokio::test] +async fn test_private_messages_use_chat_id_as_thread_scope() { + require_telegram_wasm!(); + let runtime = create_test_runtime(); + + let config = serde_json::json!({ + "bot_username": null, + "owner_id": null, + "dm_policy": "open", + "allow_from": [], + "respond_to_all_group_messages": false + }) + .to_string(); + + let channel = create_telegram_channel(runtime, &config).await; + let mut stream = channel.start().await.expect("Failed to start channel"); + + for (update_id, message_id, text) in [(6, 105, "first"), (7, 106, "second")] { + let update = build_telegram_update( + update_id, + message_id, + 999, + "private", + 999, + "ThreadUser", + text, + ); + + let response = channel + .call_on_http_request( + "POST", + "/webhook/telegram", + &HashMap::new(), + &HashMap::new(), + &update, + true, + ) + .await + .expect("HTTP callback failed"); + + assert_eq!(response.status, 200); + + let msg = timeout(Duration::from_secs(1), stream.next()) + .await + .expect("message should arrive") + .expect("stream should yield a message"); + assert_eq!(msg.thread_id.as_deref(), Some("999")); + assert_eq!(msg.conversation_scope(), Some("999")); + } + + channel.shutdown().await.expect("Shutdown failed"); +} + #[tokio::test] async fn test_private_message_without_owner_id_with_pairing_policy() { require_telegram_wasm!(); From 789cb875cbd593ea21e098ae1caf490819347edd Mon Sep 17 00:00:00 2001 From: Henry Park Date: Sat, 14 Mar 2026 17:37:14 -0700 Subject: [PATCH 08/14] fix: import Channel trait in telegram test --- tests/telegram_auth_integration.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/telegram_auth_integration.rs b/tests/telegram_auth_integration.rs index 26475e99daf..0052f8a24ff 100644 --- a/tests/telegram_auth_integration.rs +++ b/tests/telegram_auth_integration.rs @@ -14,6 +14,7 @@ use std::collections::HashMap; use std::sync::Arc; use futures::StreamExt; +use ironclaw::channels::Channel; use ironclaw::channels::wasm::{ ChannelCapabilities, PreparedChannelModule, WasmChannel, WasmChannelRuntime, WasmChannelRuntimeConfig, From 869716a2bb04c4461f2d483489eb0e4ff53db27f Mon Sep 17 00:00:00 2001 From: Henry Park Date: Sat, 14 Mar 2026 20:52:45 -0700 Subject: [PATCH 09/14] fix: normalize http webhook sender ids --- src/channels/http.rs | 106 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 98 insertions(+), 8 deletions(-) diff --git a/src/channels/http.rs b/src/channels/http.rs index 7ac92c13e16..9f39f46e007 100644 --- a/src/channels/http.rs +++ b/src/channels/http.rs @@ -404,13 +404,38 @@ async fn process_authenticated_request( state: Arc, req: WebhookRequest, ) -> axum::response::Response { - let _ = req.user_id.as_ref().map(|user_id| { - tracing::debug!( - provided_user_id = %user_id, - configured_owner_id = %state.user_id, - "HTTP webhook request provided user_id; using it as sender_id while keeping the configured owner scope" - ); - }); + let normalized_user_id = req + .user_id + .as_deref() + .map(str::trim) + .filter(|user_id| !user_id.is_empty()); + + match (req.user_id.as_deref(), normalized_user_id) { + (Some(raw_user_id), Some(user_id)) if raw_user_id != user_id => { + tracing::debug!( + provided_user_id = %raw_user_id, + normalized_sender_id = %user_id, + configured_owner_id = %state.user_id, + "HTTP webhook request provided user_id; trimming and using it as sender_id while keeping the configured owner scope" + ); + } + (Some(user_id), Some(_)) => { + tracing::debug!( + provided_user_id = %user_id, + configured_owner_id = %state.user_id, + "HTTP webhook request provided user_id; using it as sender_id while keeping the configured owner scope" + ); + } + (Some(raw_user_id), None) => { + tracing::debug!( + provided_user_id = %raw_user_id, + configured_owner_id = %state.user_id, + "HTTP webhook request provided a blank user_id; falling back to the configured owner scope for sender_id" + ); + } + (None, None) => {} + (None, Some(_)) => unreachable!("normalized user_id requires a raw user_id"), + } if req.content.len() > MAX_CONTENT_BYTES { return ( @@ -516,7 +541,7 @@ async fn process_authenticated_request( Vec::new() }; - let sender_id = req.user_id.clone().unwrap_or_else(|| state.user_id.clone()); + let sender_id = normalized_user_id.unwrap_or(&state.user_id).to_string(); let mut msg = IncomingMessage::new("http", &state.user_id, &req.content) .with_owner_id(&state.user_id) .with_sender_id(sender_id) @@ -686,6 +711,7 @@ mod tests { use axum::body::Body; use axum::http::{HeaderValue, Request}; use secrecy::SecretString; + use tokio_stream::StreamExt; use tower::ServiceExt; use super::*; @@ -824,6 +850,70 @@ mod tests { assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); } + #[tokio::test] + async fn webhook_blank_user_id_falls_back_to_owner_scope() { + let secret = "test-secret-123"; + let channel = test_channel(Some(secret)); + let mut stream = channel.start().await.unwrap(); + let app = channel.routes(); + + let body = serde_json::json!({ + "content": "hello", + "user_id": " " + }); + let body_bytes = serde_json::to_vec(&body).unwrap(); + let signature = compute_signature(secret, &body_bytes); + let req = Request::builder() + .method("POST") + .uri("/webhook") + .header("content-type", "application/json") + .header("x-hub-signature-256", signature) + .body(Body::from(body_bytes)) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next()) + .await + .expect("timed out waiting for webhook message") + .expect("stream should yield a webhook message"); + assert_eq!(msg.sender_id, "http"); + assert_eq!(msg.owner_id, "http"); + } + + #[tokio::test] + async fn webhook_user_id_is_trimmed_before_becoming_sender_id() { + let secret = "test-secret-123"; + let channel = test_channel(Some(secret)); + let mut stream = channel.start().await.unwrap(); + let app = channel.routes(); + + let body = serde_json::json!({ + "content": "hello", + "user_id": " alice " + }); + let body_bytes = serde_json::to_vec(&body).unwrap(); + let signature = compute_signature(secret, &body_bytes); + let req = Request::builder() + .method("POST") + .uri("/webhook") + .header("content-type", "application/json") + .header("x-hub-signature-256", signature) + .body(Body::from(body_bytes)) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next()) + .await + .expect("timed out waiting for webhook message") + .expect("stream should yield a webhook message"); + assert_eq!(msg.sender_id, "alice"); + assert_eq!(msg.owner_id, "http"); + } + /// Regression test for issue #869: RwLock read guard was held across /// tx.send(msg).await in `process_message()`, blocking shutdown() from /// acquiring the write lock when the channel buffer was full. From 2f474c73cf39fafd7eac641673a52da7f50749e5 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 16 Mar 2026 07:45:23 -0700 Subject: [PATCH 10/14] fix: address remaining owner-scope review issues --- src/agent/agent_loop.rs | 14 ++-- src/tools/wasm/wrapper.rs | 135 +++++++++++++++++++++++++++++++++++++- 2 files changed, 142 insertions(+), 7 deletions(-) diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 614603c1ab6..aaaad879d11 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -119,11 +119,15 @@ pub struct Agent { impl Agent { pub(super) fn owner_id(&self) -> &str { - self.deps - .workspace - .as_ref() - .map(|workspace| workspace.user_id()) - .unwrap_or(&self.deps.owner_id) + if let Some(workspace) = self.deps.workspace.as_ref() { + debug_assert_eq!( + workspace.user_id(), + self.deps.owner_id, + "workspace.user_id() must stay aligned with deps.owner_id" + ); + } + + &self.deps.owner_id } /// Create a new agent. diff --git a/src/tools/wasm/wrapper.rs b/src/tools/wasm/wrapper.rs index ee00ccd8c78..be089dd83b2 100644 --- a/src/tools/wasm/wrapper.rs +++ b/src/tools/wasm/wrapper.rs @@ -1386,7 +1386,16 @@ fn build_tool_usage_hint(tool_name: &str, schema: &serde_json::Value) -> String #[cfg(test)] mod tests { - use std::sync::Arc; + use std::sync::{Arc, Mutex}; + + use async_trait::async_trait; + use uuid::Uuid; + + use crate::context::JobContext; + use crate::secrets::{ + CreateSecretParams, DecryptedSecret, InMemorySecretsStore, Secret, SecretError, SecretRef, + SecretsStore, + }; use crate::testing::credentials::{ TEST_BEARER_TOKEN_123, TEST_GOOGLE_OAUTH_FRESH, TEST_GOOGLE_OAUTH_LEGACY, @@ -1397,6 +1406,78 @@ mod tests { use crate::tools::wasm::capabilities::Capabilities; use crate::tools::wasm::runtime::{WasmRuntimeConfig, WasmToolRuntime}; + struct RecordingSecretsStore { + inner: InMemorySecretsStore, + get_decrypted_lookups: Mutex>, + } + + impl RecordingSecretsStore { + fn new() -> Self { + Self { + inner: test_secrets_store(), + get_decrypted_lookups: Mutex::new(Vec::new()), + } + } + + fn decrypted_lookups(&self) -> Vec<(String, String)> { + self.get_decrypted_lookups.lock().unwrap().clone() + } + } + + #[async_trait] + impl SecretsStore for RecordingSecretsStore { + async fn create( + &self, + user_id: &str, + params: CreateSecretParams, + ) -> Result { + self.inner.create(user_id, params).await + } + + async fn get(&self, user_id: &str, name: &str) -> Result { + self.inner.get(user_id, name).await + } + + async fn get_decrypted( + &self, + user_id: &str, + name: &str, + ) -> Result { + self.get_decrypted_lookups + .lock() + .unwrap() + .push((user_id.to_string(), name.to_string())); + self.inner.get_decrypted(user_id, name).await + } + + async fn exists(&self, user_id: &str, name: &str) -> Result { + self.inner.exists(user_id, name).await + } + + async fn list(&self, user_id: &str) -> Result, SecretError> { + self.inner.list(user_id).await + } + + async fn delete(&self, user_id: &str, name: &str) -> Result { + self.inner.delete(user_id, name).await + } + + async fn record_usage(&self, secret_id: Uuid) -> Result<(), SecretError> { + self.inner.record_usage(secret_id).await + } + + async fn is_accessible( + &self, + user_id: &str, + secret_name: &str, + allowed_secrets: &[String], + ) -> Result { + self.inner + .is_accessible(user_id, secret_name, allowed_secrets) + .await + } + } + #[test] fn test_wrapper_creation() { // This test verifies the runtime can be created @@ -1696,7 +1777,6 @@ mod tests { async fn test_resolve_host_credentials_owner_scope_bearer() { use std::collections::HashMap; - use crate::context::JobContext; use crate::secrets::{ CreateSecretParams, CredentialLocation, CredentialMapping, SecretsStore, }; @@ -1740,6 +1820,57 @@ mod tests { ); } + #[tokio::test] + async fn test_execute_resolves_host_credentials_from_owner_scope_context() { + use std::collections::HashMap; + + use crate::secrets::{CredentialLocation, CredentialMapping}; + use crate::tools::wasm::capabilities::HttpCapability; + + let runtime = Arc::new(WasmToolRuntime::new(WasmRuntimeConfig::for_testing()).unwrap()); + let prepared = runtime + .prepare("search", b"\0asm\x0d\0\x01\0", None) + .await + .unwrap(); + let store = Arc::new(RecordingSecretsStore::new()); + let ctx = JobContext::with_user("owner-scope", "owner-scope test", "owner-scope test"); + + store + .create( + &ctx.user_id, + CreateSecretParams::new("google_oauth_token", TEST_GOOGLE_OAUTH_TOKEN), + ) + .await + .unwrap(); + + let mut credentials = HashMap::new(); + credentials.insert( + "google_oauth_token".to_string(), + CredentialMapping { + secret_name: "google_oauth_token".to_string(), + location: CredentialLocation::AuthorizationBearer, + host_patterns: vec!["www.googleapis.com".to_string()], + }, + ); + + let caps = Capabilities { + http: Some(HttpCapability { + credentials, + ..Default::default() + }), + ..Default::default() + }; + + let wrapper = super::WasmToolWrapper::new(Arc::clone(&runtime), prepared, caps) + .with_secrets_store(store.clone()); + let result = wrapper.execute(serde_json::json!({}), &ctx).await; + assert!(result.is_err()); + + let lookups = store.decrypted_lookups(); + assert!(lookups.contains(&("owner-scope".to_string(), "google_oauth_token".to_string()))); + assert!(!lookups.contains(&("default".to_string(), "google_oauth_token".to_string()))); + } + #[tokio::test] async fn test_resolve_host_credentials_missing_secret() { use std::collections::HashMap; From b932ee969e2a4219e423e05d1aeb324bfa481576 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 16 Mar 2026 08:09:36 -0700 Subject: [PATCH 11/14] fix: reconcile config rebase fallout --- src/channels/channel.rs | 11 +++++++++++ src/config/channels.rs | 6 +++++- src/config/mod.rs | 14 ++++++++------ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/channels/channel.rs b/src/channels/channel.rs index f6b1a419d89..43e35688ccc 100644 --- a/src/channels/channel.rs +++ b/src/channels/channel.rs @@ -93,6 +93,10 @@ pub struct IncomingMessage { pub timezone: Option, /// File or media attachments on this message. pub attachments: Vec, + /// Internal-only flag: message was generated inside the process (e.g. job + /// monitor) and must bypass the normal user-input pipeline. This field is + /// not settable via metadata, so external channels cannot spoof it. + pub(crate) is_internal: bool, } impl IncomingMessage { @@ -117,6 +121,7 @@ impl IncomingMessage { metadata: serde_json::Value::Null, timezone: None, attachments: Vec::new(), + is_internal: false, } } @@ -170,6 +175,12 @@ impl IncomingMessage { self } + /// Mark this message as internal (bypasses user-input pipeline). + pub(crate) fn into_internal(mut self) -> Self { + self.is_internal = true; + self + } + /// Effective conversation scope, falling back to thread_id for legacy callers. pub fn conversation_scope(&self) -> Option<&str> { self.conversation_scope_id diff --git a/src/config/channels.rs b/src/config/channels.rs index 7578d0534fd..1a24ceefe65 100644 --- a/src/config/channels.rs +++ b/src/config/channels.rs @@ -107,7 +107,7 @@ impl ChannelsConfig { let gateway = if gateway_enabled { Some(GatewayConfig { host: optional_env("GATEWAY_HOST")?.unwrap_or_else(|| "127.0.0.1".to_string()), - port: parse_optional_env("GATEWAY_PORT", 3000)?, + port: parse_optional_env("GATEWAY_PORT", DEFAULT_GATEWAY_PORT)?, auth_token: optional_env("GATEWAY_AUTH_TOKEN")?, user_id: owner_id.to_string(), }) @@ -200,6 +200,10 @@ impl ChannelsConfig { } } +/// Default gateway port — used both in `resolve()` and as the fallback in +/// other modules that need to construct a gateway URL. +pub const DEFAULT_GATEWAY_PORT: u16 = 3000; + /// Get the default channels directory (~/.ironclaw/channels/). fn default_channels_dir() -> PathBuf { ironclaw_base_dir().join("channels") diff --git a/src/config/mod.rs b/src/config/mod.rs index 85b64e1532b..38c80880502 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -34,7 +34,9 @@ use crate::settings::Settings; // Re-export all public types so `crate::config::FooConfig` continues to work. pub use self::agent::AgentConfig; pub use self::builder::BuilderModeConfig; -pub use self::channels::{ChannelsConfig, CliConfig, GatewayConfig, HttpConfig, SignalConfig}; +pub use self::channels::{ + ChannelsConfig, CliConfig, DEFAULT_GATEWAY_PORT, GatewayConfig, HttpConfig, SignalConfig, +}; pub use self::database::{DatabaseBackend, DatabaseConfig, SslMode, default_libsql_path}; pub use self::embeddings::EmbeddingsConfig; pub use self::heartbeat::HeartbeatConfig; @@ -311,15 +313,15 @@ impl Config { tunnel: TunnelConfig::resolve(settings)?, channels: ChannelsConfig::resolve(settings, &owner_id)?, agent: AgentConfig::resolve(settings)?, - safety: resolve_safety_config()?, - wasm: WasmConfig::resolve()?, + safety: resolve_safety_config(settings)?, + wasm: WasmConfig::resolve(settings)?, secrets: SecretsConfig::resolve().await?, - builder: BuilderModeConfig::resolve()?, + builder: BuilderModeConfig::resolve(settings)?, heartbeat: HeartbeatConfig::resolve(settings)?, hygiene: HygieneConfig::resolve()?, routines: RoutineConfig::resolve()?, - sandbox: SandboxModeConfig::resolve()?, - claude_code: ClaudeCodeConfig::resolve()?, + sandbox: SandboxModeConfig::resolve(settings)?, + claude_code: ClaudeCodeConfig::resolve(settings)?, skills: SkillsConfig::resolve()?, transcription: TranscriptionConfig::resolve(settings)?, search: WorkspaceSearchConfig::resolve()?, From 6ebf011a402bc3d2a36d18bf67484608b766ed22 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 16 Mar 2026 08:21:06 -0700 Subject: [PATCH 12/14] fix: reconcile extension manager rebase drift --- src/extensions/manager.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 2562c1996c7..471f10cf897 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -3464,9 +3464,7 @@ impl ExtensionManager { owner_id: Option, ) -> Result { let channel_name = loaded.name().to_string(); - let owner_actor_id = wasm_channel_owner_ids - .get(channel_name.as_str()) - .map(ToString::to_string); + let owner_actor_id = owner_id.map(|id| id.to_string()); let webhook_secret_name = loaded.webhook_secret_name(); let secret_header = loaded.webhook_secret_header().map(|s| s.to_string()); let sig_key_secret_name = loaded.signature_key_secret_name(); @@ -5620,6 +5618,7 @@ mod tests { runtime, prepared, capabilities, + "default", "{}".to_string(), pairing_store, None, From 77e3de6707fe064e92283ab65d54618e7465face Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 16 Mar 2026 08:42:27 -0700 Subject: [PATCH 13/14] fix: address current copilot review regressions --- channels-src/telegram/src/lib.rs | 220 ++++++++++++++++++++++++------- src/config/channels.rs | 121 +++++++++++++---- tests/e2e/helpers.py | 2 +- 3 files changed, 263 insertions(+), 80 deletions(-) diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs index f845f0ce11a..a095ccb3a23 100644 --- a/channels-src/telegram/src/lib.rs +++ b/channels-src/telegram/src/lib.rs @@ -100,6 +100,14 @@ struct TelegramMessage { /// Sticker. sticker: Option, + + /// Forum topic ID. Present when the message is sent inside a forum topic. + #[serde(default)] + message_thread_id: Option, + + /// True when this message is sent inside a forum topic. + #[serde(default)] + is_topic_message: Option, } /// Telegram PhotoSize object. @@ -290,6 +298,10 @@ struct TelegramMessageMetadata { /// Whether this is a private (DM) chat. is_private: bool, + + /// Forum topic thread ID (for routing replies back to the correct topic). + #[serde(default, skip_serializing_if = "Option::is_none")] + message_thread_id: Option, } /// Channel configuration injected by host. @@ -491,8 +503,7 @@ impl Guest for TelegramChannel { // Delete any existing webhook before polling. Telegram returns success // when no webhook exists, so any error here (e.g. 401) means a bad token. - delete_webhook() - .map_err(|e| format!("Bot token validation failed: {}", e))?; + delete_webhook().map_err(|e| format!("Bot token validation failed: {}", e))?; } // Configure polling only if not in webhook mode @@ -680,7 +691,12 @@ impl Guest for TelegramChannel { let metadata: TelegramMessageMetadata = serde_json::from_str(&response.metadata_json) .map_err(|e| format!("Failed to parse metadata: {}", e))?; - send_response(metadata.chat_id, &response, Some(metadata.message_id)) + send_response( + metadata.chat_id, + &response, + Some(metadata.message_id), + metadata.message_thread_id, + ) } fn on_broadcast(user_id: String, response: AgentResponse) -> Result<(), String> { @@ -688,7 +704,7 @@ impl Guest for TelegramChannel { .parse() .map_err(|e| format!("Invalid chat_id '{}': {}", user_id, e))?; - send_response(chat_id, &response, None) + send_response(chat_id, &response, None, None) } fn on_status(update: StatusUpdate) { @@ -712,11 +728,15 @@ impl Guest for TelegramChannel { match action { TelegramStatusAction::Typing => { // POST /sendChatAction with action "typing" - let payload = serde_json::json!({ + let mut payload = serde_json::json!({ "chat_id": metadata.chat_id, "action": "typing" }); + if let Some(thread_id) = metadata.message_thread_id { + payload["message_thread_id"] = serde_json::Value::Number(thread_id.into()); + } + let payload_bytes = match serde_json::to_vec(&payload) { Ok(b) => b, Err(_) => return, @@ -743,9 +763,13 @@ impl Guest for TelegramChannel { } TelegramStatusAction::Notify(prompt) => { // Send user-visible status updates for actionable events. - if let Err(first_err) = - send_message(metadata.chat_id, &prompt, Some(metadata.message_id), None) - { + if let Err(first_err) = send_message( + metadata.chat_id, + &prompt, + Some(metadata.message_id), + None, + metadata.message_thread_id, + ) { channel_host::log( channel_host::LogLevel::Warn, &format!( @@ -754,7 +778,13 @@ impl Guest for TelegramChannel { ), ); - if let Err(retry_err) = send_message(metadata.chat_id, &prompt, None, None) { + if let Err(retry_err) = send_message( + metadata.chat_id, + &prompt, + None, + None, + metadata.message_thread_id, + ) { channel_host::log( channel_host::LogLevel::Debug, &format!( @@ -797,6 +827,14 @@ impl std::fmt::Display for SendError { } } +/// Normalize `message_thread_id` for outbound API calls. +/// +/// Telegram rejects `sendMessage` and file-send methods when +/// `message_thread_id = 1` (the "General" topic), so omit it in that case. +fn normalize_thread_id(thread_id: Option) -> Option { + thread_id.filter(|&id| id != 1) +} + /// Send a message via the Telegram Bot API. /// /// Returns the sent message_id on success. When `parse_mode` is set and @@ -807,7 +845,10 @@ fn send_message( text: &str, reply_to_message_id: Option, parse_mode: Option<&str>, + message_thread_id: Option, ) -> Result { + let message_thread_id = normalize_thread_id(message_thread_id); + let mut payload = serde_json::json!({ "chat_id": chat_id, "text": text, @@ -821,6 +862,10 @@ fn send_message( payload["parse_mode"] = serde_json::Value::String(mode.to_string()); } + if let Some(thread_id) = message_thread_id { + payload["message_thread_id"] = serde_json::Value::Number(thread_id.into()); + } + let payload_bytes = serde_json::to_vec(&payload) .map_err(|e| SendError::Other(format!("Failed to serialize payload: {}", e)))?; @@ -911,19 +956,20 @@ fn download_telegram_file(file_id: &str) -> Result, String> { ); let headers = serde_json::json!({}); - let result = - channel_host::http_request("GET", &get_file_url, &headers.to_string(), None, None); + let result = channel_host::http_request("GET", &get_file_url, &headers.to_string(), None, None); let response = result.map_err(|e| format!("getFile request failed: {}", e))?; if response.status != 200 { let body_str = String::from_utf8_lossy(&response.body); - return Err(format!("getFile returned {}: {}", response.status, body_str)); + return Err(format!( + "getFile returned {}: {}", + response.status, body_str + )); } - let api_response: TelegramApiResponse = - serde_json::from_slice(&response.body) - .map_err(|e| format!("Failed to parse getFile response: {}", e))?; + let api_response: TelegramApiResponse = serde_json::from_slice(&response.body) + .map_err(|e| format!("Failed to parse getFile response: {}", e))?; if !api_response.ok { return Err(format!( @@ -953,16 +999,12 @@ fn download_telegram_file(file_id: &str) -> Result, String> { file_path ); - let result = - channel_host::http_request("GET", &download_url, &headers.to_string(), None, None); + let result = channel_host::http_request("GET", &download_url, &headers.to_string(), None, None); let response = result.map_err(|e| format!("File download failed: {}", e))?; if response.status != 200 { - return Err(format!( - "File download returned status {}", - response.status - )); + return Err(format!("File download returned status {}", response.status)); } // Post-download size guard: Telegram metadata file_size is optional, @@ -1036,7 +1078,10 @@ fn send_photo( mime_type: &str, data: &[u8], reply_to_message_id: Option, + message_thread_id: Option, ) -> Result<(), String> { + let message_thread_id = normalize_thread_id(message_thread_id); + if data.len() > MAX_PHOTO_SIZE { channel_host::log( channel_host::LogLevel::Info, @@ -1046,7 +1091,14 @@ fn send_photo( data.len() ), ); - return send_document(chat_id, filename, mime_type, data, reply_to_message_id); + return send_document( + chat_id, + filename, + mime_type, + data, + reply_to_message_id, + message_thread_id, + ); } let boundary = format!("ironclaw-{}", channel_host::now_millis()); @@ -1054,7 +1106,20 @@ fn send_photo( 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_field( + &mut body, + &boundary, + "reply_to_message_id", + &msg_id.to_string(), + ); + } + if let Some(thread_id) = message_thread_id { + write_multipart_field( + &mut body, + &boundary, + "message_thread_id", + &thread_id.to_string(), + ); } write_multipart_file(&mut body, &boundary, "photo", filename, mime_type, data); body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes()); @@ -1097,13 +1162,29 @@ fn send_document( mime_type: &str, data: &[u8], reply_to_message_id: Option, + message_thread_id: Option, ) -> Result<(), String> { + let message_thread_id = normalize_thread_id(message_thread_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_field( + &mut body, + &boundary, + "reply_to_message_id", + &msg_id.to_string(), + ); + } + if let Some(thread_id) = message_thread_id { + write_multipart_field( + &mut body, + &boundary, + "message_thread_id", + &thread_id.to_string(), + ); } write_multipart_file(&mut body, &boundary, "document", filename, mime_type, data); body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes()); @@ -1140,12 +1221,7 @@ fn send_document( } /// Image MIME types that Telegram's sendPhoto API supports. -const PHOTO_MIME_TYPES: &[&str] = &[ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", -]; +const PHOTO_MIME_TYPES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"]; /// Send a full agent response (attachments + text) to a chat. /// @@ -1154,10 +1230,11 @@ fn send_response( chat_id: i64, response: &AgentResponse, reply_to_message_id: Option, + message_thread_id: Option, ) -> Result<(), String> { // Send attachments first (photos/documents) for attachment in &response.attachments { - send_attachment(chat_id, attachment, reply_to_message_id)?; + send_attachment(chat_id, attachment, reply_to_message_id, message_thread_id)?; } // Skip text if empty and we already sent attachments @@ -1166,13 +1243,23 @@ fn send_response( } // Try Markdown, fall back to plain text on parse errors - match send_message(chat_id, &response.content, reply_to_message_id, Some("Markdown")) { + match send_message( + chat_id, + &response.content, + reply_to_message_id, + Some("Markdown"), + message_thread_id, + ) { Ok(_) => Ok(()), - Err(SendError::ParseEntities(_)) => { - send_message(chat_id, &response.content, reply_to_message_id, None) - .map(|_| ()) - .map_err(|e| format!("Plain-text retry also failed: {}", e)) - } + Err(SendError::ParseEntities(_)) => send_message( + chat_id, + &response.content, + reply_to_message_id, + None, + message_thread_id, + ) + .map(|_| ()) + .map_err(|e| format!("Plain-text retry also failed: {}", e)), Err(e) => Err(e.to_string()), } } @@ -1182,6 +1269,7 @@ fn send_attachment( chat_id: i64, attachment: &Attachment, reply_to_message_id: Option, + message_thread_id: Option, ) -> Result<(), String> { if PHOTO_MIME_TYPES.contains(&attachment.mime_type.as_str()) { send_photo( @@ -1190,6 +1278,7 @@ fn send_attachment( &attachment.mime_type, &attachment.data, reply_to_message_id, + message_thread_id, ) } else { send_document( @@ -1198,6 +1287,7 @@ fn send_attachment( &attachment.mime_type, &attachment.data, reply_to_message_id, + message_thread_id, ) } } @@ -1337,7 +1427,10 @@ fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<() let context = if retried { " (after retry)" } else { "" }; channel_host::log( channel_host::LogLevel::Info, - &format!("Webhook registered successfully{}: {}", context, webhook_url), + &format!( + "Webhook registered successfully{}: {}", + context, webhook_url + ), ); Ok(()) @@ -1357,6 +1450,7 @@ fn send_pairing_reply(chat_id: i64, code: &str) -> Result<(), String> { ), None, Some("Markdown"), + None, ) .map(|_| ()) .map_err(|e| e.to_string()) @@ -1438,7 +1532,9 @@ fn extract_attachments(message: &TelegramMessage) -> Vec { if let Some(ref doc) = message.document { attachments.push(make_inbound_attachment( doc.file_id.clone(), - doc.mime_type.clone().unwrap_or_else(|| "application/octet-stream".to_string()), + doc.mime_type + .clone() + .unwrap_or_else(|| "application/octet-stream".to_string()), doc.file_name.clone(), doc.file_size.map(|s| s as u64), Some(get_file_url(&doc.file_id)), @@ -1451,7 +1547,10 @@ fn extract_attachments(message: &TelegramMessage) -> Vec { if let Some(ref audio) = message.audio { attachments.push(make_inbound_attachment( audio.file_id.clone(), - audio.mime_type.clone().unwrap_or_else(|| "audio/mpeg".to_string()), + audio + .mime_type + .clone() + .unwrap_or_else(|| "audio/mpeg".to_string()), audio.file_name.clone(), audio.file_size.map(|s| s as u64), Some(get_file_url(&audio.file_id)), @@ -1464,7 +1563,10 @@ fn extract_attachments(message: &TelegramMessage) -> Vec { if let Some(ref video) = message.video { attachments.push(make_inbound_attachment( video.file_id.clone(), - video.mime_type.clone().unwrap_or_else(|| "video/mp4".to_string()), + video + .mime_type + .clone() + .unwrap_or_else(|| "video/mp4".to_string()), video.file_name.clone(), video.file_size.map(|s| s as u64), Some(get_file_url(&video.file_id)), @@ -1803,6 +1905,7 @@ fn handle_message(message: TelegramMessage) { message_id: message.message_id, user_id: from.id, is_private, + message_thread_id: message.message_thread_id, }; let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string()); @@ -2427,7 +2530,11 @@ mod tests { assert_eq!(attachments[0].id, "large_id"); // Largest photo assert_eq!(attachments[0].mime_type, "image/jpeg"); assert_eq!(attachments[0].size_bytes, Some(54321)); - assert!(attachments[0].source_url.as_ref().unwrap().contains("large_id")); + assert!(attachments[0] + .source_url + .as_ref() + .unwrap() + .contains("large_id")); } #[test] @@ -2479,9 +2586,7 @@ mod tests { attachments[0].filename.as_deref(), Some("voice_voice_xyz.ogg") ); - assert!(attachments[0] - .extras_json - .contains("\"duration_secs\":5")); + assert!(attachments[0].extras_json.contains("\"duration_secs\":5")); } #[test] @@ -2627,18 +2732,33 @@ mod tests { }; // PDFs and Office docs should be downloaded - assert!(is_downloadable_document(&make("application/pdf", Some("report.pdf")))); + assert!(is_downloadable_document(&make( + "application/pdf", + Some("report.pdf") + ))); assert!(is_downloadable_document(&make( "application/vnd.openxmlformats-officedocument.wordprocessingml.document", Some("doc.docx"), ))); - assert!(is_downloadable_document(&make("text/plain", Some("notes.txt")))); + assert!(is_downloadable_document(&make( + "text/plain", + Some("notes.txt") + ))); // Voice, image, audio, video should NOT be downloaded - assert!(!is_downloadable_document(&make("audio/ogg", Some("voice_123.ogg")))); + assert!(!is_downloadable_document(&make( + "audio/ogg", + Some("voice_123.ogg") + ))); assert!(!is_downloadable_document(&make("image/jpeg", None))); - assert!(!is_downloadable_document(&make("audio/mpeg", Some("song.mp3")))); - assert!(!is_downloadable_document(&make("video/mp4", Some("clip.mp4")))); + assert!(!is_downloadable_document(&make( + "audio/mpeg", + Some("song.mp3") + ))); + assert!(!is_downloadable_document(&make( + "video/mp4", + Some("clip.mp4") + ))); } #[test] diff --git a/src/config/channels.rs b/src/config/channels.rs index 1a24ceefe65..6b1058a0e39 100644 --- a/src/config/channels.rs +++ b/src/config/channels.rs @@ -92,10 +92,16 @@ pub struct SignalConfig { impl ChannelsConfig { pub(crate) fn resolve(settings: &Settings, owner_id: &str) -> Result { - let http = if optional_env("HTTP_PORT")?.is_some() || optional_env("HTTP_HOST")?.is_some() { + let cs = &settings.channels; + + let http_enabled_by_env = + optional_env("HTTP_PORT")?.is_some() || optional_env("HTTP_HOST")?.is_some(); + let http = if http_enabled_by_env || cs.http_enabled { Some(HttpConfig { - host: optional_env("HTTP_HOST")?.unwrap_or_else(|| "0.0.0.0".to_string()), - port: parse_optional_env("HTTP_PORT", 8080)?, + host: optional_env("HTTP_HOST")? + .or_else(|| cs.http_host.clone()) + .unwrap_or_else(|| "0.0.0.0".to_string()), + port: parse_optional_env("HTTP_PORT", cs.http_port.unwrap_or(8080))?, webhook_secret: optional_env("HTTP_WEBHOOK_SECRET")?.map(SecretString::from), user_id: owner_id.to_string(), }) @@ -103,42 +109,53 @@ impl ChannelsConfig { None }; - let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", true)?; + let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", cs.gateway_enabled)?; let gateway = if gateway_enabled { Some(GatewayConfig { - host: optional_env("GATEWAY_HOST")?.unwrap_or_else(|| "127.0.0.1".to_string()), - port: parse_optional_env("GATEWAY_PORT", DEFAULT_GATEWAY_PORT)?, - auth_token: optional_env("GATEWAY_AUTH_TOKEN")?, + host: optional_env("GATEWAY_HOST")? + .or_else(|| cs.gateway_host.clone()) + .unwrap_or_else(|| "127.0.0.1".to_string()), + port: parse_optional_env( + "GATEWAY_PORT", + cs.gateway_port.unwrap_or(DEFAULT_GATEWAY_PORT), + )?, + auth_token: optional_env("GATEWAY_AUTH_TOKEN")? + .or_else(|| cs.gateway_auth_token.clone()), user_id: owner_id.to_string(), }) } else { None }; - let signal = if let Some(http_url) = optional_env("SIGNAL_HTTP_URL")? { - let account = optional_env("SIGNAL_ACCOUNT")?.ok_or(ConfigError::InvalidValue { - key: "SIGNAL_ACCOUNT".to_string(), - message: "SIGNAL_ACCOUNT is required when SIGNAL_HTTP_URL is set".to_string(), - })?; - let allow_from = match std::env::var_os("SIGNAL_ALLOW_FROM") { - None => vec![account.clone()], - Some(val) => { - let s = val.to_string_lossy(); - s.split(',') + let signal_url = optional_env("SIGNAL_HTTP_URL")?.or_else(|| cs.signal_http_url.clone()); + let signal = if let Some(http_url) = signal_url { + let account = optional_env("SIGNAL_ACCOUNT")? + .or_else(|| cs.signal_account.clone()) + .ok_or(ConfigError::InvalidValue { + key: "SIGNAL_ACCOUNT".to_string(), + message: "SIGNAL_ACCOUNT is required when SIGNAL_HTTP_URL is set".to_string(), + })?; + let allow_from = + match optional_env("SIGNAL_ALLOW_FROM")?.or_else(|| cs.signal_allow_from.clone()) { + None => vec![account.clone()], + Some(s) => s + .split(',') .map(|e| e.trim().to_string()) .filter(|s| !s.is_empty()) - .collect() - } - }; - let dm_policy = - optional_env("SIGNAL_DM_POLICY")?.unwrap_or_else(|| "pairing".to_string()); - let group_policy = - optional_env("SIGNAL_GROUP_POLICY")?.unwrap_or_else(|| "allowlist".to_string()); + .collect(), + }; + let dm_policy = optional_env("SIGNAL_DM_POLICY")? + .or_else(|| cs.signal_dm_policy.clone()) + .unwrap_or_else(|| "pairing".to_string()); + let group_policy = optional_env("SIGNAL_GROUP_POLICY")? + .or_else(|| cs.signal_group_policy.clone()) + .unwrap_or_else(|| "allowlist".to_string()); Some(SignalConfig { http_url, account, allow_from, allow_from_groups: optional_env("SIGNAL_ALLOW_FROM_GROUPS")? + .or_else(|| cs.signal_allow_from_groups.clone()) .map(|s| { s.split(',') .map(|e| e.trim().to_string()) @@ -149,6 +166,7 @@ impl ChannelsConfig { dm_policy, group_policy, group_allow_from: optional_env("SIGNAL_GROUP_ALLOW_FROM")? + .or_else(|| cs.signal_group_allow_from.clone()) .map(|s| { s.split(',') .map(|e| e.trim().to_string()) @@ -167,9 +185,7 @@ impl ChannelsConfig { None }; - let cli_enabled = optional_env("CLI_ENABLED")? - .map(|s| s.to_lowercase() != "false" && s != "0") - .unwrap_or(true); + let cli_enabled = parse_bool_env("CLI_ENABLED", cs.cli_enabled)?; Ok(Self { cli: CliConfig { @@ -180,10 +196,14 @@ impl ChannelsConfig { signal, wasm_channels_dir: optional_env("WASM_CHANNELS_DIR")? .map(PathBuf::from) + .or_else(|| cs.wasm_channels_dir.clone()) .unwrap_or_else(default_channels_dir), - wasm_channels_enabled: parse_bool_env("WASM_CHANNELS_ENABLED", true)?, + wasm_channels_enabled: parse_bool_env( + "WASM_CHANNELS_ENABLED", + cs.wasm_channels_enabled, + )?, wasm_channel_owner_ids: { - let mut ids = settings.channels.wasm_channel_owner_ids.clone(); + let mut ids = cs.wasm_channel_owner_ids.clone(); // Backwards compat: TELEGRAM_OWNER_ID env var if let Some(id_str) = optional_env("TELEGRAM_OWNER_ID")? { let id: i64 = id_str.parse().map_err(|e: std::num::ParseIntError| { @@ -212,6 +232,8 @@ fn default_channels_dir() -> PathBuf { #[cfg(test)] mod tests { use crate::config::channels::*; + use crate::config::helpers::ENV_MUTEX; + use crate::settings::Settings; #[test] fn cli_config_fields() { @@ -366,4 +388,45 @@ mod tests { "expected path ending in 'channels', got: {dir:?}" ); } + + #[test] + fn resolve_uses_settings_channel_values_with_owner_scope_user_ids() { + let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner()); + let mut settings = Settings::default(); + settings.channels.http_enabled = true; + settings.channels.http_host = Some("127.0.0.2".to_string()); + settings.channels.http_port = Some(8181); + settings.channels.gateway_enabled = true; + settings.channels.gateway_host = Some("127.0.0.3".to_string()); + settings.channels.gateway_port = Some(9191); + settings.channels.gateway_auth_token = Some("tok".to_string()); + settings.channels.signal_http_url = Some("http://127.0.0.1:8080".to_string()); + settings.channels.signal_account = Some("+15551234567".to_string()); + settings.channels.signal_allow_from = Some("+15551234567,+15557654321".to_string()); + settings.channels.wasm_channels_dir = Some(PathBuf::from("/tmp/settings-channels")); + settings.channels.wasm_channels_enabled = false; + + let cfg = ChannelsConfig::resolve(&settings, "owner-scope").expect("resolve"); + + let http = cfg.http.expect("http config"); + assert_eq!(http.host, "127.0.0.2"); + assert_eq!(http.port, 8181); + assert_eq!(http.user_id, "owner-scope"); + + let gateway = cfg.gateway.expect("gateway config"); + assert_eq!(gateway.host, "127.0.0.3"); + assert_eq!(gateway.port, 9191); + assert_eq!(gateway.auth_token.as_deref(), Some("tok")); + assert_eq!(gateway.user_id, "owner-scope"); + + let signal = cfg.signal.expect("signal config"); + assert_eq!(signal.account, "+15551234567"); + assert_eq!(signal.allow_from, vec!["+15551234567", "+15557654321"]); + + assert_eq!( + cfg.wasm_channels_dir, + PathBuf::from("/tmp/settings-channels") + ); + assert!(!cfg.wasm_channels_enabled); + } } diff --git a/tests/e2e/helpers.py b/tests/e2e/helpers.py index 1f4f0ae7b96..a0c498e575c 100644 --- a/tests/e2e/helpers.py +++ b/tests/e2e/helpers.py @@ -184,5 +184,5 @@ def signed_http_webhook_headers(body: bytes) -> dict[str, str]: ).hexdigest() return { "Content-Type": "application/json", - "X-IronClaw-Signature": f"sha256={digest}", + "X-Hub-Signature-256": f"sha256={digest}", } From 370b7d908e2e6e15e1267b68ba7affeae708d241 Mon Sep 17 00:00:00 2001 From: Henry Park Date: Mon, 16 Mar 2026 08:54:42 -0700 Subject: [PATCH 14/14] fix: restore clippy matrix after rebase --- src/setup/wizard.rs | 383 +---------------------------- tests/e2e_builtin_tool_coverage.rs | 2 +- tests/e2e_routine_heartbeat.rs | 123 ++++----- 3 files changed, 55 insertions(+), 453 deletions(-) diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 16b2b7382ed..23494d12e95 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -23,6 +23,12 @@ use crate::channels::wasm::{ ChannelCapabilitiesFile, available_channel_names, install_bundled_channel, }; use crate::config::OAUTH_PLACEHOLDER; +use crate::llm::models::{ + build_nearai_model_fetch_config, fetch_anthropic_models, fetch_ollama_models, + fetch_openai_compatible_models, fetch_openai_models, +}; +#[cfg(test)] +use crate::llm::models::{is_openai_chat_model, sort_openai_models}; use crate::llm::{SessionConfig, SessionManager}; use crate::secrets::{SecretsCrypto, SecretsStore}; use crate::settings::{KeySource, Settings}; @@ -3024,331 +3030,6 @@ fn mask_password_in_url(url: &str) -> String { format!("{}{}:****{}", scheme, username, after_at) } -/// Fetch models from the Anthropic API. -/// -/// Returns `(model_id, display_label)` pairs. Falls back to static defaults on error. -async fn fetch_anthropic_models(cached_key: Option<&str>) -> Vec<(String, String)> { - let static_defaults = vec![ - ( - "claude-opus-4-6".into(), - "Claude Opus 4.6 (latest flagship)".into(), - ), - ("claude-sonnet-4-6".into(), "Claude Sonnet 4.6".into()), - ("claude-opus-4-5".into(), "Claude Opus 4.5".into()), - ("claude-sonnet-4-5".into(), "Claude Sonnet 4.5".into()), - ("claude-haiku-4-5".into(), "Claude Haiku 4.5 (fast)".into()), - ]; - - let api_key = cached_key - .map(String::from) - .or_else(|| std::env::var("ANTHROPIC_API_KEY").ok()) - .filter(|k| !k.is_empty() && k != crate::config::OAUTH_PLACEHOLDER); - - // Fall back to OAuth token if no API key - let oauth_token = if api_key.is_none() { - crate::config::helpers::optional_env("ANTHROPIC_OAUTH_TOKEN") - .ok() - .flatten() - .filter(|t| !t.is_empty()) - } else { - None - }; - - let (key_or_token, is_oauth) = match (api_key, oauth_token) { - (Some(k), _) => (k, false), - (None, Some(t)) => (t, true), - (None, None) => return static_defaults, - }; - - let client = reqwest::Client::new(); - let mut request = client - .get("https://api.anthropic.com/v1/models") - .header("anthropic-version", "2023-06-01") - .timeout(std::time::Duration::from_secs(5)); - - if is_oauth { - request = request - .bearer_auth(&key_or_token) - .header("anthropic-beta", "oauth-2025-04-20"); - } else { - request = request.header("x-api-key", &key_or_token); - } - - let resp = match request.send().await { - Ok(r) if r.status().is_success() => r, - _ => return static_defaults, - }; - - #[derive(serde::Deserialize)] - struct ModelEntry { - id: String, - } - #[derive(serde::Deserialize)] - struct ModelsResponse { - data: Vec, - } - - match resp.json::().await { - Ok(body) => { - let mut models: Vec<(String, String)> = body - .data - .into_iter() - .filter(|m| !m.id.contains("embedding") && !m.id.contains("audio")) - .map(|m| { - let label = m.id.clone(); - (m.id, label) - }) - .collect(); - if models.is_empty() { - return static_defaults; - } - models.sort_by(|a, b| a.0.cmp(&b.0)); - models - } - Err(_) => static_defaults, - } -} - -/// Fetch models from the OpenAI API. -/// -/// Returns `(model_id, display_label)` pairs. Falls back to static defaults on error. -async fn fetch_openai_models(cached_key: Option<&str>) -> Vec<(String, String)> { - let static_defaults = vec![ - ( - "gpt-5.3-codex".into(), - "GPT-5.3 Codex (latest flagship)".into(), - ), - ("gpt-5.2-codex".into(), "GPT-5.2 Codex".into()), - ("gpt-5.2".into(), "GPT-5.2".into()), - ( - "gpt-5.1-codex-mini".into(), - "GPT-5.1 Codex Mini (fast)".into(), - ), - ("gpt-5".into(), "GPT-5".into()), - ("gpt-5-mini".into(), "GPT-5 Mini".into()), - ("gpt-4.1".into(), "GPT-4.1".into()), - ("gpt-4.1-mini".into(), "GPT-4.1 Mini".into()), - ("o4-mini".into(), "o4-mini (fast reasoning)".into()), - ("o3".into(), "o3 (reasoning)".into()), - ]; - - let api_key = cached_key - .map(String::from) - .or_else(|| std::env::var("OPENAI_API_KEY").ok()) - .filter(|k| !k.is_empty()); - - let api_key = match api_key { - Some(k) => k, - None => return static_defaults, - }; - - let client = reqwest::Client::new(); - let resp = match client - .get("https://api.openai.com/v1/models") - .bearer_auth(&api_key) - .timeout(std::time::Duration::from_secs(5)) - .send() - .await - { - Ok(r) if r.status().is_success() => r, - _ => return static_defaults, - }; - - #[derive(serde::Deserialize)] - struct ModelEntry { - id: String, - } - #[derive(serde::Deserialize)] - struct ModelsResponse { - data: Vec, - } - - match resp.json::().await { - Ok(body) => { - let mut models: Vec<(String, String)> = body - .data - .into_iter() - .filter(|m| is_openai_chat_model(&m.id)) - .map(|m| { - let label = m.id.clone(); - (m.id, label) - }) - .collect(); - if models.is_empty() { - return static_defaults; - } - sort_openai_models(&mut models); - models - } - Err(_) => static_defaults, - } -} - -fn is_openai_chat_model(model_id: &str) -> bool { - let id = model_id.to_ascii_lowercase(); - - let is_chat_family = id.starts_with("gpt-") - || id.starts_with("chatgpt-") - || id.starts_with("o1") - || id.starts_with("o3") - || id.starts_with("o4") - || id.starts_with("o5"); - - let is_non_chat_variant = id.contains("realtime") - || id.contains("audio") - || id.contains("transcribe") - || id.contains("tts") - || id.contains("embedding") - || id.contains("moderation") - || id.contains("image"); - - is_chat_family && !is_non_chat_variant -} - -fn openai_model_priority(model_id: &str) -> usize { - let id = model_id.to_ascii_lowercase(); - - const EXACT_PRIORITY: &[&str] = &[ - "gpt-5.3-codex", - "gpt-5.2-codex", - "gpt-5.2", - "gpt-5.1-codex-mini", - "gpt-5", - "gpt-5-mini", - "gpt-5-nano", - "o4-mini", - "o3", - "o1", - "gpt-4.1", - "gpt-4.1-mini", - "gpt-4o", - "gpt-4o-mini", - ]; - if let Some(pos) = EXACT_PRIORITY.iter().position(|m| id == *m) { - return pos; - } - - const PREFIX_PRIORITY: &[&str] = &[ - "gpt-5.", "gpt-5-", "o3-", "o4-", "o1-", "gpt-4.1-", "gpt-4o-", "gpt-3.5-", "chatgpt-", - ]; - if let Some(pos) = PREFIX_PRIORITY - .iter() - .position(|prefix| id.starts_with(prefix)) - { - return EXACT_PRIORITY.len() + pos; - } - - EXACT_PRIORITY.len() + PREFIX_PRIORITY.len() + 1 -} - -fn sort_openai_models(models: &mut [(String, String)]) { - models.sort_by(|a, b| { - openai_model_priority(&a.0) - .cmp(&openai_model_priority(&b.0)) - .then_with(|| a.0.cmp(&b.0)) - }); -} - -/// Fetch installed models from a local Ollama instance. -/// -/// Returns `(model_name, display_label)` pairs. Falls back to static defaults on error. -async fn fetch_ollama_models(base_url: &str) -> Vec<(String, String)> { - let static_defaults = vec![ - ("llama3".into(), "llama3".into()), - ("mistral".into(), "mistral".into()), - ("codellama".into(), "codellama".into()), - ]; - - let url = format!("{}/api/tags", base_url.trim_end_matches('/')); - let client = reqwest::Client::new(); - - let resp = match client - .get(&url) - .timeout(std::time::Duration::from_secs(5)) - .send() - .await - { - Ok(r) if r.status().is_success() => r, - Ok(_) => return static_defaults, - Err(_) => { - print_info("Could not connect to Ollama. Is it running?"); - return static_defaults; - } - }; - - #[derive(serde::Deserialize)] - struct ModelEntry { - name: String, - } - #[derive(serde::Deserialize)] - struct TagsResponse { - models: Vec, - } - - match resp.json::().await { - Ok(body) => { - let models: Vec<(String, String)> = body - .models - .into_iter() - .map(|m| { - let label = m.name.clone(); - (m.name, label) - }) - .collect(); - if models.is_empty() { - return static_defaults; - } - models - } - Err(_) => static_defaults, - } -} - -/// Fetch models from a generic OpenAI-compatible /v1/models endpoint. -/// -/// Used for registry providers like Groq, NVIDIA NIM, etc. -async fn fetch_openai_compatible_models( - base_url: &str, - cached_key: Option<&str>, -) -> Vec<(String, String)> { - if base_url.is_empty() { - return vec![]; - } - - let url = format!("{}/models", base_url.trim_end_matches('/')); - let client = reqwest::Client::new(); - let mut req = client.get(&url).timeout(std::time::Duration::from_secs(5)); - if let Some(key) = cached_key { - req = req.bearer_auth(key); - } - - let resp = match req.send().await { - Ok(r) if r.status().is_success() => r, - _ => return vec![], - }; - - #[derive(serde::Deserialize)] - struct Model { - id: String, - } - #[derive(serde::Deserialize)] - struct ModelsResponse { - data: Vec, - } - - match resp.json::().await { - Ok(body) => body - .data - .into_iter() - .map(|m| { - let label = m.id.clone(); - (m.id, label) - }) - .collect(), - Err(_) => vec![], - } -} - /// Discover WASM channels in a directory. /// /// Returns a list of (channel_name, capabilities_file) pairs. @@ -3418,58 +3099,6 @@ async fn discover_wasm_channels(dir: &std::path::Path) -> Vec<(String, ChannelCa /// Mask an API key for display: show first 6 + last 4 chars. /// /// Uses char-based indexing to avoid panicking on multi-byte UTF-8. -/// Build the `LlmConfig` used by `fetch_nearai_models` to list available models. -/// -/// Reads `NEARAI_API_KEY` from the environment so that users who authenticated -/// via Cloud API key (option 4) don't get re-prompted during model selection. -fn build_nearai_model_fetch_config() -> crate::config::LlmConfig { - // If the user authenticated via API key (option 4), the key is stored - // as an env var. Pass it through so `resolve_bearer_token()` doesn't - // re-trigger the interactive auth prompt. - let api_key = std::env::var("NEARAI_API_KEY") - .ok() - .filter(|k| !k.is_empty()) - .map(secrecy::SecretString::from); - - // Match the same base_url logic as LlmConfig::resolve(): use cloud-api - // when an API key is present, private.near.ai for session-token auth. - let default_base = if api_key.is_some() { - "https://cloud-api.near.ai" - } else { - "https://private.near.ai" - }; - let base_url = std::env::var("NEARAI_BASE_URL").unwrap_or_else(|_| default_base.to_string()); - let auth_base_url = - std::env::var("NEARAI_AUTH_URL").unwrap_or_else(|_| "https://private.near.ai".to_string()); - - crate::config::LlmConfig { - backend: "nearai".to_string(), - session: crate::llm::session::SessionConfig { - auth_base_url, - session_path: crate::config::llm::default_session_path(), - }, - nearai: crate::config::NearAiConfig { - model: "dummy".to_string(), - cheap_model: None, - base_url, - api_key, - fallback_model: None, - max_retries: 3, - circuit_breaker_threshold: None, - circuit_breaker_recovery_secs: 30, - response_cache_enabled: false, - response_cache_ttl_secs: 3600, - response_cache_max_entries: 1000, - failover_cooldown_secs: 300, - failover_cooldown_threshold: 3, - smart_routing_cascade: true, - }, - provider: None, - bedrock: None, - request_timeout_secs: 120, - } -} - fn mask_api_key(key: &str) -> String { let chars: Vec = key.chars().collect(); if chars.len() < 12 { diff --git a/tests/e2e_builtin_tool_coverage.rs b/tests/e2e_builtin_tool_coverage.rs index 4da65c23cce..2a97a0d5033 100644 --- a/tests/e2e_builtin_tool_coverage.rs +++ b/tests/e2e_builtin_tool_coverage.rs @@ -155,7 +155,7 @@ mod tests { } assert_eq!(routine.notify.channel.as_deref(), Some("telegram")); - assert_eq!(routine.notify.user, "ops-team"); + assert_eq!(routine.notify.user.as_deref(), Some("ops-team")); assert_eq!(routine.guardrails.cooldown.as_secs(), 600); rig.shutdown(); diff --git a/tests/e2e_routine_heartbeat.rs b/tests/e2e_routine_heartbeat.rs index c4d06409f79..e5c94c6d6c3 100644 --- a/tests/e2e_routine_heartbeat.rs +++ b/tests/e2e_routine_heartbeat.rs @@ -48,6 +48,19 @@ mod tests { Arc::new(Workspace::new_with_db("default", db.clone())) } + fn make_message( + channel: &str, + user_id: &str, + owner_id: &str, + sender_id: &str, + content: &str, + ) -> IncomingMessage { + IncomingMessage::new(channel, user_id, content) + .with_owner_id(owner_id) + .with_sender_id(sender_id) + .with_metadata(serde_json::json!({})) + } + /// Helper to insert a routine directly into the database. fn make_routine(name: &str, trigger: Trigger, prompt: &str) -> Routine { Routine { @@ -218,21 +231,13 @@ mod tests { engine.refresh_event_cache().await; // Positive match: message containing "deploy to production". - let matching_msg = IncomingMessage { - id: Uuid::new_v4(), - channel: "test".to_string(), - user_id: "default".to_string(), - owner_id: "default".to_string(), - sender_id: "default".to_string(), - user_name: None, - content: "deploy to production now".to_string(), - thread_id: None, - conversation_scope_id: None, - received_at: Utc::now(), - metadata: serde_json::json!({}), - timezone: None, - attachments: Vec::new(), - }; + let matching_msg = make_message( + "test", + "default", + "default", + "default", + "deploy to production now", + ); let fired = engine.check_event_triggers(&matching_msg).await; assert!( fired >= 1, @@ -243,21 +248,13 @@ mod tests { tokio::time::sleep(Duration::from_millis(500)).await; // Negative match: message that doesn't match. - let non_matching_msg = IncomingMessage { - id: Uuid::new_v4(), - channel: "test".to_string(), - user_id: "default".to_string(), - owner_id: "default".to_string(), - sender_id: "default".to_string(), - user_name: None, - content: "check the staging environment".to_string(), - thread_id: None, - conversation_scope_id: None, - received_at: Utc::now(), - metadata: serde_json::json!({}), - timezone: None, - attachments: Vec::new(), - }; + let non_matching_msg = make_message( + "test", + "default", + "default", + "default", + "check the staging environment", + ); let fired_neg = engine.check_event_triggers(&non_matching_msg).await; assert_eq!(fired_neg, 0, "Expected 0 routines fired on non-match"); } @@ -311,21 +308,13 @@ mod tests { db.create_routine(&routine).await.expect("create_routine"); engine.refresh_event_cache().await; - let guest_msg = IncomingMessage { - id: Uuid::new_v4(), - channel: "telegram".to_string(), - user_id: "guest".to_string(), - owner_id: "default".to_string(), - sender_id: "guest-sender".to_string(), - user_name: None, - content: "deploy to production now".to_string(), - thread_id: None, - conversation_scope_id: None, - received_at: Utc::now(), - metadata: serde_json::json!({}), - timezone: None, - attachments: Vec::new(), - }; + let guest_msg = make_message( + "telegram", + "guest", + "default", + "guest-sender", + "deploy to production now", + ); let guest_fired = engine.check_event_triggers(&guest_msg).await; assert_eq!( guest_fired, 0, @@ -342,21 +331,13 @@ mod tests { "Guest message should not create routine runs" ); - let owner_msg = IncomingMessage { - id: Uuid::new_v4(), - channel: "telegram".to_string(), - user_id: "default".to_string(), - owner_id: "default".to_string(), - sender_id: "owner-sender".to_string(), - user_name: None, - content: "deploy to production now".to_string(), - thread_id: None, - conversation_scope_id: None, - received_at: Utc::now(), - metadata: serde_json::json!({}), - timezone: None, - attachments: Vec::new(), - }; + let owner_msg = make_message( + "telegram", + "default", + "default", + "owner-sender", + "deploy to production now", + ); let owner_fired = engine.check_event_triggers(&owner_msg).await; assert!( owner_fired >= 1, @@ -574,21 +555,13 @@ mod tests { engine.refresh_event_cache().await; // First fire should work. - let msg = IncomingMessage { - id: Uuid::new_v4(), - channel: "test".to_string(), - user_id: "default".to_string(), - owner_id: "default".to_string(), - sender_id: "default".to_string(), - user_name: None, - content: "test-cooldown trigger".to_string(), - thread_id: None, - conversation_scope_id: None, - received_at: Utc::now(), - metadata: serde_json::json!({}), - timezone: None, - attachments: Vec::new(), - }; + let msg = make_message( + "test", + "default", + "default", + "default", + "test-cooldown trigger", + ); let fired1 = engine.check_event_triggers(&msg).await; assert!(fired1 >= 1, "First fire should work");