From 5250bc2bfce37c987df3205962eea5c935887e04 Mon Sep 17 00:00:00 2001 From: Ali Turki Date: Fri, 3 Jul 2026 15:43:14 +0800 Subject: [PATCH] fix(agents): register a stable sidecar copy for AppImage installs AppImages mount at an ephemeral per-launch path, so the Connect flow was writing the mounted /tmp/.mount_* sidecar path into client configs, which dies as soon as the app exits. When APPIMAGE is set, copy the bundled docsreader-mcp into the app data dir and register that path, refreshing the copy on every resolution so app updates self-heal. --- src-tauri/src/agents/mod.rs | 59 ++++++++++++++++++++++++++++++++-- src-tauri/src/tauri_api/mod.rs | 10 ++++-- 2 files changed, 65 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/agents/mod.rs b/src-tauri/src/agents/mod.rs index 31bf2b1..9a08034 100644 --- a/src-tauri/src/agents/mod.rs +++ b/src-tauri/src/agents/mod.rs @@ -129,12 +129,32 @@ fn vscode_user_dir(home: &Path) -> PathBuf { } } -pub fn sidecar_path() -> Result { +pub fn sidecar_path(app_data_dir: &Path) -> Result { let exe = std::env::current_exe().map_err(|e| format!("resolve current executable: {e}"))?; let dir = exe .parent() .ok_or_else(|| format!("{} has no parent directory", exe.display()))?; - Ok(dir.join(format!("docsreader-mcp{}", std::env::consts::EXE_SUFFIX))) + let bundled = dir.join(format!("docsreader-mcp{}", std::env::consts::EXE_SUFFIX)); + if std::env::var_os("APPIMAGE").is_none() { + return Ok(bundled); + } + // AppImages mount at an ephemeral per-launch path, so registered commands + // must point at a copy that outlives the mount. Refreshing on every + // resolution keeps the copy current across app updates. + stable_sidecar_copy(&bundled, &app_data_dir.join("bin")) +} + +fn stable_sidecar_copy(bundled: &Path, bin_dir: &Path) -> Result { + let name = bundled + .file_name() + .ok_or_else(|| format!("{} has no file name", bundled.display()))?; + let dest = bin_dir.join(name); + std::fs::create_dir_all(bin_dir).map_err(|e| format!("create {}: {e}", bin_dir.display()))?; + let tmp = dest.with_extension("docsreader-tmp"); + std::fs::copy(bundled, &tmp) + .map_err(|e| format!("copy {} to {}: {e}", bundled.display(), tmp.display()))?; + std::fs::rename(&tmp, &dest).map_err(|e| format!("replace {}: {e}", dest.display()))?; + Ok(dest) } pub fn detect_clients(home: &Path, sidecar: &Path) -> Vec { @@ -276,6 +296,41 @@ mod tests { assert_eq!(root["servers"][SERVER_NAME]["type"], "stdio"); } + #[test] + fn stable_copy_lands_in_bin_dir_and_refreshes() { + let dir = home(); + let bundled = dir.path().join("docsreader-mcp"); + let bin_dir = dir.path().join("data/bin"); + fs::write(&bundled, "v1").unwrap(); + let dest = stable_sidecar_copy(&bundled, &bin_dir).unwrap(); + assert_eq!(dest, bin_dir.join("docsreader-mcp")); + assert_eq!(fs::read_to_string(&dest).unwrap(), "v1"); + fs::write(&bundled, "v2").unwrap(); + stable_sidecar_copy(&bundled, &bin_dir).unwrap(); + assert_eq!(fs::read_to_string(&dest).unwrap(), "v2"); + } + + #[cfg(unix)] + #[test] + fn stable_copy_preserves_executable_bit() { + use std::os::unix::fs::PermissionsExt; + let dir = home(); + let bundled = dir.path().join("docsreader-mcp"); + fs::write(&bundled, "bin").unwrap(); + fs::set_permissions(&bundled, fs::Permissions::from_mode(0o755)).unwrap(); + let dest = stable_sidecar_copy(&bundled, &dir.path().join("bin")).unwrap(); + let mode = fs::metadata(&dest).unwrap().permissions().mode(); + assert_ne!(mode & 0o111, 0, "executable bit lost: {mode:o}"); + } + + #[test] + fn stable_copy_fails_loud_when_bundled_missing() { + let dir = home(); + let missing = dir.path().join("docsreader-mcp"); + let err = stable_sidecar_copy(&missing, &dir.path().join("bin")).unwrap_err(); + assert!(err.contains("copy"), "unexpected error: {err}"); + } + #[test] fn client_ids_serialize_kebab_case() { let ids: Vec = CLIENT_IDS diff --git a/src-tauri/src/tauri_api/mod.rs b/src-tauri/src/tauri_api/mod.rs index 49eb81e..2e97993 100644 --- a/src-tauri/src/tauri_api/mod.rs +++ b/src-tauri/src/tauri_api/mod.rs @@ -47,13 +47,19 @@ pub async fn convert_workspace( #[tauri::command] pub fn detect_agent_clients(app: AppHandle) -> Result, String> { let home = app.path().home_dir().map_err(|e| e.to_string())?; - Ok(agents::detect_clients(&home, &agents::sidecar_path()?)) + let sidecar = agents::sidecar_path(&app_data_dir(&app)?)?; + Ok(agents::detect_clients(&home, &sidecar)) } #[tauri::command] pub fn connect_agent_client(app: AppHandle, id: ClientId) -> Result { let home = app.path().home_dir().map_err(|e| e.to_string())?; - agents::connect_client(&home, &agents::sidecar_path()?, id) + let sidecar = agents::sidecar_path(&app_data_dir(&app)?)?; + agents::connect_client(&home, &sidecar, id) +} + +fn app_data_dir(app: &AppHandle) -> Result { + app.path().app_local_data_dir().map_err(|e| e.to_string()) } #[tauri::command]