unsloth/studio/src-tauri/src/preflight/managed.rs
Wasim Yousef Said 0a54d001ec
Harden Tauri release flow (#5341)
* Harden Tauri backend preflight and startup

Require managed Studio root IDs to match before attaching to existing backends, close the concurrent backend-start window, and tighten frontend Tauri detection to Tauri-specific signals.

* Add Tauri backend manageability guards

Gate desktop backend compatibility on explicit manageability fields, add external-conflict handling for unsafe backend states, and protect update/repair paths from mutating active non-owned Studio backends. Track Tauri-owned backends with local owner metadata for verified orphan cleanup only.

* Split Tauri preflight probes into modules

Move preflight types, version checks, managed install probing, and backend probing into focused submodules while preserving behavior and keeping implementation files under the release-readiness size target.

* Use desktop-specific Tauri updater channel

Point the desktop updater at a same-repo desktop-latest manifest and publish that channel from non-draft desktop releases after validating the Tauri-generated latest.json.

* Add Linux desktop update policy

* Add owned backend lifecycle guards

* Adopt verified desktop-owned backends

* Validate desktop backend readiness

* Trim Tauri release hardening code

* Require desktop backend 2026.5.3

* Handle desktop backend edge cases

* Fail stalled desktop backend startup

* Fix desktop update edge cases

* Avoid secret-gating adopted watchdog

* Fix desktop update comparison guards

* Automate desktop release versioning

* Serialize desktop release workflow

* tests: follow preflight.rs split into preflight/{backend,managed,types,version}.rs

PR #5341 splits studio/src-tauri/src/preflight.rs into a directory of
submodules. The cmd.env_remove("UNSLOTH_STUDIO_HOME") + STUDIO_HOME
calls now live in preflight/managed.rs instead of preflight.rs, so
test_tauri_preflight_scrubs_studio_home_env counted zero matches in
the old single-file location and failed with "assert 0 >= 2".

Read whichever shape is on disk: preflight.rs at the old path plus
every *.rs under preflight/ (current PR has 2 occurrences in
preflight/managed.rs). The guard intent is unchanged: at least 2
env_remove calls covering run_cli_probe and probe_cli_capability,
plus the single commands.rs scrub in check_install_status. Verified
locally: pytest tests/test_studio_install_workspace_guard.py::test_tauri_preflight_scrubs_studio_home_env passes.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Avoid browser Tauri hostname detection

* Restore shutdown flag after failed stop

---------

Co-authored-by: Daniel Han <danielhanchen@gmail.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-05-12 20:30:20 -07:00

169 lines
5.3 KiB
Rust

use super::types::ManagedProbe;
use super::version::{
backend_version_stale_reason, DESKTOP_MANAGEABILITY_VERSION, DESKTOP_PROTOCOL_VERSION,
};
use serde::Deserialize;
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio::process::Command;
#[derive(Debug, Deserialize)]
struct DesktopCapability {
desktop_protocol_version: Option<u16>,
desktop_manageability_version: Option<u16>,
supports_api_only: Option<bool>,
supports_provision_desktop_auth: Option<bool>,
supports_desktop_backend_ownership: Option<bool>,
desktop_auth_stale_reason: Option<String>,
version: Option<String>,
}
async fn run_cli_probe(bin: &Path, args: &[&str]) -> bool {
let mut cmd = Command::new(bin);
cmd.args(args).stdout(Stdio::null()).stderr(Stdio::null());
#[cfg(target_os = "linux")]
if std::env::var_os("APPIMAGE").is_some() {
cmd.env_remove("LD_LIBRARY_PATH");
cmd.env_remove("PYTHONHOME");
cmd.env_remove("PYTHONPATH");
}
// Tauri uses the legacy root regardless of UNSLOTH_STUDIO_HOME / STUDIO_HOME;
// probe subprocesses must follow the same isolation as process.rs.
cmd.env_remove("UNSLOTH_STUDIO_HOME");
cmd.env_remove("STUDIO_HOME");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(crate::process::CREATE_NO_WINDOW);
}
let Ok(mut child) = cmd.spawn() else {
return false;
};
match tokio::time::timeout(Duration::from_secs(10), child.wait()).await {
Ok(Ok(status)) => status.success(),
_ => {
let _ = child.kill().await;
let _ = child.wait().await;
false
}
}
}
async fn probe_cli_capability(bin: &Path) -> Option<DesktopCapability> {
let mut cmd = Command::new(bin);
cmd.args(["studio", "desktop-capabilities", "--json"])
.stdout(Stdio::piped())
.stderr(Stdio::null());
#[cfg(target_os = "linux")]
if std::env::var_os("APPIMAGE").is_some() {
cmd.env_remove("LD_LIBRARY_PATH");
cmd.env_remove("PYTHONHOME");
cmd.env_remove("PYTHONPATH");
}
// Tauri uses the legacy root regardless of UNSLOTH_STUDIO_HOME / STUDIO_HOME;
// probe subprocesses must follow the same isolation as process.rs.
cmd.env_remove("UNSLOTH_STUDIO_HOME");
cmd.env_remove("STUDIO_HOME");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
cmd.creation_flags(crate::process::CREATE_NO_WINDOW);
}
let Ok(mut child) = cmd.spawn() else {
return None;
};
let Some(mut stdout) = child.stdout.take() else {
return None;
};
match tokio::time::timeout(Duration::from_secs(10), child.wait()).await {
Ok(Ok(status)) if status.success() => {}
Err(_) => {
let _ = child.kill().await;
let _ = child.wait().await;
return None;
}
_ => return None,
}
let mut output = Vec::new();
if stdout.read_to_end(&mut output).await.is_err() {
return None;
}
serde_json::from_slice::<DesktopCapability>(&output).ok()
}
fn desktop_capability_stale_reason(capability: &DesktopCapability) -> Option<String> {
if capability.desktop_protocol_version != Some(DESKTOP_PROTOCOL_VERSION) {
return Some("desktop_protocol_incompatible".to_string());
}
if capability.supports_api_only != Some(true) {
return Some("desktop_api_only_unsupported".to_string());
}
if capability.supports_provision_desktop_auth != Some(true) {
return capability
.desktop_auth_stale_reason
.clone()
.or_else(|| Some("desktop_auth_unsupported".to_string()));
}
if capability.desktop_manageability_version.unwrap_or(0) < DESKTOP_MANAGEABILITY_VERSION {
return Some("desktop_manageability_unsupported".to_string());
}
if capability.supports_desktop_backend_ownership != Some(true) {
return Some("desktop_backend_ownership_unsupported".to_string());
}
backend_version_stale_reason(capability.version.as_deref())
}
fn desktop_capability_ready(capability: &DesktopCapability) -> bool {
desktop_capability_stale_reason(capability).is_none()
}
pub(super) async fn probe_managed_bin(bin: PathBuf) -> ManagedProbe {
if !run_cli_probe(&bin, &["-h"]).await {
return ManagedProbe::Stale {
bin,
reason: "cli_unusable".to_string(),
};
}
let capability = probe_cli_capability(&bin).await;
if let Some(capability) = capability {
if desktop_capability_ready(&capability) {
return ManagedProbe::Ready { bin };
}
return ManagedProbe::Stale {
bin,
reason: desktop_capability_stale_reason(&capability)
.unwrap_or_else(|| "desktop_capability_incompatible".to_string()),
};
}
ManagedProbe::Stale {
bin,
reason: "desktop_capability_probe_failed".to_string(),
}
}
pub(super) async fn probe_managed_install() -> ManagedProbe {
match crate::process::find_unsloth_binary() {
Some(bin) => probe_managed_bin(bin).await,
None => ManagedProbe::Missing,
}
}
pub async fn managed_install_ready() -> bool {
matches!(probe_managed_install().await, ManagedProbe::Ready { .. })
}