* 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>
471 lines
15 KiB
Rust
471 lines
15 KiB
Rust
use crate::diagnostics::{self, DiagnosticsState};
|
|
use crate::preflight::{DesktopPreflightDisposition, DesktopPreflightResult};
|
|
use crate::process::BackendState;
|
|
use log::{info, warn};
|
|
use reqwest::Client;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
static DESKTOP_AUTH_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct DesktopAuthResponse {
|
|
pub access_token: String,
|
|
pub refresh_token: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize, Deserialize)]
|
|
struct DesktopAuthRequest {
|
|
secret: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct TokenResponse {
|
|
access_token: String,
|
|
refresh_token: String,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum PortSource {
|
|
Cached,
|
|
Discovered,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct BackendPort {
|
|
port: u16,
|
|
source: PortSource,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
enum AuthError {
|
|
Connectivity(String),
|
|
StaleResponder(String),
|
|
Failed(String),
|
|
}
|
|
|
|
impl AuthError {
|
|
fn message(self) -> String {
|
|
match self {
|
|
Self::Connectivity(message) | Self::StaleResponder(message) | Self::Failed(message) => {
|
|
message
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn auth_secret_path(home: &Path, filename: &str) -> PathBuf {
|
|
home.join(".unsloth")
|
|
.join("studio")
|
|
.join("auth")
|
|
.join(filename)
|
|
}
|
|
|
|
fn auth_url(port: u16, route: &str) -> String {
|
|
format!("http://127.0.0.1:{port}/api/auth/{route}")
|
|
}
|
|
|
|
fn home_dir() -> Result<PathBuf, String> {
|
|
dirs::home_dir().ok_or_else(|| "Could not determine home directory".to_string())
|
|
}
|
|
|
|
fn desktop_secret_path() -> Result<PathBuf, String> {
|
|
Ok(auth_secret_path(&home_dir()?, ".desktop_secret"))
|
|
}
|
|
|
|
fn read_secret_if_exists(path: &Path) -> Result<Option<String>, String> {
|
|
match std::fs::read_to_string(path) {
|
|
Ok(s) => Ok(Some(s.trim().to_string())),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
|
Err(e) => {
|
|
warn!(
|
|
"Desktop auth: ignoring unreadable auth secret at {}: {}",
|
|
path.display(),
|
|
e
|
|
);
|
|
Ok(None)
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn current_backend_port(
|
|
state: &tauri::State<'_, BackendState>,
|
|
) -> Result<BackendPort, String> {
|
|
let cached_port = {
|
|
let proc = state.lock().map_err(|e| e.to_string())?;
|
|
if let Some(port) = proc.owned_backend_port() {
|
|
return Ok(BackendPort {
|
|
port,
|
|
source: PortSource::Cached,
|
|
});
|
|
}
|
|
if proc.has_owned_backend() {
|
|
None
|
|
} else {
|
|
proc.port
|
|
}
|
|
};
|
|
|
|
if let Some(port) = cached_port {
|
|
return Ok(BackendPort {
|
|
port,
|
|
source: PortSource::Cached,
|
|
});
|
|
}
|
|
|
|
if state
|
|
.lock()
|
|
.map(|proc| proc.has_owned_backend())
|
|
.map_err(|e| e.to_string())?
|
|
{
|
|
return Err("Backend is not ready".to_string());
|
|
}
|
|
|
|
let port = discover_compatible_backend_port()
|
|
.await
|
|
.ok_or_else(|| "Backend is not ready".to_string())?;
|
|
|
|
{
|
|
let mut proc = state.lock().map_err(|e| e.to_string())?;
|
|
if proc.port.is_none() {
|
|
proc.port = Some(port);
|
|
}
|
|
}
|
|
|
|
Ok(BackendPort {
|
|
port,
|
|
source: PortSource::Discovered,
|
|
})
|
|
}
|
|
|
|
fn attached_ready_port(preflight: DesktopPreflightResult) -> Option<u16> {
|
|
if preflight.disposition == DesktopPreflightDisposition::AttachedReady {
|
|
preflight.port
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
async fn discover_compatible_backend_port() -> Option<u16> {
|
|
attached_ready_port(crate::preflight::desktop_preflight_result().await)
|
|
}
|
|
|
|
fn update_backend_port(state: &tauri::State<'_, BackendState>, port: u16) -> Result<(), String> {
|
|
let mut proc = state.lock().map_err(|e| e.to_string())?;
|
|
proc.port = Some(port);
|
|
Ok(())
|
|
}
|
|
|
|
fn classify_auth_send_error(error: reqwest::Error) -> AuthError {
|
|
let message = format!("Desktop auth failed: {}", error);
|
|
if error.is_connect() || error.is_timeout() {
|
|
AuthError::Connectivity(message)
|
|
} else {
|
|
AuthError::Failed(message)
|
|
}
|
|
}
|
|
|
|
fn should_retry_with_discovered_port(source: PortSource, error: &AuthError) -> bool {
|
|
matches!(
|
|
(source, error),
|
|
(
|
|
PortSource::Cached,
|
|
AuthError::Connectivity(_) | AuthError::StaleResponder(_)
|
|
)
|
|
)
|
|
}
|
|
|
|
fn can_retry_on_discovered_port(state: &BackendState, source: PortSource) -> Result<bool, String> {
|
|
if source != PortSource::Cached {
|
|
return Ok(false);
|
|
}
|
|
state
|
|
.lock()
|
|
.map(|proc| !proc.has_owned_backend())
|
|
.map_err(|e| e.to_string())
|
|
}
|
|
|
|
async fn exchange_desktop_secret(
|
|
client: &Client,
|
|
port: u16,
|
|
secret: &str,
|
|
) -> Result<Option<DesktopAuthResponse>, AuthError> {
|
|
let response = client
|
|
.post(auth_url(port, "desktop-login"))
|
|
.json(&DesktopAuthRequest {
|
|
secret: secret.to_string(),
|
|
})
|
|
.send()
|
|
.await
|
|
.map_err(classify_auth_send_error)?;
|
|
|
|
if response.status() == reqwest::StatusCode::NOT_FOUND {
|
|
return Err(AuthError::StaleResponder(
|
|
"Running Studio backend is too old for this desktop app. Update that backend and restart."
|
|
.to_string(),
|
|
));
|
|
}
|
|
if response.status() == reqwest::StatusCode::UNAUTHORIZED {
|
|
return Ok(None);
|
|
}
|
|
if !response.status().is_success() {
|
|
return Err(AuthError::StaleResponder("Desktop auth failed".to_string()));
|
|
}
|
|
|
|
response
|
|
.json::<TokenResponse>()
|
|
.await
|
|
.map(|tokens| {
|
|
Some(DesktopAuthResponse {
|
|
access_token: tokens.access_token,
|
|
refresh_token: tokens.refresh_token,
|
|
})
|
|
})
|
|
.map_err(|e| AuthError::Failed(format!("Desktop auth failed: {}", e)))
|
|
}
|
|
|
|
async fn provision_desktop_auth() -> Result<(), String> {
|
|
let bin = crate::process::resolve_backend_binary()?;
|
|
let mut cmd = tokio::process::Command::new(&bin);
|
|
cmd.args(["studio", "provision-desktop-auth"])
|
|
.stdout(std::process::Stdio::null())
|
|
.stderr(std::process::Stdio::piped());
|
|
#[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.
|
|
// Scrub so provisioning writes match what the Rust auth code reads.
|
|
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 output = tokio::time::timeout(std::time::Duration::from_secs(30), cmd.output())
|
|
.await
|
|
.map_err(|_| "Desktop auth provisioning timed out after 30s".to_string())?
|
|
.map_err(|e| format!("Desktop auth provisioning failed: {}", e))?;
|
|
if output.status.success() {
|
|
return Ok(());
|
|
}
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
Err(format!(
|
|
"Desktop auth provisioning failed: {}",
|
|
stderr.trim()
|
|
))
|
|
}
|
|
|
|
async fn retry_on_discovered_port(
|
|
client: &Client,
|
|
state: &tauri::State<'_, BackendState>,
|
|
previous: BackendPort,
|
|
secret: &str,
|
|
) -> Result<Option<(Option<DesktopAuthResponse>, BackendPort)>, String> {
|
|
if !can_retry_on_discovered_port(state.inner(), previous.source)? {
|
|
return Ok(None);
|
|
}
|
|
let Some(port) = discover_compatible_backend_port().await else {
|
|
return Ok(None);
|
|
};
|
|
if port == previous.port {
|
|
return Ok(None);
|
|
}
|
|
|
|
update_backend_port(state, port)?;
|
|
let backend = BackendPort {
|
|
port,
|
|
source: PortSource::Discovered,
|
|
};
|
|
exchange_desktop_secret(client, port, secret)
|
|
.await
|
|
.map(|tokens| Some((tokens, backend)))
|
|
.map_err(AuthError::message)
|
|
}
|
|
|
|
async fn authenticate_with_stale_port_retry(
|
|
client: &Client,
|
|
state: &tauri::State<'_, BackendState>,
|
|
backend: BackendPort,
|
|
secret: &str,
|
|
) -> Result<(Option<DesktopAuthResponse>, BackendPort), String> {
|
|
match exchange_desktop_secret(client, backend.port, secret).await {
|
|
Ok(Some(tokens)) => Ok((Some(tokens), backend)),
|
|
Ok(None) => {
|
|
// 401 means the backend is reachable but the cached secret is stale.
|
|
// Keep using the same backend so the next desktop_auth_inner attempt
|
|
// provisions a new secret for the server we are retrying instead of
|
|
// switching to an unrelated discovered/attached backend.
|
|
Ok((None, backend))
|
|
}
|
|
Err(error) if should_retry_with_discovered_port(backend.source, &error) => {
|
|
if let Some(retried) = retry_on_discovered_port(client, state, backend, secret).await? {
|
|
return Ok(retried);
|
|
}
|
|
Err(error.message())
|
|
}
|
|
Err(error) => Err(error.message()),
|
|
}
|
|
}
|
|
|
|
#[tauri::command]
|
|
pub async fn desktop_auth(
|
|
state: tauri::State<'_, BackendState>,
|
|
diagnostics: tauri::State<'_, DiagnosticsState>,
|
|
) -> Result<DesktopAuthResponse, String> {
|
|
let result = desktop_auth_inner(&state, diagnostics.inner()).await;
|
|
if let Err(message) = &result {
|
|
let port = state.lock().ok().and_then(|proc| proc.port);
|
|
diagnostics::record_auth_failure(&diagnostics, "desktop_auth", port, message);
|
|
}
|
|
result
|
|
}
|
|
|
|
async fn desktop_auth_inner(
|
|
state: &tauri::State<'_, BackendState>,
|
|
diagnostics: &DiagnosticsState,
|
|
) -> Result<DesktopAuthResponse, String> {
|
|
let _auth_guard = DESKTOP_AUTH_LOCK.lock().await;
|
|
let mut backend = current_backend_port(state).await?;
|
|
if backend.source == PortSource::Discovered {
|
|
diagnostics::record_attached_external_backend(diagnostics, backend.port);
|
|
}
|
|
let client = Client::builder()
|
|
.timeout(std::time::Duration::from_secs(5))
|
|
.build()
|
|
.map_err(|e| format!("Desktop auth failed: {}", e))?;
|
|
|
|
for attempt in 0..2 {
|
|
if attempt == 1 {
|
|
info!("Desktop auth: provisioning local desktop secret");
|
|
provision_desktop_auth().await?;
|
|
}
|
|
|
|
let path = desktop_secret_path()?;
|
|
let Some(secret) = read_secret_if_exists(&path)? else {
|
|
continue;
|
|
};
|
|
|
|
info!("Desktop auth: exchanging desktop secret");
|
|
let (tokens, resolved_backend) =
|
|
authenticate_with_stale_port_retry(&client, state, backend, &secret).await?;
|
|
backend = resolved_backend;
|
|
if backend.source == PortSource::Discovered {
|
|
diagnostics::record_attached_external_backend(diagnostics, backend.port);
|
|
}
|
|
if let Some(tokens) = tokens {
|
|
return Ok(tokens);
|
|
}
|
|
}
|
|
|
|
Err(
|
|
"Desktop auth failed. Update or repair the managed Studio install, then restart Studio."
|
|
.to_string(),
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::TcpListener;
|
|
|
|
async fn login_server(status: &str) -> u16 {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
|
|
let port = listener.local_addr().unwrap().port();
|
|
let status = status.to_string();
|
|
|
|
tokio::spawn(async move {
|
|
let (mut stream, _) = listener.accept().await.unwrap();
|
|
let mut buffer = [0; 1024];
|
|
let _ = stream.read(&mut buffer).await.unwrap();
|
|
let response =
|
|
format!("HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
|
stream.write_all(response.as_bytes()).await.unwrap();
|
|
});
|
|
|
|
port
|
|
}
|
|
|
|
#[test]
|
|
fn retry_discovery_only_for_cached_recoverable_errors() {
|
|
assert!(should_retry_with_discovered_port(
|
|
PortSource::Cached,
|
|
&AuthError::Connectivity("connection refused".to_string())
|
|
));
|
|
assert!(should_retry_with_discovered_port(
|
|
PortSource::Cached,
|
|
&AuthError::StaleResponder("old responder".to_string())
|
|
));
|
|
assert!(!should_retry_with_discovered_port(
|
|
PortSource::Discovered,
|
|
&AuthError::Connectivity("connection refused".to_string())
|
|
));
|
|
assert!(!should_retry_with_discovered_port(
|
|
PortSource::Cached,
|
|
&AuthError::Failed("Desktop auth failed".to_string())
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn owned_handle_disables_discovered_auth_retry() {
|
|
const ROOT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
|
const TOKEN: &str = "desktop-owner-token";
|
|
|
|
let state = crate::process::new_backend_state();
|
|
assert!(can_retry_on_discovered_port(&state, PortSource::Cached).unwrap());
|
|
assert!(!can_retry_on_discovered_port(&state, PortSource::Discovered).unwrap());
|
|
|
|
let owner = crate::desktop_backend_owner::test_owner_state(ROOT_ID, TOKEN, 8890);
|
|
state.lock().unwrap().owned = Some(crate::process::OwnedBackendHandle::adopted(
|
|
owner, 8890, 2, 3,
|
|
));
|
|
|
|
assert!(!can_retry_on_discovered_port(&state, PortSource::Cached).unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn read_secret_handles_missing_trimmed_and_invalid_files() {
|
|
let base =
|
|
std::env::temp_dir().join(format!("unsloth-desktop-secret-{}", std::process::id()));
|
|
let missing = base.with_extension("missing");
|
|
let trimmed = base.with_extension("trimmed");
|
|
let invalid = base.with_extension("invalid");
|
|
let _ = std::fs::remove_file(&missing);
|
|
std::fs::write(&trimmed, " desktop-secret\n").unwrap();
|
|
std::fs::write(&invalid, [0xff, 0xfe]).unwrap();
|
|
|
|
assert_eq!(read_secret_if_exists(&missing).unwrap(), None);
|
|
assert_eq!(
|
|
read_secret_if_exists(&trimmed).unwrap(),
|
|
Some("desktop-secret".to_string())
|
|
);
|
|
assert_eq!(read_secret_if_exists(&invalid).unwrap(), None);
|
|
let _ = std::fs::remove_file(trimmed);
|
|
let _ = std::fs::remove_file(invalid);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn exchange_desktop_secret_handles_unauthorized_and_not_found() {
|
|
let port = login_server("401 Unauthorized").await;
|
|
let tokens = exchange_desktop_secret(&Client::new(), port, "desktop-stale")
|
|
.await
|
|
.unwrap();
|
|
assert!(tokens.is_none());
|
|
|
|
let port = login_server("404 Not Found").await;
|
|
let error = exchange_desktop_secret(&Client::new(), port, "desktop-secret")
|
|
.await
|
|
.unwrap_err()
|
|
.message();
|
|
assert_eq!(
|
|
error,
|
|
"Running Studio backend is too old for this desktop app. Update that backend and restart."
|
|
);
|
|
}
|
|
}
|