From 35a79caf87bd6a943fa3dddab712568efc5c6e32 Mon Sep 17 00:00:00 2001 From: Zaki Manian Date: Tue, 3 Mar 2026 16:41:41 -0800 Subject: [PATCH] fix(web): assign unique thread_id to manual routine triggers (#500) * fix(web): assign unique thread_id to manual routine triggers Manual routine triggers via the web API created an IncomingMessage without a thread_id, causing session_manager.resolve_thread() to route the output to whatever thread was last associated with the (user, "gateway", None) key. This sets a unique thread_id of the form "routine-{id}-{timestamp}" so each manual trigger gets its own dedicated thread. Closes #484 Co-Authored-By: Claude Opus 4.6 * fix: add ownership check to routine trigger handler (IDOR) Address review feedback: verify routine.user_id matches the authenticated user before allowing the trigger, preventing unauthorized cross-user routine execution. Co-Authored-By: Claude Opus 4.6 * style: cargo fmt Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- src/channels/web/handlers/routines.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/src/channels/web/handlers/routines.rs b/src/channels/web/handlers/routines.rs index f23f3a94..6cdccfc6 100644 --- a/src/channels/web/handlers/routines.rs +++ b/src/channels/web/handlers/routines.rs @@ -147,6 +147,10 @@ pub async fn routines_trigger_handler( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Routine not found".to_string()))?; + if routine.user_id != state.user_id { + return Err((StatusCode::FORBIDDEN, "Access denied".to_string())); + } + // Send the routine prompt through the message pipeline as a manual trigger. let prompt = match &routine.action { crate::agent::routine::RoutineAction::Lightweight { prompt, .. } => prompt.clone(), @@ -156,7 +160,12 @@ pub async fn routines_trigger_handler( }; let content = format!("[routine:{}] {}", routine.name, prompt); - let msg = IncomingMessage::new("gateway", &state.user_id, content); + let thread_id = format!( + "routine-{}-{}", + routine_id, + chrono::Utc::now().timestamp_millis() + ); + let msg = IncomingMessage::new("gateway", &state.user_id, content).with_thread(thread_id); let tx_guard = state.msg_tx.read().await; let tx = tx_guard.as_ref().ok_or((