mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
chore: resolve conflicts
This commit is contained in:
+1
-1
@@ -113,7 +113,7 @@ Check-insert is done under a single write lock to prevent TOCTOU races. A cleanu
|
||||
4. Detects broken tools via `store.get_broken_tools(5)` (threshold: 5 failures). Requires `with_store()` to be called; returns empty without a store.
|
||||
5. Attempts to rebuild broken tools via `SoftwareBuilder`. Requires `with_builder()` to be called; returns `ManualRequired` without a builder.
|
||||
|
||||
Note: the `stuck_threshold` duration is stored but currently unused (marked `#[allow(dead_code)]`). Stuck detection relies on `JobState::Stuck` being set by the state machine, not wall-clock time comparison.
|
||||
The `stuck_threshold` duration is used for time-based detection of `InProgress` jobs that have been running longer than the threshold. When `detect_stuck_jobs()` finds such jobs, it transitions them to `Stuck` before returning them, enabling the normal `attempt_recovery()` path.
|
||||
|
||||
Repair results: `Success`, `Retry`, `Failed`, `ManualRequired`. `Retry` does NOT notify the user (to avoid spam).
|
||||
|
||||
|
||||
+58
-2
@@ -120,6 +120,17 @@ async fn resolve_routine_notification_target(
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) fn chat_tool_execution_metadata(message: &IncomingMessage) -> serde_json::Value {
|
||||
serde_json::json!({
|
||||
"notify_channel": message.channel,
|
||||
"notify_user": message
|
||||
.routing_target()
|
||||
.unwrap_or_else(|| message.user_id.clone()),
|
||||
"notify_thread_id": message.thread_id,
|
||||
"notify_metadata": message.metadata,
|
||||
})
|
||||
}
|
||||
|
||||
fn should_fallback_routine_notification(error: &ChannelError) -> bool {
|
||||
!matches!(error, ChannelError::MissingRoutingTarget { .. })
|
||||
}
|
||||
@@ -1177,9 +1188,10 @@ impl Agent {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
resolve_routine_notification_user, should_fallback_routine_notification,
|
||||
truncate_for_preview,
|
||||
chat_tool_execution_metadata, resolve_routine_notification_user,
|
||||
should_fallback_routine_notification, truncate_for_preview,
|
||||
};
|
||||
use crate::channels::IncomingMessage;
|
||||
use crate::error::ChannelError;
|
||||
|
||||
#[test]
|
||||
@@ -1275,6 +1287,50 @@ mod tests {
|
||||
assert_eq!(resolve_routine_notification_user(&metadata), None); // safety: test-only assertion
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_tool_execution_metadata_prefers_message_routing_target() {
|
||||
let message = IncomingMessage::new("telegram", "owner-scope", "hello")
|
||||
.with_sender_id("telegram-user")
|
||||
.with_thread("thread-7")
|
||||
.with_metadata(serde_json::json!({
|
||||
"chat_id": 424242,
|
||||
"chat_type": "private",
|
||||
}));
|
||||
|
||||
let metadata = chat_tool_execution_metadata(&message);
|
||||
assert_eq!(
|
||||
metadata.get("notify_channel").and_then(|v| v.as_str()),
|
||||
Some("telegram")
|
||||
); // safety: test-only assertion
|
||||
assert_eq!(
|
||||
metadata.get("notify_user").and_then(|v| v.as_str()),
|
||||
Some("424242")
|
||||
); // safety: test-only assertion
|
||||
assert_eq!(
|
||||
metadata.get("notify_thread_id").and_then(|v| v.as_str()),
|
||||
Some("thread-7")
|
||||
); // safety: test-only assertion
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_tool_execution_metadata_falls_back_to_user_scope_without_route() {
|
||||
let message = IncomingMessage::new("gateway", "owner-scope", "hello").with_sender_id("");
|
||||
|
||||
let metadata = chat_tool_execution_metadata(&message);
|
||||
assert_eq!(
|
||||
metadata.get("notify_channel").and_then(|v| v.as_str()),
|
||||
Some("gateway")
|
||||
); // safety: test-only assertion
|
||||
assert_eq!(
|
||||
metadata.get("notify_user").and_then(|v| v.as_str()),
|
||||
Some("owner-scope")
|
||||
); // safety: test-only assertion
|
||||
assert_eq!(
|
||||
metadata.get("notify_thread_id"),
|
||||
Some(&serde_json::Value::Null)
|
||||
); // safety: test-only assertion
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn targeted_routine_notifications_do_not_fallback_without_owner_route() {
|
||||
let error = ChannelError::MissingRoutingTarget {
|
||||
|
||||
@@ -144,12 +144,7 @@ impl Agent {
|
||||
.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!({
|
||||
"notify_channel": message.channel,
|
||||
"notify_user": message.user_id,
|
||||
"notify_thread_id": message.thread_id,
|
||||
"notify_metadata": message.metadata,
|
||||
});
|
||||
job_ctx.metadata = crate::agent::agent_loop::chat_tool_execution_metadata(message);
|
||||
|
||||
// Build system prompts once for this turn. Two variants: with tools
|
||||
// (normal iterations) and without (force_text final iteration).
|
||||
|
||||
@@ -14,12 +14,15 @@
|
||||
//! Agent Loop
|
||||
//! ```
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tokio::task::JoinHandle;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::channels::IncomingMessage;
|
||||
use crate::channels::web::types::SseEvent;
|
||||
use crate::context::{ContextManager, JobState};
|
||||
|
||||
/// Route context for forwarding job monitor events back to the user's channel.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -40,10 +43,23 @@ pub struct JobMonitorRoute {
|
||||
/// Tool use/result and status events are intentionally skipped (too noisy for
|
||||
/// the main agent's context window).
|
||||
pub fn spawn_job_monitor(
|
||||
job_id: Uuid,
|
||||
event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
|
||||
inject_tx: mpsc::Sender<IncomingMessage>,
|
||||
route: JobMonitorRoute,
|
||||
) -> JoinHandle<()> {
|
||||
spawn_job_monitor_with_context(job_id, event_rx, inject_tx, route, None)
|
||||
}
|
||||
|
||||
/// Like `spawn_job_monitor`, but also transitions the job's in-memory state
|
||||
/// when it receives a `JobResult` event. This ensures fire-and-forget sandbox
|
||||
/// jobs don't stay `InProgress` forever in the `ContextManager`.
|
||||
pub fn spawn_job_monitor_with_context(
|
||||
job_id: Uuid,
|
||||
mut event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
|
||||
inject_tx: mpsc::Sender<IncomingMessage>,
|
||||
route: JobMonitorRoute,
|
||||
context_manager: Option<Arc<ContextManager>>,
|
||||
) -> JoinHandle<()> {
|
||||
let short_id = job_id.to_string()[..8].to_string();
|
||||
|
||||
@@ -77,6 +93,26 @@ pub fn spawn_job_monitor(
|
||||
}
|
||||
}
|
||||
SseEvent::JobResult { status, .. } => {
|
||||
// Transition in-memory state so the job frees its
|
||||
// max_jobs slot and query tools show the final state.
|
||||
if let Some(ref cm) = context_manager {
|
||||
let target = if status == "completed" {
|
||||
JobState::Completed
|
||||
} else {
|
||||
JobState::Failed
|
||||
};
|
||||
let reason = if status != "completed" {
|
||||
Some(format!("Container finished: {}", status))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _ = cm
|
||||
.update_context(job_id, |ctx| {
|
||||
let _ = ctx.transition_to(target, reason);
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
let mut msg = IncomingMessage::new(
|
||||
route.channel.clone(),
|
||||
route.user_id.clone(),
|
||||
@@ -121,6 +157,62 @@ pub fn spawn_job_monitor(
|
||||
})
|
||||
}
|
||||
|
||||
/// Lightweight watcher that only transitions ContextManager state on job
|
||||
/// completion. Used when monitor routing metadata is absent (no channel to
|
||||
/// inject messages into) but we still need to free the `max_jobs` slot.
|
||||
pub fn spawn_completion_watcher(
|
||||
job_id: Uuid,
|
||||
mut event_rx: broadcast::Receiver<(Uuid, SseEvent)>,
|
||||
context_manager: Arc<ContextManager>,
|
||||
) -> JoinHandle<()> {
|
||||
let short_id = job_id.to_string()[..8].to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match event_rx.recv().await {
|
||||
Ok((ev_job_id, SseEvent::JobResult { status, .. })) if ev_job_id == job_id => {
|
||||
let target = if status == "completed" {
|
||||
JobState::Completed
|
||||
} else {
|
||||
JobState::Failed
|
||||
};
|
||||
let reason = if status != "completed" {
|
||||
Some(format!("Container finished: {}", status))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let _ = context_manager
|
||||
.update_context(job_id, |ctx| {
|
||||
let _ = ctx.transition_to(target, reason);
|
||||
})
|
||||
.await;
|
||||
tracing::debug!(
|
||||
job_id = %short_id,
|
||||
status = %status,
|
||||
"Completion watcher exiting (job finished)"
|
||||
);
|
||||
break;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!(
|
||||
job_id = %short_id,
|
||||
skipped = n,
|
||||
"Completion watcher lagged"
|
||||
);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
tracing::debug!(
|
||||
job_id = %short_id,
|
||||
"Broadcast channel closed, stopping completion watcher"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -294,4 +386,139 @@ mod tests {
|
||||
let msg = IncomingMessage::new("monitor", "system", "test").into_internal();
|
||||
assert!(msg.is_internal);
|
||||
}
|
||||
|
||||
// === Regression: fire-and-forget sandbox jobs must transition out of InProgress ===
|
||||
// Before this fix, spawn_job_monitor only forwarded SSE messages but never
|
||||
// updated ContextManager. Background sandbox jobs stayed InProgress forever,
|
||||
// permanently consuming a max_jobs slot.
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_monitor_transitions_context_on_completion() {
|
||||
use crate::context::{ContextManager, JobState};
|
||||
|
||||
let cm = Arc::new(ContextManager::new(5));
|
||||
let job_id = Uuid::new_v4();
|
||||
cm.register_sandbox_job(job_id, "user-1", "Build app", "desc")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let handle = spawn_job_monitor_with_context(
|
||||
job_id,
|
||||
event_tx.subscribe(),
|
||||
inject_tx,
|
||||
test_route(),
|
||||
Some(Arc::clone(&cm)),
|
||||
);
|
||||
|
||||
// Send completion event
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobResult {
|
||||
job_id: job_id.to_string(),
|
||||
status: "completed".to_string(),
|
||||
session_id: None,
|
||||
fallback_deliverable: None,
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
// Drain the injected message
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), inject_rx.recv()).await;
|
||||
|
||||
// Wait for monitor to exit
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), handle)
|
||||
.await
|
||||
.expect("monitor should exit")
|
||||
.expect("monitor should not panic");
|
||||
|
||||
// Job should now be Completed, not InProgress
|
||||
let ctx = cm.get_context(job_id).await.unwrap();
|
||||
assert_eq!(ctx.state, JobState::Completed);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_monitor_transitions_context_on_failure() {
|
||||
use crate::context::{ContextManager, JobState};
|
||||
|
||||
let cm = Arc::new(ContextManager::new(5));
|
||||
let job_id = Uuid::new_v4();
|
||||
cm.register_sandbox_job(job_id, "user-1", "Build app", "desc")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let (inject_tx, mut inject_rx) = mpsc::channel::<IncomingMessage>(16);
|
||||
|
||||
let handle = spawn_job_monitor_with_context(
|
||||
job_id,
|
||||
event_tx.subscribe(),
|
||||
inject_tx,
|
||||
test_route(),
|
||||
Some(Arc::clone(&cm)),
|
||||
);
|
||||
|
||||
// Send failure event
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobResult {
|
||||
job_id: job_id.to_string(),
|
||||
status: "failed".to_string(),
|
||||
session_id: None,
|
||||
fallback_deliverable: None,
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let _ = tokio::time::timeout(std::time::Duration::from_secs(1), inject_rx.recv()).await;
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), handle)
|
||||
.await
|
||||
.expect("monitor should exit")
|
||||
.expect("monitor should not panic");
|
||||
|
||||
let ctx = cm.get_context(job_id).await.unwrap();
|
||||
assert_eq!(ctx.state, JobState::Failed);
|
||||
}
|
||||
|
||||
// === Regression: completion watcher (no route metadata) ===
|
||||
// When monitor_route_from_ctx() returns None, spawn_completion_watcher
|
||||
// must still transition the job so the max_jobs slot is freed.
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_completion_watcher_transitions_on_result() {
|
||||
use crate::context::{ContextManager, JobState};
|
||||
|
||||
let cm = Arc::new(ContextManager::new(5));
|
||||
let job_id = Uuid::new_v4();
|
||||
cm.register_sandbox_job(job_id, "user-1", "Build app", "desc")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let (event_tx, _) = broadcast::channel::<(Uuid, SseEvent)>(16);
|
||||
let handle = spawn_completion_watcher(job_id, event_tx.subscribe(), Arc::clone(&cm));
|
||||
|
||||
event_tx
|
||||
.send((
|
||||
job_id,
|
||||
SseEvent::JobResult {
|
||||
job_id: job_id.to_string(),
|
||||
status: "completed".to_string(),
|
||||
session_id: None,
|
||||
fallback_deliverable: None,
|
||||
},
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
tokio::time::timeout(std::time::Duration::from_secs(1), handle)
|
||||
.await
|
||||
.expect("watcher should exit")
|
||||
.expect("watcher should not panic");
|
||||
|
||||
let ctx = cm.get_context(job_id).await.unwrap();
|
||||
assert_eq!(ctx.state, JobState::Completed);
|
||||
}
|
||||
}
|
||||
|
||||
+137
-41
@@ -66,6 +66,7 @@ pub trait SelfRepair: Send + Sync {
|
||||
/// Default self-repair implementation.
|
||||
pub struct DefaultSelfRepair {
|
||||
context_manager: Arc<ContextManager>,
|
||||
/// Jobs in `InProgress` longer than this are treated as stuck.
|
||||
stuck_threshold: Duration,
|
||||
max_repair_attempts: u32,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
@@ -111,15 +112,58 @@ impl DefaultSelfRepair {
|
||||
#[async_trait]
|
||||
impl SelfRepair for DefaultSelfRepair {
|
||||
async fn detect_stuck_jobs(&self) -> Vec<StuckJob> {
|
||||
let stuck_ids = self.context_manager.find_stuck_jobs().await;
|
||||
let stuck_ids = self
|
||||
.context_manager
|
||||
.find_stuck_jobs_with_threshold(Some(self.stuck_threshold))
|
||||
.await;
|
||||
let mut stuck_jobs = Vec::new();
|
||||
|
||||
for job_id in stuck_ids {
|
||||
if let Ok(ctx) = self.context_manager.get_context(job_id).await
|
||||
&& ctx.state == JobState::Stuck
|
||||
&& matches!(ctx.state, JobState::Stuck | JobState::InProgress)
|
||||
{
|
||||
// Measure stuck_duration from the most recent Stuck transition,
|
||||
// not from started_at (which reflects when the job first ran).
|
||||
// InProgress jobs detected by threshold need to be transitioned
|
||||
// to Stuck before they can be repaired (attempt_recovery requires
|
||||
// Stuck state). These jobs already passed the threshold check in
|
||||
// find_stuck_jobs_with_threshold, so skip the duration filter below.
|
||||
let just_transitioned = ctx.state == JobState::InProgress;
|
||||
if just_transitioned {
|
||||
let reason = "exceeded stuck_threshold";
|
||||
let transition = self
|
||||
.context_manager
|
||||
.update_context(job_id, |ctx| ctx.mark_stuck(reason))
|
||||
.await;
|
||||
match transition {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => {
|
||||
tracing::warn!(
|
||||
job = %job_id,
|
||||
"Failed to mark InProgress job as Stuck: {}",
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
job = %job_id,
|
||||
"Failed to transition InProgress job to Stuck: {}",
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-fetch context after potential InProgress->Stuck transition
|
||||
// so that stuck_since picks up the new transition timestamp.
|
||||
let ctx = match self.context_manager.get_context(job_id).await {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// Use the timestamp of the most recent Stuck transition, not started_at.
|
||||
// A job that ran for hours before becoming stuck should not immediately
|
||||
// exceed the threshold — we measure from when it actually became stuck.
|
||||
let stuck_since = ctx
|
||||
.transitions
|
||||
.iter()
|
||||
@@ -134,8 +178,10 @@ impl SelfRepair for DefaultSelfRepair {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Only report jobs that have been stuck long enough
|
||||
if stuck_duration < self.stuck_threshold {
|
||||
// Only report already-Stuck jobs that have been stuck long enough.
|
||||
// Jobs just transitioned from InProgress skip this check — they
|
||||
// were already vetted by find_stuck_jobs_with_threshold.
|
||||
if !just_transitioned && stuck_duration < self.stuck_threshold {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -163,10 +209,17 @@ impl SelfRepair for DefaultSelfRepair {
|
||||
});
|
||||
}
|
||||
|
||||
// Try to recover the job
|
||||
// Try to recover the job.
|
||||
// If the job is still InProgress (detected via stuck_threshold), transition
|
||||
// it to Stuck first so that attempt_recovery() can move it back to InProgress.
|
||||
let result = self
|
||||
.context_manager
|
||||
.update_context(job.job_id, |ctx| ctx.attempt_recovery())
|
||||
.update_context(job.job_id, |ctx| {
|
||||
if ctx.state == JobState::InProgress {
|
||||
ctx.transition_to(JobState::Stuck, Some("exceeded stuck_threshold".into()))?;
|
||||
}
|
||||
ctx.attempt_recovery()
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
@@ -489,6 +542,82 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_and_repair_in_progress_job_via_threshold() {
|
||||
let cm = Arc::new(ContextManager::new(10));
|
||||
let job_id = cm.create_job("Long running", "desc").await.unwrap();
|
||||
|
||||
// Transition to InProgress.
|
||||
cm.update_context(job_id, |ctx| ctx.transition_to(JobState::InProgress, None))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// Backdate started_at to simulate a job running for 10 minutes.
|
||||
cm.update_context(job_id, |ctx| {
|
||||
ctx.started_at = Some(Utc::now() - chrono::Duration::seconds(600));
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Use a 5-minute threshold so the 10-minute job is detected.
|
||||
let repair = DefaultSelfRepair::new(Arc::clone(&cm), Duration::from_secs(300), 3);
|
||||
|
||||
// detect_stuck_jobs should find it and transition InProgress -> Stuck.
|
||||
let stuck = repair.detect_stuck_jobs().await;
|
||||
assert_eq!(stuck.len(), 1);
|
||||
assert_eq!(stuck[0].job_id, job_id);
|
||||
|
||||
// After detection the job should now be in Stuck state.
|
||||
let ctx = cm.get_context(job_id).await.unwrap();
|
||||
assert_eq!(ctx.state, JobState::Stuck);
|
||||
|
||||
// Repair should recover it: Stuck -> InProgress.
|
||||
let result = repair.repair_stuck_job(&stuck[0]).await.unwrap();
|
||||
assert!(
|
||||
matches!(result, RepairResult::Success { .. }),
|
||||
"Expected Success, got: {:?}",
|
||||
result
|
||||
);
|
||||
|
||||
// Job should be back to InProgress after recovery.
|
||||
let ctx = cm.get_context(job_id).await.unwrap();
|
||||
assert_eq!(ctx.state, JobState::InProgress);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_broken_tools_returns_empty_without_store() {
|
||||
let cm = Arc::new(ContextManager::new(10));
|
||||
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
|
||||
|
||||
// No store configured, should return empty.
|
||||
let broken = repair.detect_broken_tools().await;
|
||||
assert!(broken.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repair_broken_tool_returns_manual_without_builder() {
|
||||
let cm = Arc::new(ContextManager::new(10));
|
||||
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
|
||||
|
||||
let broken = BrokenTool {
|
||||
name: "test-tool".to_string(),
|
||||
failure_count: 10,
|
||||
last_error: Some("crash".to_string()),
|
||||
first_failure: Utc::now(),
|
||||
last_failure: Utc::now(),
|
||||
last_build_result: None,
|
||||
repair_attempts: 0,
|
||||
};
|
||||
|
||||
let result = repair.repair_broken_tool(&broken).await.unwrap();
|
||||
assert!(
|
||||
matches!(result, RepairResult::ManualRequired { .. }),
|
||||
"Expected ManualRequired without builder, got: {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_stuck_jobs_filters_by_threshold() {
|
||||
let cm = Arc::new(ContextManager::new(10));
|
||||
@@ -581,39 +710,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn detect_broken_tools_returns_empty_without_store() {
|
||||
let cm = Arc::new(ContextManager::new(10));
|
||||
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
|
||||
|
||||
// No store configured, should return empty.
|
||||
let broken = repair.detect_broken_tools().await;
|
||||
assert!(broken.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn repair_broken_tool_returns_manual_without_builder() {
|
||||
let cm = Arc::new(ContextManager::new(10));
|
||||
let repair = DefaultSelfRepair::new(cm, Duration::from_secs(60), 3);
|
||||
|
||||
let broken = BrokenTool {
|
||||
name: "test-tool".to_string(),
|
||||
failure_count: 10,
|
||||
last_error: Some("crash".to_string()),
|
||||
first_failure: Utc::now(),
|
||||
last_failure: Utc::now(),
|
||||
last_build_result: None,
|
||||
repair_attempts: 0,
|
||||
};
|
||||
|
||||
let result = repair.repair_broken_tool(&broken).await.unwrap();
|
||||
assert!(
|
||||
matches!(result, RepairResult::ManualRequired { .. }),
|
||||
"Expected ManualRequired without builder, got: {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
/// Mock SoftwareBuilder that returns a successful build result.
|
||||
struct MockBuilder {
|
||||
build_count: std::sync::atomic::AtomicU32,
|
||||
|
||||
@@ -939,6 +939,7 @@ impl Agent {
|
||||
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.metadata = crate::agent::agent_loop::chat_tool_execution_metadata(message);
|
||||
// Prefer a valid timezone from the approval message, fall back to the
|
||||
// resolved timezone stored when the approval was originally requested.
|
||||
let tz_candidate = message
|
||||
|
||||
+4
-1
@@ -694,7 +694,10 @@ impl AppBuilder {
|
||||
// Post-init validation: if a non-nearai backend was selected but
|
||||
// credentials were never resolved (deferred resolution found no keys),
|
||||
// fail early with a clear error instead of a confusing runtime failure.
|
||||
if self.config.llm.backend != "nearai" && self.config.llm.provider.is_none() {
|
||||
if self.config.llm.backend != "nearai"
|
||||
&& self.config.llm.backend != "bedrock"
|
||||
&& self.config.llm.provider.is_none()
|
||||
{
|
||||
let backend = &self.config.llm.backend;
|
||||
anyhow::bail!(
|
||||
"LLM_BACKEND={backend} is configured but no credentials were found. \
|
||||
|
||||
@@ -3314,6 +3314,7 @@ mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::channels::Channel;
|
||||
use crate::channels::OutgoingResponse;
|
||||
use crate::channels::wasm::capabilities::ChannelCapabilities;
|
||||
use crate::channels::wasm::runtime::{
|
||||
PreparedChannelModule, WasmChannelRuntime, WasmChannelRuntimeConfig,
|
||||
@@ -3401,6 +3402,16 @@ mod tests {
|
||||
assert!(channel.health_check().await.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_broadcast_delegates_to_call_on_broadcast() {
|
||||
let channel = create_test_channel();
|
||||
// With `component: None`, call_on_broadcast short-circuits to Ok(()).
|
||||
let result = channel
|
||||
.broadcast("146032821", OutgoingResponse::text("hello"))
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_poll_no_wasm_returns_empty() {
|
||||
// When there's no WASM module (None component), execute_poll
|
||||
|
||||
@@ -354,6 +354,7 @@ pub async fn start_server(
|
||||
.route("/", get(index_handler))
|
||||
.route("/style.css", get(css_handler))
|
||||
.route("/app.js", get(js_handler))
|
||||
.route("/theme-init.js", get(theme_init_handler))
|
||||
.route("/favicon.ico", get(favicon_handler))
|
||||
.route("/i18n/index.js", get(i18n_index_handler))
|
||||
.route("/i18n/en.js", get(i18n_en_handler))
|
||||
@@ -475,6 +476,16 @@ async fn js_handler() -> impl IntoResponse {
|
||||
)
|
||||
}
|
||||
|
||||
async fn theme_init_handler() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/javascript"),
|
||||
(header::CACHE_CONTROL, "no-cache"),
|
||||
],
|
||||
include_str!("static/theme-init.js"),
|
||||
)
|
||||
}
|
||||
|
||||
async fn favicon_handler() -> impl IntoResponse {
|
||||
(
|
||||
[
|
||||
|
||||
@@ -1,5 +1,69 @@
|
||||
// IronClaw Web Gateway - Client
|
||||
|
||||
// --- Theme Management (dark / light / system) ---
|
||||
// Icon switching is handled by pure CSS via data-theme-mode on <html>.
|
||||
|
||||
function getSystemTheme() {
|
||||
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
}
|
||||
|
||||
const VALID_THEME_MODES = { dark: true, light: true, system: true };
|
||||
|
||||
function getThemeMode() {
|
||||
const stored = localStorage.getItem('ironclaw-theme');
|
||||
return (stored && VALID_THEME_MODES[stored]) ? stored : 'system';
|
||||
}
|
||||
|
||||
function resolveTheme(mode) {
|
||||
return mode === 'system' ? getSystemTheme() : mode;
|
||||
}
|
||||
|
||||
function applyTheme(mode) {
|
||||
const resolved = resolveTheme(mode);
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
document.documentElement.setAttribute('data-theme-mode', mode);
|
||||
const titleKeys = { dark: 'theme.tooltipDark', light: 'theme.tooltipLight', system: 'theme.tooltipSystem' };
|
||||
const btn = document.getElementById('theme-toggle');
|
||||
if (btn) btn.title = (typeof I18n !== 'undefined' && titleKeys[mode]) ? I18n.t(titleKeys[mode]) : ('Theme: ' + mode);
|
||||
const announce = document.getElementById('theme-announce');
|
||||
if (announce) announce.textContent = (typeof I18n !== 'undefined') ? I18n.t('theme.announce', { mode: mode }) : ('Theme: ' + mode);
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
const cycle = { dark: 'light', light: 'system', system: 'dark' };
|
||||
const current = getThemeMode();
|
||||
const next = cycle[current] || 'dark';
|
||||
localStorage.setItem('ironclaw-theme', next);
|
||||
applyTheme(next);
|
||||
}
|
||||
|
||||
// Apply theme immediately (FOUC prevention is done via inline script in <head>,
|
||||
// but we call again here to ensure tooltip is set after DOM is ready).
|
||||
applyTheme(getThemeMode());
|
||||
|
||||
// Delay enabling theme transition to avoid flash on initial load.
|
||||
requestAnimationFrame(function() {
|
||||
requestAnimationFrame(function() {
|
||||
document.body.classList.add('theme-transition');
|
||||
});
|
||||
});
|
||||
|
||||
// Listen for OS theme changes — only re-apply when in 'system' mode.
|
||||
const mql = window.matchMedia('(prefers-color-scheme: light)');
|
||||
const onSchemeChange = function() {
|
||||
if (getThemeMode() === 'system') {
|
||||
applyTheme('system');
|
||||
}
|
||||
};
|
||||
if (mql.addEventListener) {
|
||||
mql.addEventListener('change', onSchemeChange);
|
||||
} else if (mql.addListener) {
|
||||
mql.addListener(onSchemeChange);
|
||||
}
|
||||
|
||||
// Bind theme toggle button (CSP-compliant — no inline onclick).
|
||||
document.getElementById('theme-toggle').addEventListener('click', toggleTheme);
|
||||
|
||||
let token = '';
|
||||
let eventSource = null;
|
||||
let logEventSource = null;
|
||||
|
||||
@@ -24,6 +24,12 @@ I18n.register('en', {
|
||||
'restart.progressSubtitle': 'Please wait for the process to restart...',
|
||||
'restart.checkLogs': 'Check the Logs tab for details after restart completes.',
|
||||
|
||||
// Theme
|
||||
'theme.tooltipDark': 'Theme: Dark (click for Light)',
|
||||
'theme.tooltipLight': 'Theme: Light (click for System)',
|
||||
'theme.tooltipSystem': 'Theme: System (click for Dark)',
|
||||
'theme.announce': 'Theme: {mode}',
|
||||
|
||||
// Tabs
|
||||
'tab.chat': 'Chat',
|
||||
'tab.memory': 'Memory',
|
||||
|
||||
@@ -24,6 +24,12 @@ I18n.register('zh-CN', {
|
||||
'restart.progressSubtitle': '请等待进程重启...',
|
||||
'restart.checkLogs': '重启完成后,请查看日志标签页了解详情。',
|
||||
|
||||
// 主题
|
||||
'theme.tooltipDark': '主题:深色(点击切换浅色)',
|
||||
'theme.tooltipLight': '主题:浅色(点击切换跟随系统)',
|
||||
'theme.tooltipSystem': '主题:跟随系统(点击切换深色)',
|
||||
'theme.announce': '主题:{mode}',
|
||||
|
||||
// 标签页
|
||||
'tab.chat': '聊天',
|
||||
'tab.memory': '记忆',
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
integrity="sha384-pN9zSKOnTZwXRtYZAu0PBPEgR2B7DOC1aeLxQ33oJ0oy5iN1we6gm57xldM2irDG"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<script src="/theme-init.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Auth Screen -->
|
||||
@@ -163,6 +164,18 @@
|
||||
</div>
|
||||
|
||||
<button class="status-logs-btn" data-tab="logs" data-i18n="tab.logs" title="Logs">Logs</button>
|
||||
<button class="theme-toggle-btn" id="theme-toggle" title="Toggle theme" aria-label="Toggle theme">
|
||||
<svg class="theme-icon icon-dark" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
<svg class="theme-icon icon-light" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
<svg class="theme-icon icon-system" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/><line x1="8" y1="21" x2="16" y2="21"/><line x1="12" y1="17" x2="12" y2="21"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span id="theme-announce" class="sr-only" aria-live="polite"></span>
|
||||
<div class="tee-shield" id="tee-shield" style="display:none" title="Running in a Trusted Execution Environment">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>
|
||||
|
||||
+294
-118
@@ -18,10 +18,46 @@
|
||||
--radius-lg: 12px;
|
||||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
--font-mono: 'IBM Plex Mono', 'SF Mono', 'Fira Code', Consolas, monospace;
|
||||
--text-muted: #71717a;
|
||||
--bg-hover: rgba(255, 255, 255, 0.03);
|
||||
--danger-soft: rgba(230, 76, 76, 0.15);
|
||||
--warning-soft: rgba(245, 166, 35, 0.15);
|
||||
--bg-overlay: rgba(0, 0, 0, 0.5);
|
||||
--bg-modal: #1a1a1a;
|
||||
--border-modal: #333;
|
||||
--border-soft: #2a2a2a;
|
||||
--text-tertiary: #e0e0e0;
|
||||
--text-muted: #888;
|
||||
--text-dimmed: #666;
|
||||
--text-on-accent: #09090b;
|
||||
--accent-brand: #00D894;
|
||||
--accent-brand-hover: #00be82;
|
||||
--warning-bg: #1e1400;
|
||||
--warning-border: #3a2a00;
|
||||
--warning-text: #facc15;
|
||||
--tab-bg: rgba(9, 9, 11, 0.75);
|
||||
--popover-bg: rgba(15, 15, 17, 0.9);
|
||||
--badge-sandbox-bg: rgba(136, 132, 216, 0.15);
|
||||
--badge-sandbox-text: #b4b0e8;
|
||||
--hover-surface: rgba(255, 255, 255, 0.03);
|
||||
--focus-ring: rgba(52, 211, 153, 0.1);
|
||||
--accent-subtle: rgba(52, 211, 153, 0.15);
|
||||
--accent-border-subtle: rgba(52, 211, 153, 0.3);
|
||||
--danger-subtle: rgba(230, 76, 76, 0.15);
|
||||
--danger-border-subtle: rgba(230, 76, 76, 0.3);
|
||||
--warning-subtle: rgba(245, 166, 35, 0.15);
|
||||
--border-hover: rgba(255, 255, 255, 0.15);
|
||||
--user-msg-bg: rgba(52, 211, 153, 0.08);
|
||||
--user-msg-border: rgba(52, 211, 153, 0.2);
|
||||
--danger-error-bg: rgba(230, 76, 76, 0.1);
|
||||
--accent-tee-bg: rgba(52, 211, 153, 0.1);
|
||||
--accent-tee-border: rgba(52, 211, 153, 0.25);
|
||||
--accent-tee-hover: rgba(52, 211, 153, 0.18);
|
||||
--text-on-danger: #fff;
|
||||
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
--shadow-toast: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
--shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
--danger-error-border: rgba(230, 76, 76, 0.2);
|
||||
--note-bg: rgba(255, 255, 255, 0.04);
|
||||
--overlay-heavy: rgba(0, 0, 0, 0.6);
|
||||
--highlight-bg: rgba(52, 211, 153, 0.3);
|
||||
--hover-subtle: rgba(255, 255, 255, 0.06);
|
||||
--transition-fast: 150ms ease;
|
||||
--transition-base: 0.2s ease;
|
||||
}
|
||||
@@ -62,7 +98,7 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: var(--shadow-card);
|
||||
}
|
||||
|
||||
.auth-brand {
|
||||
@@ -106,13 +142,13 @@ body {
|
||||
#auth-screen input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.3);
|
||||
box-shadow: 0 0 0 3px var(--accent-border-subtle);
|
||||
}
|
||||
|
||||
#auth-screen button {
|
||||
padding: 10px 16px;
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
@@ -156,7 +192,7 @@ body {
|
||||
/* Tab Bar */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
background: rgba(9, 9, 11, 0.75);
|
||||
background: var(--tab-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
will-change: backdrop-filter;
|
||||
@@ -212,7 +248,7 @@ body {
|
||||
.tab-bar .status-logs-btn.active {
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
background: rgba(52, 211, 153, 0.1);
|
||||
background: var(--accent-tee-bg);
|
||||
}
|
||||
|
||||
.tab-bar .status {
|
||||
@@ -245,8 +281,8 @@ body {
|
||||
color: var(--success);
|
||||
padding: 4px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(52, 211, 153, 0.1);
|
||||
border: 1px solid rgba(52, 211, 153, 0.25);
|
||||
background: var(--accent-tee-bg);
|
||||
border: 1px solid var(--accent-tee-border);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
margin-right: 8px;
|
||||
@@ -254,7 +290,7 @@ body {
|
||||
}
|
||||
|
||||
.tee-shield:hover {
|
||||
background: rgba(52, 211, 153, 0.18);
|
||||
background: var(--accent-tee-hover);
|
||||
}
|
||||
|
||||
.tee-shield svg {
|
||||
@@ -275,20 +311,20 @@ body {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
border: 1px solid #00d894;
|
||||
color: #00d894;
|
||||
border: 1px solid var(--accent-brand);
|
||||
color: var(--accent-brand);
|
||||
background-color: transparent;
|
||||
cursor: pointer;
|
||||
transition: color 150ms, background-color 150ms, border-color 150ms;
|
||||
}
|
||||
|
||||
.tab-bar .restart-btn:hover:not(:disabled) {
|
||||
background-color: rgba(0, 216, 148, 0.1);
|
||||
background-color: var(--accent-tee-bg);
|
||||
}
|
||||
|
||||
.tab-bar .restart-btn:disabled {
|
||||
border-color: #333;
|
||||
color: #666;
|
||||
border-color: var(--border-modal);
|
||||
color: var(--text-dimmed);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@@ -330,7 +366,7 @@ body {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: var(--bg-overlay);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: -1;
|
||||
}
|
||||
@@ -338,10 +374,10 @@ body {
|
||||
.restart-loader-content {
|
||||
position: relative;
|
||||
z-index: 10000;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
background-color: var(--bg-modal);
|
||||
border: 1px solid var(--border-modal);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
margin: 0 1rem;
|
||||
@@ -358,7 +394,7 @@ body {
|
||||
}
|
||||
|
||||
.restart-title {
|
||||
color: var(--text);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.85rem;
|
||||
margin-bottom: 1rem;
|
||||
margin-top: 0;
|
||||
@@ -387,17 +423,17 @@ body {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
background: var(--bg-overlay);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.restart-modal-content {
|
||||
position: relative;
|
||||
z-index: 10000;
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px solid var(--border);
|
||||
background-color: var(--bg-modal);
|
||||
border: 1px solid var(--border-modal);
|
||||
border-radius: 0.75rem;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
box-shadow: var(--shadow-lg);
|
||||
width: 100%;
|
||||
max-width: 28rem;
|
||||
margin: 0 1rem;
|
||||
@@ -409,17 +445,17 @@ body {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.restart-modal-header h2 {
|
||||
color: var(--text);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 0.95rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.restart-modal-close {
|
||||
color: #888;
|
||||
color: var(--text-muted);
|
||||
padding: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: transparent;
|
||||
@@ -432,8 +468,8 @@ body {
|
||||
}
|
||||
|
||||
.restart-modal-close:hover {
|
||||
color: var(--text-secondary);
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text);
|
||||
background-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.restart-modal-body {
|
||||
@@ -448,14 +484,14 @@ body {
|
||||
|
||||
.restart-modal-warning {
|
||||
margin-top: 1rem;
|
||||
background-color: var(--warning-soft);
|
||||
border: 1px solid rgba(245, 166, 35, 0.25);
|
||||
background-color: var(--warning-bg);
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.restart-modal-warning p {
|
||||
color: var(--warning);
|
||||
color: var(--warning-text);
|
||||
font-size: 0.8rem;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -466,7 +502,7 @@ body {
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
border-top: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.restart-modal-btn {
|
||||
@@ -479,28 +515,28 @@ body {
|
||||
}
|
||||
|
||||
.restart-modal-btn.cancel {
|
||||
color: var(--text-secondary);
|
||||
color: var(--text);
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.restart-modal-btn.cancel:hover {
|
||||
background-color: var(--bg-tertiary);
|
||||
background-color: var(--border-soft);
|
||||
}
|
||||
|
||||
.restart-modal-btn.confirm {
|
||||
background-color: var(--accent);
|
||||
color: #09090b;
|
||||
background-color: var(--accent-brand);
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
|
||||
.restart-modal-btn.confirm:hover {
|
||||
background-color: var(--accent-hover);
|
||||
background-color: var(--accent-brand-hover);
|
||||
}
|
||||
|
||||
/* Progress Bar for Restart */
|
||||
.restart-progress-bar {
|
||||
width: 100%;
|
||||
height: 0.375rem;
|
||||
background-color: var(--bg-tertiary);
|
||||
background-color: var(--border-soft);
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -508,7 +544,7 @@ body {
|
||||
.restart-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 9999px;
|
||||
background-color: var(--accent);
|
||||
background-color: var(--accent-brand);
|
||||
width: 40%;
|
||||
animation: indeterminate 1.5s ease-in-out infinite;
|
||||
}
|
||||
@@ -529,14 +565,14 @@ body {
|
||||
}
|
||||
|
||||
.restart-modal-info {
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-dimmed);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 1.25rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.restart-modal-info a {
|
||||
color: var(--accent);
|
||||
color: var(--accent-brand);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@@ -550,7 +586,7 @@ body {
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 8px;
|
||||
background: rgba(15, 15, 17, 0.9);
|
||||
background: var(--popover-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border);
|
||||
@@ -887,11 +923,11 @@ body {
|
||||
}
|
||||
|
||||
.activity-tool-card[data-status="running"] {
|
||||
border-color: rgba(52, 211, 153, 0.3);
|
||||
border-color: var(--accent-border-subtle);
|
||||
}
|
||||
|
||||
.activity-tool-card[data-status="fail"] {
|
||||
border-color: rgba(230, 76, 76, 0.3);
|
||||
border-color: var(--danger-border-subtle);
|
||||
}
|
||||
|
||||
.activity-tool-card[data-status="fail"] .activity-tool-name {
|
||||
@@ -1132,21 +1168,21 @@ body {
|
||||
.approval-card .approval-actions button.approve {
|
||||
background: var(--success);
|
||||
border-color: var(--success);
|
||||
color: #09090b;
|
||||
color: var(--text-on-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.approval-card .approval-actions button.always {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #09090b;
|
||||
color: var(--text-on-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.approval-card .approval-actions button.deny {
|
||||
background: var(--danger);
|
||||
border-color: var(--danger);
|
||||
color: #fff;
|
||||
color: var(--text-on-danger);
|
||||
}
|
||||
|
||||
.approval-resolved {
|
||||
@@ -1308,7 +1344,7 @@ body {
|
||||
.auth-card .auth-token-input input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.auth-card .auth-actions {
|
||||
@@ -1335,7 +1371,7 @@ body {
|
||||
.auth-card .auth-actions button.auth-submit {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
color: #09090b;
|
||||
color: var(--text-on-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1347,7 +1383,7 @@ body {
|
||||
.auth-card .auth-actions button.auth-oauth {
|
||||
background: var(--success);
|
||||
border-color: var(--success);
|
||||
color: #09090b;
|
||||
color: var(--text-on-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
@@ -1413,7 +1449,7 @@ body {
|
||||
.chat-input-wrapper textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.chat-input-wrapper textarea:disabled {
|
||||
@@ -1451,7 +1487,7 @@ body {
|
||||
.chat-input button {
|
||||
padding: 8px 20px;
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
@@ -1518,7 +1554,7 @@ body {
|
||||
.memory-sidebar input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.memory-tree {
|
||||
@@ -1712,7 +1748,7 @@ body {
|
||||
}
|
||||
|
||||
.summary-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.summary-card .count {
|
||||
@@ -1756,7 +1792,7 @@ body {
|
||||
}
|
||||
|
||||
.jobs-table tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--hover-surface);
|
||||
}
|
||||
|
||||
.badge {
|
||||
@@ -1768,13 +1804,13 @@ body {
|
||||
}
|
||||
|
||||
.badge.pending { background: var(--bg-tertiary); color: var(--text-secondary); }
|
||||
.badge.in_progress { background: rgba(52, 211, 153, 0.15); color: var(--accent); }
|
||||
.badge.completed { background: rgba(52, 211, 153, 0.15); color: var(--success); }
|
||||
.badge.failed { background: rgba(230, 76, 76, 0.15); color: var(--danger); }
|
||||
.badge.stuck { background: rgba(245, 166, 35, 0.15); color: var(--warning); }
|
||||
.badge.in_progress { background: var(--accent-subtle); color: var(--accent); }
|
||||
.badge.completed { background: var(--accent-subtle); color: var(--success); }
|
||||
.badge.failed { background: var(--danger-subtle); color: var(--danger); }
|
||||
.badge.stuck { background: var(--warning-subtle); color: var(--warning); }
|
||||
.badge.cancelled { background: var(--bg-tertiary); color: var(--text-secondary); }
|
||||
.badge.interrupted { background: rgba(245, 166, 35, 0.15); color: var(--warning); }
|
||||
.badge.source-sandbox { background: rgba(136, 132, 216, 0.15); color: #b4b0e8; }
|
||||
.badge.interrupted { background: var(--warning-subtle); color: var(--warning); }
|
||||
.badge.source-sandbox { background: var(--badge-sandbox-bg); color: var(--badge-sandbox-text); }
|
||||
.badge.source-direct { background: var(--bg-tertiary); color: var(--text-secondary); }
|
||||
|
||||
.btn-cancel {
|
||||
@@ -1788,7 +1824,7 @@ body {
|
||||
}
|
||||
|
||||
.btn-cancel:hover {
|
||||
background: rgba(230, 76, 76, 0.15);
|
||||
background: var(--danger-subtle);
|
||||
}
|
||||
|
||||
.btn-restart {
|
||||
@@ -1802,7 +1838,7 @@ body {
|
||||
}
|
||||
|
||||
.btn-restart:hover {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.btn-browse {
|
||||
@@ -1817,7 +1853,7 @@ body {
|
||||
}
|
||||
|
||||
.btn-browse:hover {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
/* Job started card in chat */
|
||||
@@ -1835,7 +1871,7 @@ body {
|
||||
}
|
||||
|
||||
.job-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.job-card-icon {
|
||||
@@ -1872,7 +1908,7 @@ body {
|
||||
}
|
||||
|
||||
.job-card-view:hover {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.job-card-browse {
|
||||
@@ -1882,7 +1918,7 @@ body {
|
||||
}
|
||||
|
||||
.job-card-browse:hover {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
/* Clickable job rows */
|
||||
@@ -2145,7 +2181,7 @@ body {
|
||||
}
|
||||
|
||||
.action-error {
|
||||
background: rgba(230, 76, 76, 0.1);
|
||||
background: var(--danger-error-bg);
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 12px;
|
||||
@@ -2187,8 +2223,8 @@ body {
|
||||
.conv-system .conv-body { color: var(--text-secondary); font-size: 13px; }
|
||||
|
||||
.conv-user {
|
||||
background: rgba(52, 211, 153, 0.08);
|
||||
border: 1px solid rgba(52, 211, 153, 0.2);
|
||||
background: var(--user-msg-bg);
|
||||
border: 1px solid var(--user-msg-border);
|
||||
}
|
||||
|
||||
.conv-user .conv-role { color: var(--accent); }
|
||||
@@ -2335,7 +2371,7 @@ body {
|
||||
}
|
||||
|
||||
.routines-table tr:hover td {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: var(--hover-surface);
|
||||
}
|
||||
|
||||
.routine-row {
|
||||
@@ -2346,9 +2382,9 @@ body {
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.badge.enabled { background: rgba(52, 211, 153, 0.15); color: var(--success); }
|
||||
.badge.enabled { background: var(--accent-subtle); color: var(--success); }
|
||||
.badge.disabled { background: var(--bg-tertiary); color: var(--text-secondary); }
|
||||
.badge.failing { background: rgba(230, 76, 76, 0.15); color: var(--danger); }
|
||||
.badge.failing { background: var(--danger-subtle); color: var(--danger); }
|
||||
|
||||
.btn-trigger {
|
||||
padding: 4px 10px;
|
||||
@@ -2361,7 +2397,7 @@ body {
|
||||
}
|
||||
|
||||
.btn-trigger:hover {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.btn-toggle {
|
||||
@@ -2375,7 +2411,7 @@ body {
|
||||
}
|
||||
|
||||
.btn-toggle:hover {
|
||||
background: rgba(245, 166, 35, 0.15);
|
||||
background: var(--warning-subtle);
|
||||
}
|
||||
|
||||
/* Logs Tab */
|
||||
@@ -2419,7 +2455,7 @@ body {
|
||||
.logs-toolbar input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.logs-checkbox {
|
||||
@@ -2580,7 +2616,7 @@ body {
|
||||
}
|
||||
|
||||
.ext-card:hover {
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
border-color: var(--border-hover);
|
||||
}
|
||||
|
||||
.ext-header {
|
||||
@@ -2605,17 +2641,17 @@ body {
|
||||
}
|
||||
|
||||
.ext-kind.kind-mcp_server {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.ext-kind.kind-wasm_tool {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.ext-kind.kind-wasm_channel {
|
||||
background: rgba(245, 166, 35, 0.15);
|
||||
background: var(--warning-subtle);
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
@@ -2720,7 +2756,7 @@ body {
|
||||
|
||||
.stepper-step.failed .stepper-circle {
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
color: var(--text-on-danger);
|
||||
}
|
||||
|
||||
.stepper-step.failed .stepper-label {
|
||||
@@ -2773,8 +2809,8 @@ body {
|
||||
.ext-error {
|
||||
font-size: 11px;
|
||||
color: var(--danger);
|
||||
background: rgba(230, 76, 76, 0.1);
|
||||
border: 1px solid rgba(230, 76, 76, 0.2);
|
||||
background: var(--danger-error-bg);
|
||||
border: 1px solid var(--danger-error-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 8px;
|
||||
margin-top: 6px;
|
||||
@@ -2783,7 +2819,7 @@ body {
|
||||
.ext-note {
|
||||
font-size: 11px;
|
||||
color: var(--text-secondary);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
background: var(--note-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 6px 8px;
|
||||
@@ -2821,7 +2857,7 @@ body {
|
||||
}
|
||||
|
||||
.btn-ext.activate:hover {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.btn-ext.remove {
|
||||
@@ -2830,7 +2866,7 @@ body {
|
||||
}
|
||||
|
||||
.btn-ext.remove:hover {
|
||||
background: rgba(230, 76, 76, 0.15);
|
||||
background: var(--danger-subtle);
|
||||
}
|
||||
|
||||
.btn-ext.install {
|
||||
@@ -2839,7 +2875,7 @@ body {
|
||||
}
|
||||
|
||||
.btn-ext.install:hover {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.btn-ext.install:disabled {
|
||||
@@ -2863,7 +2899,7 @@ body {
|
||||
}
|
||||
|
||||
.btn-ext.configure:hover {
|
||||
background: rgba(136, 132, 216, 0.15);
|
||||
background: var(--badge-sandbox-bg);
|
||||
}
|
||||
|
||||
/* Pairing requests */
|
||||
@@ -2911,7 +2947,7 @@ body {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
background: var(--overlay-heavy);
|
||||
backdrop-filter: blur(4px);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
@@ -3076,6 +3112,32 @@ body {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tools-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.tools-table th,
|
||||
.tools-table td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.tools-table th {
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.tools-table tr:hover td {
|
||||
background: var(--hover-surface);
|
||||
}
|
||||
|
||||
|
||||
/* --- Activity tab (unified sandbox job events) --- */
|
||||
|
||||
.activity-terminal {
|
||||
@@ -3094,7 +3156,7 @@ body {
|
||||
|
||||
.activity-event {
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
border-bottom: 1px solid var(--note-bg);
|
||||
}
|
||||
|
||||
.activity-event-message .activity-role {
|
||||
@@ -3197,13 +3259,13 @@ body {
|
||||
.activity-input-bar input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.activity-input-bar button {
|
||||
padding: 8px 16px;
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
@@ -3280,13 +3342,13 @@ body {
|
||||
padding: 10px 16px;
|
||||
border-radius: var(--radius);
|
||||
font-size: 13px;
|
||||
color: #fff;
|
||||
color: var(--text-on-danger);
|
||||
pointer-events: auto;
|
||||
transform: translateX(120%);
|
||||
transition: transform 0.25s ease;
|
||||
max-width: 360px;
|
||||
word-break: break-word;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
box-shadow: var(--shadow-toast);
|
||||
}
|
||||
|
||||
.toast.visible {
|
||||
@@ -3308,7 +3370,7 @@ body {
|
||||
/* --- Memory search highlighting --- */
|
||||
|
||||
mark {
|
||||
background: rgba(52, 211, 153, 0.3);
|
||||
background: var(--highlight-bg);
|
||||
color: inherit;
|
||||
border-radius: 2px;
|
||||
padding: 0 1px;
|
||||
@@ -3361,7 +3423,7 @@ mark {
|
||||
}
|
||||
|
||||
.thread-new-btn:hover {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
}
|
||||
|
||||
.assistant-item {
|
||||
@@ -3379,11 +3441,11 @@ mark {
|
||||
}
|
||||
|
||||
.assistant-item:hover {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
background: var(--hover-subtle);
|
||||
}
|
||||
|
||||
.assistant-item.active {
|
||||
background: rgba(52, 211, 153, 0.1);
|
||||
background: var(--accent-tee-bg);
|
||||
color: var(--accent);
|
||||
border-left: 2px solid var(--accent);
|
||||
}
|
||||
@@ -3471,14 +3533,14 @@ mark {
|
||||
letter-spacing: 0.5px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
background: var(--border);
|
||||
color: var(--text-secondary);
|
||||
margin-right: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thread-badge-routine { background: rgba(52, 211, 153, 0.15); color: var(--accent); }
|
||||
.thread-badge-heartbeat { background: rgba(245, 166, 35, 0.15); color: var(--warning); }
|
||||
.thread-badge-routine { background: var(--accent-subtle); color: var(--accent); }
|
||||
.thread-badge-heartbeat { background: var(--warning-subtle); color: var(--warning); }
|
||||
.thread-badge-telegram { background: rgba(0, 136, 204, 0.15); color: #0088cc; }
|
||||
.thread-badge-signal { background: rgba(59, 118, 240, 0.15); color: #3b76f0; }
|
||||
.thread-badge-slack { background: rgba(74, 21, 75, 0.15); color: #e01e5a; }
|
||||
@@ -3546,7 +3608,7 @@ mark {
|
||||
.memory-editor textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.memory-editor-actions {
|
||||
@@ -3557,7 +3619,7 @@ mark {
|
||||
.btn-save {
|
||||
padding: 6px 16px;
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
@@ -3638,7 +3700,7 @@ mark {
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 8px;
|
||||
background: rgba(15, 15, 17, 0.9);
|
||||
background: var(--popover-bg);
|
||||
backdrop-filter: blur(16px);
|
||||
-webkit-backdrop-filter: blur(16px);
|
||||
border: 1px solid var(--border);
|
||||
@@ -3736,13 +3798,13 @@ mark {
|
||||
.ext-install-form input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.ext-install-form button {
|
||||
padding: 6px 16px;
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
@@ -3786,13 +3848,13 @@ mark {
|
||||
.skill-search-box input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.skill-search-box button {
|
||||
padding: 8px 20px;
|
||||
background: var(--accent);
|
||||
color: #09090b;
|
||||
color: var(--text-on-accent);
|
||||
border: none;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
@@ -3816,7 +3878,7 @@ mark {
|
||||
}
|
||||
|
||||
.skill-trust.trust-trusted {
|
||||
background: rgba(52, 211, 153, 0.15);
|
||||
background: var(--accent-subtle);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
@@ -3861,7 +3923,7 @@ mark {
|
||||
.activity-toolbar select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px rgba(52, 211, 153, 0.1);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
/* --- Mobile responsive --- */
|
||||
@@ -4103,7 +4165,7 @@ mark {
|
||||
}
|
||||
|
||||
.settings-row:hover {
|
||||
background: var(--bg-hover);
|
||||
background: var(--hover-surface);
|
||||
}
|
||||
|
||||
.settings-row.hidden {
|
||||
@@ -4170,8 +4232,8 @@ mark {
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 14px;
|
||||
background: var(--warning-soft);
|
||||
border: 1px solid rgba(245, 166, 35, 0.25);
|
||||
background: var(--warning-subtle);
|
||||
border: 1px solid var(--warning-border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
@@ -4331,7 +4393,7 @@ input[type="checkbox"]:focus-visible {
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--danger);
|
||||
color: #fff;
|
||||
color: var(--text-on-danger);
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
line-height: 18px;
|
||||
@@ -4341,7 +4403,7 @@ input[type="checkbox"]:focus-visible {
|
||||
}
|
||||
|
||||
.image-preview-remove:hover {
|
||||
background: #c33;
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
|
||||
/* Generated Image */
|
||||
@@ -4628,6 +4690,120 @@ input[type="checkbox"]:focus-visible {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Screen-reader only utility */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Light Theme
|
||||
============================================================ */
|
||||
|
||||
[data-theme="light"] {
|
||||
--bg: #ffffff;
|
||||
--bg-secondary: #f5f5f7;
|
||||
--bg-tertiary: #ebebed;
|
||||
--border: rgba(0, 0, 0, 0.1);
|
||||
--text: #1a1a2e;
|
||||
--text-secondary: #555555;
|
||||
--accent: #059669;
|
||||
--accent-hover: #047857;
|
||||
--success: #059669;
|
||||
--warning: #d97706;
|
||||
--danger: #dc2626;
|
||||
--code-bg: #f0f0f2;
|
||||
--shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
--bg-overlay: rgba(0, 0, 0, 0.3);
|
||||
--bg-modal: #ffffff;
|
||||
--border-modal: #e0e0e0;
|
||||
--border-soft: #e5e5e5;
|
||||
--text-tertiary: #333333;
|
||||
--text-muted: #777777;
|
||||
--text-dimmed: #999999;
|
||||
--text-on-accent: #ffffff;
|
||||
--accent-brand: #059669;
|
||||
--accent-brand-hover: #047857;
|
||||
--warning-bg: #fffbeb;
|
||||
--warning-border: #fde68a;
|
||||
--warning-text: #92400e;
|
||||
--tab-bg: rgba(255, 255, 255, 0.9);
|
||||
--popover-bg: rgba(255, 255, 255, 0.95);
|
||||
--badge-sandbox-bg: rgba(136, 132, 216, 0.1);
|
||||
--badge-sandbox-text: #6b67b0;
|
||||
--hover-surface: rgba(0, 0, 0, 0.03);
|
||||
--focus-ring: rgba(5, 150, 105, 0.15);
|
||||
--accent-subtle: rgba(5, 150, 105, 0.1);
|
||||
--accent-border-subtle: rgba(5, 150, 105, 0.3);
|
||||
--danger-subtle: rgba(220, 38, 38, 0.1);
|
||||
--danger-border-subtle: rgba(220, 38, 38, 0.2);
|
||||
--warning-subtle: rgba(217, 119, 6, 0.1);
|
||||
--border-hover: rgba(0, 0, 0, 0.15);
|
||||
--user-msg-bg: rgba(5, 150, 105, 0.08);
|
||||
--user-msg-border: rgba(5, 150, 105, 0.2);
|
||||
--danger-error-bg: rgba(220, 38, 38, 0.06);
|
||||
--accent-tee-bg: rgba(5, 150, 105, 0.08);
|
||||
--accent-tee-border: rgba(5, 150, 105, 0.2);
|
||||
--accent-tee-hover: rgba(5, 150, 105, 0.15);
|
||||
--text-on-danger: #fff;
|
||||
--shadow-card: 0 4px 24px rgba(0, 0, 0, 0.08);
|
||||
--shadow-toast: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
--shadow-lg: 0 25px 50px -12px rgba(0, 0, 0, 0.1);
|
||||
--danger-error-border: rgba(220, 38, 38, 0.15);
|
||||
--note-bg: rgba(0, 0, 0, 0.02);
|
||||
--overlay-heavy: rgba(0, 0, 0, 0.4);
|
||||
--highlight-bg: rgba(5, 150, 105, 0.2);
|
||||
--hover-subtle: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Theme transition (delayed via JS to avoid FOUC)
|
||||
============================================================ */
|
||||
|
||||
body.theme-transition,
|
||||
body.theme-transition *:not(svg):not(path):not(line):not(circle):not(rect) {
|
||||
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Theme toggle button
|
||||
============================================================ */
|
||||
|
||||
.theme-toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 6px;
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
align-self: center;
|
||||
margin-right: 8px;
|
||||
transition: color 0.2s, border-color 0.2s;
|
||||
}
|
||||
|
||||
.theme-toggle-btn:hover {
|
||||
color: var(--text);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* CSS-only icon switching via data-theme-mode on <html> */
|
||||
.theme-icon { display: none; }
|
||||
[data-theme-mode="dark"] .icon-dark { display: block; }
|
||||
[data-theme-mode="light"] .icon-light { display: block; }
|
||||
[data-theme-mode="system"] .icon-system { display: block; }
|
||||
|
||||
|
||||
|
||||
|
||||
/* --- Config Tab --- */
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
// Prevent FOUC: apply saved theme before first paint.
|
||||
// This script must be loaded synchronously in <head> (no defer/async).
|
||||
(function() {
|
||||
const stored = localStorage.getItem('ironclaw-theme');
|
||||
const mode = (stored === 'dark' || stored === 'light' || stored === 'system') ? stored : 'system';
|
||||
let resolved = mode;
|
||||
if (mode === 'system') {
|
||||
resolved = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', resolved);
|
||||
document.documentElement.setAttribute('data-theme-mode', mode);
|
||||
})();
|
||||
@@ -2,7 +2,7 @@ use std::sync::Arc;
|
||||
|
||||
use secrecy::{ExposeSecret, SecretString};
|
||||
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env, validate_base_url};
|
||||
use crate::error::ConfigError;
|
||||
use crate::llm::SessionManager;
|
||||
use crate::settings::Settings;
|
||||
@@ -90,6 +90,12 @@ impl EmbeddingsConfig {
|
||||
|
||||
let openai_base_url = optional_env("EMBEDDING_BASE_URL")?;
|
||||
|
||||
// Validate base URLs to prevent SSRF attacks (#1103).
|
||||
validate_base_url(&ollama_base_url, "OLLAMA_BASE_URL")?;
|
||||
if let Some(ref url) = openai_base_url {
|
||||
validate_base_url(url, "EMBEDDING_BASE_URL")?;
|
||||
}
|
||||
|
||||
let cache_size = parse_optional_env("EMBEDDING_CACHE_SIZE", DEFAULT_EMBEDDING_CACHE_SIZE)?;
|
||||
|
||||
if cache_size == 0 {
|
||||
|
||||
@@ -176,6 +176,151 @@ pub(crate) fn parse_string_env(
|
||||
Ok(optional_env(key)?.unwrap_or_else(|| default.into()))
|
||||
}
|
||||
|
||||
/// Validate a user-configurable base URL to prevent SSRF attacks (#1103).
|
||||
///
|
||||
/// Rejects:
|
||||
/// - Non-HTTP(S) schemes (file://, ftp://, etc.)
|
||||
/// - HTTPS URLs pointing at private/loopback/link-local IPs
|
||||
/// - HTTP URLs pointing at anything other than localhost/127.0.0.1/::1
|
||||
///
|
||||
/// This is intended for config-time validation of base URLs like
|
||||
/// `OLLAMA_BASE_URL`, `EMBEDDING_BASE_URL`, `NEARAI_BASE_URL`, etc.
|
||||
pub(crate) fn validate_base_url(url: &str, field_name: &str) -> Result<(), ConfigError> {
|
||||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
let parsed = reqwest::Url::parse(url).map_err(|e| ConfigError::InvalidValue {
|
||||
key: field_name.to_string(),
|
||||
message: format!("invalid URL '{}': {}", url, e),
|
||||
})?;
|
||||
|
||||
let scheme = parsed.scheme();
|
||||
if scheme != "http" && scheme != "https" {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: field_name.to_string(),
|
||||
message: format!("only http/https URLs are allowed, got '{}'", scheme),
|
||||
});
|
||||
}
|
||||
|
||||
let host = parsed.host_str().ok_or_else(|| ConfigError::InvalidValue {
|
||||
key: field_name.to_string(),
|
||||
message: "URL is missing a host".to_string(),
|
||||
})?;
|
||||
|
||||
let host_lower = host.to_lowercase();
|
||||
|
||||
// For HTTP (non-TLS), only allow localhost — remote HTTP endpoints
|
||||
// risk credential leakage (e.g. NEAR AI bearer tokens sent over plaintext).
|
||||
if scheme == "http" {
|
||||
let is_localhost = host_lower == "localhost"
|
||||
|| host_lower == "127.0.0.1"
|
||||
|| host_lower == "::1"
|
||||
|| host_lower == "[::1]"
|
||||
|| host_lower.ends_with(".localhost");
|
||||
if !is_localhost {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: field_name.to_string(),
|
||||
message: format!(
|
||||
"HTTP (non-TLS) is only allowed for localhost, got '{}'. \
|
||||
Use HTTPS for remote endpoints.",
|
||||
host
|
||||
),
|
||||
});
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Check whether an IP is in a blocked range (private, loopback,
|
||||
// link-local, multicast, metadata, CGN, ULA).
|
||||
let is_dangerous_ip = |ip: &IpAddr| -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
v4.is_private()
|
||||
|| v4.is_loopback()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_multicast()
|
||||
|| v4.is_unspecified()
|
||||
|| *v4 == Ipv4Addr::new(169, 254, 169, 254)
|
||||
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64) // CGN
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
if let Some(v4) = v6.to_ipv4_mapped() {
|
||||
v4.is_private()
|
||||
|| v4.is_loopback()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_multicast()
|
||||
|| v4.is_unspecified()
|
||||
|| v4 == Ipv4Addr::new(169, 254, 169, 254)
|
||||
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xC0) == 64) // CGN
|
||||
} else {
|
||||
v6.is_loopback()
|
||||
|| v6.is_unspecified()
|
||||
|| (v6.octets()[0] & 0xfe) == 0xfc // ULA (fc00::/7)
|
||||
|| (v6.segments()[0] & 0xffc0) == 0xfe80 // link-local (fe80::/10)
|
||||
|| v6.octets()[0] == 0xff // multicast (ff00::/8)
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// For HTTPS, reject private/loopback/link-local/metadata IPs.
|
||||
// Check both IP literals and resolved hostnames to prevent DNS-based SSRF.
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
if is_dangerous_ip(&ip) {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: field_name.to_string(),
|
||||
message: format!(
|
||||
"URL points to a private/internal IP '{}'. \
|
||||
This is blocked to prevent SSRF attacks.",
|
||||
ip
|
||||
),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Hostname — resolve and check all resulting IPs as defense-in-depth.
|
||||
// NOTE: This does NOT fully prevent DNS rebinding attacks (the hostname
|
||||
// could resolve to a different IP at request time). Full protection
|
||||
// would require pinning the resolved IP in the HTTP client's connector.
|
||||
// This validation catches the common case of misconfigured or malicious URLs.
|
||||
//
|
||||
// NOTE: `to_socket_addrs()` performs blocking DNS resolution. This is
|
||||
// acceptable because `validate_base_url` runs at config-load time only,
|
||||
// before the async runtime is fully driving I/O. If this ever moves to
|
||||
// a hot path, wrap in `tokio::task::spawn_blocking` or use
|
||||
// `tokio::net::lookup_host`.
|
||||
use std::net::ToSocketAddrs;
|
||||
let port = parsed.port().unwrap_or(443);
|
||||
match (host, port).to_socket_addrs() {
|
||||
Ok(addrs) => {
|
||||
for addr in addrs {
|
||||
if is_dangerous_ip(&addr.ip()) {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: field_name.to_string(),
|
||||
message: format!(
|
||||
"hostname '{}' resolves to private/internal IP '{}'. \
|
||||
This is blocked to prevent SSRF attacks.",
|
||||
host,
|
||||
addr.ip()
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: field_name.to_string(),
|
||||
message: format!(
|
||||
"failed to resolve hostname '{}': {}. \
|
||||
Base URLs must be resolvable at config time.",
|
||||
host, e
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -226,4 +371,122 @@ mod tests {
|
||||
// Now the runtime override is visible again
|
||||
assert_eq!(env_or_override(key), Some("override_value".to_string()));
|
||||
}
|
||||
|
||||
// --- validate_base_url tests (regression for #1103) ---
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_allows_https() {
|
||||
// Use IP literals to avoid DNS resolution in sandboxed test environments.
|
||||
assert!(validate_base_url("https://8.8.8.8", "TEST").is_ok());
|
||||
assert!(validate_base_url("https://8.8.8.8/v1", "TEST").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_allows_http_localhost() {
|
||||
assert!(validate_base_url("http://localhost:11434", "TEST").is_ok());
|
||||
assert!(validate_base_url("http://127.0.0.1:11434", "TEST").is_ok());
|
||||
assert!(validate_base_url("http://[::1]:11434", "TEST").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_http_remote() {
|
||||
assert!(validate_base_url("http://evil.example.com", "TEST").is_err());
|
||||
assert!(validate_base_url("http://192.168.1.1", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_non_http_schemes() {
|
||||
assert!(validate_base_url("file:///etc/passwd", "TEST").is_err());
|
||||
assert!(validate_base_url("ftp://evil.com", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_cloud_metadata() {
|
||||
assert!(validate_base_url("https://169.254.169.254", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_private_ips() {
|
||||
assert!(validate_base_url("https://10.0.0.1", "TEST").is_err());
|
||||
assert!(validate_base_url("https://192.168.1.1", "TEST").is_err());
|
||||
assert!(validate_base_url("https://172.16.0.1", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_cgn_range() {
|
||||
// Carrier-grade NAT: 100.64.0.0/10
|
||||
assert!(validate_base_url("https://100.64.0.1", "TEST").is_err());
|
||||
assert!(validate_base_url("https://100.127.255.254", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_ipv4_mapped_ipv6() {
|
||||
// ::ffff:10.0.0.1 is an IPv4-mapped IPv6 address pointing to private IP
|
||||
assert!(validate_base_url("https://[::ffff:10.0.0.1]", "TEST").is_err());
|
||||
assert!(validate_base_url("https://[::ffff:169.254.169.254]", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_ula_ipv6() {
|
||||
// fc00::/7 — unique local addresses
|
||||
assert!(validate_base_url("https://[fc00::1]", "TEST").is_err());
|
||||
assert!(validate_base_url("https://[fd12:3456:789a::1]", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_handles_url_with_credentials() {
|
||||
// URLs with embedded credentials — validate_base_url checks the host,
|
||||
// not the credentials. Use IP literal to avoid DNS in sandboxed envs.
|
||||
let result = validate_base_url("https://user:[email protected]", "TEST");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_empty_and_invalid() {
|
||||
assert!(validate_base_url("", "TEST").is_err());
|
||||
assert!(validate_base_url("not-a-url", "TEST").is_err());
|
||||
assert!(validate_base_url("://missing-scheme", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_unspecified_ipv4() {
|
||||
assert!(validate_base_url("https://0.0.0.0", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_ipv6_loopback_https() {
|
||||
// IPv6 loopback is allowed over HTTP (localhost equivalent),
|
||||
// but must be rejected over HTTPS as a dangerous IP.
|
||||
assert!(validate_base_url("https://[::1]", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_ipv6_link_local() {
|
||||
// fe80::/10 — link-local addresses
|
||||
assert!(validate_base_url("https://[fe80::1]", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_ipv6_multicast() {
|
||||
// ff00::/8 — multicast addresses
|
||||
assert!(validate_base_url("https://[ff02::1]", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_ipv6_unspecified() {
|
||||
// :: — unspecified address
|
||||
assert!(validate_base_url("https://[::]", "TEST").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_base_url_rejects_dns_failure() {
|
||||
// .invalid TLD is guaranteed to never resolve (RFC 6761)
|
||||
let result = validate_base_url("https://ssrf-test.invalid", "TEST");
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
err.contains("failed to resolve"),
|
||||
"Expected DNS resolution failure, got: {err}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+22
-10
@@ -3,7 +3,7 @@ use std::path::PathBuf;
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::bootstrap::ironclaw_base_dir;
|
||||
use crate::config::helpers::{optional_env, parse_optional_env};
|
||||
use crate::config::helpers::{optional_env, parse_optional_env, validate_base_url};
|
||||
use crate::error::ConfigError;
|
||||
use crate::llm::config::*;
|
||||
use crate::llm::registry::{ProviderProtocol, ProviderRegistry};
|
||||
@@ -98,9 +98,11 @@ impl LlmConfig {
|
||||
}
|
||||
|
||||
// Session config (used by NearAI provider for OAuth/session-token auth)
|
||||
let nearai_auth_url = optional_env("NEARAI_AUTH_URL")?
|
||||
.unwrap_or_else(|| "https://private.near.ai".to_string());
|
||||
validate_base_url(&nearai_auth_url, "NEARAI_AUTH_URL")?;
|
||||
let session = SessionConfig {
|
||||
auth_base_url: optional_env("NEARAI_AUTH_URL")?
|
||||
.unwrap_or_else(|| "https://private.near.ai".to_string()),
|
||||
auth_base_url: nearai_auth_url,
|
||||
session_path: optional_env("NEARAI_SESSION_PATH")?
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_session_path),
|
||||
@@ -111,13 +113,17 @@ impl LlmConfig {
|
||||
let nearai = NearAiConfig {
|
||||
model: Self::resolve_model("NEARAI_MODEL", settings, crate::llm::DEFAULT_MODEL)?,
|
||||
cheap_model: optional_env("NEARAI_CHEAP_MODEL")?,
|
||||
base_url: optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| {
|
||||
if nearai_api_key.is_some() {
|
||||
"https://cloud-api.near.ai".to_string()
|
||||
} else {
|
||||
"https://private.near.ai".to_string()
|
||||
}
|
||||
}),
|
||||
base_url: {
|
||||
let url = optional_env("NEARAI_BASE_URL")?.unwrap_or_else(|| {
|
||||
if nearai_api_key.is_some() {
|
||||
"https://cloud-api.near.ai".to_string()
|
||||
} else {
|
||||
"https://private.near.ai".to_string()
|
||||
}
|
||||
});
|
||||
validate_base_url(&url, "NEARAI_BASE_URL")?;
|
||||
url
|
||||
},
|
||||
api_key: nearai_api_key,
|
||||
fallback_model: optional_env("NEARAI_FALLBACK_MODEL")?,
|
||||
max_retries: parse_optional_env("NEARAI_MAX_RETRIES", 3)?,
|
||||
@@ -402,6 +408,12 @@ impl LlmConfig {
|
||||
});
|
||||
}
|
||||
|
||||
// Validate base URL to prevent SSRF (#1103).
|
||||
if !base_url.is_empty() {
|
||||
let field = base_url_env.unwrap_or("LLM_BASE_URL");
|
||||
validate_base_url(&base_url, field)?;
|
||||
}
|
||||
|
||||
// Resolve model: env var > selected_model (/model command) > per-provider override > registry default
|
||||
let model = optional_env(model_env)?
|
||||
.or_else(|| settings.selected_model.clone())
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::config::helpers::{optional_env, parse_bool_env};
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, validate_base_url};
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
|
||||
@@ -60,6 +60,11 @@ impl TranscriptionConfig {
|
||||
|
||||
let base_url = optional_env("TRANSCRIPTION_BASE_URL")?;
|
||||
|
||||
// Validate base URL to prevent SSRF (#1103).
|
||||
if let Some(ref url) = base_url {
|
||||
validate_base_url(url, "TRANSCRIPTION_BASE_URL")?;
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
enabled,
|
||||
provider,
|
||||
|
||||
+201
-11
@@ -1,11 +1,12 @@
|
||||
//! Context manager for handling multiple job contexts.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::context::{JobContext, Memory};
|
||||
use crate::context::{JobContext, JobState, Memory};
|
||||
use crate::error::JobError;
|
||||
|
||||
/// Manages contexts for multiple concurrent jobs.
|
||||
@@ -45,12 +46,41 @@ impl ContextManager {
|
||||
title: impl Into<String>,
|
||||
description: impl Into<String>,
|
||||
) -> Result<Uuid, JobError> {
|
||||
// Hold write lock for the entire check-insert to prevent TOCTOU races
|
||||
// where two concurrent calls both pass the parallel_count check.
|
||||
let context = JobContext::with_user(user_id, title, description);
|
||||
let job_id = context.job_id;
|
||||
self.insert_context(context).await?;
|
||||
Ok(job_id)
|
||||
}
|
||||
|
||||
/// Register a sandbox job with a pre-determined ID.
|
||||
///
|
||||
/// Unlike `create_job_for_user` (which generates its own UUID), this method
|
||||
/// accepts an existing `job_id` — used by `execute_sandbox()` which creates
|
||||
/// the UUID before the container so it can be shared with Docker labels and
|
||||
/// DB persistence.
|
||||
///
|
||||
/// The job starts in `InProgress` state since the container is about to be
|
||||
/// created. Counts against `max_jobs` like any other job.
|
||||
pub async fn register_sandbox_job(
|
||||
&self,
|
||||
job_id: Uuid,
|
||||
user_id: impl Into<String>,
|
||||
title: impl Into<String>,
|
||||
description: impl Into<String>,
|
||||
) -> Result<(), JobError> {
|
||||
let mut context = JobContext::with_user(user_id, title, description);
|
||||
context.job_id = job_id;
|
||||
context.state = JobState::InProgress;
|
||||
context.started_at = Some(chrono::Utc::now());
|
||||
self.insert_context(context).await
|
||||
}
|
||||
|
||||
/// Check max_jobs limit, insert context, and allocate memory.
|
||||
///
|
||||
/// Holds the write lock for the entire check-insert to prevent TOCTOU
|
||||
/// races where two concurrent calls both pass the parallel_count check.
|
||||
async fn insert_context(&self, context: JobContext) -> Result<(), JobError> {
|
||||
let mut contexts = self.contexts.write().await;
|
||||
// Only count jobs that consume execution slots (Pending, InProgress, Stuck).
|
||||
// Completed and Submitted jobs are no longer actively executing and shouldn't
|
||||
// block new job creation.
|
||||
let parallel_count = contexts
|
||||
.values()
|
||||
.filter(|c| c.state.is_parallel_blocking())
|
||||
@@ -60,15 +90,16 @@ impl ContextManager {
|
||||
return Err(JobError::MaxJobsExceeded { max: self.max_jobs });
|
||||
}
|
||||
|
||||
let context = JobContext::with_user(user_id, title, description);
|
||||
let job_id = context.job_id;
|
||||
contexts.insert(job_id, context);
|
||||
drop(contexts);
|
||||
|
||||
let memory = Memory::new(job_id);
|
||||
self.memories.write().await.insert(job_id, memory);
|
||||
self.memories
|
||||
.write()
|
||||
.await
|
||||
.insert(job_id, Memory::new(job_id));
|
||||
|
||||
Ok(job_id)
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a job context by ID.
|
||||
@@ -205,12 +236,46 @@ impl ContextManager {
|
||||
}
|
||||
|
||||
/// Find stuck jobs.
|
||||
///
|
||||
/// Returns jobs that are explicitly in `Stuck` state, plus `InProgress`
|
||||
/// jobs that have been running longer than `elapsed_threshold` (if provided).
|
||||
/// The threshold-based detection catches jobs that never transitioned to
|
||||
/// `Stuck` (e.g., due to a deadlock or unhandled timeout).
|
||||
pub async fn find_stuck_jobs(&self) -> Vec<Uuid> {
|
||||
self.find_stuck_jobs_with_threshold(None).await
|
||||
}
|
||||
|
||||
/// Find stuck jobs with an optional elapsed threshold for `InProgress` detection.
|
||||
pub async fn find_stuck_jobs_with_threshold(
|
||||
&self,
|
||||
elapsed_threshold: Option<Duration>,
|
||||
) -> Vec<Uuid> {
|
||||
let now = chrono::Utc::now();
|
||||
self.contexts
|
||||
.read()
|
||||
.await
|
||||
.iter()
|
||||
.filter(|(_, c)| c.state == crate::context::JobState::Stuck)
|
||||
.filter(|(_, c)| {
|
||||
// Always include explicitly Stuck jobs.
|
||||
if c.state == crate::context::JobState::Stuck {
|
||||
return true;
|
||||
}
|
||||
// Detect InProgress jobs that have been running beyond the elapsed threshold.
|
||||
// NOTE: `started_at` is set on the first transition to InProgress and is
|
||||
// NOT reset when a job recovers from Stuck back to InProgress. This means
|
||||
// a recovered job may be re-detected on the next scan. A future improvement
|
||||
// could track `in_progress_since` or use the most recent StateTransition
|
||||
// with `to == InProgress` to avoid false positives on recovered jobs.
|
||||
if c.state == crate::context::JobState::InProgress
|
||||
&& let Some(threshold) = elapsed_threshold
|
||||
&& let Some(started) = c.started_at
|
||||
{
|
||||
let elapsed = now.signed_duration_since(started);
|
||||
let elapsed_secs = elapsed.num_seconds().max(0) as u64;
|
||||
return elapsed_secs > threshold.as_secs();
|
||||
}
|
||||
false
|
||||
})
|
||||
.map(|(id, _)| *id)
|
||||
.collect()
|
||||
}
|
||||
@@ -629,6 +694,48 @@ mod tests {
|
||||
assert_eq!(stuck[0], id2);
|
||||
}
|
||||
|
||||
/// Regression test for #1223: InProgress jobs exceeding the threshold
|
||||
/// should be detected as stuck even if they never transitioned to Stuck.
|
||||
#[tokio::test]
|
||||
async fn find_stuck_jobs_with_threshold_detects_idle_in_progress() {
|
||||
let manager = ContextManager::new(10);
|
||||
|
||||
let id1 = manager.create_job("Active job", "desc").await.unwrap();
|
||||
let id2 = manager.create_job("Idle job", "desc").await.unwrap();
|
||||
|
||||
// Both transition to InProgress
|
||||
for id in [id1, id2] {
|
||||
manager
|
||||
.update_context(id, |ctx| {
|
||||
ctx.transition_to(crate::context::JobState::InProgress, None)
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Backdate id2's started_at to simulate a long-running job
|
||||
manager
|
||||
.update_context(id2, |ctx| -> Result<(), crate::error::JobError> {
|
||||
ctx.started_at = Some(chrono::Utc::now() - chrono::Duration::seconds(600));
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
// With a 5-minute threshold, only id2 (10 min) should be detected
|
||||
let stuck = manager
|
||||
.find_stuck_jobs_with_threshold(Some(Duration::from_secs(300)))
|
||||
.await;
|
||||
assert_eq!(stuck.len(), 1);
|
||||
assert_eq!(stuck[0], id2);
|
||||
|
||||
// Without threshold, neither InProgress job is detected (no explicit Stuck state)
|
||||
let stuck_no_threshold = manager.find_stuck_jobs().await;
|
||||
assert!(stuck_no_threshold.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn active_count_tracks_non_terminal_jobs() {
|
||||
let manager = ContextManager::new(10);
|
||||
@@ -1185,4 +1292,87 @@ mod tests {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// === Regression: sandbox jobs must be visible to query tools ===
|
||||
// Before the fix, execute_sandbox() only persisted to DB but never
|
||||
// registered in ContextManager, making sandbox jobs invisible to
|
||||
// list_jobs, job_status, job_events, and resolve_job_id.
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_sandbox_job_visible_to_queries() {
|
||||
let manager = ContextManager::new(5);
|
||||
let job_id = Uuid::new_v4();
|
||||
|
||||
manager
|
||||
.register_sandbox_job(
|
||||
job_id,
|
||||
"user-42",
|
||||
"Run tests",
|
||||
"Execute test suite in sandbox",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Job should be retrievable by ID (used by job_status, job_events)
|
||||
let ctx = manager.get_context(job_id).await.unwrap();
|
||||
assert_eq!(ctx.job_id, job_id);
|
||||
assert_eq!(ctx.user_id, "user-42");
|
||||
assert_eq!(ctx.title, "Run tests");
|
||||
assert_eq!(ctx.state, JobState::InProgress);
|
||||
assert!(ctx.started_at.is_some());
|
||||
|
||||
// Job should appear in all_jobs (used by resolve_job_id prefix matching)
|
||||
let all = manager.all_jobs().await;
|
||||
assert!(all.contains(&job_id));
|
||||
|
||||
// Job should appear in user-scoped listing (used by list_jobs)
|
||||
let user_jobs = manager.all_jobs_for("user-42").await;
|
||||
assert!(user_jobs.contains(&job_id));
|
||||
|
||||
// Job should appear in active jobs listing
|
||||
let active = manager.active_jobs_for("user-42").await;
|
||||
assert!(active.contains(&job_id));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_sandbox_job_respects_max_jobs() {
|
||||
let manager = ContextManager::new(2);
|
||||
|
||||
// Fill up the slots with sandbox jobs
|
||||
manager
|
||||
.register_sandbox_job(Uuid::new_v4(), "user-1", "Job 1", "desc")
|
||||
.await
|
||||
.unwrap();
|
||||
manager
|
||||
.register_sandbox_job(Uuid::new_v4(), "user-1", "Job 2", "desc")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Third should fail
|
||||
let result = manager
|
||||
.register_sandbox_job(Uuid::new_v4(), "user-1", "Job 3", "desc")
|
||||
.await;
|
||||
assert!(matches!(result, Err(JobError::MaxJobsExceeded { max: 2 })));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn register_sandbox_job_transitions_correctly() {
|
||||
let manager = ContextManager::new(5);
|
||||
let job_id = Uuid::new_v4();
|
||||
|
||||
manager
|
||||
.register_sandbox_job(job_id, "user-1", "Task", "desc")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should be able to transition InProgress -> Completed
|
||||
manager
|
||||
.update_context(job_id, |ctx| ctx.transition_to(JobState::Completed, None))
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
|
||||
let ctx = manager.get_context(job_id).await.unwrap();
|
||||
assert_eq!(ctx.state, JobState::Completed);
|
||||
}
|
||||
}
|
||||
|
||||
+133
-5
@@ -225,6 +225,41 @@ impl CreateJobTool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Transition a sandbox job's state in the ContextManager (awaited).
|
||||
///
|
||||
/// Best-effort: logs on failure (job may have been cleaned up already).
|
||||
async fn update_context_state_async(
|
||||
&self,
|
||||
job_id: Uuid,
|
||||
state: JobState,
|
||||
reason: Option<String>,
|
||||
) {
|
||||
if let Err(e) = self
|
||||
.context_manager
|
||||
.update_context(job_id, |ctx| {
|
||||
let _ = ctx.transition_to(state, reason);
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::debug!(job_id = %job_id, "sandbox context update skipped: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// Fire-and-forget variant for use in sync contexts (e.g. `.map_err()` closures).
|
||||
fn update_context_state(&self, job_id: Uuid, state: JobState, reason: Option<String>) {
|
||||
let cm = self.context_manager.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = cm
|
||||
.update_context(job_id, |ctx| {
|
||||
let _ = ctx.transition_to(state, reason);
|
||||
})
|
||||
.await
|
||||
{
|
||||
tracing::debug!(job_id = %job_id, "sandbox context update skipped: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Update sandbox job status in DB (fire-and-forget).
|
||||
fn update_status(
|
||||
&self,
|
||||
@@ -354,6 +389,16 @@ impl CreateJobTool {
|
||||
}
|
||||
};
|
||||
|
||||
// Register in ContextManager so query tools (list_jobs, job_status,
|
||||
// job_events, cancel_job) can find sandbox jobs. Without this, sandbox
|
||||
// jobs exist only in the DB and are invisible to the agent.
|
||||
self.context_manager
|
||||
.register_sandbox_job(job_id, &ctx.user_id, task, task)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::ExecutionFailed(format!("failed to register sandbox job: {}", e))
|
||||
})?;
|
||||
|
||||
// Persist the job to DB before creating the container.
|
||||
self.persist_job(SandboxJobRecord {
|
||||
id: job_id,
|
||||
@@ -397,6 +442,7 @@ impl CreateJobTool {
|
||||
None,
|
||||
Some(Utc::now()),
|
||||
);
|
||||
self.update_context_state(job_id, JobState::Failed, Some(e.to_string()));
|
||||
ToolError::ExecutionFailed(format!("failed to create container: {}", e))
|
||||
})?;
|
||||
|
||||
@@ -416,16 +462,20 @@ impl CreateJobTool {
|
||||
// monitor terminates. No JoinHandle is retained.
|
||||
if let (Some(etx), Some(itx)) = (&self.event_tx, &self.inject_tx) {
|
||||
if let Some(route) = monitor_route_from_ctx(ctx) {
|
||||
crate::agent::job_monitor::spawn_job_monitor(
|
||||
crate::agent::job_monitor::spawn_job_monitor_with_context(
|
||||
job_id,
|
||||
etx.subscribe(),
|
||||
itx.clone(),
|
||||
route,
|
||||
Some(self.context_manager.clone()),
|
||||
);
|
||||
} else {
|
||||
tracing::debug!(
|
||||
job_id = %job_id,
|
||||
"Skipping job monitor injection due to missing route metadata"
|
||||
// No routing metadata — can't inject messages, but still
|
||||
// need to transition the job out of InProgress when done.
|
||||
crate::agent::job_monitor::spawn_completion_watcher(
|
||||
job_id,
|
||||
etx.subscribe(),
|
||||
self.context_manager.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -457,6 +507,12 @@ impl CreateJobTool {
|
||||
None,
|
||||
Some(Utc::now()),
|
||||
);
|
||||
self.update_context_state_async(
|
||||
job_id,
|
||||
JobState::Failed,
|
||||
Some("Timed out (10 minutes)".to_string()),
|
||||
)
|
||||
.await;
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
"container execution timed out (10 minutes)".to_string(),
|
||||
));
|
||||
@@ -491,6 +547,8 @@ impl CreateJobTool {
|
||||
None,
|
||||
Some(finished_at),
|
||||
);
|
||||
self.update_context_state_async(job_id, JobState::Completed, None)
|
||||
.await;
|
||||
let result = serde_json::json!({
|
||||
"job_id": job_id.to_string(),
|
||||
"status": "completed",
|
||||
@@ -508,6 +566,12 @@ impl CreateJobTool {
|
||||
None,
|
||||
Some(finished_at),
|
||||
);
|
||||
self.update_context_state_async(
|
||||
job_id,
|
||||
JobState::Failed,
|
||||
Some(message.clone()),
|
||||
)
|
||||
.await;
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"container job failed: {}",
|
||||
message
|
||||
@@ -529,6 +593,12 @@ impl CreateJobTool {
|
||||
None,
|
||||
Some(Utc::now()),
|
||||
);
|
||||
self.update_context_state_async(
|
||||
job_id,
|
||||
JobState::Failed,
|
||||
Some(message.clone()),
|
||||
)
|
||||
.await;
|
||||
return Err(ToolError::ExecutionFailed(format!(
|
||||
"container job failed: {}",
|
||||
message
|
||||
@@ -544,6 +614,8 @@ impl CreateJobTool {
|
||||
None,
|
||||
Some(Utc::now()),
|
||||
);
|
||||
self.update_context_state_async(job_id, JobState::Completed, None)
|
||||
.await;
|
||||
let result = serde_json::json!({
|
||||
"job_id": job_id.to_string(),
|
||||
"status": "completed",
|
||||
@@ -1025,13 +1097,34 @@ impl Tool for JobStatusTool {
|
||||
}
|
||||
|
||||
/// Tool for canceling a job.
|
||||
///
|
||||
/// For sandbox jobs (registered via `register_sandbox_job`), cancellation also
|
||||
/// stops the Docker container and updates the DB status — matching the behavior
|
||||
/// of the web cancellation handler in `channels/web/handlers/jobs.rs`.
|
||||
pub struct CancelJobTool {
|
||||
context_manager: Arc<ContextManager>,
|
||||
job_manager: Option<Arc<ContainerJobManager>>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
}
|
||||
|
||||
impl CancelJobTool {
|
||||
pub fn new(context_manager: Arc<ContextManager>) -> Self {
|
||||
Self { context_manager }
|
||||
Self {
|
||||
context_manager,
|
||||
job_manager: None,
|
||||
store: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject sandbox dependencies so cancellation also stops containers.
|
||||
pub fn with_sandbox(
|
||||
mut self,
|
||||
job_manager: Arc<ContainerJobManager>,
|
||||
store: Option<Arc<dyn Database>>,
|
||||
) -> Self {
|
||||
self.job_manager = Some(job_manager);
|
||||
self.store = store;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1081,6 +1174,41 @@ impl Tool for CancelJobTool {
|
||||
.await
|
||||
{
|
||||
Ok(Ok(())) => {
|
||||
// Stop the sandbox container if one exists for this job.
|
||||
if let Some(ref jm) = self.job_manager
|
||||
&& let Err(e) = jm.stop_job(job_id).await
|
||||
{
|
||||
tracing::warn!(
|
||||
job_id = %job_id,
|
||||
"Failed to stop container during cancellation: {}", e
|
||||
);
|
||||
}
|
||||
|
||||
// Update DB status for sandbox jobs. Uses "failed" (not
|
||||
// "cancelled") to match the web cancel handler convention —
|
||||
// the sandbox DB schema treats cancellation as a failure variant.
|
||||
if let Some(ref store) = self.store {
|
||||
let store = store.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = store
|
||||
.update_sandbox_job_status(
|
||||
job_id,
|
||||
"failed",
|
||||
Some(false),
|
||||
Some("Cancelled by user"),
|
||||
None,
|
||||
Some(Utc::now()),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(
|
||||
job_id = %job_id,
|
||||
"Failed to update sandbox job status on cancel: {}", e
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let result = serde_json::json!({
|
||||
"job_id": job_id.to_string(),
|
||||
"status": "cancelled",
|
||||
|
||||
+298
-70
@@ -67,6 +67,95 @@ impl MessageTool {
|
||||
}
|
||||
}
|
||||
|
||||
fn metadata_string(metadata: &serde_json::Value, key: &str) -> Option<String> {
|
||||
metadata
|
||||
.get(key)
|
||||
.and_then(|value| value.as_str())
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn metadata_notify_user(metadata: &serde_json::Value) -> Option<String> {
|
||||
metadata_string(metadata, "notify_user").filter(|value| value != "default")
|
||||
}
|
||||
|
||||
fn channel_matches_source(resolved_channel: Option<&str>, source_channel: Option<&str>) -> bool {
|
||||
match (resolved_channel, source_channel) {
|
||||
(None, _) => true,
|
||||
(Some(resolved), Some(source)) if resolved == source => true,
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
async fn resolve_channel_fallback_target(
|
||||
extension_manager: Option<&Arc<ExtensionManager>>,
|
||||
channel: Option<&str>,
|
||||
ctx_user_id: &str,
|
||||
) -> Option<String> {
|
||||
let channel_name = channel?;
|
||||
|
||||
if let Some(extension_manager) = extension_manager
|
||||
&& let Some(target) = extension_manager
|
||||
.notification_target_for_channel(channel_name)
|
||||
.await
|
||||
{
|
||||
return Some(target);
|
||||
}
|
||||
|
||||
Some(ctx_user_id.to_string())
|
||||
}
|
||||
|
||||
struct MessageTargetResolution<'a> {
|
||||
extension_manager: Option<&'a Arc<ExtensionManager>>,
|
||||
explicit_target: Option<String>,
|
||||
metadata_target: Option<String>,
|
||||
default_target: Option<String>,
|
||||
channel: Option<&'a str>,
|
||||
metadata_channel: Option<&'a str>,
|
||||
default_channel: Option<&'a str>,
|
||||
has_execution_routing_metadata: bool,
|
||||
ctx_user_id: &'a str,
|
||||
}
|
||||
|
||||
async fn resolve_message_target(inputs: MessageTargetResolution<'_>) -> Option<String> {
|
||||
if let Some(target) = inputs.explicit_target {
|
||||
return Some(target);
|
||||
}
|
||||
|
||||
if inputs.has_execution_routing_metadata {
|
||||
if channel_matches_source(inputs.channel, inputs.metadata_channel)
|
||||
&& let Some(target) = inputs.metadata_target
|
||||
{
|
||||
return Some(target);
|
||||
}
|
||||
|
||||
return resolve_channel_fallback_target(
|
||||
inputs.extension_manager,
|
||||
inputs.channel,
|
||||
inputs.ctx_user_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
if channel_matches_source(inputs.channel, inputs.default_channel)
|
||||
&& let Some(target) = inputs.default_target
|
||||
{
|
||||
return Some(target);
|
||||
}
|
||||
|
||||
if inputs.channel.is_some() {
|
||||
return resolve_channel_fallback_target(
|
||||
inputs.extension_manager,
|
||||
inputs.channel,
|
||||
inputs.ctx_user_id,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Tool for MessageTool {
|
||||
fn name(&self) -> &str {
|
||||
@@ -123,68 +212,52 @@ impl Tool for MessageTool {
|
||||
.get("channel")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|value| value.to_string());
|
||||
let metadata_channel = metadata_string(&ctx.metadata, "notify_channel");
|
||||
let default_channel = self
|
||||
.default_channel
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
let metadata_channel = ctx
|
||||
.metadata
|
||||
.get("notify_channel")
|
||||
let default_target = self
|
||||
.default_target
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone();
|
||||
let metadata_target = metadata_notify_user(&ctx.metadata);
|
||||
let has_execution_routing_metadata =
|
||||
metadata_channel.is_some() || metadata_target.is_some();
|
||||
|
||||
// Job metadata is authoritative for autonomous executions. The shared
|
||||
// conversation defaults are only a legacy fallback when no execution-local
|
||||
// routing metadata is available.
|
||||
let channel: Option<String> = explicit_channel
|
||||
.clone()
|
||||
.or_else(|| metadata_channel.clone())
|
||||
.or_else(|| {
|
||||
(!has_execution_routing_metadata)
|
||||
.then(|| default_channel.clone())
|
||||
.flatten()
|
||||
});
|
||||
|
||||
let explicit_target = params
|
||||
.get("target")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|value| value.to_string());
|
||||
|
||||
// Get channel: use param → conversation default → job metadata → None (broadcast all)
|
||||
let channel: Option<String> = explicit_channel
|
||||
.clone()
|
||||
.or_else(|| default_channel.clone())
|
||||
.or_else(|| metadata_channel.clone());
|
||||
|
||||
let can_use_default_target = match (explicit_channel.as_deref(), default_channel.as_deref())
|
||||
{
|
||||
(None, _) => true,
|
||||
(Some(explicit), Some(current)) if explicit == current => true,
|
||||
_ => false,
|
||||
};
|
||||
let can_use_metadata_target = match (channel.as_deref(), metadata_channel.as_deref()) {
|
||||
(None, _) => true,
|
||||
(Some(resolved), Some(current)) if resolved == current => true,
|
||||
_ => false,
|
||||
};
|
||||
|
||||
// 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()) {
|
||||
Some(t.to_string())
|
||||
} else if can_use_default_target
|
||||
&& let Some(t) = self
|
||||
.default_target
|
||||
.read()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.clone()
|
||||
{
|
||||
Some(t)
|
||||
} else if can_use_metadata_target
|
||||
&& let Some(t) = ctx.metadata.get("notify_user").and_then(|v| v.as_str())
|
||||
{
|
||||
Some(t.to_string())
|
||||
} else if channel.is_some() {
|
||||
if let Some(channel_name) = channel.as_deref() {
|
||||
if let Some(extension_manager) = self.extension_manager.as_ref()
|
||||
&& let Some(target) = extension_manager
|
||||
.notification_target_for_channel(channel_name)
|
||||
.await
|
||||
{
|
||||
Some(target)
|
||||
} else {
|
||||
Some(ctx.user_id.clone())
|
||||
}
|
||||
} else {
|
||||
Some(ctx.user_id.clone())
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// Prefer explicit params, then execution-local routing metadata. Shared
|
||||
// conversation defaults are only consulted when no job metadata exists.
|
||||
let target = resolve_message_target(MessageTargetResolution {
|
||||
extension_manager: self.extension_manager.as_ref(),
|
||||
explicit_target,
|
||||
metadata_target,
|
||||
default_target,
|
||||
channel: channel.as_deref(),
|
||||
metadata_channel: metadata_channel.as_deref(),
|
||||
default_channel: default_channel.as_deref(),
|
||||
has_execution_routing_metadata,
|
||||
ctx_user_id: &ctx.user_id,
|
||||
})
|
||||
.await;
|
||||
|
||||
let Some(target) = target else {
|
||||
return Err(ToolError::ExecutionFailed(
|
||||
@@ -230,6 +303,12 @@ impl Tool for MessageTool {
|
||||
if !attachments.is_empty() {
|
||||
response = response.with_attachments(attachments);
|
||||
}
|
||||
if channel.as_deref() == Some("gateway")
|
||||
&& response.thread_id.is_none()
|
||||
&& let Some(thread_id) = metadata_string(&ctx.metadata, "notify_thread_id")
|
||||
{
|
||||
response = response.in_thread(thread_id);
|
||||
}
|
||||
|
||||
if let Some(ref channel) = channel {
|
||||
// Send to a specific channel
|
||||
@@ -326,6 +405,92 @@ impl Tool for MessageTool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use tokio::sync::{Mutex, mpsc};
|
||||
|
||||
use crate::channels::{
|
||||
Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate,
|
||||
};
|
||||
use crate::error::ChannelError;
|
||||
|
||||
type BroadcastCapture = Arc<Mutex<Vec<(String, OutgoingResponse)>>>;
|
||||
|
||||
struct RecordingChannel {
|
||||
name: &'static str,
|
||||
captures: BroadcastCapture,
|
||||
}
|
||||
|
||||
impl RecordingChannel {
|
||||
fn new(name: &'static str) -> (Self, BroadcastCapture) {
|
||||
let captures = Arc::new(Mutex::new(Vec::new()));
|
||||
(
|
||||
Self {
|
||||
name,
|
||||
captures: Arc::clone(&captures),
|
||||
},
|
||||
captures,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for RecordingChannel {
|
||||
fn name(&self) -> &str {
|
||||
self.name
|
||||
}
|
||||
|
||||
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
||||
let (_tx, rx) = mpsc::channel::<IncomingMessage>(1);
|
||||
Ok(Box::pin(tokio_stream::wrappers::ReceiverStream::new(rx)))
|
||||
}
|
||||
|
||||
async fn respond(
|
||||
&self,
|
||||
_msg: &IncomingMessage,
|
||||
_response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn send_status(
|
||||
&self,
|
||||
_status: StatusUpdate,
|
||||
_metadata: &serde_json::Value,
|
||||
) -> Result<(), ChannelError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn broadcast(
|
||||
&self,
|
||||
user_id: &str,
|
||||
response: OutgoingResponse,
|
||||
) -> Result<(), ChannelError> {
|
||||
self.captures
|
||||
.lock()
|
||||
.await
|
||||
.push((user_id.to_string(), response));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn health_check(&self) -> Result<(), ChannelError> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn message_tool_with_recording_channels()
|
||||
-> (MessageTool, BroadcastCapture, BroadcastCapture) {
|
||||
let channel_manager = ChannelManager::new();
|
||||
let (gateway, gateway_captures) = RecordingChannel::new("gateway");
|
||||
let (telegram, telegram_captures) = RecordingChannel::new("telegram");
|
||||
channel_manager.add(Box::new(gateway)).await;
|
||||
channel_manager.add(Box::new(telegram)).await;
|
||||
|
||||
(
|
||||
MessageTool::new(Arc::new(channel_manager)),
|
||||
gateway_captures,
|
||||
telegram_captures,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_tool_name() {
|
||||
@@ -782,31 +947,94 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn message_tool_does_not_apply_metadata_target_to_different_default_channel() {
|
||||
let tool = MessageTool::new(Arc::new(ChannelManager::new()));
|
||||
tool.set_context(Some("telegram".to_string()), None).await;
|
||||
async fn message_tool_prefers_metadata_over_stale_default_context() {
|
||||
let (tool, gateway_captures, telegram_captures) =
|
||||
message_tool_with_recording_channels().await;
|
||||
tool.set_context(
|
||||
Some("gateway".to_string()),
|
||||
Some("stale-gateway-target".to_string()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut ctx = crate::context::JobContext::with_user("owner-scope", "test", "test");
|
||||
ctx.metadata = serde_json::json!({
|
||||
"notify_channel": "signal",
|
||||
"notify_user": "metadata-user",
|
||||
"notify_channel": "telegram",
|
||||
"notify_user": "424242",
|
||||
});
|
||||
|
||||
let result = tool
|
||||
.execute(serde_json::json!({"content": "hello"}), &ctx)
|
||||
.await;
|
||||
.await
|
||||
.expect("message tool should use telegram metadata routing");
|
||||
assert_eq!(
|
||||
result.result.as_str(),
|
||||
Some("Sent message to telegram:424242")
|
||||
);
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.unwrap_err().to_string();
|
||||
assert!(gateway_captures.lock().await.is_empty());
|
||||
let telegram = telegram_captures.lock().await.clone();
|
||||
assert_eq!(telegram.len(), 1);
|
||||
assert_eq!(telegram[0].0, "424242");
|
||||
assert_eq!(telegram[0].1.content, "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn message_tool_notify_user_only_metadata_does_not_reuse_stale_default_channel() {
|
||||
let (tool, gateway_captures, telegram_captures) =
|
||||
message_tool_with_recording_channels().await;
|
||||
tool.set_context(
|
||||
Some("gateway".to_string()),
|
||||
Some("stale-gateway-target".to_string()),
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut ctx = crate::context::JobContext::with_user("owner-scope", "test", "test");
|
||||
ctx.metadata = serde_json::json!({
|
||||
"notify_user": "424242",
|
||||
});
|
||||
|
||||
let result = tool
|
||||
.execute(serde_json::json!({"content": "hello"}), &ctx)
|
||||
.await
|
||||
.expect("message tool should broadcast when only notify_user is provided");
|
||||
assert!(
|
||||
!err.contains("metadata-user"),
|
||||
"metadata target should not be applied to a different default channel: {}",
|
||||
err
|
||||
);
|
||||
assert!(
|
||||
err.contains("owner-scope"),
|
||||
"expected owner-scope fallback target when metadata channel differs: {}",
|
||||
err
|
||||
result
|
||||
.result
|
||||
.as_str()
|
||||
.is_some_and(|message| message.contains("Broadcast message to"))
|
||||
);
|
||||
|
||||
let gateway = gateway_captures.lock().await.clone();
|
||||
assert_eq!(gateway.len(), 1);
|
||||
assert_eq!(gateway[0].0, "424242");
|
||||
assert_eq!(gateway[0].1.content, "hello");
|
||||
|
||||
let telegram = telegram_captures.lock().await.clone();
|
||||
assert_eq!(telegram.len(), 1);
|
||||
assert_eq!(telegram[0].0, "424242");
|
||||
assert_eq!(telegram[0].1.content, "hello");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn message_tool_applies_notify_thread_id_for_gateway_delivery() {
|
||||
let (tool, gateway_captures, telegram_captures) =
|
||||
message_tool_with_recording_channels().await;
|
||||
|
||||
let mut ctx = crate::context::JobContext::with_user("owner-scope", "test", "test");
|
||||
ctx.metadata = serde_json::json!({
|
||||
"notify_channel": "gateway",
|
||||
"notify_user": "owner-scope",
|
||||
"notify_thread_id": "thread-123",
|
||||
});
|
||||
|
||||
tool.execute(serde_json::json!({"content": "hello"}), &ctx)
|
||||
.await
|
||||
.expect("gateway routing with thread id should succeed");
|
||||
|
||||
assert!(telegram_captures.lock().await.is_empty());
|
||||
let gateway = gateway_captures.lock().await.clone();
|
||||
assert_eq!(gateway.len(), 1);
|
||||
assert_eq!(gateway[0].0, "owner-scope");
|
||||
assert_eq!(gateway[0].1.thread_id.as_deref(), Some("thread-123"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -367,6 +367,9 @@ impl ToolRegistry {
|
||||
if let Some(slot) = scheduler_slot {
|
||||
create_tool = create_tool.with_scheduler_slot(slot);
|
||||
}
|
||||
// Clone before moving into create_tool so cancel_job can also use them.
|
||||
let jm_for_cancel = job_manager.clone();
|
||||
let store_for_cancel = store.clone();
|
||||
if let Some(jm) = job_manager {
|
||||
create_tool = create_tool.with_sandbox(jm, store.clone());
|
||||
}
|
||||
@@ -379,7 +382,11 @@ impl ToolRegistry {
|
||||
self.register_sync(Arc::new(create_tool));
|
||||
self.register_sync(Arc::new(ListJobsTool::new(Arc::clone(&context_manager))));
|
||||
self.register_sync(Arc::new(JobStatusTool::new(Arc::clone(&context_manager))));
|
||||
self.register_sync(Arc::new(CancelJobTool::new(Arc::clone(&context_manager))));
|
||||
let mut cancel_tool = CancelJobTool::new(Arc::clone(&context_manager));
|
||||
if let Some(jm) = jm_for_cancel {
|
||||
cancel_tool = cancel_tool.with_sandbox(jm, store_for_cancel);
|
||||
}
|
||||
self.register_sync(Arc::new(cancel_tool));
|
||||
|
||||
// Base tools: create, list, status, cancel
|
||||
let mut job_tool_count = 4;
|
||||
|
||||
Reference in New Issue
Block a user