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

* refactor: add explicit owner scope across channels

* fix: tighten routine owner target routing

* fix: address owner scope review feedback

* Fix owner-scope onboarding and event trigger isolation

* Tighten routing fallback and wizard owner validation

* fix: address owner-scope follow-up review

* fix: tighten owner-scope follow-up details

* fix: import Channel trait in telegram test

* fix: normalize http webhook sender ids

* fix: address remaining owner-scope review issues

* fix: reconcile config rebase fallout

* fix: reconcile extension manager rebase drift

* fix: address current copilot review regressions

* fix: restore clippy matrix after rebase
This commit is contained in:
Henry Park
2026-03-16 13:31:03 -07:00
committed by GitHub
parent 971b4c2ef4
commit 878a67cdb6
50 changed files with 2767 additions and 1071 deletions
+132 -28
View File
@@ -22,7 +22,7 @@ use crate::channels::{ChannelManager, IncomingMessage, OutgoingResponse};
use crate::config::{AgentConfig, HeartbeatConfig, RoutineConfig, SkillsConfig};
use crate::context::ContextManager;
use crate::db::Database;
use crate::error::Error;
use crate::error::{ChannelError, Error};
use crate::extensions::ExtensionManager;
use crate::hooks::HookRegistry;
use crate::llm::LlmProvider;
@@ -54,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.
///
/// Bundles the shared components to reduce argument count.
pub struct AgentDeps {
/// Resolved durable owner scope for the instance.
pub owner_id: String,
pub store: Option<Arc<dyn Database>>,
pub llm: Arc<dyn LlmProvider>,
/// Cheap/fast LLM for lightweight tasks (heartbeat, routing, evaluation).
@@ -102,6 +118,18 @@ pub struct 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.
///
/// 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_channels = self.channels.clone();
let repair_owner_id = self.owner_id().to_string();
let repair_handle = tokio::spawn(async move {
loop {
tokio::time::sleep(repair_interval).await;
@@ -311,7 +340,9 @@ impl Agent {
if let Some(msg) = notification {
let response = OutgoingResponse::text(format!("Self-Repair: {}", msg));
let _ = repair_channels.broadcast_all("default", response).await;
let _ = repair_channels
.broadcast_all(&repair_owner_id, response)
.await;
}
}
@@ -325,7 +356,9 @@ impl Agent {
"Self-Repair: Tool '{}' repaired: {}",
tool.name, message
));
let _ = repair_channels.broadcast_all("default", response).await;
let _ = repair_channels
.broadcast_all(&repair_owner_id, response)
.await;
}
Ok(result) => {
tracing::info!("Tool repair result: {:?}", result);
@@ -362,9 +395,11 @@ impl Agent {
.timezone
.clone()
.or_else(|| Some(self.config.default_timezone.clone()));
if let (Some(user), Some(channel)) =
(&hb_config.notify_user, &hb_config.notify_channel)
{
if let Some(channel) = &hb_config.notify_channel {
let user = hb_config
.notify_user
.clone()
.unwrap_or_else(|| self.owner_id().to_string());
config = config.with_notify(user, channel);
}
@@ -374,17 +409,18 @@ impl Agent {
// Spawn notification forwarder that routes through channel manager
let notify_channel = hb_config.notify_channel.clone();
let notify_user = hb_config.notify_user.clone();
let notify_user = hb_config
.notify_user
.clone()
.unwrap_or_else(|| self.owner_id().to_string());
let channels = self.channels.clone();
tokio::spawn(async move {
while let Some(response) = notify_rx.recv().await {
let user = notify_user.as_deref().unwrap_or("default");
// Try the configured channel first, fall back to
// broadcasting on all channels.
let targeted_ok = if let Some(ref channel) = notify_channel {
channels
.broadcast(channel, user, response.clone())
.broadcast(channel, &notify_user, response.clone())
.await
.is_ok()
} else {
@@ -392,7 +428,7 @@ impl Agent {
};
if !targeted_ok {
let results = channels.broadcast_all(user, response).await;
let results = channels.broadcast_all(&notify_user, response).await;
for (ch, result) in results {
if let Err(e) = result {
tracing::warn!(
@@ -462,25 +498,41 @@ impl Agent {
let channels = self.channels.clone();
tokio::spawn(async move {
while let Some(response) = notify_rx.recv().await {
let user = response
.metadata
.get("notify_user")
.and_then(|v| v.as_str())
.unwrap_or("default")
.to_string();
let notify_channel = response
.metadata
.get("notify_channel")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let Some(user) = resolve_routine_notification_user(&response.metadata)
else {
tracing::warn!(
notify_channel = ?notify_channel,
"Skipping routine notification with no explicit target or owner scope"
);
continue;
};
// Try the configured channel first, fall back to
// broadcasting on all channels.
let targeted_ok = if let Some(ref channel) = notify_channel {
channels
.broadcast(channel, &user, response.clone())
.await
.is_ok()
match channels.broadcast(channel, &user, response.clone()).await {
Ok(()) => true,
Err(e) => {
let should_fallback =
should_fallback_routine_notification(&e);
tracing::warn!(
channel = %channel,
user = %user,
error = %e,
should_fallback,
"Failed to send routine notification to configured channel"
);
if !should_fallback {
continue;
}
false
}
}
} else {
false
};
@@ -768,10 +820,7 @@ impl Agent {
// For Signal, use signal_target from metadata (group:ID or phone number),
// otherwise fall back to user_id
let target = message
.metadata
.get("signal_target")
.and_then(|v| v.as_str())
.map(|s| s.to_string())
.routing_target()
.unwrap_or_else(|| message.user_id.clone());
self.tools()
.set_message_tool_context(Some(message.channel.clone()), Some(target))
@@ -811,7 +860,7 @@ impl Agent {
}
// Hydrate thread from DB if it's a historical thread not in memory
if let Some(ref external_thread_id) = message.thread_id {
if let Some(external_thread_id) = message.conversation_scope() {
tracing::trace!(
message_id = %message.id,
thread_id = %external_thread_id,
@@ -832,7 +881,7 @@ impl Agent {
.resolve_thread(
&message.user_id,
&message.channel,
message.thread_id.as_deref(),
message.conversation_scope(),
)
.await;
tracing::debug!(
@@ -985,7 +1034,11 @@ impl Agent {
#[cfg(test)]
mod tests {
use super::truncate_for_preview;
use super::{
resolve_routine_notification_user, should_fallback_routine_notification,
truncate_for_preview,
};
use crate::error::ChannelError;
#[test]
fn test_truncate_short_input() {
@@ -1048,4 +1101,55 @@ mod tests {
// 'h','e','l','l','o',' ','世','界' = 8 chars
assert_eq!(result, "hello 世界...");
}
#[test]
fn resolve_routine_notification_user_prefers_explicit_target() {
let metadata = serde_json::json!({
"notify_user": "12345",
"owner_id": "owner-scope",
});
let resolved = resolve_routine_notification_user(&metadata);
assert_eq!(resolved.as_deref(), Some("12345")); // safety: test-only assertion
}
#[test]
fn resolve_routine_notification_user_falls_back_to_owner_scope() {
let metadata = serde_json::json!({
"notify_user": null,
"owner_id": "owner-scope",
});
let resolved = resolve_routine_notification_user(&metadata);
assert_eq!(resolved.as_deref(), Some("owner-scope")); // safety: test-only assertion
}
#[test]
fn resolve_routine_notification_user_rejects_missing_values() {
let metadata = serde_json::json!({
"notify_user": " ",
});
assert_eq!(resolve_routine_notification_user(&metadata), None); // safety: test-only assertion
}
#[test]
fn targeted_routine_notifications_do_not_fallback_without_owner_route() {
let error = ChannelError::MissingRoutingTarget {
name: "telegram".to_string(),
reason: "No stored owner routing target for channel 'telegram'.".to_string(),
};
assert!(!should_fallback_routine_notification(&error)); // safety: test-only assertion
}
#[test]
fn targeted_routine_notifications_may_fallback_for_other_errors() {
let error = ChannelError::SendFailed {
name: "telegram".to_string(),
reason: "timeout talking to channel".to_string(),
};
assert!(should_fallback_routine_notification(&error)); // safety: test-only assertion
}
}
+4 -1
View File
@@ -836,7 +836,10 @@ impl Agent {
// 1. Persist to DB if available.
if let Some(store) = self.store() {
let value = serde_json::Value::String(model.to_string());
if let Err(e) = store.set_setting("default", "selected_model", &value).await {
if let Err(e) = store
.set_setting(self.owner_id(), "selected_model", &value)
.await
{
tracing::warn!("Failed to persist model to DB: {}", e);
}
}
+5 -1
View File
@@ -140,7 +140,8 @@ impl Agent {
// Create a JobContext for tool execution (chat doesn't have a real job)
let mut job_ctx =
JobContext::with_user(&message.user_id, "chat", "Interactive chat session");
JobContext::with_user(&message.user_id, "chat", "Interactive chat session")
.with_requester_id(&message.sender_id);
job_ctx.http_interceptor = self.deps.http_interceptor.clone();
job_ctx.user_timezone = user_tz.name().to_string();
job_ctx.metadata = serde_json::json!({
@@ -1175,6 +1176,7 @@ mod tests {
/// Build a minimal `Agent` for unit testing (no DB, no workspace, no extensions).
fn make_test_agent() -> Agent {
let deps = AgentDeps {
owner_id: "default".to_string(),
store: None,
llm: Arc::new(StaticLlmProvider),
cheap_llm: None,
@@ -2014,6 +2016,7 @@ mod tests {
/// `max_tool_iterations` override.
fn make_test_agent_with_llm(llm: Arc<dyn LlmProvider>, max_tool_iterations: usize) -> Agent {
let deps = AgentDeps {
owner_id: "default".to_string(),
store: None,
llm,
cheap_llm: None,
@@ -2127,6 +2130,7 @@ mod tests {
let max_iter = 3;
let agent = {
let deps = AgentDeps {
owner_id: "default".to_string(),
store: None,
llm,
cheap_llm: None,
+6 -1
View File
@@ -402,7 +402,11 @@ impl HeartbeatRunner {
return;
};
let user_id = self.config.notify_user_id.as_deref().unwrap_or("default");
let user_id = self
.config
.notify_user_id
.as_deref()
.unwrap_or_else(|| self.workspace.user_id());
// Persist to heartbeat conversation and get thread_id
let thread_id = if let Some(ref store) = self.store {
@@ -431,6 +435,7 @@ impl HeartbeatRunner {
attachments: Vec::new(),
metadata: serde_json::json!({
"source": "heartbeat",
"owner_id": self.workspace.user_id(),
}),
};
+3 -3
View File
@@ -422,8 +422,8 @@ impl Default for RoutineGuardrails {
pub struct NotifyConfig {
/// Channel to notify on (None = default/broadcast all).
pub channel: Option<String>,
/// User to notify.
pub user: String,
/// Explicit target to notify. None means "resolve the owner's last-seen target".
pub user: Option<String>,
/// Notify when routine produces actionable output.
pub on_attention: bool,
/// Notify when routine errors.
@@ -436,7 +436,7 @@ impl Default for NotifyConfig {
fn default() -> Self {
Self {
channel: None,
user: "default".to_string(),
user: None,
on_attention: true,
on_failure: true,
on_success: false,
+10 -1
View File
@@ -172,6 +172,11 @@ impl RoutineEngine {
EventMatcher::Message { routine, regex } => (routine, regex),
EventMatcher::System { .. } => continue,
};
if routine.user_id != message.user_id {
continue;
}
// Channel filter
if let Trigger::Event {
channel: Some(ch), ..
@@ -650,6 +655,7 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
send_notification(
&ctx.notify_tx,
&routine.notify,
&routine.user_id,
&routine.name,
status,
summary.as_deref(),
@@ -694,7 +700,8 @@ async fn execute_full_job(
reason: "scheduler not available".to_string(),
})?;
let mut metadata = serde_json::json!({ "max_iterations": max_iterations });
let mut metadata =
serde_json::json!({ "max_iterations": max_iterations, "owner_id": routine.user_id });
// Carry the routine's notify config in job metadata so the message tool
// can resolve channel/target per-job without global state mutation.
if let Some(channel) = &routine.notify.channel {
@@ -1207,6 +1214,7 @@ async fn execute_routine_tool(
async fn send_notification(
tx: &mpsc::Sender<OutgoingResponse>,
notify: &NotifyConfig,
owner_id: &str,
routine_name: &str,
status: RunStatus,
summary: Option<&str>,
@@ -1243,6 +1251,7 @@ async fn send_notification(
"source": "routine",
"routine_name": routine_name,
"status": status.to_string(),
"owner_id": owner_id,
"notify_user": notify.user,
"notify_channel": notify.channel,
}),
+2 -1
View File
@@ -924,7 +924,8 @@ impl Agent {
// Execute the approved tool and continue the loop
let mut job_ctx =
JobContext::with_user(&message.user_id, "chat", "Interactive chat session");
JobContext::with_user(&message.user_id, "chat", "Interactive chat session")
.with_requester_id(&message.sender_id);
job_ctx.http_interceptor = self.deps.http_interceptor.clone();
// Prefer a valid timezone from the approval message, fall back to the
// resolved timezone stored when the approval was originally requested.
+19 -10
View File
@@ -140,12 +140,14 @@ impl AppBuilder {
self.handles = Some(handles);
// Post-init: migrate disk config, reload config from DB, attach session, cleanup
if let Err(e) = crate::bootstrap::migrate_disk_to_db(db.as_ref(), "default").await {
if let Err(e) =
crate::bootstrap::migrate_disk_to_db(db.as_ref(), &self.config.owner_id).await
{
tracing::warn!("Disk-to-DB settings migration failed: {}", e);
}
let toml_path = self.toml_path.as_deref();
match Config::from_db_with_toml(db.as_ref(), "default", toml_path).await {
match Config::from_db_with_toml(db.as_ref(), &self.config.owner_id, toml_path).await {
Ok(db_config) => {
self.config = db_config;
tracing::debug!("Configuration reloaded from database");
@@ -158,7 +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.
let db_cleanup = db.clone();
@@ -193,9 +197,10 @@ impl AppBuilder {
let store: Option<&(dyn crate::db::SettingsStore + Sync)> =
self.db.as_ref().map(|db| db.as_ref() as _);
let toml_path = self.toml_path.as_deref();
let owner_id = self.config.owner_id.clone();
if let Err(e) = self
.config
.re_resolve_llm(store, "default", toml_path)
.re_resolve_llm(store, &owner_id, toml_path)
.await
{
tracing::warn!(
@@ -224,15 +229,17 @@ impl AppBuilder {
if let Some(ref secrets) = store {
// Inject LLM API keys from encrypted storage
crate::config::inject_llm_keys_from_secrets(secrets.as_ref(), "default").await;
crate::config::inject_llm_keys_from_secrets(secrets.as_ref(), &self.config.owner_id)
.await;
// Re-resolve only the LLM config with newly available keys.
let store: Option<&(dyn crate::db::SettingsStore + Sync)> =
self.db.as_ref().map(|db| db.as_ref() as _);
let toml_path = self.toml_path.as_deref();
let owner_id = self.config.owner_id.clone();
if let Err(e) = self
.config
.re_resolve_llm(store, "default", toml_path)
.re_resolve_llm(store, &owner_id, toml_path)
.await
{
tracing::warn!("Failed to re-resolve LLM config after secret injection: {e}");
@@ -304,7 +311,7 @@ impl AppBuilder {
// Register memory tools if database is available
let workspace = if let Some(ref db) = self.db {
let mut ws = Workspace::new_with_db("default", db.clone())
let mut ws = Workspace::new_with_db(&self.config.owner_id, db.clone())
.with_search_config(&self.config.search);
if let Some(ref emb) = embeddings {
ws = ws.with_embeddings(emb.clone());
@@ -469,9 +476,10 @@ impl AppBuilder {
let tools = Arc::clone(tools);
let mcp_sm = Arc::clone(&mcp_session_manager);
let pm = Arc::clone(&mcp_process_manager);
let owner_id = self.config.owner_id.clone();
async move {
let servers_result = if let Some(ref d) = db {
load_mcp_servers_from_db(d.as_ref(), "default").await
load_mcp_servers_from_db(d.as_ref(), &owner_id).await
} else {
crate::tools::mcp::config::load_mcp_servers().await
};
@@ -491,6 +499,7 @@ impl AppBuilder {
let secrets = secrets_store.clone();
let tools = Arc::clone(&tools);
let pm = Arc::clone(&pm);
let owner_id = owner_id.clone();
join_set.spawn(async move {
let server_name = server.name.clone();
@@ -500,7 +509,7 @@ impl AppBuilder {
&mcp_sm,
&pm,
secrets,
"default",
&owner_id,
)
.await
{
@@ -642,7 +651,7 @@ impl AppBuilder {
self.config.wasm.tools_dir.clone(),
self.config.channels.wasm_channels_dir.clone(),
self.config.tunnel.public_url.clone(),
"default".to_string(),
self.config.owner_id.clone(),
self.db.clone(),
catalog_entries.clone(),
));
+82 -6
View File
@@ -67,14 +67,24 @@ pub struct IncomingMessage {
pub id: Uuid,
/// Channel this message came from.
pub channel: String,
/// User identifier within the channel.
/// Storage/persistence scope for this interaction.
///
/// For owner-capable channels this is the stable instance owner ID when the
/// configured owner is speaking; otherwise it can be a guest/sender-scoped
/// identifier to preserve isolation.
pub user_id: String,
/// Stable instance owner scope for this IronClaw deployment.
pub owner_id: String,
/// Channel-specific sender/actor identifier.
pub sender_id: String,
/// Optional display name.
pub user_name: Option<String>,
/// Message content.
pub content: String,
/// Thread/conversation ID for threaded conversations.
pub thread_id: Option<String>,
/// Stable channel/chat/thread scope for this conversation.
pub conversation_scope_id: Option<String>,
/// When the message was received.
pub received_at: DateTime<Utc>,
/// Channel-specific metadata.
@@ -84,9 +94,8 @@ pub struct IncomingMessage {
/// File or media attachments on this message.
pub attachments: Vec<IncomingAttachment>,
/// Internal-only flag: message was generated inside the process (e.g. job
/// monitor) and must bypass the normal user-input pipeline. This field is
/// **not** settable via `with_metadata()` — only trusted code paths inside
/// the binary can set it, preventing external channels from spoofing it.
/// monitor) and must bypass the normal user-input pipeline. This field is
/// not settable via metadata, so external channels cannot spoof it.
pub(crate) is_internal: bool,
}
@@ -97,13 +106,17 @@ impl IncomingMessage {
user_id: impl Into<String>,
content: impl Into<String>,
) -> Self {
let user_id = user_id.into();
Self {
id: Uuid::new_v4(),
channel: channel.into(),
user_id: user_id.into(),
owner_id: user_id.clone(),
sender_id: user_id.clone(),
user_id,
user_name: None,
content: content.into(),
thread_id: None,
conversation_scope_id: None,
received_at: Utc::now(),
metadata: serde_json::Value::Null,
timezone: None,
@@ -114,7 +127,27 @@ impl IncomingMessage {
/// Set the thread ID.
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
}
@@ -147,6 +180,49 @@ impl IncomingMessage {
self.is_internal = true;
self
}
/// Effective conversation scope, falling back to thread_id for legacy callers.
pub fn conversation_scope(&self) -> Option<&str> {
self.conversation_scope_id
.as_deref()
.or(self.thread_id.as_deref())
}
/// Best-effort routing target for proactive replies on the current channel.
pub fn routing_target(&self) -> Option<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.
+105 -11
View File
@@ -133,7 +133,8 @@ impl HttpChannel {
#[derive(Debug, Deserialize)]
struct WebhookRequest {
/// User or client identifier (ignored, user is fixed by server config).
/// Optional caller or client identifier for sender-scoped routing.
/// The channel owner/storage scope remains fixed by server config.
#[serde(default)]
user_id: Option<String>,
/// Message content.
@@ -403,12 +404,38 @@ async fn process_authenticated_request(
state: Arc<HttpChannelState>,
req: WebhookRequest,
) -> axum::response::Response {
let _ = req.user_id.as_ref().map(|user_id| {
tracing::debug!(
provided_user_id = %user_id,
"HTTP webhook request provided user_id, ignoring in favor of configured user_id"
);
});
let normalized_user_id = req
.user_id
.as_deref()
.map(str::trim)
.filter(|user_id| !user_id.is_empty());
match (req.user_id.as_deref(), normalized_user_id) {
(Some(raw_user_id), Some(user_id)) if raw_user_id != user_id => {
tracing::debug!(
provided_user_id = %raw_user_id,
normalized_sender_id = %user_id,
configured_owner_id = %state.user_id,
"HTTP webhook request provided user_id; trimming and using it as sender_id while keeping the configured owner scope"
);
}
(Some(user_id), Some(_)) => {
tracing::debug!(
provided_user_id = %user_id,
configured_owner_id = %state.user_id,
"HTTP webhook request provided user_id; using it as sender_id while keeping the configured owner scope"
);
}
(Some(raw_user_id), None) => {
tracing::debug!(
provided_user_id = %raw_user_id,
configured_owner_id = %state.user_id,
"HTTP webhook request provided a blank user_id; falling back to the configured owner scope for sender_id"
);
}
(None, None) => {}
(None, Some(_)) => unreachable!("normalized user_id requires a raw user_id"),
}
if req.content.len() > MAX_CONTENT_BYTES {
return (
@@ -514,11 +541,13 @@ async fn process_authenticated_request(
Vec::new()
};
let mut msg = IncomingMessage::new("http", &state.user_id, &req.content).with_metadata(
serde_json::json!({
let sender_id = normalized_user_id.unwrap_or(&state.user_id).to_string();
let mut msg = IncomingMessage::new("http", &state.user_id, &req.content)
.with_owner_id(&state.user_id)
.with_sender_id(sender_id)
.with_metadata(serde_json::json!({
"wait_for_response": wait_for_response,
}),
);
}));
if !attachments.is_empty() {
msg = msg.with_attachments(attachments);
@@ -682,6 +711,7 @@ mod tests {
use axum::body::Body;
use axum::http::{HeaderValue, Request};
use secrecy::SecretString;
use tokio_stream::StreamExt;
use tower::ServiceExt;
use super::*;
@@ -820,6 +850,70 @@ mod tests {
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn webhook_blank_user_id_falls_back_to_owner_scope() {
let secret = "test-secret-123";
let channel = test_channel(Some(secret));
let mut stream = channel.start().await.unwrap();
let app = channel.routes();
let body = serde_json::json!({
"content": "hello",
"user_id": " "
});
let body_bytes = serde_json::to_vec(&body).unwrap();
let signature = compute_signature(secret, &body_bytes);
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.header("x-hub-signature-256", signature)
.body(Body::from(body_bytes))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
.await
.expect("timed out waiting for webhook message")
.expect("stream should yield a webhook message");
assert_eq!(msg.sender_id, "http");
assert_eq!(msg.owner_id, "http");
}
#[tokio::test]
async fn webhook_user_id_is_trimmed_before_becoming_sender_id() {
let secret = "test-secret-123";
let channel = test_channel(Some(secret));
let mut stream = channel.start().await.unwrap();
let app = channel.routes();
let body = serde_json::json!({
"content": "hello",
"user_id": " alice "
});
let body_bytes = serde_json::to_vec(&body).unwrap();
let signature = compute_signature(secret, &body_bytes);
let req = Request::builder()
.method("POST")
.uri("/webhook")
.header("content-type", "application/json")
.header("x-hub-signature-256", signature)
.body(Body::from(body_bytes))
.unwrap();
let resp = app.oneshot(req).await.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let msg = tokio::time::timeout(std::time::Duration::from_secs(1), stream.next())
.await
.expect("timed out waiting for webhook message")
.expect("stream should yield a webhook message");
assert_eq!(msg.sender_id, "alice");
assert_eq!(msg.owner_id, "http");
}
/// Regression test for issue #869: RwLock read guard was held across
/// tx.send(msg).await in `process_message()`, blocking shutdown() from
/// acquiring the write lock when the channel buffer was full.
+1 -1
View File
@@ -39,7 +39,7 @@ mod webhook_server;
pub use channel::{
AttachmentKind, Channel, ChannelSecretUpdater, IncomingAttachment, IncomingMessage,
MessageStream, OutgoingResponse, StatusUpdate,
MessageStream, OutgoingResponse, StatusUpdate, routing_target_from_metadata,
};
pub use http::{HttpChannel, HttpChannelState};
pub use manager::ChannelManager;
+22 -7
View File
@@ -200,6 +200,8 @@ fn format_json_params(params: &serde_json::Value, indent: &str) -> String {
/// REPL channel with line editing and markdown rendering.
pub struct ReplChannel {
/// Stable owner scope for this REPL instance.
user_id: String,
/// Optional single message to send (for -m flag).
single_message: Option<String>,
/// Debug mode flag (shared with input thread).
@@ -213,7 +215,13 @@ pub struct ReplChannel {
impl ReplChannel {
/// Create a new REPL channel.
pub fn new() -> Self {
Self::with_user_id("default")
}
/// Create a new REPL channel for a specific owner scope.
pub fn with_user_id(user_id: impl Into<String>) -> Self {
Self {
user_id: user_id.into(),
single_message: None,
debug_mode: Arc::new(AtomicBool::new(false)),
is_streaming: Arc::new(AtomicBool::new(false)),
@@ -223,7 +231,13 @@ impl ReplChannel {
/// Create a REPL channel that sends a single message and exits.
pub fn with_message(message: String) -> Self {
Self::with_message_for_user("default", message)
}
/// Create a REPL channel that sends a single message for a specific owner scope and exits.
pub fn with_message_for_user(user_id: impl Into<String>, message: String) -> Self {
Self {
user_id: user_id.into(),
single_message: Some(message),
debug_mode: Arc::new(AtomicBool::new(false)),
is_streaming: Arc::new(AtomicBool::new(false)),
@@ -292,6 +306,7 @@ impl Channel for ReplChannel {
async fn start(&self) -> Result<MessageStream, ChannelError> {
let (tx, rx) = mpsc::channel(32);
let single_message = self.single_message.clone();
let user_id = self.user_id.clone();
let debug_mode = Arc::clone(&self.debug_mode);
let suppress_banner = Arc::clone(&self.suppress_banner);
let esc_interrupt_triggered_for_thread = Arc::new(AtomicBool::new(false));
@@ -301,11 +316,11 @@ impl Channel for ReplChannel {
// Single message mode: send it and return
if let Some(msg) = single_message {
let incoming = IncomingMessage::new("repl", "default", &msg).with_timezone(&sys_tz);
let incoming = IncomingMessage::new("repl", &user_id, &msg).with_timezone(&sys_tz);
let _ = tx.blocking_send(incoming);
// Ensure the agent exits after handling exactly one turn in -m mode,
// even when other channels (gateway/http) are enabled.
let _ = tx.blocking_send(IncomingMessage::new("repl", "default", "/quit"));
let _ = tx.blocking_send(IncomingMessage::new("repl", &user_id, "/quit"));
return;
}
@@ -366,7 +381,7 @@ impl Channel for ReplChannel {
"/quit" | "/exit" => {
// Forward shutdown command so the agent loop exits even
// when other channels (e.g. web gateway) are still active.
let msg = IncomingMessage::new("repl", "default", "/quit")
let msg = IncomingMessage::new("repl", &user_id, "/quit")
.with_timezone(&sys_tz);
let _ = tx.blocking_send(msg);
break;
@@ -389,7 +404,7 @@ impl Channel for ReplChannel {
}
let msg =
IncomingMessage::new("repl", "default", line).with_timezone(&sys_tz);
IncomingMessage::new("repl", &user_id, line).with_timezone(&sys_tz);
if tx.blocking_send(msg).is_err() {
break;
}
@@ -397,14 +412,14 @@ impl Channel for ReplChannel {
Err(ReadlineError::Interrupted) => {
if esc_interrupt_triggered_for_thread.swap(false, Ordering::Relaxed) {
// Esc: interrupt current operation and keep REPL open.
let msg = IncomingMessage::new("repl", "default", "/interrupt")
let msg = IncomingMessage::new("repl", &user_id, "/interrupt")
.with_timezone(&sys_tz);
if tx.blocking_send(msg).is_err() {
break;
}
} else {
// Ctrl+C (VINTR): request graceful shutdown.
let msg = IncomingMessage::new("repl", "default", "/quit")
let msg = IncomingMessage::new("repl", &user_id, "/quit")
.with_timezone(&sys_tz);
let _ = tx.blocking_send(msg);
break;
@@ -416,7 +431,7 @@ impl Channel for ReplChannel {
// immediately — just drop the REPL thread silently so other
// channels (gateway, telegram, …) keep running.
if std::io::stdin().is_terminal() {
let msg = IncomingMessage::new("repl", "default", "/quit")
let msg = IncomingMessage::new("repl", &user_id, "/quit")
.with_timezone(&sys_tz);
let _ = tx.blocking_send(msg);
}
+8 -2
View File
@@ -27,6 +27,7 @@ pub struct WasmChannelLoader {
pairing_store: Arc<PairingStore>,
settings_store: Option<Arc<dyn SettingsStore>>,
secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
owner_scope_id: String,
}
impl WasmChannelLoader {
@@ -35,12 +36,14 @@ impl WasmChannelLoader {
runtime: Arc<WasmChannelRuntime>,
pairing_store: Arc<PairingStore>,
settings_store: Option<Arc<dyn SettingsStore>>,
owner_scope_id: impl Into<String>,
) -> Self {
Self {
runtime,
pairing_store,
settings_store,
secrets_store: None,
owner_scope_id: owner_scope_id.into(),
}
}
@@ -149,6 +152,7 @@ impl WasmChannelLoader {
self.runtime.clone(),
prepared,
capabilities,
self.owner_scope_id.clone(),
config_json,
self.pairing_store.clone(),
self.settings_store.clone(),
@@ -487,7 +491,8 @@ mod tests {
async fn test_loader_invalid_name() {
let config = WasmChannelRuntimeConfig::for_testing();
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()), None);
let loader =
WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()), None, "default");
let dir = TempDir::new().unwrap();
let wasm_path = dir.path().join("test.wasm");
@@ -505,7 +510,8 @@ mod tests {
async fn load_from_dir_returns_empty_when_dir_missing() {
let config = WasmChannelRuntimeConfig::for_testing();
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
let loader = WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()), None);
let loader =
WasmChannelLoader::new(runtime, Arc::new(PairingStore::new()), None, "default");
let dir = TempDir::new().unwrap();
let missing = dir.path().join("nonexistent_channels_dir");
+1 -1
View File
@@ -69,7 +69,7 @@
//! let runtime = WasmChannelRuntime::new(config)?;
//!
//! // Load channels from directory
//! let loader = WasmChannelLoader::new(runtime);
//! let loader = WasmChannelLoader::new(runtime, pairing_store, settings_store, owner_scope_id);
//! let channels = loader.load_from_dir(Path::new("~/.ironclaw/channels/")).await?;
//!
//! // Add to channel manager
+1
View File
@@ -672,6 +672,7 @@ mod tests {
runtime,
prepared,
capabilities,
"default",
"{}".to_string(),
Arc::new(PairingStore::new()),
None,
+16 -6
View File
@@ -50,6 +50,7 @@ pub async fn setup_wasm_channels(
Arc::clone(&runtime),
Arc::clone(&pairing_store),
settings_store.clone(),
config.owner_id.clone(),
);
if let Some(secrets) = secrets_store {
loader = loader.with_secrets_store(Arc::clone(secrets));
@@ -117,6 +118,11 @@ async fn register_channel(
) -> (String, Box<dyn crate::channels::Channel>) {
let channel_name = loaded.name().to_string();
tracing::info!("Loaded WASM channel: {}", channel_name);
let owner_actor_id = config
.channels
.wasm_channel_owner_ids
.get(channel_name.as_str())
.map(ToString::to_string);
let secret_name = loaded.webhook_secret_name();
let sig_key_secret_name = loaded.signature_key_secret_name();
@@ -124,7 +130,7 @@ async fn register_channel(
let webhook_secret = if let Some(secrets) = secrets_store {
secrets
.get_decrypted("default", &secret_name)
.get_decrypted(&config.owner_id, &secret_name)
.await
.ok()
.map(|s| s.expose().to_string())
@@ -142,7 +148,7 @@ async fn register_channel(
require_secret: webhook_secret.is_some(),
}];
let channel_arc = Arc::new(loaded.channel);
let channel_arc = Arc::new(loaded.channel.with_owner_actor_id(owner_actor_id.clone()));
// 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.
if let Some(ref sig_key_name) = sig_key_secret_name
&& let Some(secrets) = secrets_store
&& let Ok(key_secret) = secrets.get_decrypted("default", sig_key_name).await
&& let Ok(key_secret) = secrets.get_decrypted(&config.owner_id, sig_key_name).await
{
match wasm_router
.register_signature_key(&channel_name, key_secret.expose())
@@ -234,7 +240,9 @@ async fn register_channel(
// Register HMAC signing secret if declared in capabilities.
if let Some(ref hmac_secret_name) = hmac_secret_name
&& let Some(secrets) = secrets_store
&& let Ok(secret) = secrets.get_decrypted("default", hmac_secret_name).await
&& let Ok(secret) = secrets
.get_decrypted(&config.owner_id, hmac_secret_name)
.await
{
wasm_router
.register_hmac_secret(&channel_name, secret.expose())
@@ -249,6 +257,7 @@ async fn register_channel(
.as_ref()
.map(|s| s.as_ref() as &dyn SecretsStore),
&channel_name,
&config.owner_id,
)
.await
{
@@ -286,6 +295,7 @@ pub async fn inject_channel_credentials(
channel: &Arc<WasmChannel>,
secrets: Option<&dyn SecretsStore>,
channel_name: &str,
owner_id: &str,
) -> anyhow::Result<usize> {
if channel_name.trim().is_empty() {
return Ok(0);
@@ -297,7 +307,7 @@ pub async fn inject_channel_credentials(
// 1. Try injecting from persistent secrets store if available
if let Some(secrets) = secrets {
let all_secrets = secrets
.list("default")
.list(owner_id)
.await
.map_err(|e| anyhow::anyhow!("Failed to list secrets: {}", e))?;
@@ -308,7 +318,7 @@ pub async fn inject_channel_credentials(
continue;
}
let decrypted = match secrets.get_decrypted("default", &secret_meta.name).await {
let decrypted = match secrets.get_decrypted(owner_id, &secret_meta.name).await {
Ok(d) => d,
Err(e) => {
tracing::warn!(
+422 -103
View File
@@ -709,6 +709,12 @@ pub struct WasmChannel {
/// Settings store for persisting broadcast metadata across restarts.
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.
/// Used to pre-resolve credentials before each WASM callback.
secrets_store: Option<Arc<dyn SecretsStore + Send + Sync>>,
@@ -719,6 +725,7 @@ pub struct WasmChannel {
/// method and the static polling helper share one implementation.
async fn do_update_broadcast_metadata(
channel_name: &str,
owner_scope_id: &str,
metadata: &str,
last_broadcast_metadata: &tokio::sync::RwLock<Option<String>>,
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 {
let key = format!("channel_broadcast_metadata_{}", channel_name);
let value = serde_json::Value::String(metadata.to_string());
if let Err(e) = store.set_setting("default", &key, &value).await {
if let Err(e) = store.set_setting(owner_scope_id, &key, &value).await {
tracing::warn!(
channel = %channel_name,
"Failed to persist broadcast metadata: {}",
@@ -741,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 {
/// Create a new WASM channel.
pub fn new(
runtime: Arc<WasmChannelRuntime>,
prepared: Arc<PreparedChannelModule>,
capabilities: ChannelCapabilities,
owner_scope_id: impl Into<String>,
config_json: String,
pairing_store: Arc<PairingStore>,
settings_store: Option<Arc<dyn crate::db::SettingsStore>>,
@@ -773,6 +838,8 @@ impl WasmChannel {
workspace_store: Arc::new(ChannelWorkspaceStore::new()),
last_broadcast_metadata: Arc::new(tokio::sync::RwLock::new(None)),
settings_store,
owner_scope_id: owner_scope_id.into(),
owner_actor_id: None,
secrets_store: None,
}
}
@@ -787,6 +854,12 @@ impl WasmChannel {
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.
///
/// Merges the provided values into the existing config JSON.
@@ -843,6 +916,7 @@ impl WasmChannel {
async fn update_broadcast_metadata(&self, metadata: &str) {
do_update_broadcast_metadata(
&self.name,
&self.owner_scope_id,
metadata,
&self.last_broadcast_metadata,
self.settings_store.as_ref(),
@@ -854,7 +928,7 @@ impl WasmChannel {
async fn load_broadcast_metadata(&self) {
if let Some(ref store) = self.settings_store {
match store
.get_setting("default", &self.broadcast_metadata_key())
.get_setting(&self.owner_scope_id, &self.broadcast_metadata_key())
.await
{
Ok(Some(serde_json::Value::String(meta))) => {
@@ -864,7 +938,30 @@ impl WasmChannel {
"Restored broadcast metadata from settings"
);
}
Ok(_) => {}
Ok(_) => {
if self.owner_scope_id != "default" {
match store
.get_setting("default", &self.broadcast_metadata_key())
.await
{
Ok(Some(serde_json::Value::String(meta))) => {
*self.last_broadcast_metadata.write().await = Some(meta);
tracing::debug!(
channel = %self.name,
"Restored legacy owner broadcast metadata from default scope"
);
}
Ok(_) => {}
Err(e) => {
tracing::warn!(
channel = %self.name,
"Failed to load legacy broadcast metadata: {}",
e
);
}
}
}
}
Err(e) => {
tracing::warn!(
channel = %self.name,
@@ -1064,9 +1161,12 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone();
let credentials = self.get_credentials().await;
let host_credentials =
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref())
.await;
let host_credentials = resolve_channel_host_credentials(
&self.capabilities,
self.secrets_store.as_deref(),
&self.owner_scope_id,
)
.await;
let pairing_store = self.pairing_store.clone();
let workspace_store = self.workspace_store.clone();
@@ -1204,9 +1304,12 @@ impl WasmChannel {
let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store);
let timeout = self.runtime.config().callback_timeout;
let credentials = self.get_credentials().await;
let host_credentials =
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref())
.await;
let host_credentials = resolve_channel_host_credentials(
&self.capabilities,
self.secrets_store.as_deref(),
&self.owner_scope_id,
)
.await;
let pairing_store = self.pairing_store.clone();
let workspace_store = self.workspace_store.clone();
@@ -1307,9 +1410,12 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone();
let credentials = self.get_credentials().await;
let host_credentials =
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref())
.await;
let host_credentials = resolve_channel_host_credentials(
&self.capabilities,
self.secrets_store.as_deref(),
&self.owner_scope_id,
)
.await;
let pairing_store = self.pairing_store.clone();
let workspace_store = self.workspace_store.clone();
@@ -1414,9 +1520,12 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone();
let credentials = self.get_credentials().await;
let host_credentials =
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref())
.await;
let host_credentials = resolve_channel_host_credentials(
&self.capabilities,
self.secrets_store.as_deref(),
&self.owner_scope_id,
)
.await;
let pairing_store = self.pairing_store.clone();
// Prepare response data
@@ -1555,9 +1664,12 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone();
let credentials = self.get_credentials().await;
let host_credentials =
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref())
.await;
let host_credentials = resolve_channel_host_credentials(
&self.capabilities,
self.secrets_store.as_deref(),
&self.owner_scope_id,
)
.await;
let pairing_store = self.pairing_store.clone();
let user_id = user_id.to_string();
@@ -1659,9 +1771,12 @@ impl WasmChannel {
let timeout = self.runtime.config().callback_timeout;
let channel_name = self.name.clone();
let credentials = self.get_credentials().await;
let host_credentials =
resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref())
.await;
let host_credentials = resolve_channel_host_credentials(
&self.capabilities,
self.secrets_store.as_deref(),
&self.owner_scope_id,
)
.await;
let pairing_store = self.pairing_store.clone();
let Some(wit_update) = status_to_wit(status, metadata) else {
@@ -1831,6 +1946,7 @@ impl WasmChannel {
let repeater_host_credentials = resolve_channel_host_credentials(
&self.capabilities,
self.secrets_store.as_deref(),
&self.owner_scope_id,
)
.await;
let pairing_store = self.pairing_store.clone();
@@ -2027,8 +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
let mut msg = IncomingMessage::new(&self.name, &emitted.user_id, &emitted.content);
let mut msg = IncomingMessage::new(&self.name, &resolved_user_id, &emitted.content)
.with_owner_id(&self.owner_scope_id)
.with_sender_id(&emitted.user_id);
if let Some(name) = emitted.user_name {
msg = msg.with_user_name(name);
@@ -2060,9 +2184,9 @@ impl WasmChannel {
}
// Parse metadata JSON
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
msg = msg.with_metadata(metadata);
// Store for broadcast routing (chat_id etc.)
msg = apply_emitted_metadata(msg, &emitted.metadata_json);
if is_owner_sender {
// Store for owner-target routing (chat_id etc.).
self.update_broadcast_metadata(&emitted.metadata_json).await;
}
@@ -2112,6 +2236,8 @@ impl WasmChannel {
let last_broadcast_metadata = self.last_broadcast_metadata.clone();
let settings_store = self.settings_store.clone();
let poll_secrets_store = self.secrets_store.clone();
let owner_scope_id = self.owner_scope_id.clone();
let owner_actor_id = self.owner_actor_id.clone();
tokio::spawn(async move {
let mut interval_timer = tokio::time::interval(interval);
@@ -2129,6 +2255,7 @@ impl WasmChannel {
let host_credentials = resolve_channel_host_credentials(
&poll_capabilities,
poll_secrets_store.as_deref(),
&owner_scope_id,
)
.await;
@@ -2150,12 +2277,16 @@ impl WasmChannel {
// Process any emitted messages
if !emitted_messages.is_empty()
&& 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,
&message_tx,
&rate_limiter,
&last_broadcast_metadata,
settings_store.as_ref(),
).await {
tracing::warn!(
channel = %channel_name,
@@ -2277,25 +2408,21 @@ impl WasmChannel {
/// This is a static helper used by the polling loop since it doesn't have
/// access to `&self`.
async fn dispatch_emitted_messages(
channel_name: &str,
dispatch: EmitDispatchContext<'_>,
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> {
tracing::info!(
channel = %channel_name,
channel = %dispatch.channel_name,
message_count = messages.len(),
"Processing emitted messages from polling callback"
);
// Clone sender to avoid holding RwLock read guard across send().await in the loop
let tx = {
let tx_guard = message_tx.read().await;
let tx_guard = dispatch.message_tx.read().await;
let Some(tx) = tx_guard.as_ref() else {
tracing::error!(
channel = %channel_name,
channel = %dispatch.channel_name,
count = messages.len(),
"Messages emitted but no sender available - channel may not be started!"
);
@@ -2307,20 +2434,29 @@ impl WasmChannel {
for emitted in messages {
// Check rate limit — acquire and release the write lock before send().await
{
let mut limiter = rate_limiter.write().await;
let mut limiter = dispatch.rate_limiter.write().await;
if !limiter.check_and_record() {
tracing::warn!(
channel = %channel_name,
channel = %dispatch.channel_name,
"Message emission rate limited"
);
return Err(WasmChannelError::EmitRateLimited {
name: channel_name.to_string(),
name: dispatch.channel_name.to_string(),
});
}
}
let (resolved_user_id, is_owner_sender) = resolve_message_scope(
dispatch.owner_scope_id,
dispatch.owner_actor_id,
&emitted.user_id,
);
// Convert to IncomingMessage
let mut msg = IncomingMessage::new(channel_name, &emitted.user_id, &emitted.content);
let mut msg =
IncomingMessage::new(dispatch.channel_name, &resolved_user_id, &emitted.content)
.with_owner_id(dispatch.owner_scope_id)
.with_sender_id(&emitted.user_id);
if let Some(name) = emitted.user_name {
msg = msg.with_user_name(name);
@@ -2351,22 +2487,22 @@ impl WasmChannel {
msg = msg.with_attachments(incoming_attachments);
}
// Parse metadata JSON
if let Ok(metadata) = serde_json::from_str(&emitted.metadata_json) {
msg = msg.with_metadata(metadata);
// Store for broadcast routing (chat_id etc.)
msg = apply_emitted_metadata(msg, &emitted.metadata_json);
if is_owner_sender {
// Store for owner-target routing (chat_id etc.)
do_update_broadcast_metadata(
channel_name,
dispatch.channel_name,
dispatch.owner_scope_id,
&emitted.metadata_json,
last_broadcast_metadata,
settings_store,
dispatch.last_broadcast_metadata,
dispatch.settings_store,
)
.await;
}
// Send to stream — no locks held across this await
tracing::info!(
channel = %channel_name,
channel = %dispatch.channel_name,
user_id = %emitted.user_id,
content_len = emitted.content.len(),
attachment_count = msg.attachments.len(),
@@ -2375,14 +2511,14 @@ impl WasmChannel {
if tx.send(msg).await.is_err() {
tracing::error!(
channel = %channel_name,
channel = %dispatch.channel_name,
"Failed to send polled message, channel closed"
);
break;
}
tracing::info!(
channel = %channel_name,
channel = %dispatch.channel_name,
"Message successfully sent to agent queue"
);
}
@@ -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]
impl Channel for WasmChannel {
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)
// that the WASM channel needs to send the reply to the correct destination.
let metadata_json = serde_json::to_string(&msg.metadata).unwrap_or_default();
// Store for broadcast routing (chat_id etc.)
self.update_broadcast_metadata(&metadata_json).await;
// Store for owner-target routing (chat_id etc.) only when the configured
// owner is the actor in this conversation.
if msg.user_id == self.owner_scope_id {
self.update_broadcast_metadata(&metadata_json).await;
}
self.call_on_respond(
msg.id,
&response.content,
@@ -2514,8 +2663,24 @@ impl Channel for WasmChannel {
response: OutgoingResponse,
) -> Result<(), ChannelError> {
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(
user_id,
&resolved_target,
&response.content,
response.thread_id.as_deref(),
&response.attachments,
@@ -2931,6 +3096,7 @@ fn extract_host_from_url(url: &str) -> Option<String> {
async fn resolve_channel_host_credentials(
capabilities: &ChannelCapabilities,
store: Option<&(dyn SecretsStore + Send + Sync)>,
owner_scope_id: &str,
) -> Vec<ResolvedHostCredential> {
let store = match store {
Some(s) => s,
@@ -2957,7 +3123,10 @@ async fn resolve_channel_host_credentials(
continue;
}
let secret = match store.get_decrypted("default", &mapping.secret_name).await {
let secret = match store
.get_decrypted(owner_scope_id, &mapping.secret_name)
.await
{
Ok(s) => s,
Err(e) => {
tracing::debug!(
@@ -3076,12 +3245,18 @@ mod tests {
use crate::channels::wasm::runtime::{
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
};
use crate::channels::wasm::wrapper::{HttpResponse, WasmChannel};
use crate::channels::wasm::wrapper::{
EmitDispatchContext, HttpResponse, WasmChannel, uses_owner_broadcast_target,
};
use crate::pairing::PairingStore;
use crate::testing::credentials::TEST_TELEGRAM_BOT_TOKEN;
use crate::tools::wasm::ResourceLimits;
fn create_test_channel() -> WasmChannel {
create_test_channel_with_owner_scope("default")
}
fn create_test_channel_with_owner_scope(owner_scope_id: &str) -> WasmChannel {
let config = WasmChannelRuntimeConfig::for_testing();
let runtime = Arc::new(WasmChannelRuntime::new(config).unwrap());
@@ -3098,6 +3273,7 @@ mod tests {
runtime,
prepared,
capabilities,
owner_scope_id,
"{}".to_string(),
Arc::new(PairingStore::new()),
None,
@@ -3185,7 +3361,7 @@ mod tests {
)
.await;
assert!(result.is_ok());
assert!(result.is_ok()); // safety: test-only assertion
assert!(result.unwrap().is_empty());
}
@@ -3209,28 +3385,32 @@ mod tests {
let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
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,
&message_tx,
&rate_limiter,
&last_broadcast_metadata,
None,
)
.await;
assert!(result.is_ok());
assert!(result.is_ok()); // safety: test-only assertion
// Verify messages were sent
let msg1 = rx.try_recv().expect("Should receive first message");
assert_eq!(msg1.user_id, "user1");
assert_eq!(msg1.content, "Hello from polling!");
let msg1 = rx.try_recv().expect("Should receive first message"); // safety: test-only assertion
assert_eq!(msg1.user_id, "user1"); // safety: test-only assertion
assert_eq!(msg1.content, "Hello from polling!"); // safety: test-only assertion
let msg2 = rx.try_recv().expect("Should receive second message");
assert_eq!(msg2.user_id, "user2");
assert_eq!(msg2.content, "Another message");
let msg2 = rx.try_recv().expect("Should receive second message"); // safety: test-only assertion
assert_eq!(msg2.user_id, "user2"); // safety: test-only assertion
assert_eq!(msg2.content, "Another message"); // safety: test-only assertion
// No more messages
assert!(rx.try_recv().is_err());
assert!(rx.try_recv().is_err()); // safety: test-only assertion
}
#[tokio::test]
@@ -3250,12 +3430,16 @@ mod tests {
// Should return Ok even without a sender (logs warning but doesn't fail)
let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
let result = WasmChannel::dispatch_emitted_messages(
"test-channel",
EmitDispatchContext {
channel_name: "test-channel",
owner_scope_id: "default",
owner_actor_id: None,
message_tx: &message_tx,
rate_limiter: &rate_limiter,
last_broadcast_metadata: &last_broadcast_metadata,
settings_store: None,
},
messages,
&message_tx,
&rate_limiter,
&last_broadcast_metadata,
None,
)
.await;
@@ -3284,6 +3468,7 @@ mod tests {
runtime,
prepared,
capabilities,
"default",
"{}".to_string(),
Arc::new(PairingStore::new()),
None,
@@ -4255,42 +4440,172 @@ mod tests {
let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
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,
&message_tx,
&rate_limiter,
&last_broadcast_metadata,
None,
)
.await;
assert!(result.is_ok());
assert!(result.is_ok()); // safety: test-only assertion
let msg = rx.try_recv().expect("Should receive message");
assert_eq!(msg.content, "Check these files");
assert_eq!(msg.attachments.len(), 2);
let msg = rx.try_recv().expect("Should receive message"); // safety: test-only assertion
assert_eq!(msg.content, "Check these files"); // safety: test-only assertion
assert_eq!(msg.attachments.len(), 2); // safety: test-only assertion
// Verify first attachment
assert_eq!(msg.attachments[0].id, "photo123");
assert_eq!(msg.attachments[0].mime_type, "image/jpeg");
assert_eq!(msg.attachments[0].filename, Some("cat.jpg".to_string()));
assert_eq!(msg.attachments[0].size_bytes, Some(50_000));
assert_eq!(msg.attachments[0].id, "photo123"); // safety: test-only assertion
assert_eq!(msg.attachments[0].mime_type, "image/jpeg"); // safety: test-only assertion
assert_eq!(msg.attachments[0].filename, Some("cat.jpg".to_string())); // safety: test-only assertion
assert_eq!(msg.attachments[0].size_bytes, Some(50_000)); // safety: test-only assertion
assert_eq!(
msg.attachments[0].source_url,
Some("https://api.telegram.org/file/photo123".to_string())
);
); // safety: test-only assertion
// Verify second attachment
assert_eq!(msg.attachments[1].id, "doc456");
assert_eq!(msg.attachments[1].mime_type, "application/pdf");
assert_eq!(msg.attachments[1].id, "doc456"); // safety: test-only assertion
assert_eq!(msg.attachments[1].mime_type, "application/pdf"); // safety: test-only assertion
assert_eq!(
msg.attachments[1].extracted_text,
Some("Report contents...".to_string())
);
); // safety: test-only assertion
assert_eq!(
msg.attachments[1].storage_key,
Some("store/doc456".to_string())
);
); // safety: test-only assertion
}
#[tokio::test]
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]
@@ -4310,20 +4625,24 @@ mod tests {
let last_broadcast_metadata = Arc::new(tokio::sync::RwLock::new(None));
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,
&message_tx,
&rate_limiter,
&last_broadcast_metadata,
None,
)
.await;
assert!(result.is_ok());
assert!(result.is_ok()); // safety: test-only assertion
let msg = rx.try_recv().expect("Should receive message");
assert_eq!(msg.content, "Just text, no attachments");
assert!(msg.attachments.is_empty());
let msg = rx.try_recv().expect("Should receive message"); // safety: test-only assertion
assert_eq!(msg.content, "Just text, no attachments"); // safety: test-only assertion
assert!(msg.attachments.is_empty()); // safety: test-only assertion
}
#[test]
+5 -4
View File
@@ -405,10 +405,11 @@ fn check_routines_config() -> CheckResult {
fn check_gateway_config(settings: &Settings) -> CheckResult {
// Use the same resolve() path as runtime so invalid env values
// (e.g. GATEWAY_PORT=abc) are caught here too.
let tunnel_enabled = crate::config::TunnelConfig::resolve(settings)
.map(|t| t.is_enabled())
.unwrap_or(false);
match crate::config::ChannelsConfig::resolve(settings, tunnel_enabled) {
let owner_id = match crate::config::resolve_owner_id(settings) {
Ok(owner_id) => owner_id,
Err(e) => return CheckResult::Fail(format!("config error: {e}")),
};
match crate::config::ChannelsConfig::resolve(settings, &owner_id) {
Ok(channels) => match channels.gateway {
Some(gw) => {
if gw.auth_token.is_some() {
+21 -7
View File
@@ -292,6 +292,16 @@ async fn list(
// ── 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)]
async fn create(
db: &Arc<dyn Database>,
@@ -338,13 +348,7 @@ async fn create(
max_concurrent: 1,
dedup_window: None,
},
notify: NotifyConfig {
channel: notify_channel,
user: user_id.to_string(),
on_attention: true,
on_failure: true,
on_success: false,
},
notify: cli_notify_config(notify_channel),
last_run_at: None,
next_fire_at: next_fire,
run_count: 0,
@@ -729,4 +733,14 @@ mod tests {
// Must be valid UTF-8 (would have panicked otherwise).
assert!(result.is_char_boundary(result.len()));
}
#[test]
fn cli_notify_config_defaults_to_runtime_target_resolution() {
let notify = cli_notify_config(Some("telegram".to_string()));
assert_eq!(notify.channel.as_deref(), Some("telegram")); // safety: test-only assertion
assert_eq!(notify.user, None); // safety: test-only assertion
assert!(notify.on_attention); // safety: test-only assertion
assert!(notify.on_failure); // safety: test-only assertion
assert!(!notify.on_success); // safety: test-only assertion
}
}
+55 -335
View File
@@ -91,36 +91,24 @@ pub struct SignalConfig {
}
impl ChannelsConfig {
/// Resolve channels config following `env > settings > default` for every field.
pub(crate) fn resolve(settings: &Settings, tunnel_enabled: bool) -> Result<Self, ConfigError> {
pub(crate) fn resolve(settings: &Settings, owner_id: &str) -> Result<Self, ConfigError> {
let cs = &settings.channels;
// --- HTTP webhook ---
// HTTP is enabled when env vars are set OR settings has it enabled.
let http_enabled_by_env =
optional_env("HTTP_PORT")?.is_some() || optional_env("HTTP_HOST")?.is_some();
// When a tunnel is configured, default to loopback since external
// traffic arrives through the tunnel. Without a tunnel the webhook
// server needs to accept connections from the network directly.
let default_host = if tunnel_enabled {
"127.0.0.1"
} else {
"0.0.0.0"
};
let http = if http_enabled_by_env || cs.http_enabled {
Some(HttpConfig {
host: optional_env("HTTP_HOST")?
.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))?,
webhook_secret: optional_env("HTTP_WEBHOOK_SECRET")?.map(SecretString::from),
user_id: optional_env("HTTP_USER_ID")?.unwrap_or_else(|| "http".to_string()),
user_id: owner_id.to_string(),
})
} else {
None
};
// --- Web gateway ---
let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", cs.gateway_enabled)?;
let gateway = if gateway_enabled {
Some(GatewayConfig {
@@ -133,33 +121,29 @@ impl ChannelsConfig {
)?,
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?
.or_else(|| cs.gateway_auth_token.clone()),
user_id: optional_env("GATEWAY_USER_ID")?
.or_else(|| cs.gateway_user_id.clone())
.unwrap_or_else(|| "default".to_string()),
user_id: owner_id.to_string(),
})
} else {
None
};
// --- Signal ---
let signal_url = optional_env("SIGNAL_HTTP_URL")?.or_else(|| cs.signal_http_url.clone());
let signal = if let Some(http_url) = signal_url {
let account = optional_env("SIGNAL_ACCOUNT")?
.or_else(|| cs.signal_account.clone())
.ok_or(ConfigError::InvalidValue {
key: "SIGNAL_ACCOUNT".to_string(),
message: "SIGNAL_ACCOUNT is required when Signal is enabled".to_string(),
message: "SIGNAL_ACCOUNT is required when SIGNAL_HTTP_URL is set".to_string(),
})?;
let allow_from_str =
optional_env("SIGNAL_ALLOW_FROM")?.or_else(|| cs.signal_allow_from.clone());
let allow_from = match allow_from_str {
None => vec![account.clone()],
Some(s) => s
.split(',')
.map(|e| e.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
};
let allow_from =
match optional_env("SIGNAL_ALLOW_FROM")?.or_else(|| cs.signal_allow_from.clone()) {
None => vec![account.clone()],
Some(s) => s
.split(',')
.map(|e| e.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
};
let dm_policy = optional_env("SIGNAL_DM_POLICY")?
.or_else(|| cs.signal_dm_policy.clone())
.unwrap_or_else(|| "pairing".to_string());
@@ -201,18 +185,8 @@ impl ChannelsConfig {
None
};
// --- CLI ---
let cli_enabled = parse_bool_env("CLI_ENABLED", cs.cli_enabled)?;
// --- WASM channels ---
let wasm_channels_dir = optional_env("WASM_CHANNELS_DIR")?
.map(PathBuf::from)
.or_else(|| cs.wasm_channels_dir.clone())
.unwrap_or_else(default_channels_dir);
let wasm_channels_enabled =
parse_bool_env("WASM_CHANNELS_ENABLED", cs.wasm_channels_enabled)?;
Ok(Self {
cli: CliConfig {
enabled: cli_enabled,
@@ -220,8 +194,14 @@ impl ChannelsConfig {
http,
gateway,
signal,
wasm_channels_dir,
wasm_channels_enabled,
wasm_channels_dir: optional_env("WASM_CHANNELS_DIR")?
.map(PathBuf::from)
.or_else(|| cs.wasm_channels_dir.clone())
.unwrap_or_else(default_channels_dir),
wasm_channels_enabled: parse_bool_env(
"WASM_CHANNELS_ENABLED",
cs.wasm_channels_enabled,
)?,
wasm_channel_owner_ids: {
let mut ids = cs.wasm_channel_owner_ids.clone();
// Backwards compat: TELEGRAM_OWNER_ID env var
@@ -252,6 +232,8 @@ fn default_channels_dir() -> PathBuf {
#[cfg(test)]
mod tests {
use crate::config::channels::*;
use crate::config::helpers::ENV_MUTEX;
use crate::settings::Settings;
#[test]
fn cli_config_fields() {
@@ -398,69 +380,6 @@ mod tests {
assert!(!cfg.wasm_channels_enabled);
}
/// When a tunnel is active and HTTP_HOST is not explicitly set, the
/// webhook server should default to loopback to avoid unnecessary exposure.
#[test]
fn http_host_defaults_to_loopback_with_tunnel() {
// Set HTTP_PORT to trigger HttpConfig creation, but leave HTTP_HOST unset
// so the default kicks in.
unsafe {
std::env::set_var("HTTP_PORT", "9999");
std::env::remove_var("HTTP_HOST");
}
let settings = crate::settings::Settings::default();
let cfg = ChannelsConfig::resolve(&settings, true).unwrap();
unsafe {
std::env::remove_var("HTTP_PORT");
}
let http = cfg.http.expect("HttpConfig should be present");
assert_eq!(
http.host, "127.0.0.1",
"tunnel active should default to loopback"
);
assert_eq!(http.port, 9999);
}
/// Without a tunnel, the webhook server defaults to 0.0.0.0 so external
/// services can reach it directly.
#[test]
fn http_host_defaults_to_all_interfaces_without_tunnel() {
unsafe {
std::env::set_var("HTTP_PORT", "9998");
std::env::remove_var("HTTP_HOST");
}
let settings = crate::settings::Settings::default();
let cfg = ChannelsConfig::resolve(&settings, false).unwrap();
unsafe {
std::env::remove_var("HTTP_PORT");
}
let http = cfg.http.expect("HttpConfig should be present");
assert_eq!(
http.host, "0.0.0.0",
"no tunnel should default to all interfaces"
);
}
/// An explicit HTTP_HOST always wins regardless of tunnel state.
#[test]
fn explicit_http_host_overrides_tunnel_default() {
unsafe {
std::env::set_var("HTTP_PORT", "9997");
std::env::set_var("HTTP_HOST", "192.168.1.50");
}
let settings = crate::settings::Settings::default();
let cfg = ChannelsConfig::resolve(&settings, true).unwrap();
unsafe {
std::env::remove_var("HTTP_PORT");
std::env::remove_var("HTTP_HOST");
}
let http = cfg.http.expect("HttpConfig should be present");
assert_eq!(
http.host, "192.168.1.50",
"explicit host should override tunnel default"
);
}
#[test]
fn default_channels_dir_ends_with_channels() {
let dir = default_channels_dir();
@@ -471,242 +390,43 @@ mod tests {
}
#[test]
fn default_gateway_port_constant() {
assert_eq!(DEFAULT_GATEWAY_PORT, 3000);
}
/// With default settings and no env vars, gateway should use defaults.
#[test]
fn resolve_gateway_defaults_from_settings() {
let _lock = crate::config::helpers::ENV_MUTEX.lock();
// Clear env vars that would interfere
unsafe {
std::env::remove_var("GATEWAY_ENABLED");
std::env::remove_var("GATEWAY_HOST");
std::env::remove_var("GATEWAY_PORT");
std::env::remove_var("GATEWAY_AUTH_TOKEN");
std::env::remove_var("GATEWAY_USER_ID");
std::env::remove_var("HTTP_PORT");
std::env::remove_var("HTTP_HOST");
std::env::remove_var("SIGNAL_HTTP_URL");
std::env::remove_var("CLI_ENABLED");
std::env::remove_var("WASM_CHANNELS_DIR");
std::env::remove_var("WASM_CHANNELS_ENABLED");
std::env::remove_var("TELEGRAM_OWNER_ID");
}
let settings = crate::settings::Settings::default();
let cfg = ChannelsConfig::resolve(&settings, false).unwrap();
let gw = cfg.gateway.expect("gateway should be enabled by default");
assert_eq!(gw.host, "127.0.0.1");
assert_eq!(gw.port, DEFAULT_GATEWAY_PORT);
assert!(gw.auth_token.is_none());
assert_eq!(gw.user_id, "default");
}
/// Settings values should be used when no env vars are set.
#[test]
fn resolve_gateway_from_settings() {
let _lock = crate::config::helpers::ENV_MUTEX.lock();
unsafe {
std::env::remove_var("GATEWAY_ENABLED");
std::env::remove_var("GATEWAY_HOST");
std::env::remove_var("GATEWAY_PORT");
std::env::remove_var("GATEWAY_AUTH_TOKEN");
std::env::remove_var("GATEWAY_USER_ID");
std::env::remove_var("HTTP_PORT");
std::env::remove_var("HTTP_HOST");
std::env::remove_var("SIGNAL_HTTP_URL");
std::env::remove_var("CLI_ENABLED");
std::env::remove_var("WASM_CHANNELS_DIR");
std::env::remove_var("WASM_CHANNELS_ENABLED");
std::env::remove_var("TELEGRAM_OWNER_ID");
}
let mut settings = crate::settings::Settings::default();
settings.channels.gateway_port = Some(4000);
settings.channels.gateway_host = Some("0.0.0.0".to_string());
settings.channels.gateway_auth_token = Some("db-token-123".to_string());
settings.channels.gateway_user_id = Some("myuser".to_string());
let cfg = ChannelsConfig::resolve(&settings, false).unwrap();
let gw = cfg.gateway.expect("gateway should be enabled");
assert_eq!(gw.port, 4000);
assert_eq!(gw.host, "0.0.0.0");
assert_eq!(gw.auth_token.as_deref(), Some("db-token-123"));
assert_eq!(gw.user_id, "myuser");
}
/// Env vars should override settings values.
#[test]
fn resolve_env_overrides_settings() {
let _lock = crate::config::helpers::ENV_MUTEX.lock();
unsafe {
std::env::set_var("GATEWAY_PORT", "5000");
std::env::set_var("GATEWAY_HOST", "10.0.0.1");
std::env::set_var("GATEWAY_AUTH_TOKEN", "env-token");
std::env::remove_var("GATEWAY_ENABLED");
std::env::remove_var("GATEWAY_USER_ID");
std::env::remove_var("HTTP_PORT");
std::env::remove_var("HTTP_HOST");
std::env::remove_var("SIGNAL_HTTP_URL");
std::env::remove_var("CLI_ENABLED");
std::env::remove_var("WASM_CHANNELS_DIR");
std::env::remove_var("WASM_CHANNELS_ENABLED");
std::env::remove_var("TELEGRAM_OWNER_ID");
}
let mut settings = crate::settings::Settings::default();
settings.channels.gateway_port = Some(4000);
settings.channels.gateway_host = Some("0.0.0.0".to_string());
settings.channels.gateway_auth_token = Some("db-token".to_string());
let cfg = ChannelsConfig::resolve(&settings, false).unwrap();
let gw = cfg.gateway.expect("gateway should be enabled");
assert_eq!(gw.port, 5000, "env should override settings");
assert_eq!(gw.host, "10.0.0.1", "env should override settings");
assert_eq!(
gw.auth_token.as_deref(),
Some("env-token"),
"env should override settings"
);
// Cleanup
unsafe {
std::env::remove_var("GATEWAY_PORT");
std::env::remove_var("GATEWAY_HOST");
std::env::remove_var("GATEWAY_AUTH_TOKEN");
}
}
/// CLI enabled should fall back to settings.
#[test]
fn resolve_cli_enabled_from_settings() {
let _lock = crate::config::helpers::ENV_MUTEX.lock();
unsafe {
std::env::remove_var("CLI_ENABLED");
std::env::remove_var("GATEWAY_ENABLED");
std::env::remove_var("GATEWAY_HOST");
std::env::remove_var("GATEWAY_PORT");
std::env::remove_var("GATEWAY_AUTH_TOKEN");
std::env::remove_var("GATEWAY_USER_ID");
std::env::remove_var("HTTP_PORT");
std::env::remove_var("HTTP_HOST");
std::env::remove_var("SIGNAL_HTTP_URL");
std::env::remove_var("WASM_CHANNELS_DIR");
std::env::remove_var("WASM_CHANNELS_ENABLED");
std::env::remove_var("TELEGRAM_OWNER_ID");
}
let mut settings = crate::settings::Settings::default();
settings.channels.cli_enabled = false;
let cfg = ChannelsConfig::resolve(&settings, false).unwrap();
assert!(!cfg.cli.enabled, "settings should disable CLI");
}
/// HTTP channel should activate when settings has it enabled.
#[test]
fn resolve_http_from_settings() {
let _lock = crate::config::helpers::ENV_MUTEX.lock();
unsafe {
std::env::remove_var("HTTP_PORT");
std::env::remove_var("HTTP_HOST");
std::env::remove_var("HTTP_WEBHOOK_SECRET");
std::env::remove_var("HTTP_USER_ID");
std::env::remove_var("GATEWAY_ENABLED");
std::env::remove_var("GATEWAY_HOST");
std::env::remove_var("GATEWAY_PORT");
std::env::remove_var("GATEWAY_AUTH_TOKEN");
std::env::remove_var("GATEWAY_USER_ID");
std::env::remove_var("SIGNAL_HTTP_URL");
std::env::remove_var("CLI_ENABLED");
std::env::remove_var("WASM_CHANNELS_DIR");
std::env::remove_var("WASM_CHANNELS_ENABLED");
std::env::remove_var("TELEGRAM_OWNER_ID");
}
let mut settings = crate::settings::Settings::default();
fn resolve_uses_settings_channel_values_with_owner_scope_user_ids() {
let _guard = ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
let mut settings = Settings::default();
settings.channels.http_enabled = true;
settings.channels.http_port = Some(9090);
settings.channels.http_host = Some("10.0.0.1".to_string());
settings.channels.http_host = Some("127.0.0.2".to_string());
settings.channels.http_port = Some(8181);
settings.channels.gateway_enabled = true;
settings.channels.gateway_host = Some("127.0.0.3".to_string());
settings.channels.gateway_port = Some(9191);
settings.channels.gateway_auth_token = Some("tok".to_string());
settings.channels.signal_http_url = Some("http://127.0.0.1:8080".to_string());
settings.channels.signal_account = Some("+15551234567".to_string());
settings.channels.signal_allow_from = Some("+15551234567,+15557654321".to_string());
settings.channels.wasm_channels_dir = Some(PathBuf::from("/tmp/settings-channels"));
settings.channels.wasm_channels_enabled = false;
let cfg = ChannelsConfig::resolve(&settings, false).unwrap();
let http = cfg.http.expect("HTTP should be enabled from settings");
assert_eq!(http.port, 9090);
assert_eq!(http.host, "10.0.0.1");
}
let cfg = ChannelsConfig::resolve(&settings, "owner-scope").expect("resolve");
/// Settings round-trip through DB map for new gateway fields.
#[test]
fn settings_gateway_fields_db_roundtrip() {
let mut settings = crate::settings::Settings::default();
settings.channels.gateway_port = Some(4000);
settings.channels.gateway_host = Some("0.0.0.0".to_string());
settings.channels.gateway_auth_token = Some("tok-abc".to_string());
settings.channels.gateway_user_id = Some("myuser".to_string());
settings.channels.cli_enabled = false;
let http = cfg.http.expect("http config");
assert_eq!(http.host, "127.0.0.2");
assert_eq!(http.port, 8181);
assert_eq!(http.user_id, "owner-scope");
let map = settings.to_db_map();
let restored = crate::settings::Settings::from_db_map(&map);
let gateway = cfg.gateway.expect("gateway config");
assert_eq!(gateway.host, "127.0.0.3");
assert_eq!(gateway.port, 9191);
assert_eq!(gateway.auth_token.as_deref(), Some("tok"));
assert_eq!(gateway.user_id, "owner-scope");
let signal = cfg.signal.expect("signal config");
assert_eq!(signal.account, "+15551234567");
assert_eq!(signal.allow_from, vec!["+15551234567", "+15557654321"]);
assert_eq!(restored.channels.gateway_port, Some(4000));
assert_eq!(restored.channels.gateway_host.as_deref(), Some("0.0.0.0"));
assert_eq!(
restored.channels.gateway_auth_token.as_deref(),
Some("tok-abc")
cfg.wasm_channels_dir,
PathBuf::from("/tmp/settings-channels")
);
assert_eq!(restored.channels.gateway_user_id.as_deref(), Some("myuser"));
assert!(!restored.channels.cli_enabled);
}
/// Invalid boolean env values must produce errors, not silently degrade.
#[test]
fn resolve_rejects_invalid_bool_env() {
let _lock = crate::config::helpers::ENV_MUTEX.lock();
let settings = crate::settings::Settings::default();
// GATEWAY_ENABLED=maybe should error
unsafe {
std::env::set_var("GATEWAY_ENABLED", "maybe");
std::env::remove_var("HTTP_PORT");
std::env::remove_var("HTTP_HOST");
std::env::remove_var("SIGNAL_HTTP_URL");
std::env::remove_var("CLI_ENABLED");
std::env::remove_var("WASM_CHANNELS_ENABLED");
std::env::remove_var("GATEWAY_PORT");
std::env::remove_var("GATEWAY_HOST");
std::env::remove_var("GATEWAY_AUTH_TOKEN");
std::env::remove_var("GATEWAY_USER_ID");
std::env::remove_var("WASM_CHANNELS_DIR");
std::env::remove_var("TELEGRAM_OWNER_ID");
}
let result = ChannelsConfig::resolve(&settings, false);
assert!(result.is_err(), "GATEWAY_ENABLED=maybe should be rejected");
// CLI_ENABLED=on should error
unsafe {
std::env::remove_var("GATEWAY_ENABLED");
std::env::set_var("CLI_ENABLED", "on");
}
let result = ChannelsConfig::resolve(&settings, false);
assert!(result.is_err(), "CLI_ENABLED=on should be rejected");
// WASM_CHANNELS_ENABLED=yes should error
unsafe {
std::env::remove_var("CLI_ENABLED");
std::env::set_var("WASM_CHANNELS_ENABLED", "yes");
}
let result = ChannelsConfig::resolve(&settings, false);
assert!(
result.is_err(),
"WASM_CHANNELS_ENABLED=yes should be rejected"
);
// Cleanup
unsafe {
std::env::remove_var("WASM_CHANNELS_ENABLED");
}
assert!(!cfg.wasm_channels_enabled);
}
}
+46 -13
View File
@@ -26,7 +26,7 @@ mod tunnel;
mod wasm;
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex};
use std::sync::{LazyLock, Mutex, Once};
use crate::error::ConfigError;
use crate::settings::Settings;
@@ -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.
static INJECTED_VARS: LazyLock<Mutex<HashMap<String, String>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
static WARNED_EXPLICIT_DEFAULT_OWNER_ID: Once = Once::new();
/// Main configuration for the agent.
#[derive(Debug, Clone)]
pub struct Config {
pub owner_id: String,
pub database: DatabaseConfig,
pub llm: LlmConfig,
pub embeddings: EmbeddingsConfig,
@@ -118,6 +120,7 @@ impl Config {
installed_skills_dir: std::path::PathBuf,
) -> Self {
Self {
owner_id: "default".to_string(),
database: DatabaseConfig {
backend: DatabaseBackend::LibSql,
url: secrecy::SecretString::from("unused://test".to_string()),
@@ -228,13 +231,7 @@ impl Config {
pub async fn from_env_with_toml(
toml_path: Option<&std::path::Path>,
) -> Result<Self, ConfigError> {
let _ = dotenvy::dotenv();
crate::bootstrap::load_ironclaw_env();
let mut settings = Settings::load();
// Overlay TOML config file (values win over JSON settings)
Self::apply_toml_overlay(&mut settings, toml_path)?;
let settings = load_bootstrap_settings(toml_path)?;
Self::build(&settings).await
}
@@ -306,16 +303,15 @@ impl Config {
/// Build config from settings (shared by from_env and from_db).
async fn build(settings: &Settings) -> Result<Self, ConfigError> {
// Resolve tunnel first so channels can default to loopback when a
// tunnel handles external exposure (no need to bind 0.0.0.0).
let tunnel = TunnelConfig::resolve(settings)?;
let owner_id = resolve_owner_id(settings)?;
Ok(Self {
owner_id: owner_id.clone(),
database: DatabaseConfig::resolve()?,
llm: LlmConfig::resolve(settings)?,
embeddings: EmbeddingsConfig::resolve(settings)?,
channels: ChannelsConfig::resolve(settings, tunnel.is_enabled())?,
tunnel,
tunnel: TunnelConfig::resolve(settings)?,
channels: ChannelsConfig::resolve(settings, &owner_id)?,
agent: AgentConfig::resolve(settings)?,
safety: resolve_safety_config(settings)?,
wasm: WasmConfig::resolve(settings)?,
@@ -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.
///
/// This bridges the gap between secrets stored during onboarding and the
+10
View File
@@ -121,6 +121,9 @@ pub struct JobContext {
pub state: JobState,
/// User ID that owns this job (for workspace scoping).
pub user_id: String,
/// Channel-specific requester/actor ID, when different from the owner scope.
#[serde(skip_serializing_if = "Option::is_none")]
pub requester_id: Option<String>,
/// Conversation ID if linked to a conversation.
pub conversation_id: Option<Uuid>,
/// Job title.
@@ -202,6 +205,7 @@ impl JobContext {
job_id: Uuid::new_v4(),
state: JobState::Pending,
user_id: user_id.into(),
requester_id: None,
conversation_id: None,
title: title.into(),
description: description.into(),
@@ -233,6 +237,12 @@ impl JobContext {
self
}
/// Set the channel-specific requester/actor ID.
pub fn with_requester_id(mut self, requester_id: impl Into<String>) -> Self {
self.requester_id = Some(requester_id.into());
self
}
/// Transition to a new state.
pub fn transition_to(
&mut self,
+1
View File
@@ -106,6 +106,7 @@ impl JobStore for LibSqlBackend {
job_id: get_text(&row, 0).parse().unwrap_or_default(),
state,
user_id: get_text(&row, 6),
requester_id: None,
conversation_id: get_opt_text(&row, 1).and_then(|s| s.parse().ok()),
title: get_text(&row, 2),
description: get_text(&row, 3),
+23 -2
View File
@@ -247,6 +247,17 @@ pub(crate) fn opt_text_owned(s: Option<String>) -> libsql::Value {
}
}
pub(crate) fn normalize_notify_user(value: Option<String>) -> Option<String> {
value.and_then(|value| {
let trimmed = value.trim();
if trimmed.is_empty() || trimmed == "default" {
None
} else {
Some(trimmed.to_string())
}
})
}
/// Extract an i64 column, defaulting to 0.
pub(crate) fn get_i64(row: &libsql::Row, idx: i32) -> i64 {
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 {
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_failure: get_i64(row, 15) != 0,
on_attention: get_i64(row, 16) != 0,
@@ -419,7 +430,17 @@ mod tests {
use chrono::{TimeZone, Utc};
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]
fn test_parse_timestamp_accepts_rfc3339_and_legacy_naive_formats() {
+2 -2
View File
@@ -57,7 +57,7 @@ impl RoutineStore for LibSqlBackend {
max_concurrent,
dedup_window_secs,
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_failure as i64,
routine.notify.on_attention as i64,
@@ -250,7 +250,7 @@ impl RoutineStore for LibSqlBackend {
max_concurrent,
dedup_window_secs,
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_failure as i64,
routine.notify.on_attention as i64,
+72 -2
View File
@@ -462,7 +462,7 @@ CREATE TABLE IF NOT EXISTS routines (
max_concurrent INTEGER NOT NULL DEFAULT 1,
dedup_window_secs INTEGER,
notify_channel TEXT,
notify_user TEXT NOT NULL DEFAULT 'default',
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,
@@ -546,7 +546,9 @@ CREATE INDEX IF NOT EXISTS idx_tool_failures_unrepaired ON tool_failures(tool_na
-- routines
CREATE INDEX IF NOT EXISTS idx_routines_next_fire ON routines(next_fire_at);
CREATE INDEX IF NOT EXISTS idx_routines_event_triggers ON routines(user_id);
CREATE INDEX IF NOT EXISTS idx_routines_event_triggers
ON routines(trigger_type, user_id)
WHERE enabled = 1 AND trigger_type IN ('event', 'system_event');
-- routine_runs
CREATE INDEX IF NOT EXISTS idx_routine_runs_status ON routine_runs(status);
@@ -654,6 +656,74 @@ END;
r#"
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;
"#,
),
(
13,
"routine_notify_user_nullable",
// Remove the legacy 'default' sentinel from routine notify_user.
// SQLite cannot drop NOT NULL / DEFAULT constraints in place, so we
// rebuild the table and normalize existing 'default' values to NULL.
r#"
PRAGMA foreign_keys=OFF;
CREATE TABLE IF NOT EXISTS routines_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
user_id TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
trigger_type TEXT NOT NULL,
trigger_config TEXT NOT NULL,
action_type TEXT NOT NULL,
action_config TEXT NOT NULL,
cooldown_secs INTEGER NOT NULL DEFAULT 300,
max_concurrent INTEGER NOT NULL DEFAULT 1,
dedup_window_secs INTEGER,
notify_channel TEXT,
notify_user TEXT,
notify_on_success INTEGER NOT NULL DEFAULT 0,
notify_on_failure INTEGER NOT NULL DEFAULT 1,
notify_on_attention INTEGER NOT NULL DEFAULT 1,
state TEXT NOT NULL DEFAULT '{}',
last_run_at TEXT,
next_fire_at TEXT,
run_count INTEGER NOT NULL DEFAULT 0,
consecutive_failures INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE (user_id, name)
);
INSERT INTO routines_new (
id, name, description, user_id, enabled,
trigger_type, trigger_config, action_type, action_config,
cooldown_secs, max_concurrent, dedup_window_secs,
notify_channel, notify_user, notify_on_success, notify_on_failure, notify_on_attention,
state, last_run_at, next_fire_at, run_count, consecutive_failures,
created_at, updated_at
)
SELECT
id, name, description, user_id, enabled,
trigger_type, trigger_config, action_type, action_config,
cooldown_secs, max_concurrent, dedup_window_secs,
notify_channel,
CASE WHEN notify_user = 'default' THEN NULL ELSE notify_user END,
notify_on_success, notify_on_failure, notify_on_attention,
state, last_run_at, next_fire_at, run_count, consecutive_failures,
created_at, updated_at
FROM routines;
DROP TABLE routines;
ALTER TABLE routines_new RENAME TO routines;
CREATE INDEX IF NOT EXISTS idx_routines_user ON routines(user_id);
CREATE INDEX IF NOT EXISTS idx_routines_next_fire ON routines(next_fire_at);
CREATE INDEX IF NOT EXISTS idx_routines_event_triggers
ON routines(trigger_type, user_id)
WHERE enabled = 1 AND trigger_type IN ('event', 'system_event');
PRAGMA foreign_keys=ON;
"#,
),
];
+3
View File
@@ -122,6 +122,9 @@ pub enum ChannelError {
#[error("Failed to send response on channel {name}: {reason}")]
SendFailed { name: String, reason: String },
#[error("Channel {name} is missing a routing target: {reason}")]
MissingRoutingTarget { name: String, reason: String },
#[error("Invalid message format: {0}")]
InvalidMessage(String),
+5 -1
View File
@@ -3419,6 +3419,7 @@ impl ExtensionManager {
Arc::clone(&channel_runtime),
Arc::clone(&pairing_store),
settings_store,
self.user_id.clone(),
)
.with_secrets_store(Arc::clone(&self.secrets));
loader
@@ -3435,6 +3436,7 @@ impl ExtensionManager {
Arc::clone(&channel_runtime),
Arc::clone(&pairing_store),
settings_store,
self.user_id.clone(),
)
.with_secrets_store(Arc::clone(&self.secrets));
loader
@@ -3462,6 +3464,7 @@ impl ExtensionManager {
owner_id: Option<i64>,
) -> Result<ActivateResult, ExtensionError> {
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 secret_header = loaded.webhook_secret_header().map(|s| s.to_string());
let sig_key_secret_name = loaded.signature_key_secret_name();
@@ -3475,7 +3478,7 @@ impl ExtensionManager {
.ok()
.map(|s| s.expose().to_string());
let channel_arc = Arc::new(loaded.channel);
let channel_arc = Arc::new(loaded.channel.with_owner_actor_id(owner_actor_id));
// Inject runtime config (tunnel_url, webhook_secret, owner_id)
{
@@ -5615,6 +5618,7 @@ mod tests {
runtime,
prepared,
capabilities,
"default",
"{}".to_string(),
pairing_store,
None,
+1
View File
@@ -227,6 +227,7 @@ impl Store {
job_id: row.get("id"),
state,
user_id: row.get::<_, String>("user_id"),
requester_id: None,
conversation_id: row.get("conversation_id"),
title: row.get("title"),
description: row.get("description"),
+19 -15
View File
@@ -153,7 +153,8 @@ async fn async_main() -> anyhow::Result<()> {
provider_only: *provider_only,
quick: *quick,
};
let mut wizard = SetupWizard::with_config(config);
let mut wizard =
SetupWizard::try_with_config_and_toml(config, cli.config.as_deref())?;
wizard.run().await?;
}
#[cfg(not(any(feature = "postgres", feature = "libsql")))]
@@ -195,10 +196,13 @@ async fn async_main() -> anyhow::Result<()> {
{
println!("Onboarding needed: {}", reason);
println!();
let mut wizard = SetupWizard::with_config(SetupConfig {
quick: true,
..Default::default()
});
let mut wizard = SetupWizard::try_with_config_and_toml(
SetupConfig {
quick: true,
..Default::default()
},
cli.config.as_deref(),
)?;
wizard.run().await?;
}
@@ -282,9 +286,12 @@ async fn async_main() -> anyhow::Result<()> {
// Create CLI channel
let repl_channel = if let Some(ref msg) = cli.message {
Some(ReplChannel::with_message(msg.clone()))
Some(ReplChannel::with_message_for_user(
config.owner_id.clone(),
msg.clone(),
))
} else if config.channels.cli.enabled {
let repl = ReplChannel::new();
let repl = ReplChannel::with_user_id(config.owner_id.clone());
repl.suppress_banner();
Some(repl)
} else {
@@ -311,12 +318,7 @@ async fn async_main() -> anyhow::Result<()> {
webhook_routes.push(webhooks::routes(ToolWebhookState {
tools: Arc::clone(&components.tools),
routine_engine: Arc::clone(&shared_routine_engine_slot),
user_id: config
.channels
.gateway
.as_ref()
.map(|g| g.user_id.clone())
.unwrap_or_else(|| "default".to_string()),
user_id: config.owner_id.clone(),
secrets_store: components.secrets_store.clone(),
}));
@@ -703,6 +705,7 @@ async fn async_main() -> anyhow::Result<()> {
.map(|db| Arc::clone(db) as Arc<dyn ironclaw::db::SettingsStore>);
let deps = AgentDeps {
owner_id: config.owner_id.clone(),
store: components.db,
llm: components.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_settings_store_clone = sighup_settings_store.clone();
let sighup_secrets_store = components.secrets_store.clone();
let sighup_owner_id = config.owner_id.clone();
let mut shutdown_rx = shutdown_tx.subscribe();
tokio::spawn(async move {
@@ -805,7 +809,7 @@ async fn async_main() -> anyhow::Result<()> {
if let Some(ref secrets_store) = sighup_secrets_store {
// Inject HTTP webhook secret from encrypted store
if let Ok(webhook_secret) = secrets_store
.get_decrypted("default", "http_webhook_secret")
.get_decrypted(&sighup_owner_id, "http_webhook_secret")
.await
{
// Thread-safe: Uses INJECTED_VARS mutex instead of unsafe std::env::set_var
@@ -821,7 +825,7 @@ async fn async_main() -> anyhow::Result<()> {
// Reload config (now with secrets injected into environment)
let new_config = match &sighup_settings_store_clone {
Some(store) => {
ironclaw::config::Config::from_db(store.as_ref(), "default").await
ironclaw::config::Config::from_db(store.as_ref(), &sighup_owner_id).await
}
None => ironclaw::config::Config::from_env().await,
};
+13
View File
@@ -16,6 +16,14 @@ pub struct Settings {
#[serde(default, alias = "setup_completed")]
pub onboard_completed: bool,
/// Stable owner scope for this IronClaw instance.
///
/// This is bootstrap configuration loaded from env / disk / TOML. We do
/// not persist it in the per-user DB settings table because the DB lookup
/// itself already requires the owner scope to be known.
#[serde(default)]
pub owner_id: Option<String>,
// === Step 1: Database ===
/// Database backend: "postgres" or "libsql".
#[serde(default)]
@@ -733,6 +741,10 @@ impl Settings {
let mut settings = Self::default();
for (key, value) in map {
if key == "owner_id" {
continue;
}
// Convert the JSONB value to a string for the existing set() method
let value_str = match value {
serde_json::Value::String(s) => s.clone(),
@@ -772,6 +784,7 @@ impl Settings {
let mut map = std::collections::HashMap::new();
collect_settings_json(&json, String::new(), &mut map);
map.remove("owner_id");
map
}
+708 -268
View File
File diff suppressed because it is too large Load Diff
+3 -2
View File
@@ -439,6 +439,7 @@ impl TestHarnessBuilder {
};
let deps = AgentDeps {
owner_id: "default".to_string(),
store: Some(Arc::clone(&db)),
llm,
cheap_llm: None,
@@ -1077,7 +1078,7 @@ mod tests {
},
notify: NotifyConfig {
channel: None,
user: "user1".to_string(),
user: Some("user1".to_string()),
on_attention: true,
on_failure: true,
on_success: false,
@@ -1210,7 +1211,7 @@ mod tests {
},
notify: NotifyConfig {
channel: None,
user: "user1".to_string(),
user: Some("user1".to_string()),
on_attention: false,
on_failure: false,
on_success: false,
+37 -5
View File
@@ -129,21 +129,28 @@ impl Tool for MessageTool {
.map(|c| c.to_string())
};
// Get target: use param → conversation default → job metadata
// Get target: use param → conversation default → job metadata → owner scope
// fallback when a specific channel is known.
let target = if let Some(t) = params.get("target").and_then(|v| v.as_str()) {
t.to_string()
Some(t.to_string())
} else if let Some(t) = self
.default_target
.read()
.unwrap_or_else(|e| e.into_inner())
.clone()
{
t
Some(t)
} else if let Some(t) = ctx.metadata.get("notify_user").and_then(|v| v.as_str()) {
t.to_string()
Some(t.to_string())
} else if channel.is_some() {
Some(ctx.user_id.clone())
} else {
None
};
let Some(target) = target else {
return Err(ToolError::ExecutionFailed(
"No target specified and no active conversation. Provide target parameter."
"No target specified and no channel-scoped routing target could be resolved. Provide target parameter."
.to_string(),
));
};
@@ -659,6 +666,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]
async fn message_tool_no_metadata_still_errors() {
// When neither conversation context nor metadata is set, should still
+2 -3
View File
@@ -106,7 +106,7 @@ pub(crate) fn routine_create_parameters_schema() -> serde_json::Value {
},
"notify_user": {
"type": "string",
"description": "User or destination to notify, for example a username or chat ID."
"description": "Optional explicit user or destination to notify, for example a username or chat ID. Omit it to use the configured owner's last-seen target for that channel."
},
"timezone": {
"type": "string",
@@ -387,8 +387,7 @@ impl Tool for RoutineCreateTool {
user: params
.get("notify_user")
.and_then(|v| v.as_str())
.unwrap_or("default")
.to_string(),
.map(String::from),
..NotifyConfig::default()
},
last_run_at: None,
+188 -8
View File
@@ -841,13 +841,7 @@ impl Tool for WasmToolWrapper {
// Pre-resolve host credentials from secrets store (async, before blocking task).
// This decrypts the secrets once so the sync http_request() host function
// can inject them without needing async access.
//
// BUG FIX: ExtensionManager stores OAuth tokens under user_id "default"
// (hardcoded at construction in app.rs), but this was previously looking
// them up under ctx.user_id — which could be a Telegram user ID, web
// gateway user, etc. — causing credential resolution to silently fail.
// Must match the storage key until per-user credential isolation is added.
let credential_user_id = "default";
let credential_user_id = &ctx.user_id;
let host_credentials = resolve_host_credentials(
&self.capabilities,
self.secrets_store.as_deref(),
@@ -1165,6 +1159,13 @@ async fn resolve_host_credentials(
let secret = match store.get_decrypted(user_id, &mapping.secret_name).await {
Ok(s) => Some(s),
Err(e) => {
tracing::trace!(
user_id = %user_id,
secret_name = %mapping.secret_name,
error = %e,
"No matching host credential resolved for WASM tool in the requested scope"
);
// If lookup fails and we're not already looking up "default", try "default" as fallback
if user_id != "default" {
tracing::debug!(
@@ -1385,7 +1386,16 @@ fn build_tool_usage_hint(tool_name: &str, schema: &serde_json::Value) -> String
#[cfg(test)]
mod tests {
use std::sync::Arc;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use uuid::Uuid;
use crate::context::JobContext;
use crate::secrets::{
CreateSecretParams, DecryptedSecret, InMemorySecretsStore, Secret, SecretError, SecretRef,
SecretsStore,
};
use crate::testing::credentials::{
TEST_BEARER_TOKEN_123, TEST_GOOGLE_OAUTH_FRESH, TEST_GOOGLE_OAUTH_LEGACY,
@@ -1396,6 +1406,78 @@ mod tests {
use crate::tools::wasm::capabilities::Capabilities;
use crate::tools::wasm::runtime::{WasmRuntimeConfig, WasmToolRuntime};
struct RecordingSecretsStore {
inner: InMemorySecretsStore,
get_decrypted_lookups: Mutex<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]
fn test_wrapper_creation() {
// 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]
async fn test_resolve_host_credentials_missing_secret() {
use std::collections::HashMap;