mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
Refactor owner scope across channels and fix default routing fallback (#1151)
* refactor: add explicit owner scope across channels * fix: tighten routine owner target routing * fix: address owner scope review feedback * Fix owner-scope onboarding and event trigger isolation * Tighten routing fallback and wizard owner validation * fix: address owner-scope follow-up review * fix: tighten owner-scope follow-up details * fix: import Channel trait in telegram test * fix: normalize http webhook sender ids * fix: address remaining owner-scope review issues * fix: reconcile config rebase fallout * fix: reconcile extension manager rebase drift * fix: address current copilot review regressions * fix: restore clippy matrix after rebase
This commit is contained in:
+132
-28
@@ -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, ¬ify_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(¬ify_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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -672,6 +672,7 @@ mod tests {
|
||||
runtime,
|
||||
prepared,
|
||||
capabilities,
|
||||
"default",
|
||||
"{}".to_string(),
|
||||
Arc::new(PairingStore::new()),
|
||||
None,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
"#,
|
||||
),
|
||||
];
|
||||
|
||||
@@ -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),
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user