mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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:
+4
-4
@@ -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
@@ -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';
|
||||||
@@ -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
@@ -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, ¬ify_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(¬ify_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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
"#,
|
"#,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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}",
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 _: {},
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user