From 2fd08aae5d50db518eeb641122d9ee975dcc4287 Mon Sep 17 00:00:00 2001 From: Mathieu JESER <6737167+bobdivx@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:54:02 +0200 Subject: [PATCH] feat(admin): connect the Cursor worker without an API key (cursor-agent login) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Cursor user API keys (grok_…) are not yet accepted by the self-hosted worker's key validation (known Cursor bug: liveness 404 valid:false), and the CLI reports "The provided API key is invalid". Cursor's documented workaround is the CLI's browser login, which the worker supports for My Machines ("Sign in with agent login, a personal user API key, or a user-scoped token"). - POST /api/v1/cursor-worker/login: installs the CLI if needed, runs `cursor-agent login` with NO_OPEN_BROWSER in the managed HOME and returns the cursor.com login URL; clears the stored (refused) key. GET /api/v1/cursor-worker/login: pending / logged_in. - Worker start: API key if one is stored, otherwise the login session (CURSOR_API_KEY removed from the environment, managed HOME). Install no longer requires a key when logged in (this server only). - Status: logged_in, auth (key/login/none), key_unsupported (grok_ key). - UI: « Se connecter avec Cursor » in the install modal: opens the Cursor login link, polls until validated, then installs without a key. grok_ keys get a non-blocking warning pointing to the login. - Prefer the `cursor-agent` binary over `agent` (name collision). --- apps/server/src/cursor_worker.rs | 184 +++++++++++++++++- apps/server/src/cursor_worker_routes.rs | 72 ++++++- apps/server/src/cursor_worker_tests.rs | 56 +++++- .../CursorWorkerSettingsPanel.test.tsx | 36 +++- .../components/CursorWorkerSettingsPanel.tsx | 149 ++++++++++++-- apps/web/src/lib/api.ts | 13 ++ 6 files changed, 477 insertions(+), 33 deletions(-) diff --git a/apps/server/src/cursor_worker.rs b/apps/server/src/cursor_worker.rs index 17adeaf68..2e752e303 100644 --- a/apps/server/src/cursor_worker.rs +++ b/apps/server/src/cursor_worker.rs @@ -97,8 +97,16 @@ pub fn managed_home() -> PathBuf { } /// Binaire `agent` installé par DevForge. +/// +/// `cursor-agent` d'abord : `agent` peut être écrasé par un autre CLI du +/// même nom (collision connue), `cursor-agent` pointe toujours vers Cursor. pub fn managed_binary() -> PathBuf { - managed_home().join(".local").join("bin").join("agent") + let bin = managed_home().join(".local").join("bin"); + let preferred = bin.join("cursor-agent"); + if preferred.is_file() { + return preferred; + } + bin.join("agent") } /// Dossier vide servi quand aucun checkout Git n'existe encore. @@ -821,12 +829,14 @@ pub fn start_named( if is_running() { return Err("Worker déjà en cours — arrête-le avant de relancer.".into()); } - let Some((api_key, _)) = effective_api_key(settings) else { + // Clé API si enregistrée, sinon session `cursor-agent login` (HOME géré). + let api_key = effective_api_key(settings).map(|(k, _)| k); + if api_key.is_none() && !is_logged_in(bin) { return Err( - "Clé API Cursor manquante. Dashboard Cursor → API Keys (clé personnelle), puis enregistre-la ici." + "Ni clé API Cursor ni connexion Cursor : utilise « Se connecter avec Cursor » ou colle une clé." .into(), ); - }; + } ensure_state_dir()?; let workdirs = workdirs_or_fallback(workdirs); let args = build_worker_args(name, &workdirs)?; @@ -835,10 +845,19 @@ pub fn start_named( let mut cmd = Command::new(bin); cmd.args(&args) - .env("CURSOR_API_KEY", &api_key) .stdin(Stdio::null()) .stdout(Stdio::from(out)) .stderr(Stdio::from(err)); + match &api_key { + Some(k) => { + cmd.env("CURSOR_API_KEY", k); + } + None => { + // Session de connexion : jamais une clé héritée de l'environnement. + cmd.env_remove("CURSOR_API_KEY"); + cmd.env("HOME", managed_home()); + } + } if bin.starts_with(managed_home()) { // Config / sessions du CLI dans le volume persistant. cmd.env("HOME", managed_home()); @@ -921,6 +940,11 @@ pub async fn status_json(pool: &PgPool, node_name: &str) -> Result Result PathBuf { + state_dir().join("login.out") +} + +fn login_pid_path() -> PathBuf { + state_dir().join("login.pid") +} + +/// Sortie de `cursor-agent status` → connecté ? +pub fn parse_logged_in(status_out: &str) -> bool { + let l = strip_ansi(status_out).to_lowercase(); + if l.contains("not logged in") || l.contains("not authenticated") { + return false; + } + l.contains("logged in") || l.contains("authenticated") +} + +fn strip_ansi(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut chars = s.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\u{1b}' { + if chars.peek() == Some(&'[') { + chars.next(); + for n in chars.by_ref() { + if n.is_ascii_alphabetic() { + break; + } + } + } + continue; + } + out.push(c); + } + out +} + +/// URL de connexion affichée par `cursor-agent login` (NO_OPEN_BROWSER). +pub fn parse_login_url(out: &str) -> Option { + strip_ansi(out) + .split_whitespace() + .find(|w| w.starts_with("https://cursor.com/") || w.starts_with("https://www.cursor.com/")) + .map(|w| w.trim_end_matches(['.', ',', ')']).to_string()) +} + +/// Session CLI valide dans le HOME géré (`cursor-agent status`, sans clé). +pub fn is_logged_in(bin: &Path) -> bool { + let out = Command::new(bin) + .arg("status") + .env("HOME", managed_home()) + .env("NO_OPEN_BROWSER", "1") + .env_remove("CURSOR_API_KEY") + .stdin(Stdio::null()) + .output(); + match out { + Ok(o) => parse_logged_in(&format!( + "{}\n{}", + String::from_utf8_lossy(&o.stdout), + String::from_utf8_lossy(&o.stderr) + )), + Err(_) => false, + } +} + +fn login_pending_pid() -> Option { + let raw = std::fs::read_to_string(login_pid_path()).ok()?; + let pid: u32 = raw.trim().parse().ok()?; + pid_is_alive(pid).then_some(pid) +} + +/// Lance `cursor-agent login` (sans navigateur) et renvoie l'URL à ouvrir. +/// Le process attend la validation dans le navigateur puis enregistre la +/// session dans [`managed_home`]. +pub fn login_start(bin: &Path) -> Result { + ensure_state_dir()?; + std::fs::create_dir_all(managed_home()).map_err(|e| e.to_string())?; + if let Some(pid) = login_pending_pid() { + let _ = kill_pid(pid); + } + let out = std::fs::File::create(login_out_path()).map_err(|e| e.to_string())?; + let err = out.try_clone().map_err(|e| e.to_string())?; + let child = Command::new(bin) + .arg("login") + .env("HOME", managed_home()) + .env("NO_OPEN_BROWSER", "1") + .env_remove("CURSOR_API_KEY") + .stdin(Stdio::null()) + .stdout(Stdio::from(out)) + .stderr(Stdio::from(err)) + .spawn() + .map_err(|e| format!("Échec `cursor-agent login` : {e}"))?; + let pid = child.id(); + std::fs::write(login_pid_path(), pid.to_string()).map_err(|e| e.to_string())?; + let _ = std::thread::Builder::new() + .name("cursor-login-reap".into()) + .spawn(move || { + let mut child = child; + let _ = child.wait(); + }); + for _ in 0..40 { + std::thread::sleep(std::time::Duration::from_millis(250)); + let raw = std::fs::read_to_string(login_out_path()).unwrap_or_default(); + if let Some(url) = parse_login_url(&raw) { + return Ok(url); + } + if !pid_is_alive(pid) { + return Err(format!( + "`cursor-agent login` s'est arrêté.\n{}", + tail_lines(&strip_ansi(&raw), 10) + )); + } + } + Err("`cursor-agent login` n'a pas donné d'URL de connexion (10 s).".into()) +} + +/// État de la connexion : (en attente, URL, connecté). +pub fn login_state(bin: Option<&Path>) -> Value { + let pending = login_pending_pid().is_some(); + let raw = std::fs::read_to_string(login_out_path()).unwrap_or_default(); + let logged_in = bin.is_some_and(is_logged_in); + json!({ + "pending": pending && !logged_in, + "url": if pending { parse_login_url(&raw) } else { None }, + "logged_in": logged_in, + }) +} + +/// Les clés `grok_` (nouvelles clés à permissions) ne sont pas encore +/// acceptées par la validation du worker côté Cursor (bug connu, oct. 2026). +pub fn key_unsupported_by_worker(key: &str) -> bool { + key.trim().starts_with("grok_") +} + +/// Clé `grok_` refusée par le worker. +pub const GROK_KEY_HINT: &str = + "Les clés grok_ ne sont pas encore acceptées par le worker Cursor : utilise « Se connecter avec Cursor »."; + /// Message affiché quand Cursor a refusé la clé. pub const INVALID_KEY_HINT: &str = - "Cursor a refusé la clé API. Colle une clé Cursor (cursor.com/dashboard → API Keys) puis réinstalle."; + "Cursor a refusé la clé API. Utilise « Se connecter avec Cursor » ou colle une autre clé Cursor."; /// Phrase d'état unique (sans commande à taper). pub fn status_hint(cli: bool, key: bool, running: bool, ready: Option) -> &'static str { diff --git a/apps/server/src/cursor_worker_routes.rs b/apps/server/src/cursor_worker_routes.rs index 25ef7837a..4f3ffd721 100644 --- a/apps/server/src/cursor_worker_routes.rs +++ b/apps/server/src/cursor_worker_routes.rs @@ -25,6 +25,10 @@ pub fn router() -> Router { Router::new() .route("/api/v1/cursor-worker/status", get(status)) .route("/api/v1/cursor-worker/install", post(install)) + .route( + "/api/v1/cursor-worker/login", + get(login_status).post(login_start), + ) .route("/api/v1/cursor-worker/start", post(start)) .route("/api/v1/cursor-worker/stop", post(stop)) .route("/api/v1/cursor-worker/restart", post(restart)) @@ -362,16 +366,21 @@ async fn install( )); } } - let Some((api_key, _)) = cursor_worker::effective_api_key(&settings) else { + // Sans clé : session `cursor-agent login` (leader uniquement). + let api_key = cursor_worker::effective_api_key(&settings).map(|(k, _)| k); + let logged_in = api_key.is_none() + && cursor_worker::resolve_agent_binary(&settings) + .is_some_and(|b| cursor_worker::is_logged_in(&b)); + if api_key.is_none() && !logged_in { return Err(( StatusCode::BAD_REQUEST, Json(json!({ "ok": false, "code": "api_key_required", - "error": "Clé API Cursor requise (Cursor → Dashboard → API Keys, clé personnelle).", + "error": "Connecte-toi avec Cursor (bouton « Se connecter avec Cursor ») ou colle une clé API Cursor.", })), )); - }; + } // 2. Nœud : leader par défaut. let nodes = worker_nodes(&state).await; @@ -386,6 +395,12 @@ async fn install( if !node.online { return Err(map_err(format!("Nœud {} hors ligne", node.name))); } + if api_key.is_none() && !node.leader { + return Err(map_err( + "La connexion Cursor (sans clé) ne marche que sur ce serveur : choisis-le ou colle une clé." + .into(), + )); + } let previous_node = settings.node_id.clone(); settings.node_id = node.id.clone(); settings.enabled = true; @@ -409,7 +424,15 @@ async fn install( let result = if node.leader { install_leader(&state, &settings, &name, &mut steps).await } else { - install_remote(&state, &settings, &node, &name, &api_key, &mut steps).await + install_remote( + &state, + &settings, + &node, + &name, + api_key.as_deref().unwrap_or_default(), + &mut steps, + ) + .await }; let ok = result.is_ok(); let key_invalid = match &result { @@ -932,3 +955,44 @@ mod tests { assert_eq!(steps[0]["detail"], "Connecté à Cursor"); } } + +// ───────────────────────── Connexion Cursor (sans clé) ───────────────────────── + +/// Démarre `cursor-agent login` sur ce serveur (installe le CLI si besoin) et +/// renvoie l'URL à ouvrir. La clé enregistrée est effacée : la connexion la +/// remplace (une clé invalide ne doit pas reprendre la main). +async fn login_start( + State(state): State, + headers: HeaderMap, +) -> Result, ApiErr> { + require_admin(&state, &headers).await?; + let settings = cursor_worker::load_settings(&state.pool).await; + let bin = match cursor_worker::resolve_agent_binary(&settings) { + Some(b) => b, + None => { + cursor_worker::install_cli_local().await.map_err(map_err)?; + cursor_worker::resolve_agent_binary(&settings) + .ok_or_else(|| map_err("CLI Cursor introuvable après installation".into()))? + } + }; + let url = tokio::task::spawn_blocking(move || cursor_worker::login_start(&bin)) + .await + .map_err(|e| map_err(e.to_string()))? + .map_err(map_err)?; + save_columns(&state, settings.enabled, "", "").await?; + cursor_worker::clear_logs_if_stopped(); + Ok(Json(json!({ "ok": true, "url": url, "pending": true }))) +} + +async fn login_status( + State(state): State, + headers: HeaderMap, +) -> Result, ApiErr> { + require_admin(&state, &headers).await?; + let settings = cursor_worker::load_settings(&state.pool).await; + let bin = cursor_worker::resolve_agent_binary(&settings); + let v = tokio::task::spawn_blocking(move || cursor_worker::login_state(bin.as_deref())) + .await + .map_err(|e| map_err(e.to_string()))?; + Ok(Json(v)) +} diff --git a/apps/server/src/cursor_worker_tests.rs b/apps/server/src/cursor_worker_tests.rs index 38658e147..d9242d824 100644 --- a/apps/server/src/cursor_worker_tests.rs +++ b/apps/server/src/cursor_worker_tests.rs @@ -241,5 +241,59 @@ fn detects_invalid_key_in_worker_logs() { #[test] fn invalid_key_hint_points_to_cursor_dashboard() { - assert!(INVALID_KEY_HINT.contains("cursor.com/dashboard")); + assert!(INVALID_KEY_HINT.contains("Se connecter avec Cursor")); + assert!(GROK_KEY_HINT.contains("grok_")); +} + +#[test] +fn login_status_parsing() { + assert!(!parse_logged_in("Not logged in\n")); + assert!(!parse_logged_in("\u{1b}[31mNot logged in\u{1b}[0m")); + assert!(parse_logged_in( + "\u{1b}[32m✓\u{1b}[0m Logged in as someone@example.com" + )); + assert!(!parse_logged_in("")); +} + +#[test] +fn login_url_parsing() { + let out = "Starting login process...\nWaiting for browser authentication...\nOpen a browser and navigate to this link: https://cursor.com/loginDeepControl?challenge=abc&uuid=def&mode=login&redirectTarget=cli\n"; + assert_eq!( + parse_login_url(out).as_deref(), + Some("https://cursor.com/loginDeepControl?challenge=abc&uuid=def&mode=login&redirectTarget=cli") + ); + assert_eq!(parse_login_url("no url here"), None); + assert_eq!(parse_login_url("see https://evil.example.com/x"), None); +} + +#[test] +fn grok_keys_flagged_for_worker() { + assert!(key_unsupported_by_worker("grok_0123_abcd")); + assert!(!key_unsupported_by_worker("crsr_0123456789abcdef")); + // grok_ n'est pas rejeté (Cursor pourra le corriger), seulement signalé. + assert!(validate_api_key("grok_0123456789abcdef0123456789abcdef_x").is_ok()); +} + +/// Démarrage sans clé : exige une session `login` (faux CLI « Not logged in »). +#[cfg(target_os = "linux")] +#[test] +fn start_without_key_requires_login() { + use std::os::unix::fs::PermissionsExt; + let dir = std::env::temp_dir().join(format!("cw-login-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let fake = dir.join("cursor-agent"); + std::fs::write( + &fake, + "#!/bin/sh\n[ \"$1\" = status ] && echo 'Not logged in'\nexit 0\n", + ) + .unwrap(); + std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap(); + assert!(!is_logged_in(&fake)); + std::fs::write( + &fake, + "#!/bin/sh\n[ \"$1\" = status ] && echo 'Logged in as me@example.com'\nexit 0\n", + ) + .unwrap(); + assert!(is_logged_in(&fake)); + let _ = std::fs::remove_dir_all(&dir); } diff --git a/apps/web/src/components/CursorWorkerSettingsPanel.test.tsx b/apps/web/src/components/CursorWorkerSettingsPanel.test.tsx index 0eb15505c..dd3e38def 100644 --- a/apps/web/src/components/CursorWorkerSettingsPanel.test.tsx +++ b/apps/web/src/components/CursorWorkerSettingsPanel.test.tsx @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vitest'; -import { cursorKeyProblem, installNeeds, workerSummary } from './CursorWorkerSettingsPanel'; +import { + cursorKeyProblem, + cursorKeyWarning, + installNeeds, + workerSummary, +} from './CursorWorkerSettingsPanel'; const base = { running: false, @@ -17,7 +22,10 @@ describe('workerSummary', () => { }); it('reports running / connecting / stopped', () => { const ok = { ...base, cli_installed: true, api_key_set: true }; - expect(workerSummary({ ...ok, running: true, ready: true })).toMatchObject({ label: 'ON', tone: 'ok' }); + expect(workerSummary({ ...ok, running: true, ready: true })).toMatchObject({ + label: 'ON', + tone: 'ok', + }); expect(workerSummary({ ...ok, running: true, ready: false })).toMatchObject({ tone: 'accent' }); expect(workerSummary(ok)).toMatchObject({ label: 'Off', installed: true }); }); @@ -50,3 +58,27 @@ describe('invalid / foreign keys', () => { expect(cursorKeyProblem('')).toBeNull(); }); }); + +describe('Cursor login (no key)', () => { + it('does not require a key once logged in', () => { + expect(installNeeds({ api_key_set: false, logged_in: true })).toEqual([]); + const s = { ...base, cli_installed: true, logged_in: true }; + expect(workerSummary(s)).toMatchObject({ label: 'Off', installed: true }); + }); + it('warns (without blocking) about grok_ keys', () => { + const k = 'grok_' + 'a'.repeat(32) + '_' + 'b'.repeat(64); + expect(cursorKeyProblem(k)).toBeNull(); + expect(cursorKeyWarning(k)).toMatch(/Se connecter avec Cursor/); + expect(cursorKeyWarning('crsr_0123456789abcdef')).toBeNull(); + }); + it('points a refused grok_ key to the login', () => { + const s = { + ...base, + cli_installed: true, + api_key_set: true, + key_invalid: true, + key_unsupported: true, + }; + expect(workerSummary(s).detail).toMatch(/grok_/); + }); +}); diff --git a/apps/web/src/components/CursorWorkerSettingsPanel.tsx b/apps/web/src/components/CursorWorkerSettingsPanel.tsx index 09355632f..b1e22f23d 100644 --- a/apps/web/src/components/CursorWorkerSettingsPanel.tsx +++ b/apps/web/src/components/CursorWorkerSettingsPanel.tsx @@ -61,7 +61,7 @@ export function workerSummary( s: Pick< CursorWorkerStatus, 'running' | 'ready' | 'cli_installed' | 'api_key_set' | 'node_missing' | 'node_error' - > & { key_invalid?: boolean }, + > & { key_invalid?: boolean; key_unsupported?: boolean; logged_in?: boolean }, ): { label: string; tone: Tone; detail: string; installed: boolean } { if (s.node_missing) { return { @@ -83,7 +83,7 @@ export function workerSummary( return { label: 'Clé KO', tone: 'danger', - detail: 'Clé API refusée par Cursor', + detail: s.key_unsupported ? 'Clé grok_ refusée : se connecter' : 'Clé API refusée par Cursor', installed: false, }; } @@ -97,7 +97,7 @@ export function workerSummary( } : { label: 'ON', tone: 'ok', detail: 'Connecté', installed: true }; } - if (!s.api_key_set || !s.cli_installed) { + if ((!s.api_key_set && !s.logged_in) || !s.cli_installed) { return { label: 'À installer', tone: 'warn', @@ -113,9 +113,97 @@ export function workerSummary( * enregistrée ou si Cursor a refusé la clé actuelle. Exporté pour les tests. */ export function installNeeds( - s: (Pick & { key_invalid?: boolean }) | null, + s: + | (Pick & { key_invalid?: boolean; logged_in?: boolean }) + | null, ): Array<'api_key'> { - return s && (!s.api_key_set || s.key_invalid) ? ['api_key'] : []; + if (!s || s.logged_in) return []; + return !s.api_key_set || s.key_invalid ? ['api_key'] : []; +} + +/** Avertissement non bloquant (clé probablement inutilisable par le worker). */ +export function cursorKeyWarning(raw: string): string | null { + return raw.trim().startsWith('grok_') + ? 'Les clés grok_ ne sont pas encore acceptées par le worker Cursor (bug Cursor connu). Préfère « Se connecter avec Cursor ».' + : null; +} + +/** + * Connexion Cursor sans clé (`cursor-agent login`) : DevForge donne le lien, + * l’utilisateur valide dans son navigateur, la session est gardée sur le serveur. + */ +function CursorLogin({ loggedIn, onLoggedIn }: { loggedIn: boolean; onLoggedIn: () => void }) { + const [url, setUrl] = useState(null); + const [busy, setBusy] = useState(false); + const [done, setDone] = useState(loggedIn); + const [err, setErr] = useState(null); + + useEffect(() => setDone(loggedIn), [loggedIn]); + useEffect(() => { + if (!url || done) return; + const t = setInterval(async () => { + try { + const r = await api.cursorWorkerLoginStatus(); + if (r.logged_in) { + setDone(true); + setUrl(null); + onLoggedIn(); + } else if (!r.pending) { + setErr('La connexion a expiré : recommence.'); + setUrl(null); + } + } catch { + /* réessaie au prochain tour */ + } + }, 3000); + return () => clearInterval(t); + }, [url, done]); + + async function start() { + setBusy(true); + setErr(null); + try { + const r = await api.cursorWorkerLoginStart(); + setUrl(r.url); + } catch (e) { + setErr(String((e as Error).message || e)); + } finally { + setBusy(false); + } + } + + if (done) { + return Connecté à Cursor : aucune clé nécessaire.; + } + return ( +
+
Se connecter avec Cursor
+

+ Recommandé : pas de clé à copier. Tu valides la connexion dans ton navigateur avec ton + compte Cursor ; la clé enregistrée est alors remplacée. +

+ {url ? ( +
+ + Ouvrir la connexion Cursor + +
+ En attente de la validation dans le navigateur… +
+
+ ) : ( + + )} + {err ?

{err}

: null} +
+ ); } const FOREIGN_KEYS: Array<[string, string]> = [ @@ -161,6 +249,7 @@ function KeyField({ required: boolean; }) { const problem = cursorKeyProblem(value); + const warning = problem ? null : cursorKeyWarning(value); const placeholder = status.api_key_set && !required ? `${status.api_key_masked ?? '••••'} — laisser vide pour conserver` @@ -179,6 +268,7 @@ function KeyField({ onInput={(e) => onInput((e.target as HTMLInputElement).value)} /> {problem ?

{problem}

: null} + {warning ?

{warning}

: null} {status.env_key_present ? (

{status.api_key_source === 'env' @@ -554,9 +644,11 @@ function InstallModal({ value={ status.key_invalid ? 'Refusée par Cursor — à remplacer' - : status.api_key_set - ? `${status.api_key_masked ?? 'enregistrée'}${status.api_key_source === 'env' ? ' (serveur)' : ''}` - : 'À fournir ci-dessous' + : status.logged_in + ? 'Connexion Cursor (sans clé)' + : status.api_key_set + ? `${status.api_key_masked ?? 'enregistrée'}${status.api_key_source === 'env' ? ' (serveur)' : ''}` + : 'À fournir ci-dessous' } /> @@ -581,16 +673,31 @@ function InstallModal({ ) : null} {status.key_invalid ? ( - Cursor a refusé la clé enregistrée ({status.api_key_masked}). Colle une clé Cursor - personnelle : cursor.com/dashboard → API Keys. + {status.key_unsupported + ? `Cursor a refusé la clé grok_ (${status.api_key_masked}) : le worker ne les accepte pas encore. Connecte-toi avec Cursor ci-dessous.` + : `Cursor a refusé la clé enregistrée (${status.api_key_masked}). Connecte-toi avec Cursor ou colle une autre clé.`} ) : null} - + {node?.leader !== false ? ( + { + setApiKey(''); + onDone(); + }} + /> + ) : null} + {status.logged_in ? null : ( +

ou
+ )} + {status.logged_in ? null : ( + + )} {err ? {err} : null}

Le worker exécute les agents Cursor sur cette machine (fichiers, terminal). La @@ -678,7 +785,7 @@ function StatusModal({