Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 72 additions & 5 deletions apps/server/src/cursor_worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String> {
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() {
Expand Down Expand Up @@ -860,6 +915,12 @@ pub async fn status_json(pool: &PgPool, node_name: &str) -> Result<Value, String
};
let key = effective_api_key(&settings);
let name = resolve_worker_name_for_node(&settings, node_name);
let logs = if running || err_path().is_file() || out_path().is_file() {
read_logs_tail(30)
} else {
String::new()
};
let key_invalid = key.is_some() && logs_show_invalid_key(&logs);

Ok(json!({
"ok": true,
Expand All @@ -886,15 +947,21 @@ pub async fn status_json(pool: &PgPool, node_name: &str) -> Result<Value, String
"eligible_count": eligible.len(),
"eligible_paths": eligible,
"registered_paths": load_registered(),
"logs_tail": if running || err_path().is_file() || out_path().is_file() {
read_logs_tail(30)
"env_key_present": env_key_present(),
"key_invalid": key_invalid,
"logs_tail": logs,
"hint": if key_invalid {
INVALID_KEY_HINT
} else {
String::new()
status_hint(bin.is_some(), key.is_some(), running, ready)
},
"hint": status_hint(bin.is_some(), key.is_some(), running, ready),
}))
}

/// 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.";

/// Phrase d'état unique (sans commande à taper).
pub fn status_hint(cli: bool, key: bool, running: bool, ready: Option<bool>) -> &'static str {
if !key {
Expand Down
38 changes: 37 additions & 1 deletion apps/server/src/cursor_worker_routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ async fn status(State(state): State<AppState>, headers: HeaderMap) -> Result<Jso
Err(e) => (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,
Expand All @@ -266,8 +267,14 @@ async fn status(State(state): State<AppState>, headers: HeaderMap) -> Result<Jso
"node_error": err,
"workdirs": [],
"eligible_count": Value::Null,
"env_key_present": cursor_worker::env_key_present(),
"key_invalid": key_invalid,
"logs_tail": rep.logs,
"hint": cursor_worker::status_hint(cli, key.is_some(), rep.running, rep.ready),
"hint": if key_invalid {
cursor_worker::INVALID_KEY_HINT
} else {
cursor_worker::status_hint(cli, key.is_some(), rep.running, rep.ready)
},
})))
}

Expand Down Expand Up @@ -340,6 +347,21 @@ async fn install(
let k = cursor_worker::validate_api_key(k).map_err(map_err)?;
settings.api_key_sealed = crate::secret_box::seal_compat(&k);
}
// Une clé déjà stockée mais d'un autre fournisseur ne doit pas repartir.
if let Some((stored, _)) = cursor_worker::effective_api_key(&settings) {
if let Some(provider) = cursor_worker::foreign_key_provider(&stored) {
return Err((
StatusCode::BAD_REQUEST,
Json(json!({
"ok": false,
"code": "api_key_invalid",
"error": format!(
"La clé enregistrée est une clé {provider}, pas une clé Cursor. Colle une clé Cursor (cursor.com/dashboard → API Keys)."
),
})),
));
}
}
let Some((api_key, _)) = cursor_worker::effective_api_key(&settings) else {
return Err((
StatusCode::BAD_REQUEST,
Expand Down Expand Up @@ -390,16 +412,23 @@ async fn install(
install_remote(&state, &settings, &node, &name, &api_key, &mut steps).await
};
let ok = result.is_ok();
let key_invalid = match &result {
Err(e) => 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()
},
Expand Down Expand Up @@ -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(),
})
}

Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions apps/server/src/cursor_worker_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
18 changes: 17 additions & 1 deletion apps/web/src/components/CursorWorkerSettingsPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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();
});
});
Loading
Loading