diff --git a/clawpal-core/src/ssh/mod.rs b/clawpal-core/src/ssh/mod.rs index a91a3329..483b52d7 100644 --- a/clawpal-core/src/ssh/mod.rs +++ b/clawpal-core/src/ssh/mod.rs @@ -96,18 +96,6 @@ impl SshSession { if config.host.trim().is_empty() { return Err(SshError::InvalidConfig("host is empty".to_string())); } - if config.auth_method.trim().eq_ignore_ascii_case("password") - && config - .password - .as_deref() - .map(str::trim) - .filter(|v| !v.is_empty()) - .is_none() - { - return Err(SshError::InvalidConfig( - "password auth selected but password is empty".to_string(), - )); - } let backend = match connect_and_auth(config, passphrase).await { Ok((handle, _)) => Backend::Russh { handle: Arc::new(handle), @@ -457,11 +445,10 @@ async fn connect_and_auth( .map_err(|e| SshError::Connect(e.to_string()))?; if config.auth_method.trim().eq_ignore_ascii_case("password") { - let password = config - .password - .as_deref() + let password = passphrase .map(str::trim) .filter(|v| !v.is_empty()) + .or_else(|| config.password.as_deref().map(str::trim).filter(|v| !v.is_empty())) .ok_or_else(|| { SshError::InvalidConfig("password auth selected but password is empty".to_string()) })?; diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b35f5c12..acbe5b0b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,7 +21,7 @@ uuid = { version = "1.11.0", features = ["v4"] } chrono = { version = "0.4.38", features = ["clock"] } base64 = "0.22" ed25519-dalek = { version = "2", features = ["pkcs8", "pem"] } -tokio = { version = "1", features = ["sync", "process", "macros"] } +tokio = { version = "1", features = ["sync", "process", "macros", "io-util"] } tokio-tungstenite = { version = "0.24", features = ["rustls-tls-webpki-roots"] } futures-util = "0.3" shellexpand = "3.1" diff --git a/src-tauri/src/doctor_commands.rs b/src-tauri/src/doctor_commands.rs index b773e8f6..fcf81800 100644 --- a/src-tauri/src/doctor_commands.rs +++ b/src-tauri/src/doctor_commands.rs @@ -6,9 +6,7 @@ use tauri::{AppHandle, Emitter, State}; use crate::doctor_runtime_bridge::emit_runtime_event; use crate::models::resolve_paths; -use crate::runtime::types::{ - RuntimeAdapter, RuntimeDomain, RuntimeError, RuntimeEvent, RuntimeSessionKey, -}; +use crate::runtime::types::{RuntimeDomain, RuntimeError, RuntimeEvent, RuntimeSessionKey}; use crate::runtime::zeroclaw::adapter::ZeroclawDoctorAdapter; use crate::runtime::zeroclaw::install_adapter::ZeroclawInstallAdapter; use crate::ssh::SshConnectionPool; @@ -106,7 +104,11 @@ pub async fn doctor_start_diagnosis( session_key.clone(), ); let adapter = ZeroclawDoctorAdapter; - match adapter.start(&key, &context) { + let app_clone = app.clone(); + let on_delta = move |text: &str| { + emit_runtime_event(&app_clone, RuntimeEvent::chat_delta(text.to_string())); + }; + match adapter.start_streaming(&key, &context, on_delta).await { Ok(events) => { for ev in events { register_runtime_invoke(&ev); @@ -145,7 +147,11 @@ pub async fn doctor_send_message( session_key.clone(), ); let adapter = ZeroclawDoctorAdapter; - match adapter.send(&key, &message) { + let app_clone = app.clone(); + let on_delta = move |text: &str| { + emit_runtime_event(&app_clone, RuntimeEvent::chat_delta(text.to_string())); + }; + match adapter.send_streaming(&key, &message, on_delta).await { Ok(events) => { for ev in events { register_runtime_invoke(&ev); @@ -256,10 +262,18 @@ pub async fn doctor_approve_invoke( agent_id.clone(), session_key.clone(), ); + let app_clone = app.clone(); + let on_delta = move |text: &str| { + emit_runtime_event(&app_clone, RuntimeEvent::chat_delta(text.to_string())); + }; let send_result = if is_install { - ZeroclawInstallAdapter.send(&key, &result_text) + ZeroclawInstallAdapter + .send_streaming(&key, &result_text, on_delta) + .await } else { - ZeroclawDoctorAdapter.send(&key, &result_text) + ZeroclawDoctorAdapter + .send_streaming(&key, &result_text, on_delta) + .await }; let events = match handle_runtime_send_result(rt_domain.as_str(), send_result) { Ok(events) => events, diff --git a/src-tauri/src/install_commands.rs b/src-tauri/src/install_commands.rs index cd4d98b5..28216468 100644 --- a/src-tauri/src/install_commands.rs +++ b/src-tauri/src/install_commands.rs @@ -2,7 +2,7 @@ use tauri::AppHandle; use crate::doctor_commands::register_runtime_invoke; use crate::doctor_runtime_bridge::emit_runtime_event; -use crate::runtime::types::{RuntimeAdapter, RuntimeDomain, RuntimeEvent, RuntimeSessionKey}; +use crate::runtime::types::{RuntimeDomain, RuntimeEvent, RuntimeSessionKey}; use crate::runtime::zeroclaw::install_adapter::ZeroclawInstallAdapter; #[tauri::command] @@ -22,7 +22,11 @@ pub async fn install_start_session( session_key.clone(), ); let adapter = ZeroclawInstallAdapter; - match adapter.start(&key, &context) { + let app_clone = app.clone(); + let on_delta = move |text: &str| { + emit_runtime_event(&app_clone, RuntimeEvent::chat_delta(text.to_string())); + }; + match adapter.start_streaming(&key, &context, on_delta).await { Ok(events) => { for ev in events { register_runtime_invoke(&ev); @@ -55,7 +59,11 @@ pub async fn install_send_message( session_key.clone(), ); let adapter = ZeroclawInstallAdapter; - match adapter.send(&key, &message) { + let app_clone = app.clone(); + let on_delta = move |text: &str| { + emit_runtime_event(&app_clone, RuntimeEvent::chat_delta(text.to_string())); + }; + match adapter.send_streaming(&key, &message, on_delta).await { Ok(events) => { for ev in events { register_runtime_invoke(&ev); diff --git a/src-tauri/src/runtime/zeroclaw/adapter.rs b/src-tauri/src/runtime/zeroclaw/adapter.rs index b9c790d1..7feca430 100644 --- a/src-tauri/src/runtime/zeroclaw/adapter.rs +++ b/src-tauri/src/runtime/zeroclaw/adapter.rs @@ -8,6 +8,7 @@ use serde_json::Value; use super::process::run_zeroclaw_message; use super::session::{append_history, build_prompt_with_history, reset_history}; +use super::streaming::run_zeroclaw_streaming_turn; pub struct ZeroclawDoctorAdapter; @@ -116,6 +117,59 @@ impl ZeroclawDoctorAdapter { } } +impl ZeroclawDoctorAdapter { + pub async fn start_streaming( + &self, + key: &RuntimeSessionKey, + message: &str, + on_delta: F, +) -> Result, RuntimeError> + where + F: Fn(&str) + Send + Sync + 'static, + { + let prompt = Self::doctor_domain_prompt(key, message); + let assistant_events = run_zeroclaw_streaming_turn( + key, + &prompt, + true, + None, + on_delta, + Self::normalize_doctor_output, + Self::parse_tool_intent, + Self::map_error, + ) + .await?; + let session_key = key.storage_key(); + append_history(&session_key, "system", &prompt); + Ok(assistant_events) + } + + pub async fn send_streaming( + &self, + key: &RuntimeSessionKey, + message: &str, + on_delta: F, +) -> Result, RuntimeError> + where + F: Fn(&str) + Send + Sync + 'static, + { + let prompt = build_prompt_with_history(&key.storage_key(), message); + let guarded = Self::doctor_domain_prompt(key, &prompt); + let assistant_events = run_zeroclaw_streaming_turn( + key, + &guarded, + false, + Some(message), + on_delta, + Self::normalize_doctor_output, + Self::parse_tool_intent, + Self::map_error, + ) + .await?; + Ok(assistant_events) + } +} + impl RuntimeAdapter for ZeroclawDoctorAdapter { fn engine_name(&self) -> &'static str { "zeroclaw" diff --git a/src-tauri/src/runtime/zeroclaw/install_adapter.rs b/src-tauri/src/runtime/zeroclaw/install_adapter.rs index 4bb8cd0a..89f6f9fa 100644 --- a/src-tauri/src/runtime/zeroclaw/install_adapter.rs +++ b/src-tauri/src/runtime/zeroclaw/install_adapter.rs @@ -6,6 +6,7 @@ use serde_json::json; use super::process::run_zeroclaw_message; use super::session::{append_history, build_prompt_with_history_preamble, reset_history}; +use super::streaming::run_zeroclaw_streaming_turn; pub struct ZeroclawInstallAdapter; @@ -67,6 +68,63 @@ impl ZeroclawInstallAdapter { } } +impl ZeroclawInstallAdapter { + pub async fn start_streaming( + &self, + key: &RuntimeSessionKey, + message: &str, + on_delta: F, +) -> Result, RuntimeError> + where + F: Fn(&str) + Send + Sync + 'static, + { + let session_key = key.storage_key(); + reset_history(&session_key); + let prompt = Self::install_domain_prompt(key, message); + let assistant_events = run_zeroclaw_streaming_turn( + key, + &prompt, + true, + None, + on_delta, + |text| text, + Self::parse_tool_intent, + Self::map_error, + ) + .await?; + append_history(&session_key, "system", &prompt); + Ok(assistant_events) + } + + pub async fn send_streaming( + &self, + key: &RuntimeSessionKey, + message: &str, + on_delta: F, +) -> Result, RuntimeError> + where + F: Fn(&str) + Send + Sync + 'static, + { + let session_key = key.storage_key(); + append_history(&session_key, "user", message); + let preamble = format!("{}\n", crate::prompt_templates::install_history_preamble()); + let prompt = build_prompt_with_history_preamble(&session_key, message, &preamble); + let guarded = Self::install_domain_prompt(key, &prompt); + let assistant_events = run_zeroclaw_streaming_turn( + key, + &guarded, + false, + Some(message), + on_delta, + |text| text, + Self::parse_tool_intent, + Self::map_error, + ) + .await?; + Ok(assistant_events) + } +} + impl RuntimeAdapter for ZeroclawInstallAdapter { fn engine_name(&self) -> &'static str { "zeroclaw" diff --git a/src-tauri/src/runtime/zeroclaw/mod.rs b/src-tauri/src/runtime/zeroclaw/mod.rs index 5216aa98..a1e6bdca 100644 --- a/src-tauri/src/runtime/zeroclaw/mod.rs +++ b/src-tauri/src/runtime/zeroclaw/mod.rs @@ -1,4 +1,5 @@ pub mod adapter; +mod streaming; pub mod install_adapter; pub mod process; pub mod sanitize; diff --git a/src-tauri/src/runtime/zeroclaw/process.rs b/src-tauri/src/runtime/zeroclaw/process.rs index 9d903808..935278ef 100644 --- a/src-tauri/src/runtime/zeroclaw/process.rs +++ b/src-tauri/src/runtime/zeroclaw/process.rs @@ -11,8 +11,9 @@ use std::{ use crate::models::{resolve_paths, OpenClawPaths}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use tokio::io::{AsyncBufReadExt, AsyncReadExt}; -use super::sanitize::sanitize_output; +use super::sanitize::{sanitize_line, sanitize_output}; #[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] @@ -780,71 +781,20 @@ fn prepend_preferred_model_candidate( candidates.insert(0, normalized); } -pub fn run_zeroclaw_message( - message: &str, - instance_id: &str, - session_scope: &str, -) -> Result { - let cmd = resolve_zeroclaw_command_path() - .ok_or_else(|| "zeroclaw binary not found in bundled resources".to_string())?; - let cfg = doctor_sidecar_config_dir(instance_id, session_scope)?; - ensure_runtime_trace_mode(&cfg); - let env_pairs = zeroclaw_env_pairs_from_clawpal(); - if env_pairs.is_empty() { - return Err( - "No compatible API key found in ClawPal model profiles for zeroclaw.".to_string(), - ); - } - let cfg_arg = cfg.to_string_lossy().to_string(); - let message = clamp_message_for_cli(message); - let base_args = vec![ - "--config-dir".to_string(), - cfg_arg, - "agent".to_string(), - "-m".to_string(), - message, - ]; - let preferred_model = crate::commands::load_zeroclaw_model_preference(); - let provider_order = provider_order_for_runtime(&env_pairs, preferred_model.as_deref()); - if provider_order.is_empty() { - return Err( - "No supported zeroclaw provider is available from current profiles.".to_string(), - ); - } +async fn run_zeroclaw_retry( + base_args: &[String], + provider_order: &[&str], + preferred_model: Option, + mut run_once: T, +) -> Result +where + T: FnMut(Vec) -> Fut, + Fut: std::future::Future>, +{ let mut attempt_errors = Vec::::new(); - let try_once = |args: Vec| -> Result { - let output = Command::new(&cmd) - .envs(env_pairs.clone()) - .args(args) - .output() - .map_err(|e| format!("failed to run zeroclaw sidecar: {e}"))?; - let stdout = sanitize_output(&String::from_utf8_lossy(&output.stdout)); - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - record_zeroclaw_usage(&stdout, &stderr); - if parse_usage_from_text(&stdout).is_none() && parse_usage_from_text(&stderr).is_none() { - if let Ok(mut stats) = usage_store().lock() { - if let Some((prompt, completion, total)) = - read_usage_from_builtin_traces(&cmd, &cfg, &env_pairs) - { - stats.usage_calls = stats.usage_calls.saturating_add(1); - stats.prompt_tokens = stats.prompt_tokens.saturating_add(prompt); - stats.completion_tokens = stats.completion_tokens.saturating_add(completion); - stats.total_tokens = stats.total_tokens.saturating_add(total); - stats.last_updated_ms = now_ms(); - } - } - } - if !output.status.success() { - let msg = if !stderr.is_empty() { stderr } else { stdout }; - return Err(format!("zeroclaw sidecar failed: {msg}")); - } - if !stdout.is_empty() { - return Ok(stdout); - } - Ok("(zeroclaw returned no output)".to_string()) - }; for provider in provider_order { - let mut provider_base_args = base_args.clone(); + let provider = *provider; + let mut provider_base_args = base_args.to_vec(); provider_base_args.push("-p".to_string()); provider_base_args.push(provider.to_string()); @@ -855,7 +805,7 @@ pub fn run_zeroclaw_message( Some(provider), ); if model_candidates.is_empty() { - match try_once(provider_base_args.clone()) { + match run_once(provider_base_args.clone()).await { Ok(v) => return Ok(v), Err(e) => { attempt_errors.push(format!("provider={provider} no-model: {e}")); @@ -864,11 +814,12 @@ pub fn run_zeroclaw_message( } } - for model in model_candidates { + let mut auth_break = false; + for model in &model_candidates { let mut args = provider_base_args.clone(); args.push("--model".to_string()); args.push(model.clone()); - match try_once(args) { + match run_once(args).await { Ok(v) => return Ok(v), Err(e) => { attempt_errors.push(format!("provider={provider} model={model}: {e}")); @@ -878,14 +829,16 @@ pub fn run_zeroclaw_message( || lower.contains("invalid x-api-key") || lower.contains("invalid api key") { + auth_break = true; break; } - continue; } } } - if let Ok(v) = try_once(provider_base_args.clone()) { - return Ok(v); + if !auth_break { + if let Ok(v) = run_once(provider_base_args).await { + return Ok(v); + } } } @@ -899,6 +852,236 @@ pub fn run_zeroclaw_message( } } +fn run_zeroclaw_once( + cmd: &Path, + cfg: &Path, + env_pairs: &[(String, String)], + args: &[String], +) -> Result { + let output = Command::new(cmd) + .envs(env_pairs.iter().cloned()) + .args(args) + .output() + .map_err(|e| format!("failed to run zeroclaw sidecar: {e}"))?; + let stdout = sanitize_output(&String::from_utf8_lossy(&output.stdout)); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + record_zeroclaw_usage(&stdout, &stderr); + if parse_usage_from_text(&stdout).is_none() && parse_usage_from_text(&stderr).is_none() { + if let Ok(mut stats) = usage_store().lock() { + if let Some((prompt, completion, total)) = + read_usage_from_builtin_traces(cmd, cfg, env_pairs) + { + stats.usage_calls = stats.usage_calls.saturating_add(1); + stats.prompt_tokens = stats.prompt_tokens.saturating_add(prompt); + stats.completion_tokens = stats.completion_tokens.saturating_add(completion); + stats.total_tokens = stats.total_tokens.saturating_add(total); + stats.last_updated_ms = now_ms(); + } + } + } + if !output.status.success() { + let msg = if !stderr.is_empty() { stderr } else { stdout }; + return Err(format!("zeroclaw sidecar failed: {msg}")); + } + if !stdout.is_empty() { + return Ok(stdout); + } + Ok("(zeroclaw returned no output)".to_string()) +} + +pub fn run_zeroclaw_message( + message: &str, + instance_id: &str, + session_scope: &str, +) -> Result { + let cmd = resolve_zeroclaw_command_path() + .ok_or_else(|| "zeroclaw binary not found in bundled resources".to_string())?; + let cfg = doctor_sidecar_config_dir(instance_id, session_scope)?; + ensure_runtime_trace_mode(&cfg); + let env_pairs = zeroclaw_env_pairs_from_clawpal(); + if env_pairs.is_empty() { + return Err( + "No compatible API key found in ClawPal model profiles for zeroclaw.".to_string(), + ); + } + let cfg_arg = cfg.to_string_lossy().to_string(); + let message = clamp_message_for_cli(message); + let base_args = vec![ + "--config-dir".to_string(), + cfg_arg, + "agent".to_string(), + "-m".to_string(), + message, + ]; + let preferred_model = crate::commands::load_zeroclaw_model_preference(); + let provider_order = provider_order_for_runtime(&env_pairs, preferred_model.as_deref()); + if provider_order.is_empty() { + return Err( + "No supported zeroclaw provider is available from current profiles.".to_string(), + ); + } + let runtime = + tokio::runtime::Runtime::new().map_err(|e| format!("failed to initialize runtime: {e}"))?; + runtime.block_on(run_zeroclaw_retry( + &base_args, + &provider_order, + preferred_model, + |args| { + let cmd = cmd.clone(); + let cfg = cfg.clone(); + let env_pairs = env_pairs.clone(); + let args = args; + async move { + run_zeroclaw_once(&cmd, &cfg, &env_pairs, &args) + } + }, + )) +} + +async fn stream_once( + cmd: &Path, + cfg: &Path, + env_pairs: &[(String, String)], + args: &[String], + on_delta: &(dyn Fn(&str) + Send + Sync), +) -> Result { + let mut child = tokio::process::Command::new(cmd) + .envs(env_pairs.iter().cloned()) + .args(args) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .map_err(|e| format!("failed to spawn zeroclaw sidecar: {e}"))?; + + let stdout_pipe = child + .stdout + .take() + .ok_or_else(|| "failed to capture zeroclaw stdout".to_string())?; + let stderr_pipe = child + .stderr + .take() + .ok_or_else(|| "failed to capture zeroclaw stderr".to_string())?; + + let mut reader = tokio::io::BufReader::new(stdout_pipe).lines(); + let stderr_task = tokio::spawn(async move { + let mut stderr_reader = tokio::io::BufReader::new(stderr_pipe); + let mut stderr = String::new(); + let _ = stderr_reader.read_to_string(&mut stderr).await; + stderr + }); + let mut accumulated = String::new(); + + while let Some(line) = reader + .next_line() + .await + .map_err(|e| format!("error reading zeroclaw stdout: {e}"))? + { + if let Some(sanitized) = sanitize_line(&line) { + if !accumulated.is_empty() { + accumulated.push('\n'); + } + accumulated.push_str(&sanitized); + on_delta(&accumulated); + } + } + + let status = child + .wait() + .await + .map_err(|e| format!("failed to wait for zeroclaw sidecar: {e}"))?; + let stderr = stderr_task + .await + .unwrap_or_default() + .trim() + .to_string(); + record_zeroclaw_usage(&accumulated, &stderr); + + if parse_usage_from_text(&accumulated).is_none() && parse_usage_from_text(&stderr).is_none() { + if let Ok(mut stats) = usage_store().lock() { + if let Some((prompt, completion, total)) = + read_usage_from_builtin_traces(cmd, cfg, env_pairs) + { + stats.usage_calls = stats.usage_calls.saturating_add(1); + stats.prompt_tokens = stats.prompt_tokens.saturating_add(prompt); + stats.completion_tokens = stats.completion_tokens.saturating_add(completion); + stats.total_tokens = stats.total_tokens.saturating_add(total); + stats.last_updated_ms = now_ms(); + } + } + } + + if !status.success() { + let msg = if !stderr.is_empty() { + stderr + } else { + accumulated.clone() + }; + return Err(format!("zeroclaw sidecar failed: {msg}")); + } + + if !accumulated.is_empty() { + return Ok(accumulated); + } + Ok("(zeroclaw returned no output)".to_string()) +} + +pub async fn run_zeroclaw_message_streaming( + message: &str, + instance_id: &str, + session_scope: &str, + on_delta: F, +) -> Result +where + F: Fn(&str) + Send + Sync + 'static, +{ + let cmd = resolve_zeroclaw_command_path() + .ok_or_else(|| "zeroclaw binary not found in bundled resources".to_string())?; + let cfg = doctor_sidecar_config_dir(instance_id, session_scope)?; + ensure_runtime_trace_mode(&cfg); + let env_pairs = zeroclaw_env_pairs_from_clawpal(); + if env_pairs.is_empty() { + return Err( + "No compatible API key found in ClawPal model profiles for zeroclaw.".to_string(), + ); + } + let cfg_arg = cfg.to_string_lossy().to_string(); + let message = clamp_message_for_cli(message); + let base_args = vec![ + "--config-dir".to_string(), + cfg_arg, + "agent".to_string(), + "-m".to_string(), + message, + ]; + let preferred_model = crate::commands::load_zeroclaw_model_preference(); + let provider_order = provider_order_for_runtime(&env_pairs, preferred_model.as_deref()); + if provider_order.is_empty() { + return Err( + "No supported zeroclaw provider is available from current profiles.".to_string(), + ); + } + + let on_delta: std::sync::Arc = + std::sync::Arc::new(on_delta); + (on_delta.as_ref())(""); + run_zeroclaw_retry( + &base_args, + &provider_order, + preferred_model, + |args| { + let cmd = cmd.clone(); + let cfg = cfg.clone(); + let env_pairs = env_pairs.clone(); + let on_delta = std::sync::Arc::clone(&on_delta); + let args = args; + async move { + stream_once(&cmd, &cfg, &env_pairs, &args, on_delta.as_ref()).await + } + }, + ) + .await +} + #[cfg(test)] mod tests { use super::{ diff --git a/src-tauri/src/runtime/zeroclaw/sanitize.rs b/src-tauri/src/runtime/zeroclaw/sanitize.rs index 6226c414..d8784507 100644 --- a/src-tauri/src/runtime/zeroclaw/sanitize.rs +++ b/src-tauri/src/runtime/zeroclaw/sanitize.rs @@ -1,9 +1,42 @@ +use regex::Regex; +use std::sync::OnceLock; + +fn ansi_esc_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"\x1b\[[0-9;]*[A-Za-z]").expect("valid ansi regex")) +} + +fn ansi_literal_regex() -> &'static Regex { + static RE: OnceLock = OnceLock::new(); + RE.get_or_init(|| Regex::new(r"\[[0-9;]*m").expect("valid ansi literal regex")) +} + +fn is_zeroclaw_trace_line(lower: &str) -> bool { + lower.contains("zeroclaw::") && (lower.contains(" info ") || lower.contains(" warn ")) +} + +fn strip_ansi(raw: &str) -> String { + let escaped = ansi_esc_regex().replace_all(raw, ""); + ansi_literal_regex().replace_all(&escaped, "").into_owned() +} + +/// Sanitize a single line of output from zeroclaw. +/// Returns `None` for lines that should be suppressed (empty, ANSI-only, zeroclaw trace lines). +pub fn sanitize_line(raw: &str) -> Option { + let cleaned = strip_ansi(raw); + let trimmed = cleaned.trim(); + if trimmed.is_empty() { + return None; + } + let lower = trimmed.to_ascii_lowercase(); + if is_zeroclaw_trace_line(&lower) { + return None; + } + Some(trimmed.to_string()) +} + pub fn sanitize_output(raw: &str) -> String { - // Strip ANSI escape/control fragments and zeroclaw tracing lines. - let ansi_esc = regex::Regex::new(r"\x1b\[[0-9;]*[A-Za-z]").expect("valid ansi regex"); - let ansi_literal = regex::Regex::new(r"\[[0-9;]*m").expect("valid ansi literal regex"); - let escaped = ansi_esc.replace_all(raw, ""); - let cleaned = ansi_literal.replace_all(&escaped, ""); + let cleaned = strip_ansi(raw); let mut lines = Vec::::new(); for line in cleaned.lines() { let trimmed = line.trim(); @@ -11,7 +44,7 @@ pub fn sanitize_output(raw: &str) -> String { continue; } let lower = trimmed.to_ascii_lowercase(); - if lower.contains("zeroclaw::") && (lower.contains(" info ") || lower.contains(" warn ")) { + if is_zeroclaw_trace_line(&lower) { continue; } lines.push(trimmed.to_string()); diff --git a/src-tauri/src/runtime/zeroclaw/streaming.rs b/src-tauri/src/runtime/zeroclaw/streaming.rs new file mode 100644 index 00000000..f369aec0 --- /dev/null +++ b/src-tauri/src/runtime/zeroclaw/streaming.rs @@ -0,0 +1,43 @@ +use crate::runtime::types::{RuntimeError, RuntimeEvent, RuntimeSessionKey}; + +use super::process::run_zeroclaw_message_streaming; +use super::session::append_history; +use super::session::reset_history; + +pub(crate) async fn run_zeroclaw_streaming_turn( + key: &RuntimeSessionKey, + prompt: &str, + reset_session: bool, + user_message: Option<&str>, + on_delta: FDelta, + normalize_output: FNormalize, + parse_tool_intent: FIntent, + map_error: FMapError, +) -> Result, RuntimeError> +where + FDelta: Fn(&str) + Send + Sync + 'static, + FNormalize: Fn(String) -> String, + FIntent: Fn(&str) -> Option<(RuntimeEvent, String)>, + FMapError: Fn(String) -> RuntimeError, +{ + let session_key = key.storage_key(); + if reset_session { + reset_history(&session_key); + } + if let Some(message) = user_message { + append_history(&session_key, "user", message); + } + + let text = run_zeroclaw_message_streaming(prompt, &key.instance_id, &key.storage_key(), on_delta) + .await + .map(normalize_output) + .map_err(map_error)?; + + if let Some((invoke, note)) = parse_tool_intent(&text) { + append_history(&session_key, "assistant", ¬e); + return Ok(vec![RuntimeEvent::chat_final(note), invoke]); + } + + append_history(&session_key, "assistant", &text); + Ok(vec![RuntimeEvent::chat_final(text)]) +} diff --git a/src-tauri/tests/runtime_zeroclaw_sanitize.rs b/src-tauri/tests/runtime_zeroclaw_sanitize.rs index a6101edc..a7484963 100644 --- a/src-tauri/tests/runtime_zeroclaw_sanitize.rs +++ b/src-tauri/tests/runtime_zeroclaw_sanitize.rs @@ -1,4 +1,4 @@ -use clawpal::runtime::zeroclaw::sanitize::sanitize_output; +use clawpal::runtime::zeroclaw::sanitize::{sanitize_line, sanitize_output}; #[test] fn sanitize_removes_ansi_and_runtime_info_lines() { @@ -7,3 +7,56 @@ fn sanitize_removes_ansi_and_runtime_info_lines() { let out = sanitize_output(raw); assert_eq!(out, "Final answer"); } + +#[test] +fn sanitize_line_strips_ansi_escapes() { + let line = "\x1b[32mHello world\x1b[0m"; + assert_eq!(sanitize_line(line), Some("Hello world".to_string())); +} + +#[test] +fn sanitize_line_strips_literal_ansi_fragments() { + let line = "[0m[32m Some text [0m"; + assert_eq!(sanitize_line(line), Some("Some text".to_string())); +} + +#[test] +fn sanitize_line_suppresses_empty_lines() { + assert_eq!(sanitize_line(""), None); + assert_eq!(sanitize_line(" "), None); + assert_eq!(sanitize_line("\t \t"), None); +} + +#[test] +fn sanitize_line_suppresses_ansi_only_lines() { + assert_eq!(sanitize_line("\x1b[0m"), None); + assert_eq!(sanitize_line("[0m[32m[0m"), None); +} + +#[test] +fn sanitize_line_suppresses_zeroclaw_trace_lines() { + let trace = "[2m2026-02-25T08:04:09.490132Z[0m [32m INFO[0m zeroclaw::config::schema"; + assert_eq!(sanitize_line(trace), None); +} + +#[test] +fn sanitize_line_suppresses_zeroclaw_warn_lines() { + let warn_line = "2026-03-01T10:00:00Z WARN zeroclaw::runtime something happened"; + assert_eq!(sanitize_line(warn_line), None); +} + +#[test] +fn sanitize_line_passes_through_normal_text() { + assert_eq!( + sanitize_line("Hello, how can I help?"), + Some("Hello, how can I help?".to_string()) + ); +} + +#[test] +fn sanitize_line_trims_whitespace() { + assert_eq!( + sanitize_line(" some padded text "), + Some("some padded text".to_string()) + ); +} diff --git a/src/App.tsx b/src/App.tsx index e0f2ba57..3cbc8317 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -766,13 +766,13 @@ export function App() { const connectWithPassphraseFallback = useCallback(async (hostId: string) => { const host = sshHosts.find((h) => h.id === hostId); - const hostLabel = host?.label || host?.host || hostId; + const hostLabel = host?.label || host?.host || (hostId.startsWith("ssh:") ? hostId.slice("ssh:".length) : hostId); try { await api.sshConnect(hostId); return; } catch (err) { const raw = String(err); - if (host && host.authMethod !== "password" && SSH_PASSPHRASE_RETRY_HINT.test(raw)) { + if (SSH_PASSPHRASE_RETRY_HINT.test(raw)) { const passphrase = await requestPassphrase(hostLabel); if (passphrase !== null) { try { diff --git a/src/lib/__tests__/sshConnectErrors.test.ts b/src/lib/__tests__/sshConnectErrors.test.ts index 31e2e5c2..c1755939 100644 --- a/src/lib/__tests__/sshConnectErrors.test.ts +++ b/src/lib/__tests__/sshConnectErrors.test.ts @@ -24,6 +24,18 @@ describe("sshConnectErrors", () => { expect(SSH_PASSPHRASE_RETRY_HINT.test("The key is encrypted.")).toBe(true); }); + test("classifies public-key auth failure from russh connect output", () => { + expect( + SSH_PASSPHRASE_RETRY_HINT.test( + "public key authentication failed for root@5.78.141.96:22 after trying /Users/user/.ssh/hetzner: encrypted or passphrase mismatch (key exchange failed)", + ), + ).toBe(true); + }); + + test("classifies password auth failure hint", () => { + expect(SSH_PASSPHRASE_RETRY_HINT.test("password is empty")).toBe(true); + }); + test("classifies reject hint", () => { expect(SSH_PASSPHRASE_REJECT_HINT.test("bad decrypt")).toBe(true); }); diff --git a/src/lib/sshConnectErrors.ts b/src/lib/sshConnectErrors.ts index 4b47491c..3c652c45 100644 --- a/src/lib/sshConnectErrors.ts +++ b/src/lib/sshConnectErrors.ts @@ -4,7 +4,7 @@ export type SshTranslate = ( ) => string; export const SSH_PASSPHRASE_RETRY_HINT = - /passphrase|sign_and_send_pubkey|agent refused operation|can't open \/dev\/tty|authentication agent|key is encrypted|encrypted|passphrase required|public key authentication failed/i; + /passphrase|sign_and_send_pubkey|agent refused operation|can't open \/dev\/tty|authentication agent|key is encrypted|encrypted|passphrase required|public key authentication failed|password authentication failed|password is empty|password.*required/i; export const SSH_PASSPHRASE_REJECT_HINT = /bad decrypt|incorrect passphrase|wrong passphrase|passphrase.*failed|decrypt failed/i;