mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 01:19:34 +00:00
fix(security): block cross-channel approval thread hijacking (#1485)
Add source_channel field to Thread struct and verify channel authorization before allowing approval messages to target threads. The web gateway channel is allowed as a trusted approval UI, and threads without a source_channel (e.g. deserialized from older DB records) are permitted for backward compatibility. Closes #1485 Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
@@ -731,7 +731,7 @@ impl Agent {
|
||||
{
|
||||
use crate::agent::session::Thread;
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(id, sess.id);
|
||||
let thread = Thread::with_id(id, sess.id, Some("gateway"));
|
||||
sess.active_thread = Some(id);
|
||||
sess.threads.entry(id).or_insert(thread);
|
||||
}
|
||||
|
||||
@@ -319,7 +319,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_format_turns() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
thread.start_turn("Hello");
|
||||
thread.complete_turn("Hi there");
|
||||
thread.start_turn("How are you?");
|
||||
@@ -351,7 +351,7 @@ mod tests {
|
||||
/// Helper: build a thread with `n` completed turns.
|
||||
/// Turn `i` has user_input "msg-{i}" and response "resp-{i}".
|
||||
fn make_thread(n: usize) -> Thread {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
for i in 0..n {
|
||||
thread.start_turn(format!("msg-{}", i));
|
||||
thread.complete_turn(format!("resp-{}", i));
|
||||
@@ -457,7 +457,7 @@ mod tests {
|
||||
async fn test_compact_truncate_empty_turns() {
|
||||
let llm = Arc::new(StubLlm::new("unused"));
|
||||
let compactor = make_compactor(llm);
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
assert!(thread.turns.is_empty());
|
||||
|
||||
let result = compactor
|
||||
@@ -698,7 +698,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_format_turns_for_storage_with_tool_calls() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
thread.start_turn("Search for X");
|
||||
// Record a tool call on the current turn
|
||||
if let Some(turn) = thread.turns.last_mut() {
|
||||
@@ -719,7 +719,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_format_turns_for_storage_incomplete_turn() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
thread.start_turn("In progress message");
|
||||
// Don't complete the turn
|
||||
|
||||
|
||||
@@ -2140,7 +2140,7 @@ mod tests {
|
||||
// Initialize a thread in the session so the loop can record tool calls.
|
||||
let thread_id = {
|
||||
let mut sess = session.lock().await;
|
||||
sess.create_thread().id
|
||||
sess.create_thread("test").id
|
||||
};
|
||||
|
||||
let message = IncomingMessage::new("test", "test-user", "do something");
|
||||
@@ -2245,7 +2245,7 @@ mod tests {
|
||||
let session = Arc::new(Mutex::new(Session::new("test-user")));
|
||||
let thread_id = {
|
||||
let mut sess = session.lock().await;
|
||||
sess.create_thread().id
|
||||
sess.create_thread("test").id
|
||||
};
|
||||
|
||||
let message = IncomingMessage::new("test", "test-user", "keep calling tools");
|
||||
|
||||
+100
-78
@@ -68,8 +68,8 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Create a new thread in this session.
|
||||
pub fn create_thread(&mut self) -> &mut Thread {
|
||||
let thread = Thread::new(self.id);
|
||||
pub fn create_thread(&mut self, channel: &str) -> &mut Thread {
|
||||
let thread = Thread::new(self.id, Some(channel));
|
||||
let thread_id = thread.id;
|
||||
self.active_thread = Some(thread_id);
|
||||
self.last_active_at = Utc::now();
|
||||
@@ -87,9 +87,9 @@ impl Session {
|
||||
}
|
||||
|
||||
/// Get or create the active thread.
|
||||
pub fn get_or_create_thread(&mut self) -> &mut Thread {
|
||||
pub fn get_or_create_thread(&mut self, channel: &str) -> &mut Thread {
|
||||
match self.active_thread {
|
||||
None => self.create_thread(),
|
||||
None => self.create_thread(channel),
|
||||
Some(id) => {
|
||||
if self.threads.contains_key(&id) {
|
||||
// Entry existence confirmed by contains_key above.
|
||||
@@ -100,7 +100,7 @@ impl Session {
|
||||
} else {
|
||||
// Stale active_thread ID: create a new thread, which
|
||||
// updates self.active_thread to the new thread's ID.
|
||||
self.create_thread()
|
||||
self.create_thread(channel)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,6 +225,9 @@ pub struct Thread {
|
||||
/// Messages queued while the thread was processing a turn.
|
||||
#[serde(default, skip_serializing_if = "VecDeque::is_empty")]
|
||||
pub pending_messages: VecDeque<String>,
|
||||
/// Channel that created this thread (for approval authorization).
|
||||
#[serde(default)]
|
||||
pub source_channel: Option<String>,
|
||||
}
|
||||
|
||||
/// Maximum number of messages that can be queued while a thread is processing.
|
||||
@@ -235,7 +238,7 @@ pub const MAX_PENDING_MESSAGES: usize = 10;
|
||||
|
||||
impl Thread {
|
||||
/// Create a new thread.
|
||||
pub fn new(session_id: Uuid) -> Self {
|
||||
pub fn new(session_id: Uuid, source_channel: Option<&str>) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id: Uuid::new_v4(),
|
||||
@@ -248,11 +251,12 @@ impl Thread {
|
||||
pending_approval: None,
|
||||
pending_auth: None,
|
||||
pending_messages: VecDeque::new(),
|
||||
source_channel: source_channel.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a thread with a specific ID (for DB hydration).
|
||||
pub fn with_id(id: Uuid, session_id: Uuid) -> Self {
|
||||
pub fn with_id(id: Uuid, session_id: Uuid, source_channel: Option<&str>) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
id,
|
||||
@@ -265,6 +269,7 @@ impl Thread {
|
||||
pending_approval: None,
|
||||
pending_auth: None,
|
||||
pending_messages: VecDeque::new(),
|
||||
source_channel: source_channel.map(String::from),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -696,13 +701,13 @@ mod tests {
|
||||
let mut session = Session::new("user-123");
|
||||
assert!(session.active_thread.is_none());
|
||||
|
||||
session.create_thread();
|
||||
session.create_thread("test");
|
||||
assert!(session.active_thread.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thread_turns() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("Hello");
|
||||
assert_eq!(thread.state, ThreadState::Processing);
|
||||
@@ -715,7 +720,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_thread_messages() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("First message");
|
||||
thread.complete_turn("First response");
|
||||
@@ -738,7 +743,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_restore_from_messages() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// First add some turns
|
||||
thread.start_turn("Original message");
|
||||
@@ -764,7 +769,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_restore_from_messages_incomplete_turn() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Messages with incomplete last turn (no assistant response)
|
||||
let messages = vec![
|
||||
@@ -783,7 +788,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_enter_auth_mode() {
|
||||
let before = Utc::now();
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
assert!(thread.pending_auth.is_none());
|
||||
|
||||
thread.enter_auth_mode("telegram".to_string());
|
||||
@@ -796,7 +801,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_take_pending_auth() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
thread.enter_auth_mode("notion".to_string());
|
||||
|
||||
let pending = thread.take_pending_auth();
|
||||
@@ -811,7 +816,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_pending_auth_serialization() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
thread.enter_auth_mode("openai".to_string());
|
||||
|
||||
let json = serde_json::to_string(&thread).expect("should serialize");
|
||||
@@ -841,7 +846,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_pending_auth_default_none() {
|
||||
// Deserialization of old data without pending_auth should default to None
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
thread.pending_auth = None;
|
||||
let json = serde_json::to_string(&thread).expect("serialize");
|
||||
|
||||
@@ -855,7 +860,7 @@ mod tests {
|
||||
fn test_thread_with_id() {
|
||||
let specific_id = Uuid::new_v4();
|
||||
let session_id = Uuid::new_v4();
|
||||
let thread = Thread::with_id(specific_id, session_id);
|
||||
let thread = Thread::with_id(specific_id, session_id, None);
|
||||
|
||||
assert_eq!(thread.id, specific_id);
|
||||
assert_eq!(thread.session_id, session_id);
|
||||
@@ -867,7 +872,7 @@ mod tests {
|
||||
fn test_thread_with_id_restore_messages() {
|
||||
let thread_id = Uuid::new_v4();
|
||||
let session_id = Uuid::new_v4();
|
||||
let mut thread = Thread::with_id(thread_id, session_id);
|
||||
let mut thread = Thread::with_id(thread_id, session_id, None);
|
||||
|
||||
let messages = vec![
|
||||
ChatMessage::user("Hello from DB"),
|
||||
@@ -886,7 +891,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_restore_from_messages_empty() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Add a turn first, then restore with empty vec
|
||||
thread.start_turn("hello");
|
||||
@@ -902,7 +907,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_restore_from_messages_only_assistant_messages() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Only assistant messages (no user messages to anchor turns)
|
||||
let messages = vec![
|
||||
@@ -919,7 +924,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_restore_from_messages_multiple_user_messages_in_a_row() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Two user messages with no assistant response between them
|
||||
let messages = vec![
|
||||
@@ -946,8 +951,8 @@ mod tests {
|
||||
fn test_thread_switch() {
|
||||
let mut session = Session::new("user-1");
|
||||
|
||||
let t1_id = session.create_thread().id;
|
||||
let t2_id = session.create_thread().id;
|
||||
let t1_id = session.create_thread("test").id;
|
||||
let t2_id = session.create_thread("test").id;
|
||||
|
||||
// After creating two threads, active should be the last one
|
||||
assert_eq!(session.active_thread, Some(t2_id));
|
||||
@@ -967,8 +972,8 @@ mod tests {
|
||||
fn test_get_or_create_thread_idempotent() {
|
||||
let mut session = Session::new("user-1");
|
||||
|
||||
let tid1 = session.get_or_create_thread().id;
|
||||
let tid2 = session.get_or_create_thread().id;
|
||||
let tid1 = session.get_or_create_thread("test").id;
|
||||
let tid2 = session.get_or_create_thread("test").id;
|
||||
|
||||
// Should return the same thread (not create a new one each time)
|
||||
assert_eq!(tid1, tid2);
|
||||
@@ -977,7 +982,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_truncate_turns() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
for i in 0..5 {
|
||||
thread.start_turn(format!("msg-{}", i));
|
||||
@@ -1001,7 +1006,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_truncate_turns_noop_when_fewer() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("only one");
|
||||
thread.complete_turn("response");
|
||||
@@ -1013,7 +1018,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_thread_interrupt_and_resume() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("do something");
|
||||
assert_eq!(thread.state, ThreadState::Processing);
|
||||
@@ -1031,7 +1036,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_resume_only_from_interrupted() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Idle thread: resume should be a no-op
|
||||
assert_eq!(thread.state, ThreadState::Idle);
|
||||
@@ -1047,7 +1052,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_turn_fail() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("risky operation");
|
||||
thread.fail_turn("connection timed out");
|
||||
@@ -1063,7 +1068,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_messages_with_incomplete_last_turn() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("first");
|
||||
thread.complete_turn("first reply");
|
||||
@@ -1079,7 +1084,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_thread_serialization_round_trip() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("hello");
|
||||
thread.complete_turn("world");
|
||||
@@ -1097,7 +1102,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_session_serialization_round_trip() {
|
||||
let mut session = Session::new("user-ser");
|
||||
session.create_thread();
|
||||
session.create_thread("test");
|
||||
session.auto_approve_tool("echo");
|
||||
|
||||
let json = serde_json::to_string(&session).unwrap();
|
||||
@@ -1135,7 +1140,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_turn_number_increments() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Before any turns, turn_number() is 1 (1-indexed for display)
|
||||
assert_eq!(thread.turn_number(), 1);
|
||||
@@ -1150,7 +1155,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_complete_turn_on_empty_thread() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Completing a turn when there are no turns should be a safe no-op
|
||||
thread.complete_turn("phantom response");
|
||||
@@ -1160,7 +1165,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_fail_turn_on_empty_thread() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Failing a turn when there are no turns should be a safe no-op
|
||||
thread.fail_turn("phantom error");
|
||||
@@ -1170,7 +1175,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_pending_approval_flow() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
let approval = PendingApproval {
|
||||
request_id: Uuid::new_v4(),
|
||||
@@ -1197,7 +1202,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_clear_pending_approval() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
let approval = PendingApproval {
|
||||
request_id: Uuid::new_v4(),
|
||||
@@ -1226,7 +1231,7 @@ mod tests {
|
||||
assert!(session.active_thread().is_none());
|
||||
assert!(session.active_thread_mut().is_none());
|
||||
|
||||
let tid = session.create_thread().id;
|
||||
let tid = session.create_thread("test").id;
|
||||
|
||||
assert!(session.active_thread().is_some());
|
||||
assert_eq!(session.active_thread().unwrap().id, tid);
|
||||
@@ -1243,7 +1248,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_messages_includes_tool_calls() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("Search for X");
|
||||
{
|
||||
@@ -1275,7 +1280,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_messages_multiple_tool_calls_per_turn() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("Do two things");
|
||||
{
|
||||
@@ -1302,7 +1307,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_restore_from_messages_with_tool_calls() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Build a message sequence with tool calls
|
||||
let tc = ToolCall {
|
||||
@@ -1333,7 +1338,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_restore_from_messages_with_tool_error() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
let tc = ToolCall {
|
||||
id: "call_0".to_string(),
|
||||
@@ -1363,7 +1368,7 @@ mod tests {
|
||||
fn test_messages_round_trip_with_tools() {
|
||||
// Build a thread with tool calls, get messages(), restore, get messages() again
|
||||
// The two message sequences should be equivalent.
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("Do search");
|
||||
{
|
||||
@@ -1376,7 +1381,7 @@ mod tests {
|
||||
let messages_original = thread.messages();
|
||||
|
||||
// Restore into a new thread
|
||||
let mut thread2 = Thread::new(Uuid::new_v4());
|
||||
let mut thread2 = Thread::new(Uuid::new_v4(), None);
|
||||
thread2.restore_from_messages(messages_original.clone());
|
||||
|
||||
let messages_restored = thread2.messages();
|
||||
@@ -1398,7 +1403,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_restore_multi_stage_tool_calls() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
let tc1 = ToolCall {
|
||||
id: "call_a".to_string(),
|
||||
@@ -1439,7 +1444,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_messages_truncates_large_tool_results() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
thread.start_turn("Read big file");
|
||||
{
|
||||
@@ -1462,13 +1467,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_thread_message_queue() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Queue is initially empty
|
||||
assert!(thread.pending_messages.is_empty());
|
||||
assert!(thread.take_pending_message().is_none());
|
||||
|
||||
// Queue messages and verify FIFO ordering
|
||||
assert!(thread.queue_message("first".to_string()));
|
||||
assert!(thread.queue_message("second".to_string()));
|
||||
assert!(thread.queue_message("third".to_string()));
|
||||
@@ -1479,17 +1482,14 @@ mod tests {
|
||||
assert_eq!(thread.take_pending_message(), Some("third".to_string()));
|
||||
assert!(thread.take_pending_message().is_none());
|
||||
|
||||
// Fill to capacity — all 10 should succeed
|
||||
for i in 0..MAX_PENDING_MESSAGES {
|
||||
assert!(thread.queue_message(format!("msg-{}", i)));
|
||||
}
|
||||
assert_eq!(thread.pending_messages.len(), MAX_PENDING_MESSAGES);
|
||||
|
||||
// 11th message rejected by queue_message itself
|
||||
assert!(!thread.queue_message("overflow".to_string()));
|
||||
assert_eq!(thread.pending_messages.len(), MAX_PENDING_MESSAGES);
|
||||
|
||||
// Drain and verify order
|
||||
for i in 0..MAX_PENDING_MESSAGES {
|
||||
assert_eq!(thread.take_pending_message(), Some(format!("msg-{}", i)));
|
||||
}
|
||||
@@ -1498,13 +1498,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_thread_message_queue_serialization() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Empty queue should not appear in serialization (skip_serializing_if)
|
||||
let json = serde_json::to_string(&thread).unwrap();
|
||||
assert!(!json.contains("pending_messages"));
|
||||
|
||||
// Non-empty queue should serialize and deserialize
|
||||
thread.queue_message("queued msg".to_string());
|
||||
let json = serde_json::to_string(&thread).unwrap();
|
||||
assert!(json.contains("pending_messages"));
|
||||
@@ -1517,11 +1515,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_thread_message_queue_default_on_old_data() {
|
||||
// Deserialization of old data without pending_messages should default to empty
|
||||
let thread = Thread::new(Uuid::new_v4());
|
||||
let thread = Thread::new(Uuid::new_v4(), None);
|
||||
let json = serde_json::to_string(&thread).unwrap();
|
||||
|
||||
// The field is absent (skip_serializing_if), simulating old data
|
||||
assert!(!json.contains("pending_messages"));
|
||||
let restored: Thread = serde_json::from_str(&json).unwrap();
|
||||
assert!(restored.pending_messages.is_empty());
|
||||
@@ -1529,18 +1525,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_interrupt_clears_pending_messages() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Start a turn so there's something to interrupt
|
||||
thread.start_turn("initial input");
|
||||
|
||||
// Queue several messages while "processing"
|
||||
thread.queue_message("queued-1".to_string());
|
||||
thread.queue_message("queued-2".to_string());
|
||||
thread.queue_message("queued-3".to_string());
|
||||
assert_eq!(thread.pending_messages.len(), 3);
|
||||
|
||||
// Interrupt should clear the queue
|
||||
thread.interrupt();
|
||||
assert!(thread.pending_messages.is_empty());
|
||||
assert_eq!(thread.state, ThreadState::Interrupted);
|
||||
@@ -1548,27 +1541,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_thread_state_idle_after_full_drain() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Simulate a full drain cycle: start turn, queue messages, complete turn,
|
||||
// then drain all queued messages as a single merged turn (#259).
|
||||
thread.start_turn("turn 1");
|
||||
assert_eq!(thread.state, ThreadState::Processing);
|
||||
|
||||
thread.queue_message("queued-a".to_string());
|
||||
thread.queue_message("queued-b".to_string());
|
||||
|
||||
// Complete the turn (simulates process_user_input finishing)
|
||||
thread.complete_turn("response 1");
|
||||
assert_eq!(thread.state, ThreadState::Idle);
|
||||
|
||||
// Drain: merge all queued messages and process as a single turn
|
||||
let merged = thread.drain_pending_messages().unwrap();
|
||||
assert_eq!(merged, "queued-a\nqueued-b");
|
||||
thread.start_turn(&merged);
|
||||
thread.complete_turn("response for merged");
|
||||
|
||||
// Queue is fully drained, thread is idle
|
||||
assert!(thread.drain_pending_messages().is_none());
|
||||
assert!(thread.pending_messages.is_empty());
|
||||
assert_eq!(thread.state, ThreadState::Idle);
|
||||
@@ -1576,12 +1564,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_drain_pending_messages_merges_with_newlines() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Empty queue returns None
|
||||
assert!(thread.drain_pending_messages().is_none());
|
||||
|
||||
// Single message returned as-is (no trailing newline)
|
||||
thread.queue_message("only one".to_string());
|
||||
assert_eq!(
|
||||
thread.drain_pending_messages(),
|
||||
@@ -1589,7 +1575,6 @@ mod tests {
|
||||
);
|
||||
assert!(thread.pending_messages.is_empty());
|
||||
|
||||
// Multiple messages joined with newlines
|
||||
thread.queue_message("hey".to_string());
|
||||
thread.queue_message("can you check the server".to_string());
|
||||
thread.queue_message("it started 10 min ago".to_string());
|
||||
@@ -1599,25 +1584,62 @@ mod tests {
|
||||
);
|
||||
assert!(thread.pending_messages.is_empty());
|
||||
|
||||
// Queue is empty after drain
|
||||
assert!(thread.drain_pending_messages().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_requeue_drained_preserves_content_at_front() {
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
|
||||
// Re-queue into empty queue
|
||||
thread.requeue_drained("failed batch".to_string());
|
||||
assert_eq!(thread.pending_messages.len(), 1);
|
||||
assert_eq!(thread.pending_messages[0], "failed batch");
|
||||
|
||||
// New messages go behind the re-queued content
|
||||
thread.queue_message("new msg".to_string());
|
||||
assert_eq!(thread.pending_messages.len(), 2);
|
||||
|
||||
// Drain should return re-queued content first (front of queue)
|
||||
let merged = thread.drain_pending_messages().unwrap();
|
||||
assert_eq!(merged, "failed batch\nnew msg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thread_new_stores_source_channel() {
|
||||
let thread = Thread::new(Uuid::new_v4(), Some("gateway"));
|
||||
assert_eq!(thread.source_channel.as_deref(), Some("gateway"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thread_new_none_channel() {
|
||||
let thread = Thread::new(Uuid::new_v4(), None);
|
||||
assert!(thread.source_channel.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_thread_with_id_stores_source_channel() {
|
||||
let thread = Thread::with_id(Uuid::new_v4(), Uuid::new_v4(), Some("http"));
|
||||
assert_eq!(thread.source_channel.as_deref(), Some("http"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_thread_sets_source_channel() {
|
||||
let mut session = Session::new("user-chan");
|
||||
let thread_id = session.create_thread("gateway").id;
|
||||
let thread = session.threads.get(&thread_id).unwrap();
|
||||
assert_eq!(thread.source_channel.as_deref(), Some("gateway"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_source_channel_serde_backcompat() {
|
||||
let json = r#"{
|
||||
"id": "00000000-0000-0000-0000-000000000001",
|
||||
"session_id": "00000000-0000-0000-0000-000000000002",
|
||||
"state": "Idle",
|
||||
"turns": [],
|
||||
"created_at": "2025-01-01T00:00:00Z",
|
||||
"updated_at": "2025-01-01T00:00:00Z",
|
||||
"metadata": null
|
||||
}"#;
|
||||
let thread: Thread = serde_json::from_str(json).unwrap();
|
||||
assert!(thread.source_channel.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ impl SessionManager {
|
||||
// Create new thread (always create a new one for a new key)
|
||||
let thread_id = {
|
||||
let mut sess = session.lock().await;
|
||||
let thread = sess.create_thread();
|
||||
let thread = sess.create_thread(channel);
|
||||
thread.id
|
||||
};
|
||||
|
||||
@@ -443,7 +443,7 @@ mod tests {
|
||||
let session = Arc::new(Mutex::new(Session::new("user-hydrate")));
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(thread_id, sess.id);
|
||||
let thread = Thread::with_id(thread_id, sess.id, None);
|
||||
sess.threads.insert(thread_id, thread);
|
||||
sess.active_thread = Some(thread_id);
|
||||
}
|
||||
@@ -567,7 +567,7 @@ mod tests {
|
||||
// Simulate hydration: create thread with a known UUID
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(known_uuid, session_id);
|
||||
let thread = Thread::with_id(known_uuid, session_id, None);
|
||||
sess.threads.insert(known_uuid, thread);
|
||||
}
|
||||
|
||||
@@ -594,7 +594,7 @@ mod tests {
|
||||
let session = Arc::new(Mutex::new(Session::new("user-idem")));
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(tid, sess.id);
|
||||
let thread = Thread::with_id(tid, sess.id, None);
|
||||
sess.threads.insert(tid, thread);
|
||||
}
|
||||
|
||||
@@ -623,7 +623,7 @@ mod tests {
|
||||
let session = Arc::new(Mutex::new(Session::new("user-undo")));
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(tid, sess.id);
|
||||
let thread = Thread::with_id(tid, sess.id, None);
|
||||
sess.threads.insert(tid, thread);
|
||||
}
|
||||
|
||||
@@ -647,7 +647,7 @@ mod tests {
|
||||
let session = Arc::new(Mutex::new(Session::new("user-new")));
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(tid, sess.id);
|
||||
let thread = Thread::with_id(tid, sess.id, None);
|
||||
sess.threads.insert(tid, thread);
|
||||
}
|
||||
|
||||
@@ -755,7 +755,7 @@ mod tests {
|
||||
let session = Arc::new(Mutex::new(Session::new("user-cross")));
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(tid, sess.id);
|
||||
let thread = Thread::with_id(tid, sess.id, None);
|
||||
sess.threads.insert(tid, thread);
|
||||
}
|
||||
|
||||
@@ -782,7 +782,7 @@ mod tests {
|
||||
let session = Arc::new(Mutex::new(Session::new("user-cross")));
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(tid, sess.id);
|
||||
let thread = Thread::with_id(tid, sess.id, None);
|
||||
sess.threads.insert(tid, thread);
|
||||
}
|
||||
|
||||
@@ -921,7 +921,7 @@ mod tests {
|
||||
let session = Arc::new(Mutex::new(Session::new("user-direct")));
|
||||
{
|
||||
let mut sess = session.lock().await;
|
||||
let thread = Thread::with_id(tid, sess.id);
|
||||
let thread = Thread::with_id(tid, sess.id, None);
|
||||
sess.threads.insert(tid, thread);
|
||||
}
|
||||
{
|
||||
@@ -947,4 +947,23 @@ mod tests {
|
||||
"should have exactly 1 thread, not a duplicate"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_thread_stores_source_channel() {
|
||||
let manager = SessionManager::new();
|
||||
let (session, thread_id) = manager
|
||||
.resolve_thread("user1", "gateway", Some("ext-1"))
|
||||
.await;
|
||||
let sess = session.lock().await;
|
||||
let thread = sess.threads.get(&thread_id).unwrap();
|
||||
assert_eq!(thread.source_channel.as_deref(), Some("gateway"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_different_channels_get_different_threads() {
|
||||
let manager = SessionManager::new();
|
||||
let (_, tid1) = manager.resolve_thread("user1", "gateway", None).await;
|
||||
let (_, tid2) = manager.resolve_thread("user1", "web", None).await;
|
||||
assert_ne!(tid1, tid2);
|
||||
}
|
||||
}
|
||||
|
||||
+30
-7
@@ -141,7 +141,8 @@ impl Agent {
|
||||
sess.id
|
||||
};
|
||||
|
||||
let mut thread = crate::agent::session::Thread::with_id(thread_uuid, session_id);
|
||||
let mut thread =
|
||||
crate::agent::session::Thread::with_id(thread_uuid, session_id, Some(&message.channel));
|
||||
if !chat_messages.is_empty() {
|
||||
thread.restore_from_messages(chat_messages);
|
||||
}
|
||||
@@ -954,6 +955,28 @@ impl Agent {
|
||||
approved: bool,
|
||||
always: bool,
|
||||
) -> Result<SubmissionResult, Error> {
|
||||
// Verify channel authorization: the approving channel must match the
|
||||
// thread's source channel, OR be the web gateway (trusted approval UI).
|
||||
{
|
||||
let sess = session.lock().await;
|
||||
if let Some(thread) = sess.threads.get(&thread_id) {
|
||||
let authorized = thread.source_channel.as_ref().is_none_or(|src| {
|
||||
src == &message.channel || message.channel == "web"
|
||||
});
|
||||
if !authorized {
|
||||
tracing::warn!(
|
||||
%thread_id,
|
||||
source_channel = ?thread.source_channel,
|
||||
approval_channel = %message.channel,
|
||||
"Blocked cross-channel approval attempt"
|
||||
);
|
||||
return Ok(SubmissionResult::error(
|
||||
"approval not authorized for this channel",
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get pending approval for this thread
|
||||
let pending = {
|
||||
let mut sess = session.lock().await;
|
||||
@@ -1744,7 +1767,7 @@ impl Agent {
|
||||
.get_or_create_session(&message.user_id)
|
||||
.await;
|
||||
let mut sess = session.lock().await;
|
||||
let thread = sess.create_thread();
|
||||
let thread = sess.create_thread(&message.channel);
|
||||
let thread_id = thread.id;
|
||||
Ok(SubmissionResult::ok_with_message(format!(
|
||||
"New thread: {}",
|
||||
@@ -2035,7 +2058,7 @@ mod tests {
|
||||
|
||||
let session_id = Uuid::new_v4();
|
||||
let thread_id = Uuid::new_v4();
|
||||
let mut thread = Thread::with_id(thread_id, session_id);
|
||||
let mut thread = Thread::with_id(thread_id, session_id, None);
|
||||
|
||||
// Set thread to AwaitingApproval with a pending tool approval
|
||||
let pending = PendingApproval {
|
||||
@@ -2103,7 +2126,7 @@ mod tests {
|
||||
use crate::agent::session::{MAX_PENDING_MESSAGES, Thread, ThreadState};
|
||||
use uuid::Uuid;
|
||||
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
thread.start_turn("processing something");
|
||||
assert_eq!(thread.state, ThreadState::Processing);
|
||||
|
||||
@@ -2129,7 +2152,7 @@ mod tests {
|
||||
use crate::agent::session::{Thread, ThreadState};
|
||||
use uuid::Uuid;
|
||||
|
||||
let mut thread = Thread::new(Uuid::new_v4());
|
||||
let mut thread = Thread::new(Uuid::new_v4(), None);
|
||||
thread.start_turn("processing");
|
||||
|
||||
thread.queue_message("pending-1".to_string());
|
||||
@@ -2159,7 +2182,7 @@ mod tests {
|
||||
|
||||
let thread_id = Uuid::new_v4();
|
||||
let session_id = Uuid::new_v4();
|
||||
let mut thread = Thread::with_id(thread_id, session_id);
|
||||
let mut thread = Thread::with_id(thread_id, session_id, None);
|
||||
thread.start_turn("working");
|
||||
assert_eq!(thread.state, ThreadState::Processing);
|
||||
|
||||
@@ -2186,7 +2209,7 @@ mod tests {
|
||||
|
||||
let thread_id = Uuid::new_v4();
|
||||
let session_id = Uuid::new_v4();
|
||||
let mut thread = Thread::with_id(thread_id, session_id);
|
||||
let mut thread = Thread::with_id(thread_id, session_id, None);
|
||||
thread.start_turn("working");
|
||||
assert_eq!(thread.state, ThreadState::Processing);
|
||||
|
||||
|
||||
@@ -543,7 +543,7 @@ pub async fn chat_new_thread_handler(
|
||||
let session = session_manager.get_or_create_session(&state.user_id).await;
|
||||
let (thread_id, info) = {
|
||||
let mut sess = session.lock().await;
|
||||
let thread = sess.create_thread();
|
||||
let thread = sess.create_thread("web");
|
||||
let id = thread.id;
|
||||
let info = ThreadInfo {
|
||||
id: thread.id,
|
||||
|
||||
@@ -1658,7 +1658,7 @@ async fn chat_new_thread_handler(
|
||||
let session = session_manager.get_or_create_session(&state.user_id).await;
|
||||
let (thread_id, info) = {
|
||||
let mut sess = session.lock().await;
|
||||
let thread = sess.create_thread();
|
||||
let thread = sess.create_thread("web");
|
||||
let id = thread.id;
|
||||
let info = ThreadInfo {
|
||||
id: thread.id,
|
||||
|
||||
Reference in New Issue
Block a user