From 0f076cf9da1024d79de7236ef5365879da062e09 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 18 May 2026 18:50:35 +0530 Subject: [PATCH 01/10] fix(security): always canonicalize paths before policy check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Merges the split is_path_allowed/is_resolved_path_allowed API into a single validate_path() that resolves symlinks before checking workspace boundaries and forbidden paths. Adds validate_parent_path() for write operations where the target file may not yet exist. Two callers (image_info.rs, cron/scheduler.rs) were missing the resolved check entirely — image_info.rs could be used to exfiltrate files via a symlink inside the workspace. Closes #1927 --- src/openhuman/cron/scheduler.rs | 2 +- src/openhuman/security/policy.rs | 111 +++++++++-- src/openhuman/security/policy_tests.rs | 186 +++++++++++++----- .../tools/impl/browser/image_info.rs | 31 ++- .../tools/impl/filesystem/apply_patch.rs | 18 +- .../tools/impl/filesystem/csv_export.rs | 31 +-- .../tools/impl/filesystem/edit_file.rs | 16 +- .../tools/impl/filesystem/file_read.rs | 28 +-- .../tools/impl/filesystem/file_write.rs | 33 +--- src/openhuman/tools/impl/filesystem/grep.rs | 17 +- .../tools/impl/filesystem/list_files.rs | 17 +- 11 files changed, 275 insertions(+), 215 deletions(-) diff --git a/src/openhuman/cron/scheduler.rs b/src/openhuman/cron/scheduler.rs index 35b4055203..6085eb249c 100644 --- a/src/openhuman/cron/scheduler.rs +++ b/src/openhuman/cron/scheduler.rs @@ -574,7 +574,7 @@ fn forbidden_path_argument(security: &SecurityPolicy, command: &str) -> Option bool { + fn expand_tilde(&self, path: &str) -> String { + if let Some(rest) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return format!("{}/{rest}", home.display()); + } + } + path.to_string() + } + + /// String-only path check. Does NOT resolve symlinks. + /// Use `validate_path()` for any path that will be used for file I/O. + pub fn is_path_string_allowed(&self, path: &str) -> bool { // Block null bytes (can truncate paths in C-backed syscalls) if path.contains('\0') { return false; @@ -741,15 +751,7 @@ impl SecurityPolicy { } // Expand tilde for comparison - let expanded = if let Some(stripped) = path.strip_prefix("~/") { - if let Some(home) = std::env::var("HOME").ok().map(PathBuf::from) { - home.join(stripped).to_string_lossy().to_string() - } else { - path.to_string() - } - } else { - path.to_string() - }; + let expanded = self.expand_tilde(path); // Block absolute paths when workspace_only is set if self.workspace_only && Path::new(&expanded).is_absolute() { @@ -759,15 +761,7 @@ impl SecurityPolicy { // Block forbidden paths using path-component-aware matching let expanded_path = Path::new(&expanded); for forbidden in &self.forbidden_paths { - let forbidden_expanded = if let Some(stripped) = forbidden.strip_prefix("~/") { - if let Some(home) = std::env::var("HOME").ok().map(PathBuf::from) { - home.join(stripped).to_string_lossy().to_string() - } else { - forbidden.clone() - } - } else { - forbidden.clone() - }; + let forbidden_expanded = self.expand_tilde(forbidden); let forbidden_path = Path::new(&forbidden_expanded); if expanded_path.starts_with(forbidden_path) { return false; @@ -777,6 +771,83 @@ impl SecurityPolicy { true } + /// Validate a path for file I/O: string checks, canonicalize, workspace containment, + /// and forbidden-path check on the resolved path. + /// Returns the canonical `PathBuf` on success. + pub async fn validate_path(&self, path: &str) -> Result { + if !self.is_path_string_allowed(path) { + return Err(format!("Path not allowed by security policy: {path}")); + } + let full_path = self.workspace_dir.join(path); + let resolved = tokio::fs::canonicalize(&full_path) + .await + .map_err(|e| format!("Failed to resolve path '{path}': {e}"))?; + if !self.is_resolved_path_allowed(&resolved) { + return Err(format!( + "Resolved path escapes workspace: {}", + resolved.display() + )); + } + let resolved_str = resolved.to_string_lossy(); + for forbidden in &self.forbidden_paths { + let expanded = self.expand_tilde(forbidden); + if resolved_str.starts_with(expanded.as_str()) { + return Err(format!( + "Resolved path is inside a forbidden directory: {}", + expanded + )); + } + } + log::debug!( + "[security] validate_path: '{}' resolved to '{}'", + path, + resolved.display() + ); + Ok(resolved) + } + + /// Like `validate_path` but canonicalizes the parent directory. + /// Use for write operations where the target file may not yet exist. + /// Returns the canonical full path (parent resolved + filename appended). + pub async fn validate_parent_path(&self, path: &str) -> Result { + if !self.is_path_string_allowed(path) { + return Err(format!("Path not allowed by security policy: {path}")); + } + let full_path = self.workspace_dir.join(path); + let parent = full_path + .parent() + .ok_or_else(|| format!("Invalid path (no parent): {path}"))?; + let resolved_parent = tokio::fs::canonicalize(parent) + .await + .map_err(|e| format!("Failed to resolve parent of '{path}': {e}"))?; + if !self.is_resolved_path_allowed(&resolved_parent) { + return Err(format!( + "Resolved parent path escapes workspace: {}", + resolved_parent.display() + )); + } + let resolved_str = resolved_parent.to_string_lossy(); + for forbidden in &self.forbidden_paths { + let expanded = self.expand_tilde(forbidden); + if resolved_str.starts_with(expanded.as_str()) { + return Err(format!( + "Resolved parent path is inside a forbidden directory: {}", + expanded + )); + } + } + let file_name = full_path + .file_name() + .ok_or_else(|| format!("Invalid path (no filename): {path}"))?; + let result = resolved_parent.join(file_name); + log::debug!( + "[security] validate_parent_path: '{}' resolved parent to '{}'", + path, + resolved_parent.display() + ); + Ok(result) + } + /// Validate that a resolved path is still inside the workspace. /// Call this AFTER joining `workspace_dir` + relative path and canonicalizing. pub fn is_resolved_path_allowed(&self, resolved: &Path) -> bool { diff --git a/src/openhuman/security/policy_tests.rs b/src/openhuman/security/policy_tests.rs index 1ee332567d..7e2ece86bc 100644 --- a/src/openhuman/security/policy_tests.rs +++ b/src/openhuman/security/policy_tests.rs @@ -336,26 +336,26 @@ fn validate_command_handles_short_multibyte_command() { #[test] fn relative_paths_allowed() { let p = default_policy(); - assert!(p.is_path_allowed("file.txt")); - assert!(p.is_path_allowed("src/main.rs")); - assert!(p.is_path_allowed("deep/nested/dir/file.txt")); + assert!(p.is_path_string_allowed("file.txt")); + assert!(p.is_path_string_allowed("src/main.rs")); + assert!(p.is_path_string_allowed("deep/nested/dir/file.txt")); } #[test] fn path_traversal_blocked() { let p = default_policy(); - assert!(!p.is_path_allowed("../etc/passwd")); - assert!(!p.is_path_allowed("../../root/.ssh/id_rsa")); - assert!(!p.is_path_allowed("foo/../../../etc/shadow")); - assert!(!p.is_path_allowed("..")); + assert!(!p.is_path_string_allowed("../etc/passwd")); + assert!(!p.is_path_string_allowed("../../root/.ssh/id_rsa")); + assert!(!p.is_path_string_allowed("foo/../../../etc/shadow")); + assert!(!p.is_path_string_allowed("..")); } #[test] fn absolute_paths_blocked_when_workspace_only() { let p = default_policy(); - assert!(!p.is_path_allowed("/etc/passwd")); - assert!(!p.is_path_allowed("/root/.ssh/id_rsa")); - assert!(!p.is_path_allowed("/tmp/file.txt")); + assert!(!p.is_path_string_allowed("/etc/passwd")); + assert!(!p.is_path_string_allowed("/root/.ssh/id_rsa")); + assert!(!p.is_path_string_allowed("/tmp/file.txt")); } #[test] @@ -365,7 +365,7 @@ fn absolute_paths_allowed_when_not_workspace_only() { forbidden_paths: vec![], ..SecurityPolicy::default() }; - assert!(p.is_path_allowed("/tmp/file.txt")); + assert!(p.is_path_string_allowed("/tmp/file.txt")); } #[test] @@ -374,23 +374,23 @@ fn forbidden_paths_blocked() { workspace_only: false, ..SecurityPolicy::default() }; - assert!(!p.is_path_allowed("/etc/passwd")); - assert!(!p.is_path_allowed("/root/.bashrc")); - assert!(!p.is_path_allowed("~/.ssh/id_rsa")); - assert!(!p.is_path_allowed("~/.gnupg/pubring.kbx")); + assert!(!p.is_path_string_allowed("/etc/passwd")); + assert!(!p.is_path_string_allowed("/root/.bashrc")); + assert!(!p.is_path_string_allowed("~/.ssh/id_rsa")); + assert!(!p.is_path_string_allowed("~/.gnupg/pubring.kbx")); } #[test] fn empty_path_allowed() { let p = default_policy(); - assert!(p.is_path_allowed("")); + assert!(p.is_path_string_allowed("")); } #[test] fn dotfile_in_workspace_allowed() { let p = default_policy(); - assert!(p.is_path_allowed(".gitignore")); - assert!(p.is_path_allowed(".env")); + assert!(p.is_path_string_allowed(".gitignore")); + assert!(p.is_path_string_allowed(".env")); } // -- from_config -------------------------------------------------- @@ -711,29 +711,29 @@ fn command_env_var_prefix_with_allowed_cmd() { fn path_traversal_encoded_dots() { let p = default_policy(); // Literal ".." in path — always blocked - assert!(!p.is_path_allowed("foo/..%2f..%2fetc/passwd")); + assert!(!p.is_path_string_allowed("foo/..%2f..%2fetc/passwd")); } #[test] fn path_traversal_double_dot_in_filename() { let p = default_policy(); // ".." in a filename (not a path component) is allowed - assert!(p.is_path_allowed("my..file.txt")); + assert!(p.is_path_string_allowed("my..file.txt")); // But actual traversal components are still blocked - assert!(!p.is_path_allowed("../etc/passwd")); - assert!(!p.is_path_allowed("foo/../etc/passwd")); + assert!(!p.is_path_string_allowed("../etc/passwd")); + assert!(!p.is_path_string_allowed("foo/../etc/passwd")); } #[test] fn path_with_null_byte_blocked() { let p = default_policy(); - assert!(!p.is_path_allowed("file\0.txt")); + assert!(!p.is_path_string_allowed("file\0.txt")); } #[test] fn path_symlink_style_absolute() { let p = default_policy(); - assert!(!p.is_path_allowed("/proc/self/root/etc/passwd")); + assert!(!p.is_path_string_allowed("/proc/self/root/etc/passwd")); } #[test] @@ -742,8 +742,8 @@ fn path_home_tilde_ssh() { workspace_only: false, ..SecurityPolicy::default() }; - assert!(!p.is_path_allowed("~/.ssh/id_rsa")); - assert!(!p.is_path_allowed("~/.gnupg/secring.gpg")); + assert!(!p.is_path_string_allowed("~/.ssh/id_rsa")); + assert!(!p.is_path_string_allowed("~/.gnupg/secring.gpg")); } #[test] @@ -752,7 +752,7 @@ fn path_var_run_blocked() { workspace_only: false, ..SecurityPolicy::default() }; - assert!(!p.is_path_allowed("/var/run/docker.sock")); + assert!(!p.is_path_string_allowed("/var/run/docker.sock")); } // -- Edge cases: rate limiter boundary ---------------------------- @@ -820,8 +820,8 @@ fn full_autonomy_still_respects_forbidden_paths() { workspace_only: false, ..SecurityPolicy::default() }; - assert!(!p.is_path_allowed("/etc/shadow")); - assert!(!p.is_path_allowed("/root/.bashrc")); + assert!(!p.is_path_string_allowed("/etc/shadow")); + assert!(!p.is_path_string_allowed("/root/.bashrc")); } // -- Edge cases: from_config preserves tracker -------------------- @@ -857,11 +857,11 @@ fn from_config_creates_fresh_tracker() { fn checklist_root_path_blocked() { let p = default_policy(); if cfg!(windows) { - assert!(!p.is_path_allowed("C:\\")); - assert!(!p.is_path_allowed("C:\\anything")); + assert!(!p.is_path_string_allowed("C:\\")); + assert!(!p.is_path_string_allowed("C:\\anything")); } else { - assert!(!p.is_path_allowed("/")); - assert!(!p.is_path_allowed("/anything")); + assert!(!p.is_path_string_allowed("/")); + assert!(!p.is_path_string_allowed("/anything")); } } @@ -876,11 +876,11 @@ fn checklist_all_system_dirs_blocked() { "/proc", "/sys", "/var", "/tmp", ] { assert!( - !p.is_path_allowed(dir), + !p.is_path_string_allowed(dir), "System dir should be blocked: {dir}" ); assert!( - !p.is_path_allowed(&format!("{dir}/subpath")), + !p.is_path_string_allowed(&format!("{dir}/subpath")), "Subpath of system dir should be blocked: {dir}/subpath" ); } @@ -899,7 +899,7 @@ fn checklist_sensitive_dotfiles_blocked() { "~/.config/secrets", ] { assert!( - !p.is_path_allowed(path), + !p.is_path_string_allowed(path), "Sensitive dotfile should be blocked: {path}" ); } @@ -908,9 +908,9 @@ fn checklist_sensitive_dotfiles_blocked() { #[test] fn checklist_null_byte_injection_blocked() { let p = default_policy(); - assert!(!p.is_path_allowed("safe\0/../../../etc/passwd")); - assert!(!p.is_path_allowed("\0")); - assert!(!p.is_path_allowed("file\0")); + assert!(!p.is_path_string_allowed("safe\0/../../../etc/passwd")); + assert!(!p.is_path_string_allowed("\0")); + assert!(!p.is_path_string_allowed("file\0")); } #[test] @@ -920,11 +920,11 @@ fn checklist_workspace_only_blocks_all_absolute() { ..SecurityPolicy::default() }; if cfg!(windows) { - assert!(!p.is_path_allowed("C:\\any\\absolute\\path")); + assert!(!p.is_path_string_allowed("C:\\any\\absolute\\path")); } else { - assert!(!p.is_path_allowed("/any/absolute/path")); + assert!(!p.is_path_string_allowed("/any/absolute/path")); } - assert!(p.is_path_allowed("relative/path.txt")); + assert!(p.is_path_string_allowed("relative/path.txt")); } #[test] @@ -1060,7 +1060,7 @@ fn resolved_path_blocks_symlink_escape() { fn is_path_allowed_blocks_null_bytes() { let policy = default_policy(); assert!( - !policy.is_path_allowed("file\0.txt"), + !policy.is_path_string_allowed("file\0.txt"), "paths with null bytes must be blocked" ); } @@ -1069,11 +1069,11 @@ fn is_path_allowed_blocks_null_bytes() { fn is_path_allowed_blocks_url_encoded_traversal() { let policy = default_policy(); assert!( - !policy.is_path_allowed("..%2fetc%2fpasswd"), + !policy.is_path_string_allowed("..%2fetc%2fpasswd"), "URL-encoded path traversal must be blocked" ); assert!( - !policy.is_path_allowed("subdir%2f..%2f..%2fetc"), + !policy.is_path_string_allowed("subdir%2f..%2f..%2fetc"), "URL-encoded parent dir traversal must be blocked" ); } @@ -1215,3 +1215,99 @@ fn validate_path_within_root_blocks_symlink_escape() { "symlink escaping prompt root must be blocked" ); } + +// ── validate_path / validate_parent_path (async) ──────────────────────────── + +#[cfg(unix)] +#[tokio::test] +async fn validate_path_blocks_symlink_to_outside_workspace() { + let workspace = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let secret = outside.path().join("secret.txt"); + std::fs::write(&secret, "secret").unwrap(); + let link = workspace.path().join("link.txt"); + std::os::unix::fs::symlink(&secret, &link).unwrap(); + let policy = SecurityPolicy { + workspace_dir: workspace.path().to_path_buf(), + workspace_only: false, + forbidden_paths: vec![], + ..SecurityPolicy::default() + }; + assert!(policy.validate_path("link.txt").await.is_err()); +} + +#[cfg(unix)] +#[tokio::test] +async fn validate_path_blocks_symlink_to_forbidden_path() { + let workspace = tempfile::tempdir().unwrap(); + // /etc/hostname is readable on most Unix systems + let link = workspace.path().join("link"); + std::os::unix::fs::symlink("/etc/hostname", &link).unwrap(); + let policy = SecurityPolicy { + workspace_dir: workspace.path().to_path_buf(), + workspace_only: true, + forbidden_paths: vec!["/etc".to_string()], + ..SecurityPolicy::default() + }; + assert!(policy.validate_path("link").await.is_err()); +} + +#[tokio::test] +async fn validate_path_allows_regular_file_in_workspace() { + let workspace = tempfile::tempdir().unwrap(); + let file = workspace.path().join("data.txt"); + std::fs::write(&file, "hello").unwrap(); + let policy = SecurityPolicy { + workspace_dir: workspace.path().to_path_buf(), + workspace_only: true, + forbidden_paths: vec![], + ..SecurityPolicy::default() + }; + let result = policy.validate_path("data.txt").await; + assert!(result.is_ok()); + assert_eq!(result.unwrap(), file.canonicalize().unwrap()); +} + +#[tokio::test] +async fn validate_path_returns_err_for_nonexistent_path() { + let workspace = tempfile::tempdir().unwrap(); + let policy = SecurityPolicy { + workspace_dir: workspace.path().to_path_buf(), + workspace_only: true, + forbidden_paths: vec![], + ..SecurityPolicy::default() + }; + assert!(policy.validate_path("does_not_exist.txt").await.is_err()); +} + +#[tokio::test] +async fn validate_parent_path_allows_new_file() { + let workspace = tempfile::tempdir().unwrap(); + let policy = SecurityPolicy { + workspace_dir: workspace.path().to_path_buf(), + workspace_only: true, + forbidden_paths: vec![], + ..SecurityPolicy::default() + }; + let result = policy.validate_parent_path("newfile.txt").await; + assert!(result.is_ok()); +} + +#[cfg(unix)] +#[tokio::test] +async fn validate_parent_path_blocks_symlinked_parent_dir() { + let workspace = tempfile::tempdir().unwrap(); + let outside = tempfile::tempdir().unwrap(); + let link_dir = workspace.path().join("subdir"); + std::os::unix::fs::symlink(outside.path(), &link_dir).unwrap(); + let policy = SecurityPolicy { + workspace_dir: workspace.path().to_path_buf(), + workspace_only: true, + forbidden_paths: vec![], + ..SecurityPolicy::default() + }; + assert!(policy + .validate_parent_path("subdir/newfile.txt") + .await + .is_err()); +} diff --git a/src/openhuman/tools/impl/browser/image_info.rs b/src/openhuman/tools/impl/browser/image_info.rs index 36ef5ef453..0eed8a5d64 100644 --- a/src/openhuman/tools/impl/browser/image_info.rs +++ b/src/openhuman/tools/impl/browser/image_info.rs @@ -3,7 +3,6 @@ use crate::openhuman::tools::traits::{Tool, ToolResult}; use async_trait::async_trait; use serde_json::json; use std::fmt::Write; -use std::path::Path; use std::sync::Arc; /// Maximum file size we will read and base64-encode (5 MB). @@ -156,20 +155,13 @@ impl Tool for ImageInfoTool { .and_then(serde_json::Value::as_bool) .unwrap_or(false); - let path = Path::new(path_str); + // Security check: validate path string, resolve symlinks, confirm workspace containment. + let resolved = match self.security.validate_path(path_str).await { + Ok(p) => p, + Err(msg) => return Ok(ToolResult::error(format!("Path not allowed: {msg}"))), + }; - // Restrict reads to workspace directory to prevent arbitrary file exfiltration - if !self.security.is_path_allowed(path_str) { - return Ok(ToolResult::error(format!( - "Path not allowed: {path_str} (must be within workspace)" - ))); - } - - if !path.exists() { - return Ok(ToolResult::error(format!("File not found: {path_str}"))); - } - - let metadata = tokio::fs::metadata(path) + let metadata = tokio::fs::metadata(&resolved) .await .map_err(|e| anyhow::anyhow!("Failed to read file metadata: {e}"))?; @@ -181,7 +173,7 @@ impl Tool for ImageInfoTool { ))); } - let bytes = tokio::fs::read(path) + let bytes = tokio::fs::read(&resolved) .await .map_err(|e| anyhow::anyhow!("Failed to read image file: {e}"))?; @@ -401,11 +393,16 @@ mod tests { async fn execute_nonexistent_file() { let tool = ImageInfoTool::new(test_security()); let result = tool - .execute(json!({"path": "/tmp/nonexistent_image_xyz.png"})) + .execute(json!({"path": "nonexistent_image_xyz.png"})) .await .unwrap(); assert!(result.is_error); - assert!(&result.output().contains("not found")); + assert!( + result.output().contains("not allowed") + || result.output().contains("Failed to resolve"), + "unexpected error: {}", + result.output() + ); } #[tokio::test] diff --git a/src/openhuman/tools/impl/filesystem/apply_patch.rs b/src/openhuman/tools/impl/filesystem/apply_patch.rs index fdfe9c2ba0..3162b7848b 100644 --- a/src/openhuman/tools/impl/filesystem/apply_patch.rs +++ b/src/openhuman/tools/impl/filesystem/apply_patch.rs @@ -120,7 +120,7 @@ impl Tool for ApplyPatchTool { "edit[{i}]: `old_string` must not be empty" ))); } - if !self.security.is_path_allowed(path) { + if !self.security.is_path_string_allowed(path) { return Ok(ToolResult::error(format!( "edit[{i}]: path not allowed: {path}" ))); @@ -153,21 +153,13 @@ impl Tool for ApplyPatchTool { } } - let resolved = match tokio::fs::canonicalize(&full).await { + // Security check: validate path string, resolve symlinks, confirm workspace containment. + let resolved = match self.security.validate_path(&edit.path).await { Ok(p) => p, - Err(e) => { - return Ok(ToolResult::error(format!( - "edit[{}]: failed to resolve {}: {e}", - edit.index, edit.path - ))) + Err(msg) => { + return Ok(ToolResult::error(format!("edit[{}]: {msg}", edit.index))) } }; - if !self.security.is_resolved_path_allowed(&resolved) { - return Ok(ToolResult::error(format!( - "edit[{}]: resolved path escapes workspace", - edit.index - ))); - } if let Ok(meta) = tokio::fs::metadata(&resolved).await { if meta.len() > MAX_FILE_BYTES { return Ok(ToolResult::error(format!( diff --git a/src/openhuman/tools/impl/filesystem/csv_export.rs b/src/openhuman/tools/impl/filesystem/csv_export.rs index 7d47cff83f..986b0e6f35 100644 --- a/src/openhuman/tools/impl/filesystem/csv_export.rs +++ b/src/openhuman/tools/impl/filesystem/csv_export.rs @@ -183,11 +183,6 @@ impl Tool for CsvExportTool { // Validate the relative path let relative_path = format!("exports/{filename}"); - if !self.security.is_path_allowed(&relative_path) { - return Ok(ToolResult::error(format!( - "Path not allowed by security policy: {relative_path}" - ))); - } let full_path = self.security.workspace_dir.join(&relative_path); @@ -195,32 +190,16 @@ impl Tool for CsvExportTool { return Ok(ToolResult::error("Invalid path: missing parent directory")); }; - // Ensure exports/ directory exists + // Ensure exports/ directory exists before canonicalization. tokio::fs::create_dir_all(parent).await?; - // Resolve parent AFTER creation to block symlink escapes. - let resolved_parent = match tokio::fs::canonicalize(parent).await { + // Security check: validate path string, resolve symlinks on the parent dir, + // confirm workspace containment. File may not exist yet, so validate_parent_path is used. + let resolved_target = match self.security.validate_parent_path(&relative_path).await { Ok(p) => p, - Err(e) => { - return Ok(ToolResult::error(format!( - "Failed to resolve file path: {e}" - ))); - } + Err(msg) => return Ok(ToolResult::error(msg)), }; - if !self.security.is_resolved_path_allowed(&resolved_parent) { - return Ok(ToolResult::error(format!( - "Resolved path escapes workspace: {}", - resolved_parent.display() - ))); - } - - let Some(file_name) = full_path.file_name() else { - return Ok(ToolResult::error("Invalid path: missing file name")); - }; - - let resolved_target = resolved_parent.join(file_name); - // If the target already exists and is a symlink, refuse to follow it if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await { if meta.file_type().is_symlink() { diff --git a/src/openhuman/tools/impl/filesystem/edit_file.rs b/src/openhuman/tools/impl/filesystem/edit_file.rs index 51f951d80d..f7a09666c0 100644 --- a/src/openhuman/tools/impl/filesystem/edit_file.rs +++ b/src/openhuman/tools/impl/filesystem/edit_file.rs @@ -91,11 +91,6 @@ impl Tool for EditFileTool { "Rate limit exceeded: too many actions in the last hour", )); } - if !self.security.is_path_allowed(path) { - return Ok(ToolResult::error(format!( - "Path not allowed by security policy: {path}" - ))); - } if !self.security.record_action() { return Ok(ToolResult::error( "Rate limit exceeded: action budget exhausted", @@ -116,16 +111,11 @@ impl Tool for EditFileTool { } } - let resolved = match tokio::fs::canonicalize(&full).await { + // Security check: validate path string, resolve symlinks, confirm workspace containment. + let resolved = match self.security.validate_path(path).await { Ok(p) => p, - Err(e) => return Ok(ToolResult::error(format!("Failed to resolve path: {e}"))), + Err(msg) => return Ok(ToolResult::error(msg)), }; - if !self.security.is_resolved_path_allowed(&resolved) { - return Ok(ToolResult::error(format!( - "Resolved path escapes workspace: {}", - resolved.display() - ))); - } if let Ok(meta) = tokio::fs::metadata(&resolved).await { if meta.len() > MAX_FILE_BYTES { diff --git a/src/openhuman/tools/impl/filesystem/file_read.rs b/src/openhuman/tools/impl/filesystem/file_read.rs index 3a692c4e63..c1a3af56e3 100644 --- a/src/openhuman/tools/impl/filesystem/file_read.rs +++ b/src/openhuman/tools/impl/filesystem/file_read.rs @@ -57,14 +57,7 @@ impl Tool for FileReadTool { )); } - // Security check: validate path is within workspace - if !self.security.is_path_allowed(path) { - return Ok(ToolResult::error(format!( - "Path not allowed by security policy: {path}" - ))); - } - - // Record action BEFORE canonicalization so that every non-trivially-rejected + // Record action BEFORE validation so that every non-trivially-rejected // request consumes rate limit budget. This prevents attackers from probing // path existence (via canonicalize errors) without rate limit cost. if !self.security.record_action() { @@ -73,25 +66,12 @@ impl Tool for FileReadTool { )); } - let full_path = self.security.workspace_dir.join(path); - - // Resolve path before reading to block symlink escapes. - let resolved_path = match tokio::fs::canonicalize(&full_path).await { + // Security check: validate path string, resolve symlinks, confirm workspace containment. + let resolved_path = match self.security.validate_path(path).await { Ok(p) => p, - Err(e) => { - return Ok(ToolResult::error(format!( - "Failed to resolve file path: {e}" - ))); - } + Err(msg) => return Ok(ToolResult::error(msg)), }; - if !self.security.is_resolved_path_allowed(&resolved_path) { - return Ok(ToolResult::error(format!( - "Resolved path escapes workspace: {}", - resolved_path.display() - ))); - } - // Check file size AFTER canonicalization to prevent TOCTOU symlink bypass match tokio::fs::metadata(&resolved_path).await { Ok(meta) => { diff --git a/src/openhuman/tools/impl/filesystem/file_write.rs b/src/openhuman/tools/impl/filesystem/file_write.rs index 2b97765ca2..2d4e893839 100644 --- a/src/openhuman/tools/impl/filesystem/file_write.rs +++ b/src/openhuman/tools/impl/filesystem/file_write.rs @@ -63,45 +63,22 @@ impl Tool for FileWriteTool { )); } - // Security check: validate path is within workspace - if !self.security.is_path_allowed(path) { - return Ok(ToolResult::error(format!( - "Path not allowed by security policy: {path}" - ))); - } - let full_path = self.security.workspace_dir.join(path); let Some(parent) = full_path.parent() else { return Ok(ToolResult::error("Invalid path: missing parent directory")); }; - // Ensure parent directory exists + // Ensure parent directory exists before canonicalization. tokio::fs::create_dir_all(parent).await?; - // Resolve parent AFTER creation to block symlink escapes. - let resolved_parent = match tokio::fs::canonicalize(parent).await { + // Security check: validate path string, resolve symlinks on the parent dir, + // confirm workspace containment. File may not exist yet, so validate_parent_path is used. + let resolved_target = match self.security.validate_parent_path(path).await { Ok(p) => p, - Err(e) => { - return Ok(ToolResult::error(format!( - "Failed to resolve file path: {e}" - ))); - } + Err(msg) => return Ok(ToolResult::error(msg)), }; - if !self.security.is_resolved_path_allowed(&resolved_parent) { - return Ok(ToolResult::error(format!( - "Resolved path escapes workspace: {}", - resolved_parent.display() - ))); - } - - let Some(file_name) = full_path.file_name() else { - return Ok(ToolResult::error("Invalid path: missing file name")); - }; - - let resolved_target = resolved_parent.join(file_name); - // If the target already exists and is a symlink, refuse to follow it if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await { if meta.file_type().is_symlink() { diff --git a/src/openhuman/tools/impl/filesystem/grep.rs b/src/openhuman/tools/impl/filesystem/grep.rs index 7e71b6e971..582d46ba87 100644 --- a/src/openhuman/tools/impl/filesystem/grep.rs +++ b/src/openhuman/tools/impl/filesystem/grep.rs @@ -97,11 +97,6 @@ impl Tool for GrepTool { "Rate limit exceeded: too many actions in the last hour", )); } - if !self.security.is_path_allowed(sub_path) { - return Ok(ToolResult::error(format!( - "Path not allowed by security policy: {sub_path}" - ))); - } if !self.security.record_action() { return Ok(ToolResult::error( "Rate limit exceeded: action budget exhausted", @@ -113,17 +108,11 @@ impl Tool for GrepTool { Err(e) => return Ok(ToolResult::error(format!("Invalid regex: {e}"))), }; - let root = self.security.workspace_dir.join(sub_path); - let resolved_root = match tokio::fs::canonicalize(&root).await { + // Security check: validate path string, resolve symlinks, confirm workspace containment. + let resolved_root = match self.security.validate_path(sub_path).await { Ok(p) => p, - Err(e) => return Ok(ToolResult::error(format!("Failed to resolve path: {e}"))), + Err(msg) => return Ok(ToolResult::error(msg)), }; - if !self.security.is_resolved_path_allowed(&resolved_root) { - return Ok(ToolResult::error(format!( - "Resolved path escapes workspace: {}", - resolved_root.display() - ))); - } let workspace = self.security.workspace_dir.clone(); let result = tokio::task::spawn_blocking(move || { diff --git a/src/openhuman/tools/impl/filesystem/list_files.rs b/src/openhuman/tools/impl/filesystem/list_files.rs index 0b8567d18b..577f246e11 100644 --- a/src/openhuman/tools/impl/filesystem/list_files.rs +++ b/src/openhuman/tools/impl/filesystem/list_files.rs @@ -58,28 +58,17 @@ impl Tool for ListFilesTool { "Rate limit exceeded: too many actions in the last hour", )); } - if !self.security.is_path_allowed(path) { - return Ok(ToolResult::error(format!( - "Path not allowed by security policy: {path}" - ))); - } if !self.security.record_action() { return Ok(ToolResult::error( "Rate limit exceeded: action budget exhausted", )); } - let full = self.security.workspace_dir.join(path); - let resolved = match tokio::fs::canonicalize(&full).await { + // Security check: validate path string, resolve symlinks, confirm workspace containment. + let resolved = match self.security.validate_path(path).await { Ok(p) => p, - Err(e) => return Ok(ToolResult::error(format!("Failed to resolve path: {e}"))), + Err(msg) => return Ok(ToolResult::error(msg)), }; - if !self.security.is_resolved_path_allowed(&resolved) { - return Ok(ToolResult::error(format!( - "Resolved path escapes workspace: {}", - resolved.display() - ))); - } let mut read = match tokio::fs::read_dir(&resolved).await { Ok(r) => r, From be2966955f9a7418c03fe2bad362da4a70e57919 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Mon, 18 May 2026 19:24:10 +0530 Subject: [PATCH 02/10] fix(security): resolve forbidden paths against workspace root; validate before create_dir_all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - In validate_path/validate_parent_path, switch from string starts_with to path-component-aware comparison for forbidden_paths. - Resolve relative forbidden entries against the workspace root so entries like "secrets" correctly block workspace/secrets/ even after canonicalization. - Skip absolute forbidden entries that are ancestors of the workspace root (e.g. /tmp when workspace is /tmp/…) — the workspace containment check already guarantees the resolved path is safe. - validate_parent_path now walks up to the deepest existing ancestor before canonicalizing, so it works without requiring the parent directory to exist. - file_write and csv_export now call validate_parent_path BEFORE create_dir_all, then create directories at the validated canonical location. This prevents a symlinked path component from causing directory creation outside the workspace before the security check fires. Fixes 25 failing filesystem tests (false-positive forbidden-path rejections when workspace is under /tmp) and closes the pre-create-dir_all attack surface. --- src/openhuman/security/policy.rs | 87 +++++++++++++++---- .../tools/impl/filesystem/csv_export.rs | 19 ++-- .../tools/impl/filesystem/file_write.rs | 19 ++-- 3 files changed, 88 insertions(+), 37 deletions(-) diff --git a/src/openhuman/security/policy.rs b/src/openhuman/security/policy.rs index 367b9df32c..07cdf968a5 100644 --- a/src/openhuman/security/policy.rs +++ b/src/openhuman/security/policy.rs @@ -788,13 +788,28 @@ impl SecurityPolicy { resolved.display() )); } - let resolved_str = resolved.to_string_lossy(); + // Check forbidden paths using canonical path-component-aware comparison. + // Relative forbidden entries are resolved against the workspace root. + // Absolute entries whose prefix IS the workspace root are skipped — the + // workspace containment check above already guarantees the path is safe. + let workspace_root = self + .workspace_dir + .canonicalize() + .unwrap_or_else(|_| self.workspace_dir.clone()); for forbidden in &self.forbidden_paths { - let expanded = self.expand_tilde(forbidden); - if resolved_str.starts_with(expanded.as_str()) { + let forbidden_path = PathBuf::from(self.expand_tilde(forbidden)); + let forbidden_resolved = if forbidden_path.is_absolute() { + if workspace_root.starts_with(&forbidden_path) { + continue; + } + forbidden_path + } else { + workspace_root.join(forbidden_path) + }; + if resolved.starts_with(&forbidden_resolved) { return Err(format!( "Resolved path is inside a forbidden directory: {}", - expanded + forbidden_resolved.display() )); } } @@ -808,6 +823,8 @@ impl SecurityPolicy { /// Like `validate_path` but canonicalizes the parent directory. /// Use for write operations where the target file may not yet exist. + /// Does NOT require the parent directory to exist — walks up to the deepest + /// existing ancestor and checks that for symlink escapes. /// Returns the canonical full path (parent resolved + filename appended). pub async fn validate_parent_path(&self, path: &str) -> Result { if !self.is_path_string_allowed(path) { @@ -817,29 +834,69 @@ impl SecurityPolicy { let parent = full_path .parent() .ok_or_else(|| format!("Invalid path (no parent): {path}"))?; - let resolved_parent = tokio::fs::canonicalize(parent) + let file_name = full_path + .file_name() + .ok_or_else(|| format!("Invalid path (no filename): {path}"))?; + + // Walk up to the deepest existing ancestor so we can canonicalize without + // requiring the full parent path to exist yet. This catches symlink escapes + // in existing path components even when deeper dirs are not created yet. + let mut existing_ancestor = parent.to_path_buf(); + loop { + if existing_ancestor.exists() { + break; + } + match existing_ancestor.parent() { + Some(p) => existing_ancestor = p.to_path_buf(), + None => break, + } + } + let canonical_ancestor = tokio::fs::canonicalize(&existing_ancestor) .await .map_err(|e| format!("Failed to resolve parent of '{path}': {e}"))?; - if !self.is_resolved_path_allowed(&resolved_parent) { + if !self.is_resolved_path_allowed(&canonical_ancestor) { return Err(format!( "Resolved parent path escapes workspace: {}", - resolved_parent.display() + canonical_ancestor.display() )); } - let resolved_str = resolved_parent.to_string_lossy(); + + // Build resolved result: canonical_ancestor + suffix from existing_ancestor to parent + filename. + // Since is_path_string_allowed blocked "..", all components between the ancestor + // and the intended parent are newly created dirs — no symlinks possible there. + let relative_suffix = parent + .strip_prefix(&existing_ancestor) + .unwrap_or(std::path::Path::new("")); + let resolved_parent = canonical_ancestor.join(relative_suffix); + let result = resolved_parent.join(file_name); + + // Forbidden path check using canonical path-component-aware comparison. + // Relative entries are resolved against the workspace root. Absolute entries + // whose prefix IS the workspace root are skipped (all workspace paths are under them). + let workspace_root = self + .workspace_dir + .canonicalize() + .unwrap_or_else(|_| self.workspace_dir.clone()); for forbidden in &self.forbidden_paths { - let expanded = self.expand_tilde(forbidden); - if resolved_str.starts_with(expanded.as_str()) { + let forbidden_path = PathBuf::from(self.expand_tilde(forbidden)); + let forbidden_resolved = if forbidden_path.is_absolute() { + if workspace_root.starts_with(&forbidden_path) { + continue; + } + forbidden_path + } else { + workspace_root.join(forbidden_path) + }; + if canonical_ancestor.starts_with(&forbidden_resolved) + || result.starts_with(&forbidden_resolved) + { return Err(format!( "Resolved parent path is inside a forbidden directory: {}", - expanded + forbidden_resolved.display() )); } } - let file_name = full_path - .file_name() - .ok_or_else(|| format!("Invalid path (no filename): {path}"))?; - let result = resolved_parent.join(file_name); + log::debug!( "[security] validate_parent_path: '{}' resolved parent to '{}'", path, diff --git a/src/openhuman/tools/impl/filesystem/csv_export.rs b/src/openhuman/tools/impl/filesystem/csv_export.rs index 986b0e6f35..43a1f285c1 100644 --- a/src/openhuman/tools/impl/filesystem/csv_export.rs +++ b/src/openhuman/tools/impl/filesystem/csv_export.rs @@ -184,22 +184,19 @@ impl Tool for CsvExportTool { // Validate the relative path let relative_path = format!("exports/{filename}"); - let full_path = self.security.workspace_dir.join(&relative_path); - - let Some(parent) = full_path.parent() else { - return Ok(ToolResult::error("Invalid path: missing parent directory")); - }; - - // Ensure exports/ directory exists before canonicalization. - tokio::fs::create_dir_all(parent).await?; - - // Security check: validate path string, resolve symlinks on the parent dir, - // confirm workspace containment. File may not exist yet, so validate_parent_path is used. + // Security check first: validate path string, resolve symlinks, confirm workspace + // containment. validate_parent_path walks up to the deepest existing ancestor so + // it does not require the exports/ directory to exist yet. let resolved_target = match self.security.validate_parent_path(&relative_path).await { Ok(p) => p, Err(msg) => return Ok(ToolResult::error(msg)), }; + // Create exports/ directory only at the validated, resolved location. + if let Some(resolved_parent) = resolved_target.parent() { + tokio::fs::create_dir_all(resolved_parent).await?; + } + // If the target already exists and is a symlink, refuse to follow it if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await { if meta.file_type().is_symlink() { diff --git a/src/openhuman/tools/impl/filesystem/file_write.rs b/src/openhuman/tools/impl/filesystem/file_write.rs index 2d4e893839..7fd9afa717 100644 --- a/src/openhuman/tools/impl/filesystem/file_write.rs +++ b/src/openhuman/tools/impl/filesystem/file_write.rs @@ -63,22 +63,19 @@ impl Tool for FileWriteTool { )); } - let full_path = self.security.workspace_dir.join(path); - - let Some(parent) = full_path.parent() else { - return Ok(ToolResult::error("Invalid path: missing parent directory")); - }; - - // Ensure parent directory exists before canonicalization. - tokio::fs::create_dir_all(parent).await?; - - // Security check: validate path string, resolve symlinks on the parent dir, - // confirm workspace containment. File may not exist yet, so validate_parent_path is used. + // Security check first: validate path string, resolve symlinks, confirm workspace + // containment. validate_parent_path walks up to the deepest existing ancestor so + // it does not require the parent directory to exist yet. let resolved_target = match self.security.validate_parent_path(path).await { Ok(p) => p, Err(msg) => return Ok(ToolResult::error(msg)), }; + // Create parent directory only at the validated, resolved location. + if let Some(resolved_parent) = resolved_target.parent() { + tokio::fs::create_dir_all(resolved_parent).await?; + } + // If the target already exists and is a symlink, refuse to follow it if let Ok(meta) = tokio::fs::symlink_metadata(&resolved_target).await { if meta.file_type().is_symlink() { From 61343cffcb10f7f2e568226fe5fab01591b428e3 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 19 May 2026 15:14:55 +0530 Subject: [PATCH 03/10] fix(review): unwrap double-wrapped error in image_info; add relative forbidden-entry symlink regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - image_info.rs: remove redundant "Path not allowed: " prefix — validate_path already returns a complete user-facing error string. - policy_tests.rs: add validate_path_blocks_symlink_to_relative_forbidden_entry to lock in the be296695 fix where relative forbidden entries (e.g. "secrets") were not resolved against the workspace root and could be bypassed via a symlink pointing into the forbidden directory. --- src/openhuman/security/policy_tests.rs | 25 +++++++++++++++++++ .../tools/impl/browser/image_info.rs | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/src/openhuman/security/policy_tests.rs b/src/openhuman/security/policy_tests.rs index 7e2ece86bc..6a05df7e8d 100644 --- a/src/openhuman/security/policy_tests.rs +++ b/src/openhuman/security/policy_tests.rs @@ -1311,3 +1311,28 @@ async fn validate_parent_path_blocks_symlinked_parent_dir() { .await .is_err()); } + +#[cfg(unix)] +#[tokio::test] +async fn validate_path_blocks_symlink_to_relative_forbidden_entry() { + // Regression: relative forbidden entries (e.g. "secrets") must match after + // canonicalization. Before the fix, "secrets" was never resolved against the + // workspace root, so workspace/link -> workspace/secrets/ passed the check. + let workspace = tempfile::tempdir().unwrap(); + let secrets_dir = workspace.path().join("secrets"); + std::fs::create_dir_all(&secrets_dir).unwrap(); + let secret_file = secrets_dir.join("token.txt"); + std::fs::write(&secret_file, "s3cr3t").unwrap(); + let link = workspace.path().join("link"); + std::os::unix::fs::symlink(&secrets_dir, &link).unwrap(); + let policy = SecurityPolicy { + workspace_dir: workspace.path().to_path_buf(), + workspace_only: true, + forbidden_paths: vec!["secrets".to_string()], + ..SecurityPolicy::default() + }; + // Direct path into the forbidden dir is blocked. + assert!(policy.validate_path("secrets/token.txt").await.is_err()); + // Symlink that resolves into the forbidden dir is also blocked. + assert!(policy.validate_path("link/token.txt").await.is_err()); +} diff --git a/src/openhuman/tools/impl/browser/image_info.rs b/src/openhuman/tools/impl/browser/image_info.rs index 0eed8a5d64..796838c3c4 100644 --- a/src/openhuman/tools/impl/browser/image_info.rs +++ b/src/openhuman/tools/impl/browser/image_info.rs @@ -158,7 +158,7 @@ impl Tool for ImageInfoTool { // Security check: validate path string, resolve symlinks, confirm workspace containment. let resolved = match self.security.validate_path(path_str).await { Ok(p) => p, - Err(msg) => return Ok(ToolResult::error(format!("Path not allowed: {msg}"))), + Err(msg) => return Ok(ToolResult::error(msg)), }; let metadata = tokio::fs::metadata(&resolved) From 60d7a0fcbd48e620e8750da938174d9fabbb53f3 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 19 May 2026 15:28:28 +0530 Subject: [PATCH 04/10] test(security): cover validate_parent_path forbidden-path block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lines 888-896 of policy.rs were uncovered — the forbidden_paths loop inside validate_parent_path had no test. Add validate_parent_path_blocks_forbidden_path to assert that writing a new file into a relative-forbidden directory is rejected. --- src/openhuman/security/policy_tests.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/openhuman/security/policy_tests.rs b/src/openhuman/security/policy_tests.rs index 6a05df7e8d..3c65fed5e8 100644 --- a/src/openhuman/security/policy_tests.rs +++ b/src/openhuman/security/policy_tests.rs @@ -1336,3 +1336,23 @@ async fn validate_path_blocks_symlink_to_relative_forbidden_entry() { // Symlink that resolves into the forbidden dir is also blocked. assert!(policy.validate_path("link/token.txt").await.is_err()); } + +#[cfg(unix)] +#[tokio::test] +async fn validate_parent_path_blocks_forbidden_path() { + // Covers lines 888-896: the forbidden-path check inside validate_parent_path. + let workspace = tempfile::tempdir().unwrap(); + let secrets_dir = workspace.path().join("secrets"); + std::fs::create_dir_all(&secrets_dir).unwrap(); + let policy = SecurityPolicy { + workspace_dir: workspace.path().to_path_buf(), + workspace_only: true, + forbidden_paths: vec!["secrets".to_string()], + ..SecurityPolicy::default() + }; + // Writing a new file directly into the forbidden dir must be blocked. + assert!(policy + .validate_parent_path("secrets/output.csv") + .await + .is_err()); +} From 2011507fc27799a3b92ad4fd921a85a419a206fd Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 19 May 2026 17:42:57 +0530 Subject: [PATCH 05/10] feat(local-ai): add editable Ollama server URL with connection test Wires config.local_ai.base_url into the UI for the first time: previously the field existed in core but was never written from the frontend and was ignored by ollama_base_url() which only read env vars. - Add ollama_base_url_from_config(config) with priority chain: config.local_ai.base_url > env vars > default; update bootstrap/health check/infer/vision-embed callsites so an external server is used when configured without requiring a local binary - Add validate_ollama_url() (Rust + TS mirrors) for scheme/host/creds validation with path normalisation - Register openhuman.local_ai_test_connection RPC that probes /api/tags with a 3-second timeout and returns {reachable, error, models_count} - Add Ollama Server URL section to LocalModelDebugPanel/ModelStatusSection: editable input, inline validation, Test Connection button, Save/Reset, seeds from persisted config on mount via openhumanGetConfig() - Add i18n keys (localModel.ollamaServer.*) across all locale chunks - 4 new Rust tests for test_ollama_connection; 8 new Vitest tests for URL input UX; 12 tests for ollamaUrlValidation util Closes #2159 --- .claude/memory.md | 35 +++++ .../settings/panels/LocalModelDebugPanel.tsx | 93 +++++++++++ .../local-model/ModelStatusSection.test.tsx | 97 ++++++++++++ .../panels/local-model/ModelStatusSection.tsx | 88 +++++++++++ app/src/lib/i18n/chunks/ar-2.ts | 9 ++ app/src/lib/i18n/chunks/bn-2.ts | 9 ++ app/src/lib/i18n/chunks/en-2.ts | 9 ++ app/src/lib/i18n/chunks/es-2.ts | 9 ++ app/src/lib/i18n/chunks/fr-2.ts | 9 ++ app/src/lib/i18n/chunks/hi-2.ts | 9 ++ app/src/lib/i18n/chunks/id-2.ts | 9 ++ app/src/lib/i18n/chunks/it-2.ts | 9 ++ app/src/lib/i18n/chunks/pt-2.ts | 9 ++ app/src/lib/i18n/chunks/ru-2.ts | 9 ++ app/src/lib/i18n/chunks/zh-CN-2.ts | 9 ++ app/src/lib/i18n/en.ts | 9 ++ app/src/utils/ollamaUrlValidation.test.ts | 73 +++++++++ app/src/utils/ollamaUrlValidation.ts | 48 ++++++ app/src/utils/tauriCommands/localAi.ts | 15 ++ src/openhuman/inference/local/ollama.rs | 144 ++++++++++++++++++ src/openhuman/inference/local/schemas.rs | 29 ++++ .../inference/local/schemas_tests.rs | 1 + src/openhuman/inference/local/service/mod.rs | 2 +- .../inference/local/service/ollama_admin.rs | 123 ++++++++++++--- .../local/service/ollama_admin_tests.rs | 66 +++++++- .../inference/local/service/public_infer.rs | 32 +++- .../inference/local/service/vision_embed.rs | 8 +- 27 files changed, 926 insertions(+), 36 deletions(-) create mode 100644 app/src/utils/ollamaUrlValidation.test.ts create mode 100644 app/src/utils/ollamaUrlValidation.ts diff --git a/.claude/memory.md b/.claude/memory.md index f6a0f56720..a56cf9aa42 100644 --- a/.claude/memory.md +++ b/.claude/memory.md @@ -33,6 +33,8 @@ Quick reference for anyone starting with Claude on this project. Updated by the - **`OPENHUMAN_LOCAL_AI_TIER` env var** overrides the selected tier at config load time (in `load.rs`). - **Frontend tier selector** is in `LocalModelPanel.tsx` under Settings > Local AI Model. Uses `coreRpcClient` to call 3 RPC methods: `local_ai_device_profile`, `local_ai_presets`, `local_ai_apply_preset`. - **Default config maps to Medium tier** (`gemma3:4b-it-qat`). If someone changes `model_ids.rs` defaults, they should keep `presets.rs` in sync. +- **`ollama_base_url()` previously ignored `config.local_ai.base_url`** — It only read env vars. Fixed in feat/ollama-external-server-url by adding `ollama_base_url_from_config(config)`. Any new Ollama URL resolution must go through the config-aware helper, not the env-only one. +- **`LocalModelDebugPanel.tsx` must seed URL from config on mount** — Previously initialized `ollamaBaseUrlInput` to the hardcoded default and only loaded the persisted URL when diagnostics ran. Fix: `useEffect` on mount calls `openhumanGetConfig()` and sets state from `config.local_ai.base_url`. Pattern to follow for any settings field backed by Rust config. ## Core process (in-process, no sidecar) @@ -149,6 +151,7 @@ Quick reference for anyone starting with Claude on this project. Updated by the - **`pnpm typecheck` script was renamed** — Check `app/package.json` for the current name; as of issue #830 work, use `pnpm workspace openhuman-app compile` for tsc checks. - **PR #745 (command palette) merged without its deps** — `@radix-ui/react-dialog`, `cmdk`, and `@testing-library/user-event` are missing from `package.json`. Install them if tsc fails after syncing main. - **Pre-push hooks fail on upstream lint warnings** — ESLint warns on `setState` in effects and unused `eslint-disable` directives inherited from upstream. Use `--no-verify` only when the lint errors are pre-existing upstream issues, not new code. +- **`pnpm test:coverage` ENOENT on `coverage/.tmp/coverage-0.json`** — Race condition in coverage file collection; flaky, not reproducible every run. Use `pnpm debug unit` instead — runs Vitest without coverage, faster and reliable for iteration. ## Mascot Native Window (macOS) @@ -188,3 +191,35 @@ Quick reference for anyone starting with Claude on this project. Updated by the - **`pnpm core:stage`** — no-op (sidecar removed in PR #1061). Use `pnpm dev:app` for full Tauri+core dev. - **Kill stuck processes** — `lsof -i :7788` then `kill `. Useful when `dev:app` reports a stale listener and you want to force a fresh boot rather than relying on the handle's auto-recovery. - **Skills runtime removed** — the QuickJS / `rquickjs` runtime is gone; `src/openhuman/skills/` is metadata-only ("Legacy skill metadata helpers retained after QuickJS runtime removal"). Skill execution surfaces are being rebuilt; don't assume a `.skill` can run end-to-end without checking the current code. + +## Project Board & Issue Queries + +- **Project #2 paginates at 100 items** — Board has 627+ items. Use GraphQL cursor pagination to find all open P0 issues; a single query only returns the first 100. +- **jq regex `\s+` causes parse errors** — Use plain `test("#NNNN")` to check if a PR/issue body references an issue number. `\s+` in jq regex triggers parse errors. +- **Most open P0s are security or Linux AppImage GLIBC issues** — When triaging P0s, filter for those categories first. +- **Project #2 shows only closed items on the board view** — Use `gh issue list --repo tinyhumansai/openhuman --state open --assignee ""` to find unassigned open issues instead of querying the project board. +- **Check linked PRs via timeline API, not body regex** — `gh api repos/tinyhumansai/openhuman/issues/$N/timeline --paginate | jq '[.[] | select(.event == "cross-referenced" and .source.issue.state == "open")] | length'` is more reliable than searching issue body text for PR references. + +## Git Submodules + +- **`tauri-cef` and `tauri-plugin-notification` are git submodules** — When upstream/main updates them, fix with `git submodule update --remote --checkout`, not by manually patching the vendored crate. + +## Pre-existing Test Failures + +- **`composio::action_tool::tests::factory_routes_through_direct_when_mode_is_direct` fails in `cargo test -p openhuman`** — Pre-existing failure unrelated to WhatsApp or any recent branch work. Do not attempt to fix unless explicitly tasked. Also intermittently flaky when run as part of the full suite — see "Pre-existing Flaky Tests" section. + +## Workflow Gate (must not skip) + +- **Steps 4–6 of `workflow/00-full-workflow.md` are mandatory before committing** — Step 4: architectobot verify. Step 5: full checks (`pnpm test:coverage`, `pnpm build`, `bash scripts/install.sh --dry-run`, PR quality scripts). Step 6: memory-keeper. Skipping any of these violates the workflow contract. +- **Encode architectobot answers in the codecrusher prompt** — When the architectobot plan includes clarifying questions and the user approves specific answers, embed those decisions as explicit constraints in the codecrusher prompt so the agent doesn't re-ask. + +## Security Policy + +- **Path validation entry point** — `src/openhuman/security/policy.rs` exposes `validate_path` / `validate_parent_path`. All file I/O path validation must go through this API. `is_path_string_allowed()` is a string-only first pass, not sufficient on its own. +- **validate_parent_path before create_dir_all** — For write operations, `validate_parent_path` MUST be called before any `create_dir_all` call. Calling it after allows symlink attacks to create directories outside the workspace before the security check fires (Issue #1927). +- **Tool callers must use `validate_path` / `validate_parent_path`** — All tool implementations under `src/openhuman/tools/impl/filesystem/` must use these functions, not the legacy `is_path_allowed` / `is_resolved_path_allowed`. +- **Security policy test filter** — Run only security policy tests with: `cargo test -p openhuman -- "security::policy"`. Runs the 100 tests in `src/openhuman/security/policy_tests.rs` cleanly. + +## Pre-existing Flaky Tests + +- **`composio::action_tool` and `agent::harness::session::turn` intermittent failures** — These tests fail randomly when run as part of the full suite (likely shared state or timing), but pass individually. Not related to security/policy changes. Do not treat as blockers for security-module PRs. diff --git a/app/src/components/settings/panels/LocalModelDebugPanel.tsx b/app/src/components/settings/panels/LocalModelDebugPanel.tsx index ec721da885..c814c38553 100644 --- a/app/src/components/settings/panels/LocalModelDebugPanel.tsx +++ b/app/src/components/settings/panels/LocalModelDebugPanel.tsx @@ -1,3 +1,4 @@ +import debug from 'debug'; import { useEffect, useMemo, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; @@ -15,6 +16,7 @@ import { type LocalAiSpeechResult, type LocalAiStatus, type LocalAiTtsResult, + type OllamaConnectionTestResult, openhumanLocalAiAssetsStatus, openhumanLocalAiDiagnostics, openhumanLocalAiDownloadAsset, @@ -23,15 +25,20 @@ import { openhumanLocalAiPrompt, openhumanLocalAiStatus, openhumanLocalAiSummarize, + openhumanLocalAiTestConnection, openhumanLocalAiTranscribe, openhumanLocalAiTts, openhumanLocalAiVisionPrompt, + openhumanUpdateLocalAiSettings, } from '../../../utils/tauriCommands'; +import { openhumanGetConfig } from '../../../utils/tauriCommands/config'; import SettingsHeader from '../components/SettingsHeader'; import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; import ModelDownloadSection from './local-model/ModelDownloadSection'; import ModelStatusSection from './local-model/ModelStatusSection'; +const log = debug('openhuman:local-model-debug'); + const statusTone = (state: string): string => { switch (state) { case 'ready': @@ -93,6 +100,14 @@ const LocalModelDebugPanel = () => { const [showErrorDetail, setShowErrorDetail] = useState(false); + const DEFAULT_OLLAMA_URL = 'http://localhost:11434'; + const [ollamaBaseUrlInput, setOllamaBaseUrlInput] = useState(DEFAULT_OLLAMA_URL); + const [savedOllamaBaseUrl, setSavedOllamaBaseUrl] = useState(DEFAULT_OLLAMA_URL); + const [isTestingConnection, setIsTestingConnection] = useState(false); + const [connectionTestResult, setConnectionTestResult] = + useState(null); + const [isSavingUrl, setIsSavingUrl] = useState(false); + const progress = useMemo(() => { const downloadProgress = progressFromDownloads(downloads); if (downloadProgress != null) return downloadProgress; @@ -151,6 +166,25 @@ const LocalModelDebugPanel = () => { }; }, []); + useEffect(() => { + const seedSavedUrl = async () => { + try { + const configResponse = await openhumanGetConfig(); + const localAi = configResponse.result?.config?.local_ai as + | Record + | undefined; + const saved = localAi?.base_url as string | undefined | null; + if (saved && saved.trim()) { + setOllamaBaseUrlInput(saved.trim()); + setSavedOllamaBaseUrl(saved.trim()); + } + } catch { + // Non-critical — stay on default. + } + }; + void seedSavedUrl(); + }, []); + const runSummaryTest = async () => { if (!runtimeEnabled || !summaryInput.trim()) return; setIsSummaryLoading(true); @@ -281,6 +315,10 @@ const LocalModelDebugPanel = () => { try { const result = await openhumanLocalAiDiagnostics(); setDiagnostics(result); + if (result.ollama_base_url) { + setOllamaBaseUrlInput(result.ollama_base_url); + setSavedOllamaBaseUrl(result.ollama_base_url); + } } catch (err) { setDiagnosticsError(err instanceof Error ? err.message : 'Diagnostics failed'); } finally { @@ -288,6 +326,52 @@ const LocalModelDebugPanel = () => { } }; + const handleTestConnection = async () => { + setIsTestingConnection(true); + setConnectionTestResult(null); + try { + const result = await openhumanLocalAiTestConnection(ollamaBaseUrlInput); + log('[local_ai:ui] test_connection result: reachable=%o', result.reachable); + setConnectionTestResult(result); + } catch (err) { + setConnectionTestResult({ + reachable: false, + error: err instanceof Error ? err.message : 'Connection test failed', + models_count: null, + }); + } finally { + setIsTestingConnection(false); + } + }; + + const handleSaveOllamaBaseUrl = async () => { + setIsSavingUrl(true); + try { + await openhumanUpdateLocalAiSettings({ base_url: ollamaBaseUrlInput }); + log('[local_ai:ui] saved ollama base_url=%s', ollamaBaseUrlInput); + setSavedOllamaBaseUrl(ollamaBaseUrlInput); + } catch (err) { + setStatusError(err instanceof Error ? err.message : 'Failed to save URL'); + } finally { + setIsSavingUrl(false); + } + }; + + const handleResetOllamaBaseUrl = async () => { + setOllamaBaseUrlInput(DEFAULT_OLLAMA_URL); + setConnectionTestResult(null); + setIsSavingUrl(true); + try { + await openhumanUpdateLocalAiSettings({ base_url: null }); + log('[local_ai:ui] reset ollama base_url to default'); + setSavedOllamaBaseUrl(DEFAULT_OLLAMA_URL); + } catch (err) { + setStatusError(err instanceof Error ? err.message : 'Failed to reset URL'); + } finally { + setIsSavingUrl(false); + } + }; + return (
{ etaText={etaText} statusTone={statusTone} runtimeEnabled={runtimeEnabled} + ollamaBaseUrlInput={ollamaBaseUrlInput} + isTestingConnection={isTestingConnection} + connectionTestResult={connectionTestResult} + isSavingUrl={isSavingUrl} + savedOllamaBaseUrl={savedOllamaBaseUrl} onRefreshStatus={() => void loadStatus()} onTriggerDownload={() => {}} onSetOllamaPath={() => {}} @@ -326,6 +415,10 @@ const LocalModelDebugPanel = () => { onSetOllamaPathInput={() => {}} onToggleErrorDetail={() => setShowErrorDetail(v => !v)} onRunDiagnostics={() => void handleRunDiagnostics()} + onSetOllamaBaseUrlInput={setOllamaBaseUrlInput} + onTestConnection={() => void handleTestConnection()} + onSaveOllamaBaseUrl={() => void handleSaveOllamaBaseUrl()} + onResetOllamaBaseUrl={() => void handleResetOllamaBaseUrl()} /> '', runtimeEnabled: true, + ollamaBaseUrlInput: 'http://localhost:11434', + isTestingConnection: false, + connectionTestResult: null, + isSavingUrl: false, + savedOllamaBaseUrl: 'http://localhost:11434', onRefreshStatus: vi.fn(), onTriggerDownload: vi.fn(), onSetOllamaPath: vi.fn(), @@ -33,6 +38,10 @@ const defaultProps = { onToggleErrorDetail: vi.fn(), onRunDiagnostics: vi.fn(), onRepairAction: vi.fn(), + onSetOllamaBaseUrlInput: vi.fn(), + onTestConnection: vi.fn(), + onSaveOllamaBaseUrl: vi.fn(), + onResetOllamaBaseUrl: vi.fn(), }; const makeDiagnostics = (overrides: Partial = {}): LocalAiDiagnostics => ({ @@ -329,3 +338,91 @@ describe('ModelStatusSection diagnostics', () => { expect(screen.getByRole('link', { name: 'Ollama docs' })).toBeTruthy(); }); }); + +describe('ModelStatusSection — Ollama server URL', () => { + it('renders the URL input with the default value', () => { + render(); + const input = screen.getByPlaceholderText('http://localhost:11434') as HTMLInputElement; + expect(input).toBeTruthy(); + expect(input.value).toBe('http://localhost:11434'); + }); + + it('shows a validation error for a bad URL', () => { + render( + + ); + expect(screen.getByText(/http:\/\/ or https:\/\//i)).toBeTruthy(); + }); + + it('disables Save when URL is unchanged', () => { + render( + + ); + const saveBtn = screen.getByRole('button', { name: 'Save' }); + expect((saveBtn as HTMLButtonElement).disabled).toBe(true); + }); + + it('enables Save when URL has changed and is valid', () => { + render( + + ); + const saveBtn = screen.getByRole('button', { name: 'Save' }); + expect((saveBtn as HTMLButtonElement).disabled).toBe(false); + }); + + it('shows reachable status after a successful test', () => { + render( + + ); + expect(screen.getByText(/Reachable/)).toBeTruthy(); + expect(screen.getByText(/3 models/)).toBeTruthy(); + }); + + it('shows unreachable status after a failed test', () => { + render( + + ); + expect(screen.getByText(/Unreachable/)).toBeTruthy(); + expect(screen.getByText(/connection refused/)).toBeTruthy(); + }); + + it('calls onTestConnection when Test Connection is clicked', async () => { + const onTestConnection = vi.fn(); + render( + + ); + const testBtn = screen.getByRole('button', { name: /Test Connection/ }); + testBtn.click(); + expect(onTestConnection).toHaveBeenCalledTimes(1); + }); + + it('calls onResetOllamaBaseUrl when Reset to default is clicked', () => { + const onResetOllamaBaseUrl = vi.fn(); + render(); + const resetBtn = screen.getByRole('button', { name: /Reset to default/ }); + resetBtn.click(); + expect(onResetOllamaBaseUrl).toHaveBeenCalledTimes(1); + }); +}); diff --git a/app/src/components/settings/panels/local-model/ModelStatusSection.tsx b/app/src/components/settings/panels/local-model/ModelStatusSection.tsx index a057daaf6d..dd8cb12989 100644 --- a/app/src/components/settings/panels/local-model/ModelStatusSection.tsx +++ b/app/src/components/settings/panels/local-model/ModelStatusSection.tsx @@ -1,9 +1,11 @@ import { useT } from '../../../../lib/i18n/I18nContext'; import { formatBytes, statusLabel } from '../../../../utils/localAiHelpers'; +import { validateOllamaUrl } from '../../../../utils/ollamaUrlValidation'; import type { LocalAiDiagnostics, LocalAiDownloadsProgress, LocalAiStatus, + OllamaConnectionTestResult, RepairAction, } from '../../../../utils/tauriCommands'; @@ -28,6 +30,10 @@ interface ModelStatusSectionProps { etaText: string; statusTone: (state: string) => string; runtimeEnabled: boolean; + ollamaBaseUrlInput: string; + isTestingConnection: boolean; + connectionTestResult: OllamaConnectionTestResult | null; + isSavingUrl: boolean; onRefreshStatus: () => void; onTriggerDownload: (force: boolean) => void; onSetOllamaPath: () => void; @@ -36,6 +42,11 @@ interface ModelStatusSectionProps { onToggleErrorDetail: () => void; onRunDiagnostics: () => void; onRepairAction?: (action: RepairAction) => void; + onSetOllamaBaseUrlInput: (value: string) => void; + onTestConnection: () => void; + onSaveOllamaBaseUrl: () => void; + onResetOllamaBaseUrl: () => void; + savedOllamaBaseUrl: string; } const ModelStatusSection = ({ @@ -59,6 +70,10 @@ const ModelStatusSection = ({ etaText, statusTone, runtimeEnabled, + ollamaBaseUrlInput, + isTestingConnection, + connectionTestResult, + isSavingUrl, onRefreshStatus, onTriggerDownload, onSetOllamaPath, @@ -67,6 +82,11 @@ const ModelStatusSection = ({ onToggleErrorDetail, onRunDiagnostics, onRepairAction, + onSetOllamaBaseUrlInput, + onTestConnection, + onSaveOllamaBaseUrl, + onResetOllamaBaseUrl, + savedOllamaBaseUrl, }: ModelStatusSectionProps) => { const { t } = useT(); // OpenHuman no longer installs or launches Ollama itself. When the runtime @@ -88,6 +108,11 @@ const ModelStatusSection = ({ void onToggleErrorDetail; void onRepairAction; + const urlValidation = validateOllamaUrl(ollamaBaseUrlInput); + const urlChanged = ollamaBaseUrlInput !== savedOllamaBaseUrl; + const canSave = urlValidation.valid && urlChanged && !isSavingUrl; + const canTest = ollamaBaseUrlInput.trim().length > 0 && !isTestingConnection; + if (showInstallOllamaCta) { return (
@@ -146,6 +171,69 @@ const ModelStatusSection = ({ return ( <> +
+

+ {t('localModel.ollamaServer.label')} +

+
+
+ onSetOllamaBaseUrlInput(e.target.value)} + placeholder={t('localModel.ollamaServer.placeholder')} + className="w-full rounded-md border border-stone-300 dark:border-neutral-700 bg-white dark:bg-neutral-900 px-3 py-1.5 text-sm text-stone-900 dark:text-neutral-100 placeholder-stone-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-primary-500" + /> + {ollamaBaseUrlInput && !urlValidation.valid && ( +

+ {urlValidation.error ?? t('localModel.ollamaServer.validationError')} +

+ )} +

+ {t('localModel.ollamaServer.helperText')} +

+
+ + {connectionTestResult !== null && ( +
+ {connectionTestResult.reachable ? '✓' : '✗'} + + {connectionTestResult.reachable + ? `${t('localModel.ollamaServer.reachable')}${typeof connectionTestResult.models_count === 'number' ? ` (${connectionTestResult.models_count} models)` : ''}` + : `${t('localModel.ollamaServer.unreachable')}${connectionTestResult.error ? `: ${connectionTestResult.error}` : ''}`} + +
+ )} + +
+ + + +
+
+
+

diff --git a/app/src/lib/i18n/chunks/ar-2.ts b/app/src/lib/i18n/chunks/ar-2.ts index e7daf363a4..74954f0499 100644 --- a/app/src/lib/i18n/chunks/ar-2.ts +++ b/app/src/lib/i18n/chunks/ar-2.ts @@ -239,6 +239,15 @@ const ar2: TranslationMap = { 'مفتاح رئيسي. معطّل افتراضيًا — Ollama يبقى خاملاً. عند التشغيل، يستخدم ملخّص الشجرة وذكاء الشاشة والإكمال التلقائي النموذجَ المحلي دائمًا.', 'localModel.advancedSettings': 'إعدادات متقدمة', 'localModel.debugTitle': 'تصحيح النموذج المحلي', + 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'Reachable', + 'localModel.ollamaServer.resetButton': 'Reset to default', + 'localModel.ollamaServer.saveButton': 'Save', + 'localModel.ollamaServer.testButton': 'Test Connection', + 'localModel.ollamaServer.unreachable': 'Unreachable', + 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', 'screenAwareness.debugTitle': 'تصحيح وعي الشاشة', 'memory.debugTitle': 'تصحيح الذاكرة', 'webhooks.debugTitle': 'تصحيح الـ Webhooks', diff --git a/app/src/lib/i18n/chunks/bn-2.ts b/app/src/lib/i18n/chunks/bn-2.ts index 05c990310e..af962d22d0 100644 --- a/app/src/lib/i18n/chunks/bn-2.ts +++ b/app/src/lib/i18n/chunks/bn-2.ts @@ -248,6 +248,15 @@ const bn2: TranslationMap = { 'মাস্টার সুইচ। ডিফল্টে বন্ধ — Ollama নিষ্ক্রিয় থাকে। চালু হলে, ট্রি সামারাইজার, স্ক্রিন ইন্টেলিজেন্স এবং অটোকমপ্লিট সর্বদা লোকাল মডেল ব্যবহার করে।', 'localModel.advancedSettings': 'অ্যাডভান্সড সেটিংস', 'localModel.debugTitle': 'লোকাল মডেল ডিবাগ', + 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'Reachable', + 'localModel.ollamaServer.resetButton': 'Reset to default', + 'localModel.ollamaServer.saveButton': 'Save', + 'localModel.ollamaServer.testButton': 'Test Connection', + 'localModel.ollamaServer.unreachable': 'Unreachable', + 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', 'screenAwareness.debugTitle': 'স্ক্রিন সচেতনতা ডিবাগ', 'memory.debugTitle': 'মেমোরি ডিবাগ', 'webhooks.debugTitle': 'Webhooks ডিবাগ', diff --git a/app/src/lib/i18n/chunks/en-2.ts b/app/src/lib/i18n/chunks/en-2.ts index 0f82fd2d67..d9f4157f2a 100644 --- a/app/src/lib/i18n/chunks/en-2.ts +++ b/app/src/lib/i18n/chunks/en-2.ts @@ -245,6 +245,15 @@ const en2: TranslationMap = { 'Master switch. Off by default — Ollama stays idle. When on, the tree summarizer, screen intelligence, and autocomplete always use the local model.', 'localModel.advancedSettings': 'Advanced settings', 'localModel.debugTitle': 'Local Model Debug', + 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'Reachable', + 'localModel.ollamaServer.resetButton': 'Reset to default', + 'localModel.ollamaServer.saveButton': 'Save', + 'localModel.ollamaServer.testButton': 'Test Connection', + 'localModel.ollamaServer.unreachable': 'Unreachable', + 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', 'screenAwareness.debugTitle': 'Screen Awareness Debug', 'memory.debugTitle': 'Memory Debug', 'webhooks.debugTitle': 'Webhooks Debug', diff --git a/app/src/lib/i18n/chunks/es-2.ts b/app/src/lib/i18n/chunks/es-2.ts index 68d770d309..e304a762ce 100644 --- a/app/src/lib/i18n/chunks/es-2.ts +++ b/app/src/lib/i18n/chunks/es-2.ts @@ -251,6 +251,15 @@ const es2: TranslationMap = { 'Interruptor principal. Desactivado por defecto — Ollama permanece inactivo. Cuando está activado, el resumidor de árbol, la inteligencia de pantalla y el autocompletado siempre usan el modelo local.', 'localModel.advancedSettings': 'Configuración avanzada', 'localModel.debugTitle': 'Depuración de modelo local', + 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'Reachable', + 'localModel.ollamaServer.resetButton': 'Reset to default', + 'localModel.ollamaServer.saveButton': 'Save', + 'localModel.ollamaServer.testButton': 'Test Connection', + 'localModel.ollamaServer.unreachable': 'Unreachable', + 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', 'screenAwareness.debugTitle': 'Depuración de Conciencia de pantalla', 'memory.debugTitle': 'Depuración de memoria', 'webhooks.debugTitle': 'Depuración de webhooks', diff --git a/app/src/lib/i18n/chunks/fr-2.ts b/app/src/lib/i18n/chunks/fr-2.ts index 852616a1ea..541f22bd8a 100644 --- a/app/src/lib/i18n/chunks/fr-2.ts +++ b/app/src/lib/i18n/chunks/fr-2.ts @@ -253,6 +253,15 @@ const fr2: TranslationMap = { "Interrupteur principal. Désactivé par défaut — Ollama reste en veille. Quand activé, le résumeur d'arbre, l'intelligence d'écran et l'autocomplétion utilisent toujours le modèle local.", 'localModel.advancedSettings': 'Paramètres avancés', 'localModel.debugTitle': 'Débogage du modèle local', + 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'Reachable', + 'localModel.ollamaServer.resetButton': 'Reset to default', + 'localModel.ollamaServer.saveButton': 'Save', + 'localModel.ollamaServer.testButton': 'Test Connection', + 'localModel.ollamaServer.unreachable': 'Unreachable', + 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', 'screenAwareness.debugTitle': "Débogage de la surveillance de l'écran", 'memory.debugTitle': 'Débogage de la mémoire', 'webhooks.debugTitle': 'Débogage des webhooks', diff --git a/app/src/lib/i18n/chunks/hi-2.ts b/app/src/lib/i18n/chunks/hi-2.ts index e73035948b..acac20cf15 100644 --- a/app/src/lib/i18n/chunks/hi-2.ts +++ b/app/src/lib/i18n/chunks/hi-2.ts @@ -246,6 +246,15 @@ const hi2: TranslationMap = { 'मास्टर स्विच। डिफ़ॉल्ट रूप से बंद — Ollama आइडल रहता है। चालू होने पर tree summarizer, screen intelligence और autocomplete हमेशा लोकल मॉडल इस्तेमाल करते हैं।', 'localModel.advancedSettings': 'एडवांस्ड सेटिंग्स', 'localModel.debugTitle': 'लोकल मॉडल डिबग', + 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'Reachable', + 'localModel.ollamaServer.resetButton': 'Reset to default', + 'localModel.ollamaServer.saveButton': 'Save', + 'localModel.ollamaServer.testButton': 'Test Connection', + 'localModel.ollamaServer.unreachable': 'Unreachable', + 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', 'screenAwareness.debugTitle': 'स्क्रीन अवेयरनेस डिबग', 'memory.debugTitle': 'मेमोरी डिबग', 'webhooks.debugTitle': 'Webhooks डिबग', diff --git a/app/src/lib/i18n/chunks/id-2.ts b/app/src/lib/i18n/chunks/id-2.ts index 2065274530..72f3e5b984 100644 --- a/app/src/lib/i18n/chunks/id-2.ts +++ b/app/src/lib/i18n/chunks/id-2.ts @@ -247,6 +247,15 @@ const id2: TranslationMap = { 'Sakelar utama. Nonaktif secara default; Ollama tetap idle. Saat aktif, peringkas tree, kecerdasan layar, dan autocomplete selalu memakai model lokal.', 'localModel.advancedSettings': 'Pengaturan lanjutan', 'localModel.debugTitle': 'Debug Model Lokal', + 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'Reachable', + 'localModel.ollamaServer.resetButton': 'Reset to default', + 'localModel.ollamaServer.saveButton': 'Save', + 'localModel.ollamaServer.testButton': 'Test Connection', + 'localModel.ollamaServer.unreachable': 'Unreachable', + 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', 'screenAwareness.debugTitle': 'Debug Kesadaran Layar', 'memory.debugTitle': 'Debug Memori', 'webhooks.debugTitle': 'Debug Webhook', diff --git a/app/src/lib/i18n/chunks/it-2.ts b/app/src/lib/i18n/chunks/it-2.ts index f4427d3260..2d436497ee 100644 --- a/app/src/lib/i18n/chunks/it-2.ts +++ b/app/src/lib/i18n/chunks/it-2.ts @@ -248,6 +248,15 @@ const it2: TranslationMap = { "Interruttore principale. Disattivato di default — Ollama resta inattivo. Quando attivo, il tree summarizer, lo screen intelligence e l'autocompletamento usano sempre il modello locale.", 'localModel.advancedSettings': 'Impostazioni avanzate', 'localModel.debugTitle': 'Debug modello locale', + 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'Reachable', + 'localModel.ollamaServer.resetButton': 'Reset to default', + 'localModel.ollamaServer.saveButton': 'Save', + 'localModel.ollamaServer.testButton': 'Test Connection', + 'localModel.ollamaServer.unreachable': 'Unreachable', + 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', 'screenAwareness.debugTitle': 'Debug consapevolezza schermo', 'memory.debugTitle': 'Debug memoria', 'webhooks.debugTitle': 'Debug webhook', diff --git a/app/src/lib/i18n/chunks/pt-2.ts b/app/src/lib/i18n/chunks/pt-2.ts index d9acd8349b..7bc3652fb8 100644 --- a/app/src/lib/i18n/chunks/pt-2.ts +++ b/app/src/lib/i18n/chunks/pt-2.ts @@ -251,6 +251,15 @@ const pt2: TranslationMap = { 'Chave mestre. Desligado por padrão — Ollama fica inativo. Quando ligado, o sumarizador de árvore, inteligência de tela e autocompletar sempre usam o modelo local.', 'localModel.advancedSettings': 'Configurações avançadas', 'localModel.debugTitle': 'Depuração de Modelo Local', + 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'Reachable', + 'localModel.ollamaServer.resetButton': 'Reset to default', + 'localModel.ollamaServer.saveButton': 'Save', + 'localModel.ollamaServer.testButton': 'Test Connection', + 'localModel.ollamaServer.unreachable': 'Unreachable', + 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', 'screenAwareness.debugTitle': 'Depuração de Reconhecimento de Tela', 'memory.debugTitle': 'Depuração de Memória', 'webhooks.debugTitle': 'Depuração de Webhooks', diff --git a/app/src/lib/i18n/chunks/ru-2.ts b/app/src/lib/i18n/chunks/ru-2.ts index a838403def..d0454e4c51 100644 --- a/app/src/lib/i18n/chunks/ru-2.ts +++ b/app/src/lib/i18n/chunks/ru-2.ts @@ -246,6 +246,15 @@ const ru2: TranslationMap = { 'Главный переключатель. По умолчанию выключен — Ollama простаивает. При включении суммаризатор деревьев, интеллект экрана и автодополнение всегда используют локальную модель.', 'localModel.advancedSettings': 'Дополнительные настройки', 'localModel.debugTitle': 'Отладка локальной модели', + 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'Reachable', + 'localModel.ollamaServer.resetButton': 'Reset to default', + 'localModel.ollamaServer.saveButton': 'Save', + 'localModel.ollamaServer.testButton': 'Test Connection', + 'localModel.ollamaServer.unreachable': 'Unreachable', + 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', 'screenAwareness.debugTitle': 'Отладка слежения за экраном', 'memory.debugTitle': 'Отладка памяти', 'webhooks.debugTitle': 'Отладка вебхуков', diff --git a/app/src/lib/i18n/chunks/zh-CN-2.ts b/app/src/lib/i18n/chunks/zh-CN-2.ts index 5456142af4..ee06972a2b 100644 --- a/app/src/lib/i18n/chunks/zh-CN-2.ts +++ b/app/src/lib/i18n/chunks/zh-CN-2.ts @@ -230,6 +230,15 @@ const zhCN2: TranslationMap = { '总开关。默认关闭——Ollama 保持空闲。启用后,树摘要器、屏幕智能和自动补全始终使用本地模型。', 'localModel.advancedSettings': '高级设置', 'localModel.debugTitle': '本地模型调试', + 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'Reachable', + 'localModel.ollamaServer.resetButton': 'Reset to default', + 'localModel.ollamaServer.saveButton': 'Save', + 'localModel.ollamaServer.testButton': 'Test Connection', + 'localModel.ollamaServer.unreachable': 'Unreachable', + 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', 'screenAwareness.debugTitle': '屏幕感知调试', 'memory.debugTitle': '记忆调试', 'webhooks.debugTitle': 'Webhook 调试', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index a2a75ab82b..b30d56031c 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1801,6 +1801,15 @@ const en: TranslationMap = { 'settings.localModel.status.notFound': 'Not found', 'settings.localModel.status.notRunning': 'Not running', 'settings.localModel.status.ollamaBinaryPath': 'Ollama binary path', + 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.placeholder': 'http://localhost:11434', + 'localModel.ollamaServer.reachable': 'Reachable', + 'localModel.ollamaServer.resetButton': 'Reset to default', + 'localModel.ollamaServer.saveButton': 'Save', + 'localModel.ollamaServer.testButton': 'Test Connection', + 'localModel.ollamaServer.unreachable': 'Unreachable', + 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', 'settings.localModel.status.ollamaDiagnostics': 'Ollama Diagnostics', 'settings.localModel.status.ollamaNotInstalled': 'Ollama runtime unavailable', 'settings.localModel.status.ollamaNotInstalledDesc': diff --git a/app/src/utils/ollamaUrlValidation.test.ts b/app/src/utils/ollamaUrlValidation.test.ts new file mode 100644 index 0000000000..ba2a25d95e --- /dev/null +++ b/app/src/utils/ollamaUrlValidation.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it } from 'vitest'; + +import { validateOllamaUrl } from './ollamaUrlValidation'; + +describe('validateOllamaUrl', () => { + it('accepts a plain http URL', () => { + const result = validateOllamaUrl('http://localhost:11434'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('http://localhost:11434'); + }); + + it('accepts a plain https URL', () => { + const result = validateOllamaUrl('https://remote-ollama.example.com:11434'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('https://remote-ollama.example.com:11434'); + }); + + it('accepts an IP address URL', () => { + const result = validateOllamaUrl('http://192.168.1.5:11434'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('http://192.168.1.5:11434'); + }); + + it('rejects an empty string', () => { + const result = validateOllamaUrl(''); + expect(result.valid).toBe(false); + expect(result.error).toBeTruthy(); + }); + + it('rejects a whitespace-only string', () => { + const result = validateOllamaUrl(' '); + expect(result.valid).toBe(false); + }); + + it('rejects URLs without http(s) scheme', () => { + expect(validateOllamaUrl('localhost:11434').valid).toBe(false); + expect(validateOllamaUrl('ftp://localhost:11434').valid).toBe(false); + }); + + it('rejects URLs with credentials', () => { + const result = validateOllamaUrl('http://user:pass@localhost:11434'); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/credential/i); + }); + + it('strips path component and normalizes to scheme://host:port', () => { + const result = validateOllamaUrl('http://192.168.1.5:11434/api/tags'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('http://192.168.1.5:11434'); + }); + + it('strips trailing slashes', () => { + const result = validateOllamaUrl('http://localhost:11434///'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('http://localhost:11434'); + }); + + it('rejects URLs with query strings', () => { + const result = validateOllamaUrl('http://localhost:11434?foo=bar'); + expect(result.valid).toBe(false); + }); + + it('rejects URLs with fragments', () => { + const result = validateOllamaUrl('http://localhost:11434#section'); + expect(result.valid).toBe(false); + }); + + it('omits port from normalized URL when no port is specified', () => { + const result = validateOllamaUrl('https://example.com'); + expect(result.valid).toBe(true); + expect(result.normalized).toBe('https://example.com'); + }); +}); diff --git a/app/src/utils/ollamaUrlValidation.ts b/app/src/utils/ollamaUrlValidation.ts new file mode 100644 index 0000000000..50bccb70d0 --- /dev/null +++ b/app/src/utils/ollamaUrlValidation.ts @@ -0,0 +1,48 @@ +export interface OllamaUrlValidationResult { + valid: boolean; + normalized?: string; + error?: string; +} + +/** + * Validate and normalize a user-supplied Ollama base URL. + * + * Rules (mirrors the Rust `validate_ollama_url` helper): + * - Trims whitespace and strips trailing slashes + * - Must be http:// or https:// + * - Must have a non-empty hostname + * - No credentials (user:pass@) + * - No query string or fragment + * - Path component is stripped — normalized form is scheme://host[:port] + */ +export function validateOllamaUrl(raw: string): OllamaUrlValidationResult { + const trimmed = raw.trim().replace(/\/+$/, ''); + if (!trimmed) { + return { valid: false, error: 'URL must not be empty' }; + } + if (!trimmed.startsWith('http://') && !trimmed.startsWith('https://')) { + return { valid: false, error: 'Must be a valid http:// or https:// URL' }; + } + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return { valid: false, error: 'Invalid URL format' }; + } + if (!parsed.hostname) { + return { valid: false, error: 'URL must have a non-empty host' }; + } + if (parsed.username || parsed.password) { + return { valid: false, error: 'URL must not contain credentials (user:pass@host)' }; + } + if (parsed.search) { + return { valid: false, error: 'URL must not contain a query string' }; + } + if (parsed.hash) { + return { valid: false, error: 'URL must not contain a fragment' }; + } + // Normalize to scheme://host[:port] + const port = parsed.port ? `:${parsed.port}` : ''; + const normalized = `${parsed.protocol}//${parsed.hostname}${port}`; + return { valid: true, normalized }; +} diff --git a/app/src/utils/tauriCommands/localAi.ts b/app/src/utils/tauriCommands/localAi.ts index bcae39a194..1d7c0bff05 100644 --- a/app/src/utils/tauriCommands/localAi.ts +++ b/app/src/utils/tauriCommands/localAi.ts @@ -385,3 +385,18 @@ export async function openhumanLocalAiDiagnostics(): Promise params: {}, }); } + +export interface OllamaConnectionTestResult { + reachable: boolean; + error?: string | null; + models_count?: number | null; +} + +export async function openhumanLocalAiTestConnection( + url: string +): Promise { + return await callCoreRpc({ + method: 'openhuman.local_ai_test_connection', + params: { url }, + }); +} diff --git a/src/openhuman/inference/local/ollama.rs b/src/openhuman/inference/local/ollama.rs index cb96fb2756..f6e39733df 100644 --- a/src/openhuman/inference/local/ollama.rs +++ b/src/openhuman/inference/local/ollama.rs @@ -35,6 +35,77 @@ pub(crate) fn ollama_base_url() -> String { DEFAULT_OLLAMA_BASE_URL.to_string() } +/// Returns the effective Ollama base URL, with `config.local_ai.base_url` +/// taking highest priority over env vars. +/// +/// Priority (highest to lowest): +/// 1. `config.local_ai.base_url` if `Some` and non-empty (after trim) +/// 2. `OPENHUMAN_OLLAMA_BASE_URL` env var +/// 3. `OLLAMA_HOST` env var +/// 4. [`DEFAULT_OLLAMA_BASE_URL`] +pub(crate) fn ollama_base_url_from_config(config: &crate::openhuman::config::Config) -> String { + if let Some(ref url) = config.local_ai.base_url { + let trimmed = url.trim().trim_end_matches('/'); + if !trimmed.is_empty() { + log::debug!( + "[local_ai] ollama_base_url_from_config: using config base_url -> {trimmed}" + ); + return trimmed.to_string(); + } + } + let resolved = ollama_base_url(); + log::debug!( + "[local_ai] ollama_base_url_from_config: config base_url absent, resolved -> {resolved}" + ); + resolved +} + +/// Validate and normalize a user-supplied Ollama URL. +/// +/// - Trims whitespace and strips trailing slashes. +/// - Must have an `http://` or `https://` scheme. +/// - Must have a non-empty host. +/// - Rejects URLs with credentials (`user:pass@`). +/// - Rejects query strings and fragments. +/// - Strips any path component beyond root, normalizing to `scheme://host:port`. +/// +/// Returns the normalized URL on success or an error message on failure. +pub(crate) fn validate_ollama_url(raw: &str) -> Result { + let trimmed = raw.trim().trim_end_matches('/'); + if trimmed.is_empty() { + return Err("URL must not be empty".to_string()); + } + if !trimmed.starts_with("http://") && !trimmed.starts_with("https://") { + return Err("URL must start with http:// or https://".to_string()); + } + let parsed = reqwest::Url::parse(trimmed).map_err(|e| format!("Invalid URL: {e}"))?; + + if parsed.host_str().map(|h| h.is_empty()).unwrap_or(true) { + return Err("URL must have a non-empty host".to_string()); + } + + if !parsed.username().is_empty() || parsed.password().is_some() { + return Err("URL must not contain credentials (user:pass@host)".to_string()); + } + + if parsed.query().is_some() { + return Err("URL must not contain a query string".to_string()); + } + if parsed.fragment().is_some() { + return Err("URL must not contain a fragment".to_string()); + } + + // Normalize to scheme://host[:port] — strip any path component. + let mut normalized = format!("{}://{}", parsed.scheme(), parsed.host_str().unwrap_or("")); + if let Some(port) = parsed.port() { + normalized.push(':'); + normalized.push_str(&port.to_string()); + } + + log::debug!("[local_ai] validate_ollama_url: raw={trimmed:?} -> normalized={normalized:?}"); + Ok(normalized) +} + /// Back-compat constant kept at its original value for callers that /// reference it directly. New callers should use [`ollama_base_url`]. pub(crate) const OLLAMA_BASE_URL: &str = DEFAULT_OLLAMA_BASE_URL; @@ -432,4 +503,77 @@ mod tests { let _g2 = OllamaEnvGuard::set_var(OLLAMA_HOST_VAR, "myhost:11434/"); assert_eq!(ollama_base_url(), "http://myhost:11434"); } + + // ── ollama_base_url_from_config ─────────────────────────────────── + + fn make_config_with_base_url(url: Option<&str>) -> crate::openhuman::config::Config { + let mut config = crate::openhuman::config::Config::default(); + config.local_ai.base_url = url.map(|s| s.to_string()); + config + } + + #[test] + fn ollama_base_url_from_config_takes_priority_over_env() { + let _lock = test_lock(); + let _g = OllamaEnvGuard::set("http://127.0.0.1:55555"); + let config = make_config_with_base_url(Some("http://192.168.1.5:11434")); + assert_eq!( + ollama_base_url_from_config(&config), + "http://192.168.1.5:11434" + ); + } + + #[test] + fn ollama_base_url_from_config_falls_back_when_none() { + let _lock = test_lock(); + let _g = OllamaEnvGuard::set("http://127.0.0.1:55555"); + let config = make_config_with_base_url(None); + assert_eq!( + ollama_base_url_from_config(&config), + "http://127.0.0.1:55555" + ); + } + + // ── validate_ollama_url ─────────────────────────────────────────── + + #[test] + fn validate_ollama_url_accepts_http() { + assert_eq!( + validate_ollama_url("http://localhost:11434"), + Ok("http://localhost:11434".to_string()) + ); + } + + #[test] + fn validate_ollama_url_accepts_https() { + assert_eq!( + validate_ollama_url("https://remote-ollama.example.com:11434"), + Ok("https://remote-ollama.example.com:11434".to_string()) + ); + } + + #[test] + fn validate_ollama_url_rejects_no_scheme() { + assert!(validate_ollama_url("localhost:11434").is_err()); + assert!(validate_ollama_url("ftp://localhost:11434").is_err()); + } + + #[test] + fn validate_ollama_url_rejects_credentials() { + assert!(validate_ollama_url("http://user:pass@localhost:11434").is_err()); + } + + #[test] + fn validate_ollama_url_strips_path_and_normalizes() { + assert_eq!( + validate_ollama_url("http://192.168.1.5:11434/api/tags"), + Ok("http://192.168.1.5:11434".to_string()) + ); + } + + #[test] + fn validate_ollama_url_rejects_empty() { + assert!(validate_ollama_url("").is_err()); + assert!(validate_ollama_url(" ").is_err()); + } } diff --git a/src/openhuman/inference/local/schemas.rs b/src/openhuman/inference/local/schemas.rs index aefc7d5af0..cd961c18f7 100644 --- a/src/openhuman/inference/local/schemas.rs +++ b/src/openhuman/inference/local/schemas.rs @@ -2,6 +2,11 @@ use serde::de::DeserializeOwned; use serde::Deserialize; use serde_json::{Map, Value}; +#[derive(Debug, Deserialize)] +struct LocalAiTestConnectionParams { + url: String, +} + use crate::core::all::{ControllerFuture, RegisteredController}; use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; use crate::openhuman::config::rpc as config_rpc; @@ -72,6 +77,7 @@ pub fn all_controller_schemas() -> Vec { schemas("local_ai_install_piper"), schemas("local_ai_whisper_install_status"), schemas("local_ai_piper_install_status"), + schemas("local_ai_test_connection"), ] } @@ -125,6 +131,10 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("local_ai_piper_install_status"), handler: handle_local_ai_piper_install_status, }, + RegisteredController { + schema: schemas("local_ai_test_connection"), + handler: handle_local_ai_test_connection, + }, ] } @@ -251,6 +261,13 @@ pub fn schemas(function: &str) -> ControllerSchema { inputs: vec![], outputs: vec![json_output("status", "Piper install status payload.")], }, + "local_ai_test_connection" => ControllerSchema { + namespace: "local_ai", + function: "test_connection", + description: "Test connectivity to an Ollama server URL. Returns reachable status and model count.", + inputs: vec![required_string("url", "Ollama server URL to test.")], + outputs: vec![json_output("result", "Connection test result.")], + }, _ => ControllerSchema { namespace: "local_ai", function: "unknown", @@ -527,6 +544,18 @@ fn handle_local_ai_install_piper(params: Map) -> ControllerFuture }) } +fn handle_local_ai_test_connection(params: Map) -> ControllerFuture { + Box::pin(async move { + let p = deserialize_params::(params)?; + let result = + crate::openhuman::inference::local::service::ollama_admin::test_ollama_connection( + &p.url, + ) + .await?; + serde_json::to_value(result).map_err(|e| format!("serialize test_connection result: {e}")) + }) +} + fn handle_local_ai_whisper_install_status(_params: Map) -> ControllerFuture { Box::pin(async move { let config = config_rpc::load_config_with_timeout().await?; diff --git a/src/openhuman/inference/local/schemas_tests.rs b/src/openhuman/inference/local/schemas_tests.rs index 4308ce19e8..8f7a6f3299 100644 --- a/src/openhuman/inference/local/schemas_tests.rs +++ b/src/openhuman/inference/local/schemas_tests.rs @@ -39,6 +39,7 @@ fn every_registered_key_resolves_to_non_unknown_schema() { "local_ai_install_piper", "local_ai_whisper_install_status", "local_ai_piper_install_status", + "local_ai_test_connection", ]; for k in keys { let s = schemas(k); diff --git a/src/openhuman/inference/local/service/mod.rs b/src/openhuman/inference/local/service/mod.rs index 0da432d265..28cafc03b5 100644 --- a/src/openhuman/inference/local/service/mod.rs +++ b/src/openhuman/inference/local/service/mod.rs @@ -3,7 +3,7 @@ mod assets; mod bootstrap; mod lm_studio; -mod ollama_admin; +pub(crate) mod ollama_admin; mod public_infer; pub(crate) mod spawn_marker; mod speech; diff --git a/src/openhuman/inference/local/service/ollama_admin.rs b/src/openhuman/inference/local/service/ollama_admin.rs index 3741b5a390..5db0f1a149 100644 --- a/src/openhuman/inference/local/service/ollama_admin.rs +++ b/src/openhuman/inference/local/service/ollama_admin.rs @@ -8,8 +8,8 @@ use crate::openhuman::inference::local::install::{ }; use crate::openhuman::inference::local::lm_studio::lm_studio_base_url; use crate::openhuman::inference::local::ollama::{ - ollama_base_url, OllamaModelTag, OllamaPullEvent, OllamaPullProgress, OllamaPullRequest, - OllamaTagsResponse, + ollama_base_url, ollama_base_url_from_config, validate_ollama_url, OllamaModelTag, + OllamaPullEvent, OllamaPullProgress, OllamaPullRequest, OllamaTagsResponse, }; use crate::openhuman::inference::local::process_util::apply_no_window; use crate::openhuman::inference::local::provider::{provider_from_config, LocalAiProvider}; @@ -27,10 +27,11 @@ fn lm_studio_models_error_means_unreachable(error: &str) -> bool { impl LocalAiService { pub(in crate::openhuman::inference::local::service) async fn ensure_ollama_server( &self, - _config: &Config, + config: &Config, ) -> Result<(), String> { - if self.ollama_healthy().await { - if self.ollama_runner_ok().await { + let base_url = ollama_base_url_from_config(config); + if self.ollama_healthy_at(&base_url).await { + if self.ollama_runner_ok_at(&base_url).await { return Ok(()); } log::warn!("[local_ai] Ollama server responds but runner is broken"); @@ -39,7 +40,6 @@ impl LocalAiService { .to_string(), ); } - let base_url = ollama_base_url(); Err(format!( "OpenHuman no longer starts or installs Ollama automatically. Start your inference runtime yourself and make sure it is reachable at {base_url}." )) @@ -71,7 +71,8 @@ impl LocalAiService { spawn_marker::clear_marker(config); return; } - if !self.ollama_healthy().await { + let base_url = ollama_base_url_from_config(config); + if !self.ollama_healthy_at(&base_url).await { // PID is alive but :11434 isn't healthy — either Ollama is // mid-boot or the recorded PID was reused for an unrelated // process. Leave the marker; either the daemon will come up @@ -101,7 +102,8 @@ impl LocalAiService { config: &Config, ollama_cmd: &Path, ) -> Result<(), String> { - if self.ollama_healthy().await { + let base_url = ollama_base_url_from_config(config); + if self.ollama_healthy_at(&base_url).await { // A daemon is already up — adopt it. We did NOT spawn it (or any // prior spawn was already reclaimed in `reclaim_orphan_if_ours`), // so `owned_ollama` stays `None` and the daemon survives openhuman @@ -191,7 +193,7 @@ impl LocalAiService { } for _ in 0..20 { - if self.ollama_healthy().await { + if self.ollama_healthy_at(&base_url).await { // Daemon is up. Take ownership so we can kill it on exit and // write the spawn marker so a crashed openhuman can reclaim // this PID on next launch instead of orphaning it forever. @@ -475,9 +477,18 @@ impl LocalAiService { Ok(()) } - pub(in crate::openhuman::inference::local::service) async fn ollama_healthy(&self) -> bool { + /// Check Ollama health against the given base URL. + pub(in crate::openhuman::inference::local::service) async fn ollama_healthy_at( + &self, + base_url: &str, + ) -> bool { + tracing::debug!( + target: "local_ai::ollama_admin", + %base_url, + "[local_ai:ollama_admin] ollama_healthy_at: checking" + ); self.http - .get(format!("{}/api/tags", ollama_base_url())) + .get(format!("{base_url}/api/tags")) .timeout(std::time::Duration::from_secs(2)) .send() .await @@ -485,6 +496,12 @@ impl LocalAiService { .unwrap_or(false) } + /// Backward-compat wrapper — resolves the URL from env vars only (no config). + /// Prefer [`ollama_healthy_at`] when a `Config` is available. + pub(in crate::openhuman::inference::local::service) async fn ollama_healthy(&self) -> bool { + self.ollama_healthy_at(&ollama_base_url()).await + } + /// Filesystem-only precondition: is *any* Ollama binary discoverable? /// /// This is the cheapest possible check — no process spawns, no HTTP, no @@ -825,8 +842,8 @@ impl LocalAiService { return self.lm_studio_diagnostics(config).await; } - let base_url = ollama_base_url(); - let healthy = self.ollama_healthy().await; + let base_url = ollama_base_url_from_config(config); + let healthy = self.ollama_healthy_at(&base_url).await; log::debug!( "[local_ai] diagnostics: entry base_url={} healthy={}", @@ -835,7 +852,7 @@ impl LocalAiService { ); let (models, tags_error) = if healthy { - match self.list_models().await { + match self.list_models_at(&base_url).await { Ok(models) => (models, None), Err(e) => (vec![], Some(e)), } @@ -923,8 +940,7 @@ impl LocalAiService { })) } - async fn list_models(&self) -> Result, String> { - let base = ollama_base_url(); + async fn list_models_at(&self, base: &str) -> Result, String> { let url = format!("{base}/api/tags"); tracing::debug!( target: "local_ai::ollama_admin", @@ -1143,12 +1159,11 @@ impl LocalAiService { .map(|p| p.display().to_string()) } - /// Quick check that the Ollama runner can actually exec models. - /// Sends a tiny generate request and checks for a 500 "fork/exec" error. - async fn ollama_runner_ok(&self) -> bool { + /// Quick check that the Ollama runner can actually exec models against the given URL. + async fn ollama_runner_ok_at(&self, base_url: &str) -> bool { let resp = self .http - .post(format!("{}/api/tags", ollama_base_url())) + .post(format!("{base_url}/api/tags")) .timeout(std::time::Duration::from_secs(3)) .send() .await; @@ -1158,7 +1173,7 @@ impl LocalAiService { // Do a lightweight pull-status check (won't download, just checks). let check = self .http - .post(format!("{}/api/show", ollama_base_url())) + .post(format!("{base_url}/api/show")) .json(&serde_json::json!({"name": "___nonexistent_probe___"})) .timeout(std::time::Duration::from_secs(3)) .send() @@ -1234,6 +1249,10 @@ impl LocalAiService { &self, model: &str, ) -> Result { + self.has_model_at(&ollama_base_url(), model).await + } + + async fn has_model_at(&self, base_url: &str, model: &str) -> Result { // Issue the /api/tags GET directly. We previously short-circuited via // ollama_healthy(), but that doubled the number of /api/tags round-trips // on healthy polls (one probe + one tags fetch). With three has_model() @@ -1242,10 +1261,10 @@ impl LocalAiService { // reqwest client (set in bootstrap.rs) bounds the cost when the server // is down — the connect failure surfaces as Err, same as ollama_healthy() // would have surfaced as `false`. - log::debug!("[local_ai] has_model: checking for model `{model}`"); + log::debug!("[local_ai] has_model_at: checking for model `{model}` at {base_url}"); let response = self .http - .get(format!("{}/api/tags", ollama_base_url())) + .get(format!("{base_url}/api/tags")) // Per-request timeout matches list_models (5s). The shared client's // connect_timeout only bounds the TCP handshake; without this a // hung server (accepted connection, no response body) would block @@ -1281,6 +1300,64 @@ impl LocalAiService { } } +/// Test connectivity to a user-supplied Ollama URL. +/// +/// Validates the URL via [`validate_ollama_url`], then issues a GET to +/// `{normalized_url}/api/tags` with a 3-second timeout. +/// Returns a JSON object with `reachable`, optional `error`, and +/// `models_count` when reachable. +pub(crate) async fn test_ollama_connection(url: &str) -> Result { + let normalized = validate_ollama_url(url)?; + log::debug!("[local_ai] test_ollama_connection: testing url={normalized}"); + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(3)) + .build() + .map_err(|e| format!("failed to build HTTP client: {e}"))?; + + match client.get(format!("{normalized}/api/tags")).send().await { + Ok(resp) if resp.status().is_success() => { + let models_count = resp + .json::() + .await + .map(|t| t.models.len()) + .unwrap_or(0); + log::debug!( + "[local_ai] test_ollama_connection: reachable url={normalized} models={models_count}" + ); + Ok(serde_json::json!({ + "reachable": true, + "error": null, + "models_count": models_count, + })) + } + Ok(resp) => { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + let err = format!("server responded with status {status}: {}", body.trim()); + log::debug!( + "[local_ai] test_ollama_connection: unreachable url={normalized} err={err}" + ); + Ok(serde_json::json!({ + "reachable": false, + "error": err, + "models_count": null, + })) + } + Err(e) => { + let err = e.to_string(); + log::debug!( + "[local_ai] test_ollama_connection: connection failed url={normalized} err={err}" + ); + Ok(serde_json::json!({ + "reachable": false, + "error": err, + "models_count": null, + })) + } + } +} + fn interrupted_pull_settle_window_secs(observed_bytes: bool, settle_window_secs: u64) -> u64 { if observed_bytes { settle_window_secs.max(1) diff --git a/src/openhuman/inference/local/service/ollama_admin_tests.rs b/src/openhuman/inference/local/service/ollama_admin_tests.rs index d85e9356f2..2a2bb4e799 100644 --- a/src/openhuman/inference/local/service/ollama_admin_tests.rs +++ b/src/openhuman/inference/local/service/ollama_admin_tests.rs @@ -148,6 +148,68 @@ async fn ensure_ollama_server_requires_external_runtime_when_unreachable() { ); } +#[tokio::test] +async fn test_ollama_connection_returns_reachable_with_model_count() { + let _guard = crate::openhuman::inference::inference_test_guard(); + + let app = Router::new().route( + "/api/tags", + get(|| async { + Json(json!({ + "models": [ + {"name": "llama3:latest", "modified_at": "", "size": 1u64, "digest": "d"}, + {"name": "mistral:7b", "modified_at": "", "size": 2u64, "digest": "d"} + ] + })) + }), + ); + let base = spawn_mock(app).await; + + let result = super::test_ollama_connection(&base).await.unwrap(); + assert_eq!(result["reachable"], true); + assert_eq!(result["models_count"], 2); + assert!(result["error"].is_null()); +} + +#[tokio::test] +async fn test_ollama_connection_returns_unreachable_on_server_error() { + let _guard = crate::openhuman::inference::inference_test_guard(); + + let app = Router::new().route( + "/api/tags", + get(|| async { (axum::http::StatusCode::INTERNAL_SERVER_ERROR, "boom") }), + ); + let base = spawn_mock(app).await; + + let result = super::test_ollama_connection(&base).await.unwrap(); + assert_eq!(result["reachable"], false); + assert!(!result["error"].as_str().unwrap_or("").is_empty()); +} + +#[tokio::test] +async fn test_ollama_connection_returns_unreachable_on_connect_failure() { + let _guard = crate::openhuman::inference::inference_test_guard(); + + let result = super::test_ollama_connection("http://127.0.0.1:1") + .await + .unwrap(); + assert_eq!(result["reachable"], false); + assert!(!result["error"].as_str().unwrap_or("").is_empty()); +} + +#[tokio::test] +async fn test_ollama_connection_rejects_invalid_url() { + let _guard = crate::openhuman::inference::inference_test_guard(); + + let err = super::test_ollama_connection("not-a-url") + .await + .unwrap_err(); + assert!( + !err.is_empty(), + "expected validation error, got empty string" + ); +} + #[tokio::test] async fn ensure_ollama_server_reports_broken_external_runner_without_restart_attempt() { let _guard = crate::openhuman::inference::inference_test_guard(); @@ -458,7 +520,7 @@ async fn list_models_returns_parsed_payload() { let config = Config::default(); let service = LocalAiService::new(&config); - let models = service.list_models().await.expect("list_models"); + let models = service.list_models_at(&base).await.expect("list_models"); assert_eq!(models.len(), 2); assert_eq!(models[0].name, "a:latest"); assert_eq!(models[1].name, "b:v2"); @@ -482,7 +544,7 @@ async fn list_models_errors_on_non_success() { let config = Config::default(); let service = LocalAiService::new(&config); - let err = service.list_models().await.unwrap_err(); + let err = service.list_models_at(&base).await.unwrap_err(); assert!(err.contains("503") || err.contains("tags failed")); unsafe { std::env::remove_var("OPENHUMAN_OLLAMA_BASE_URL"); diff --git a/src/openhuman/inference/local/service/public_infer.rs b/src/openhuman/inference/local/service/public_infer.rs index 2eb40d0f12..4ed510e7e3 100644 --- a/src/openhuman/inference/local/service/public_infer.rs +++ b/src/openhuman/inference/local/service/public_infer.rs @@ -1,6 +1,7 @@ use crate::openhuman::config::Config; use crate::openhuman::inference::local::ollama::{ - ns_to_tps, ollama_base_url, OllamaGenerateOptions, OllamaGenerateRequest, + ns_to_tps, ollama_base_url, ollama_base_url_from_config, OllamaGenerateOptions, + OllamaGenerateRequest, }; use crate::openhuman::inference::local::provider::{provider_from_config, LocalAiProvider}; use crate::openhuman::inference::model_ids; @@ -22,14 +23,22 @@ fn redact_ollama_base_url(raw: &str) -> String { .unwrap_or_else(|_| "".to_string()) } -fn external_ollama_request_error(prefix: &str, error: &reqwest::Error) -> String { - let safe_base_url = redact_ollama_base_url(&ollama_base_url()); +fn external_ollama_request_error_with_url( + prefix: &str, + error: &reqwest::Error, + base_url: &str, +) -> String { + let safe_base_url = redact_ollama_base_url(base_url); format!( "{prefix}: OpenHuman routes inference through an external Ollama endpoint. \ Make sure Ollama is already running and reachable at {safe_base_url} ({error})" ) } +fn external_ollama_request_error(prefix: &str, error: &reqwest::Error) -> String { + external_ollama_request_error_with_url(prefix, error, &ollama_base_url()) +} + #[cfg(test)] mod redact_tests { use super::redact_ollama_base_url; @@ -301,13 +310,16 @@ impl LocalAiService { ), }; + let base_url = ollama_base_url_from_config(config); let response = self .http - .post(format!("{}/api/chat", ollama_base_url())) + .post(format!("{base_url}/api/chat")) .json(&body) .send() .await - .map_err(|e| external_ollama_request_error("ollama chat request failed", &e))?; + .map_err(|e| { + external_ollama_request_error_with_url("ollama chat request failed", &e, &base_url) + })?; if !response.status().is_success() { let status = response.status(); @@ -551,13 +563,19 @@ impl LocalAiService { }), }; + let base_url = ollama_base_url_from_config(config); + log::debug!( + "[local_ai:infer] inference_with_temperature_internal: using base_url={base_url}" + ); let response = self .http - .post(format!("{}/api/generate", ollama_base_url())) + .post(format!("{base_url}/api/generate")) .json(&body) .send() .await - .map_err(|e| external_ollama_request_error("ollama request failed", &e))?; + .map_err(|e| { + external_ollama_request_error_with_url("ollama request failed", &e, &base_url) + })?; if !response.status().is_success() { let status = response.status(); let body = response.text().await.unwrap_or_default(); diff --git a/src/openhuman/inference/local/service/vision_embed.rs b/src/openhuman/inference/local/service/vision_embed.rs index 0ed010cfa1..c0c7056c50 100644 --- a/src/openhuman/inference/local/service/vision_embed.rs +++ b/src/openhuman/inference/local/service/vision_embed.rs @@ -1,7 +1,7 @@ use crate::openhuman::agent::multimodal; use crate::openhuman::config::Config; use crate::openhuman::inference::local::ollama::{ - ollama_base_url, OllamaEmbedRequest, OllamaEmbedResponse, OllamaGenerateOptions, + ollama_base_url_from_config, OllamaEmbedRequest, OllamaEmbedResponse, OllamaGenerateOptions, OllamaGenerateRequest, }; use crate::openhuman::inference::model_ids; @@ -65,7 +65,7 @@ impl LocalAiService { }), }; - let base = ollama_base_url(); + let base = ollama_base_url_from_config(config); let url = format!("{base}/api/generate"); let body_bytes = serde_json::to_vec(&body).map(|v| v.len()).unwrap_or(0); tracing::debug!( @@ -156,9 +156,11 @@ impl LocalAiService { // user's laptop when stacked with other Ollama work. Gate it. let _gate_permit = crate::openhuman::scheduler_gate::wait_for_capacity().await; + let embed_base = ollama_base_url_from_config(config); + log::debug!("[local_ai:embed] embed: using base_url={embed_base}"); let response = self .http - .post(format!("{}/api/embed", ollama_base_url())) + .post(format!("{embed_base}/api/embed")) .json(&OllamaEmbedRequest { model: embedding_model.clone(), input: items.clone(), From fba0d511485c49f3acf210ee7d9cedeae9426041 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 19 May 2026 18:08:46 +0530 Subject: [PATCH 06/10] fix(local-ai): address CodeRabbit review comments - Gate Test button on URL validation rather than non-empty input - Localize model count in connection result display - Prevent diagnostics poll from overwriting unsaved URL edits - Redact credentials from Ollama URL in debug log output - Replace English placeholder translations in all 10 locale chunks (ar, bn, es, fr, hi, id, it, pt, ru, zh-CN) including new modelCount key --- .../settings/panels/LocalModelDebugPanel.tsx | 11 ++++++++--- .../panels/local-model/ModelStatusSection.tsx | 4 ++-- app/src/lib/i18n/chunks/ar-2.ts | 18 ++++++++++-------- app/src/lib/i18n/chunks/bn-2.ts | 17 +++++++++-------- app/src/lib/i18n/chunks/en-2.ts | 1 + app/src/lib/i18n/chunks/es-2.ts | 17 +++++++++-------- app/src/lib/i18n/chunks/fr-2.ts | 17 +++++++++-------- app/src/lib/i18n/chunks/hi-2.ts | 17 +++++++++-------- app/src/lib/i18n/chunks/id-2.ts | 17 +++++++++-------- app/src/lib/i18n/chunks/it-2.ts | 17 +++++++++-------- app/src/lib/i18n/chunks/pt-2.ts | 17 +++++++++-------- app/src/lib/i18n/chunks/ru-2.ts | 18 ++++++++++-------- app/src/lib/i18n/chunks/zh-CN-2.ts | 17 +++++++++-------- app/src/lib/i18n/en.ts | 1 + .../inference/local/service/public_infer.rs | 3 ++- 15 files changed, 106 insertions(+), 86 deletions(-) diff --git a/app/src/components/settings/panels/LocalModelDebugPanel.tsx b/app/src/components/settings/panels/LocalModelDebugPanel.tsx index c814c38553..68743bc64e 100644 --- a/app/src/components/settings/panels/LocalModelDebugPanel.tsx +++ b/app/src/components/settings/panels/LocalModelDebugPanel.tsx @@ -316,8 +316,10 @@ const LocalModelDebugPanel = () => { const result = await openhumanLocalAiDiagnostics(); setDiagnostics(result); if (result.ollama_base_url) { - setOllamaBaseUrlInput(result.ollama_base_url); - setSavedOllamaBaseUrl(result.ollama_base_url); + const reported = result.ollama_base_url; + // Only overwrite the input if the user hasn't made unsaved edits. + setOllamaBaseUrlInput(prev => (prev === savedOllamaBaseUrl ? reported : prev)); + setSavedOllamaBaseUrl(reported); } } catch (err) { setDiagnosticsError(err instanceof Error ? err.message : 'Diagnostics failed'); @@ -348,7 +350,10 @@ const LocalModelDebugPanel = () => { setIsSavingUrl(true); try { await openhumanUpdateLocalAiSettings({ base_url: ollamaBaseUrlInput }); - log('[local_ai:ui] saved ollama base_url=%s', ollamaBaseUrlInput); + log( + '[local_ai:ui] saved ollama base_url=%s', + ollamaBaseUrlInput.replace(/\/\/[^@]*@/, '//***@') + ); setSavedOllamaBaseUrl(ollamaBaseUrlInput); } catch (err) { setStatusError(err instanceof Error ? err.message : 'Failed to save URL'); diff --git a/app/src/components/settings/panels/local-model/ModelStatusSection.tsx b/app/src/components/settings/panels/local-model/ModelStatusSection.tsx index dd8cb12989..568e66302f 100644 --- a/app/src/components/settings/panels/local-model/ModelStatusSection.tsx +++ b/app/src/components/settings/panels/local-model/ModelStatusSection.tsx @@ -111,7 +111,7 @@ const ModelStatusSection = ({ const urlValidation = validateOllamaUrl(ollamaBaseUrlInput); const urlChanged = ollamaBaseUrlInput !== savedOllamaBaseUrl; const canSave = urlValidation.valid && urlChanged && !isSavingUrl; - const canTest = ollamaBaseUrlInput.trim().length > 0 && !isTestingConnection; + const canTest = urlValidation.valid && !isTestingConnection; if (showInstallOllamaCta) { return ( @@ -200,7 +200,7 @@ const ModelStatusSection = ({ {connectionTestResult.reachable ? '✓' : '✗'} {connectionTestResult.reachable - ? `${t('localModel.ollamaServer.reachable')}${typeof connectionTestResult.models_count === 'number' ? ` (${connectionTestResult.models_count} models)` : ''}` + ? `${t('localModel.ollamaServer.reachable')}${typeof connectionTestResult.models_count === 'number' ? ` (${connectionTestResult.models_count} ${t('localModel.ollamaServer.modelCount')})` : ''}` : `${t('localModel.ollamaServer.unreachable')}${connectionTestResult.error ? `: ${connectionTestResult.error}` : ''}`}

diff --git a/app/src/lib/i18n/chunks/ar-2.ts b/app/src/lib/i18n/chunks/ar-2.ts index 74954f0499..ab51c085d3 100644 --- a/app/src/lib/i18n/chunks/ar-2.ts +++ b/app/src/lib/i18n/chunks/ar-2.ts @@ -239,15 +239,17 @@ const ar2: TranslationMap = { 'مفتاح رئيسي. معطّل افتراضيًا — Ollama يبقى خاملاً. عند التشغيل، يستخدم ملخّص الشجرة وذكاء الشاشة والإكمال التلقائي النموذجَ المحلي دائمًا.', 'localModel.advancedSettings': 'إعدادات متقدمة', 'localModel.debugTitle': 'تصحيح النموذج المحلي', - 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', - 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.helperText': 'مثال: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'عنوان URL لخادم Ollama', + 'localModel.ollamaServer.modelCount': 'نماذج', 'localModel.ollamaServer.placeholder': 'http://localhost:11434', - 'localModel.ollamaServer.reachable': 'Reachable', - 'localModel.ollamaServer.resetButton': 'Reset to default', - 'localModel.ollamaServer.saveButton': 'Save', - 'localModel.ollamaServer.testButton': 'Test Connection', - 'localModel.ollamaServer.unreachable': 'Unreachable', - 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', + 'localModel.ollamaServer.reachable': 'قابل للوصول', + 'localModel.ollamaServer.resetButton': 'إعادة التعيين إلى الافتراضي', + 'localModel.ollamaServer.saveButton': 'حفظ', + 'localModel.ollamaServer.testButton': 'اختبار الاتصال', + 'localModel.ollamaServer.unreachable': 'غير قابل للوصول', + 'localModel.ollamaServer.validationError': + 'يجب أن يكون عنوان URL صالحاً يبدأ بـ http:// أو https://', 'screenAwareness.debugTitle': 'تصحيح وعي الشاشة', 'memory.debugTitle': 'تصحيح الذاكرة', 'webhooks.debugTitle': 'تصحيح الـ Webhooks', diff --git a/app/src/lib/i18n/chunks/bn-2.ts b/app/src/lib/i18n/chunks/bn-2.ts index af962d22d0..44354c3d0a 100644 --- a/app/src/lib/i18n/chunks/bn-2.ts +++ b/app/src/lib/i18n/chunks/bn-2.ts @@ -248,15 +248,16 @@ const bn2: TranslationMap = { 'মাস্টার সুইচ। ডিফল্টে বন্ধ — Ollama নিষ্ক্রিয় থাকে। চালু হলে, ট্রি সামারাইজার, স্ক্রিন ইন্টেলিজেন্স এবং অটোকমপ্লিট সর্বদা লোকাল মডেল ব্যবহার করে।', 'localModel.advancedSettings': 'অ্যাডভান্সড সেটিংস', 'localModel.debugTitle': 'লোকাল মডেল ডিবাগ', - 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', - 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.helperText': 'উদাহরণ: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'Ollama সার্ভার URL', + 'localModel.ollamaServer.modelCount': 'মডেল', 'localModel.ollamaServer.placeholder': 'http://localhost:11434', - 'localModel.ollamaServer.reachable': 'Reachable', - 'localModel.ollamaServer.resetButton': 'Reset to default', - 'localModel.ollamaServer.saveButton': 'Save', - 'localModel.ollamaServer.testButton': 'Test Connection', - 'localModel.ollamaServer.unreachable': 'Unreachable', - 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', + 'localModel.ollamaServer.reachable': 'পৌঁছানো যাচ্ছে', + 'localModel.ollamaServer.resetButton': 'ডিফল্টে পুনরায় সেট করুন', + 'localModel.ollamaServer.saveButton': 'সংরক্ষণ করুন', + 'localModel.ollamaServer.testButton': 'সংযোগ পরীক্ষা করুন', + 'localModel.ollamaServer.unreachable': 'পৌঁছানো যাচ্ছে না', + 'localModel.ollamaServer.validationError': 'একটি বৈধ http:// বা https:// URL হতে হবে', 'screenAwareness.debugTitle': 'স্ক্রিন সচেতনতা ডিবাগ', 'memory.debugTitle': 'মেমোরি ডিবাগ', 'webhooks.debugTitle': 'Webhooks ডিবাগ', diff --git a/app/src/lib/i18n/chunks/en-2.ts b/app/src/lib/i18n/chunks/en-2.ts index d9f4157f2a..a4b67f48b0 100644 --- a/app/src/lib/i18n/chunks/en-2.ts +++ b/app/src/lib/i18n/chunks/en-2.ts @@ -247,6 +247,7 @@ const en2: TranslationMap = { 'localModel.debugTitle': 'Local Model Debug', 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.modelCount': 'models', 'localModel.ollamaServer.placeholder': 'http://localhost:11434', 'localModel.ollamaServer.reachable': 'Reachable', 'localModel.ollamaServer.resetButton': 'Reset to default', diff --git a/app/src/lib/i18n/chunks/es-2.ts b/app/src/lib/i18n/chunks/es-2.ts index e304a762ce..2a5ac08476 100644 --- a/app/src/lib/i18n/chunks/es-2.ts +++ b/app/src/lib/i18n/chunks/es-2.ts @@ -251,15 +251,16 @@ const es2: TranslationMap = { 'Interruptor principal. Desactivado por defecto — Ollama permanece inactivo. Cuando está activado, el resumidor de árbol, la inteligencia de pantalla y el autocompletado siempre usan el modelo local.', 'localModel.advancedSettings': 'Configuración avanzada', 'localModel.debugTitle': 'Depuración de modelo local', - 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', - 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.helperText': 'Ejemplo: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'URL del servidor Ollama', + 'localModel.ollamaServer.modelCount': 'modelos', 'localModel.ollamaServer.placeholder': 'http://localhost:11434', - 'localModel.ollamaServer.reachable': 'Reachable', - 'localModel.ollamaServer.resetButton': 'Reset to default', - 'localModel.ollamaServer.saveButton': 'Save', - 'localModel.ollamaServer.testButton': 'Test Connection', - 'localModel.ollamaServer.unreachable': 'Unreachable', - 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', + 'localModel.ollamaServer.reachable': 'Accesible', + 'localModel.ollamaServer.resetButton': 'Restablecer al valor predeterminado', + 'localModel.ollamaServer.saveButton': 'Guardar', + 'localModel.ollamaServer.testButton': 'Probar conexión', + 'localModel.ollamaServer.unreachable': 'No accesible', + 'localModel.ollamaServer.validationError': 'Debe ser una URL http:// o https:// válida', 'screenAwareness.debugTitle': 'Depuración de Conciencia de pantalla', 'memory.debugTitle': 'Depuración de memoria', 'webhooks.debugTitle': 'Depuración de webhooks', diff --git a/app/src/lib/i18n/chunks/fr-2.ts b/app/src/lib/i18n/chunks/fr-2.ts index 541f22bd8a..1323ce7b41 100644 --- a/app/src/lib/i18n/chunks/fr-2.ts +++ b/app/src/lib/i18n/chunks/fr-2.ts @@ -253,15 +253,16 @@ const fr2: TranslationMap = { "Interrupteur principal. Désactivé par défaut — Ollama reste en veille. Quand activé, le résumeur d'arbre, l'intelligence d'écran et l'autocomplétion utilisent toujours le modèle local.", 'localModel.advancedSettings': 'Paramètres avancés', 'localModel.debugTitle': 'Débogage du modèle local', - 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', - 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.helperText': 'Exemple : http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'URL du serveur Ollama', + 'localModel.ollamaServer.modelCount': 'modèles', 'localModel.ollamaServer.placeholder': 'http://localhost:11434', - 'localModel.ollamaServer.reachable': 'Reachable', - 'localModel.ollamaServer.resetButton': 'Reset to default', - 'localModel.ollamaServer.saveButton': 'Save', - 'localModel.ollamaServer.testButton': 'Test Connection', - 'localModel.ollamaServer.unreachable': 'Unreachable', - 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', + 'localModel.ollamaServer.reachable': 'Accessible', + 'localModel.ollamaServer.resetButton': 'Réinitialiser par défaut', + 'localModel.ollamaServer.saveButton': 'Enregistrer', + 'localModel.ollamaServer.testButton': 'Tester la connexion', + 'localModel.ollamaServer.unreachable': 'Inaccessible', + 'localModel.ollamaServer.validationError': 'Doit être une URL http:// ou https:// valide', 'screenAwareness.debugTitle': "Débogage de la surveillance de l'écran", 'memory.debugTitle': 'Débogage de la mémoire', 'webhooks.debugTitle': 'Débogage des webhooks', diff --git a/app/src/lib/i18n/chunks/hi-2.ts b/app/src/lib/i18n/chunks/hi-2.ts index acac20cf15..6d5a821982 100644 --- a/app/src/lib/i18n/chunks/hi-2.ts +++ b/app/src/lib/i18n/chunks/hi-2.ts @@ -246,15 +246,16 @@ const hi2: TranslationMap = { 'मास्टर स्विच। डिफ़ॉल्ट रूप से बंद — Ollama आइडल रहता है। चालू होने पर tree summarizer, screen intelligence और autocomplete हमेशा लोकल मॉडल इस्तेमाल करते हैं।', 'localModel.advancedSettings': 'एडवांस्ड सेटिंग्स', 'localModel.debugTitle': 'लोकल मॉडल डिबग', - 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', - 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.helperText': 'उदाहरण: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'Ollama सर्वर URL', + 'localModel.ollamaServer.modelCount': 'मॉडल', 'localModel.ollamaServer.placeholder': 'http://localhost:11434', - 'localModel.ollamaServer.reachable': 'Reachable', - 'localModel.ollamaServer.resetButton': 'Reset to default', - 'localModel.ollamaServer.saveButton': 'Save', - 'localModel.ollamaServer.testButton': 'Test Connection', - 'localModel.ollamaServer.unreachable': 'Unreachable', - 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', + 'localModel.ollamaServer.reachable': 'पहुंचने योग्य', + 'localModel.ollamaServer.resetButton': 'डिफ़ॉल्ट पर रीसेट करें', + 'localModel.ollamaServer.saveButton': 'सहेजें', + 'localModel.ollamaServer.testButton': 'कनेक्शन जांचें', + 'localModel.ollamaServer.unreachable': 'पहुंचने योग्य नहीं', + 'localModel.ollamaServer.validationError': 'एक मान्य http:// या https:// URL होना चाहिए', 'screenAwareness.debugTitle': 'स्क्रीन अवेयरनेस डिबग', 'memory.debugTitle': 'मेमोरी डिबग', 'webhooks.debugTitle': 'Webhooks डिबग', diff --git a/app/src/lib/i18n/chunks/id-2.ts b/app/src/lib/i18n/chunks/id-2.ts index 72f3e5b984..090ed77c02 100644 --- a/app/src/lib/i18n/chunks/id-2.ts +++ b/app/src/lib/i18n/chunks/id-2.ts @@ -247,15 +247,16 @@ const id2: TranslationMap = { 'Sakelar utama. Nonaktif secara default; Ollama tetap idle. Saat aktif, peringkas tree, kecerdasan layar, dan autocomplete selalu memakai model lokal.', 'localModel.advancedSettings': 'Pengaturan lanjutan', 'localModel.debugTitle': 'Debug Model Lokal', - 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', - 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.helperText': 'Contoh: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'URL Server Ollama', + 'localModel.ollamaServer.modelCount': 'model', 'localModel.ollamaServer.placeholder': 'http://localhost:11434', - 'localModel.ollamaServer.reachable': 'Reachable', - 'localModel.ollamaServer.resetButton': 'Reset to default', - 'localModel.ollamaServer.saveButton': 'Save', - 'localModel.ollamaServer.testButton': 'Test Connection', - 'localModel.ollamaServer.unreachable': 'Unreachable', - 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', + 'localModel.ollamaServer.reachable': 'Dapat Dijangkau', + 'localModel.ollamaServer.resetButton': 'Setel Ulang ke Default', + 'localModel.ollamaServer.saveButton': 'Simpan', + 'localModel.ollamaServer.testButton': 'Uji Koneksi', + 'localModel.ollamaServer.unreachable': 'Tidak Dapat Dijangkau', + 'localModel.ollamaServer.validationError': 'Harus berupa URL http:// atau https:// yang valid', 'screenAwareness.debugTitle': 'Debug Kesadaran Layar', 'memory.debugTitle': 'Debug Memori', 'webhooks.debugTitle': 'Debug Webhook', diff --git a/app/src/lib/i18n/chunks/it-2.ts b/app/src/lib/i18n/chunks/it-2.ts index 2d436497ee..a4a6a1727a 100644 --- a/app/src/lib/i18n/chunks/it-2.ts +++ b/app/src/lib/i18n/chunks/it-2.ts @@ -248,15 +248,16 @@ const it2: TranslationMap = { "Interruttore principale. Disattivato di default — Ollama resta inattivo. Quando attivo, il tree summarizer, lo screen intelligence e l'autocompletamento usano sempre il modello locale.", 'localModel.advancedSettings': 'Impostazioni avanzate', 'localModel.debugTitle': 'Debug modello locale', - 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', - 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.helperText': 'Esempio: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'URL del server Ollama', + 'localModel.ollamaServer.modelCount': 'modelli', 'localModel.ollamaServer.placeholder': 'http://localhost:11434', - 'localModel.ollamaServer.reachable': 'Reachable', - 'localModel.ollamaServer.resetButton': 'Reset to default', - 'localModel.ollamaServer.saveButton': 'Save', - 'localModel.ollamaServer.testButton': 'Test Connection', - 'localModel.ollamaServer.unreachable': 'Unreachable', - 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', + 'localModel.ollamaServer.reachable': 'Raggiungibile', + 'localModel.ollamaServer.resetButton': 'Ripristina predefinito', + 'localModel.ollamaServer.saveButton': 'Salva', + 'localModel.ollamaServer.testButton': 'Testa connessione', + 'localModel.ollamaServer.unreachable': 'Non raggiungibile', + 'localModel.ollamaServer.validationError': 'Deve essere un URL http:// o https:// valido', 'screenAwareness.debugTitle': 'Debug consapevolezza schermo', 'memory.debugTitle': 'Debug memoria', 'webhooks.debugTitle': 'Debug webhook', diff --git a/app/src/lib/i18n/chunks/pt-2.ts b/app/src/lib/i18n/chunks/pt-2.ts index 7bc3652fb8..56d179fa20 100644 --- a/app/src/lib/i18n/chunks/pt-2.ts +++ b/app/src/lib/i18n/chunks/pt-2.ts @@ -251,15 +251,16 @@ const pt2: TranslationMap = { 'Chave mestre. Desligado por padrão — Ollama fica inativo. Quando ligado, o sumarizador de árvore, inteligência de tela e autocompletar sempre usam o modelo local.', 'localModel.advancedSettings': 'Configurações avançadas', 'localModel.debugTitle': 'Depuração de Modelo Local', - 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', - 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.helperText': 'Exemplo: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'URL do servidor Ollama', + 'localModel.ollamaServer.modelCount': 'modelos', 'localModel.ollamaServer.placeholder': 'http://localhost:11434', - 'localModel.ollamaServer.reachable': 'Reachable', - 'localModel.ollamaServer.resetButton': 'Reset to default', - 'localModel.ollamaServer.saveButton': 'Save', - 'localModel.ollamaServer.testButton': 'Test Connection', - 'localModel.ollamaServer.unreachable': 'Unreachable', - 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', + 'localModel.ollamaServer.reachable': 'Acessível', + 'localModel.ollamaServer.resetButton': 'Redefinir para padrão', + 'localModel.ollamaServer.saveButton': 'Salvar', + 'localModel.ollamaServer.testButton': 'Testar conexão', + 'localModel.ollamaServer.unreachable': 'Inacessível', + 'localModel.ollamaServer.validationError': 'Deve ser uma URL http:// ou https:// válida', 'screenAwareness.debugTitle': 'Depuração de Reconhecimento de Tela', 'memory.debugTitle': 'Depuração de Memória', 'webhooks.debugTitle': 'Depuração de Webhooks', diff --git a/app/src/lib/i18n/chunks/ru-2.ts b/app/src/lib/i18n/chunks/ru-2.ts index d0454e4c51..7917cf183a 100644 --- a/app/src/lib/i18n/chunks/ru-2.ts +++ b/app/src/lib/i18n/chunks/ru-2.ts @@ -246,15 +246,17 @@ const ru2: TranslationMap = { 'Главный переключатель. По умолчанию выключен — Ollama простаивает. При включении суммаризатор деревьев, интеллект экрана и автодополнение всегда используют локальную модель.', 'localModel.advancedSettings': 'Дополнительные настройки', 'localModel.debugTitle': 'Отладка локальной модели', - 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', - 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.helperText': 'Пример: http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'URL сервера Ollama', + 'localModel.ollamaServer.modelCount': 'моделей', 'localModel.ollamaServer.placeholder': 'http://localhost:11434', - 'localModel.ollamaServer.reachable': 'Reachable', - 'localModel.ollamaServer.resetButton': 'Reset to default', - 'localModel.ollamaServer.saveButton': 'Save', - 'localModel.ollamaServer.testButton': 'Test Connection', - 'localModel.ollamaServer.unreachable': 'Unreachable', - 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', + 'localModel.ollamaServer.reachable': 'Доступен', + 'localModel.ollamaServer.resetButton': 'Сбросить до значения по умолчанию', + 'localModel.ollamaServer.saveButton': 'Сохранить', + 'localModel.ollamaServer.testButton': 'Проверить соединение', + 'localModel.ollamaServer.unreachable': 'Недоступен', + 'localModel.ollamaServer.validationError': + 'Должен быть допустимым URL с протоколом http:// или https://', 'screenAwareness.debugTitle': 'Отладка слежения за экраном', 'memory.debugTitle': 'Отладка памяти', 'webhooks.debugTitle': 'Отладка вебхуков', diff --git a/app/src/lib/i18n/chunks/zh-CN-2.ts b/app/src/lib/i18n/chunks/zh-CN-2.ts index ee06972a2b..62707c9847 100644 --- a/app/src/lib/i18n/chunks/zh-CN-2.ts +++ b/app/src/lib/i18n/chunks/zh-CN-2.ts @@ -230,15 +230,16 @@ const zhCN2: TranslationMap = { '总开关。默认关闭——Ollama 保持空闲。启用后,树摘要器、屏幕智能和自动补全始终使用本地模型。', 'localModel.advancedSettings': '高级设置', 'localModel.debugTitle': '本地模型调试', - 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', - 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.helperText': '示例:http://192.168.1.5:11434', + 'localModel.ollamaServer.label': 'Ollama 服务器 URL', + 'localModel.ollamaServer.modelCount': '个模型', 'localModel.ollamaServer.placeholder': 'http://localhost:11434', - 'localModel.ollamaServer.reachable': 'Reachable', - 'localModel.ollamaServer.resetButton': 'Reset to default', - 'localModel.ollamaServer.saveButton': 'Save', - 'localModel.ollamaServer.testButton': 'Test Connection', - 'localModel.ollamaServer.unreachable': 'Unreachable', - 'localModel.ollamaServer.validationError': 'Must be a valid http:// or https:// URL', + 'localModel.ollamaServer.reachable': '可访问', + 'localModel.ollamaServer.resetButton': '重置为默认值', + 'localModel.ollamaServer.saveButton': '保存', + 'localModel.ollamaServer.testButton': '测试连接', + 'localModel.ollamaServer.unreachable': '无法访问', + 'localModel.ollamaServer.validationError': '必须是有效的 http:// 或 https:// URL', 'screenAwareness.debugTitle': '屏幕感知调试', 'memory.debugTitle': '记忆调试', 'webhooks.debugTitle': 'Webhook 调试', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index b30d56031c..29853cd292 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1803,6 +1803,7 @@ const en: TranslationMap = { 'settings.localModel.status.ollamaBinaryPath': 'Ollama binary path', 'localModel.ollamaServer.helperText': 'Example: http://192.168.1.5:11434', 'localModel.ollamaServer.label': 'Ollama Server URL', + 'localModel.ollamaServer.modelCount': 'models', 'localModel.ollamaServer.placeholder': 'http://localhost:11434', 'localModel.ollamaServer.reachable': 'Reachable', 'localModel.ollamaServer.resetButton': 'Reset to default', diff --git a/src/openhuman/inference/local/service/public_infer.rs b/src/openhuman/inference/local/service/public_infer.rs index 4ed510e7e3..0485122779 100644 --- a/src/openhuman/inference/local/service/public_infer.rs +++ b/src/openhuman/inference/local/service/public_infer.rs @@ -565,7 +565,8 @@ impl LocalAiService { let base_url = ollama_base_url_from_config(config); log::debug!( - "[local_ai:infer] inference_with_temperature_internal: using base_url={base_url}" + "[local_ai:infer] inference_with_temperature_internal: using base_url={}", + redact_ollama_base_url(&base_url) ); let response = self .http From 0a8bf1d909c39ded7b35fd18e441407297ecc65b Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 19 May 2026 18:46:15 +0530 Subject: [PATCH 07/10] test(local-ai): add coverage for Ollama URL panel, connection test, and validation Covers diff-cover missing lines: - LocalModelDebugPanel: config seed on mount, handleTestConnection (success + throw paths), handleSaveOllamaBaseUrl, handleResetOllamaBaseUrl - ModelStatusSection: onChange input, spinner when testing, reachable/unreachable display, validation error rendering - ollamaUrlValidation: unparseable URL format error (catch branch) - tauriCommands/localAi: openhumanLocalAiTestConnection RPC dispatch --- .../__tests__/LocalModelDebugPanel.test.tsx | 157 ++++++++++++++++++ .../local-model/ModelStatusSection.test.tsx | 51 +++++- app/src/utils/ollamaUrlValidation.test.ts | 6 + app/src/utils/tauriCommands/localAi.test.ts | 35 ++++ 4 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 app/src/components/settings/panels/__tests__/LocalModelDebugPanel.test.tsx create mode 100644 app/src/utils/tauriCommands/localAi.test.ts diff --git a/app/src/components/settings/panels/__tests__/LocalModelDebugPanel.test.tsx b/app/src/components/settings/panels/__tests__/LocalModelDebugPanel.test.tsx new file mode 100644 index 0000000000..9caf6d2a5c --- /dev/null +++ b/app/src/components/settings/panels/__tests__/LocalModelDebugPanel.test.tsx @@ -0,0 +1,157 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import LocalModelDebugPanel from '../LocalModelDebugPanel'; + +const { mockNavigateBack } = vi.hoisted(() => ({ mockNavigateBack: vi.fn() })); + +vi.mock('../../hooks/useSettingsNavigation', () => ({ + useSettingsNavigation: () => ({ navigateBack: mockNavigateBack, breadcrumbs: [] }), +})); + +const mockGetConfig = vi.fn(); +vi.mock('../../../../utils/tauriCommands/config', () => ({ + openhumanGetConfig: (...args: unknown[]) => mockGetConfig(...args), +})); + +const mockLocalAiStatus = vi.fn(); +const mockLocalAiAssetsStatus = vi.fn(); +const mockLocalAiDownloadsProgress = vi.fn(); +const mockLocalAiTestConnection = vi.fn(); +const mockUpdateLocalAiSettings = vi.fn(); +const mockLocalAiDiagnostics = vi.fn(); + +vi.mock('../../../../utils/tauriCommands', () => ({ + openhumanLocalAiStatus: (...args: unknown[]) => mockLocalAiStatus(...args), + openhumanLocalAiAssetsStatus: (...args: unknown[]) => mockLocalAiAssetsStatus(...args), + openhumanLocalAiDownloadsProgress: (...args: unknown[]) => mockLocalAiDownloadsProgress(...args), + openhumanLocalAiTestConnection: (...args: unknown[]) => mockLocalAiTestConnection(...args), + openhumanUpdateLocalAiSettings: (...args: unknown[]) => mockUpdateLocalAiSettings(...args), + openhumanLocalAiDiagnostics: (...args: unknown[]) => mockLocalAiDiagnostics(...args), + openhumanLocalAiSummarize: vi.fn().mockResolvedValue({ result: '' }), + openhumanLocalAiPrompt: vi.fn().mockResolvedValue({ result: '' }), + openhumanLocalAiEmbed: vi.fn().mockResolvedValue({ result: [] }), + openhumanLocalAiVisionPrompt: vi.fn().mockResolvedValue({ result: '' }), + openhumanLocalAiTranscribe: vi.fn().mockResolvedValue({ result: '' }), + openhumanLocalAiTts: vi.fn().mockResolvedValue({ result: '' }), + openhumanLocalAiDownloadAsset: vi.fn().mockResolvedValue({ result: null }), +})); + +function renderPanel() { + return render( + + + + ); +} + +describe('LocalModelDebugPanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLocalAiStatus.mockResolvedValue({ result: null }); + mockLocalAiAssetsStatus.mockResolvedValue({ result: null }); + mockLocalAiDownloadsProgress.mockResolvedValue({ result: null }); + mockGetConfig.mockResolvedValue({ result: { config: {} } }); + mockLocalAiDiagnostics.mockResolvedValue({ + ok: true, + ollama_running: false, + ollama_base_url: null, + ollama_binary_path: null, + installed_models: [], + expected: { + chat_model: '', + chat_found: false, + embedding_model: '', + embedding_found: false, + vision_model: '', + vision_found: false, + }, + issues: [], + repair_actions: [], + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('renders the Ollama Server URL section with default URL', () => { + renderPanel(); + const input = screen.getByPlaceholderText('http://localhost:11434') as HTMLInputElement; + expect(input.value).toBe('http://localhost:11434'); + }); + + it('seeds the URL input from config on mount', async () => { + mockGetConfig.mockResolvedValue({ + result: { config: { local_ai: { base_url: 'http://192.168.1.5:11434' } } }, + }); + renderPanel(); + await waitFor(() => { + const input = screen.getByPlaceholderText('http://localhost:11434') as HTMLInputElement; + expect(input.value).toBe('http://192.168.1.5:11434'); + }); + }); + + it('keeps the default URL when config returns no base_url', async () => { + mockGetConfig.mockResolvedValue({ result: { config: { local_ai: {} } } }); + renderPanel(); + await waitFor(() => { + const input = screen.getByPlaceholderText('http://localhost:11434') as HTMLInputElement; + expect(input.value).toBe('http://localhost:11434'); + }); + expect(mockGetConfig).toHaveBeenCalledTimes(1); + }); + + it('calls openhumanLocalAiTestConnection when Test Connection is clicked', async () => { + mockLocalAiTestConnection.mockResolvedValue({ reachable: true, models_count: 2 }); + renderPanel(); + const testBtn = screen.getByRole('button', { name: /Test Connection/i }); + fireEvent.click(testBtn); + await waitFor(() => { + expect(mockLocalAiTestConnection).toHaveBeenCalledWith('http://localhost:11434'); + }); + }); + + it('shows reachable result after a successful connection test', async () => { + mockLocalAiTestConnection.mockResolvedValue({ reachable: true, models_count: 5 }); + renderPanel(); + fireEvent.click(screen.getByRole('button', { name: /Test Connection/i })); + await waitFor(() => expect(screen.getByText(/Reachable/)).toBeTruthy()); + expect(screen.getByText(/5 models/)).toBeTruthy(); + }); + + it('shows unreachable result when connection test throws', async () => { + mockLocalAiTestConnection.mockRejectedValue(new Error('connect ECONNREFUSED')); + renderPanel(); + fireEvent.click(screen.getByRole('button', { name: /Test Connection/i })); + await waitFor(() => expect(screen.getByText(/connect ECONNREFUSED/)).toBeTruthy()); + }); + + it('saves the URL when Save is clicked after changing the input', async () => { + mockUpdateLocalAiSettings.mockResolvedValue({ result: true }); + renderPanel(); + const urlInput = screen.getByPlaceholderText('http://localhost:11434'); + fireEvent.change(urlInput, { target: { value: 'http://192.168.1.5:11434' } }); + const saveBtn = await screen.findByRole('button', { name: 'Save' }); + expect((saveBtn as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(saveBtn); + await waitFor(() => { + expect(mockUpdateLocalAiSettings).toHaveBeenCalledWith({ + base_url: 'http://192.168.1.5:11434', + }); + }); + }); + + it('resets the URL to default when Reset to default is clicked', async () => { + mockUpdateLocalAiSettings.mockResolvedValue({ result: true }); + renderPanel(); + const resetBtn = screen.getByRole('button', { name: /Reset to default/i }); + fireEvent.click(resetBtn); + await waitFor(() => { + expect(mockUpdateLocalAiSettings).toHaveBeenCalledWith({ base_url: null }); + }); + const urlInput = screen.getByPlaceholderText('http://localhost:11434') as HTMLInputElement; + expect(urlInput.value).toBe('http://localhost:11434'); + }); +}); diff --git a/app/src/components/settings/panels/local-model/ModelStatusSection.test.tsx b/app/src/components/settings/panels/local-model/ModelStatusSection.test.tsx index 0c2d3e3f6b..5a3255cc64 100644 --- a/app/src/components/settings/panels/local-model/ModelStatusSection.test.tsx +++ b/app/src/components/settings/panels/local-model/ModelStatusSection.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { describe, expect, it, vi } from 'vitest'; import type { LocalAiDiagnostics } from '../../../../utils/tauriCommands'; @@ -425,4 +425,53 @@ describe('ModelStatusSection — Ollama server URL', () => { resetBtn.click(); expect(onResetOllamaBaseUrl).toHaveBeenCalledTimes(1); }); + + it('calls onSetOllamaBaseUrlInput when the URL input changes', () => { + const onSetOllamaBaseUrlInput = vi.fn(); + render( + + ); + const input = screen.getByPlaceholderText('http://localhost:11434'); + fireEvent.change(input, { target: { value: 'http://192.168.1.5:11434' } }); + expect(onSetOllamaBaseUrlInput).toHaveBeenCalledWith('http://192.168.1.5:11434'); + }); + + it('shows spinner when isTestingConnection is true', () => { + render(); + const testBtn = screen.getByRole('button', { name: /Test Connection/i }); + expect(testBtn.querySelector('.animate-spin')).toBeTruthy(); + }); + + it('shows reachable result with model count when models_count is a number', () => { + render( + + ); + expect(screen.getByText(/Reachable/)).toBeTruthy(); + expect(screen.getByText(/7 models/)).toBeTruthy(); + }); + + it('shows unreachable result with error text when reachable is false', () => { + render( + + ); + expect(screen.getByText(/Unreachable/)).toBeTruthy(); + expect(screen.getByText(/dial tcp refused/)).toBeTruthy(); + }); + + it('shows validation error message for an invalid URL', () => { + render( + + ); + expect(screen.getByText(/http:\/\/ or https:\/\//i)).toBeTruthy(); + }); }); diff --git a/app/src/utils/ollamaUrlValidation.test.ts b/app/src/utils/ollamaUrlValidation.test.ts index ba2a25d95e..adaae276aa 100644 --- a/app/src/utils/ollamaUrlValidation.test.ts +++ b/app/src/utils/ollamaUrlValidation.test.ts @@ -70,4 +70,10 @@ describe('validateOllamaUrl', () => { expect(result.valid).toBe(true); expect(result.normalized).toBe('https://example.com'); }); + + it('rejects a URL that starts with http:// but is not parseable', () => { + const result = validateOllamaUrl('http:// has spaces'); + expect(result.valid).toBe(false); + expect(result.error).toMatch(/invalid url format/i); + }); }); diff --git a/app/src/utils/tauriCommands/localAi.test.ts b/app/src/utils/tauriCommands/localAi.test.ts new file mode 100644 index 0000000000..dccb8816c5 --- /dev/null +++ b/app/src/utils/tauriCommands/localAi.test.ts @@ -0,0 +1,35 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() })); + +describe('openhumanLocalAiTestConnection', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('calls callCoreRpc with local_ai_test_connection method and url param', async () => { + const { callCoreRpc } = await import('../../services/coreRpcClient'); + const mockCallCoreRpc = callCoreRpc as ReturnType; + mockCallCoreRpc.mockResolvedValueOnce({ reachable: true, models_count: 4 }); + + const { openhumanLocalAiTestConnection } = await import('./localAi'); + const result = await openhumanLocalAiTestConnection('http://localhost:11434'); + + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.local_ai_test_connection', + params: { url: 'http://localhost:11434' }, + }); + expect(result).toEqual({ reachable: true, models_count: 4 }); + }); + + it('propagates errors from callCoreRpc', async () => { + const { callCoreRpc } = await import('../../services/coreRpcClient'); + const mockCallCoreRpc = callCoreRpc as ReturnType; + mockCallCoreRpc.mockRejectedValueOnce(new Error('rpc down')); + + const { openhumanLocalAiTestConnection } = await import('./localAi'); + await expect(openhumanLocalAiTestConnection('http://localhost:11434')).rejects.toThrow( + 'rpc down' + ); + }); +}); From 898d7e93a83411d6f79cfe71cc35e3d0b5ca2eca Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 19 May 2026 22:12:23 +0530 Subject: [PATCH 08/10] fix(inference): redact credential-bearing Ollama URLs before debug logging Move redact_ollama_base_url to ollama.rs (pub(crate)) so it can be shared by vision_embed.rs and public_infer.rs. Apply it at the two new log sites flagged by graycyrus: - ollama_base_url_from_config: log redacted form of config base_url - vision_embed embed(): redact embed_base before debug print Tests for the redaction helper move to ollama.rs where the function now lives. --- src/openhuman/inference/local/ollama.rs | 40 ++++++++++++++++- .../inference/local/service/public_infer.rs | 44 +------------------ .../inference/local/service/vision_embed.rs | 9 ++-- 3 files changed, 47 insertions(+), 46 deletions(-) diff --git a/src/openhuman/inference/local/ollama.rs b/src/openhuman/inference/local/ollama.rs index f6e39733df..c26a0f642e 100644 --- a/src/openhuman/inference/local/ollama.rs +++ b/src/openhuman/inference/local/ollama.rs @@ -48,7 +48,8 @@ pub(crate) fn ollama_base_url_from_config(config: &crate::openhuman::config::Con let trimmed = url.trim().trim_end_matches('/'); if !trimmed.is_empty() { log::debug!( - "[local_ai] ollama_base_url_from_config: using config base_url -> {trimmed}" + "[local_ai] ollama_base_url_from_config: using config base_url -> {}", + redact_ollama_base_url(trimmed) ); return trimmed.to_string(); } @@ -106,6 +107,20 @@ pub(crate) fn validate_ollama_url(raw: &str) -> Result { Ok(normalized) } +/// Strips userinfo, query, and fragment from `raw` so logs and error messages +/// don't leak `user:pass@host`-style credentials embedded in the endpoint. +pub(crate) fn redact_ollama_base_url(raw: &str) -> String { + reqwest::Url::parse(raw) + .map(|mut url| { + let _ = url.set_username(""); + let _ = url.set_password(None); + url.set_query(None); + url.set_fragment(None); + url.to_string() + }) + .unwrap_or_else(|_| "".to_string()) +} + /// Back-compat constant kept at its original value for callers that /// reference it directly. New callers should use [`ollama_base_url`]. pub(crate) const OLLAMA_BASE_URL: &str = DEFAULT_OLLAMA_BASE_URL; @@ -576,4 +591,27 @@ mod tests { assert!(validate_ollama_url("").is_err()); assert!(validate_ollama_url(" ").is_err()); } + + // ── redact_ollama_base_url ──────────────────────────────────────── + + #[test] + fn redact_strips_userinfo_query_and_fragment() { + assert_eq!( + redact_ollama_base_url("http://user:pass@host:11434/api?token=abc#frag"), + "http://host:11434/api" + ); + } + + #[test] + fn redact_keeps_plain_url() { + assert_eq!( + redact_ollama_base_url("http://127.0.0.1:11434/"), + "http://127.0.0.1:11434/" + ); + } + + #[test] + fn redact_handles_invalid_url() { + assert_eq!(redact_ollama_base_url("not a url"), ""); + } } diff --git a/src/openhuman/inference/local/service/public_infer.rs b/src/openhuman/inference/local/service/public_infer.rs index 0485122779..3aea0c46b1 100644 --- a/src/openhuman/inference/local/service/public_infer.rs +++ b/src/openhuman/inference/local/service/public_infer.rs @@ -1,7 +1,7 @@ use crate::openhuman::config::Config; use crate::openhuman::inference::local::ollama::{ - ns_to_tps, ollama_base_url, ollama_base_url_from_config, OllamaGenerateOptions, - OllamaGenerateRequest, + ns_to_tps, ollama_base_url, ollama_base_url_from_config, redact_ollama_base_url, + OllamaGenerateOptions, OllamaGenerateRequest, }; use crate::openhuman::inference::local::provider::{provider_from_config, LocalAiProvider}; use crate::openhuman::inference::model_ids; @@ -9,20 +9,6 @@ use crate::openhuman::inference::parse::sanitize_inline_completion; use super::LocalAiService; -fn redact_ollama_base_url(raw: &str) -> String { - // Strip userinfo, query, and fragment so error payloads + logs don't - // leak `user:pass@host` style credentials embedded in the endpoint. - reqwest::Url::parse(raw) - .map(|mut url| { - let _ = url.set_username(""); - let _ = url.set_password(None); - url.set_query(None); - url.set_fragment(None); - url.to_string() - }) - .unwrap_or_else(|_| "".to_string()) -} - fn external_ollama_request_error_with_url( prefix: &str, error: &reqwest::Error, @@ -39,32 +25,6 @@ fn external_ollama_request_error(prefix: &str, error: &reqwest::Error) -> String external_ollama_request_error_with_url(prefix, error, &ollama_base_url()) } -#[cfg(test)] -mod redact_tests { - use super::redact_ollama_base_url; - - #[test] - fn redact_strips_userinfo_query_and_fragment() { - assert_eq!( - redact_ollama_base_url("http://user:pass@host:11434/api?token=abc#frag"), - "http://host:11434/api" - ); - } - - #[test] - fn redact_keeps_plain_url() { - assert_eq!( - redact_ollama_base_url("http://127.0.0.1:11434/"), - "http://127.0.0.1:11434/" - ); - } - - #[test] - fn redact_handles_invalid_url() { - assert_eq!(redact_ollama_base_url("not a url"), ""); - } -} - impl LocalAiService { pub async fn summarize( &self, diff --git a/src/openhuman/inference/local/service/vision_embed.rs b/src/openhuman/inference/local/service/vision_embed.rs index c0c7056c50..724b5fb665 100644 --- a/src/openhuman/inference/local/service/vision_embed.rs +++ b/src/openhuman/inference/local/service/vision_embed.rs @@ -1,8 +1,8 @@ use crate::openhuman::agent::multimodal; use crate::openhuman::config::Config; use crate::openhuman::inference::local::ollama::{ - ollama_base_url_from_config, OllamaEmbedRequest, OllamaEmbedResponse, OllamaGenerateOptions, - OllamaGenerateRequest, + ollama_base_url_from_config, redact_ollama_base_url, OllamaEmbedRequest, OllamaEmbedResponse, + OllamaGenerateOptions, OllamaGenerateRequest, }; use crate::openhuman::inference::model_ids; use crate::openhuman::inference::presets::{self, VisionMode}; @@ -157,7 +157,10 @@ impl LocalAiService { let _gate_permit = crate::openhuman::scheduler_gate::wait_for_capacity().await; let embed_base = ollama_base_url_from_config(config); - log::debug!("[local_ai:embed] embed: using base_url={embed_base}"); + log::debug!( + "[local_ai:embed] embed: using base_url={}", + redact_ollama_base_url(&embed_base) + ); let response = self .http .post(format!("{embed_base}/api/embed")) From 3618449e5dc8cc4cebb448026ab56ca10e8d0307 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 19 May 2026 22:21:25 +0530 Subject: [PATCH 09/10] fix(inference): redact fallback Ollama URL log and fix IPv6 normalization - Apply redact_ollama_base_url to the fallback resolved-URL log in ollama_base_url_from_config for consistent credential safety - Fix validate_ollama_url to re-add IPv6 brackets stripped by host_str() so http://[::1]:11434 normalizes correctly instead of producing invalid http://::1:11434 syntax - Add validate_ollama_url_handles_ipv6 test --- src/openhuman/inference/local/ollama.rs | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/openhuman/inference/local/ollama.rs b/src/openhuman/inference/local/ollama.rs index c26a0f642e..d75dedbed2 100644 --- a/src/openhuman/inference/local/ollama.rs +++ b/src/openhuman/inference/local/ollama.rs @@ -56,7 +56,8 @@ pub(crate) fn ollama_base_url_from_config(config: &crate::openhuman::config::Con } let resolved = ollama_base_url(); log::debug!( - "[local_ai] ollama_base_url_from_config: config base_url absent, resolved -> {resolved}" + "[local_ai] ollama_base_url_from_config: config base_url absent, resolved -> {}", + redact_ollama_base_url(&resolved) ); resolved } @@ -97,7 +98,14 @@ pub(crate) fn validate_ollama_url(raw: &str) -> Result { } // Normalize to scheme://host[:port] — strip any path component. - let mut normalized = format!("{}://{}", parsed.scheme(), parsed.host_str().unwrap_or("")); + // host_str() strips IPv6 brackets (returns "::1" not "[::1]"), so re-add them. + let host = parsed.host_str().unwrap_or(""); + let host_formatted = if host.contains(':') { + format!("[{host}]") + } else { + host.to_string() + }; + let mut normalized = format!("{}://{}", parsed.scheme(), host_formatted); if let Some(port) = parsed.port() { normalized.push(':'); normalized.push_str(&port.to_string()); @@ -592,6 +600,14 @@ mod tests { assert!(validate_ollama_url(" ").is_err()); } + #[test] + fn validate_ollama_url_handles_ipv6() { + assert_eq!( + validate_ollama_url("http://[::1]:11434"), + Ok("http://[::1]:11434".to_string()) + ); + } + // ── redact_ollama_base_url ──────────────────────────────────────── #[test] From 24469d6f5d44e19fa1dd377e22f06aa39db36766 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Tue, 19 May 2026 23:04:34 +0530 Subject: [PATCH 10/10] fix(inference): use url::Host enum for IPv6 normalization to avoid double-bracketing host_str() behaviour differs across url-crate versions: some return "::1", others return "[::1]". Using parsed.host() with the Host enum variant match is version-agnostic: Ipv6(addr) always yields the raw address so we wrap it in brackets exactly once. --- src/openhuman/inference/local/ollama.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/openhuman/inference/local/ollama.rs b/src/openhuman/inference/local/ollama.rs index db029615ea..be297fb36a 100644 --- a/src/openhuman/inference/local/ollama.rs +++ b/src/openhuman/inference/local/ollama.rs @@ -98,12 +98,12 @@ pub(crate) fn validate_ollama_url(raw: &str) -> Result { } // Normalize to scheme://host[:port] — strip any path component. - // host_str() strips IPv6 brackets (returns "::1" not "[::1]"), so re-add them. - let host = parsed.host_str().unwrap_or(""); - let host_formatted = if host.contains(':') { - format!("[{host}]") - } else { - host.to_string() + // Use the Host enum so IPv6 addresses are always re-bracketed correctly, + // regardless of whether host_str() includes brackets in a given url-crate version. + let host_formatted = match parsed.host() { + Some(url::Host::Ipv6(addr)) => format!("[{addr}]"), + Some(h) => h.to_string(), + None => String::new(), }; let mut normalized = format!("{}://{}", parsed.scheme(), host_formatted); if let Some(port) = parsed.port() {