From ec9e4ed64c49c4b6d85d4f07ffd42ad55c4aba8b Mon Sep 17 00:00:00 2001 From: Mathieu JESER <6737167+bobdivx@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:22:02 +0200 Subject: [PATCH] fix(admin): let the Cursor worker API key be replaced or removed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once a key was stored, the main "Réinstaller le worker Cursor" modal no longer showed a key field, and the only way to change it was a field in "Avancé" whose tile said everything was automatic. A wrong key (e.g. an xAI key) was accepted by the server and the refusal by Cursor was only visible in the logs, with the tile showing a plain "Arrêté". - Install modal always shows one masked "Clé API Cursor" field: the stored key is only shown as a masked placeholder, a typed value replaces it, empty keeps it. Required when no key is stored or when Cursor refused the current one. - When Cursor refused the key (worker logs), status returns key_invalid: the worker tile shows "Clé KO", the modal opens with the key field and a hint (cursor.com/dashboard → API Keys, not xAI); a failed install offers "Réessayer avec cette clé". - Advanced modal: replace the key, or delete it (with confirmation). - Server and UI reject obvious non-Cursor keys (xai-, sk-, sk-ant-, gsk_, AIza, ghp_, hf_…) with a clear message; install refuses to restart with a stored foreign key. - status/settings expose env_key_present (boolean only): the UI explains that a key saved in DevForge takes precedence over CURSOR_API_KEY. - Changing the key clears the stopped worker's old logs so the old "invalid key" error no longer shows. - Password manager autofill disabled on the key field (new-password). --- apps/server/src/cursor_worker.rs | 77 +++- apps/server/src/cursor_worker_routes.rs | 38 +- apps/server/src/cursor_worker_tests.rs | 30 ++ .../CursorWorkerSettingsPanel.test.tsx | 18 +- .../components/CursorWorkerSettingsPanel.tsx | 350 ++++++++++++++---- apps/web/src/lib/api.ts | 5 + 6 files changed, 435 insertions(+), 83 deletions(-) diff --git a/apps/server/src/cursor_worker.rs b/apps/server/src/cursor_worker.rs index d7758dc96..17adeaf68 100644 --- a/apps/server/src/cursor_worker.rs +++ b/apps/server/src/cursor_worker.rs @@ -204,20 +204,75 @@ pub fn effective_api_key(settings: &WorkerSettings) -> Option<(String, &'static .map(|k| (k, "env")) } +/// Préfixes de clés d'autres fournisseurs (collées par erreur à la place +/// d'une clé Cursor). +const FOREIGN_KEY_PREFIXES: &[(&str, &str)] = &[ + ("xai-", "xAI"), + ("sk-ant-", "Anthropic"), + ("sk-or-", "OpenRouter"), + ("sk-", "OpenAI"), + ("gsk_", "Groq"), + ("AIza", "Google"), + ("ghp_", "GitHub"), + ("gho_", "GitHub"), + ("github_pat_", "GitHub"), + ("hf_", "Hugging Face"), +]; + +/// Fournisseur reconnu si la clé n'est manifestement pas une clé Cursor. +pub fn foreign_key_provider(raw: &str) -> Option<&'static str> { + let k = raw.trim(); + FOREIGN_KEY_PREFIXES + .iter() + .find(|(p, _)| k.starts_with(p)) + .map(|(_, name)| *name) +} + /// Forme minimale d'une clé utilisateur Cursor (anti faute de frappe). pub fn validate_api_key(raw: &str) -> Result { let k = raw.trim(); if k.is_empty() { return Err("Clé API Cursor vide.".into()); } + if let Some(provider) = foreign_key_provider(k) { + return Err(format!( + "Ceci est une clé {provider}, pas une clé Cursor. Crée une clé sur cursor.com/dashboard → API Keys." + )); + } if k.len() < 16 || k.chars().any(char::is_whitespace) { return Err( - "Clé API Cursor invalide (copie-la depuis Cursor → Dashboard → API Keys).".into(), + "Clé API Cursor invalide (copie-la depuis cursor.com/dashboard → API Keys).".into(), ); } Ok(k.to_string()) } +/// Le CLI a refusé la clé (logs du worker). +pub fn logs_show_invalid_key(logs: &str) -> bool { + let l = logs.to_lowercase(); + l.contains("api key is invalid") + || l.contains("invalid api key") + || l.contains("api key was loaded from") + || l.contains("unauthenticated") +} + +/// Une variable `CURSOR_API_KEY` non vide existe côté serveur (jamais la valeur). +pub fn env_key_present() -> bool { + std::env::var("CURSOR_API_KEY") + .map(|v| !v.trim().is_empty()) + .unwrap_or(false) +} + +/// Efface les logs d'un worker arrêté (après changement de clé, pour ne +/// plus afficher l'erreur de l'ancienne clé). +pub fn clear_logs_if_stopped() { + if is_running() { + return; + } + let _ = std::fs::remove_file(out_path()); + let _ = std::fs::remove_file(err_path()); +} + pub fn mask_key(plain: &str) -> String { let t = plain.trim(); if t.is_empty() { @@ -860,6 +915,12 @@ pub async fn status_json(pool: &PgPool, node_name: &str) -> Result Result) -> &'static str { if !key { diff --git a/apps/server/src/cursor_worker_routes.rs b/apps/server/src/cursor_worker_routes.rs index f889094e2..25ef7837a 100644 --- a/apps/server/src/cursor_worker_routes.rs +++ b/apps/server/src/cursor_worker_routes.rs @@ -246,6 +246,7 @@ async fn status(State(state): State, headers: HeaderMap) -> Result (Default::default(), Some(e)), }; let cli = rep.agent.is_some(); + let key_invalid = key.is_some() && cursor_worker::logs_show_invalid_key(&rep.logs); Ok(Json(json!({ "ok": true, "running": rep.running, @@ -266,8 +267,14 @@ async fn status(State(state): State, headers: HeaderMap) -> Result cursor_worker::logs_show_invalid_key(e), + Ok(()) => false, + }; if let Err(e) = result { tracing::warn!(node = %node.name, error = %cursor_worker::tail_lines(&e, 3), "cursor-worker install"); } Ok(Json(json!({ "ok": ok, + "key_invalid": key_invalid, "node": node.to_json(), "name": name, "steps": steps, "message": if ok { format!("Worker « {name} » installé et démarré sur {}", node.name) + } else if key_invalid { + cursor_worker::INVALID_KEY_HINT.to_string() } else { "Installation interrompue — voir le détail des étapes.".to_string() }, @@ -743,6 +772,7 @@ fn settings_response(s: &WorkerSettings) -> Value { "api_key_set": key.is_some(), "api_key_source": key.as_ref().map(|(_, src)| *src), "api_key_masked": key.as_ref().map(|(k, _)| cursor_worker::mask_key(k)), + "env_key_present": cursor_worker::env_key_present(), }) } @@ -800,6 +830,7 @@ async fn patch_settings( .map(|s| s.trim().to_string()) .unwrap_or(current.node_id); + let key_changed = body.api_key.is_some(); let api_key_sealed = match body.api_key { None => current.api_key_sealed, Some(k) => { @@ -839,6 +870,11 @@ async fn patch_settings( ) })?; + if key_changed && cursor_worker::is_leader_target(&node_id) { + // L'erreur « clé invalide » de l'ancienne clé ne doit plus s'afficher. + cursor_worker::clear_logs_if_stopped(); + } + let s = WorkerSettings { enabled, name, diff --git a/apps/server/src/cursor_worker_tests.rs b/apps/server/src/cursor_worker_tests.rs index 7ca748690..38658e147 100644 --- a/apps/server/src/cursor_worker_tests.rs +++ b/apps/server/src/cursor_worker_tests.rs @@ -213,3 +213,33 @@ fn scripts_run_end_to_end_with_fake_agent() { assert!(!parse_script_output(&String::from_utf8_lossy(&out.stdout)).running); let _ = std::fs::remove_dir_all(&tmp); } + +#[test] +fn rejects_keys_from_other_providers() { + let e = validate_api_key("xai-0123456789abcdefghijklmnop").unwrap_err(); + assert!(e.contains("xAI"), "{e}"); + assert!(e.contains("cursor.com/dashboard"), "{e}"); + assert_eq!( + foreign_key_provider("sk-ant-api03-xxxxxxxxxxxx"), + Some("Anthropic") + ); + assert_eq!( + foreign_key_provider("sk-proj-xxxxxxxxxxxxxxxx"), + Some("OpenAI") + ); + assert_eq!(foreign_key_provider("key_0123456789abcdef"), None); + assert!(validate_api_key("key_0123456789abcdef").is_ok()); +} + +#[test] +fn detects_invalid_key_in_worker_logs() { + let logs = "—— worker.err ——\n\u{1b}[33m⚠ Warning: The provided API key is invalid.\u{1b}[0m\nThe API key was loaded from the CURSOR_API_KEY environment variable."; + assert!(logs_show_invalid_key(logs)); + assert!(!logs_show_invalid_key("Starting worker...\nconnected")); + assert!(!logs_show_invalid_key("")); +} + +#[test] +fn invalid_key_hint_points_to_cursor_dashboard() { + assert!(INVALID_KEY_HINT.contains("cursor.com/dashboard")); +} diff --git a/apps/web/src/components/CursorWorkerSettingsPanel.test.tsx b/apps/web/src/components/CursorWorkerSettingsPanel.test.tsx index 5637c6579..0eb15505c 100644 --- a/apps/web/src/components/CursorWorkerSettingsPanel.test.tsx +++ b/apps/web/src/components/CursorWorkerSettingsPanel.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { installNeeds, workerSummary } from './CursorWorkerSettingsPanel'; +import { cursorKeyProblem, installNeeds, workerSummary } from './CursorWorkerSettingsPanel'; const base = { running: false, @@ -34,3 +34,19 @@ describe('installNeeds', () => { expect(installNeeds(null)).toEqual([]); }); }); + +describe('invalid / foreign keys', () => { + it('flags a key refused by Cursor and asks for a new one', () => { + const s = { ...base, cli_installed: true, api_key_set: true, key_invalid: true }; + expect(workerSummary(s)).toMatchObject({ label: 'Clé KO', tone: 'danger', installed: false }); + expect(installNeeds({ api_key_set: true, key_invalid: true })).toEqual(['api_key']); + }); + it('rejects keys from other providers client-side', () => { + expect(cursorKeyProblem('xai-abcdefghijklmnopqrstuvwxyz')).toMatch(/xAI.*pas une clé Cursor/); + expect(cursorKeyProblem('xai-abcdefghijklmnopqrstuvwxyz')).toMatch(/cursor\.com\/dashboard/); + expect(cursorKeyProblem('sk-proj-abcdefghijklmnop')).toMatch(/OpenAI/); + expect(cursorKeyProblem('short')).toMatch(/trop courte/); + expect(cursorKeyProblem('key_0123456789abcdef')).toBeNull(); + expect(cursorKeyProblem('')).toBeNull(); + }); +}); diff --git a/apps/web/src/components/CursorWorkerSettingsPanel.tsx b/apps/web/src/components/CursorWorkerSettingsPanel.tsx index 5ad0f17aa..09355632f 100644 --- a/apps/web/src/components/CursorWorkerSettingsPanel.tsx +++ b/apps/web/src/components/CursorWorkerSettingsPanel.tsx @@ -57,30 +57,137 @@ function pill(label: string, tone: Tone, detail?: string) { } /** État résumé du worker (tuile + bouton principal). Exporté pour les tests. */ -export function workerSummary(s: Pick< - CursorWorkerStatus, - 'running' | 'ready' | 'cli_installed' | 'api_key_set' | 'node_missing' | 'node_error' ->): { label: string; tone: Tone; detail: string; installed: boolean } { +export function workerSummary( + s: Pick< + CursorWorkerStatus, + 'running' | 'ready' | 'cli_installed' | 'api_key_set' | 'node_missing' | 'node_error' + > & { key_invalid?: boolean }, +): { label: string; tone: Tone; detail: string; installed: boolean } { if (s.node_missing) { - return { label: 'Nœud ?', tone: 'danger', detail: 'Nœud introuvable', installed: false }; + return { + label: 'Nœud ?', + tone: 'danger', + detail: 'Nœud introuvable', + installed: false, + }; } if (s.node_error) { - return { label: 'KO', tone: 'danger', detail: 'Nœud injoignable', installed: false }; + return { + label: 'KO', + tone: 'danger', + detail: 'Nœud injoignable', + installed: false, + }; + } + if (s.key_invalid) { + return { + label: 'Clé KO', + tone: 'danger', + detail: 'Clé API refusée par Cursor', + installed: false, + }; } if (s.running) { return s.ready === false - ? { label: 'Connexion', tone: 'accent', detail: 'Connexion à Cursor…', installed: true } + ? { + label: 'Connexion', + tone: 'accent', + detail: 'Connexion à Cursor…', + installed: true, + } : { label: 'ON', tone: 'ok', detail: 'Connecté', installed: true }; } if (!s.api_key_set || !s.cli_installed) { - return { label: 'À installer', tone: 'warn', detail: 'Un clic pour installer', installed: false }; + return { + label: 'À installer', + tone: 'warn', + detail: 'Un clic pour installer', + installed: false, + }; } return { label: 'Off', tone: 'neutral', detail: 'Arrêté', installed: true }; } -/** Champs demandés à l’utilisateur avant l’installation. Exporté pour les tests. */ -export function installNeeds(s: Pick | null): Array<'api_key'> { - return s && !s.api_key_set ? ['api_key'] : []; +/** + * Champs obligatoires avant l’installation : la clé seulement si aucune n’est + * enregistrée ou si Cursor a refusé la clé actuelle. Exporté pour les tests. + */ +export function installNeeds( + s: (Pick & { key_invalid?: boolean }) | null, +): Array<'api_key'> { + return s && (!s.api_key_set || s.key_invalid) ? ['api_key'] : []; +} + +const FOREIGN_KEYS: Array<[string, string]> = [ + ['xai-', 'xAI'], + ['sk-ant-', 'Anthropic'], + ['sk-or-', 'OpenRouter'], + ['sk-', 'OpenAI'], + ['gsk_', 'Groq'], + ['AIza', 'Google'], + ['ghp_', 'GitHub'], + ['gho_', 'GitHub'], + ['github_pat_', 'GitHub'], + ['hf_', 'Hugging Face'], +]; + +/** Problème évident d’une clé saisie (vide = pas de problème). Exporté pour les tests. */ +export function cursorKeyProblem(raw: string): string | null { + const k = raw.trim(); + if (!k) return null; + const foreign = FOREIGN_KEYS.find(([p]) => k.startsWith(p)); + if (foreign) { + return `Ceci est une clé ${foreign[1]}, pas une clé Cursor. Crée-la sur cursor.com/dashboard → API Keys.`; + } + if (/\s/.test(k)) return 'La clé ne doit pas contenir d’espace.'; + if (k.length < 16) + return 'Clé trop courte : copie la clé complète depuis cursor.com/dashboard → API Keys.'; + return null; +} + +const KEY_HINT = + 'Clé Cursor personnelle : cursor.com/dashboard → API Keys (pas une clé xAI / OpenAI).'; + +/** Champ masqué unique pour la clé : une saisie remplace la clé stockée, vide = conserver. */ +function KeyField({ + status, + value, + onInput, + required, +}: { + status: CursorWorkerStatus; + value: string; + onInput: (v: string) => void; + required: boolean; +}) { + const problem = cursorKeyProblem(value); + const placeholder = + status.api_key_set && !required + ? `${status.api_key_masked ?? '••••'} — laisser vide pour conserver` + : 'key_…'; + return ( +
+ onInput((e.target as HTMLInputElement).value)} + /> + {problem ?

{problem}

: null} + {status.env_key_present ? ( +

+ {status.api_key_source === 'env' + ? 'La clé actuelle vient de la variable CURSOR_API_KEY du serveur ; en saisir une ici la remplace.' + : 'Une variable CURSOR_API_KEY existe aussi sur le serveur : la clé enregistrée ici est prioritaire.'} +

+ ) : null} +
+ ); } function Row({ label, value }: { label: string; value: ComponentChildren }) { @@ -157,7 +264,9 @@ export function CursorWorkerSettingsPanel({ isAdmin }: { isAdmin: boolean }) { status.eligible_count > 0 ? 'ok' : 'warn', status.eligible_count > 0 ? 'dépôt(s) servis' : 'Aucun dépôt cloné', ); - const adv = pill('Auto', 'neutral', 'Tout est rempli automatiquement'); + const adv = status.key_invalid + ? pill('Clé', 'danger', 'Clé API refusée : la remplacer') + : pill('Auto', 'neutral', 'Clé API, nom, dossiers'); async function toggleAuto() { setBusy(true); @@ -169,7 +278,11 @@ export function CursorWorkerSettingsPanel({ isAdmin }: { isAdmin: boolean }) { }); await load(); } catch (e) { - toast.push({ title: 'Échec', detail: String((e as Error).message || e), tone: 'danger' }); + toast.push({ + title: 'Échec', + detail: String((e as Error).message || e), + tone: 'danger', + }); } finally { setBusy(false); } @@ -284,6 +397,8 @@ function InstallModal({ const [result, setResult] = useState(null); const [err, setErr] = useState(null); const needs = installNeeds(status); + const keyProblem = cursorKeyProblem(apiKey); + const retryKey = !!result && !result.ok && !!result.key_invalid; useEffect(() => { if (!open) return; @@ -295,7 +410,11 @@ function InstallModal({ }, [open]); const node = nodes.find((n) => n.id === nodeId) ?? nodes.find((n) => n.leader); - const canRun = !running && (!needs.includes('api_key') || apiKey.trim().length >= 16) && node?.online !== false; + const canRun = + !running && + !keyProblem && + (!(needs.includes('api_key') || retryKey) || apiKey.trim().length > 0) && + node?.online !== false; async function run() { setRunning(true); @@ -331,7 +450,16 @@ function InstallModal({ fullOnMobile footer={
- {result ? ( + {result && retryKey ? ( + <> + + + + ) : result ? ( @@ -353,7 +481,10 @@ function InstallModal({ {result.message}
    {result.steps.map((s) => ( -
  1. +
  2. {s.label} {s.ok ? 'OK' : 'Échec'} @@ -366,6 +497,9 @@ function InstallModal({
  3. ))}
+ {retryKey ? ( + + ) : null} {result.ok ? (

Dans Cursor (My Machines) ou Grok Bot, choisis la machine{' '} @@ -418,9 +552,11 @@ function InstallModal({

@@ -443,17 +579,18 @@ function InstallModal({ ) : null} - {needs.includes('api_key') ? ( - setApiKey((e.target as HTMLInputElement).value)} - /> + {status.key_invalid ? ( + + Cursor a refusé la clé enregistrée ({status.api_key_masked}). Colle une clé Cursor + personnelle : cursor.com/dashboard → API Keys. + ) : null} + {err ? {err} : null}

Le worker exécute les agents Cursor sur cette machine (fichiers, terminal). La @@ -500,7 +637,11 @@ function StatusModal({ setConfirmStop(false); onChanged(); } catch (e) { - toast.push({ title: 'Échec', detail: String((e as Error).message || e), tone: 'danger' }); + toast.push({ + title: 'Échec', + detail: String((e as Error).message || e), + tone: 'danger', + }); onChanged(); } finally { setBusy(null); @@ -528,7 +669,11 @@ function StatusModal({ ) : ( <> - - - + confirmDelete ? ( +

+ + +
+ ) : ( +
+ + +
+ ) } > -
- setName((e.target as HTMLInputElement).value)} - /> - setApiKey((e.target as HTMLInputElement).value)} - /> - setBinary((e.target as HTMLInputElement).value)} - /> -