Files
optimclaw/src/channels/web/ws.rs
T
4566181f40 feat(gateway): unified settings page with subtabs (#1191)
* feat(gateway): full settings page polish with all tiers

- Backend: add ActiveConfigSnapshot to expose resolved LLM backend,
  model, and enabled channels via /api/gateway/status
- Add missing Agent settings (daily cost cap, actions/hour, local tools)
- Add Sandbox, Routines, Safety, Skills, and Search setting groups
- Settings import/export (JSON download + file upload)
- Active env defaults shown as placeholders in Inference settings
- Styled confirmation modals replace window.confirm() for remove actions
- Global restart banner persists across settings subtab switches
- Client-side validation with min/max constraints on number inputs
- Accessibility: aria-label on inputs, role=status on save indicators
- Settings search filters rows across current subtab
- Smooth CSS transitions for conditional field visibility (showWhen)
- Tunnel settings in Channels subtab
- Mobile responsive settings layout at 768px breakpoint
- i18n keys for toolbar, search, and import/export in en + zh-CN

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* feat(gateway): polish settings page and remove registered tools debug section

Remove the "Registered Tools" table from the extensions tab (debug info
not useful to end users), clean up associated CSS/i18n/JS. Additional
settings page UI polish: extension card state styling, layout refinements.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(gateway): address PR review feedback [skip-regression-check]

- Use refreshCurrentSettingsTab() in SSE event handlers to reduce duplication
- Remove unused formatGroupName/formatSettingLabel helpers
- Use i18n keys for MCP Configure/Reconfigure buttons
- Add data-i18n-placeholder to settings search input
- Remove data-i18n from confirm modal button (set dynamically by showConfirmModal)
- Fix cargo fmt in main.rs

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(e2e): update tests for unified settings tab layout [skip-regression-check]

- Update TABS list: replace extensions/skills with settings
- Add settings_subtab/settings_subpanel selectors to helpers
- Update test_connection, test_skills, test_extensions, test_wasm_lifecycle
  to navigate via Settings > subtab instead of top-level tabs
- Move MCP card tests to use go_to_mcp() helper (MCP is now a separate subtab)
- Remove tools table tests and mock_ext_apis tools= parameter
- Fix CSP violation: replace inline onclick on confirm modal cancel button

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(gateway): address second round of PR review feedback [skip-regression-check]

- Use I18n.t() for MCP empty state, export/import toasts, confirm modal
- Fix CLI channel card using wrong channel key ('repl' -> 'cli')
- Fix settings search counting hidden rows as visible
- Add aria-label i18n for settings search input
- Add common.loadFailed i18n key (en + zh-CN)
- Update E2E tests: WASM channel tests use Channels subtab,
  remove tests use custom confirm modal instead of window.confirm

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(e2e): fix WASM channel card selector and skills remove confirm [skip-regression-check]

- WASM channel tests: filter by display name to avoid matching built-in
  channel cards in the Channels subtab
- Skills remove test: click confirm modal button instead of using
  window.confirm (skill removal now uses custom confirm modal)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(gateway): address third round of PR review feedback [skip-regression-check]

- approval_needed SSE: refresh any active settings subtab, not just
  Extensions — approvals can surface from Channels/MCP setup flows too
- renderCardsSkeleton: remove nested .extensions-list wrapper that
  caused skeleton cards to render constrained inside grid cells

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(e2e): fix auth_completed reload test race condition [skip-regression-check]

Use expect_response to deterministically wait for the /api/extensions
reload triggered by handleAuthCompleted → refreshCurrentSettingsTab,
instead of a fixed 600ms sleep that was too short under CI load.
Also remove stale /api/extensions/tools route handler.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(e2e): debug auth_completed reload test with function counter [skip-regression-check]

Inject a counter wrapper around refreshCurrentSettingsTab to verify it's
actually called, and wait for the async fetch to complete before
asserting the reload count.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* feat(gateway): localize all settings labels, descriptions, and channel cards [skip-regression-check]

Move 120+ hardcoded strings in settings definitions (INFERENCE_SETTINGS,
AGENT_SETTINGS, NETWORKING_SETTINGS) and channel card labels to i18n
keys. Render functions now resolve labels via I18n.t() so the settings
page translates when switching locales.

Covers: group titles, setting labels/descriptions, built-in channel
names/descriptions, and the "No settings found" empty state.

Both en.js and zh-CN.js updated with all new cfg.* and channels.* keys.

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(gateway): localize remaining hardcoded UI strings [skip-regression-check]

- Fix export error toast using wrong i18n key (importFailed → exportFailed)
- Replace "Failed to load settings:" with I18n.t('common.loadFailed')
- Localize renderBuiltinChannelCard: "Built-in", "Active", "Inactive"
- Localize settings placeholders: "env: ", "env default", "use env default"
- Localize "✓ Saved" indicator
- Add new i18n keys to both en.js and zh-CN.js

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(gateway): confirm modal a11y, Esc/click-outside, search guard [skip-regression-check]

- Add role="dialog", aria-modal="true", aria-labelledby to confirm modal
- Focus confirm button when modal opens
- Close modal on Escape key or overlay click
- Skip settings search on non-settings panels (Extensions/MCP/Skills/Channels)

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(gateway): boolean tri-state, search reset on subtab switch, stale model suggestions [skip-regression-check]

Address PR review feedback:
- Boolean settings now use a tri-state select (env default / On / Off)
  instead of a checkbox, matching the pattern used by other select settings
  and allowing users to revert to the env default
- Clear search input when switching settings subtabs so stale filters
  don't carry over to the new panel
- Always assign model suggestions (even empty array) so stale IDs from a
  previous successful /v1/models fetch don't persist when the endpoint
  later returns empty

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

* fix(gateway): auth_completed handler, bedrock_cross_region select, integer-only number inputs [skip-regression-check]

Address PR review feedback:
- auth_completed SSE listener now delegates to handleAuthCompleted(data)
  instead of inlining logic with a bare closeConfigureModal() call, so
  only the matching extension's modal is dismissed
- bedrock_cross_region changed from free text to select with the four
  valid values (us/eu/apac/global), matching backend validation
- Number settings now use step=1 and parseInt() instead of parseFloat(),
  preventing fractional values that the backend (u32/u64) would reject

Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-03-18 16:18:29 -07:00

528 lines
18 KiB
Rust

//! WebSocket handler for bidirectional client communication.
//!
//! Provides the same event stream as SSE but also accepts incoming messages
//! (chat, approvals) over a single persistent connection.
//!
//! ```text
//! Client ──── WS frame: {"type":"message","content":"hello"} ──► Agent Loop
//! ◄─── WS frame: {"type":"event","event_type":"response","data":{...}} ── Broadcast
//! ──── WS frame: {"type":"ping"} ──────────────────────────────────────►
//! ◄─── WS frame: {"type":"pong"} ──────────────────────────────────────
//! ```
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use axum::extract::ws::{Message, WebSocket};
use futures::{SinkExt, StreamExt};
use tokio::sync::mpsc;
use uuid::Uuid;
use crate::agent::submission::Submission;
use crate::channels::IncomingMessage;
use crate::channels::web::server::GatewayState;
use crate::channels::web::types::{WsClientMessage, WsServerMessage};
/// Tracks active WebSocket connections.
pub struct WsConnectionTracker {
count: AtomicU64,
}
impl WsConnectionTracker {
pub fn new() -> Self {
Self {
count: AtomicU64::new(0),
}
}
pub fn connection_count(&self) -> u64 {
self.count.load(Ordering::Relaxed)
}
fn increment(&self) {
self.count.fetch_add(1, Ordering::Relaxed);
}
fn decrement(&self) {
self.count.fetch_sub(1, Ordering::Relaxed);
}
}
impl Default for WsConnectionTracker {
fn default() -> Self {
Self::new()
}
}
/// Handle an upgraded WebSocket connection.
///
/// Spawns two tasks:
/// - **sender**: forwards broadcast events to the WebSocket client
/// - **receiver**: reads client frames and routes them to the agent
///
/// When either task ends (client disconnect or broadcast closed), both are
/// cleaned up.
pub async fn handle_ws_connection(socket: WebSocket, state: Arc<GatewayState>) {
let (mut ws_sink, mut ws_stream) = socket.split();
// Track connection
if let Some(ref tracker) = state.ws_tracker {
tracker.increment();
}
let tracker_for_drop = state.ws_tracker.clone();
// Subscribe to broadcast events (same source as SSE).
// Reject if we've hit the connection limit.
let Some(raw_stream) = state.sse.subscribe_raw() else {
tracing::warn!("WebSocket rejected: too many connections");
// Decrement the WS tracker we already incremented above.
if let Some(ref tracker) = tracker_for_drop {
tracker.decrement();
}
return;
};
let mut event_stream = Box::pin(raw_stream);
// Channel for the sender task to receive messages from both
// the broadcast stream and any direct sends (like Pong)
let (direct_tx, mut direct_rx) = mpsc::channel::<WsServerMessage>(64);
// Sender task: forward broadcast events + direct messages to WS client
let sender_handle = tokio::spawn(async move {
loop {
let msg = tokio::select! {
event = event_stream.next() => {
match event {
Some(sse_event) => WsServerMessage::from_sse_event(&sse_event),
None => break, // Broadcast channel closed
}
}
direct = direct_rx.recv() => {
match direct {
Some(msg) => msg,
None => break, // Direct channel closed
}
}
};
let json = match serde_json::to_string(&msg) {
Ok(j) => j,
Err(_) => continue,
};
if ws_sink.send(Message::Text(json.into())).await.is_err() {
break; // Client disconnected
}
}
});
// Receiver task: read client frames and route to agent
let user_id = state.user_id.clone();
while let Some(Ok(frame)) = ws_stream.next().await {
match frame {
Message::Text(text) => {
let parsed: Result<WsClientMessage, _> = serde_json::from_str(&text);
match parsed {
Ok(client_msg) => {
handle_client_message(client_msg, &state, &user_id, &direct_tx).await;
}
Err(e) => {
let _ = direct_tx
.send(WsServerMessage::Error {
message: format!("Invalid message: {}", e),
})
.await;
}
}
}
Message::Close(_) => break,
// Ignore binary, ping/pong (axum handles protocol-level pings)
_ => {}
}
}
// Clean up: abort sender, decrement counter
sender_handle.abort();
if let Some(ref tracker) = tracker_for_drop {
tracker.decrement();
}
}
/// Route a parsed client message to the appropriate handler.
async fn handle_client_message(
msg: WsClientMessage,
state: &GatewayState,
user_id: &str,
direct_tx: &mpsc::Sender<WsServerMessage>,
) {
match msg {
WsClientMessage::Message {
content,
thread_id,
timezone,
images,
} => {
let mut incoming = IncomingMessage::new("gateway", user_id, &content);
if let Some(ref tz) = timezone {
incoming = incoming.with_timezone(tz);
}
if let Some(ref tid) = thread_id {
incoming = incoming.with_thread(tid);
}
// Convert uploaded images to IncomingAttachments
if !images.is_empty() {
let attachments = crate::channels::web::server::images_to_attachments(&images);
incoming = incoming.with_attachments(attachments);
}
// Clone sender to avoid holding RwLock read guard across send().await
let tx = {
let tx_guard = state.msg_tx.read().await;
tx_guard.as_ref().cloned()
};
if let Some(tx) = tx {
if tx.send(incoming).await.is_err() {
let _ = direct_tx
.send(WsServerMessage::Error {
message: "Channel closed".to_string(),
})
.await;
}
} else {
let _ = direct_tx
.send(WsServerMessage::Error {
message: "Channel not started".to_string(),
})
.await;
}
}
WsClientMessage::Approval {
request_id,
action,
thread_id,
} => {
let (approved, always) = match action.as_str() {
"approve" => (true, false),
"always" => (true, true),
"deny" => (false, false),
other => {
let _ = direct_tx
.send(WsServerMessage::Error {
message: format!("Unknown approval action: {}", other),
})
.await;
return;
}
};
let request_uuid = match Uuid::parse_str(&request_id) {
Ok(id) => id,
Err(_) => {
let _ = direct_tx
.send(WsServerMessage::Error {
message: "Invalid request_id (expected UUID)".to_string(),
})
.await;
return;
}
};
let approval = Submission::ExecApproval {
request_id: request_uuid,
approved,
always,
};
let content = match serde_json::to_string(&approval) {
Ok(c) => c,
Err(e) => {
let _ = direct_tx
.send(WsServerMessage::Error {
message: format!("Failed to serialize approval: {}", e),
})
.await;
return;
}
};
let mut msg = IncomingMessage::new("gateway", user_id, content);
if let Some(ref tid) = thread_id {
msg = msg.with_thread(tid);
}
// Clone sender to avoid holding RwLock read guard across send().await
let tx = {
let tx_guard = state.msg_tx.read().await;
tx_guard.as_ref().cloned()
};
if let Some(tx) = tx {
let _ = tx.send(msg).await;
}
}
WsClientMessage::AuthToken {
extension_name,
token,
} => {
if let Some(ref ext_mgr) = state.extension_manager {
match ext_mgr.configure_token(&extension_name, &token).await {
Ok(result) => {
if result.verification.is_some() {
state.sse.broadcast(
crate::channels::web::types::SseEvent::AuthRequired {
extension_name: extension_name.clone(),
instructions: Some(result.message),
auth_url: None,
setup_url: None,
},
);
} else {
crate::channels::web::server::clear_auth_mode(state).await;
state.sse.broadcast(
crate::channels::web::types::SseEvent::AuthCompleted {
extension_name,
success: true,
message: result.message,
},
);
}
}
Err(e) => {
let msg = format!("Auth failed: {}", e);
if matches!(e, crate::extensions::ExtensionError::ValidationFailed(_)) {
state.sse.broadcast(
crate::channels::web::types::SseEvent::AuthRequired {
extension_name: extension_name.clone(),
instructions: Some(msg.clone()),
auth_url: None,
setup_url: None,
},
);
}
let _ = direct_tx
.send(WsServerMessage::Error { message: msg })
.await;
}
}
} else {
let _ = direct_tx
.send(WsServerMessage::Error {
message: "Extension manager not available".to_string(),
})
.await;
}
}
WsClientMessage::AuthCancel { .. } => {
crate::channels::web::server::clear_auth_mode(state).await;
}
WsClientMessage::Ping => {
let _ = direct_tx.send(WsServerMessage::Pong).await;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ws_connection_tracker() {
let tracker = WsConnectionTracker::new();
assert_eq!(tracker.connection_count(), 0);
tracker.increment();
assert_eq!(tracker.connection_count(), 1);
tracker.increment();
assert_eq!(tracker.connection_count(), 2);
tracker.decrement();
assert_eq!(tracker.connection_count(), 1);
tracker.decrement();
assert_eq!(tracker.connection_count(), 0);
}
#[test]
fn test_ws_connection_tracker_default() {
let tracker = WsConnectionTracker::default();
assert_eq!(tracker.connection_count(), 0);
}
#[tokio::test]
async fn test_handle_client_message_ping() {
// Ping should produce a Pong on the direct channel
let (direct_tx, mut direct_rx) = mpsc::channel(16);
let state = make_test_state(None).await;
handle_client_message(WsClientMessage::Ping, &state, "user1", &direct_tx).await;
let response = direct_rx.recv().await.unwrap();
assert!(matches!(response, WsServerMessage::Pong));
}
#[tokio::test]
async fn test_handle_client_message_sends_to_agent() {
// A Message should be forwarded to the agent's msg_tx
let (agent_tx, mut agent_rx) = mpsc::channel(16);
let state = make_test_state(Some(agent_tx)).await;
let (direct_tx, _direct_rx) = mpsc::channel(16);
handle_client_message(
WsClientMessage::Message {
content: "hello agent".to_string(),
thread_id: Some("t1".to_string()),
timezone: None,
images: Vec::new(),
},
&state,
"user1",
&direct_tx,
)
.await;
let incoming = agent_rx.recv().await.unwrap();
assert_eq!(incoming.content, "hello agent");
assert_eq!(incoming.thread_id.as_deref(), Some("t1"));
assert_eq!(incoming.channel, "gateway");
assert_eq!(incoming.user_id, "user1");
}
#[tokio::test]
async fn test_handle_client_message_no_channel() {
// When msg_tx is None, should send an error back
let state = make_test_state(None).await;
let (direct_tx, mut direct_rx) = mpsc::channel(16);
handle_client_message(
WsClientMessage::Message {
content: "hello".to_string(),
thread_id: None,
timezone: None,
images: Vec::new(),
},
&state,
"user1",
&direct_tx,
)
.await;
let response = direct_rx.recv().await.unwrap();
match response {
WsServerMessage::Error { message } => {
assert!(message.contains("not started"));
}
_ => panic!("Expected Error variant"),
}
}
#[tokio::test]
async fn test_handle_client_approval_approve() {
let (agent_tx, mut agent_rx) = mpsc::channel(16);
let state = make_test_state(Some(agent_tx)).await;
let (direct_tx, _direct_rx) = mpsc::channel(16);
let request_id = Uuid::new_v4();
handle_client_message(
WsClientMessage::Approval {
request_id: request_id.to_string(),
action: "approve".to_string(),
thread_id: Some("thread-42".to_string()),
},
&state,
"user1",
&direct_tx,
)
.await;
let incoming = agent_rx.recv().await.unwrap();
// The content should be a serialized ExecApproval
assert!(incoming.content.contains("ExecApproval"));
// Thread should be forwarded onto the IncomingMessage.
assert_eq!(incoming.thread_id.as_deref(), Some("thread-42"));
}
#[tokio::test]
async fn test_handle_client_approval_invalid_action() {
let state = make_test_state(None).await;
let (direct_tx, mut direct_rx) = mpsc::channel(16);
handle_client_message(
WsClientMessage::Approval {
request_id: Uuid::new_v4().to_string(),
action: "maybe".to_string(),
thread_id: None,
},
&state,
"user1",
&direct_tx,
)
.await;
let response = direct_rx.recv().await.unwrap();
match response {
WsServerMessage::Error { message } => {
assert!(message.contains("Unknown approval action"));
}
_ => panic!("Expected Error variant"),
}
}
#[tokio::test]
async fn test_handle_client_approval_invalid_uuid() {
let state = make_test_state(None).await;
let (direct_tx, mut direct_rx) = mpsc::channel(16);
handle_client_message(
WsClientMessage::Approval {
request_id: "not-a-uuid".to_string(),
action: "approve".to_string(),
thread_id: None,
},
&state,
"user1",
&direct_tx,
)
.await;
let response = direct_rx.recv().await.unwrap();
match response {
WsServerMessage::Error { message } => {
assert!(message.contains("Invalid request_id"));
}
_ => panic!("Expected Error variant"),
}
}
/// Helper to create a GatewayState for testing.
async fn make_test_state(msg_tx: Option<mpsc::Sender<IncomingMessage>>) -> GatewayState {
use crate::channels::web::sse::SseManager;
GatewayState {
msg_tx: tokio::sync::RwLock::new(msg_tx),
sse: SseManager::new(),
workspace: None,
session_manager: None,
log_broadcaster: None,
log_level_handle: None,
extension_manager: None,
tool_registry: None,
store: None,
job_manager: None,
prompt_queue: None,
scheduler: None,
user_id: "test".to_string(),
shutdown_tx: tokio::sync::RwLock::new(None),
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
llm_provider: None,
skill_registry: None,
skill_catalog: None,
chat_rate_limiter: crate::channels::web::server::RateLimiter::new(30, 60),
oauth_rate_limiter: crate::channels::web::server::RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
startup_time: std::time::Instant::now(),
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
}
}
}