Refactor owner scope across channels and fix default routing fallback (#1151)

* refactor: add explicit owner scope across channels

* fix: tighten routine owner target routing

* fix: address owner scope review feedback

* Fix owner-scope onboarding and event trigger isolation

* Tighten routing fallback and wizard owner validation

* fix: address owner-scope follow-up review

* fix: tighten owner-scope follow-up details

* fix: import Channel trait in telegram test

* fix: normalize http webhook sender ids

* fix: address remaining owner-scope review issues

* fix: reconcile config rebase fallout

* fix: reconcile extension manager rebase drift

* fix: address current copilot review regressions

* fix: restore clippy matrix after rebase
This commit is contained in:
Henry Park
2026-03-16 13:31:03 -07:00
committed by GitHub
parent 971b4c2ef4
commit 878a67cdb6
50 changed files with 2767 additions and 1071 deletions
+4 -4
View File
@@ -20,9 +20,9 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
|---------|----------|----------|-------| |---------|----------|----------|-------|
| Hub-and-spoke architecture | ✅ | ✅ | Web gateway as central hub | | Hub-and-spoke architecture | ✅ | ✅ | Web gateway as central hub |
| WebSocket control plane | ✅ | ✅ | Gateway with WebSocket + SSE | | 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 | | 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 | | Loopback-first networking | ✅ | ✅ | HTTP binds to 0.0.0.0 but can be configured |
### Owner: _Unassigned_ ### Owner: _Unassigned_
@@ -66,9 +66,9 @@ This document tracks feature parity between IronClaw (Rust implementation) and O
| CLI/TUI | ✅ | ✅ | - | Ratatui-based TUI | | CLI/TUI | ✅ | ✅ | - | Ratatui-based TUI |
| HTTP webhook | ✅ | ✅ | - | axum with secret validation | | HTTP webhook | ✅ | ✅ | - | axum with secret validation |
| REPL (simple) | ✅ | ✅ | - | For testing | | 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 | | 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 | | Discord | ✅ | ❌ | P2 | discord.js, thread parent binding inheritance |
| Signal | ✅ | ✅ | P2 | signal-cli daemonPC, SSE listener HTTP/JSON-R, user/group allowlists, DM pairing | | Signal | ✅ | ✅ | P2 | signal-cli daemonPC, SSE listener HTTP/JSON-R, user/group allowlists, DM pairing |
| Slack | ✅ | ✅ | - | WASM tool | | Slack | ✅ | ✅ | - | WASM tool |
+133 -189
View File
@@ -102,7 +102,6 @@ struct TelegramMessage {
sticker: Option<TelegramSticker>, sticker: Option<TelegramSticker>,
/// Forum topic ID. Present when the message is sent inside a forum topic. /// Forum topic ID. Present when the message is sent inside a forum topic.
/// https://core.telegram.org/bots/api#message
#[serde(default)] #[serde(default)]
message_thread_id: Option<i64>, message_thread_id: Option<i64>,
@@ -207,10 +206,6 @@ struct TelegramChat {
/// Title for groups/channels. /// Title for groups/channels.
title: Option<String>, title: Option<String>,
/// True when the supergroup has topics (forum mode) enabled.
#[serde(default)]
is_forum: Option<bool>,
/// Username for private chats. /// Username for private chats.
username: Option<String>, username: Option<String>,
} }
@@ -508,8 +503,7 @@ impl Guest for TelegramChannel {
// Delete any existing webhook before polling. Telegram returns success // Delete any existing webhook before polling. Telegram returns success
// when no webhook exists, so any error here (e.g. 401) means a bad token. // when no webhook exists, so any error here (e.g. 401) means a bad token.
delete_webhook() delete_webhook().map_err(|e| format!("Bot token validation failed: {}", e))?;
.map_err(|e| format!("Bot token validation failed: {}", e))?;
} }
// Configure polling only if not in webhook mode // Configure polling only if not in webhook mode
@@ -697,7 +691,12 @@ impl Guest for TelegramChannel {
let metadata: TelegramMessageMetadata = serde_json::from_str(&response.metadata_json) let metadata: TelegramMessageMetadata = serde_json::from_str(&response.metadata_json)
.map_err(|e| format!("Failed to parse metadata: {}", e))?; .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),
metadata.message_thread_id,
)
} }
fn on_broadcast(user_id: String, response: AgentResponse) -> Result<(), String> { fn on_broadcast(user_id: String, response: AgentResponse) -> Result<(), String> {
@@ -734,8 +733,6 @@ impl Guest for TelegramChannel {
"action": "typing" "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 { if let Some(thread_id) = metadata.message_thread_id {
payload["message_thread_id"] = serde_json::Value::Number(thread_id.into()); payload["message_thread_id"] = serde_json::Value::Number(thread_id.into());
} }
@@ -766,9 +763,13 @@ impl Guest for TelegramChannel {
} }
TelegramStatusAction::Notify(prompt) => { TelegramStatusAction::Notify(prompt) => {
// Send user-visible status updates for actionable events. // Send user-visible status updates for actionable events.
if let Err(first_err) = if let Err(first_err) = send_message(
send_message(metadata.chat_id, &prompt, Some(metadata.message_id), None, metadata.message_thread_id) metadata.chat_id,
{ &prompt,
Some(metadata.message_id),
None,
metadata.message_thread_id,
) {
channel_host::log( channel_host::log(
channel_host::LogLevel::Warn, channel_host::LogLevel::Warn,
&format!( &format!(
@@ -777,7 +778,13 @@ 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,
metadata.message_thread_id,
) {
channel_host::log( channel_host::log(
channel_host::LogLevel::Debug, channel_host::LogLevel::Debug,
&format!( &format!(
@@ -822,9 +829,8 @@ impl std::fmt::Display for SendError {
/// Normalize `message_thread_id` for outbound API calls. /// Normalize `message_thread_id` for outbound API calls.
/// ///
/// Telegram rejects `sendMessage` (and other send methods) when /// Telegram rejects `sendMessage` and file-send methods when
/// `message_thread_id = 1` (the "General" topic). Return `None` in that /// `message_thread_id = 1` (the "General" topic), so omit it in that case.
/// case so the field is omitted from the payload.
fn normalize_thread_id(thread_id: Option<i64>) -> Option<i64> { fn normalize_thread_id(thread_id: Option<i64>) -> Option<i64> {
thread_id.filter(|&id| id != 1) thread_id.filter(|&id| id != 1)
} }
@@ -950,19 +956,20 @@ fn download_telegram_file(file_id: &str) -> Result<Vec<u8>, String> {
); );
let headers = serde_json::json!({}); let headers = serde_json::json!({});
let result = let result = channel_host::http_request("GET", &get_file_url, &headers.to_string(), None, None);
channel_host::http_request("GET", &get_file_url, &headers.to_string(), None, None);
let response = result.map_err(|e| format!("getFile request failed: {}", e))?; let response = result.map_err(|e| format!("getFile request failed: {}", e))?;
if response.status != 200 { if response.status != 200 {
let body_str = String::from_utf8_lossy(&response.body); 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<TelegramFile> = let api_response: TelegramApiResponse<TelegramFile> = serde_json::from_slice(&response.body)
serde_json::from_slice(&response.body) .map_err(|e| format!("Failed to parse getFile response: {}", e))?;
.map_err(|e| format!("Failed to parse getFile response: {}", e))?;
if !api_response.ok { if !api_response.ok {
return Err(format!( return Err(format!(
@@ -992,16 +999,12 @@ fn download_telegram_file(file_id: &str) -> Result<Vec<u8>, String> {
file_path file_path
); );
let result = let result = channel_host::http_request("GET", &download_url, &headers.to_string(), None, None);
channel_host::http_request("GET", &download_url, &headers.to_string(), None, None);
let response = result.map_err(|e| format!("File download failed: {}", e))?; let response = result.map_err(|e| format!("File download failed: {}", e))?;
if response.status != 200 { if response.status != 200 {
return Err(format!( return Err(format!("File download returned status {}", response.status));
"File download returned status {}",
response.status
));
} }
// Post-download size guard: Telegram metadata file_size is optional, // Post-download size guard: Telegram metadata file_size is optional,
@@ -1088,7 +1091,14 @@ fn send_photo(
data.len() 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,
message_thread_id,
);
} }
let boundary = format!("ironclaw-{}", channel_host::now_millis()); let boundary = format!("ironclaw-{}", channel_host::now_millis());
@@ -1096,10 +1106,20 @@ fn send_photo(
write_multipart_field(&mut body, &boundary, "chat_id", &chat_id.to_string()); write_multipart_field(&mut body, &boundary, "chat_id", &chat_id.to_string());
if let Some(msg_id) = reply_to_message_id { 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 { if let Some(thread_id) = message_thread_id {
write_multipart_field(&mut body, &boundary, "message_thread_id", &thread_id.to_string()); write_multipart_field(
&mut body,
&boundary,
"message_thread_id",
&thread_id.to_string(),
);
} }
write_multipart_file(&mut body, &boundary, "photo", filename, mime_type, data); write_multipart_file(&mut body, &boundary, "photo", filename, mime_type, data);
body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes()); body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());
@@ -1151,10 +1171,20 @@ fn send_document(
write_multipart_field(&mut body, &boundary, "chat_id", &chat_id.to_string()); write_multipart_field(&mut body, &boundary, "chat_id", &chat_id.to_string());
if let Some(msg_id) = reply_to_message_id { 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 { if let Some(thread_id) = message_thread_id {
write_multipart_field(&mut body, &boundary, "message_thread_id", &thread_id.to_string()); write_multipart_field(
&mut body,
&boundary,
"message_thread_id",
&thread_id.to_string(),
);
} }
write_multipart_file(&mut body, &boundary, "document", filename, mime_type, data); write_multipart_file(&mut body, &boundary, "document", filename, mime_type, data);
body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes()); body.extend_from_slice(format!("--{}--\r\n", boundary).as_bytes());
@@ -1191,12 +1221,7 @@ fn send_document(
} }
/// Image MIME types that Telegram's sendPhoto API supports. /// Image MIME types that Telegram's sendPhoto API supports.
const PHOTO_MIME_TYPES: &[&str] = &[ const PHOTO_MIME_TYPES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"];
"image/jpeg",
"image/png",
"image/gif",
"image/webp",
];
/// Send a full agent response (attachments + text) to a chat. /// Send a full agent response (attachments + text) to a chat.
/// ///
@@ -1218,13 +1243,23 @@ fn send_response(
} }
// Try Markdown, fall back to plain text on parse errors // 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"),
message_thread_id,
) {
Ok(_) => Ok(()), Ok(_) => Ok(()),
Err(SendError::ParseEntities(_)) => { Err(SendError::ParseEntities(_)) => send_message(
send_message(chat_id, &response.content, reply_to_message_id, None, message_thread_id) chat_id,
.map(|_| ()) &response.content,
.map_err(|e| format!("Plain-text retry also failed: {}", e)) 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()), Err(e) => Err(e.to_string()),
} }
} }
@@ -1392,7 +1427,10 @@ fn register_webhook(tunnel_url: &str, webhook_secret: Option<&str>) -> Result<()
let context = if retried { " (after retry)" } else { "" }; let context = if retried { " (after retry)" } else { "" };
channel_host::log( channel_host::log(
channel_host::LogLevel::Info, channel_host::LogLevel::Info,
&format!("Webhook registered successfully{}: {}", context, webhook_url), &format!(
"Webhook registered successfully{}: {}",
context, webhook_url
),
); );
Ok(()) Ok(())
@@ -1412,7 +1450,7 @@ fn send_pairing_reply(chat_id: i64, code: &str) -> Result<(), String> {
), ),
None, None,
Some("Markdown"), Some("Markdown"),
None, // Pairing happens in DMs, not forum topics None,
) )
.map(|_| ()) .map(|_| ())
.map_err(|e| e.to_string()) .map_err(|e| e.to_string())
@@ -1494,7 +1532,9 @@ fn extract_attachments(message: &TelegramMessage) -> Vec<InboundAttachment> {
if let Some(ref doc) = message.document { if let Some(ref doc) = message.document {
attachments.push(make_inbound_attachment( attachments.push(make_inbound_attachment(
doc.file_id.clone(), 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_name.clone(),
doc.file_size.map(|s| s as u64), doc.file_size.map(|s| s as u64),
Some(get_file_url(&doc.file_id)), Some(get_file_url(&doc.file_id)),
@@ -1507,7 +1547,10 @@ fn extract_attachments(message: &TelegramMessage) -> Vec<InboundAttachment> {
if let Some(ref audio) = message.audio { if let Some(ref audio) = message.audio {
attachments.push(make_inbound_attachment( attachments.push(make_inbound_attachment(
audio.file_id.clone(), 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_name.clone(),
audio.file_size.map(|s| s as u64), audio.file_size.map(|s| s as u64),
Some(get_file_url(&audio.file_id)), Some(get_file_url(&audio.file_id)),
@@ -1520,7 +1563,10 @@ fn extract_attachments(message: &TelegramMessage) -> Vec<InboundAttachment> {
if let Some(ref video) = message.video { if let Some(ref video) = message.video {
attachments.push(make_inbound_attachment( attachments.push(make_inbound_attachment(
video.file_id.clone(), 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_name.clone(),
video.file_size.map(|s| s as u64), video.file_size.map(|s| s as u64),
Some(get_file_url(&video.file_id)), Some(get_file_url(&video.file_id)),
@@ -1745,25 +1791,14 @@ fn handle_message(message: TelegramMessage) {
let is_private = message.chat.chat_type == "private"; let is_private = message.chat.chat_type == "private";
// Owner validation: when owner_id is set, only that user can message let owner_id = channel_host::workspace_read(OWNER_ID_PATH)
let owner_id_str = channel_host::workspace_read(OWNER_ID_PATH).filter(|s| !s.is_empty()); .filter(|s| !s.is_empty())
.and_then(|s| s.parse::<i64>().ok());
let is_owner = owner_id == Some(from.id);
if let Some(ref id_str) = owner_id_str { if !is_owner {
if let Ok(owner_id) = id_str.parse::<i64>() { // Non-owner senders remain guests. Apply authorization based on
if from.id != owner_id { // dm_policy / allow_from before letting them chat in their own scope.
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
let dm_policy = let dm_policy =
channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string()); channel_host::workspace_read(DM_POLICY_PATH).unwrap_or_else(|| "pairing".to_string());
@@ -1830,8 +1865,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 // For group chats, only respond if bot was mentioned or respond_to_all is enabled
if !is_private { if !is_private {
let respond_to_all = channel_host::workspace_read(RESPOND_TO_ALL_GROUP_PATH) let respond_to_all = channel_host::workspace_read(RESPOND_TO_ALL_GROUP_PATH)
@@ -1841,6 +1874,7 @@ fn handle_message(message: TelegramMessage) {
if !respond_to_all { if !respond_to_all {
let has_command = content.starts_with('/'); 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() { let has_bot_mention = if bot_username.is_empty() {
content.contains('@') content.contains('@')
} else { } else {
@@ -1876,18 +1910,7 @@ fn handle_message(message: TelegramMessage) {
let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string()); let metadata_json = serde_json::to_string(&metadata).unwrap_or_else(|_| "{}".to_string());
// Compute thread_id for forum topics: "chat_id:topic_id" to prevent let bot_username = channel_host::workspace_read(BOT_USERNAME_PATH).unwrap_or_default();
// 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 content_to_emit = match content_to_emit_for_agent( let content_to_emit = match content_to_emit_for_agent(
&content, &content,
if bot_username.is_empty() { if bot_username.is_empty() {
@@ -1907,7 +1930,7 @@ fn handle_message(message: TelegramMessage) {
user_id: from.id.to_string(), user_id: from.id.to_string(),
user_name: Some(user_name), user_name: Some(user_name),
content: content_to_emit, content: content_to_emit,
thread_id, thread_id: Some(message.chat.id.to_string()),
metadata_json, metadata_json,
attachments, attachments,
}); });
@@ -2507,7 +2530,11 @@ mod tests {
assert_eq!(attachments[0].id, "large_id"); // Largest photo assert_eq!(attachments[0].id, "large_id"); // Largest photo
assert_eq!(attachments[0].mime_type, "image/jpeg"); assert_eq!(attachments[0].mime_type, "image/jpeg");
assert_eq!(attachments[0].size_bytes, Some(54321)); 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] #[test]
@@ -2559,9 +2586,7 @@ mod tests {
attachments[0].filename.as_deref(), attachments[0].filename.as_deref(),
Some("voice_voice_xyz.ogg") Some("voice_voice_xyz.ogg")
); );
assert!(attachments[0] assert!(attachments[0].extras_json.contains("\"duration_secs\":5"));
.extras_json
.contains("\"duration_secs\":5"));
} }
#[test] #[test]
@@ -2707,18 +2732,33 @@ mod tests {
}; };
// PDFs and Office docs should be downloaded // 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( assert!(is_downloadable_document(&make(
"application/vnd.openxmlformats-officedocument.wordprocessingml.document", "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
Some("doc.docx"), 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 // 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("image/jpeg", None)));
assert!(!is_downloadable_document(&make("audio/mpeg", Some("song.mp3")))); assert!(!is_downloadable_document(&make(
assert!(!is_downloadable_document(&make("video/mp4", Some("clip.mp4")))); "audio/mpeg",
Some("song.mp3")
)));
assert!(!is_downloadable_document(&make(
"video/mp4",
Some("clip.mp4")
)));
} }
#[test] #[test]
@@ -2726,100 +2766,4 @@ mod tests {
// Verify the constant is 20 MB, matching the Slack channel limit // Verify the constant is 20 MB, matching the Slack channel limit
assert_eq!(MAX_DOWNLOAD_SIZE_BYTES, 20 * 1024 * 1024); 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);
}
} }
@@ -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';
+1 -1
View File
@@ -26,7 +26,7 @@ CREATE TABLE routines (
-- Notification preferences -- Notification preferences
notify_channel TEXT, -- NULL = use default 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_success BOOLEAN NOT NULL DEFAULT false,
notify_on_failure BOOLEAN NOT NULL DEFAULT true, notify_on_failure BOOLEAN NOT NULL DEFAULT true,
notify_on_attention BOOLEAN NOT NULL DEFAULT true, notify_on_attention BOOLEAN NOT NULL DEFAULT true,
+132 -28
View File
@@ -22,7 +22,7 @@ use crate::channels::{ChannelManager, IncomingMessage, OutgoingResponse};
use crate::config::{AgentConfig, HeartbeatConfig, RoutineConfig, SkillsConfig}; use crate::config::{AgentConfig, HeartbeatConfig, RoutineConfig, SkillsConfig};
use crate::context::ContextManager; use crate::context::ContextManager;
use crate::db::Database; use crate::db::Database;
use crate::error::Error; use crate::error::{ChannelError, Error};
use crate::extensions::ExtensionManager; use crate::extensions::ExtensionManager;
use crate::hooks::HookRegistry; use crate::hooks::HookRegistry;
use crate::llm::LlmProvider; use crate::llm::LlmProvider;
@@ -54,10 +54,26 @@ pub(crate) fn truncate_for_preview(output: &str, max_chars: usize) -> String {
} }
} }
fn resolve_routine_notification_user(metadata: &serde_json::Value) -> Option<String> {
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 {
!matches!(error, ChannelError::MissingRoutingTarget { .. })
}
/// Core dependencies for the agent. /// Core dependencies for the agent.
/// ///
/// Bundles the shared components to reduce argument count. /// Bundles the shared components to reduce argument count.
pub struct AgentDeps { pub struct AgentDeps {
/// Resolved durable owner scope for the instance.
pub owner_id: String,
pub store: Option<Arc<dyn Database>>, pub store: Option<Arc<dyn Database>>,
pub llm: Arc<dyn LlmProvider>, pub llm: Arc<dyn LlmProvider>,
/// Cheap/fast LLM for lightweight tasks (heartbeat, routing, evaluation). /// Cheap/fast LLM for lightweight tasks (heartbeat, routing, evaluation).
@@ -102,6 +118,18 @@ pub struct Agent {
} }
impl Agent { impl Agent {
pub(super) fn owner_id(&self) -> &str {
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. /// Create a new agent.
/// ///
/// Optionally accepts pre-created `ContextManager` and `SessionManager` for sharing /// Optionally accepts pre-created `ContextManager` and `SessionManager` for sharing
@@ -264,6 +292,7 @@ impl Agent {
)); ));
let repair_interval = self.config.repair_check_interval; let repair_interval = self.config.repair_check_interval;
let repair_channels = self.channels.clone(); let repair_channels = self.channels.clone();
let repair_owner_id = self.owner_id().to_string();
let repair_handle = tokio::spawn(async move { let repair_handle = tokio::spawn(async move {
loop { loop {
tokio::time::sleep(repair_interval).await; tokio::time::sleep(repair_interval).await;
@@ -311,7 +340,9 @@ impl Agent {
if let Some(msg) = notification { if let Some(msg) = notification {
let response = OutgoingResponse::text(format!("Self-Repair: {}", msg)); 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 +356,9 @@ impl Agent {
"Self-Repair: Tool '{}' repaired: {}", "Self-Repair: Tool '{}' repaired: {}",
tool.name, message tool.name, message
)); ));
let _ = repair_channels.broadcast_all("default", response).await; let _ = repair_channels
.broadcast_all(&repair_owner_id, response)
.await;
} }
Ok(result) => { Ok(result) => {
tracing::info!("Tool repair result: {:?}", result); tracing::info!("Tool repair result: {:?}", result);
@@ -362,9 +395,11 @@ impl Agent {
.timezone .timezone
.clone() .clone()
.or_else(|| Some(self.config.default_timezone.clone())); .or_else(|| Some(self.config.default_timezone.clone()));
if let (Some(user), Some(channel)) = if let Some(channel) = &hb_config.notify_channel {
(&hb_config.notify_user, &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); config = config.with_notify(user, channel);
} }
@@ -374,17 +409,18 @@ impl Agent {
// Spawn notification forwarder that routes through channel manager // Spawn notification forwarder that routes through channel manager
let notify_channel = hb_config.notify_channel.clone(); 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(); let channels = self.channels.clone();
tokio::spawn(async move { tokio::spawn(async move {
while let Some(response) = notify_rx.recv().await { 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 // Try the configured channel first, fall back to
// broadcasting on all channels. // broadcasting on all channels.
let targeted_ok = if let Some(ref channel) = notify_channel { let targeted_ok = if let Some(ref channel) = notify_channel {
channels channels
.broadcast(channel, user, response.clone()) .broadcast(channel, &notify_user, response.clone())
.await .await
.is_ok() .is_ok()
} else { } else {
@@ -392,7 +428,7 @@ impl Agent {
}; };
if !targeted_ok { if !targeted_ok {
let results = channels.broadcast_all(user, response).await; let results = channels.broadcast_all(&notify_user, response).await;
for (ch, result) in results { for (ch, result) in results {
if let Err(e) = result { if let Err(e) = result {
tracing::warn!( tracing::warn!(
@@ -462,25 +498,41 @@ impl Agent {
let channels = self.channels.clone(); let channels = self.channels.clone();
tokio::spawn(async move { tokio::spawn(async move {
while let Some(response) = notify_rx.recv().await { while let Some(response) = notify_rx.recv().await {
let user = response
.metadata
.get("notify_user")
.and_then(|v| v.as_str())
.unwrap_or("default")
.to_string();
let notify_channel = response let notify_channel = response
.metadata .metadata
.get("notify_channel") .get("notify_channel")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.map(|s| s.to_string()); .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 // Try the configured channel first, fall back to
// broadcasting on all channels. // broadcasting on all channels.
let targeted_ok = if let Some(ref channel) = notify_channel { let targeted_ok = if let Some(ref channel) = notify_channel {
channels match channels.broadcast(channel, &user, response.clone()).await {
.broadcast(channel, &user, response.clone()) Ok(()) => true,
.await Err(e) => {
.is_ok() 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 { } else {
false false
}; };
@@ -768,10 +820,7 @@ impl Agent {
// For Signal, use signal_target from metadata (group:ID or phone number), // For Signal, use signal_target from metadata (group:ID or phone number),
// otherwise fall back to user_id // otherwise fall back to user_id
let target = message let target = message
.metadata .routing_target()
.get("signal_target")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.unwrap_or_else(|| message.user_id.clone()); .unwrap_or_else(|| message.user_id.clone());
self.tools() self.tools()
.set_message_tool_context(Some(message.channel.clone()), Some(target)) .set_message_tool_context(Some(message.channel.clone()), Some(target))
@@ -811,7 +860,7 @@ impl Agent {
} }
// Hydrate thread from DB if it's a historical thread not in memory // 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!( tracing::trace!(
message_id = %message.id, message_id = %message.id,
thread_id = %external_thread_id, thread_id = %external_thread_id,
@@ -832,7 +881,7 @@ impl Agent {
.resolve_thread( .resolve_thread(
&message.user_id, &message.user_id,
&message.channel, &message.channel,
message.thread_id.as_deref(), message.conversation_scope(),
) )
.await; .await;
tracing::debug!( tracing::debug!(
@@ -985,7 +1034,11 @@ impl Agent {
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[test]
fn test_truncate_short_input() { fn test_truncate_short_input() {
@@ -1048,4 +1101,55 @@ mod tests {
// 'h','e','l','l','o',' ','世','界' = 8 chars // 'h','e','l','l','o',' ','世','界' = 8 chars
assert_eq!(result, "hello 世界..."); 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::MissingRoutingTarget {
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
}
} }
+4 -1
View File
@@ -836,7 +836,10 @@ impl Agent {
// 1. Persist to DB if available. // 1. Persist to DB if available.
if let Some(store) = self.store() { if let Some(store) = self.store() {
let value = serde_json::Value::String(model.to_string()); 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); tracing::warn!("Failed to persist model to DB: {}", e);
} }
} }
+5 -1
View File
@@ -140,7 +140,8 @@ impl Agent {
// Create a JobContext for tool execution (chat doesn't have a real job) // Create a JobContext for tool execution (chat doesn't have a real job)
let mut job_ctx = let mut job_ctx =
JobContext::with_user(&message.user_id, "chat", "Interactive chat session"); 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.http_interceptor = self.deps.http_interceptor.clone();
job_ctx.user_timezone = user_tz.name().to_string(); job_ctx.user_timezone = user_tz.name().to_string();
job_ctx.metadata = serde_json::json!({ 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). /// Build a minimal `Agent` for unit testing (no DB, no workspace, no extensions).
fn make_test_agent() -> Agent { fn make_test_agent() -> Agent {
let deps = AgentDeps { let deps = AgentDeps {
owner_id: "default".to_string(),
store: None, store: None,
llm: Arc::new(StaticLlmProvider), llm: Arc::new(StaticLlmProvider),
cheap_llm: None, cheap_llm: None,
@@ -2014,6 +2016,7 @@ mod tests {
/// `max_tool_iterations` override. /// `max_tool_iterations` override.
fn make_test_agent_with_llm(llm: Arc<dyn LlmProvider>, max_tool_iterations: usize) -> Agent { fn make_test_agent_with_llm(llm: Arc<dyn LlmProvider>, max_tool_iterations: usize) -> Agent {
let deps = AgentDeps { let deps = AgentDeps {
owner_id: "default".to_string(),
store: None, store: None,
llm, llm,
cheap_llm: None, cheap_llm: None,
@@ -2127,6 +2130,7 @@ mod tests {
let max_iter = 3; let max_iter = 3;
let agent = { let agent = {
let deps = AgentDeps { let deps = AgentDeps {
owner_id: "default".to_string(),
store: None, store: None,
llm, llm,
cheap_llm: None, cheap_llm: None,
+6 -1
View File
@@ -402,7 +402,11 @@ impl HeartbeatRunner {
return; 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 // Persist to heartbeat conversation and get thread_id
let thread_id = if let Some(ref store) = self.store { let thread_id = if let Some(ref store) = self.store {
@@ -431,6 +435,7 @@ impl HeartbeatRunner {
attachments: Vec::new(), attachments: Vec::new(),
metadata: serde_json::json!({ metadata: serde_json::json!({
"source": "heartbeat", "source": "heartbeat",
"owner_id": self.workspace.user_id(),
}), }),
}; };
+3 -3
View File
@@ -422,8 +422,8 @@ impl Default for RoutineGuardrails {
pub struct NotifyConfig { pub struct NotifyConfig {
/// Channel to notify on (None = default/broadcast all). /// Channel to notify on (None = default/broadcast all).
pub channel: Option<String>, pub channel: Option<String>,
/// User to notify. /// Explicit target to notify. None means "resolve the owner's last-seen target".
pub user: String, pub user: Option<String>,
/// Notify when routine produces actionable output. /// Notify when routine produces actionable output.
pub on_attention: bool, pub on_attention: bool,
/// Notify when routine errors. /// Notify when routine errors.
@@ -436,7 +436,7 @@ impl Default for NotifyConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
channel: None, channel: None,
user: "default".to_string(), user: None,
on_attention: true, on_attention: true,
on_failure: true, on_failure: true,
on_success: false, on_success: false,
+10 -1
View File
@@ -172,6 +172,11 @@ impl RoutineEngine {
EventMatcher::Message { routine, regex } => (routine, regex), EventMatcher::Message { routine, regex } => (routine, regex),
EventMatcher::System { .. } => continue, EventMatcher::System { .. } => continue,
}; };
if routine.user_id != message.user_id {
continue;
}
// Channel filter // Channel filter
if let Trigger::Event { if let Trigger::Event {
channel: Some(ch), .. channel: Some(ch), ..
@@ -650,6 +655,7 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
send_notification( send_notification(
&ctx.notify_tx, &ctx.notify_tx,
&routine.notify, &routine.notify,
&routine.user_id,
&routine.name, &routine.name,
status, status,
summary.as_deref(), summary.as_deref(),
@@ -694,7 +700,8 @@ async fn execute_full_job(
reason: "scheduler not available".to_string(), 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 // Carry the routine's notify config in job metadata so the message tool
// can resolve channel/target per-job without global state mutation. // can resolve channel/target per-job without global state mutation.
if let Some(channel) = &routine.notify.channel { if let Some(channel) = &routine.notify.channel {
@@ -1207,6 +1214,7 @@ async fn execute_routine_tool(
async fn send_notification( async fn send_notification(
tx: &mpsc::Sender<OutgoingResponse>, tx: &mpsc::Sender<OutgoingResponse>,
notify: &NotifyConfig, notify: &NotifyConfig,
owner_id: &str,
routine_name: &str, routine_name: &str,
status: RunStatus, status: RunStatus,
summary: Option<&str>, summary: Option<&str>,
@@ -1243,6 +1251,7 @@ async fn send_notification(
"source": "routine", "source": "routine",
"routine_name": routine_name, "routine_name": routine_name,
"status": status.to_string(), "status": status.to_string(),
"owner_id": owner_id,
"notify_user": notify.user, "notify_user": notify.user,
"notify_channel": notify.channel, "notify_channel": notify.channel,
}), }),
+2 -1
View File
@@ -924,7 +924,8 @@ impl Agent {
// Execute the approved tool and continue the loop // Execute the approved tool and continue the loop
let mut job_ctx = let mut job_ctx =
JobContext::with_user(&message.user_id, "chat", "Interactive chat session"); 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.http_interceptor = self.deps.http_interceptor.clone();
// Prefer a valid timezone from the approval message, fall back to the // Prefer a valid timezone from the approval message, fall back to the
// resolved timezone stored when the approval was originally requested. // resolved timezone stored when the approval was originally requested.
+19 -10
View File
@@ -140,12 +140,14 @@ impl AppBuilder {
self.handles = Some(handles); self.handles = Some(handles);
// Post-init: migrate disk config, reload config from DB, attach session, cleanup // 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); tracing::warn!("Disk-to-DB settings migration failed: {}", e);
} }
let toml_path = self.toml_path.as_deref(); 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) => { Ok(db_config) => {
self.config = db_config; self.config = db_config;
tracing::debug!("Configuration reloaded from database"); tracing::debug!("Configuration reloaded from database");
@@ -158,7 +160,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. // Fire-and-forget housekeeping — no need to block startup.
let db_cleanup = db.clone(); let db_cleanup = db.clone();
@@ -193,9 +197,10 @@ impl AppBuilder {
let store: Option<&(dyn crate::db::SettingsStore + Sync)> = let store: Option<&(dyn crate::db::SettingsStore + Sync)> =
self.db.as_ref().map(|db| db.as_ref() as _); self.db.as_ref().map(|db| db.as_ref() as _);
let toml_path = self.toml_path.as_deref(); let toml_path = self.toml_path.as_deref();
let owner_id = self.config.owner_id.clone();
if let Err(e) = self if let Err(e) = self
.config .config
.re_resolve_llm(store, "default", toml_path) .re_resolve_llm(store, &owner_id, toml_path)
.await .await
{ {
tracing::warn!( tracing::warn!(
@@ -224,15 +229,17 @@ impl AppBuilder {
if let Some(ref secrets) = store { if let Some(ref secrets) = store {
// Inject LLM API keys from encrypted storage // 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. // Re-resolve only the LLM config with newly available keys.
let store: Option<&(dyn crate::db::SettingsStore + Sync)> = let store: Option<&(dyn crate::db::SettingsStore + Sync)> =
self.db.as_ref().map(|db| db.as_ref() as _); self.db.as_ref().map(|db| db.as_ref() as _);
let toml_path = self.toml_path.as_deref(); let toml_path = self.toml_path.as_deref();
let owner_id = self.config.owner_id.clone();
if let Err(e) = self if let Err(e) = self
.config .config
.re_resolve_llm(store, "default", toml_path) .re_resolve_llm(store, &owner_id, toml_path)
.await .await
{ {
tracing::warn!("Failed to re-resolve LLM config after secret injection: {e}"); tracing::warn!("Failed to re-resolve LLM config after secret injection: {e}");
@@ -304,7 +311,7 @@ impl AppBuilder {
// Register memory tools if database is available // Register memory tools if database is available
let workspace = if let Some(ref db) = self.db { 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); .with_search_config(&self.config.search);
if let Some(ref emb) = embeddings { if let Some(ref emb) = embeddings {
ws = ws.with_embeddings(emb.clone()); ws = ws.with_embeddings(emb.clone());
@@ -469,9 +476,10 @@ impl AppBuilder {
let tools = Arc::clone(tools); let tools = Arc::clone(tools);
let mcp_sm = Arc::clone(&mcp_session_manager); let mcp_sm = Arc::clone(&mcp_session_manager);
let pm = Arc::clone(&mcp_process_manager); let pm = Arc::clone(&mcp_process_manager);
let owner_id = self.config.owner_id.clone();
async move { async move {
let servers_result = if let Some(ref d) = db { 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 { } else {
crate::tools::mcp::config::load_mcp_servers().await crate::tools::mcp::config::load_mcp_servers().await
}; };
@@ -491,6 +499,7 @@ impl AppBuilder {
let secrets = secrets_store.clone(); let secrets = secrets_store.clone();
let tools = Arc::clone(&tools); let tools = Arc::clone(&tools);
let pm = Arc::clone(&pm); let pm = Arc::clone(&pm);
let owner_id = owner_id.clone();
join_set.spawn(async move { join_set.spawn(async move {
let server_name = server.name.clone(); let server_name = server.name.clone();
@@ -500,7 +509,7 @@ impl AppBuilder {
&mcp_sm, &mcp_sm,
&pm, &pm,
secrets, secrets,
"default", &owner_id,
) )
.await .await
{ {
@@ -642,7 +651,7 @@ impl AppBuilder {
self.config.wasm.tools_dir.clone(), self.config.wasm.tools_dir.clone(),
self.config.channels.wasm_channels_dir.clone(), self.config.channels.wasm_channels_dir.clone(),
self.config.tunnel.public_url.clone(), self.config.tunnel.public_url.clone(),
"default".to_string(), self.config.owner_id.clone(),
self.db.clone(), self.db.clone(),
catalog_entries.clone(), catalog_entries.clone(),
)); ));
+82 -6
View File
@@ -67,14 +67,24 @@ pub struct IncomingMessage {
pub id: Uuid, pub id: Uuid,
/// Channel this message came from. /// Channel this message came from.
pub channel: String, 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, 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. /// Optional display name.
pub user_name: Option<String>, pub user_name: Option<String>,
/// Message content. /// Message content.
pub content: String, pub content: String,
/// Thread/conversation ID for threaded conversations. /// Thread/conversation ID for threaded conversations.
pub thread_id: Option<String>, pub thread_id: Option<String>,
/// Stable channel/chat/thread scope for this conversation.
pub conversation_scope_id: Option<String>,
/// When the message was received. /// When the message was received.
pub received_at: DateTime<Utc>, pub received_at: DateTime<Utc>,
/// Channel-specific metadata. /// Channel-specific metadata.
@@ -84,9 +94,8 @@ pub struct IncomingMessage {
/// File or media attachments on this message. /// File or media attachments on this message.
pub attachments: Vec<IncomingAttachment>, pub attachments: Vec<IncomingAttachment>,
/// Internal-only flag: message was generated inside the process (e.g. job /// Internal-only flag: message was generated inside the process (e.g. job
/// monitor) and must bypass the normal user-input pipeline. This field is /// monitor) and must bypass the normal user-input pipeline. This field is
/// **not** settable via `with_metadata()` — only trusted code paths inside /// not settable via metadata, so external channels cannot spoof it.
/// the binary can set it, preventing external channels from spoofing it.
pub(crate) is_internal: bool, pub(crate) is_internal: bool,
} }
@@ -97,13 +106,17 @@ impl IncomingMessage {
user_id: impl Into<String>, user_id: impl Into<String>,
content: impl Into<String>, content: impl Into<String>,
) -> Self { ) -> Self {
let user_id = user_id.into();
Self { Self {
id: Uuid::new_v4(), id: Uuid::new_v4(),
channel: channel.into(), channel: channel.into(),
user_id: user_id.into(), owner_id: user_id.clone(),
sender_id: user_id.clone(),
user_id,
user_name: None, user_name: None,
content: content.into(), content: content.into(),
thread_id: None, thread_id: None,
conversation_scope_id: None,
received_at: Utc::now(), received_at: Utc::now(),
metadata: serde_json::Value::Null, metadata: serde_json::Value::Null,
timezone: None, timezone: None,
@@ -114,7 +127,27 @@ impl IncomingMessage {
/// Set the thread ID. /// Set the thread ID.
pub fn with_thread(mut self, thread_id: impl Into<String>) -> Self { pub fn with_thread(mut self, thread_id: impl Into<String>) -> 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<String>) -> 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<String>) -> 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<String>) -> Self {
self.conversation_scope_id = Some(scope_id.into());
self self
} }
@@ -147,6 +180,49 @@ impl IncomingMessage {
self.is_internal = true; self.is_internal = true;
self 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<String> {
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<String> {
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. /// Stream of incoming messages.
+105 -11
View File
@@ -133,7 +133,8 @@ impl HttpChannel {
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct WebhookRequest { 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)] #[serde(default)]
user_id: Option<String>, user_id: Option<String>,
/// Message content. /// Message content.
@@ -403,12 +404,38 @@ async fn process_authenticated_request(
state: Arc<HttpChannelState>, state: Arc<HttpChannelState>,
req: WebhookRequest, req: WebhookRequest,
) -> axum::response::Response { ) -> axum::response::Response {
let _ = req.user_id.as_ref().map(|user_id| { let normalized_user_id = req
tracing::debug!( .user_id
provided_user_id = %user_id, .as_deref()
"HTTP webhook request provided user_id, ignoring in favor of configured user_id" .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 { if req.content.len() > MAX_CONTENT_BYTES {
return ( return (
@@ -514,11 +541,13 @@ async fn process_authenticated_request(
Vec::new() Vec::new()
}; };
let mut msg = IncomingMessage::new("http", &state.user_id, &req.content).with_metadata( let sender_id = normalized_user_id.unwrap_or(&state.user_id).to_string();
serde_json::json!({ 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, "wait_for_response": wait_for_response,
}), }));
);
if !attachments.is_empty() { if !attachments.is_empty() {
msg = msg.with_attachments(attachments); msg = msg.with_attachments(attachments);
@@ -682,6 +711,7 @@ mod tests {
use axum::body::Body; use axum::body::Body;
use axum::http::{HeaderValue, Request}; use axum::http::{HeaderValue, Request};
use secrecy::SecretString; use secrecy::SecretString;
use tokio_stream::StreamExt;
use tower::ServiceExt; use tower::ServiceExt;
use super::*; use super::*;
@@ -820,6 +850,70 @@ mod tests {
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); 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 /// Regression test for issue #869: RwLock read guard was held across
/// tx.send(msg).await in `process_message()`, blocking shutdown() from /// tx.send(msg).await in `process_message()`, blocking shutdown() from
/// acquiring the write lock when the channel buffer was full. /// acquiring the write lock when the channel buffer was full.
+1 -1
View File
@@ -39,7 +39,7 @@ mod webhook_server;
pub use channel::{ pub use channel::{
AttachmentKind, Channel, ChannelSecretUpdater, IncomingAttachment, IncomingMessage, AttachmentKind, Channel, ChannelSecretUpdater, IncomingAttachment, IncomingMessage,
MessageStream, OutgoingResponse, StatusUpdate, MessageStream, OutgoingResponse, StatusUpdate, routing_target_from_metadata,
}; };
pub use http::{HttpChannel, HttpChannelState}; pub use http::{HttpChannel, HttpChannelState};
pub use manager::ChannelManager; pub use manager::ChannelManager;
+22 -7
View File
@@ -200,6 +200,8 @@ fn format_json_params(params: &serde_json::Value, indent: &str) -> String {
/// REPL channel with line editing and markdown rendering. /// REPL channel with line editing and markdown rendering.
pub struct ReplChannel { pub struct ReplChannel {
/// Stable owner scope for this REPL instance.
user_id: String,
/// Optional single message to send (for -m flag). /// Optional single message to send (for -m flag).
single_message: Option<String>, single_message: Option<String>,
/// Debug mode flag (shared with input thread). /// Debug mode flag (shared with input thread).
@@ -213,7 +215,13 @@ pub struct ReplChannel {
impl ReplChannel { impl ReplChannel {
/// Create a new REPL channel. /// Create a new REPL channel.
pub fn new() -> Self { 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<String>) -> Self {
Self { Self {
user_id: user_id.into(),
single_message: None, single_message: None,
debug_mode: Arc::new(AtomicBool::new(false)), debug_mode: Arc::new(AtomicBool::new(false)),
is_streaming: 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. /// Create a REPL channel that sends a single message and exits.
pub fn with_message(message: String) -> Self { 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<String>, message: String) -> Self {
Self { Self {
user_id: user_id.into(),
single_message: Some(message), single_message: Some(message),
debug_mode: Arc::new(AtomicBool::new(false)), debug_mode: Arc::new(AtomicBool::new(false)),
is_streaming: 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<MessageStream, ChannelError> { async fn start(&self) -> Result<MessageStream, ChannelError> {
let (tx, rx) = mpsc::channel(32); let (tx, rx) = mpsc::channel(32);
let single_message = self.single_message.clone(); let single_message = self.single_message.clone();
let user_id = self.user_id.clone();
let debug_mode = Arc::clone(&self.debug_mode); let debug_mode = Arc::clone(&self.debug_mode);
let suppress_banner = Arc::clone(&self.suppress_banner); let suppress_banner = Arc::clone(&self.suppress_banner);
let esc_interrupt_triggered_for_thread = Arc::new(AtomicBool::new(false)); let esc_interrupt_triggered_for_thread = Arc::new(AtomicBool::new(false));
@@ -301,11 +316,11 @@ impl Channel for ReplChannel {
// Single message mode: send it and return // Single message mode: send it and return
if let Some(msg) = single_message { 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); let _ = tx.blocking_send(incoming);
// Ensure the agent exits after handling exactly one turn in -m mode, // Ensure the agent exits after handling exactly one turn in -m mode,
// even when other channels (gateway/http) are enabled. // 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; return;
} }
@@ -366,7 +381,7 @@ impl Channel for ReplChannel {
"/quit" | "/exit" => { "/quit" | "/exit" => {
// Forward shutdown command so the agent loop exits even // Forward shutdown command so the agent loop exits even
// when other channels (e.g. web gateway) are still active. // 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); .with_timezone(&sys_tz);
let _ = tx.blocking_send(msg); let _ = tx.blocking_send(msg);
break; break;
@@ -389,7 +404,7 @@ impl Channel for ReplChannel {
} }
let msg = 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() { if tx.blocking_send(msg).is_err() {
break; break;
} }
@@ -397,14 +412,14 @@ impl Channel for ReplChannel {
Err(ReadlineError::Interrupted) => { Err(ReadlineError::Interrupted) => {
if esc_interrupt_triggered_for_thread.swap(false, Ordering::Relaxed) { if esc_interrupt_triggered_for_thread.swap(false, Ordering::Relaxed) {
// Esc: interrupt current operation and keep REPL open. // 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); .with_timezone(&sys_tz);
if tx.blocking_send(msg).is_err() { if tx.blocking_send(msg).is_err() {
break; break;
} }
} else { } else {
// Ctrl+C (VINTR): request graceful shutdown. // 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); .with_timezone(&sys_tz);
let _ = tx.blocking_send(msg); let _ = tx.blocking_send(msg);
break; break;
@@ -416,7 +431,7 @@ impl Channel for ReplChannel {
// immediately — just drop the REPL thread silently so other // immediately — just drop the REPL thread silently so other
// channels (gateway, telegram, …) keep running. // channels (gateway, telegram, …) keep running.
if std::io::stdin().is_terminal() { 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); .with_timezone(&sys_tz);
let _ = tx.blocking_send(msg); let _ = tx.blocking_send(msg);
} }
+8 -2
View File
@@ -27,6 +27,7 @@ pub struct WasmChannelLoader {
pairing_store: Arc<PairingStore>, pairing_store: Arc<PairingStore>,
settings_store: Option<Arc<dyn SettingsStore>>, settings_store: Option<Arc<dyn SettingsStore>>,
secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>, secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
owner_scope_id: String,
} }
impl WasmChannelLoader { impl WasmChannelLoader {
@@ -35,12 +36,14 @@ impl WasmChannelLoader {
runtime: Arc<WasmChannelRuntime>, runtime: Arc<WasmChannelRuntime>,
pairing_store: Arc<PairingStore>, pairing_store: Arc<PairingStore>,
settings_store: Option<Arc<dyn SettingsStore>>, settings_store: Option<Arc<dyn SettingsStore>>,
owner_scope_id: impl Into<String>,
) -> Self { ) -> Self {
Self { Self {
runtime, runtime,
pairing_store, pairing_store,
settings_store, settings_store,
secrets_store: None, secrets_store: None,
owner_scope_id: owner_scope_id.into(),
} }
} }
@@ -149,6 +152,7 @@ impl WasmChannelLoader {
self.runtime.clone(), self.runtime.clone(),
prepared, prepared,
capabilities, capabilities,
self.owner_scope_id.clone(),
config_json, config_json,
self.pairing_store.clone(), self.pairing_store.clone(),
self.settings_store.clone(), self.settings_store.clone(),
@@ -487,7 +491,8 @@ mod tests {
async fn test_loader_invalid_name() { async fn test_loader_invalid_name() {
let config = WasmChannelRuntimeConfig::for_testing(); let config = WasmChannelRuntimeConfig::for_testing();
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap()); 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 dir = TempDir::new().unwrap();
let wasm_path = dir.path().join("test.wasm"); let wasm_path = dir.path().join("test.wasm");
@@ -505,7 +510,8 @@ mod tests {
async fn load_from_dir_returns_empty_when_dir_missing() { async fn load_from_dir_returns_empty_when_dir_missing() {
let config = WasmChannelRuntimeConfig::for_testing(); let config = WasmChannelRuntimeConfig::for_testing();
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap()); 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 dir = TempDir::new().unwrap();
let missing = dir.path().join("nonexistent_channels_dir"); let missing = dir.path().join("nonexistent_channels_dir");
+1 -1
View File
@@ -69,7 +69,7 @@
//! let runtime = WasmChannelRuntime::new(config)?; //! let runtime = WasmChannelRuntime::new(config)?;
//! //!
//! // Load channels from directory //! // 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?; //! let channels = loader.load_from_dir(Path::new("~/.ironclaw/channels/")).await?;
//! //!
//! // Add to channel manager //! // Add to channel manager
+1
View File
@@ -672,6 +672,7 @@ mod tests {
runtime, runtime,
prepared, prepared,
capabilities, capabilities,
"default",
"{}".to_string(), "{}".to_string(),
Arc::new(PairingStore::new()), Arc::new(PairingStore::new()),
None, None,
+16 -6
View File
@@ -50,6 +50,7 @@ pub async fn setup_wasm_channels(
Arc::clone(&runtime), Arc::clone(&runtime),
Arc::clone(&pairing_store), Arc::clone(&pairing_store),
settings_store.clone(), settings_store.clone(),
config.owner_id.clone(),
); );
if let Some(secrets) = secrets_store { if let Some(secrets) = secrets_store {
loader = loader.with_secrets_store(Arc::clone(secrets)); loader = loader.with_secrets_store(Arc::clone(secrets));
@@ -117,6 +118,11 @@ async fn register_channel(
) -> (String, Box<dyn crate::channels::Channel>) { ) -> (String, Box<dyn crate::channels::Channel>) {
let channel_name = loaded.name().to_string(); let channel_name = loaded.name().to_string();
tracing::info!("Loaded WASM channel: {}", channel_name); 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 secret_name = loaded.webhook_secret_name();
let sig_key_secret_name = loaded.signature_key_secret_name(); let sig_key_secret_name = loaded.signature_key_secret_name();
@@ -124,7 +130,7 @@ async fn register_channel(
let webhook_secret = if let Some(secrets) = secrets_store { let webhook_secret = if let Some(secrets) = secrets_store {
secrets secrets
.get_decrypted("default", &secret_name) .get_decrypted(&config.owner_id, &secret_name)
.await .await
.ok() .ok()
.map(|s| s.expose().to_string()) .map(|s| s.expose().to_string())
@@ -142,7 +148,7 @@ async fn register_channel(
require_secret: webhook_secret.is_some(), require_secret: webhook_secret.is_some(),
}]; }];
let channel_arc = Arc::new(loaded.channel); let channel_arc = Arc::new(loaded.channel.with_owner_actor_id(owner_actor_id.clone()));
// Inject runtime config (tunnel URL, webhook secret, owner_id). // Inject runtime config (tunnel URL, webhook secret, owner_id).
{ {
@@ -216,7 +222,7 @@ async fn register_channel(
// Register Ed25519 signature key if declared in capabilities. // Register Ed25519 signature key if declared in capabilities.
if let Some(ref sig_key_name) = sig_key_secret_name if let Some(ref sig_key_name) = sig_key_secret_name
&& let Some(secrets) = secrets_store && 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 match wasm_router
.register_signature_key(&channel_name, key_secret.expose()) .register_signature_key(&channel_name, key_secret.expose())
@@ -234,7 +240,9 @@ async fn register_channel(
// Register HMAC signing secret if declared in capabilities. // Register HMAC signing secret if declared in capabilities.
if let Some(ref hmac_secret_name) = hmac_secret_name if let Some(ref hmac_secret_name) = hmac_secret_name
&& let Some(secrets) = secrets_store && 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 wasm_router
.register_hmac_secret(&channel_name, secret.expose()) .register_hmac_secret(&channel_name, secret.expose())
@@ -249,6 +257,7 @@ async fn register_channel(
.as_ref() .as_ref()
.map(|s| s.as_ref() as &dyn SecretsStore), .map(|s| s.as_ref() as &dyn SecretsStore),
&channel_name, &channel_name,
&config.owner_id,
) )
.await .await
{ {
@@ -286,6 +295,7 @@ pub async fn inject_channel_credentials(
channel: &Arc<WasmChannel>, channel: &Arc<WasmChannel>,
secrets: Option<&dyn SecretsStore>, secrets: Option<&dyn SecretsStore>,
channel_name: &str, channel_name: &str,
owner_id: &str,
) -> anyhow::Result<usize> { ) -> anyhow::Result<usize> {
if channel_name.trim().is_empty() { if channel_name.trim().is_empty() {
return Ok(0); return Ok(0);
@@ -297,7 +307,7 @@ pub async fn inject_channel_credentials(
// 1. Try injecting from persistent secrets store if available // 1. Try injecting from persistent secrets store if available
if let Some(secrets) = secrets { if let Some(secrets) = secrets {
let all_secrets = secrets let all_secrets = secrets
.list("default") .list(owner_id)
.await .await
.map_err(|e| anyhow::anyhow!("Failed to list secrets: {}", e))?; .map_err(|e| anyhow::anyhow!("Failed to list secrets: {}", e))?;
@@ -308,7 +318,7 @@ pub async fn inject_channel_credentials(
continue; 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, Ok(d) => d,
Err(e) => { Err(e) => {
tracing::warn!( tracing::warn!(
+422 -103
View File
@@ -709,6 +709,12 @@ pub struct WasmChannel {
/// Settings store for persisting broadcast metadata across restarts. /// Settings store for persisting broadcast metadata across restarts.
settings_store: Option<Arc<dyn crate::db::SettingsStore>>, settings_store: Option<Arc<dyn crate::db::SettingsStore>>,
/// 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<String>,
/// Secrets store for host-based credential injection. /// Secrets store for host-based credential injection.
/// Used to pre-resolve credentials before each WASM callback. /// Used to pre-resolve credentials before each WASM callback.
secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>, secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
@@ -719,6 +725,7 @@ pub struct WasmChannel {
/// method and the static polling helper share one implementation. /// method and the static polling helper share one implementation.
async fn do_update_broadcast_metadata( async fn do_update_broadcast_metadata(
channel_name: &str, channel_name: &str,
owner_scope_id: &str,
metadata: &str, metadata: &str,
last_broadcast_metadata: &tokio::sync::RwLock<Option<String>>, last_broadcast_metadata: &tokio::sync::RwLock<Option<String>>,
settings_store: Option<&Arc<dyn crate::db::SettingsStore>>, settings_store: Option<&Arc<dyn crate::db::SettingsStore>>,
@@ -731,7 +738,7 @@ async fn do_update_broadcast_metadata(
if changed && let Some(store) = settings_store { if changed && let Some(store) = settings_store {
let key = format!("channel_broadcast_metadata_{}", channel_name); let key = format!("channel_broadcast_metadata_{}", channel_name);
let value = serde_json::Value::String(metadata.to_string()); 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!( tracing::warn!(
channel = %channel_name, channel = %channel_name,
"Failed to persist broadcast metadata: {}", "Failed to persist broadcast metadata: {}",
@@ -741,12 +748,70 @@ 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 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<String, ChannelError> {
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);
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 { impl WasmChannel {
/// Create a new WASM channel. /// Create a new WASM channel.
pub fn new( pub fn new(
runtime: Arc<WasmChannelRuntime>, runtime: Arc<WasmChannelRuntime>,
prepared: Arc<PreparedChannelModule>, prepared: Arc<PreparedChannelModule>,
capabilities: ChannelCapabilities, capabilities: ChannelCapabilities,
owner_scope_id: impl Into<String>,
config_json: String, config_json: String,
pairing_store: Arc<PairingStore>, pairing_store: Arc<PairingStore>,
settings_store: Option<Arc<dyn crate::db::SettingsStore>>, settings_store: Option<Arc<dyn crate::db::SettingsStore>>,
@@ -773,6 +838,8 @@ impl WasmChannel {
workspace_store: Arc::new(ChannelWorkspaceStore::new()), workspace_store: Arc::new(ChannelWorkspaceStore::new()),
last_broadcast_metadata: Arc::new(tokio::sync::RwLock::new(None)), last_broadcast_metadata: Arc::new(tokio::sync::RwLock::new(None)),
settings_store, settings_store,
owner_scope_id: owner_scope_id.into(),
owner_actor_id: None,
secrets_store: None, secrets_store: None,
} }
} }
@@ -787,6 +854,12 @@ impl WasmChannel {
self self
} }
/// 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<String>) -> Self {
self.owner_actor_id = owner_actor_id;
self
}
/// Update the channel config before starting. /// Update the channel config before starting.
/// ///
/// Merges the provided values into the existing config JSON. /// Merges the provided values into the existing config JSON.
@@ -843,6 +916,7 @@ impl WasmChannel {
async fn update_broadcast_metadata(&self, metadata: &str) { async fn update_broadcast_metadata(&self, metadata: &str) {
do_update_broadcast_metadata( do_update_broadcast_metadata(
&self.name, &self.name,
&self.owner_scope_id,
metadata, metadata,
&self.last_broadcast_metadata, &self.last_broadcast_metadata,
self.settings_store.as_ref(), self.settings_store.as_ref(),
@@ -854,7 +928,7 @@ impl WasmChannel {
async fn load_broadcast_metadata(&self) { async fn load_broadcast_metadata(&self) {
if let Some(ref store) = self.settings_store { if let Some(ref store) = self.settings_store {
match store match store
.get_setting("default", &self.broadcast_metadata_key()) .get_setting(&self.owner_scope_id, &self.broadcast_metadata_key())
.await .await
{ {
Ok(Some(serde_json::Value::String(meta))) => { Ok(Some(serde_json::Value::String(meta))) => {
@@ -864,7 +938,30 @@ impl WasmChannel {
"Restored broadcast metadata from settings" "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) => { Err(e) => {
tracing::warn!( tracing::warn!(
channel = %self.name, channel = %self.name,
@@ -1064,9 +1161,12 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout; let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone(); let channel_name = self.name.clone();
let credentials = self.get_credentials().await; let credentials = self.get_credentials().await;
let host_credentials = let host_credentials = resolve_channel_host_credentials(
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref()) &self.capabilities,
.await; self.secrets_store.as_deref(),
&self.owner_scope_id,
)
.await;
let pairing_store = self.pairing_store.clone(); let pairing_store = self.pairing_store.clone();
let workspace_store = self.workspace_store.clone(); let workspace_store = self.workspace_store.clone();
@@ -1204,9 +1304,12 @@ impl WasmChannel {
let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store); let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store);
let timeout = self.runtime.config().callback_timeout; let timeout = self.runtime.config().callback_timeout;
let credentials = self.get_credentials().await; let credentials = self.get_credentials().await;
let host_credentials = let host_credentials = resolve_channel_host_credentials(
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref()) &self.capabilities,
.await; self.secrets_store.as_deref(),
&self.owner_scope_id,
)
.await;
let pairing_store = self.pairing_store.clone(); let pairing_store = self.pairing_store.clone();
let workspace_store = self.workspace_store.clone(); let workspace_store = self.workspace_store.clone();
@@ -1307,9 +1410,12 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout; let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone(); let channel_name = self.name.clone();
let credentials = self.get_credentials().await; let credentials = self.get_credentials().await;
let host_credentials = let host_credentials = resolve_channel_host_credentials(
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref()) &self.capabilities,
.await; self.secrets_store.as_deref(),
&self.owner_scope_id,
)
.await;
let pairing_store = self.pairing_store.clone(); let pairing_store = self.pairing_store.clone();
let workspace_store = self.workspace_store.clone(); let workspace_store = self.workspace_store.clone();
@@ -1414,9 +1520,12 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout; let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone(); let channel_name = self.name.clone();
let credentials = self.get_credentials().await; let credentials = self.get_credentials().await;
let host_credentials = let host_credentials = resolve_channel_host_credentials(
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref()) &self.capabilities,
.await; self.secrets_store.as_deref(),
&self.owner_scope_id,
)
.await;
let pairing_store = self.pairing_store.clone(); let pairing_store = self.pairing_store.clone();
// Prepare response data // Prepare response data
@@ -1555,9 +1664,12 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout; let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone(); let channel_name = self.name.clone();
let credentials = self.get_credentials().await; let credentials = self.get_credentials().await;
let host_credentials = let host_credentials = resolve_channel_host_credentials(
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref()) &self.capabilities,
.await; self.secrets_store.as_deref(),
&self.owner_scope_id,
)
.await;
let pairing_store = self.pairing_store.clone(); let pairing_store = self.pairing_store.clone();
let user_id = user_id.to_string(); let user_id = user_id.to_string();
@@ -1659,9 +1771,12 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout; let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone(); let channel_name = self.name.clone();
let credentials = self.get_credentials().await; let credentials = self.get_credentials().await;
let host_credentials = let host_credentials = resolve_channel_host_credentials(
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref()) &self.capabilities,
.await; self.secrets_store.as_deref(),
&self.owner_scope_id,
)
.await;
let pairing_store = self.pairing_store.clone(); let pairing_store = self.pairing_store.clone();
let Some(wit_update) = status_to_wit(status, metadata) else { let Some(wit_update) = status_to_wit(status, metadata) else {
@@ -1831,6 +1946,7 @@ impl WasmChannel {
let repeater_host_credentials = resolve_channel_host_credentials( let repeater_host_credentials = resolve_channel_host_credentials(
&self.capabilities, &self.capabilities,
self.secrets_store.as_deref(), self.secrets_store.as_deref(),
&self.owner_scope_id,
) )
.await; .await;
let pairing_store = self.pairing_store.clone(); let pairing_store = self.pairing_store.clone();
@@ -2027,8 +2143,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 // 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 { if let Some(name) = emitted.user_name {
msg = msg.with_user_name(name); msg = msg.with_user_name(name);
@@ -2060,9 +2184,9 @@ impl WasmChannel {
} }
// Parse metadata JSON // Parse metadata JSON
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) { msg = apply_emitted_metadata(msg, &emitted.metadata_json);
msg = msg.with_metadata(metadata); if is_owner_sender {
// Store for broadcast routing (chat_id etc.) // Store for owner-target routing (chat_id etc.).
self.update_broadcast_metadata(&emitted.metadata_json).await; self.update_broadcast_metadata(&emitted.metadata_json).await;
} }
@@ -2112,6 +2236,8 @@ impl WasmChannel {
let last_broadcast_metadata = self.last_broadcast_metadata.clone(); let last_broadcast_metadata = self.last_broadcast_metadata.clone();
let settings_store = self.settings_store.clone(); let settings_store = self.settings_store.clone();
let poll_secrets_store = self.secrets_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 { tokio::spawn(async move {
let mut interval_timer = tokio::time::interval(interval); let mut interval_timer = tokio::time::interval(interval);
@@ -2129,6 +2255,7 @@ impl WasmChannel {
let host_credentials = resolve_channel_host_credentials( let host_credentials = resolve_channel_host_credentials(
&poll_capabilities, &poll_capabilities,
poll_secrets_store.as_deref(), poll_secrets_store.as_deref(),
&owner_scope_id,
) )
.await; .await;
@@ -2150,12 +2277,16 @@ impl WasmChannel {
// Process any emitted messages // Process any emitted messages
if !emitted_messages.is_empty() if !emitted_messages.is_empty()
&& let Err(e) = Self::dispatch_emitted_messages( && let Err(e) = Self::dispatch_emitted_messages(
&channel_name, 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, emitted_messages,
&message_tx,
&rate_limiter,
&last_broadcast_metadata,
settings_store.as_ref(),
).await { ).await {
tracing::warn!( tracing::warn!(
channel = %channel_name, channel = %channel_name,
@@ -2277,25 +2408,21 @@ impl WasmChannel {
/// This is a static helper used by the polling loop since it doesn't have /// This is a static helper used by the polling loop since it doesn't have
/// access to `&self`. /// access to `&self`.
async fn dispatch_emitted_messages( async fn dispatch_emitted_messages(
channel_name: &str, dispatch: EmitDispatchContext<'_>,
messages: Vec<EmittedMessage>, messages: Vec<EmittedMessage>,
message_tx: &RwLock<Option<mpsc::Sender<IncomingMessage>>>,
rate_limiter: &RwLock<ChannelEmitRateLimiter>,
last_broadcast_metadata: &tokio::sync::RwLock<Option<String>>,
settings_store: Option<&Arc<dyn crate::db::SettingsStore>>,
) -> Result<(), WasmChannelError> { ) -> Result<(), WasmChannelError> {
tracing::info!( tracing::info!(
channel = %channel_name, channel = %dispatch.channel_name,
message_count = messages.len(), message_count = messages.len(),
"Processing emitted messages from polling callback" "Processing emitted messages from polling callback"
); );
// Clone sender to avoid holding RwLock read guard across send().await in the loop // Clone sender to avoid holding RwLock read guard across send().await in the loop
let tx = { 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 { let Some(tx) = tx_guard.as_ref() else {
tracing::error!( tracing::error!(
channel = %channel_name, channel = %dispatch.channel_name,
count = messages.len(), count = messages.len(),
"Messages emitted but no sender available - channel may not be started!" "Messages emitted but no sender available - channel may not be started!"
); );
@@ -2307,20 +2434,29 @@ impl WasmChannel {
for emitted in messages { for emitted in messages {
// Check rate limit — acquire and release the write lock before send().await // 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() { if !limiter.check_and_record() {
tracing::warn!( tracing::warn!(
channel = %channel_name, channel = %dispatch.channel_name,
"Message emission rate limited" "Message emission rate limited"
); );
return Err(WasmChannelError::EmitRateLimited { 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(
dispatch.owner_scope_id,
dispatch.owner_actor_id,
&emitted.user_id,
);
// Convert to IncomingMessage // Convert to IncomingMessage
let mut msg = IncomingMessage::new(channel_name, &emitted.user_id, &emitted.content); 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 { if let Some(name) = emitted.user_name {
msg = msg.with_user_name(name); msg = msg.with_user_name(name);
@@ -2351,22 +2487,22 @@ impl WasmChannel {
msg = msg.with_attachments(incoming_attachments); msg = msg.with_attachments(incoming_attachments);
} }
// Parse metadata JSON msg = apply_emitted_metadata(msg, &emitted.metadata_json);
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) { if is_owner_sender {
msg = msg.with_metadata(metadata); // Store for owner-target routing (chat_id etc.)
// Store for broadcast routing (chat_id etc.)
do_update_broadcast_metadata( do_update_broadcast_metadata(
channel_name, dispatch.channel_name,
dispatch.owner_scope_id,
&emitted.metadata_json, &emitted.metadata_json,
last_broadcast_metadata, dispatch.last_broadcast_metadata,
settings_store, dispatch.settings_store,
) )
.await; .await;
} }
// Send to stream — no locks held across this await // Send to stream — no locks held across this await
tracing::info!( tracing::info!(
channel = %channel_name, channel = %dispatch.channel_name,
user_id = %emitted.user_id, user_id = %emitted.user_id,
content_len = emitted.content.len(), content_len = emitted.content.len(),
attachment_count = msg.attachments.len(), attachment_count = msg.attachments.len(),
@@ -2375,14 +2511,14 @@ impl WasmChannel {
if tx.send(msg).await.is_err() { if tx.send(msg).await.is_err() {
tracing::error!( tracing::error!(
channel = %channel_name, channel = %dispatch.channel_name,
"Failed to send polled message, channel closed" "Failed to send polled message, channel closed"
); );
break; break;
} }
tracing::info!( tracing::info!(
channel = %channel_name, channel = %dispatch.channel_name,
"Message successfully sent to agent queue" "Message successfully sent to agent queue"
); );
} }
@@ -2391,6 +2527,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<Option<mpsc::Sender<IncomingMessage>>>,
rate_limiter: &'a RwLock<ChannelEmitRateLimiter>,
last_broadcast_metadata: &'a tokio::sync::RwLock<Option<String>>,
settings_store: Option<&'a Arc<dyn crate::db::SettingsStore>>,
}
#[async_trait] #[async_trait]
impl Channel for WasmChannel { impl Channel for WasmChannel {
fn name(&self) -> &str { fn name(&self) -> &str {
@@ -2490,8 +2636,11 @@ impl Channel for WasmChannel {
// The original metadata contains channel-specific routing info (e.g., Telegram chat_id) // 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. // 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(); let metadata_json = serde_json::to_string(&msg.metadata).unwrap_or_default();
// Store for broadcast routing (chat_id etc.) // Store for owner-target routing (chat_id etc.) only when the configured
self.update_broadcast_metadata(&metadata_json).await; // 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( self.call_on_respond(
msg.id, msg.id,
&response.content, &response.content,
@@ -2514,8 +2663,24 @@ impl Channel for WasmChannel {
response: OutgoingResponse, response: OutgoingResponse,
) -> Result<(), ChannelError> { ) -> Result<(), ChannelError> {
self.cancel_typing_task().await; self.cancel_typing_task().await;
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(|| {
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
),
)
})?;
resolve_owner_broadcast_target(&self.name, &metadata)?
} else {
user_id.to_string()
};
self.call_on_broadcast( self.call_on_broadcast(
user_id, &resolved_target,
&response.content, &response.content,
response.thread_id.as_deref(), response.thread_id.as_deref(),
&response.attachments, &response.attachments,
@@ -2931,6 +3096,7 @@ fn extract_host_from_url(url: &str) -> Option<String> {
async fn resolve_channel_host_credentials( async fn resolve_channel_host_credentials(
capabilities: &ChannelCapabilities, capabilities: &ChannelCapabilities,
store: Option<&(dyn SecretsStore + Send + Sync)>, store: Option<&(dyn SecretsStore + Send + Sync)>,
owner_scope_id: &str,
) -> Vec<ResolvedHostCredential> { ) -> Vec<ResolvedHostCredential> {
let store = match store { let store = match store {
Some(s) => s, Some(s) => s,
@@ -2957,7 +3123,10 @@ async fn resolve_channel_host_credentials(
continue; 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, Ok(s) => s,
Err(e) => { Err(e) => {
tracing::debug!( tracing::debug!(
@@ -3076,12 +3245,18 @@ mod tests {
use crate::channels::wasm::runtime::{ use crate::channels::wasm::runtime::{
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig, PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
}; };
use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel}; use crate::channels::wasm::wrapper::{
EmitDispatchContext, HttpResponse, WasmChannel, uses_owner_broadcast_target,
};
use crate::pairing::PairingStore; use crate::pairing::PairingStore;
use crate::testing::credentials::TEST_TELEGRAM_BOT_TOKEN; use crate::testing::credentials::TEST_TELEGRAM_BOT_TOKEN;
use crate::tools::wasm::ResourceLimits; use crate::tools::wasm::ResourceLimits;
fn create_test_channel() -> WasmChannel { 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 config = WasmChannelRuntimeConfig::for_testing();
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap()); let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
@@ -3098,6 +3273,7 @@ mod tests {
runtime, runtime,
prepared, prepared,
capabilities, capabilities,
owner_scope_id,
"{}".to_string(), "{}".to_string(),
Arc::new(PairingStore::new()), Arc::new(PairingStore::new()),
None, None,
@@ -3185,7 +3361,7 @@ mod tests {
) )
.await; .await;
assert!(result.is_ok()); assert!(result.is_ok()); // safety: test-only assertion
assert!(result.unwrap().is_empty()); assert!(result.unwrap().is_empty());
} }
@@ -3209,28 +3385,32 @@ mod tests {
let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None)); let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
let result = WasmChannel::dispatch_emitted_messages( let result = WasmChannel::dispatch_emitted_messages(
"test-channel", 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, messages,
&message_tx,
&rate_limiter,
&last_broadcast_metadata,
None,
) )
.await; .await;
assert!(result.is_ok()); assert!(result.is_ok()); // safety: test-only assertion
// Verify messages were sent // Verify messages were sent
let msg1 = rx.try_recv().expect("Should receive first message"); let msg1 = rx.try_recv().expect("Should receive first message"); // safety: test-only assertion
assert_eq!(msg1.user_id, "user1"); assert_eq!(msg1.user_id, "user1"); // safety: test-only assertion
assert_eq!(msg1.content, "Hello from polling!"); assert_eq!(msg1.content, "Hello from polling!"); // safety: test-only assertion
let msg2 = rx.try_recv().expect("Should receive second message"); let msg2 = rx.try_recv().expect("Should receive second message"); // safety: test-only assertion
assert_eq!(msg2.user_id, "user2"); assert_eq!(msg2.user_id, "user2"); // safety: test-only assertion
assert_eq!(msg2.content, "Another message"); assert_eq!(msg2.content, "Another message"); // safety: test-only assertion
// No more messages // No more messages
assert!(rx.try_recv().is_err()); assert!(rx.try_recv().is_err()); // safety: test-only assertion
} }
#[tokio::test] #[tokio::test]
@@ -3250,12 +3430,16 @@ mod tests {
// Should return Ok even without a sender (logs warning but doesn't fail) // 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 last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
let result = WasmChannel::dispatch_emitted_messages( let result = WasmChannel::dispatch_emitted_messages(
"test-channel", 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, messages,
&message_tx,
&rate_limiter,
&last_broadcast_metadata,
None,
) )
.await; .await;
@@ -3284,6 +3468,7 @@ mod tests {
runtime, runtime,
prepared, prepared,
capabilities, capabilities,
"default",
"{}".to_string(), "{}".to_string(),
Arc::new(PairingStore::new()), Arc::new(PairingStore::new()),
None, None,
@@ -4255,42 +4440,172 @@ mod tests {
let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None)); let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
let result = WasmChannel::dispatch_emitted_messages( let result = WasmChannel::dispatch_emitted_messages(
"test-channel", 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, messages,
&message_tx,
&rate_limiter,
&last_broadcast_metadata,
None,
) )
.await; .await;
assert!(result.is_ok()); assert!(result.is_ok()); // safety: test-only assertion
let msg = rx.try_recv().expect("Should receive message"); let msg = rx.try_recv().expect("Should receive message"); // safety: test-only assertion
assert_eq!(msg.content, "Check these files"); assert_eq!(msg.content, "Check these files"); // safety: test-only assertion
assert_eq!(msg.attachments.len(), 2); assert_eq!(msg.attachments.len(), 2); // safety: test-only assertion
// Verify first attachment // Verify first attachment
assert_eq!(msg.attachments[0].id, "photo123"); assert_eq!(msg.attachments[0].id, "photo123"); // safety: test-only assertion
assert_eq!(msg.attachments[0].mime_type, "image/jpeg"); assert_eq!(msg.attachments[0].mime_type, "image/jpeg"); // safety: test-only assertion
assert_eq!(msg.attachments[0].filename, Some("cat.jpg".to_string())); 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)); assert_eq!(msg.attachments[0].size_bytes, Some(50_000)); // safety: test-only assertion
assert_eq!( assert_eq!(
msg.attachments[0].source_url, msg.attachments[0].source_url,
Some("https://api.telegram.org/file/photo123".to_string()) Some("https://api.telegram.org/file/photo123".to_string())
); ); // safety: test-only assertion
// Verify second attachment // Verify second attachment
assert_eq!(msg.attachments[1].id, "doc456"); assert_eq!(msg.attachments[1].id, "doc456"); // safety: test-only assertion
assert_eq!(msg.attachments[1].mime_type, "application/pdf"); assert_eq!(msg.attachments[1].mime_type, "application/pdf"); // safety: test-only assertion
assert_eq!( assert_eq!(
msg.attachments[1].extracted_text, msg.attachments[1].extracted_text,
Some("Report contents...".to_string()) Some("Report contents...".to_string())
); ); // safety: test-only assertion
assert_eq!( assert_eq!(
msg.attachments[1].storage_key, msg.attachments[1].storage_key,
Some("store/doc456".to_string()) Some("store/doc456".to_string())
); ); // safety: test-only assertion
}
#[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(
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,
)
.await;
assert!(result.is_ok()); // safety: test-only assertion
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
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]
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(
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,
)
.await;
assert!(result.is_ok()); // safety: test-only assertion
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_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());
let result = channel
.broadcast(
"owner-scope",
crate::channels::OutgoingResponse::text("hello owner"),
)
.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]
async fn test_broadcast_owner_scope_requires_stored_metadata() {
let channel = create_test_channel_with_owner_scope("owner-scope")
.with_owner_actor_id(Some("telegram-owner".to_string()));
let result = channel
.broadcast(
"owner-scope",
crate::channels::OutgoingResponse::text("hello owner"),
)
.await;
assert!(result.is_err()); // safety: test-only assertion
let err = result.unwrap_err().to_string();
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] #[tokio::test]
@@ -4310,20 +4625,24 @@ mod tests {
let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None)); let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
let result = WasmChannel::dispatch_emitted_messages( let result = WasmChannel::dispatch_emitted_messages(
"test-channel", 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, messages,
&message_tx,
&rate_limiter,
&last_broadcast_metadata,
None,
) )
.await; .await;
assert!(result.is_ok()); assert!(result.is_ok()); // safety: test-only assertion
let msg = rx.try_recv().expect("Should receive message"); let msg = rx.try_recv().expect("Should receive message"); // safety: test-only assertion
assert_eq!(msg.content, "Just text, no attachments"); assert_eq!(msg.content, "Just text, no attachments"); // safety: test-only assertion
assert!(msg.attachments.is_empty()); assert!(msg.attachments.is_empty()); // safety: test-only assertion
} }
#[test] #[test]
+5 -4
View File
@@ -405,10 +405,11 @@ fn check_routines_config() -> CheckResult {
fn check_gateway_config(settings: &Settings) -> CheckResult { fn check_gateway_config(settings: &Settings) -> CheckResult {
// Use the same resolve() path as runtime so invalid env values // Use the same resolve() path as runtime so invalid env values
// (e.g. GATEWAY_PORT=abc) are caught here too. // (e.g. GATEWAY_PORT=abc) are caught here too.
let tunnel_enabled = crate::config::TunnelConfig::resolve(settings) let owner_id = match crate::config::resolve_owner_id(settings) {
.map(|t| t.is_enabled()) Ok(owner_id) => owner_id,
.unwrap_or(false); Err(e) => return CheckResult::Fail(format!("config error: {e}")),
match crate::config::ChannelsConfig::resolve(settings, tunnel_enabled) { };
match crate::config::ChannelsConfig::resolve(settings, &owner_id) {
Ok(channels) => match channels.gateway { Ok(channels) => match channels.gateway {
Some(gw) => { Some(gw) => {
if gw.auth_token.is_some() { if gw.auth_token.is_some() {
+21 -7
View File
@@ -292,6 +292,16 @@ async fn list(
// ── Create ────────────────────────────────────────────────── // ── Create ──────────────────────────────────────────────────
fn cli_notify_config(notify_channel: Option<String>) -> NotifyConfig {
NotifyConfig {
channel: notify_channel,
user: None,
on_attention: true,
on_failure: true,
on_success: false,
}
}
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
async fn create( async fn create(
db: &Arc<dyn Database>, db: &Arc<dyn Database>,
@@ -338,13 +348,7 @@ async fn create(
max_concurrent: 1, max_concurrent: 1,
dedup_window: None, dedup_window: None,
}, },
notify: NotifyConfig { notify: cli_notify_config(notify_channel),
channel: notify_channel,
user: user_id.to_string(),
on_attention: true,
on_failure: true,
on_success: false,
},
last_run_at: None, last_run_at: None,
next_fire_at: next_fire, next_fire_at: next_fire,
run_count: 0, run_count: 0,
@@ -729,4 +733,14 @@ mod tests {
// Must be valid UTF-8 (would have panicked otherwise). // Must be valid UTF-8 (would have panicked otherwise).
assert!(result.is_char_boundary(result.len())); 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
}
} }
+55 -335
View File
@@ -91,36 +91,24 @@ pub struct SignalConfig {
} }
impl ChannelsConfig { impl ChannelsConfig {
/// Resolve channels config following `env > settings > default` for every field. pub(crate) fn resolve(settings: &Settings, owner_id: &str) -> Result<Self, ConfigError> {
pub(crate) fn resolve(settings: &Settings, tunnel_enabled: bool) -> Result<Self, ConfigError> {
let cs = &settings.channels; let cs = &settings.channels;
// --- HTTP webhook ---
// HTTP is enabled when env vars are set OR settings has it enabled.
let http_enabled_by_env = let http_enabled_by_env =
optional_env("HTTP_PORT")?.is_some() || optional_env("HTTP_HOST")?.is_some(); 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 { let http = if http_enabled_by_env || cs.http_enabled {
Some(HttpConfig { Some(HttpConfig {
host: optional_env("HTTP_HOST")? host: optional_env("HTTP_HOST")?
.or_else(|| cs.http_host.clone()) .or_else(|| cs.http_host.clone())
.unwrap_or_else(|| default_host.to_string()), .unwrap_or_else(|| "0.0.0.0".to_string()),
port: parse_optional_env("HTTP_PORT", cs.http_port.unwrap_or(8080))?, port: parse_optional_env("HTTP_PORT", cs.http_port.unwrap_or(8080))?,
webhook_secret: optional_env("HTTP_WEBHOOK_SECRET")?.map(SecretString::from), 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 { } else {
None None
}; };
// --- Web gateway ---
let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", cs.gateway_enabled)?; let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", cs.gateway_enabled)?;
let gateway = if gateway_enabled { let gateway = if gateway_enabled {
Some(GatewayConfig { Some(GatewayConfig {
@@ -133,33 +121,29 @@ impl ChannelsConfig {
)?, )?,
auth_token: optional_env("GATEWAY_AUTH_TOKEN")? auth_token: optional_env("GATEWAY_AUTH_TOKEN")?
.or_else(|| cs.gateway_auth_token.clone()), .or_else(|| cs.gateway_auth_token.clone()),
user_id: optional_env("GATEWAY_USER_ID")? user_id: owner_id.to_string(),
.or_else(|| cs.gateway_user_id.clone())
.unwrap_or_else(|| "default".to_string()),
}) })
} else { } else {
None None
}; };
// --- Signal ---
let signal_url = optional_env("SIGNAL_HTTP_URL")?.or_else(|| cs.signal_http_url.clone()); 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 signal = if let Some(http_url) = signal_url {
let account = optional_env("SIGNAL_ACCOUNT")? let account = optional_env("SIGNAL_ACCOUNT")?
.or_else(|| cs.signal_account.clone()) .or_else(|| cs.signal_account.clone())
.ok_or(ConfigError::InvalidValue { .ok_or(ConfigError::InvalidValue {
key: "SIGNAL_ACCOUNT".to_string(), key: "SIGNAL_ACCOUNT".to_string(),
message: "SIGNAL_ACCOUNT is required when Signal is enabled".to_string(), message: "SIGNAL_ACCOUNT is required when SIGNAL_HTTP_URL is set".to_string(),
})?; })?;
let allow_from_str = let allow_from =
optional_env("SIGNAL_ALLOW_FROM")?.or_else(|| cs.signal_allow_from.clone()); match optional_env("SIGNAL_ALLOW_FROM")?.or_else(|| cs.signal_allow_from.clone()) {
let allow_from = match allow_from_str { None => vec![account.clone()],
None => vec![account.clone()], Some(s) => s
Some(s) => s .split(',')
.split(',') .map(|e| e.trim().to_string())
.map(|e| e.trim().to_string()) .filter(|s| !s.is_empty())
.filter(|s| !s.is_empty()) .collect(),
.collect(), };
};
let dm_policy = optional_env("SIGNAL_DM_POLICY")? let dm_policy = optional_env("SIGNAL_DM_POLICY")?
.or_else(|| cs.signal_dm_policy.clone()) .or_else(|| cs.signal_dm_policy.clone())
.unwrap_or_else(|| "pairing".to_string()); .unwrap_or_else(|| "pairing".to_string());
@@ -201,18 +185,8 @@ impl ChannelsConfig {
None None
}; };
// --- CLI ---
let cli_enabled = parse_bool_env("CLI_ENABLED", cs.cli_enabled)?; 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)?;
Ok(Self { Ok(Self {
cli: CliConfig { cli: CliConfig {
enabled: cli_enabled, enabled: cli_enabled,
@@ -220,8 +194,14 @@ impl ChannelsConfig {
http, http,
gateway, gateway,
signal, signal,
wasm_channels_dir, wasm_channels_dir: optional_env("WASM_CHANNELS_DIR")?
wasm_channels_enabled, .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",
cs.wasm_channels_enabled,
)?,
wasm_channel_owner_ids: { wasm_channel_owner_ids: {
let mut ids = cs.wasm_channel_owner_ids.clone(); let mut ids = cs.wasm_channel_owner_ids.clone();
// Backwards compat: TELEGRAM_OWNER_ID env var // Backwards compat: TELEGRAM_OWNER_ID env var
@@ -252,6 +232,8 @@ fn default_channels_dir() -> PathBuf {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use crate::config::channels::*; use crate::config::channels::*;
use crate::config::helpers::ENV_MUTEX;
use crate::settings::Settings;
#[test] #[test]
fn cli_config_fields() { fn cli_config_fields() {
@@ -398,69 +380,6 @@ mod tests {
assert!(!cfg.wasm_channels_enabled); 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] #[test]
fn default_channels_dir_ends_with_channels() { fn default_channels_dir_ends_with_channels() {
let dir = default_channels_dir(); let dir = default_channels_dir();
@@ -471,242 +390,43 @@ mod tests {
} }
#[test] #[test]
fn default_gateway_port_constant() { fn resolve_uses_settings_channel_values_with_owner_scope_user_ids() {
assert_eq!(DEFAULT_GATEWAY_PORT, 3000); let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
} let mut settings = Settings::default();
/// 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_enabled = true;
settings.channels.http_port = Some(9090); settings.channels.http_host = Some("127.0.0.2".to_string());
settings.channels.http_host = Some("10.0.0.1".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, false).unwrap(); let cfg = ChannelsConfig::resolve(&settings, "owner-scope").expect("resolve");
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. let http = cfg.http.expect("http config");
#[test] assert_eq!(http.host, "127.0.0.2");
fn settings_gateway_fields_db_roundtrip() { assert_eq!(http.port, 8181);
let mut settings = crate::settings::Settings::default(); assert_eq!(http.user_id, "owner-scope");
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 gateway = cfg.gateway.expect("gateway config");
let restored = crate::settings::Settings::from_db_map(&map); 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!(restored.channels.gateway_port, Some(4000));
assert_eq!(restored.channels.gateway_host.as_deref(), Some("0.0.0.0"));
assert_eq!( assert_eq!(
restored.channels.gateway_auth_token.as_deref(), cfg.wasm_channels_dir,
Some("tok-abc") PathBuf::from("/tmp/settings-channels")
); );
assert_eq!(restored.channels.gateway_user_id.as_deref(), Some("myuser")); assert!(!cfg.wasm_channels_enabled);
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");
}
} }
} }
+46 -13
View File
@@ -26,7 +26,7 @@ mod tunnel;
mod wasm; mod wasm;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{LazyLock, Mutex}; use std::sync::{LazyLock, Mutex, Once};
use crate::error::ConfigError; use crate::error::ConfigError;
use crate::settings::Settings; use crate::settings::Settings;
@@ -74,10 +74,12 @@ pub use self::helpers::{env_or_override, set_runtime_env};
/// their data. Whichever runs first initialises the map; the second merges in. /// their data. Whichever runs first initialises the map; the second merges in.
static INJECTED_VARS: LazyLock<Mutex<HashMap<String, String>>> = static INJECTED_VARS: LazyLock<Mutex<HashMap<String, String>>> =
LazyLock::new(|| Mutex::new(HashMap::new())); LazyLock::new(|| Mutex::new(HashMap::new()));
static WARNED_EXPLICIT_DEFAULT_OWNER_ID: Once = Once::new();
/// Main configuration for the agent. /// Main configuration for the agent.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Config { pub struct Config {
pub owner_id: String,
pub database: DatabaseConfig, pub database: DatabaseConfig,
pub llm: LlmConfig, pub llm: LlmConfig,
pub embeddings: EmbeddingsConfig, pub embeddings: EmbeddingsConfig,
@@ -118,6 +120,7 @@ impl Config {
installed_skills_dir: std::path::PathBuf, installed_skills_dir: std::path::PathBuf,
) -> Self { ) -> Self {
Self { Self {
owner_id: "default".to_string(),
database: DatabaseConfig { database: DatabaseConfig {
backend: DatabaseBackend::LibSql, backend: DatabaseBackend::LibSql,
url: secrecy::SecretString::from("unused://test".to_string()), url: secrecy::SecretString::from("unused://test".to_string()),
@@ -228,13 +231,7 @@ impl Config {
pub async fn from_env_with_toml( pub async fn from_env_with_toml(
toml_path: Option<&std::path::Path>, toml_path: Option<&std::path::Path>,
) -> Result<Self, ConfigError> { ) -> Result<Self, ConfigError> {
let _ = dotenvy::dotenv(); let settings = load_bootstrap_settings(toml_path)?;
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)?;
Self::build(&settings).await Self::build(&settings).await
} }
@@ -306,16 +303,15 @@ impl Config {
/// Build config from settings (shared by from_env and from_db). /// Build config from settings (shared by from_env and from_db).
async fn build(settings: &Settings) -> Result<Self, ConfigError> { async fn build(settings: &Settings) -> Result<Self, ConfigError> {
// Resolve tunnel first so channels can default to loopback when a let owner_id = resolve_owner_id(settings)?;
// tunnel handles external exposure (no need to bind 0.0.0.0).
let tunnel = TunnelConfig::resolve(settings)?;
Ok(Self { Ok(Self {
owner_id: owner_id.clone(),
database: DatabaseConfig::resolve()?, database: DatabaseConfig::resolve()?,
llm: LlmConfig::resolve(settings)?, llm: LlmConfig::resolve(settings)?,
embeddings: EmbeddingsConfig::resolve(settings)?, embeddings: EmbeddingsConfig::resolve(settings)?,
channels: ChannelsConfig::resolve(settings, tunnel.is_enabled())?, tunnel: TunnelConfig::resolve(settings)?,
tunnel, channels: ChannelsConfig::resolve(settings, &owner_id)?,
agent: AgentConfig::resolve(settings)?, agent: AgentConfig::resolve(settings)?,
safety: resolve_safety_config(settings)?, safety: resolve_safety_config(settings)?,
wasm: WasmConfig::resolve(settings)?, wasm: WasmConfig::resolve(settings)?,
@@ -337,6 +333,43 @@ impl Config {
} }
} }
pub(crate) fn load_bootstrap_settings(
toml_path: Option<&std::path::Path>,
) -> Result<Settings, ConfigError> {
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<String, ConfigError> {
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. /// Load API keys from the encrypted secrets store into a thread-safe overlay.
/// ///
/// This bridges the gap between secrets stored during onboarding and the /// This bridges the gap between secrets stored during onboarding and the
+10
View File
@@ -121,6 +121,9 @@ pub struct JobContext {
pub state: JobState, pub state: JobState,
/// User ID that owns this job (for workspace scoping). /// User ID that owns this job (for workspace scoping).
pub user_id: String, 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<String>,
/// Conversation ID if linked to a conversation. /// Conversation ID if linked to a conversation.
pub conversation_id: Option<Uuid>, pub conversation_id: Option<Uuid>,
/// Job title. /// Job title.
@@ -202,6 +205,7 @@ impl JobContext {
job_id: Uuid::new_v4(), job_id: Uuid::new_v4(),
state: JobState::Pending, state: JobState::Pending,
user_id: user_id.into(), user_id: user_id.into(),
requester_id: None,
conversation_id: None, conversation_id: None,
title: title.into(), title: title.into(),
description: description.into(), description: description.into(),
@@ -233,6 +237,12 @@ impl JobContext {
self self
} }
/// Set the channel-specific requester/actor ID.
pub fn with_requester_id(mut self, requester_id: impl Into<String>) -> Self {
self.requester_id = Some(requester_id.into());
self
}
/// Transition to a new state. /// Transition to a new state.
pub fn transition_to( pub fn transition_to(
&mut self, &mut self,
+1
View File
@@ -106,6 +106,7 @@ impl JobStore for LibSqlBackend {
job_id: get_text(&row, 0).parse().unwrap_or_default(), job_id: get_text(&row, 0).parse().unwrap_or_default(),
state, state,
user_id: get_text(&row, 6), user_id: get_text(&row, 6),
requester_id: None,
conversation_id: get_opt_text(&row, 1).and_then(|s| s.parse().ok()), conversation_id: get_opt_text(&row, 1).and_then(|s| s.parse().ok()),
title: get_text(&row, 2), title: get_text(&row, 2),
description: get_text(&row, 3), description: get_text(&row, 3),
+23 -2
View File
@@ -247,6 +247,17 @@ pub(crate) fn opt_text_owned(s: Option<String>) -> libsql::Value {
} }
} }
pub(crate) fn normalize_notify_user(value: Option<String>) -> Option<String> {
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. /// Extract an i64 column, defaulting to 0.
pub(crate) fn get_i64(row: &libsql::Row, idx: i32) -> i64 { pub(crate) fn get_i64(row: &libsql::Row, idx: i32) -> i64 {
row.get::<i64>(idx).unwrap_or(0) row.get::<i64>(idx).unwrap_or(0)
@@ -378,7 +389,7 @@ pub(crate) fn row_to_routine_libsql(row: &libsql::Row) -> Result<Routine, Databa
}, },
notify: NotifyConfig { notify: NotifyConfig {
channel: get_opt_text(row, 12), channel: get_opt_text(row, 12),
user: get_text(row, 13), user: normalize_notify_user(get_opt_text(row, 13)),
on_success: get_i64(row, 14) != 0, on_success: get_i64(row, 14) != 0,
on_failure: get_i64(row, 15) != 0, on_failure: get_i64(row, 15) != 0,
on_attention: get_i64(row, 16) != 0, on_attention: get_i64(row, 16) != 0,
@@ -419,7 +430,17 @@ mod tests {
use chrono::{TimeZone, Utc}; use chrono::{TimeZone, Utc};
use crate::db::Database; use crate::db::Database;
use crate::db::libsql::{LibSqlBackend, parse_timestamp}; use crate::db::libsql::{LibSqlBackend, normalize_notify_user, parse_timestamp};
#[test]
fn test_normalize_notify_user_treats_legacy_default_as_missing() {
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
let normalized = normalize_notify_user(Some("123456789".to_string()));
assert_eq!(normalized, Some("123456789".to_string())); // safety: test-only assertion
}
#[test] #[test]
fn test_parse_timestamp_accepts_rfc3339_and_legacy_naive_formats() { fn test_parse_timestamp_accepts_rfc3339_and_legacy_naive_formats() {
+2 -2
View File
@@ -57,7 +57,7 @@ impl RoutineStore for LibSqlBackend {
max_concurrent, max_concurrent,
dedup_window_secs, dedup_window_secs,
opt_text(routine.notify.channel.as_deref()), opt_text(routine.notify.channel.as_deref()),
routine.notify.user.as_str(), opt_text(routine.notify.user.as_deref()),
routine.notify.on_success as i64, routine.notify.on_success as i64,
routine.notify.on_failure as i64, routine.notify.on_failure as i64,
routine.notify.on_attention as i64, routine.notify.on_attention as i64,
@@ -250,7 +250,7 @@ impl RoutineStore for LibSqlBackend {
max_concurrent, max_concurrent,
dedup_window_secs, dedup_window_secs,
opt_text(routine.notify.channel.as_deref()), opt_text(routine.notify.channel.as_deref()),
routine.notify.user.as_str(), opt_text(routine.notify.user.as_deref()),
routine.notify.on_success as i64, routine.notify.on_success as i64,
routine.notify.on_failure as i64, routine.notify.on_failure as i64,
routine.notify.on_attention as i64, routine.notify.on_attention as i64,
+72 -2
View File
@@ -462,7 +462,7 @@ CREATE TABLE IF NOT EXISTS routines (
max_concurrent INTEGER NOT NULL DEFAULT 1, max_concurrent INTEGER NOT NULL DEFAULT 1,
dedup_window_secs INTEGER, dedup_window_secs INTEGER,
notify_channel TEXT, notify_channel TEXT,
notify_user TEXT NOT NULL DEFAULT 'default', notify_user TEXT,
notify_on_success INTEGER NOT NULL DEFAULT 0, notify_on_success INTEGER NOT NULL DEFAULT 0,
notify_on_failure INTEGER NOT NULL DEFAULT 1, notify_on_failure INTEGER NOT NULL DEFAULT 1,
notify_on_attention INTEGER NOT NULL DEFAULT 1, notify_on_attention INTEGER NOT NULL DEFAULT 1,
@@ -546,7 +546,9 @@ CREATE INDEX IF NOT EXISTS idx_tool_failures_unrepaired ON tool_failures(tool_na
-- routines -- routines
CREATE INDEX IF NOT EXISTS idx_routines_next_fire ON routines(next_fire_at); 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 -- routine_runs
CREATE INDEX IF NOT EXISTS idx_routine_runs_status ON routine_runs(status); CREATE INDEX IF NOT EXISTS idx_routine_runs_status ON routine_runs(status);
@@ -654,6 +656,74 @@ END;
r#" r#"
ALTER TABLE agent_jobs ADD COLUMN max_tokens INTEGER NOT NULL DEFAULT 0; ALTER TABLE agent_jobs ADD COLUMN max_tokens INTEGER NOT NULL DEFAULT 0;
ALTER TABLE agent_jobs ADD COLUMN total_tokens_used INTEGER NOT NULL DEFAULT 0; ALTER TABLE agent_jobs ADD COLUMN total_tokens_used INTEGER NOT NULL DEFAULT 0;
"#,
),
(
13,
"routine_notify_user_nullable",
// Remove the legacy 'default' sentinel from routine notify_user.
// SQLite cannot drop NOT NULL / DEFAULT constraints in place, so we
// rebuild the table and normalize existing 'default' values to NULL.
r#"
PRAGMA foreign_keys=OFF;
CREATE TABLE IF NOT EXISTS routines_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
user_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
trigger_type TEXT NOT NULL,
trigger_config TEXT NOT NULL,
action_type TEXT NOT NULL,
action_config TEXT NOT NULL,
cooldown_secs INTEGER NOT NULL DEFAULT 300,
max_concurrent INTEGER NOT NULL DEFAULT 1,
dedup_window_secs INTEGER,
notify_channel TEXT,
notify_user TEXT,
notify_on_success INTEGER NOT NULL DEFAULT 0,
notify_on_failure INTEGER NOT NULL DEFAULT 1,
notify_on_attention INTEGER NOT NULL DEFAULT 1,
state TEXT NOT NULL DEFAULT '{}',
last_run_at TEXT,
next_fire_at TEXT,
run_count INTEGER NOT NULL DEFAULT 0,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE (user_id, name)
);
INSERT INTO routines_new (
id, name, description, user_id, enabled,
trigger_type, trigger_config, action_type, action_config,
cooldown_secs, max_concurrent, dedup_window_secs,
notify_channel, notify_user, notify_on_success, notify_on_failure, notify_on_attention,
state, last_run_at, next_fire_at, run_count, consecutive_failures,
created_at, updated_at
)
SELECT
id, name, description, user_id, enabled,
trigger_type, trigger_config, action_type, action_config,
cooldown_secs, max_concurrent, dedup_window_secs,
notify_channel,
CASE WHEN notify_user = 'default' THEN NULL ELSE notify_user END,
notify_on_success, notify_on_failure, notify_on_attention,
state, last_run_at, next_fire_at, run_count, consecutive_failures,
created_at, updated_at
FROM routines;
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(trigger_type, user_id)
WHERE enabled = 1 AND trigger_type IN ('event', 'system_event');
PRAGMA foreign_keys=ON;
"#, "#,
), ),
]; ];
+3
View File
@@ -122,6 +122,9 @@ pub enum ChannelError {
#[error("Failed to send response on channel {name}: {reason}")] #[error("Failed to send response on channel {name}: {reason}")]
SendFailed { name: String, reason: String }, SendFailed { name: String, reason: String },
#[error("Channel {name} is missing a routing target: {reason}")]
MissingRoutingTarget { name: String, reason: String },
#[error("Invalid message format: {0}")] #[error("Invalid message format: {0}")]
InvalidMessage(String), InvalidMessage(String),
+5 -1
View File
@@ -3419,6 +3419,7 @@ impl ExtensionManager {
Arc::clone(&channel_runtime), Arc::clone(&channel_runtime),
Arc::clone(&pairing_store), Arc::clone(&pairing_store),
settings_store, settings_store,
self.user_id.clone(),
) )
.with_secrets_store(Arc::clone(&self.secrets)); .with_secrets_store(Arc::clone(&self.secrets));
loader loader
@@ -3435,6 +3436,7 @@ impl ExtensionManager {
Arc::clone(&channel_runtime), Arc::clone(&channel_runtime),
Arc::clone(&pairing_store), Arc::clone(&pairing_store),
settings_store, settings_store,
self.user_id.clone(),
) )
.with_secrets_store(Arc::clone(&self.secrets)); .with_secrets_store(Arc::clone(&self.secrets));
loader loader
@@ -3462,6 +3464,7 @@ impl ExtensionManager {
owner_id: Option<i64>, owner_id: Option<i64>,
) -> Result<ActivateResult, ExtensionError> { ) -> Result<ActivateResult, ExtensionError> {
let channel_name = loaded.name().to_string(); let channel_name = loaded.name().to_string();
let owner_actor_id = owner_id.map(|id| id.to_string());
let webhook_secret_name = loaded.webhook_secret_name(); let webhook_secret_name = loaded.webhook_secret_name();
let secret_header = loaded.webhook_secret_header().map(|s| s.to_string()); let secret_header = loaded.webhook_secret_header().map(|s| s.to_string());
let sig_key_secret_name = loaded.signature_key_secret_name(); let sig_key_secret_name = loaded.signature_key_secret_name();
@@ -3475,7 +3478,7 @@ impl ExtensionManager {
.ok() .ok()
.map(|s| s.expose().to_string()); .map(|s| s.expose().to_string());
let channel_arc = Arc::new(loaded.channel); let channel_arc = Arc::new(loaded.channel.with_owner_actor_id(owner_actor_id));
// Inject runtime config (tunnel_url, webhook_secret, owner_id) // Inject runtime config (tunnel_url, webhook_secret, owner_id)
{ {
@@ -5615,6 +5618,7 @@ mod tests {
runtime, runtime,
prepared, prepared,
capabilities, capabilities,
"default",
"{}".to_string(), "{}".to_string(),
pairing_store, pairing_store,
None, None,
+1
View File
@@ -227,6 +227,7 @@ impl Store {
job_id: row.get("id"), job_id: row.get("id"),
state, state,
user_id: row.get::<_, String>("user_id"), user_id: row.get::<_, String>("user_id"),
requester_id: None,
conversation_id: row.get("conversation_id"), conversation_id: row.get("conversation_id"),
title: row.get("title"), title: row.get("title"),
description: row.get("description"), description: row.get("description"),
+19 -15
View File
@@ -153,7 +153,8 @@ async fn async_main() -> anyhow::Result<()> {
provider_only: *provider_only, provider_only: *provider_only,
quick: *quick, 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?; wizard.run().await?;
} }
#[cfg(not(any(feature = "postgres", feature = "libsql")))] #[cfg(not(any(feature = "postgres", feature = "libsql")))]
@@ -195,10 +196,13 @@ async fn async_main() -> anyhow::Result<()> {
{ {
println!("Onboarding needed: {}", reason); println!("Onboarding needed: {}", reason);
println!(); println!();
let mut wizard = SetupWizard::with_config(SetupConfig { let mut wizard = SetupWizard::try_with_config_and_toml(
quick: true, SetupConfig {
..Default::default() quick: true,
}); ..Default::default()
},
cli.config.as_deref(),
)?;
wizard.run().await?; wizard.run().await?;
} }
@@ -282,9 +286,12 @@ async fn async_main() -> anyhow::Result<()> {
// Create CLI channel // Create CLI channel
let repl_channel = if let Some(ref msg) = cli.message { 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 { } else if config.channels.cli.enabled {
let repl = ReplChannel::new(); let repl = ReplChannel::with_user_id(config.owner_id.clone());
repl.suppress_banner(); repl.suppress_banner();
Some(repl) Some(repl)
} else { } else {
@@ -311,12 +318,7 @@ async fn async_main() -> anyhow::Result<()> {
webhook_routes.push(webhooks::routes(ToolWebhookState { webhook_routes.push(webhooks::routes(ToolWebhookState {
tools: Arc::clone(&components.tools), tools: Arc::clone(&components.tools),
routine_engine: Arc::clone(&shared_routine_engine_slot), routine_engine: Arc::clone(&shared_routine_engine_slot),
user_id: config user_id: config.owner_id.clone(),
.channels
.gateway
.as_ref()
.map(|g| g.user_id.clone())
.unwrap_or_else(|| "default".to_string()),
secrets_store: components.secrets_store.clone(), secrets_store: components.secrets_store.clone(),
})); }));
@@ -703,6 +705,7 @@ async fn async_main() -> anyhow::Result<()> {
.map(|db| Arc::clone(db) as Arc<dyn ironclaw::db::SettingsStore>); .map(|db| Arc::clone(db) as Arc<dyn ironclaw::db::SettingsStore>);
let deps = AgentDeps { let deps = AgentDeps {
owner_id: config.owner_id.clone(),
store: components.db, store: components.db,
llm: components.llm, llm: components.llm,
cheap_llm: components.cheap_llm, cheap_llm: components.cheap_llm,
@@ -775,6 +778,7 @@ async fn async_main() -> anyhow::Result<()> {
let sighup_webhook_server = webhook_server.clone(); let sighup_webhook_server = webhook_server.clone();
let sighup_settings_store_clone = sighup_settings_store.clone(); let sighup_settings_store_clone = sighup_settings_store.clone();
let sighup_secrets_store = components.secrets_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(); let mut shutdown_rx = shutdown_tx.subscribe();
tokio::spawn(async move { tokio::spawn(async move {
@@ -805,7 +809,7 @@ async fn async_main() -> anyhow::Result<()> {
if let Some(ref secrets_store) = sighup_secrets_store { if let Some(ref secrets_store) = sighup_secrets_store {
// Inject HTTP webhook secret from encrypted store // Inject HTTP webhook secret from encrypted store
if let Ok(webhook_secret) = secrets_store if let Ok(webhook_secret) = secrets_store
.get_decrypted("default", "http_webhook_secret") .get_decrypted(&sighup_owner_id, "http_webhook_secret")
.await .await
{ {
// Thread-safe: Uses INJECTED_VARS mutex instead of unsafe std::env::set_var // Thread-safe: Uses INJECTED_VARS mutex instead of unsafe std::env::set_var
@@ -821,7 +825,7 @@ async fn async_main() -> anyhow::Result<()> {
// Reload config (now with secrets injected into environment) // Reload config (now with secrets injected into environment)
let new_config = match &sighup_settings_store_clone { let new_config = match &sighup_settings_store_clone {
Some(store) => { 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, None => ironclaw::config::Config::from_env().await,
}; };
+13
View File
@@ -16,6 +16,14 @@ pub struct Settings {
#[serde(default, alias = "setup_completed")] #[serde(default, alias = "setup_completed")]
pub onboard_completed: bool, 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<String>,
// === Step 1: Database === // === Step 1: Database ===
/// Database backend: "postgres" or "libsql". /// Database backend: "postgres" or "libsql".
#[serde(default)] #[serde(default)]
@@ -733,6 +741,10 @@ impl Settings {
let mut settings = Self::default(); let mut settings = Self::default();
for (key, value) in map { for (key, value) in map {
if key == "owner_id" {
continue;
}
// Convert the JSONB value to a string for the existing set() method // Convert the JSONB value to a string for the existing set() method
let value_str = match value { let value_str = match value {
serde_json::Value::String(s) => s.clone(), serde_json::Value::String(s) => s.clone(),
@@ -772,6 +784,7 @@ impl Settings {
let mut map = std::collections::HashMap::new(); let mut map = std::collections::HashMap::new();
collect_settings_json(&json, String::new(), &mut map); collect_settings_json(&json, String::new(), &mut map);
map.remove("owner_id");
map map
} }
+708 -268
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -439,6 +439,7 @@ impl TestHarnessBuilder {
}; };
let deps = AgentDeps { let deps = AgentDeps {
owner_id: "default".to_string(),
store: Some(Arc::clone(&db)), store: Some(Arc::clone(&db)),
llm, llm,
cheap_llm: None, cheap_llm: None,
@@ -1077,7 +1078,7 @@ mod tests {
}, },
notify: NotifyConfig { notify: NotifyConfig {
channel: None, channel: None,
user: "user1".to_string(), user: Some("user1".to_string()),
on_attention: true, on_attention: true,
on_failure: true, on_failure: true,
on_success: false, on_success: false,
@@ -1210,7 +1211,7 @@ mod tests {
}, },
notify: NotifyConfig { notify: NotifyConfig {
channel: None, channel: None,
user: "user1".to_string(), user: Some("user1".to_string()),
on_attention: false, on_attention: false,
on_failure: false, on_failure: false,
on_success: false, on_success: false,
+37 -5
View File
@@ -129,21 +129,28 @@ impl Tool for MessageTool {
.map(|c| c.to_string()) .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()) { 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 } else if let Some(t) = self
.default_target .default_target
.read() .read()
.unwrap_or_else(|e| e.into_inner()) .unwrap_or_else(|e| e.into_inner())
.clone() .clone()
{ {
t Some(t)
} else if let Some(t) = ctx.metadata.get("notify_user").and_then(|v| v.as_str()) { } 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 { } else {
None
};
let Some(target) = target else {
return Err(ToolError::ExecutionFailed( 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(), .to_string(),
)); ));
}; };
@@ -659,6 +666,31 @@ 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()); // safety: test-only assertion
let err = result.unwrap_err().to_string();
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] #[tokio::test]
async fn message_tool_no_metadata_still_errors() { async fn message_tool_no_metadata_still_errors() {
// When neither conversation context nor metadata is set, should still // When neither conversation context nor metadata is set, should still
+2 -3
View File
@@ -106,7 +106,7 @@ pub(crate) fn routine_create_parameters_schema() -> serde_json::Value {
}, },
"notify_user": { "notify_user": {
"type": "string", "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": { "timezone": {
"type": "string", "type": "string",
@@ -387,8 +387,7 @@ impl Tool for RoutineCreateTool {
user: params user: params
.get("notify_user") .get("notify_user")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.unwrap_or("default") .map(String::from),
.to_string(),
..NotifyConfig::default() ..NotifyConfig::default()
}, },
last_run_at: None, last_run_at: None,
+188 -8
View File
@@ -841,13 +841,7 @@ impl Tool for WasmToolWrapper {
// Pre-resolve host credentials from secrets store (async, before blocking task). // Pre-resolve host credentials from secrets store (async, before blocking task).
// This decrypts the secrets once so the sync http_request() host function // This decrypts the secrets once so the sync http_request() host function
// can inject them without needing async access. // can inject them without needing async access.
// let credential_user_id = &ctx.user_id;
// 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 host_credentials = resolve_host_credentials( let host_credentials = resolve_host_credentials(
&self.capabilities, &self.capabilities,
self.secrets_store.as_deref(), self.secrets_store.as_deref(),
@@ -1165,6 +1159,13 @@ async fn resolve_host_credentials(
let secret = match store.get_decrypted(user_id, &mapping.secret_name).await { let secret = match store.get_decrypted(user_id, &mapping.secret_name).await {
Ok(s) => Some(s), Ok(s) => Some(s),
Err(e) => { 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 lookup fails and we're not already looking up "default", try "default" as fallback
if user_id != "default" { if user_id != "default" {
tracing::debug!( tracing::debug!(
@@ -1385,7 +1386,16 @@ fn build_tool_usage_hint(tool_name: &str, schema: &serde_json::Value) -> String
#[cfg(test)] #[cfg(test)]
mod tests { 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::{ use crate::testing::credentials::{
TEST_BEARER_TOKEN_123, TEST_GOOGLE_OAUTH_FRESH, TEST_GOOGLE_OAUTH_LEGACY, TEST_BEARER_TOKEN_123, TEST_GOOGLE_OAUTH_FRESH, TEST_GOOGLE_OAUTH_LEGACY,
@@ -1396,6 +1406,78 @@ mod tests {
use crate::tools::wasm::capabilities::Capabilities; use crate::tools::wasm::capabilities::Capabilities;
use crate::tools::wasm::runtime::{WasmRuntimeConfig, WasmToolRuntime}; use crate::tools::wasm::runtime::{WasmRuntimeConfig, WasmToolRuntime};
struct RecordingSecretsStore {
inner: InMemorySecretsStore,
get_decrypted_lookups: Mutex<Vec<(String, String)>>,
}
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<Secret, SecretError> {
self.inner.create(user_id, params).await
}
async fn get(&self, user_id: &str, name: &str) -> Result<Secret, SecretError> {
self.inner.get(user_id, name).await
}
async fn get_decrypted(
&self,
user_id: &str,
name: &str,
) -> Result<DecryptedSecret, SecretError> {
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<bool, SecretError> {
self.inner.exists(user_id, name).await
}
async fn list(&self, user_id: &str) -> Result<Vec<SecretRef>, SecretError> {
self.inner.list(user_id).await
}
async fn delete(&self, user_id: &str, name: &str) -> Result<bool, SecretError> {
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<bool, SecretError> {
self.inner
.is_accessible(user_id, secret_name, allowed_secrets)
.await
}
}
#[test] #[test]
fn test_wrapper_creation() { fn test_wrapper_creation() {
// This test verifies the runtime can be created // This test verifies the runtime can be created
@@ -1691,6 +1773,104 @@ mod tests {
); );
} }
#[tokio::test]
async fn test_resolve_host_credentials_owner_scope_bearer() {
use std::collections::HashMap;
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_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] #[tokio::test]
async fn test_resolve_host_credentials_missing_secret() { async fn test_resolve_host_credentials_missing_secret() {
use std::collections::HashMap; use std::collections::HashMap;
+61 -4
View File
@@ -15,7 +15,13 @@ from pathlib import Path
import pytest 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/) # Project root (two levels up from tests/e2e/)
ROOT = Path(__file__).resolve().parent.parent.parent ROOT = Path(__file__).resolve().parent.parent.parent
@@ -92,6 +98,21 @@ def _find_free_port() -> int:
return s.getsockname()[1] 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") @pytest.fixture(scope="session")
def ironclaw_binary(): def ironclaw_binary():
"""Ensure ironclaw binary is built. Returns the binary path.""" """Ensure ironclaw binary is built. Returns the binary path."""
@@ -108,6 +129,21 @@ def ironclaw_binary():
return str(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") @pytest.fixture(scope="session")
async def mock_llm_server(): async def mock_llm_server():
"""Start the mock LLM server. Yields the base URL.""" """Start the mock LLM server. Yields the base URL."""
@@ -177,10 +213,19 @@ def _wasm_build_symlinks():
@pytest.fixture(scope="session") @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.""" """Start the ironclaw gateway. Yields the base URL."""
gateway_port = _find_free_port()
home_dir = _HOME_TMPDIR.name 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 = { env = {
# Minimal env: PATH for process spawning, HOME for Rust/cargo defaults # Minimal env: PATH for process spawning, HOME for Rust/cargo defaults
"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "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"), "IRONCLAW_BASE_DIR": os.path.join(home_dir, ".ironclaw"),
"RUST_LOG": "ironclaw=info", "RUST_LOG": "ironclaw=info",
"RUST_BACKTRACE": "1", "RUST_BACKTRACE": "1",
"IRONCLAW_OWNER_ID": OWNER_SCOPE_ID,
"GATEWAY_ENABLED": "true", "GATEWAY_ENABLED": "true",
"GATEWAY_HOST": "127.0.0.1", "GATEWAY_HOST": "127.0.0.1",
"GATEWAY_PORT": str(gateway_port), "GATEWAY_PORT": str(gateway_port),
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN, "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", "CLI_ENABLED": "false",
"LLM_BACKEND": "openai_compatible", "LLM_BACKEND": "openai_compatible",
"LLM_BASE_URL": mock_llm_server, "LLM_BASE_URL": mock_llm_server,
@@ -261,6 +310,14 @@ async def ironclaw_server(ironclaw_binary, mock_llm_server, wasm_tools_dir):
proc.kill() 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") @pytest.fixture(scope="session")
async def ironclaw_server_with_webhook_secret(ironclaw_binary, mock_llm_server, wasm_tools_dir): 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. """Start ironclaw with HTTP_WEBHOOK_SECRET configured for webhook tests.
+24
View File
@@ -1,6 +1,8 @@
"""Shared helpers for E2E tests.""" """Shared helpers for E2E tests."""
import asyncio import asyncio
import hashlib
import hmac
import re import re
import time import time
@@ -95,12 +97,21 @@ SEL = {
"toast_success": ".toast.toast-success", "toast_success": ".toast.toast-success",
"toast_error": ".toast.toast-error", "toast_error": ".toast.toast-error",
"toast_info": ".toast.toast-info", "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"] TABS = ["chat", "memory", "jobs", "routines", "extensions", "skills"]
# Auth token used across all tests # Auth token used across all tests
AUTH_TOKEN = "e2e-test-token" 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): 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), timeout=kwargs.pop("timeout", 10),
**kwargs, **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-Hub-Signature-256": f"sha256={digest}",
}
+34
View File
@@ -26,6 +26,40 @@ DEFAULT_RESPONSE = "I understand your request."
TOOL_CALL_PATTERNS = [ TOOL_CALL_PATTERNS = [
(re.compile(r"echo (.+)", re.IGNORECASE), "echo", lambda m: {"message": m.group(1)}), (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"what time|current time", re.IGNORECASE), "time", lambda _: {"operation": "now"}),
(
re.compile(
r"create lightweight owner routine (?P<name>[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<name>[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 _: {},
),
] ]
+226
View File
@@ -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,
)
+1 -1
View File
@@ -155,7 +155,7 @@ mod tests {
} }
assert_eq!(routine.notify.channel.as_deref(), Some("telegram")); 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); assert_eq!(routine.guardrails.cooldown.as_secs(), 600);
rig.shutdown(); rig.shutdown();
+131 -4
View File
@@ -48,6 +48,19 @@ mod tests {
Arc::new(Workspace::new_with_db("default", db.clone())) 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. /// Helper to insert a routine directly into the database.
fn make_routine(name: &str, trigger: Trigger, prompt: &str) -> Routine { fn make_routine(name: &str, trigger: Trigger, prompt: &str) -> Routine {
Routine { Routine {
@@ -218,7 +231,13 @@ mod tests {
engine.refresh_event_cache().await; engine.refresh_event_cache().await;
// Positive match: message containing "deploy to production". // Positive match: message containing "deploy to production".
let matching_msg = IncomingMessage::new("test", "default", "deploy to production now"); let matching_msg = make_message(
"test",
"default",
"default",
"default",
"deploy to production now",
);
let fired = engine.check_event_triggers(&matching_msg).await; let fired = engine.check_event_triggers(&matching_msg).await;
assert!( assert!(
fired >= 1, fired >= 1,
@@ -229,12 +248,114 @@ mod tests {
tokio::time::sleep(Duration::from_millis(500)).await; tokio::time::sleep(Duration::from_millis(500)).await;
// Negative match: message that doesn't match. // Negative match: message that doesn't match.
let non_matching_msg = let non_matching_msg = make_message(
IncomingMessage::new("test", "default", "check the staging environment"); "test",
"default",
"default",
"default",
"check the staging environment",
);
let fired_neg = engine.check_event_triggers(&non_matching_msg).await; let fired_neg = engine.check_event_triggers(&non_matching_msg).await;
assert_eq!(fired_neg, 0, "Expected 0 routines fired on non-match"); 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 = 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,
"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 = 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,
"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 // Test 3: system_event_trigger_matches_and_filters
// ----------------------------------------------------------------------- // -----------------------------------------------------------------------
@@ -434,7 +555,13 @@ mod tests {
engine.refresh_event_cache().await; engine.refresh_event_cache().await;
// First fire should work. // First fire should work.
let msg = IncomingMessage::new("test", "default", "test-cooldown trigger"); let msg = make_message(
"test",
"default",
"default",
"default",
"test-cooldown trigger",
);
let fired1 = engine.check_event_triggers(&msg).await; let fired1 = engine.check_event_triggers(&msg).await;
assert!(fired1 >= 1, "First fire should work"); assert!(fired1 >= 1, "First fire should work");
@@ -239,6 +239,7 @@ impl GatewayWorkflowHarness {
let mut agent = Agent::new( let mut agent = Agent::new(
components.config.agent.clone(), components.config.agent.clone(),
AgentDeps { AgentDeps {
owner_id: components.config.owner_id.clone(),
store: components.db, store: components.db,
llm: components.llm, llm: components.llm,
cheap_llm: components.cheap_llm, cheap_llm: components.cheap_llm,
+1
View File
@@ -612,6 +612,7 @@ impl TestRigBuilder {
// 7. Construct AgentDeps from AppComponents (mirrors main.rs). // 7. Construct AgentDeps from AppComponents (mirrors main.rs).
let deps = AgentDeps { let deps = AgentDeps {
owner_id: components.config.owner_id.clone(),
store: components.db, store: components.db,
llm: components.llm, llm: components.llm,
cheap_llm: components.cheap_llm, cheap_llm: components.cheap_llm,
+85 -18
View File
@@ -6,17 +6,21 @@
//! 1. When owner_id is null and dm_policy is "allowlist", unauthorized users in //! 1. When owner_id is null and dm_policy is "allowlist", unauthorized users in
//! group chats are dropped even if they @mention the bot //! 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 //! 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 //! 4. Authorization works correctly for both private and group chats
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use futures::StreamExt;
use ironclaw::channels::Channel;
use ironclaw::channels::wasm::{ use ironclaw::channels::wasm::{
ChannelCapabilities, PreparedChannelModule, WasmChannel, WasmChannelRuntime, ChannelCapabilities, PreparedChannelModule, WasmChannel, WasmChannelRuntime,
WasmChannelRuntimeConfig, WasmChannelRuntimeConfig,
}; };
use ironclaw::pairing::PairingStore; use ironclaw::pairing::PairingStore;
use tokio::time::{Duration, timeout};
/// Skip the test if the Telegram WASM module hasn't been built. /// 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 /// In CI (detected via the `CI` env var), panic instead of skipping so a
@@ -97,6 +101,14 @@ async fn load_telegram_module(
async fn create_telegram_channel( async fn create_telegram_channel(
runtime: Arc<WasmChannelRuntime>, runtime: Arc<WasmChannelRuntime>,
config_json: &str, 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<WasmChannelRuntime>,
config_json: &str,
pairing_store: Arc<PairingStore>,
) -> WasmChannel { ) -> WasmChannel {
let module = load_telegram_module(&runtime) let module = load_telegram_module(&runtime)
.await .await
@@ -106,8 +118,9 @@ async fn create_telegram_channel(
runtime, runtime,
module, module,
ChannelCapabilities::for_channel("telegram").with_path("/webhook/telegram"), ChannelCapabilities::for_channel("telegram").with_path("/webhook/telegram"),
"default",
config_json.to_string(), config_json.to_string(),
Arc::new(PairingStore::new()), pairing_store,
None, None,
) )
} }
@@ -245,31 +258,29 @@ async fn test_group_message_authorized_user_allowed() {
} }
#[tokio::test] #[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!(); require_telegram_wasm!();
let runtime = create_test_runtime(); 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!({ let config = serde_json::json!({
"bot_username": "test_bot", "bot_username": null,
"owner_id": 123, "owner_id": 123,
"dm_policy": "allowlist", "dm_policy": "pairing",
"allow_from": ["anyone"], // ignored when owner_id is set "allow_from": [],
"respond_to_all_group_messages": false "respond_to_all_group_messages": false
}) })
.to_string(); .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 (should be dropped) // Non-owner private message should produce a pairing request.
let update = build_telegram_update( let update = build_telegram_update(
3, 3, 102, 999, "private", 999, // Not the owner
102, "Other", "hello",
-123456789,
"group",
999, // Not the owner
"Other",
"Hey @test_bot hello",
); );
let response = channel let response = channel
@@ -286,8 +297,64 @@ async fn test_group_message_with_owner_id_set() {
assert_eq!(response.status, 200); assert_eq!(response.status, 200);
// REGRESSION TEST: Non-owner messages are dropped when owner_id is set let pending = pairing_store
// This behavior is consistent and not affected by the fix .list_pending("telegram")
.expect("pairing store should be readable");
assert_eq!(pending.len(), 1);
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] #[tokio::test]
+1
View File
@@ -43,6 +43,7 @@ fn create_test_channel(
runtime, runtime,
prepared, prepared,
capabilities, capabilities,
"default",
"{}".to_string(), "{}".to_string(),
Arc::new(PairingStore::new()), Arc::new(PairingStore::new()),
None, None,