From b48a0e352ab28e1c2e9a34e1fdc2ff40af5a2433 Mon Sep 17 00:00:00 2001 From: jose luis <2064537+sosan@users.noreply.github.com> Date: Sat, 7 Nov 2026 01:24:31 +0100 Subject: [PATCH 1/2] feat(wsl): Ajustes activa el modo WSL y mejoras del seguimiento #243 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Los cinco pendientes de la issue #243 (seguimiento de #91): 1. Regresión directa del WslCall desde fetch_account: el invitado recibe la ruta POSIX exacta de la cuenta adicional (fetch_account_sends_the_exact_guest_path_for_extra_accounts), con token vencido que responde NeedsLogin sin tocar la red. 2. Ajustes permite activar el modo WSL: select «Sesión de los CLIs» (Windows/WSL) y campo «Distro WSL» en la tarjeta de Proveedores; sessionEnv y wslDistro en el tipo AppConfig del frontend. 3. El sello lee en PARALELO (join_all de read_async) y las rutas salen de la fuente única (session_relative). El límite «fallo de distro = misma huella» queda documentado. 4. Distino vacía → host: documentado como intencional (nunca inventa una distro); el estado «sin configurar» espera a la UI de este PR. 5. expect("proveedor CLI") eliminado: enum CliSessionKind con match exhaustivo (session_relative), sin Option ni expect posible. Además: la regla «POSIX absoluta del guest» vive en UNA sola fuente (wsl::guest_path_problem) usada por resolve, session_path y account_session_path. Revisión de la skill (final, APPROVE): join_all del sello real en el working tree, cero expect en runtime, test determinista sin red y serde coherente con el frontend. Co-Authored-By: Claude Opus 5.5 (1M context) --- src-tauri/src/providers/claude.rs | 6 +- src-tauri/src/providers/codex.rs | 7 +- src-tauri/src/providers/gemini.rs | 7 +- src-tauri/src/providers/grok.rs | 7 +- src-tauri/src/providers/mod.rs | 116 ++++++++++++++++++++++----- src/Settings-providers-i18n.test.tsx | 5 +- src/Settings.tsx | 21 ++++- src/i18n/en.ts | 5 ++ src/i18n/es.ts | 5 ++ src/lib/types.ts | 4 + 10 files changed, 141 insertions(+), 42 deletions(-) diff --git a/src-tauri/src/providers/claude.rs b/src-tauri/src/providers/claude.rs index 26142f4..436f1ea 100644 --- a/src-tauri/src/providers/claude.rs +++ b/src-tauri/src/providers/claude.rs @@ -185,11 +185,7 @@ pub async fn fetch( // (`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(), - ), + Some(r) => r.default_path(super::session_relative(super::CliSessionKind::Claude).as_path()), None => home().join(".claude").join(".credentials.json"), }; let creds = match resolver { diff --git a/src-tauri/src/providers/codex.rs b/src-tauri/src/providers/codex.rs index 57e4d2b..c47ffa8 100644 --- a/src-tauri/src/providers/codex.rs +++ b/src-tauri/src/providers/codex.rs @@ -63,11 +63,8 @@ pub async fn fetch( // 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 default = + r.default_path(super::session_relative(super::CliSessionKind::Codex).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()), diff --git a/src-tauri/src/providers/gemini.rs b/src-tauri/src/providers/gemini.rs index dc93027..400b103 100644 --- a/src-tauri/src/providers/gemini.rs +++ b/src-tauri/src/providers/gemini.rs @@ -41,11 +41,8 @@ pub async fn fetch( // 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 default = + r.default_path(super::session_relative(super::CliSessionKind::Gemini).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()), diff --git a/src-tauri/src/providers/grok.rs b/src-tauri/src/providers/grok.rs index 9bd50d3..8b17750 100644 --- a/src-tauri/src/providers/grok.rs +++ b/src-tauri/src/providers/grok.rs @@ -73,11 +73,8 @@ pub async fn fetch( // 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 default = + r.default_path(super::session_relative(super::CliSessionKind::Grok).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()), diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs index d4c5013..c42e860 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -248,18 +248,27 @@ pub fn session_file(cfg: &AppConfig, id: &str, home: &Path) -> Option { } /// Consulta una cuenta adicional con la config de su proveedor adaptada a ella. +/// Proveedor con sesión CLI, para `session_relative` (una sola fuente, +/// sin `expect` en runtime; revisión del mantenedor, 2026-11-06). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum CliSessionKind { + Claude, + Codex, + Gemini, + Grok, +} + /// 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, +/// los cuatro `fetch` y `wsl_session_stamp`). Match exhaustivo sobre el +/// enum: no hay `expect` posible. +pub(crate) fn session_relative(kind: CliSessionKind) -> PathBuf { + let parts: &[&str] = match kind { + CliSessionKind::Claude => &[".claude", ".credentials.json"], + CliSessionKind::Codex => &[".codex", "auth.json"], + CliSessionKind::Gemini => &[".gemini", "oauth_creds.json"], + CliSessionKind::Grok => &[".grok", "auth.json"], }; - Some(crate::wsl::guest_relative(parts)) + crate::wsl::guest_relative(parts) } /// La ruta de sesión de una cuenta ADICIONAL en el entorno del resolvedor @@ -560,9 +569,9 @@ fn host_session_stamp(cfg: &AppConfig) -> u64 { /// 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"); + // (`session_relative` sobre el enum; revisión del mantenedor, 2026-11-06). + let main = |c: &SubscriptionConfig, kind: CliSessionKind| { + let relative = session_relative(kind); r.resolve( c.credentials_path.as_deref(), &r.default_path(relative.as_path()), @@ -570,16 +579,16 @@ async fn wsl_session_stamp(cfg: &AppConfig, r: &SessionResolver) -> u64 { .ok() }; let mut paths: Vec = Vec::new(); - if let Some(p) = main(&cfg.claude, "claude") { + if let Some(p) = main(&cfg.claude, CliSessionKind::Claude) { paths.push(p); } - if let Some(p) = main(&cfg.codex, "codex") { + if let Some(p) = main(&cfg.codex, CliSessionKind::Codex) { paths.push(p); } - if let Some(p) = main(&cfg.gemini, "gemini") { + if let Some(p) = main(&cfg.gemini, CliSessionKind::Gemini) { paths.push(p); } - if let Some(p) = main(&cfg.grok, "grok") { + if let Some(p) = main(&cfg.grok, CliSessionKind::Grok) { paths.push(p); } for a in cfg.accounts.iter().filter(|a| a.enabled) { @@ -590,9 +599,13 @@ async fn wsl_session_stamp(cfg: &AppConfig, r: &SessionResolver) -> u64 { paths.push(PathBuf::from(path)); } } + // Lecturas en PARALELO (issue #243): el sello corre cada 15 s y no debe + // pagar un `wsl.exe cat` por proveedor en serie. La suma es conmutativa + // (wrapping_add), así que el orden de resultados no altera la huella. + let results = futures::future::join_all(paths.iter().map(|p| r.read_async(p))).await; let mut acc = 0u64; - for p in paths { - if let Ok(contents) = r.read_async(&p).await { + for result in results { + if let Ok(contents) = result { acc = acc.wrapping_add(content_hash(contents.as_bytes())); } } @@ -625,7 +638,7 @@ pub(crate) fn net_error(e: reqwest::Error) -> String { #[cfg(test)] mod tests { use super::*; - use crate::wsl::{distro, DistroState, MockWslAdapter}; + use crate::wsl::{distro, DistroState, MockWslAdapter, ShellStrategy}; use std::sync::Arc; /// #91 (revisión PR #220): el sello en WSL observa los ficheros DENTRO de @@ -731,6 +744,69 @@ mod tests { /// 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.) + + /// Regresión directa del `WslCall` desde `fetch_account` (issue #243, + /// revisión del mantenedor): el invitado debe recibir la ruta POSIX + /// EXACTA de la cuenta adicional. La sesión de Claude con token vencido + /// responde NeedsLogin SIN tocar la red, así que el test es + /// determinista: `expiresAt` en el pasado. + #[test] + fn fetch_account_sends_the_exact_guest_path_for_extra_accounts() { + let mock = Arc::new(MockWslAdapter::new()); + mock.set_distributions(vec![distro("Ubuntu-22.04", DistroState::Running)]); + mock.set_shell("Ubuntu-22.04", ShellStrategy::BashLogin); + 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(), + r#"{"claudeAiOauth":{"accessToken":"vencido","expiresAt":1000}}"#.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"); + // El cliente se construye DENTRO del contexto del runtime (`enter`): + // el crate de tokio del harness no expone reactor fuera de él y + // `reqwest::Client::new` exige un Handle activo. + let _enter = rt.enter(); + let http = reqwest::Client::new(); + let report = rt + .block_on(tokio::time::timeout( + std::time::Duration::from_secs(10), + fetch_account(&cfg, &cfg.accounts[0], &http, Some(&r)), + )) + .expect("no debe colgar"); + let status = &report.status; + assert!( + matches!(status, crate::model::Status::NeedsLogin), + "token vencido: {status:?} {:?}", + report.message + ); + 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, llamadas: {:?}", + mock.calls() + ); + } /// 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). diff --git a/src/Settings-providers-i18n.test.tsx b/src/Settings-providers-i18n.test.tsx index 19d547e..8831252 100644 --- a/src/Settings-providers-i18n.test.tsx +++ b/src/Settings-providers-i18n.test.tsx @@ -39,6 +39,8 @@ const enabledOnly = (...ids: string[]) => { const on = (id: string) => ({ enabled: ids.includes(id), username: 'egdev6' }); return { refreshMinutes: 5, + sessionEnv: 'host', + wslDistro: '', claude: on('claude'), codex: on('codex'), gemini: on('gemini'), @@ -63,12 +65,13 @@ const cfg = enabledOnly('claude', 'copilot'); const card = (locale: Locale, tab: 'claude' | 'copilot' = 'claude', drawerOpen = true) => renderToStaticMarkup( - , + , ); const descriptions: [es: string, en: string][] = [ ['Pro / Max · sesión de 5 h, semanal y por modelo', 'Pro / Max · 5 h session, weekly and per-model'], ['Plus / Pro · sesión y semanal de Codex', 'Plus / Pro · Codex session and weekly'], + ['Sesión de los CLIs', 'CLI session host'], ['Cuota diaria por modelo', 'Daily quota per model'], ['Uso del plan en el ciclo y on-demand', 'Plan usage in the cycle and on-demand'], ['SuperGrok · uso semanal de créditos del CLI', 'SuperGrok · weekly CLI credit usage'], diff --git a/src/Settings.tsx b/src/Settings.tsx index 34f1e18..f451077 100644 --- a/src/Settings.tsx +++ b/src/Settings.tsx @@ -1290,7 +1290,7 @@ function ProviderCard({ locale, tab: p, state: st, extraAccounts, open, onSelect /** Tarjeta de la página «Proveedores»: intervalo de consulta y las dos listas * (suscripciones y APIs). Extraída para poder renderizarla en pruebas de i18n. */ -export function ProvidersCard({ locale, cfg, secrets, tab, drawerOpen, onSelectTab, onRefreshMinutes }: { +export function ProvidersCard({ locale, cfg, secrets, tab, drawerOpen, onSelectTab, onRefreshMinutes, onSessionEnv, onWslDistro }: { locale: Locale; cfg: AppConfig; secrets: SecretStatus; @@ -1298,6 +1298,8 @@ export function ProvidersCard({ locale, cfg, secrets, tab, drawerOpen, onSelectT drawerOpen: boolean; onSelectTab: (id: ProviderTab) => void; onRefreshMinutes: (minutes: number) => void; + onSessionEnv: (env: 'host' | 'wsl') => void; + onWslDistro: (distro: string) => void; }) { const providerCard = (p: (typeof PROVIDER_TABS)[number]) => ( onRefreshMinutes(Number(e.target.value) || 1)} /> {t(locale, 'settings.providers.refreshUnit')} + + {cfg.sessionEnv === 'wsl' && ( + + )}

{t(locale, 'settings.providers.subscriptions')}

@@ -2580,6 +2597,8 @@ export function Settings() { drawerOpen={drawerOpen} onSelectTab={selectTab} onRefreshMinutes={(minutes) => set('refreshMinutes', minutes)} + onSessionEnv={(env) => set('sessionEnv', env)} + onWslDistro={(distro) => set('wslDistro', distro)} />