chore: resolve conflicts

This commit is contained in:
italic-jinxin
2026-03-20 16:06:28 +08:00
23 changed files with 1784 additions and 268 deletions
+1 -1
View File
@@ -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
View File
@@ -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 {
+1 -6
View File
@@ -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).
+227
View File
@@ -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
View File
@@ -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,
+1
View File
@@ -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
View File
@@ -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. \
+11
View File
@@ -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
+11
View File
@@ -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 {
(
[
+64
View File
@@ -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;
+6
View File
@@ -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',
+6
View File
@@ -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': '记忆',
+13
View File
@@ -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
View File
@@ -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 --- */
+12
View File
@@ -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);
})();
+7 -1
View File
@@ -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 {
+263
View File
@@ -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
View File
@@ -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())
+6 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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"));
}
}
+8 -1
View File
@@ -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;