use std::collections::HashSet; use std::fs; use std::path::{Path, PathBuf}; use std::time::UNIX_EPOCH; use crate::engine::doctor::resolve_engine_path; use crate::engine::manager::EngineManager; use crate::opencode_router::manager::OpenCodeRouterManager; use crate::openwork_server::manager::OpenworkServerManager; use crate::orchestrator; use crate::orchestrator::manager::OrchestratorManager; use crate::paths::home_dir; use crate::platform::command_for_program; use crate::types::{ExecResult, WorkspaceOpenworkConfig}; use crate::workspace::state::load_workspace_state; use tauri::{AppHandle, Manager, State}; fn pinned_opencode_install_command() -> String { let constants = include_str!(concat!( env!("CARGO_MANIFEST_DIR"), "/../../../constants.json" )); let parsed: serde_json::Value = serde_json::from_str(constants).expect("constants.json must be valid JSON"); let version = parsed["opencodeVersion"] .as_str() .expect("constants.json must include opencodeVersion") .trim() .trim_start_matches('v'); format!( "curl -fsSL https://opencode.ai/install | bash -s -- --version {} --no-modify-path", version ) } #[derive(serde::Serialize)] pub struct CacheResetResult { pub removed: Vec, pub missing: Vec, pub errors: Vec, } #[derive(serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct AppBuildInfo { pub version: String, pub git_sha: Option, pub build_epoch: Option, pub openwork_dev_mode: bool, } fn env_truthy(key: &str) -> bool { matches!( std::env::var(key) .ok() .map(|value| value.trim().to_ascii_lowercase()), Some(value) if value == "1" || value == "true" || value == "yes" || value == "on" ) } fn opencode_cache_candidates() -> Vec { let mut candidates: Vec = Vec::new(); if let Ok(value) = std::env::var("XDG_CACHE_HOME") { let trimmed = value.trim(); if !trimmed.is_empty() { candidates.push(PathBuf::from(trimmed).join("opencode")); } } if let Some(home) = home_dir() { candidates.push(home.join(".cache").join("opencode")); #[cfg(target_os = "macos")] { candidates.push(home.join("Library").join("Caches").join("opencode")); } } #[cfg(windows)] { if let Ok(value) = std::env::var("LOCALAPPDATA") { let trimmed = value.trim(); if !trimmed.is_empty() { candidates.push(PathBuf::from(trimmed).join("opencode")); } } if let Ok(value) = std::env::var("APPDATA") { let trimmed = value.trim(); if !trimmed.is_empty() { candidates.push(PathBuf::from(trimmed).join("opencode")); } } } let mut seen = HashSet::new(); candidates .into_iter() .filter(|path| seen.insert(path.to_string_lossy().to_string())) .collect() } fn stop_host_services( engine_manager: &State, orchestrator_manager: &State, openwork_manager: &State, opencode_router_manager: &State, ) { if let Ok(mut engine) = engine_manager.inner.lock() { EngineManager::stop_locked(&mut engine); } if let Ok(mut orchestrator_state) = orchestrator_manager.inner.lock() { OrchestratorManager::stop_locked(&mut orchestrator_state); } if let Ok(mut openwork_state) = openwork_manager.inner.lock() { OpenworkServerManager::stop_locked(&mut openwork_state); } if let Ok(mut opencode_router_state) = opencode_router_manager.inner.lock() { OpenCodeRouterManager::stop_locked(&mut opencode_router_state); } } fn remove_path_if_exists(path: &Path) -> Result<(), String> { if !path.exists() { return Ok(()); } if path.is_dir() { fs::remove_dir_all(path) .map_err(|e| format!("Failed to remove directory {}: {e}", path.display())) } else { fs::remove_file(path).map_err(|e| format!("Failed to remove file {}: {e}", path.display())) } } fn validate_server_name(name: &str) -> Result { let trimmed = name.trim(); if trimmed.is_empty() { return Err("server_name is required".to_string()); } if trimmed.starts_with('-') { return Err("server_name must not start with '-'".to_string()); } if !trimmed .chars() .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') { return Err("server_name must be alphanumeric with '-' or '_'".to_string()); } Ok(trimmed.to_string()) } fn read_workspace_openwork_config( workspace_path: &Path, ) -> Result { let openwork_path = workspace_path.join(".opencode").join("openwork.json"); if !openwork_path.exists() { let mut cfg = WorkspaceOpenworkConfig::default(); let workspace_value = workspace_path.to_string_lossy().to_string(); if !workspace_value.trim().is_empty() { cfg.authorized_roots.push(workspace_value); } return Ok(cfg); } let raw = fs::read_to_string(&openwork_path) .map_err(|e| format!("Failed to read {}: {e}", openwork_path.display()))?; serde_json::from_str::(&raw) .map_err(|e| format!("Failed to parse {}: {e}", openwork_path.display())) } fn load_authorized_roots(app: &AppHandle) -> Result, String> { let state = load_workspace_state(app)?; let mut roots = Vec::new(); for workspace in state.workspaces { let workspace_path = PathBuf::from(&workspace.path); let mut config = read_workspace_openwork_config(&workspace_path)?; if config.authorized_roots.is_empty() { config.authorized_roots.push(workspace.path.clone()); } for root in config.authorized_roots { let trimmed = root.trim(); if !trimmed.is_empty() { roots.push(PathBuf::from(trimmed)); } } } if roots.is_empty() { return Err("No authorized roots configured".to_string()); } Ok(roots) } fn validate_project_dir(app: &AppHandle, project_dir: &str) -> Result { let trimmed = project_dir.trim(); if trimmed.is_empty() { return Err("project_dir is required".to_string()); } let project_path = PathBuf::from(trimmed); if !project_path.is_absolute() { return Err("project_dir must be an absolute path".to_string()); } let canonical = fs::canonicalize(&project_path) .map_err(|e| format!("Failed to resolve project_dir: {e}"))?; if !canonical.is_dir() { return Err("project_dir must be a directory".to_string()); } let roots = load_authorized_roots(app)?; let mut allowed = false; for root in roots { let Ok(root) = fs::canonicalize(&root) else { continue; }; if canonical.starts_with(&root) { allowed = true; break; } } if !allowed { return Err("project_dir is not within an authorized root".to_string()); } Ok(canonical) } fn resolve_opencode_program( app: &AppHandle, prefer_sidecar: bool, opencode_bin_path: Option, ) -> Result { if let Some(custom) = opencode_bin_path { let trimmed = custom.trim(); if !trimmed.is_empty() { return Ok(PathBuf::from(trimmed)); } } let resource_dir = app.path().resource_dir().ok(); let current_bin_dir = tauri::process::current_binary(&app.env()) .ok() .and_then(|path| path.parent().map(|parent| parent.to_path_buf())); let (program, _in_path, notes) = resolve_engine_path( prefer_sidecar, resource_dir.as_deref(), current_bin_dir.as_deref(), ); program.ok_or_else(|| { let notes_text = notes.join("\n"); let install_command = pinned_opencode_install_command(); format!( "OpenCode CLI not found.\n\nInstall with:\n- {install_command}\n\nNotes:\n{notes_text}" ) }) } #[tauri::command] pub fn reset_opencode_cache() -> Result { let candidates = opencode_cache_candidates(); let mut removed = Vec::new(); let mut missing = Vec::new(); let mut errors = Vec::new(); for path in candidates { if path.exists() { if let Err(err) = std::fs::remove_dir_all(&path) { errors.push(format!("Failed to remove {}: {err}", path.display())); } else { removed.push(path.to_string_lossy().to_string()); } } else { missing.push(path.to_string_lossy().to_string()); } } Ok(CacheResetResult { removed, missing, errors, }) } #[tauri::command] pub fn reset_openwork_state( app: tauri::AppHandle, mode: String, engine_manager: State, orchestrator_manager: State, openwork_manager: State, opencode_router_manager: State, ) -> Result<(), String> { let mode = mode.trim(); if mode != "onboarding" && mode != "all" { return Err("mode must be 'onboarding' or 'all'".to_string()); } stop_host_services( &engine_manager, &orchestrator_manager, &openwork_manager, &opencode_router_manager, ); let mut paths = vec![ app.path() .app_cache_dir() .map_err(|e| format!("Failed to resolve app cache dir: {e}"))?, app.path() .app_config_dir() .map_err(|e| format!("Failed to resolve app config dir: {e}"))?, app.path() .app_local_data_dir() .map_err(|e| format!("Failed to resolve app local data dir: {e}"))?, ]; if mode == "all" { paths.push( app.path() .app_data_dir() .map_err(|e| format!("Failed to resolve app data dir: {e}"))?, ); paths.push(PathBuf::from(orchestrator::resolve_orchestrator_data_dir())); } let mut seen = HashSet::new(); for path in paths { let key = path.to_string_lossy().to_string(); if seen.insert(key) { remove_path_if_exists(&path)?; } } Ok(()) } #[tauri::command] pub fn app_build_info(app: AppHandle) -> AppBuildInfo { let version = app.package_info().version.to_string(); let git_sha = option_env!("OPENWORK_GIT_SHA").map(|value| value.to_string()); let build_epoch = option_env!("OPENWORK_BUILD_EPOCH").map(|value| value.to_string()); AppBuildInfo { version, git_sha, build_epoch, openwork_dev_mode: env_truthy("OPENWORK_DEV_MODE"), } } #[tauri::command] pub fn nuke_opencode_dev_config_and_exit( app: AppHandle, engine_manager: State, orchestrator_manager: State, openwork_manager: State, opencode_router_manager: State, ) -> Result<(), String> { if !env_truthy("OPENWORK_DEV_MODE") { return Err("OpenCode dev mode is not enabled.".to_string()); } if let Ok(mut engine) = engine_manager.inner.lock() { EngineManager::stop_locked(&mut engine); } if let Ok(mut orchestrator_state) = orchestrator_manager.inner.lock() { OrchestratorManager::stop_locked(&mut orchestrator_state); } if let Ok(mut openwork_state) = openwork_manager.inner.lock() { OpenworkServerManager::stop_locked(&mut openwork_state); } if let Ok(mut opencode_router_state) = opencode_router_manager.inner.lock() { OpenCodeRouterManager::stop_locked(&mut opencode_router_state); } let app_data_dir = app .path() .app_data_dir() .map_err(|e| format!("Failed to resolve app data dir: {e}"))?; let desktop_dev_dir = app_data_dir.join("opencode-dev"); if desktop_dev_dir.exists() { fs::remove_dir_all(&desktop_dev_dir) .map_err(|e| format!("Failed to remove {}: {e}", desktop_dev_dir.display()))?; } let orchestrator_data_dir = PathBuf::from(orchestrator::resolve_orchestrator_data_dir()); let orchestrator_dev_dir = orchestrator_data_dir.join("opencode-dev"); if orchestrator_dev_dir.exists() { fs::remove_dir_all(&orchestrator_dev_dir) .map_err(|e| format!("Failed to remove {}: {e}", orchestrator_dev_dir.display()))?; } for path in [ orchestrator_data_dir.join("openwork-orchestrator-state.json"), orchestrator_data_dir.join("openwork-orchestrator-auth.json"), ] { if path.exists() { fs::remove_file(&path) .map_err(|e| format!("Failed to remove {}: {e}", path.display()))?; } } app.exit(0); Ok(()) } #[tauri::command] pub fn obsidian_is_available() -> bool { #[cfg(target_os = "macos")] { let mut candidates = vec![PathBuf::from("/Applications/Obsidian.app")]; if let Some(home) = home_dir() { candidates.push(home.join("Applications").join("Obsidian.app")); } return candidates.into_iter().any(|path| path.exists()); } #[cfg(not(target_os = "macos"))] { false } } #[tauri::command] pub fn open_in_obsidian(file_path: String) -> Result<(), String> { let trimmed = file_path.trim(); println!("[misc][obsidian] open request path={trimmed}"); if trimmed.is_empty() { println!("[misc][obsidian] rejected: empty path"); return Err("file_path is required".to_string()); } let path = PathBuf::from(trimmed); if !path.is_absolute() { println!( "[misc][obsidian] rejected: non-absolute path={}", path.display() ); return Err("file_path must be an absolute path".to_string()); } if !path.exists() { println!( "[misc][obsidian] missing path={} cwd={}", path.display(), std::env::current_dir() .map(|dir| dir.display().to_string()) .unwrap_or_else(|_| "(unknown)".to_string()) ); return Err(format!("File does not exist: {}", path.display())); } #[cfg(target_os = "macos")] { if !obsidian_is_available() { println!("[misc][obsidian] rejected: app not installed"); return Err("Obsidian is not installed.".to_string()); } println!("[misc][obsidian] launching path={}", path.display()); let status = std::process::Command::new("open") .arg("-a") .arg("Obsidian") .arg(&path) .status() .map_err(|e| format!("Failed to launch Obsidian: {e}"))?; if status.success() { println!("[misc][obsidian] launch success path={}", path.display()); return Ok(()); } println!( "[misc][obsidian] launch failed path={} status={status}", path.display() ); return Err(format!( "Failed to launch Obsidian (exit status: {status})." )); } #[cfg(not(target_os = "macos"))] { println!( "[misc][obsidian] unsupported platform request path={}", path.display() ); Err("Open in Obsidian is currently supported on macOS only.".to_string()) } } fn sanitize_obsidian_workspace_id(value: &str) -> String { let trimmed = value.trim(); if trimmed.is_empty() { return String::new(); } let mut out = String::with_capacity(trimmed.len()); let mut last_dash = false; for ch in trimmed.chars() { let normalized = if ch.is_ascii_alphanumeric() || ch == '_' { ch.to_ascii_lowercase() } else { '-' }; if normalized == '-' { if last_dash { continue; } out.push('-'); last_dash = true; continue; } out.push(normalized); last_dash = false; } out.trim_matches('-').to_string() } fn normalize_obsidian_mirror_relative_path(file_path: &str) -> Result { let mut value = file_path.trim().replace('\\', "/"); if value.is_empty() { return Err("file_path is required".to_string()); } while let Some(stripped) = value.strip_prefix("./") { value = stripped.to_string(); } if value.is_empty() { return Err("file_path is required".to_string()); } let lower = value.to_ascii_lowercase(); if lower.starts_with("workspace/") { value = value["workspace/".len()..].to_string(); } else if lower.starts_with("/workspace/") { let without_leading_slash = value.trim_start_matches('/').to_string(); if without_leading_slash .to_ascii_lowercase() .starts_with("workspace/") { value = without_leading_slash["workspace/".len()..].to_string(); } } let bytes = value.as_bytes(); let is_windows_abs = bytes.len() >= 3 && bytes[0].is_ascii_alphabetic() && bytes[1] == b':' && bytes[2] == b'/'; if value.starts_with('/') || value.starts_with('~') || is_windows_abs { return Err("file_path must be worker-relative".to_string()); } let mut relative = PathBuf::new(); for part in value.split('/').filter(|part| !part.is_empty()) { if part == "." || part == ".." { return Err("file_path must not contain '.' or '..' segments".to_string()); } relative.push(part); } if relative.as_os_str().is_empty() { return Err("file_path is required".to_string()); } Ok(relative) } #[tauri::command] pub fn write_obsidian_mirror_file( app: AppHandle, workspace_id: String, file_path: String, content: String, ) -> Result { let workspace_trimmed = workspace_id.trim(); if workspace_trimmed.is_empty() { return Err("workspace_id is required".to_string()); } let workspace_key = sanitize_obsidian_workspace_id(workspace_trimmed); if workspace_key.is_empty() { return Err("workspace_id must contain at least one alphanumeric character".to_string()); } let relative_path = normalize_obsidian_mirror_relative_path(&file_path)?; let app_data_dir = app .path() .app_data_dir() .map_err(|e| format!("Failed to resolve app data dir: {e}"))?; let mirror_root = app_data_dir.join("obsidian-mirror").join(workspace_key); let target = mirror_root.join(relative_path); if let Some(parent) = target.parent() { fs::create_dir_all(parent) .map_err(|e| format!("Failed to create {}: {e}", parent.display()))?; } fs::write(&target, content.as_bytes()) .map_err(|e| format!("Failed to write {}: {e}", target.display()))?; Ok(target.to_string_lossy().to_string()) } #[derive(Debug, Clone, serde::Serialize)] #[serde(rename_all = "camelCase")] pub struct ObsidianMirrorFileContent { pub exists: bool, pub path: String, pub content: Option, pub updated_at_ms: Option, } #[tauri::command] pub fn read_obsidian_mirror_file( app: AppHandle, workspace_id: String, file_path: String, ) -> Result { let workspace_trimmed = workspace_id.trim(); if workspace_trimmed.is_empty() { return Err("workspace_id is required".to_string()); } let workspace_key = sanitize_obsidian_workspace_id(workspace_trimmed); if workspace_key.is_empty() { return Err("workspace_id must contain at least one alphanumeric character".to_string()); } let relative_path = normalize_obsidian_mirror_relative_path(&file_path)?; let app_data_dir = app .path() .app_data_dir() .map_err(|e| format!("Failed to resolve app data dir: {e}"))?; let mirror_root = app_data_dir.join("obsidian-mirror").join(workspace_key); let target = mirror_root.join(relative_path); let path_string = target.to_string_lossy().to_string(); if !target.exists() { return Ok(ObsidianMirrorFileContent { exists: false, path: path_string, content: None, updated_at_ms: None, }); } let metadata = fs::metadata(&target).map_err(|e| format!("Failed to stat {}: {e}", target.display()))?; if !metadata.is_file() { return Err(format!("Mirror path is not a file: {}", target.display())); } let content = fs::read_to_string(&target) .map_err(|e| format!("Failed to read {}: {e}", target.display()))?; let updated_at_ms = metadata .modified() .ok() .and_then(|value| value.duration_since(UNIX_EPOCH).ok()) .map(|duration| duration.as_millis() as u64); Ok(ObsidianMirrorFileContent { exists: true, path: path_string, content: Some(content), updated_at_ms, }) } #[cfg(test)] mod tests { use super::{normalize_obsidian_mirror_relative_path, sanitize_obsidian_workspace_id}; #[test] fn sanitize_workspace_id_collapses_separators() { let out = sanitize_obsidian_workspace_id(" Team Alpha / Worker #1 "); assert_eq!(out, "team-alpha-worker-1"); } #[test] fn normalize_mirror_path_strips_workspace_prefixes() { let path = normalize_obsidian_mirror_relative_path("/workspace/notes/plan.md") .expect("path should normalize"); assert_eq!(path.to_string_lossy(), "notes/plan.md"); let path = normalize_obsidian_mirror_relative_path("workspace/notes/plan.md") .expect("path should normalize"); assert_eq!(path.to_string_lossy(), "notes/plan.md"); } #[test] fn normalize_mirror_path_rejects_parent_segments() { let err = normalize_obsidian_mirror_relative_path("notes/../secret.md") .expect_err("parent segments should be rejected"); assert!(err.contains("must not contain")); } #[test] fn normalize_mirror_path_rejects_absolute_paths() { let err = normalize_obsidian_mirror_relative_path("/etc/passwd") .expect_err("absolute path should be rejected"); assert!(err.contains("worker-relative")); } } #[tauri::command] pub fn opencode_db_migrate( app: AppHandle, project_dir: String, prefer_sidecar: Option, opencode_bin_path: Option, ) -> Result { let project_dir = validate_project_dir(&app, &project_dir)?; let program = resolve_opencode_program(&app, prefer_sidecar.unwrap_or(false), opencode_bin_path)?; let mut command = command_for_program(&program); for (key, value) in crate::bun_env::bun_env_overrides() { command.env(key, value); } let output = command .arg("db") .arg("migrate") .current_dir(&project_dir) .output() .map_err(|e| format!("Failed to run opencode db migrate: {e}"))?; let status = output.status.code().unwrap_or(-1); Ok(ExecResult { ok: output.status.success(), status, stdout: String::from_utf8_lossy(&output.stdout).to_string(), stderr: String::from_utf8_lossy(&output.stderr).to_string(), }) } /// Run `opencode mcp auth ` in the given project directory. /// This spawns the process detached so the OAuth flow can open a browser. #[tauri::command] pub fn opencode_mcp_auth( app: AppHandle, project_dir: String, server_name: String, ) -> Result { let project_dir = validate_project_dir(&app, &project_dir)?; let server_name = validate_server_name(&server_name)?; let program = resolve_opencode_program(&app, true, None)?; let mut command = command_for_program(&program); for (key, value) in crate::bun_env::bun_env_overrides() { command.env(key, value); } let output = command .arg("mcp") .arg("auth") .arg(server_name) .current_dir(&project_dir) .output() .map_err(|e| format!("Failed to run opencode mcp auth: {e}"))?; let status = output.status.code().unwrap_or(-1); Ok(ExecResult { ok: output.status.success(), status, stdout: String::from_utf8_lossy(&output.stdout).to_string(), stderr: String::from_utf8_lossy(&output.stderr).to_string(), }) }