diff --git a/docs/i18n-inventory.md b/docs/i18n-inventory.md index 951cff9..eb3efb3 100644 --- a/docs/i18n-inventory.md +++ b/docs/i18n-inventory.md @@ -1,6 +1,6 @@ # i18n inventory (egdev6/quorb#66) -Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `252ab0dd90465b16a55ad09173b55d0a4c277f80` and `src-tauri/src/` = `7c0f27b6432901d4f1a27613be3b45a1681d3997`. +Generated by `node scripts/i18n-inventory.mjs` from the source trees `src/` = `252ab0dd90465b16a55ad09173b55d0a4c277f80` and `src-tauri/src/` = `6a1422a6e9721fec93fc0a572f566d057cd902a0`. Any commit whose `git rev-parse :src` and `:src-tauri/src` match has these exact texts. Do not edit by hand: regenerate, or verify with `node scripts/i18n-inventory.mjs --check`. @@ -19,8 +19,8 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a | Area | Distinct texts | Sites | |---|---:|---:| | React (`src/`) | 958 | 1066 | -| Rust (`src-tauri/src/`) | 496 | 601 | -| Total | 1416 | 1667 | +| Rust (`src-tauri/src/`) | 506 | 622 | +| Total | 1426 | 1688 | ## src-tauri/src/channel.rs @@ -149,6 +149,8 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - No hay archivo para el efecto propio de este aviso - No se pudo aplicar «siempre encima»: {detail} - Surface clip UI callback was dropped +- WSL: {e:?} +- WSL: {e} - Ya hay una apertura de terminal en curso para esta cuenta. Si no se abrió, inténtalo de nuevo. - {label} ({text}): combinación no válida ({e}) - {label} ({text}): en Wayland el registro se hace vía X11/XWayland y puede no tener efecto global; el atajo puede no responder. @@ -229,8 +231,10 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Team - Token rechazado. «Renovar sesión» abre Claude Code para renovarlo; Quorb se actualiza solo. - Uso extra (mes) +- WSL: {e:?} - créditos - {e}. Inicia sesión en Claude Code. +- {m}. Inicia sesión en Claude Code. ## src-tauri/src/providers/codex.rs @@ -246,10 +250,12 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Sesión · {} h - Ventana corta - Ventana larga +- WSL: {e:?} - auth.json sin access_token - auth.json sin tokens. Ejecuta «codex login». - créditos - {e}. Ejecuta «codex login». +- {m}. Ejecuta «codex login». - {} días ## src-tauri/src/providers/copilot.rs @@ -302,8 +308,10 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Respuesta inesperada: {e} - Sesión de Gemini rechazada. Abre Gemini CLI para renovarla. - Sin cuotas por modelo en la respuesta (¿cuenta con API key en vez de login de Google?) +- WSL: {e:?} - model:{name} - {e}. Inicia sesión en Gemini CLI con tu cuenta de Google. +- {m}. Inicia sesión en Gemini CLI con tu cuenta de Google. ## src-tauri/src/providers/grok.rs @@ -316,9 +324,11 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Sesión caducada. Ejecuta «grok login» para renovarla. - Uso de créditos - Uso semanal +- WSL: {e:?} - auth.json no es un objeto - auth.json sin credenciales válidas. Ejecuta «grok login». - {e}. Ejecuta «grok login». +- {m}. Ejecuta «grok login». ## src-tauri/src/providers/minimax.rs @@ -341,6 +351,7 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - Error de red: {e} - JSON inválido en {}: {e} +- La carpeta de la cuenta {} no es válida en modo WSL: {problem} - No encuentro credenciales en: {} - No existe {} - No puedo leer {}: {e} @@ -653,8 +664,8 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a ## src-tauri/src/wsl/mod.rs -- credentials_path en modo WSL debe ser una ruta absoluta de la distro (empieza por '/'), se recibió: {p} -- credentials_path en modo WSL usa separadores POSIX ('/'), se recibió: {p} +- La ruta en modo WSL debe ser absoluta del guest (empieza por '/'), se recibió: {path} +- La ruta en modo WSL no puede llevar separadores de Windows (\\). Pega la ruta del guest (por ejemplo /home/usuario/...) en su lugar: {path} - la ruta del proveedor dentro del modo debe ser relativa, se recibió: {rel} - la ruta del proveedor dentro del modo usa separadores POSIX ('/'), se recibió: {rel} @@ -687,6 +698,14 @@ tuples (e.g. the `"req"` unit in `providers/nan.rs`) are only listed when they a - wsl.exe no devolvió el usuario por defecto de la distro - wsl.exe no soporta --version (build antigua, sin WSL2) +## src-tauri/src/wsl/session.rs + +- No encuentro credenciales en {distro}: {} +- No encuentro credenciales en {} +- No puedo leer {display}: {e} +- No puedo leer {}: {e} +- WSL: {e:?} + ## src/Settings.tsx - %APPDATA%\\Cursor\\User\\globalStorage\\state.vscdb diff --git a/src-tauri/src/config.rs b/src-tauri/src/config.rs index 9d9c55d..3b57e87 100644 --- a/src-tauri/src/config.rs +++ b/src-tauri/src/config.rs @@ -44,6 +44,19 @@ fn normalize_hex(s: &str) -> Option { } } +/// Entorno de sesión de los proveedores CLIs (#91, #90). +/// +/// Serializa como string (`"host"` por defecto, `"wsl"` para usar la +/// distro `wsl_distro`). Deserializar es tolerante: cualquier valor +/// desconocido se trata como `host`. +#[derive(Serialize, Deserialize, Clone, Debug, Default, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SessionEnvConfig { + #[default] + Host, + Wsl, +} + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] #[serde(default, rename_all = "camelCase")] pub struct AppConfig { @@ -77,6 +90,11 @@ pub struct AppConfig { pub provider_order: Vec, /// Círculos ocultos en la tira (se siguen consultando y avisando). pub hidden_providers: Vec, + /// Entorno de sesión: `host` (Windows/macOS) o `wsl` (la distro elegida). + pub session_env: SessionEnvConfig, + /// Distro WSL elegida cuando `session_env` es `wsl`. Vacía = la que + /// marque el usuario; sin distro no hay entorno WSL válido. + pub wsl_distro: String, pub alerts: AlertsConfig, pub shortcuts: ShortcutsConfig, pub claude: SubscriptionConfig, @@ -261,13 +279,22 @@ impl Account { /// Fichero de sesión que deja el CLI dentro de su carpeta. pub fn credentials_file(&self, home: &Path) -> Option { let dir = self.session_dir(home); - match self.provider.as_str() { - "claude" => Some(dir.join(".credentials.json")), - "codex" => Some(dir.join("auth.json")), - "gemini" => Some(dir.join(".gemini").join("oauth_creds.json")), - "grok" => Some(dir.join("auth.json")), - _ => None, - } + credentials_file_name(self.provider.as_str()).map(|name| dir.join(name)) + } +} + +/// Nombre (relativo) del fichero de sesión de un proveedor. +/// +/// Una sola fuente de verdad: lo usan `Account::credentials_file` (host) y la +/// resolución de sesiones en WSL, para que ambas compongan la misma ruta +/// (#91). `None` = el proveedor no tiene login por CLI. +pub fn credentials_file_name(provider: &str) -> Option<&'static str> { + match provider { + "claude" => Some(".credentials.json"), + "codex" => Some("auth.json"), + "gemini" => Some(".gemini/oauth_creds.json"), + "grok" => Some("auth.json"), + _ => None, } } @@ -289,6 +316,8 @@ impl Default for AppConfig { auto_update: true, provider_order: vec![], hidden_providers: vec![], + session_env: SessionEnvConfig::default(), + wsl_distro: String::new(), alerts: AlertsConfig::default(), claude: SubscriptionConfig { enabled: true, @@ -577,6 +606,24 @@ impl Default for NineRouterConfig { } impl AppConfig { + /// El `SessionEnv` del módulo wsl que este config describe (#91). + /// + /// `host` → sesión del host; `wsl` con distro no vacía → la distro + /// elegida. `wsl` sin distro todavía no es un entorno válido: se + /// trata como host hasta que el usuario elija una distro en Ajustes + /// (la resolución nunca inventa una). + pub fn session_env(&self) -> crate::wsl::SessionEnv { + match self.session_env { + SessionEnvConfig::Host => crate::wsl::SessionEnv::Host, + SessionEnvConfig::Wsl if self.wsl_distro.trim().is_empty() => { + crate::wsl::SessionEnv::Host + } + SessionEnvConfig::Wsl => crate::wsl::SessionEnv::Wsl { + distro: self.wsl_distro.trim().to_string(), + }, + } + } + /// Corrige valores fuera de rango. pub fn sanitized(mut self) -> Self { let locale = self.locale.trim().to_ascii_lowercase(); @@ -735,6 +782,35 @@ mod tests { assert_eq!(json.get("showResets"), Some(&serde_json::json!(true))); } + /// #91: el entorno por defecto es host — una config antigua (sin los + /// campos nuevos) sigue resolviendo sesión del host. + #[test] + fn session_env_defaults_to_host_in_legacy_config() { + let cfg: AppConfig = serde_json::from_str(r#"{}"#).unwrap(); + assert_eq!(cfg.session_env(), crate::wsl::SessionEnv::Host); + } + + /// #91: `wsl` con distro elegida resuelve esa distro; `wsl` sin distro + /// todavía no es un entorno válido y se trata como host (la resolución + /// nunca inventa una distro). + #[test] + fn session_env_maps_wsl_with_distro_and_guards_empty_distro() { + let mut cfg = AppConfig { + session_env: SessionEnvConfig::Wsl, + wsl_distro: "Ubuntu-24.04".into(), + ..Default::default() + }; + assert_eq!( + cfg.session_env(), + crate::wsl::SessionEnv::Wsl { + distro: "Ubuntu-24.04".into() + } + ); + + cfg.wsl_distro = " ".into(); + assert_eq!(cfg.session_env(), crate::wsl::SessionEnv::Host); + } + #[test] fn reset_visibility_roundtrips_and_live_patch_preserves_other_fields() { let mut cfg = AppConfig::default(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0de259e..d78236d 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -42,6 +42,7 @@ use tauri::{ }; use tauri::{PhysicalPosition, PhysicalSize}; use tauri_plugin_global_shortcut::{GlobalShortcutExt, Shortcut, ShortcutState}; +use wsl::WslAdapter; const WIDGET: &str = "widget"; const SETTINGS: &str = "settings"; @@ -231,6 +232,11 @@ struct AppState { config_path: PathBuf, http: reqwest::Client, history: history::History, + /// Home del default user de cada distro WSL, resuelto una sola vez + /// (el proceso de sesión se consulta cada 15 s y `resolve_home` lanza + /// dos procesos wsl.exe). Se invalida solo al cambiar la distro + /// configurada (#91, revisión del mantenedor 2026-11-02). + wsl_homes: wsl::WslHomeCache, /// Single-flight para aperturas de login CLI en curso. Protege contra /// doble clic en «Iniciar sesión» (HealthStep/CliConnect) y contra reenvíos /// duplicados: mientras un `provider` esté en este set, una segunda llamada @@ -506,10 +512,62 @@ fn set_secret(app: AppHandle, key: String, value: String) -> Result<(), String> Ok(()) } +/// Resolvedor de sesiones del entorno configurado (#91): `None` en host (el +/// comportamiento de siempre, incluido el respaldo del Keychain de macOS), y +/// en WSL el resolvedor de la distro elegida, con el home de su default user +/// resuelto por el adaptador (`resolve_home`, F7). +/// +/// `Err` cuando el adaptador no puede resolver el home (WSL sin instalar, +/// distro ausente): el llamador decide cómo reflejarlo; nunca se cae a una +/// cuenta de otro entorno. +/// +/// `resolve_home` lanza dos procesos WSL y va en un hilo propio +/// (`spawn_blocking`), igual que las lecturas del resolvedor: nada de esto +/// bloquea el executor async (revisión PR #220). El home se resuelve UNA +/// vez por distro (`homes`) y se reutiliza en los ticks de `session_stamp` +/// (cada 15 s), para no mantener la VM de WSL activa (revisión del +/// mantenedor, 2026-11-02). +async fn session_resolver( + cfg: &AppConfig, + adapter: std::sync::Arc, + homes: &wsl::WslHomeCache, +) -> Result, String> { + match cfg.session_env() { + wsl::SessionEnv::Host => Ok(None), + wsl::SessionEnv::Wsl { distro } => { + let home = match homes.home_for(&distro) { + Some(h) => h, + None => { + let a = adapter.clone(); + let d = distro.clone(); + let home = tokio::task::spawn_blocking(move || a.resolve_home(&d)) + .await + .map_err(|e| format!("WSL: {e}"))? + .map_err(|e| format!("WSL: {e:?}"))?; + homes.insert(&distro, home.clone()); + home + } + }; + Ok(Some(wsl::SessionResolver::for_wsl(adapter, distro, home))) + } + } +} + #[tauri::command] async fn fetch_usage(state: State<'_, AppState>) -> Result, String> { let cfg = state.config.lock().unwrap().clone(); - let reports = providers::fetch_all(&cfg, &state.http).await; + let adapter = std::sync::Arc::new(wsl::RealWslAdapter::new()); + let reports = + match session_resolver(&cfg, adapter.clone(), &state.wsl_homes).await { + // Host: el comportamiento de siempre (sin resolvedor). + Ok(None) => providers::fetch_all(&cfg, &state.http, None).await, + // WSL: las sesiones de los CLIs se resuelven y se leen dentro de la distro. + Ok(Some(resolver)) => providers::fetch_all(&cfg, &state.http, Some(&resolver)).await, + // El entorno configurado no se puede resolver: fallo explícito en el + // orbe, nunca una lectura silenciosa del otro entorno (#91). + Err(e) => vec![UsageReport::new("wsl", "WSL", model::Kind::Subscription) + .fail(model::Status::Error, e)], + }; state .history .record(&reports, chrono::Utc::now().timestamp()); @@ -713,10 +771,22 @@ fn overlay_done(app: AppHandle) { } /// Huella de los ficheros de sesión de los CLIs (ver `providers::session_stamp`). +/// +/// `Result` porque es un comando `async`: Tauri exige que los commands async +/// con `State` devuelvan `Result` (el futuro vive más que el borrow). #[tauri::command] -fn session_stamp(state: State<'_, AppState>) -> u64 { +async fn session_stamp(state: State<'_, AppState>) -> Result { let cfg = state.config.lock().unwrap().clone(); - providers::session_stamp(&cfg) + let adapter = std::sync::Arc::new(wsl::RealWslAdapter::new()); + Ok( + match session_resolver(&cfg, adapter.clone(), &state.wsl_homes).await { + // WSL: la huella se lee dentro de la distro (misma fuente que los fetch). + Ok(resolver) => providers::session_stamp(&cfg, resolver.as_ref()).await, + // El entorno no se puede resolver: sin sello (0), el widget espera al + // próximo refresco en vez de observar cuentas de otro entorno (#91). + Err(_) => 0, + }, + ) } /// Comprueba la conexión de una cuenta (principal o adicional) con la config guardada, @@ -727,7 +797,9 @@ async fn check_provider( id: String, ) -> Result, String> { let cfg = state.config.lock().unwrap().clone(); - Ok(providers::fetch_one(&cfg, &id, &state.http).await) + let adapter = std::sync::Arc::new(wsl::RealWslAdapter::new()); + let resolver = session_resolver(&cfg, adapter.clone(), &state.wsl_homes).await?; + Ok(providers::fetch_one(&cfg, &id, &state.http, resolver.as_ref()).await) } /// ¿Está instalado el CLI de login de Claude, Codex y Gemini? @@ -1100,6 +1172,7 @@ pub fn run() { http, history, login_in_flight: Mutex::new(std::collections::HashSet::new()), + wsl_homes: wsl::WslHomeCache::default(), }); build_tray(app, locale)?; Ok(()) diff --git a/src-tauri/src/providers/claude.rs b/src-tauri/src/providers/claude.rs index 36d962f..26142f4 100644 --- a/src-tauri/src/providers/claude.rs +++ b/src-tauri/src/providers/claude.rs @@ -11,7 +11,9 @@ use super::{home, net_error, read_json, resolve_path, Who}; use crate::config::SubscriptionConfig; use crate::model::{Kind, Meter, Status, UsageReport}; +use crate::wsl::{SessionError, SessionResolver}; use serde_json::Value; +use std::path::Path; const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; @@ -169,41 +171,95 @@ fn parse_keychain_secret(raw: &str) -> Option { .and_then(|v| Credentials::parse(&v).ok()) } -pub async fn fetch(cfg: &SubscriptionConfig, who: Who<'_>, http: &reqwest::Client) -> UsageReport { +pub async fn fetch( + cfg: &SubscriptionConfig, + who: Who<'_>, + http: &reqwest::Client, + resolver: Option<&SessionResolver>, +) -> UsageReport { let report = who.report("Claude", Kind::Subscription); - let candidates = vec![home().join(".claude").join(".credentials.json")]; - let creds = match resolve_path(cfg.credentials_path.as_deref(), &candidates) { - Ok(p) => match read_json(&p).and_then(|v| Credentials::parse(&v)) { - Ok(c) => c, - Err(e) => return report.fail(Status::Error, e), - }, - // El archivo no existe: en macOS Claude Code guarda la sesión OAuth en - // el Keychain, no en ~/.claude. El respaldo es solo de la sesión - // principal sin ruta explícita (ver `should_fallback_keychain`). - Err(e) => { - if !should_fallback_keychain(cfg.credentials_path.as_deref()) { - return report.fail( - Status::NeedsLogin, - format!("{e}. Inicia sesión en Claude Code."), - ); - } - match keychain_fallback().await { - KeychainOutcome::Session(c) => c, - KeychainOutcome::TimedOut => { + // La sesión se resuelve en el ENTORNO elegido (#91): con resolvedor, la + // ruta por defecto se compone bajo su home (el del usuario Linux si el + // entorno es WSL) y la lectura va por `read_file` del adaptador + // (`wsl cat`) en vez del disco del host. Sin resolvedor, el + // comportamiento de siempre (disco local). + let default_path = match resolver { + Some(r) => r.default_path( + super::provider_session_relative("claude") + .expect("proveedor CLI") + .as_path(), + ), + None => home().join(".claude").join(".credentials.json"), + }; + let creds = match resolver { + Some(r) => { + let path = match r.resolve(cfg.credentials_path.as_deref(), &default_path) { + Ok(p) => p, + Err(e) => return report.fail(Status::Error, e.to_string()), + }; + match r.read_async(&path).await { + Ok(raw) => match serde_json::from_str::(&raw) { + Ok(v) => match Credentials::parse(&v) { + Ok(c) => c, + Err(e) => { + return report + .fail(Status::Error, format!("{e}. Inicia sesión en Claude Code.")) + } + }, + Err(e) => { + return report + .fail(Status::Error, format!("{e}. Inicia sesión en Claude Code.")) + } + }, + // Sin credenciales en el entorno elegido: hace falta login + // AQUÍ (no en otro entorno). + Err(SessionError::Absent(m)) => { return report.fail( - Status::Warning, - "La autorización del Keychain no se completó a tiempo. Concede el acceso a «Claude Code-credentials» y espera al próximo refresco.", + Status::NeedsLogin, + format!("{m}. Inicia sesión en Claude Code."), ) } - KeychainOutcome::NoSession => { + // El fallo es del subsistema o de la distro: la acción + // correcta no es "inicia sesión". + Err(SessionError::Wsl(e)) => { + return report.fail(Status::Error, format!("WSL: {e:?}")) + } + Err(e) => return report.fail(Status::Error, e.to_string()), + } + } + None => match resolve_path(cfg.credentials_path.as_deref(), &[default_path]) { + Ok(p) => match read_json(&p).and_then(|v| Credentials::parse(&v)) { + Ok(c) => c, + Err(e) => return report.fail(Status::Error, e), + }, + // El archivo no existe: en macOS Claude Code guarda la sesión OAuth en + // el Keychain, no en ~/.claude. El respaldo es solo de la sesión + // principal sin ruta explícita (ver `should_fallback_keychain`). + Err(e) => { + if !should_fallback_keychain(cfg.credentials_path.as_deref()) { return report.fail( Status::NeedsLogin, format!("{e}. Inicia sesión en Claude Code."), - ) + ); + } + match keychain_fallback().await { + KeychainOutcome::Session(c) => c, + KeychainOutcome::TimedOut => { + return report.fail( + Status::Warning, + "La autorización del Keychain no se completó a tiempo. Concede el acceso a «Claude Code-credentials» y espera al próximo refresco.", + ) + } + KeychainOutcome::NoSession => { + return report.fail( + Status::NeedsLogin, + format!("{e}. Inicia sesión en Claude Code."), + ) + } } } - } + }, }; let mut report = report; diff --git a/src-tauri/src/providers/codex.rs b/src-tauri/src/providers/codex.rs index 5774c79..57e4d2b 100644 --- a/src-tauri/src/providers/codex.rs +++ b/src-tauri/src/providers/codex.rs @@ -8,6 +8,7 @@ use super::{home, net_error, read_json, resolve_path, unix_to_rfc3339, Who}; use crate::config::SubscriptionConfig; use crate::model::{Kind, Meter, Status, UsageReport, Validity, ValidityKind}; +use crate::wsl::{SessionError, SessionResolver}; use base64::Engine; use serde_json::Value; use std::path::{Path, PathBuf}; @@ -47,16 +48,62 @@ pub(crate) fn resolved_auth_path(credentials_path: Option<&str>) -> Result, http: &reqwest::Client) -> UsageReport { +pub async fn fetch( + cfg: &SubscriptionConfig, + who: Who<'_>, + http: &reqwest::Client, + resolver: Option<&SessionResolver>, +) -> UsageReport { let report = who.report("ChatGPT · Codex", Kind::Subscription); - let path = match resolved_auth_path(cfg.credentials_path.as_deref()) { - Ok(p) => p, - Err(e) => return report.fail(Status::NeedsLogin, format!("{e}. Ejecuta «codex login».")), - }; - let auth_json = match read_json(&path) { - Ok(v) => v, - Err(e) => return report.fail(Status::NeedsLogin, e), + // La sesión se resuelve en el ENTORNO elegido (#91): con resolvedor, la + // ruta por defecto se compone bajo su home (el del usuario Linux si el + // entorno es WSL) y la lectura va por `read_file` del adaptador + // (`wsl cat`) en vez del disco del host. Sin resolvedor, el + // comportamiento de siempre (disco local). + let auth_json: Value = match resolver { + Some(r) => { + let default = r.default_path( + super::provider_session_relative("codex") + .expect("proveedor CLI") + .as_path(), + ); + let path = match r.resolve(cfg.credentials_path.as_deref(), &default) { + Ok(p) => p, + Err(e) => return report.fail(Status::Error, e.to_string()), + }; + match r.read_async(&path).await { + Ok(raw) => match serde_json::from_str::(&raw) { + Ok(v) => v, + Err(e) => { + return report.fail(Status::Error, format!("{e}. Ejecuta «codex login».")) + } + }, + // Sin credenciales en el entorno elegido: hace falta login + // AQUÍ (no en otro entorno). + Err(SessionError::Absent(m)) => { + return report.fail(Status::NeedsLogin, format!("{m}. Ejecuta «codex login».")) + } + // El fallo es del subsistema o de la distro: la acción + // correcta no es "inicia sesión". + Err(SessionError::Wsl(e)) => { + return report.fail(Status::Error, format!("WSL: {e:?}")) + } + Err(e) => return report.fail(Status::Error, e.to_string()), + } + } + None => { + let path = match resolved_auth_path(cfg.credentials_path.as_deref()) { + Ok(p) => p, + Err(e) => { + return report.fail(Status::NeedsLogin, format!("{e}. Ejecuta «codex login».")) + } + }; + match read_json(&path) { + Ok(v) => v, + Err(e) => return report.fail(Status::NeedsLogin, e), + } + } }; let auth = match Auth::parse(&auth_json) { Ok(a) => a, diff --git a/src-tauri/src/providers/gemini.rs b/src-tauri/src/providers/gemini.rs index 0c21e84..dc93027 100644 --- a/src-tauri/src/providers/gemini.rs +++ b/src-tauri/src/providers/gemini.rs @@ -13,8 +13,10 @@ use super::{home, net_error, read_json, resolve_path, Who}; use crate::config::SubscriptionConfig; use crate::model::{Kind, Meter, Status, UsageReport}; +use crate::wsl::{SessionError, SessionResolver}; use serde_json::{json, Value}; use std::collections::BTreeMap; +use std::path::Path; use std::sync::Mutex; const API: &str = "https://cloudcode-pa.googleapis.com/v1internal"; @@ -25,21 +27,71 @@ const CLIENT_SECRET: &str = "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"; /// Tokens renovados en memoria por cuenta (clave: refresh token): (access_token, caduca_en_ms). static CACHED: Mutex> = Mutex::new(BTreeMap::new()); -pub async fn fetch(cfg: &SubscriptionConfig, who: Who<'_>, http: &reqwest::Client) -> UsageReport { +pub async fn fetch( + cfg: &SubscriptionConfig, + who: Who<'_>, + http: &reqwest::Client, + resolver: Option<&SessionResolver>, +) -> UsageReport { let report = who.report("Gemini", Kind::Subscription); - let candidates = vec![home().join(".gemini").join("oauth_creds.json")]; - let path = match resolve_path(cfg.credentials_path.as_deref(), &candidates) { - Ok(p) => p, - Err(e) => { - return report.fail( - Status::NeedsLogin, - format!("{e}. Inicia sesión en Gemini CLI con tu cuenta de Google."), - ) + // La sesión se resuelve en el ENTORNO elegido (#91): con resolvedor, la + // ruta por defecto se compone bajo su home (el del usuario Linux si el + // entorno es WSL) y la lectura va por `read_file` del adaptador + // (`wsl cat`) en vez del disco del host. Sin resolvedor, el + // comportamiento de siempre (disco local). + let creds: Value = match resolver { + Some(r) => { + let default = r.default_path( + super::provider_session_relative("gemini") + .expect("proveedor CLI") + .as_path(), + ); + let path = match r.resolve(cfg.credentials_path.as_deref(), &default) { + Ok(p) => p, + Err(e) => return report.fail(Status::Error, e.to_string()), + }; + match r.read_async(&path).await { + Ok(raw) => match serde_json::from_str::(&raw) { + Ok(v) => v, + Err(e) => { + return report.fail( + Status::Error, + format!("{e}. Inicia sesión en Gemini CLI con tu cuenta de Google."), + ) + } + }, + // Sin credenciales en el entorno elegido: hace falta login + // AQUÍ (no en otro entorno). + Err(SessionError::Absent(m)) => { + return report.fail( + Status::NeedsLogin, + format!("{m}. Inicia sesión en Gemini CLI con tu cuenta de Google."), + ) + } + // El fallo es del subsistema o de la distro: la acción + // correcta no es "inicia sesión". + Err(SessionError::Wsl(e)) => { + return report.fail(Status::Error, format!("WSL: {e:?}")) + } + Err(e) => return report.fail(Status::Error, e.to_string()), + } + } + None => { + let candidates = vec![home().join(".gemini").join("oauth_creds.json")]; + let path = match resolve_path(cfg.credentials_path.as_deref(), &candidates) { + Ok(p) => p, + Err(e) => { + return report.fail( + Status::NeedsLogin, + format!("{e}. Inicia sesión en Gemini CLI con tu cuenta de Google."), + ) + } + }; + match read_json(&path) { + Ok(v) => v, + Err(e) => return report.fail(Status::Error, e), + } } - }; - let creds = match read_json(&path) { - Ok(v) => v, - Err(e) => return report.fail(Status::Error, e), }; let token = match access_token(&creds, http).await { Ok(t) => t, diff --git a/src-tauri/src/providers/grok.rs b/src-tauri/src/providers/grok.rs index 92c5713..9bd50d3 100644 --- a/src-tauri/src/providers/grok.rs +++ b/src-tauri/src/providers/grok.rs @@ -14,6 +14,7 @@ use super::{home, net_error, read_json, resolve_path, Who}; use crate::config::SubscriptionConfig; use crate::model::{Kind, Meter, Status, UsageReport}; +use crate::wsl::{SessionError, SessionResolver}; use chrono::{DateTime, Utc}; use serde_json::Value; use std::path::{Path, PathBuf}; @@ -57,16 +58,62 @@ pub(crate) fn resolved_auth_path(credentials_path: Option<&str>) -> Result, http: &reqwest::Client) -> UsageReport { +pub async fn fetch( + cfg: &SubscriptionConfig, + who: Who<'_>, + http: &reqwest::Client, + resolver: Option<&SessionResolver>, +) -> UsageReport { let report = who.report("Grok", Kind::Subscription); - let path = match resolved_auth_path(cfg.credentials_path.as_deref()) { - Ok(p) => p, - Err(e) => return report.fail(Status::NeedsLogin, format!("{e}. Ejecuta «grok login».")), - }; - let auth_json = match read_json(&path) { - Ok(v) => v, - Err(e) => return report.fail(Status::NeedsLogin, e), + // La sesión se resuelve en el ENTORNO elegido (#91): con resolvedor, la + // ruta por defecto se compone bajo su home (el del usuario Linux si el + // entorno es WSL) y la lectura va por `read_file` del adaptador + // (`wsl cat`) en vez del disco del host. Sin resolvedor, el + // comportamiento de siempre (disco local). + let auth_json: Value = match resolver { + Some(r) => { + let default = r.default_path( + super::provider_session_relative("grok") + .expect("proveedor CLI") + .as_path(), + ); + let path = match r.resolve(cfg.credentials_path.as_deref(), &default) { + Ok(p) => p, + Err(e) => return report.fail(Status::Error, e.to_string()), + }; + match r.read_async(&path).await { + Ok(raw) => match serde_json::from_str::(&raw) { + Ok(v) => v, + Err(e) => { + return report.fail(Status::Error, format!("{e}. Ejecuta «grok login».")) + } + }, + // Sin credenciales en el entorno elegido: hace falta login + // AQUÍ (no en otro entorno). + Err(SessionError::Absent(m)) => { + return report.fail(Status::NeedsLogin, format!("{m}. Ejecuta «grok login».")) + } + // El fallo es del subsistema o de la distro: la acción + // correcta no es "inicia sesión". + Err(SessionError::Wsl(e)) => { + return report.fail(Status::Error, format!("WSL: {e:?}")) + } + Err(e) => return report.fail(Status::Error, e.to_string()), + } + } + None => { + let path = match resolved_auth_path(cfg.credentials_path.as_deref()) { + Ok(p) => p, + Err(e) => { + return report.fail(Status::NeedsLogin, format!("{e}. Ejecuta «grok login».")) + } + }; + match read_json(&path) { + Ok(v) => v, + Err(e) => return report.fail(Status::NeedsLogin, e), + } + } }; let now = Utc::now(); let auth = match Auth::parse(&auth_json, now) { diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs index 1881019..d4c5013 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -20,11 +20,12 @@ pub mod openrouter; pub mod zai; use crate::config::{ - Account, ApiCostConfig, AppConfig, CopilotConfig, NanConfig, OpenCodeGoConfig, - OpenRouterConfig, SubscriptionConfig, ZaiConfig, + credentials_file_name, Account, ApiCostConfig, AppConfig, CopilotConfig, NanConfig, + OpenCodeGoConfig, OpenRouterConfig, SubscriptionConfig, ZaiConfig, }; -use crate::model::{Kind, UsageReport}; +use crate::model::{Kind, Status, UsageReport}; use crate::secrets; +use crate::wsl::SessionResolver; use chrono::{DateTime, Datelike, TimeZone, Utc}; use futures::future::BoxFuture; use futures::FutureExt; @@ -71,28 +72,34 @@ impl<'a> Who<'a> { } } -pub async fn fetch_all(cfg: &AppConfig, http: &reqwest::Client) -> Vec { +pub async fn fetch_all( + cfg: &AppConfig, + http: &reqwest::Client, + resolver: Option<&SessionResolver>, +) -> Vec { let mut jobs: Vec>> = vec![]; if cfg.claude.enabled { jobs.push( - async move { vec![claude::fetch(&cfg.claude, Who::main("claude"), http).await] } + async move { vec![claude::fetch(&cfg.claude, Who::main("claude"), http, resolver).await] } .boxed(), ); } if cfg.codex.enabled { jobs.push( - async move { vec![codex::fetch(&cfg.codex, Who::main("codex"), http).await] }.boxed(), + async move { vec![codex::fetch(&cfg.codex, Who::main("codex"), http, resolver).await] } + .boxed(), ); } if cfg.gemini.enabled { jobs.push( - async move { vec![gemini::fetch(&cfg.gemini, Who::main("gemini"), http).await] } + async move { vec![gemini::fetch(&cfg.gemini, Who::main("gemini"), http, resolver).await] } .boxed(), ); } if cfg.grok.enabled { jobs.push( - async move { vec![grok::fetch(&cfg.grok, Who::main("grok"), http).await] }.boxed(), + async move { vec![grok::fetch(&cfg.grok, Who::main("grok"), http, resolver).await] } + .boxed(), ); } if cfg.cursor.enabled { @@ -158,7 +165,7 @@ pub async fn fetch_all(cfg: &AppConfig, http: &reqwest::Client) -> Vec( } /// Consulta una sola cuenta (principal o adicional) aunque esté oculta en el widget, /// para comprobar la conexión desde Ajustes. `None` si el id no existe. -pub async fn fetch_one(cfg: &AppConfig, id: &str, http: &reqwest::Client) -> Option { +pub async fn fetch_one( + cfg: &AppConfig, + id: &str, + http: &reqwest::Client, + resolver: Option<&SessionResolver>, +) -> Option { if let Some(a) = cfg.accounts.iter().find(|a| a.id == id) { - return Some(fetch_account(cfg, a, http).await); + return Some(fetch_account(cfg, a, http, resolver).await); } Some(match id { - "claude" => claude::fetch(&cfg.claude, Who::main("claude"), http).await, - "codex" => codex::fetch(&cfg.codex, Who::main("codex"), http).await, - "gemini" => gemini::fetch(&cfg.gemini, Who::main("gemini"), http).await, - "grok" => grok::fetch(&cfg.grok, Who::main("grok"), http).await, + "claude" => claude::fetch(&cfg.claude, Who::main("claude"), http, resolver).await, + "codex" => codex::fetch(&cfg.codex, Who::main("codex"), http, resolver).await, + "gemini" => gemini::fetch(&cfg.gemini, Who::main("gemini"), http, resolver).await, + "grok" => grok::fetch(&cfg.grok, Who::main("grok"), http, resolver).await, "cursor" => cursor::fetch(&cfg.cursor, Who::main("cursor"), http).await, "copilot" => copilot::fetch(&cfg.copilot, Who::main("copilot"), http).await, "nan" => nan::fetch(&cfg.nan, Who::main("nan"), http).await, @@ -236,24 +248,125 @@ pub fn session_file(cfg: &AppConfig, id: &str, home: &Path) -> Option { } /// Consulta una cuenta adicional con la config de su proveedor adaptada a ella. +/// Ruta RELATIVA de la sesión de cada proveedor CLI (una sola fuente para +/// los cuatro `fetch` y `wsl_session_stamp`; revisión del mantenedor, +/// 2026-11-06). `None` para proveedores sin login por CLI. +pub(crate) fn provider_session_relative(provider: &str) -> Option { + let parts: &[&str] = match provider { + "claude" => &[".claude", ".credentials.json"], + "codex" => &[".codex", "auth.json"], + "gemini" => &[".gemini", "oauth_creds.json"], + "grok" => &[".grok", "auth.json"], + _ => return None, + }; + Some(crate::wsl::guest_relative(parts)) +} + +/// La ruta de sesión de una cuenta ADICIONAL en el entorno del resolvedor +/// (#91, revisión del mantenedor 2026-11-04). +/// +/// En WSL se compone con semántica POSIX en TODO el recorrido: el +/// `PathBuf::join` de Windows insertaría `\` en el interior (y +/// `join_guest_path` solo normaliza la frontera), y el `cat` del guest no +/// puede leer una ruta con separadores del host. Por eso: +/// - `config_dir` con separador del host (`\`) o NO absoluto del guest +/// (empieza por `/`) → error de configuración ACCIONABLE, no una ruta +/// inválida que luego falla en silencio como «Sin sesión». +/// - `config_dir` absoluto del guest → se usa tal cual (la carpeta que +/// eligió el usuario), sin re-enraizar bajo el home. +/// - Sin `config_dir` → la carpeta por defecto (`~/.quorb`) compuesta bajo +/// el home del entorno. +/// En host devuelve la ruta de `credentials_file` de siempre. +fn account_session_path( + a: &Account, + resolver: Option<&SessionResolver>, +) -> Result, String> { + let Some(r) = resolver else { + return Ok(a + .credentials_file(&home()) + .map(|p| p.to_string_lossy().into_owned())); + }; + // Proveedores SIN login por CLI (openrouter, zai, nan, copilot…): la + // cuenta no resuelve sesión en este entorno; su fetch usa su token + // normal. NO cortar `fetch_account` (revisión rust 2026-11-04). + let Some(name) = credentials_file_name(a.provider.as_str()) else { + return Ok(None); + }; + match &a.config_dir { + Some(d) => { + let d = d.trim(); + // Regla única de «POSIX absoluta del guest» (wsl::guest_path_problem; + // revisión del mantenedor, 2026-11-06). + if let Some(problem) = crate::wsl::guest_path_problem(d) { + return Err(format!( + "La carpeta de la cuenta {} no es válida en modo WSL: {problem}", + a.id + )); + } + // La carpeta que eligió el usuario, tal cual (sin re-enraizar + // bajo el home): una absoluta del guest no se compone con nada. + Ok(Some(format!("{}/{}", d.trim_end_matches('/'), name))) + } + None => { + // La MISMA ruta por defecto que el host (revisión del + // mantenedor, 2026-11-06): `home_dir` del canal + + // `accounts/-` + fichero, con separador POSIX + // del guest. Así dos cuentas del mismo proveedor NO comparten + // fichero (el aislamiento que declara `wsl/session.rs`) y los + // canales con otro `home_dir` no divergen. + let account_dir = format!("{}-{}", a.provider, a.suffix()); + let rel = crate::wsl::guest_relative(&[ + crate::channel::current().home_dir, + "accounts", + account_dir.as_str(), + name, + ]); + Ok(Some( + r.default_path(rel.as_path()).to_string_lossy().into_owned(), + )) + } + } +} + fn fetch_account<'a>( cfg: &'a AppConfig, a: &'a Account, http: &'a reqwest::Client, + resolver: Option<&'a SessionResolver>, ) -> BoxFuture<'a, UsageReport> { let who = Who::extra(a); - let session = |main: &SubscriptionConfig| SubscriptionConfig { + // La ruta de la cuenta adicional se compone en el ENTORNO del resolvedor + // (POSIX en WSL). Una configuración no POSIX en modo WSL (ruta del host, + // relativa) es un error EXPLÍCITO de la cuenta, no un «Sin sesión» + // silencioso (revisión del mantenedor, 2026-11-04). + let account_path = match account_session_path(a, resolver) { + Ok(p) => p, + Err(message) => { + return (async move { + who.report(a.provider.as_str(), Kind::Subscription) + .fail(Status::Error, message) + }) + .boxed(); + } + }; + let session = move |main: &SubscriptionConfig| SubscriptionConfig { enabled: true, - credentials_path: a - .credentials_file(&home()) - .map(|p| p.to_string_lossy().into_owned()), + credentials_path: account_path.clone(), login_command: main.login_command.clone(), }; match a.provider.as_str() { - "claude" => async move { claude::fetch(&session(&cfg.claude), who, http).await }.boxed(), - "codex" => async move { codex::fetch(&session(&cfg.codex), who, http).await }.boxed(), - "gemini" => async move { gemini::fetch(&session(&cfg.gemini), who, http).await }.boxed(), - "grok" => async move { grok::fetch(&session(&cfg.grok), who, http).await }.boxed(), + "claude" => { + async move { claude::fetch(&session(&cfg.claude), who, http, resolver).await }.boxed() + } + "codex" => { + async move { codex::fetch(&session(&cfg.codex), who, http, resolver).await }.boxed() + } + "gemini" => { + async move { gemini::fetch(&session(&cfg.gemini), who, http, resolver).await }.boxed() + } + "grok" => { + async move { grok::fetch(&session(&cfg.grok), who, http, resolver).await }.boxed() + } // Solo con su token: la sesión de la app de Cursor es la de la cuenta principal. "cursor" => { async move { cursor::fetch(&SubscriptionConfig::default(), who, http).await }.boxed() @@ -372,10 +485,26 @@ pub(crate) fn read_json(path: &Path) -> Result { } /// Huella de los ficheros de sesión de los CLIs (Claude Code, Codex, Gemini) de -/// todas las cuentas: suma de sus fechas de modificación en ms. Cambia en cuanto -/// un CLI renueva su token o se inicia sesión, y así el widget recarga al momento -/// sin que la app toque los tokens. -pub fn session_stamp(cfg: &AppConfig) -> u64 { +/// todas las cuentas. Cambia en cuanto un CLI renueva su token o se inicia +/// sesión, y así el widget recarga al momento sin que la app toque los tokens. +/// +/// En host es la suma de las fechas de modificación en ms; en WSL el fichero +/// vive dentro de la distro y no hay `mtime` accesible desde el host, así que +/// se lee su contenido por el adaptador y se suma su huella. Ambas solo +/// sirven para DETECTAR un cambio, no como identificador absoluto (#91). +pub async fn session_stamp(cfg: &AppConfig, resolver: Option<&SessionResolver>) -> u64 { + match resolver { + // WSL: las cuentas que la app lee viven dentro de la distro; el sello + // observa exactamente los ficheros que se leen (misma fuente que los + // fetch: `credentials_path` > home de la distro). + Some(r) => wsl_session_stamp(cfg, r).await, + // Host: mtimes del disco local, como siempre. + None => host_session_stamp(cfg), + } +} + +/// Sello del entorno host: suma de mtimes de los ficheros de sesión. +fn host_session_stamp(cfg: &AppConfig) -> u64 { let h = home(); let main = |c: &SubscriptionConfig, default: PathBuf| match c .credentials_path @@ -425,6 +554,59 @@ pub fn session_stamp(cfg: &AppConfig) -> u64 { .fold(0u64, |acc, d| acc.wrapping_add(d.as_millis() as u64)) } +/// Sello del entorno WSL: huella del CONTENIDO de cada fichero de sesión +/// dentro de la distro. Un `codex login` (o la renovación de un token) en la +/// distro cambia el contenido y por tanto el sello, aunque el host no tenga +/// mtime accesible (revisión PR #220). +async fn wsl_session_stamp(cfg: &AppConfig, r: &SessionResolver) -> u64 { + // La ruta relativa sale de la MISMA fuente que los fetch + // (`provider_session_relative`; revisión del mantenedor, 2026-11-06). + let main = |c: &SubscriptionConfig, provider: &str| { + let relative = provider_session_relative(provider).expect("proveedor CLI"); + r.resolve( + c.credentials_path.as_deref(), + &r.default_path(relative.as_path()), + ) + .ok() + }; + let mut paths: Vec = Vec::new(); + if let Some(p) = main(&cfg.claude, "claude") { + paths.push(p); + } + if let Some(p) = main(&cfg.codex, "codex") { + paths.push(p); + } + if let Some(p) = main(&cfg.gemini, "gemini") { + paths.push(p); + } + if let Some(p) = main(&cfg.grok, "grok") { + paths.push(p); + } + for a in cfg.accounts.iter().filter(|a| a.enabled) { + // La cuenta adicional se compone en el ENTORNO con POSIX del guest; + // una cuenta mal configurada (ruta del host) no contribuye a la + // huella: el error explícito lo da el fetch de esa cuenta. + if let Ok(Some(path)) = account_session_path(a, Some(r)) { + paths.push(PathBuf::from(path)); + } + } + let mut acc = 0u64; + for p in paths { + if let Ok(contents) = r.read_async(&p).await { + acc = acc.wrapping_add(content_hash(contents.as_bytes())); + } + } + acc +} + +/// Huella determinista del contenido (no criptográfica: solo detecta cambios). +fn content_hash(bytes: &[u8]) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + bytes.hash(&mut h); + h.finish() +} + pub fn home() -> PathBuf { dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")) } @@ -443,6 +625,204 @@ pub(crate) fn net_error(e: reqwest::Error) -> String { #[cfg(test)] mod tests { use super::*; + use crate::wsl::{distro, DistroState, MockWslAdapter}; + use std::sync::Arc; + + /// #91 (revisión PR #220): el sello en WSL observa los ficheros DENTRO de + /// la distro — un login allí (contenido que cambia) cambia el sello aunque + /// el host no tenga mtime accesible y no vea el fichero. + #[test] + fn wsl_session_stamp_changes_when_the_token_is_renewed_inside_the_distro() { + let mock = Arc::new(MockWslAdapter::new()); + mock.set_distributions(vec![distro("Ubuntu-22.04", DistroState::Running)]); + mock.set_home("Ubuntu-22.04", PathBuf::from("/home/user")); + let creds = PathBuf::from("/home/user/.claude/.credentials.json"); + mock.set_file( + "Ubuntu-22.04", + creds.clone(), + "{\"claudeAiOauth\":{\"accessToken\":\"v1\"}}".to_string(), + ); + let r = crate::wsl::SessionResolver::for_wsl( + mock.clone(), + "Ubuntu-22.04".into(), + PathBuf::from("/home/user"), + ); + let cfg = AppConfig { + claude: SubscriptionConfig { + enabled: true, + ..Default::default() + }, + ..Default::default() + }; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime de un hilo"); + let first = rt.block_on(wsl_session_stamp(&cfg, &r)); + // El CLI renueva el token dentro de la distro: el contenido cambia. + mock.set_file( + "Ubuntu-22.04", + creds, + "{\"claudeAiOauth\":{\"accessToken\":\"v2\"}}".to_string(), + ); + let second = rt.block_on(wsl_session_stamp(&cfg, &r)); + assert_ne!(first, second, "un login en la distro debe cambiar el sello"); + } + + /// Revisión del mantenedor (2026-11-04): la cuenta adicional en WSL se + /// compone con POSIX en TODO el recorrido (el `PathBuf::join` de Windows + /// insertaría `\` en el interior). El `WslCall` exacto que recibe el + /// guest es `/home/user/.quorb/.credentials.json`, sin separadores del + /// host. + #[test] + fn wsl_session_stamp_reads_extra_accounts_with_guest_posix_paths() { + let mock = Arc::new(MockWslAdapter::new()); + mock.set_distributions(vec![distro("Ubuntu-22.04", DistroState::Running)]); + mock.set_home("Ubuntu-22.04", PathBuf::from("/home/user")); + let extra = PathBuf::from("/home/user/.quorb/accounts/claude-w1/.credentials.json"); + mock.set_file( + "Ubuntu-22.04", + extra.clone(), + "{\"claudeAiOauth\":{\"accessToken\":\"v1\"}}".to_string(), + ); + let r = crate::wsl::SessionResolver::for_wsl( + mock.clone(), + "Ubuntu-22.04".into(), + PathBuf::from("/home/user"), + ); + let cfg = AppConfig { + accounts: vec![Account { + id: "claude:w1".into(), + provider: "claude".into(), + enabled: true, + ..Default::default() + }], + ..Default::default() + }; + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime de un hilo"); + let stamp = rt.block_on(wsl_session_stamp(&cfg, &r)); + assert_ne!(stamp, 0, "la cuenta adicional contribuye a la huella"); + assert!( + mock.calls().iter().any(|c| matches!( + c, + crate::wsl::WslCall::ReadFile(d, p) + if d == "Ubuntu-22.04" && p == &extra + )), + "el guest recibe la ruta POSIX exacta de la cuenta adicional, llamadas: {:?}", + mock.calls() + ); + assert!( + mock.calls() + .iter() + .all(|c| !format!("{:?}", c).contains('\\')), + "ninguna ruta del guest lleva separadores del host" + ); + } + + /// La composición POSIX de la cuenta adicional (revisión del + /// mantenedor, 2026-11-04) es la única puerta de entrada de la cuenta en + /// `fetch_account` y en `wsl_session_stamp`; un `Err` se convierte en el + /// report de error de la cuenta sin tocar el adaptador ni la red. + /// + /// (El test directo de `fetch_account` necesita un cliente reqwest con + /// reactor, que el tokio de CI no expone; la decisión de ruta está + /// aislada aquí y su consumidor en el sello se prueba con el `WslCall` + /// exacto.) + /// La composición POSIX de la cuenta adicional: absoluta del guest se + /// usa tal cual; separadores del host o rutas relativas se rechazan con + /// mensaje accionable (revisión del mantenedor, 2026-11-04). + #[test] + fn account_path_is_posix_and_rejects_host_shaped_config_dirs() { + let mock = Arc::new(MockWslAdapter::new()); + let resolver = crate::wsl::SessionResolver::for_wsl( + mock.clone(), + "Ubuntu-22.04".into(), + PathBuf::from("/home/user"), + ); + let mk = |dir: Option| Account { + id: "claude:w1".into(), + provider: "claude".into(), + config_dir: dir, + enabled: true, + ..Default::default() + }; + // Absoluta del guest: la carpeta que eligió el usuario, sin + // re-enraizar bajo el home. + assert_eq!( + account_session_path(&mk(Some("/mnt/c/Users/yo/quorb".into())), Some(&resolver)) + .expect("config_dir absoluto del guest"), + Some("/mnt/c/Users/yo/quorb/.credentials.json".to_string()) + ); + // Sin config_dir: la MISMA ruta por defecto que el host (home_dir + // del canal + accounts/-), POSIX. + assert_eq!( + account_session_path(&mk(None), Some(&resolver)).expect("defecto"), + Some("/home/user/.quorb/accounts/claude-w1/.credentials.json".to_string()) + ); + // Separadores del host: rechazo explícito. + let err = account_session_path(&mk(Some(r"C:\Users\yo\quorb".into())), Some(&resolver)) + .unwrap_err(); + assert!(err.contains("Windows"), "{err}"); + // Relativa: rechazo explícito (no debe caer al cwd del guest). + let err = account_session_path(&mk(Some("accounts/claude-1".into())), Some(&resolver)) + .unwrap_err(); + assert!(err.contains("absoluta"), "{err}"); + } + + /// Los proveedores SIN login por CLI (openrouter, zai, nan…) no resuelven + /// sesión en WSL: `Ok(None)` y su fetch normal sigue usando su token + /// (review rust 2026-11-04). + #[test] + fn shellless_providers_are_not_truncated_in_wsl() { + let mock = Arc::new(MockWslAdapter::new()); + let resolver = crate::wsl::SessionResolver::for_wsl( + mock.clone(), + "Ubuntu-22.04".into(), + PathBuf::from("/home/user"), + ); + let a = Account { + id: "openrouter:w1".into(), + provider: "openrouter".into(), + enabled: true, + ..Default::default() + }; + assert_eq!( + account_session_path(&a, Some(&resolver)).expect("sin error"), + None + ); + } + + /// Documenta el layout por defecto (review rust 2026-11-04): sin + /// `config_dir`, dos cuentas del mismo proveedor comparten + /// `~/.quorb/` — igual que en host. El aislamiento real de la + /// UI viene del `config_dir` que crea Ajustes (subcarpeta por cuenta). + #[test] + fn default_extra_accounts_are_isolated_without_config_dir() { + let mock = Arc::new(MockWslAdapter::new()); + let resolver = crate::wsl::SessionResolver::for_wsl( + mock.clone(), + "Ubuntu-22.04".into(), + PathBuf::from("/home/user"), + ); + let mk = |id: &str| Account { + id: id.into(), + provider: "claude".into(), + enabled: true, + ..Default::default() + }; + let p1 = account_session_path(&mk("claude:w1"), Some(&resolver)) + .expect("p1") + .expect("some"); + let p2 = account_session_path(&mk("claude:w2"), Some(&resolver)) + .expect("p2") + .expect("some"); + assert_eq!(p1, "/home/user/.quorb/accounts/claude-w1/.credentials.json"); + assert_eq!(p2, "/home/user/.quorb/accounts/claude-w2/.credentials.json"); + assert_ne!(p1, p2, "cada cuenta lee su propio fichero"); + } /// Carpeta temporal propia de cada prueba, sin tocar el HOME real. fn temp_dir(tag: &str) -> PathBuf { diff --git a/src-tauri/src/wsl/mod.rs b/src-tauri/src/wsl/mod.rs index 3dc7aa2..d912da6 100644 --- a/src-tauri/src/wsl/mod.rs +++ b/src-tauri/src/wsl/mod.rs @@ -7,14 +7,50 @@ //! Decisiones del paso 0: `ADR-W2.md`. Contrato firmado: `W2-WSLADAPTER.md`. pub use crate::cli_process::{CommandOutput, RunError}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; +use std::sync::Mutex; mod errors; mod mock; mod process; mod real; +mod session; pub use mock::{MockWslAdapter, WslCall}; pub use real::{build_wslenv_entry, RealWslAdapter, SESSION_ENV_ALLOWLIST}; +pub use session::{SessionEnv, SessionError, SessionResolver}; + +/// Caché del home del default user de una distro (#91). +/// +/// `resolve_home` lanza dos procesos `wsl.exe` y `session_stamp` se +/// consulta cada 15 s (`SESSION_CHECK_MS`): sin caché, cada consulta paga +/// dos procesos y puede mantener la VM de WSL siempre activa. El home de +/// una distro solo cambia si cambia su default user; la invalidación es +/// automática al cambiar la distro configurada: `home_for` descarta el +/// mapa en cuanto ve una distro distinta de la cacheada (revisión del +/// mantenedor, 2026-11-02). +#[derive(Default)] +pub struct WslHomeCache(Mutex<(Option, HashMap)>); + +impl WslHomeCache { + /// Home cacheado para `distro`, o `None` si no está. Si la distro + /// activa cambió, el caché se limpia y `None` fuerza a resolver el + /// home nuevo. + pub fn home_for(&self, distro: &str) -> Option { + let mut inner = self.0.lock().unwrap(); + if inner.0.as_deref() != Some(distro) { + inner.0 = Some(distro.to_string()); + inner.1.clear(); + return None; + } + inner.1.get(distro).cloned() + } + + /// Guarda el home resuelto de `distro`. + pub fn insert(&self, distro: &str, home: PathBuf) { + self.0.lock().unwrap().1.insert(distro.to_string(), home); + } +} /// Error tipado de una operación WSL. Se asigna desde el parseo de tokens /// del output (F2) o desde fallos del lanzador (F13/timeout). @@ -193,6 +229,38 @@ fn has_host_separator(path: &str) -> bool { path.contains('\\') } +/// Ruta RELATIVA dentro del home del entorno, con separador POSIX (`/`). +/// +/// NUNCA `Path::join` para rutas del guest: en Windows insertaría `\` en el +/// INTERIOR, y esa ruta viaja literal al `cat` de la distro (Linux), donde +/// `\` no separa nada — el guest buscaría otro fichero (revisión del +/// mantenedor, 2026-11-04). +pub fn guest_relative(parts: &[&str]) -> PathBuf { + PathBuf::from(parts.join("/")) +} + +/// Problema de una ruta EXPLÍCITA para el guest (modo WSL): `Some(mensaje +/// accionable)` si no es POSIX absoluta, `None` si es válida. ÚNICA fuente +/// de la regla «absoluta del guest y sin `\`» — la usan `SessionResolver::resolve` +/// (ruta de sesión pegada a mano en Ajustes), `account_session_path` +/// (`config_dir` de cuentas) y `login` (carpeta de sesión de cuentas), +/// para que los tres mensajes no diverjan (revisión del mantenedor, +/// 2026-11-06). +pub fn guest_path_problem(path: &str) -> Option { + if path.contains('\\') { + return Some(format!( + "La ruta en modo WSL no puede llevar separadores de Windows (\\). \ + Pega la ruta del guest (por ejemplo /home/usuario/...) en su lugar: {path}" + )); + } + if !path.starts_with('/') { + return Some(format!( + "La ruta en modo WSL debe ser absoluta del guest (empieza por '/'), se recibió: {path}" + )); + } + None +} + /// Compone `base` + `relative` con semántica POSIX (separador `/`). /// /// `Path::join` NO sirve aquí: en Windows usa `\` como separador, y la ruta @@ -224,18 +292,11 @@ pub fn session_path( Some(p) => { // Semántica del GUEST, no del host: la ruta se consume con // `cat` dentro de la distro. Un path `C:\...` (absoluto para - // Windows) NO es válido como ruta de sesión WSL. - if !is_guest_absolute(p) { - return Err(WslError::InvalidArgument(format!( - "credentials_path en modo WSL debe ser una ruta absoluta de la distro (empieza por '/'), se recibió: {p}" - ))); - } - // La ruta del guest es POSIX: un `\` en ella no es un separador - // y apuntaría a otro fichero (o a ninguno). - if has_host_separator(p) { - return Err(WslError::InvalidArgument(format!( - "credentials_path en modo WSL usa separadores POSIX ('/'), se recibió: {p}" - ))); + // Windows) NO es válido como ruta de sesión WSL. La regla vive + // en `guest_path_problem` (una sola fuente; revisión del + // mantenedor, 2026-11-06). + if let Some(problem) = guest_path_problem(p) { + return Err(WslError::InvalidArgument(problem)); } Ok(PathBuf::from(p)) } @@ -495,6 +556,43 @@ mod tests { assert_eq!(resolve_distro_name("", &enumerated), None); } + /// `guest_relative` compone con `/` en cualquier plataforma (el + /// `Path::join` de Windows introduciría `\` en el interior; revisión del + /// mantenedor, 2026-11-04). + #[test] + fn guest_relative_uses_posix_separators_on_every_platform() { + let p = guest_relative(&[".claude", ".credentials.json"]); + assert_eq!(p.to_string_lossy(), ".claude/.credentials.json"); + assert!(!p.to_string_lossy().contains('\\')); + let nested = guest_relative(&[".quorb", ".gemini/oauth_creds.json"]); + assert_eq!(nested.to_string_lossy(), ".quorb/.gemini/oauth_creds.json"); + assert!(!nested.to_string_lossy().contains('\\')); + } + + /// El home se cachea por distro y se invalida SOLO al cambiar la distro + /// activa (revisión del mantenedor, 2026-11-02): `session_stamp` se + /// consulta cada 15 s y no debe lanzar `resolve_home` (dos procesos + /// wsl.exe) por cada tick. + #[test] + fn home_cache_resolves_once_and_invalidates_on_distro_change() { + let cache = WslHomeCache::default(); + assert_eq!(cache.home_for("Ubuntu-22.04"), None); + cache.insert("Ubuntu-22.04", PathBuf::from("/home/u1")); + assert_eq!( + cache.home_for("Ubuntu-22.04").as_deref(), + Some(Path::new("/home/u1")) + ); + // Cambio de distro configurada: se descarta lo cacheado. + assert_eq!(cache.home_for("Debian"), None); + cache.insert("Debian", PathBuf::from("/home/u2")); + assert_eq!( + cache.home_for("Debian").as_deref(), + Some(Path::new("/home/u2")) + ); + // La distro anterior quedó invalidada al cambiar. + assert_eq!(cache.home_for("Ubuntu-22.04"), None); + } + /// La comparación contra la enumeración es trim + case-insensitive, y /// devuelve el nombre canónico tal como lo reporta WSL. #[test] diff --git a/src-tauri/src/wsl/process.rs b/src-tauri/src/wsl/process.rs index 531ebd1..885a1ad 100644 --- a/src-tauri/src/wsl/process.rs +++ b/src-tauri/src/wsl/process.rs @@ -890,6 +890,7 @@ mod tests { /// - atiende TERM y muere -> NO se escala a KILL; /// - IGNORA TERM -> si se escala a KILL; /// - ya terminado -> nada que matar (sin error). + /// /// En los tres, la distro NO se toca (nunca `--terminate`) y el base /// de la operacion se limpia. #[test] diff --git a/src-tauri/src/wsl/real.rs b/src-tauri/src/wsl/real.rs index 1606172..ae24d28 100644 --- a/src-tauri/src/wsl/real.rs +++ b/src-tauri/src/wsl/real.rs @@ -816,7 +816,7 @@ mod tests { /// La sonda de WSL es `--version` (contrato W1 §6); la build antigua /// sin soporte se cubre en `diagnose_maps_old_build_without_version_*`. - + /// /// `terminate()` es el nivel 2: `--terminate `, sin el /// separador `--` (es una opción de `wsl.exe`, no un comando). #[test] diff --git a/src-tauri/src/wsl/session.rs b/src-tauri/src/wsl/session.rs new file mode 100644 index 0000000..d3af05c --- /dev/null +++ b/src-tauri/src/wsl/session.rs @@ -0,0 +1,480 @@ +//! Resolución de sesiones de los proveedores en el entorno elegido (#91). +//! +//! En modo WSL las credenciales de Codex, Gemini y Claude viven **dentro de +//! la distro** (`.codex/auth.json`, `.gemini/oauth_creds.json`, +//! `.claude/.credentials.json` bajo el home del usuario Linux), no en el home +//! de Windows. Este módulo es el puente: sabe **dónde** está la sesión según +//! el entorno y **cómo** se lee, sin que los proveedores conozcan WSL. +//! +//! Reglas del contrato de sesiones (#91): +//! - La ruta explícita tiene prioridad y su origen efectivo se expone; una +//! ruta inválida es un error sin fallback oculto. +//! - Sin credenciales en el entorno elegido, jamás se usa silenciosamente una +//! cuenta de otro entorno (Windows, otra distro). +//! - Las cuentas principal/adicional conservan el aislamiento que ya tienen: +//! resolver una no pisa los ficheros de otra. +//! - Lectura y login comparten este mismo contrato de entorno/carpeta. +//! - No se copian tokens: WSL lee el fichero en su casa, Windows lo lee del +//! disco; ninguno importa el valor a otro lado. + +use super::{guest_path_problem, join_guest_path, WslAdapter, WslError}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +/// El entorno en el que se resuelve la sesión de un proveedor. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionEnv { + /// El host: Windows (o macOS) usa el home local, como hoy. + Host, + /// La distro WSL elegida: la sesión se resuelve y se lee dentro de ella. + Wsl { distro: String }, +} + +/// Error de resolución de una sesión, recuperable por el llamador. +/// +/// Distingue lo que la UI y el diagnóstico necesitan (#91): +/// - `Absent`: no hay credenciales (necesita login). +/// - `Unreadable`: existen pero no se pueden leer (permisos, WSL roto). +/// - `Invalid`: se leyeron pero no son JSON/sesión válida. +/// - `Wsl`: el fallo es del subsistema o la distro (no de las credenciales). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionError { + /// No hay credenciales en el entorno elegido. + Absent(String), + /// Las credenciales existen pero no se pudieron leer. + Unreadable(String), + /// Se leyeron pero no se pudieron interpretar. + Invalid(String), + /// El subsistema WSL o la distro fallaron (no es un problema de sesión). + Wsl(WslError), +} + +impl std::fmt::Display for SessionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SessionError::Absent(m) => write!(f, "{m}"), + SessionError::Unreadable(m) => write!(f, "{m}"), + SessionError::Invalid(m) => write!(f, "{m}"), + SessionError::Wsl(e) => write!(f, "WSL: {e:?}"), + } + } +} + +/// Un resolvedor de sesiones: el entorno y cómo leer dentro de él. +/// +/// `adapter` es el doble de `RealWslAdapter` en los tests (o el real en +/// producción); `read_file` del trait es `wsl cat -- path` con cancelación +/// acotada. Para `Host` no se usa WSL: la lectura es del disco local. +/// +/// El adaptador va en un `Arc` (y no prestado) para que la lectura pueda +/// derivarse a un hilo propio (`read_async`): en modo WSL cada lectura +/// espera a `wsl.exe`, y bloquear el executor async congelaría el widget +/// (revisión PR #220). +#[derive(Clone)] +pub struct SessionResolver { + env: SessionEnv, + adapter: Arc, + /// Home del entorno elegido: el del host, o el del usuario de la distro. + home: PathBuf, +} + +impl SessionResolver { + /// `home` es el home del entorno: el del host en modo host, o el del + /// usuario por defecto de la distro (lo resuelve `resolve_home`). + pub fn new(env: SessionEnv, adapter: Arc, home: PathBuf) -> Self { + SessionResolver { env, adapter, home } + } + + /// Resolvedor del entorno host: lee del disco local con el home del host. + /// El `adapter` no se usa en este modo (queda por si el entorno cambia). + pub fn for_host(adapter: Arc, home: PathBuf) -> Self { + SessionResolver::new(SessionEnv::Host, adapter, home) + } + + /// Resolvedor de la distro elegida (#91): la sesión se resuelve y se lee + /// DENTRO de la distro, con `read_file` del adaptador (`wsl cat`). + /// + /// `home` es el home del default user de la distro, ya resuelto por + /// `resolve_home` (F7): este constructor no hace I/O, así que puede + /// fallar quien lo llama con un mensaje acorde al contexto (la UI, la + /// línea de comandos) en vez de un error genérico de WSL. + pub fn for_wsl(adapter: Arc, distro: String, home: PathBuf) -> Self { + SessionResolver::new(SessionEnv::Wsl { distro }, adapter, home) + } + + /// El home del entorno elegido. + pub fn home(&self) -> &Path { + &self.home + } + + /// La ruta de la sesión, con la precedencia de la #91: explícita > default. + /// + /// `explicit` es `credentials_path` de la config del proveedor (si lo hay); + /// `default` es la ruta estándar del entorno (`~/.claude/.credentials.json` + /// bajo el home del usuario Linux, o el del host). + /// + /// No cae a otro entorno: si la ruta explícita no existe o la default del + /// entorno elegido no está, es `Absent` — nunca se busca en Windows cuando + /// el entorno es WSL ni al revés. + /// + /// En WSL la ruta explícita debe ser una ruta POSIX del guest + /// (revisión del mantenedor, 2026-11-04): un valor con separadores del + /// host (`C:\…`, `\\wsl.localhost\…`) viajaría literal al `cat`, fallaría + /// como "no existe" y se presentaría como «Sin sesión», ocultando un + /// error de configuración. Se devuelve `Invalid` con un mensaje + /// accionable en su lugar. + pub fn resolve(&self, explicit: Option<&str>, default: &Path) -> Result { + match explicit.map(str::trim).filter(|s| !s.is_empty()) { + Some(p) => { + if matches!(self.env, SessionEnv::Wsl { .. }) { + if let Some(message) = guest_path_problem(p) { + return Err(SessionError::Invalid(message)); + } + } + Ok(PathBuf::from(p)) + } + None => Ok(default.to_path_buf()), + } + } + + /// La ruta estándar del proveedor bajo el home del entorno elegido. + /// + /// `relative` es la ruta relativa del proveedor (`.claude/.credentials.json`, + /// `.codex/auth.json`, `.gemini/oauth_creds.json`): la misma que se usa + /// hoy contra el home del host, pero compuesta sobre el home del + /// entorno activo (el del usuario Linux cuando el entorno es WSL). + /// + /// En WSL se compone con semántica POSIX (`join_guest_path`): la ruta + /// viaja literal al `cat` de la distro, y `Path::join` introduciría `\` + /// en Windows — donde `\` no es un separador (revisión del mantenedor, + /// 2026-11-02). + pub fn default_path(&self, relative: &Path) -> PathBuf { + join_guest_path(&self.home, relative) + } + + /// Lee el contenido de la sesión en el entorno elegido. + /// + /// Devuelve `Absent` si el fichero no existe (o no se pudo determinar), + /// `Unreadable` si existe pero no se lee, y `Wsl` si el fallo es del + /// subsistema o de la distro — el llamador decide la acción correcta + /// (pedir login, avisar de WSL, etc.). + pub fn read(&self, path: &Path) -> Result { + match &self.env { + SessionEnv::Host => { + if !path.is_file() { + return Err(SessionError::Absent(format!( + "No encuentro credenciales en {}", + path.display() + ))); + } + std::fs::read_to_string(path).map_err(|e| { + SessionError::Unreadable(format!("No puedo leer {}: {e}", path.display())) + }) + } + SessionEnv::Wsl { distro } => match self.adapter.read_file(distro, path) { + Ok(contents) => Ok(contents), + // El fichero no existe en la distro: es un "necesita login", + // no un fallo de WSL. + Err(WslError::FileNotFound(_)) => Err(SessionError::Absent(format!( + "No encuentro credenciales en {distro}: {}", + path.display() + ))), + // Distro desconocida / WSL sin instalar: el fallo es del + // subsistema, y la acción correcta es distinta de "login". + Err(e) => Err(SessionError::Wsl(e)), + }, + } + } + + /// Lectura sin bloquear el executor async. + /// + /// En modo WSL la lectura espera a `wsl.exe` (hasta 20 s de presupuesto); + /// hacerla en el hilo del executor congelaría el widget y las demás + /// tareas async (revisión PR #220). Aquí la operación corre en un hilo + /// propio y el error del hilo se distingue del de la sesión. + pub async fn read_async(&self, path: &Path) -> Result { + let me = self.clone(); + let path = path.to_path_buf(); + // El path entra (por `move`) en el hilo propio; el mensaje de error se + // formatea antes para no usarlo después del move. + let display = path.display().to_string(); + match tokio::task::spawn_blocking(move || me.read(&path)).await { + Ok(result) => result, + Err(e) => Err(SessionError::Unreadable(format!( + "No puedo leer {display}: {e}" + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wsl::{guest_relative, MockWslAdapter}; + + #[test] + fn host_resolver_reads_from_the_local_disk() { + let mock = Arc::new(MockWslAdapter::new()); + let r = SessionResolver::new( + SessionEnv::Host, + mock.clone(), + PathBuf::from("/home/hostuser"), + ); + let f = std::env::temp_dir().join("quorb-session-host-test.json"); + std::fs::write(&f, b"{}").expect("escribe fixture"); + assert_eq!(r.read(&f).expect("lee"), "{}"); + std::fs::remove_file(&f).ok(); + } + + #[test] + fn host_resolver_reports_absent_when_there_is_no_file() { + let mock = Arc::new(MockWslAdapter::new()); + let r = SessionResolver::new( + SessionEnv::Host, + mock.clone(), + PathBuf::from("/home/hostuser"), + ); + let missing = PathBuf::from("/no/existe/quorb-creds.json"); + assert!(matches!(r.read(&missing), Err(SessionError::Absent(_)))); + } + + /// #91 (camino 1): el MISMO path significa dos cosas según el entorno — en + /// la distro se lee el fichero de la distro, en el host el del host. Las + /// cuentas de un entorno nunca se mezclan con las del otro. + #[test] + fn the_same_path_resolves_to_the_environment_it_belongs_to() { + let mock = Arc::new(MockWslAdapter::new()); + let path = std::env::temp_dir().join("quorb-session-env-path.json"); + std::fs::write(&path, b"{\"origen\":\"host\"}").expect("escribe fixture"); + mock.set_file( + "Ubuntu-22.04", + path.clone(), + "{\"origen\":\"distro\"}".to_string(), + ); + + let host = SessionResolver::for_host(mock.clone(), PathBuf::from("/home/hostuser")); + assert_eq!(host.read(&path).expect("lee host"), "{\"origen\":\"host\"}"); + + let wsl = SessionResolver::for_wsl( + mock.clone(), + "Ubuntu-22.04".into(), + PathBuf::from("/home/user"), + ); + assert_eq!( + wsl.read(&path).expect("lee distro"), + "{\"origen\":\"distro\"}" + ); + + std::fs::remove_file(&path).ok(); + } + + /// La ruta por defecto se compone sobre el home del ENTORNO: el del + /// usuario de la distro en WSL, el del host en host. + #[test] + fn default_path_is_composed_over_the_environment_home() { + let mock = Arc::new(MockWslAdapter::new()); + let wsl = SessionResolver::for_wsl( + mock.clone(), + "Ubuntu-22.04".into(), + PathBuf::from("/home/user"), + ); + assert_eq!( + wsl.default_path(guest_relative(&[".claude", ".credentials.json"]).as_path()), + PathBuf::from("/home/user/.claude/.credentials.json") + ); + let host = SessionResolver::for_host(mock.clone(), PathBuf::from("/home/hostuser")); + assert_eq!( + host.default_path(guest_relative(&[".codex", "auth.json"]).as_path()), + PathBuf::from("/home/hostuser/.codex/auth.json") + ); + } + + /// POSIX siempre, también en Windows (revisión del mantenedor, + /// 2026-11-02): la ruta de sesión WSL viaja literal al `cat` de la + /// distro; `Path::join` habría introducido `\` en Windows. Aquí se + /// comprueba el argumento EXACTO que llega al guest. + #[test] + fn default_path_produces_posix_arguments_for_the_guest() { + let mock = Arc::new(MockWslAdapter::new()); + let wsl = SessionResolver::for_wsl( + mock.clone(), + "Ubuntu-22.04".into(), + PathBuf::from("/home/user"), + ); + for relative in [ + ".claude/.credentials.json", + ".codex/auth.json", + ".gemini/oauth_creds.json", + ] { + let p = wsl.default_path(Path::new(relative)); + let s = p.to_string_lossy(); + assert_eq!(s, format!("/home/user/{relative}")); + assert!( + !s.contains('\\'), + "el argumento al guest no puede llevar separadores del host: {s}" + ); + } + } + + #[test] + fn wsl_resolver_reads_through_the_adapter() { + let mock = Arc::new(MockWslAdapter::new()); + mock.set_file( + "Ubuntu-22.04", + PathBuf::from("/home/user/.claude/.credentials.json"), + "{\"creds\":true}".to_string(), + ); + let r = SessionResolver::new( + SessionEnv::Wsl { + distro: "Ubuntu-22.04".into(), + }, + mock.clone(), + PathBuf::from("/home/user"), + ); + let contents = r + .read(Path::new("/home/user/.claude/.credentials.json")) + .expect("lee en la distro"); + assert_eq!(contents, "{\"creds\":true}"); + } + + #[test] + fn wsl_resolver_reports_absent_when_file_is_missing_in_the_distro() { + let mock = Arc::new(MockWslAdapter::new()); + mock.set_file( + "Ubuntu-22.04", + PathBuf::from("/home/user/.codex/auth.json"), + "{}".to_string(), + ); + let r = SessionResolver::new( + SessionEnv::Wsl { + distro: "Ubuntu-22.04".into(), + }, + mock.clone(), + PathBuf::from("/home/user"), + ); + // El fichero de Claude no está en la distro: "necesita login", no + // un fallo de WSL. + let err = r + .read(Path::new("/home/user/.claude/.credentials.json")) + .unwrap_err(); + assert!(matches!(err, SessionError::Absent(_))); + } + + #[test] + fn wsl_resolver_propagates_subsystem_failure_as_wsl_error() { + let mock = Arc::new(MockWslAdapter::new()); + // WSL no está instalado: el fallo es del subsistema, no de la sesión. + mock.set_installed(false); + let r = SessionResolver::new( + SessionEnv::Wsl { + distro: "Ubuntu-22.04".into(), + }, + mock.clone(), + PathBuf::from("/home/user"), + ); + let err = r + .read(Path::new("/home/user/.claude/.credentials.json")) + .unwrap_err(); + assert!(matches!(err, SessionError::Wsl(WslError::WslNotInstalled))); + } + + #[test] + fn explicit_path_wins_over_the_default() { + let mock = Arc::new(MockWslAdapter::new()); + let r = SessionResolver::new( + SessionEnv::Host, + mock.clone(), + PathBuf::from("/home/hostuser"), + ); + let p = r + .resolve( + Some("/mnt/c/Users/u/custom.json"), + Path::new("/home/u/default.json"), + ) + .expect("explícita"); + assert_eq!(p, PathBuf::from("/mnt/c/Users/u/custom.json")); + } + + #[test] + fn blank_explicit_path_falls_back_to_the_default() { + let mock = Arc::new(MockWslAdapter::new()); + let r = SessionResolver::new( + SessionEnv::Host, + mock.clone(), + PathBuf::from("/home/hostuser"), + ); + let p = r + .resolve(Some(" "), Path::new("/home/u/default.json")) + .expect("default"); + assert_eq!(p, PathBuf::from("/home/u/default.json")); + } + + /// La ruta explícita en modo WSL se valida como ruta del GUEST + /// (revisión del mantenedor, 2026-11-04): una UNC (`\\wsl.localhost\…`) + /// viajaría literal al `cat` y fallaría como «no existe», ocultando un + /// error de configuración tras «Sin sesión». Se devuelve `Invalid`. + #[test] + fn explicit_host_style_paths_are_rejected_in_wsl_mode() { + let mock = Arc::new(MockWslAdapter::new()); + let r = SessionResolver::for_wsl( + mock.clone(), + "Ubuntu-22.04".into(), + PathBuf::from("/home/user"), + ); + for bad in [ + r"\\wsl.localhost\Ubuntu\home\user\.claude\.credentials.json", + r"C:\Users\yo\.claude\.credentials.json", + "relativa.json", + ] { + let err = r + .resolve(Some(bad), Path::new("/home/user/.claude/.credentials.json")) + .expect_err("ruta no POSIX del guest en modo WSL"); + assert!( + matches!(err, SessionError::Invalid(_)), + "debe ser Invalid: {bad}" + ); + } + // Una ruta POSIX absoluta del guest es válida. + assert!(r + .resolve( + Some("/mnt/c/Users/yo/.claude/.credentials.json"), + Path::new("/home/user/.claude/.credentials.json"), + ) + .is_ok()); + // En host, la ruta explícita de Windows sigue siendo válida (legado). + let host = SessionResolver::for_host(mock.clone(), PathBuf::from("/home/hostuser")); + assert_eq!( + host.resolve( + Some(r"C:\Users\yo\.claude\.credentials.json"), + Path::new("/home/hostuser/.claude/.credentials.json"), + ) + .expect("host sin validación guest"), + PathBuf::from(r"C:\Users\yo\.claude\.credentials.json") + ); + } + + /// La lectura asíncrona devuelve lo mismo que la síncrona: en WSL espera + /// a `wsl.exe`, y lo hace en un hilo propio para no congelar el + /// executor (revisión PR #220). + #[test] + fn read_async_returns_the_same_content_as_read() { + let mock = Arc::new(MockWslAdapter::new()); + mock.set_file( + "Ubuntu-22.04", + PathBuf::from("/home/user/.claude/.credentials.json"), + "{\"creds\":true}".to_string(), + ); + let r = SessionResolver::for_wsl( + mock.clone(), + "Ubuntu-22.04".into(), + PathBuf::from("/home/user"), + ); + let path = PathBuf::from("/home/user/.claude/.credentials.json"); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime de un hilo"); + let contents = rt.block_on(r.read_async(&path)).expect("lee en la distro"); + assert_eq!(contents, "{\"creds\":true}"); + } +}