diff --git a/src/ingestion/anthropic.rs b/src/ingestion/anthropic.rs new file mode 100644 index 00000000..a32af364 --- /dev/null +++ b/src/ingestion/anthropic.rs @@ -0,0 +1,293 @@ +//! One-shot Anthropic API key validation. +//! +//! [`validate_key`] performs a cheap GET against `/v1/models` (zero-token, +//! zero-cost) so the config-save and bootstrap routes can refuse to persist +//! a key that Anthropic will reject when the user later tries to ingest. A +//! definitive 401/403 returns [`AnthropicValidationError::Invalid`]; any +//! transient failure (DNS, 5xx, timeout, etc.) returns +//! [`AnthropicValidationError::Transient`] so the caller can soft-warn +//! instead of blocking save. +//! +//! Why a separate probe instead of reusing `/v1/messages`: the messages +//! endpoint costs tokens and rate-limits per request. `/v1/models` is the +//! documented auth check — it returns the catalog when the key is valid +//! and a structured 401 when it isn't. +//! +//! [`MODELS_PATH`] is exposed so tests can hit `/v1/models` +//! through [`validate_key_with_base`]. + +use fold_db::llm_registry::models; +use reqwest::Client; +use std::time::Duration; + +/// Path on the Anthropic API used as a zero-cost auth probe. +pub const MODELS_PATH: &str = "/v1/models"; + +/// Default upstream Anthropic API base. Tests override via +/// [`validate_key_with_base`] to point at a wiremock server. +pub const ANTHROPIC_API_BASE: &str = "https://api.anthropic.com"; + +/// Wall-clock cap for the probe. The /v1/models endpoint usually answers in +/// under 300ms; 8s leaves room for slow networks without making the user +/// stare at a frozen "Save" button. +const PROBE_TIMEOUT_SECS: u64 = 8; + +/// Outcome of a single [`validate_key`] call. +#[derive(Debug)] +pub enum AnthropicValidationError { + /// Anthropic returned 401 or 403 — the key is definitively bad. Callers + /// MUST refuse to persist the config and surface the upstream message to + /// the user. + Invalid { + /// Upstream HTTP status (401 or 403). + status: u16, + /// Anthropic's response body, truncated. Echoed back to the user so + /// they can tell whether the key is expired vs. wrong-account vs. + /// disabled. + upstream_message: String, + }, + /// Network failure, 5xx, timeout, etc. The save handler should soft-warn + /// (200 + `warning`) and still persist the config — we can't know + /// whether the key is good, but we know it isn't *demonstrably* bad. + Transient { detail: String }, +} + +impl std::fmt::Display for AnthropicValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Invalid { + status, + upstream_message, + } => { + write!( + f, + "Anthropic key rejected (HTTP {status}): {upstream_message}" + ) + } + Self::Transient { detail } => { + write!(f, "Could not validate Anthropic key: {detail}") + } + } + } +} + +impl std::error::Error for AnthropicValidationError {} + +/// Probe the Anthropic API with `key`. Public entry point used by the routes. +/// +/// In test builds (`cfg(debug_assertions)`) the optional +/// `FOLD_ANTHROPIC_PROBE_BASE_URL` env var overrides the upstream so route-level +/// tests can point the probe at a wiremock server without threading a base URL +/// argument through every handler signature. Release builds ignore the env var +/// and always hit `api.anthropic.com`. +pub async fn validate_key(key: &str) -> Result<(), AnthropicValidationError> { + #[cfg(debug_assertions)] + if let Ok(base) = std::env::var("FOLD_ANTHROPIC_PROBE_BASE_URL") { + if !base.is_empty() { + return validate_key_with_base(key, &base).await; + } + } + validate_key_with_base(key, ANTHROPIC_API_BASE).await +} + +/// Variant of [`validate_key`] that lets tests point at a mock server. +/// `base` is the URL prefix (no trailing slash needed) — `MODELS_PATH` +/// is appended to form the request. +pub async fn validate_key_with_base(key: &str, base: &str) -> Result<(), AnthropicValidationError> { + let url = format!("{}{}", base.trim_end_matches('/'), MODELS_PATH); + + // trace-egress: skip-3p (Anthropic API; third-party, does not honour + // W3C traceparent — no inject_w3c wrap). + let client = Client::builder() + .timeout(Duration::from_secs(PROBE_TIMEOUT_SECS)) + .no_proxy() + .build() + .map_err(|e| AnthropicValidationError::Transient { + detail: format!("Failed to build HTTP client: {e}"), + })?; + + let response = client + .get(&url) + .header("x-api-key", key) + .header("anthropic-version", models::ANTHROPIC_API_VERSION) + .send() + .await + .map_err(|e| AnthropicValidationError::Transient { + detail: format!("Network error contacting Anthropic: {e}"), + })?; + + let status = response.status(); + if status.is_success() { + return Ok(()); + } + + // 401/403 are the documented "key invalid / forbidden" signals. + // Everything else — 5xx, 429, unexpected status — is treated as + // transient so we don't refuse to save when Anthropic is having a + // bad day. + if status == reqwest::StatusCode::UNAUTHORIZED || status == reqwest::StatusCode::FORBIDDEN { + let upstream_message = extract_upstream_message(response).await; + return Err(AnthropicValidationError::Invalid { + status: status.as_u16(), + upstream_message, + }); + } + + let detail = match response.text().await { + Ok(body) if !body.is_empty() => format!("HTTP {status}: {}", truncate(&body)), + _ => format!("HTTP {status}"), + }; + Err(AnthropicValidationError::Transient { detail }) +} + +/// Parse Anthropic's error JSON (`{"error":{"message":"...","type":"..."}}`) +/// and return a short, user-facing string. Falls back to the raw body when +/// the JSON shape doesn't match — Anthropic occasionally returns plain-text +/// or differently-shaped bodies during incidents. +async fn extract_upstream_message(response: reqwest::Response) -> String { + let body = response.text().await.unwrap_or_default(); + if body.is_empty() { + return "Anthropic returned no body".to_string(); + } + if let Ok(parsed) = serde_json::from_str::(&body) { + if let Some(msg) = parsed + .get("error") + .and_then(|e| e.get("message")) + .and_then(|m| m.as_str()) + { + return msg.to_string(); + } + } + truncate(&body) +} + +fn truncate(body: &str) -> String { + if body.len() > 240 { + format!("{}...", &body[..240]) + } else { + body.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use wiremock::matchers::{header, method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + #[tokio::test] + async fn returns_ok_on_200() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(MODELS_PATH)) + .and(header("x-api-key", "sk-ant-good")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "data": [], + "has_more": false + }))) + .mount(&server) + .await; + validate_key_with_base("sk-ant-good", &server.uri()) + .await + .expect("good key must pass"); + } + + #[tokio::test] + async fn returns_invalid_on_401() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(MODELS_PATH)) + .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ + "type": "error", + "error": { + "type": "authentication_error", + "message": "invalid x-api-key" + } + }))) + .mount(&server) + .await; + let err = validate_key_with_base("sk-ant-bad", &server.uri()) + .await + .expect_err("bad key must fail"); + match err { + AnthropicValidationError::Invalid { + status, + upstream_message, + } => { + assert_eq!(status, 401); + assert!( + upstream_message.contains("invalid x-api-key"), + "should extract upstream message, got: {upstream_message}" + ); + } + other => panic!("expected Invalid, got {other:?}"), + } + } + + #[tokio::test] + async fn returns_invalid_on_403() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(MODELS_PATH)) + .respond_with(ResponseTemplate::new(403).set_body_string("forbidden")) + .mount(&server) + .await; + let err = validate_key_with_base("sk-ant-revoked", &server.uri()) + .await + .expect_err("revoked key must fail"); + assert!(matches!( + err, + AnthropicValidationError::Invalid { status: 403, .. } + )); + } + + #[tokio::test] + async fn returns_transient_on_500() { + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(MODELS_PATH)) + .respond_with(ResponseTemplate::new(500).set_body_string("upstream blew up")) + .mount(&server) + .await; + let err = validate_key_with_base("sk-ant-any", &server.uri()) + .await + .expect_err("5xx must surface as transient"); + assert!( + matches!(err, AnthropicValidationError::Transient { .. }), + "5xx must be transient, got {err:?}" + ); + } + + #[tokio::test] + async fn returns_transient_on_dns_failure() { + // Unrouteable scheme so the request fails before any TCP — no + // sleeping on a real connect timeout. + let err = validate_key_with_base("sk-ant-any", "http://does.not.exist.invalid:1") + .await + .expect_err("unreachable host must surface as transient"); + assert!( + matches!(err, AnthropicValidationError::Transient { .. }), + "DNS failure must be transient, got {err:?}" + ); + } + + #[tokio::test] + async fn returns_transient_on_429_so_we_dont_block_save() { + // Rate-limit during config save shouldn't block the user from + // saving — soft-warn and let the actual ingestion call retry + // through the AI client's existing retry path. + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path(MODELS_PATH)) + .respond_with(ResponseTemplate::new(429).set_body_string("rate limited")) + .mount(&server) + .await; + let err = validate_key_with_base("sk-ant-good", &server.uri()) + .await + .expect_err("429 must surface as transient, not Invalid"); + assert!( + matches!(err, AnthropicValidationError::Transient { .. }), + "429 must be transient (so we don't refuse to save), got {err:?}" + ); + } +} diff --git a/src/ingestion/mod.rs b/src/ingestion/mod.rs index 338080a4..f33a95e1 100644 --- a/src/ingestion/mod.rs +++ b/src/ingestion/mod.rs @@ -4,6 +4,7 @@ //! and optionally executes mutations to persist the data. pub mod ai; +pub mod anthropic; pub mod anthropic_key_store; pub mod apple_import; pub mod batch_controller; diff --git a/src/server/routes/ingestion.rs b/src/server/routes/ingestion.rs index a654b6d2..b78d5a29 100644 --- a/src/server/routes/ingestion.rs +++ b/src/server/routes/ingestion.rs @@ -243,6 +243,53 @@ pub async fn save_ingestion_config( let saved_config = request.into_inner(); let cfg_dir = config_dir.as_path().to_path_buf(); + // Validate the Anthropic key BEFORE we persist anything. The probe is a + // single GET against /v1/models — zero tokens, zero cost, ~300ms. An + // invalid key fails the save loudly here instead of silently passing + // through and only surfacing as a 401 the first time the user actually + // ingests a file. Transient errors (5xx, DNS, timeout) don't block the + // save — we soft-warn and persist, because refusing to save during an + // Anthropic outage would be worse than the silent-save bug we're fixing. + // + // Empty / `***configured***` means "keep the existing on-disk key" — that + // path doesn't need re-probing (and we don't have the cleartext to probe + // with), so skip validation there. Matches the persistence rule in + // IngestionConfig::save_to_file. + let mut transient_warning: Option<(String, String)> = None; + if saved_config.provider == AIProvider::Anthropic { + let incoming_key = saved_config.anthropic.api_key.as_str(); + if !incoming_key.is_empty() && incoming_key != "***configured***" { + match crate::ingestion::anthropic::validate_key(incoming_key).await { + Ok(()) => {} + Err(crate::ingestion::anthropic::AnthropicValidationError::Invalid { + upstream_message, + .. + }) => { + tracing::warn!( + target: "fold_node::ingestion", + upstream_message = %upstream_message, + "Rejecting ingestion config save: Anthropic key validation returned auth error" + ); + return HttpResponse::BadRequest().json(json!({ + "success": false, + "error": "invalid_anthropic_key", + "detail": upstream_message, + })); + } + Err(crate::ingestion::anthropic::AnthropicValidationError::Transient { + detail, + }) => { + tracing::warn!( + target: "fold_node::ingestion", + detail = %detail, + "Anthropic key probe failed transiently; saving config anyway and returning soft-warning" + ); + transient_warning = Some(("could_not_validate_key".to_string(), detail)); + } + } + } + } + // AI config is per-device (saved to ingestion_config.json only, not Sled). // A laptop might run Ollama locally while a phone uses Anthropic's API. match crate::ingestion::config::IngestionConfig::save_to_file(&cfg_dir, &saved_config) { @@ -270,10 +317,15 @@ pub async fn save_ingestion_config( // Also reload the LLM query service so model changes take effect llm_state.reload().await; - HttpResponse::Ok().json(json!({ + let mut body = json!({ "success": true, "message": "Configuration saved successfully" - })) + }); + if let Some((warning, detail)) = transient_warning { + body["warning"] = json!(warning); + body["detail"] = json!(detail); + } + HttpResponse::Ok().json(body) } Err(e) => HttpResponse::InternalServerError().json(json!({ "success": false, @@ -923,4 +975,267 @@ mod tests { assert_eq!(parsed.schema_hint, Some("TestSchema".to_string())); assert_eq!(parsed.auto_execute, Some(true)); } + + // ── save_ingestion_config: Anthropic key validation ───────────────── + // + // Regression for the dogfood-reported bug where a bad Anthropic key + // saved cleanly via POST /api/ingestion/config and only surfaced as a + // 401 the first time the user attempted an ingestion. The save handler + // now probes /v1/models with the supplied key and refuses to persist + // on a definitive 401/403, while soft-warning on transient errors. + + use wiremock::matchers::{header, method, path as wm_path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + /// Tiny RAII wrapper so each test sets the probe override env var and + /// clears it on drop. Cleared at drop time so a panicking assertion + /// doesn't poison neighbour tests with leftover state. + struct ProbeBaseOverride; + impl ProbeBaseOverride { + fn set(base: &str) -> Self { + std::env::set_var("FOLD_ANTHROPIC_PROBE_BASE_URL", base); + Self + } + } + impl Drop for ProbeBaseOverride { + fn drop(&mut self) { + std::env::remove_var("FOLD_ANTHROPIC_PROBE_BASE_URL"); + } + } + + /// 400 + structured `{error: "invalid_anthropic_key"}` when Anthropic + /// answers the probe with 401, AND no on-disk persistence. + #[allow(clippy::await_holding_lock)] + #[actix_web::test] + async fn save_config_rejects_bad_anthropic_key_with_400_and_does_not_persist() { + let _guard = crate::ingestion::config::anthropic_api_key_env_lock(); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(wm_path("/v1/models")) + .and(header("x-api-key", "sk-ant-bogus")) + .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ + "type": "error", + "error": { + "type": "authentication_error", + "message": "invalid x-api-key" + } + }))) + .mount(&server) + .await; + let _probe = ProbeBaseOverride::set(&server.uri()); + + let tmp = tempfile::tempdir().expect("tempdir"); + let llm_state = crate::fold_node::llm_query::LlmQueryState::new(tmp.path().to_path_buf()); + let ingestion_service: IngestionServiceState = tokio::sync::RwLock::new(None); + let app = test::init_service( + App::new() + .app_data(web::Data::new(ingestion_service)) + .app_data(web::Data::new(llm_state)) + .app_data(web::Data::new(crate::server::startup::ConfigDir( + tmp.path().to_path_buf(), + ))) + .route("/config", web::post().to(save_ingestion_config)), + ) + .await; + + let body = serde_json::json!({ + "provider": "Anthropic", + "anthropic": { + "api_key": "sk-ant-bogus", + "model": "claude-haiku-4-5-20251001", + "base_url": "https://api.anthropic.com" + } + }); + let req = test::TestRequest::post() + .uri("/config") + .set_json(&body) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!( + resp.status().as_u16(), + 400, + "bad key must return 400, not silently persist" + ); + let body: serde_json::Value = test::read_body_json(resp).await; + assert_eq!(body["error"], "invalid_anthropic_key"); + assert!( + body["detail"] + .as_str() + .unwrap_or("") + .contains("invalid x-api-key"), + "detail must echo upstream message, got: {body}" + ); + // No persistence: neither the JSON config nor the sensitive key file + // should exist after a rejected save. + assert!( + !tmp.path().join("ingestion_config.json").exists(), + "rejected save must not write ingestion_config.json" + ); + assert!( + crate::ingestion::anthropic_key_store::load(tmp.path()) + .expect("load store") + .is_none(), + "rejected save must not write the sensitive key store" + ); + } + + /// Transient upstream failure → 200 + `warning` field, key IS persisted. + /// We can't know whether the key is good when Anthropic is down, so + /// blocking save would be worse than the silent-save bug we're fixing. + #[allow(clippy::await_holding_lock)] + #[actix_web::test] + async fn save_config_soft_warns_on_transient_probe_failure() { + let _guard = crate::ingestion::config::anthropic_api_key_env_lock(); + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(wm_path("/v1/models")) + .respond_with(ResponseTemplate::new(500).set_body_string("internal error")) + .mount(&server) + .await; + let _probe = ProbeBaseOverride::set(&server.uri()); + + let tmp = tempfile::tempdir().expect("tempdir"); + let llm_state = crate::fold_node::llm_query::LlmQueryState::new(tmp.path().to_path_buf()); + let ingestion_service: IngestionServiceState = tokio::sync::RwLock::new(None); + let app = test::init_service( + App::new() + .app_data(web::Data::new(ingestion_service)) + .app_data(web::Data::new(llm_state)) + .app_data(web::Data::new(crate::server::startup::ConfigDir( + tmp.path().to_path_buf(), + ))) + .route("/config", web::post().to(save_ingestion_config)), + ) + .await; + + let body = serde_json::json!({ + "provider": "Anthropic", + "anthropic": { + "api_key": "sk-ant-likely-fine", + "model": "claude-haiku-4-5-20251001", + "base_url": "https://api.anthropic.com" + } + }); + let req = test::TestRequest::post() + .uri("/config") + .set_json(&body) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status().as_u16(), 200, "transient must still 200"); + let body: serde_json::Value = test::read_body_json(resp).await; + assert_eq!(body["success"], true); + assert_eq!(body["warning"], "could_not_validate_key"); + assert!( + body["detail"].as_str().is_some(), + "warning response must carry a detail string" + ); + assert!( + tmp.path().join("ingestion_config.json").exists(), + "transient failure must still persist the saved config" + ); + assert_eq!( + crate::ingestion::anthropic_key_store::load(tmp.path()) + .expect("load store") + .as_deref(), + Some("sk-ant-likely-fine"), + "transient failure must still persist the api key" + ); + } + + /// Ollama provider doesn't trigger the Anthropic probe at all — even if + /// the env override points at a server that would reject everything, + /// we must not call it. Asserts the route never hits the mock. + #[allow(clippy::await_holding_lock)] + #[actix_web::test] + async fn save_config_does_not_probe_when_provider_is_ollama() { + let _guard = crate::ingestion::config::anthropic_api_key_env_lock(); + + let server = MockServer::start().await; + // Note: no `.mount(...)` — wiremock auto-asserts zero matched + // requests when `.verify()` runs at drop. Any inbound request will + // return 404 and we'd be fine, but the explicit absence of a Mock + // documents the invariant. + let _probe = ProbeBaseOverride::set(&server.uri()); + + let tmp = tempfile::tempdir().expect("tempdir"); + let llm_state = crate::fold_node::llm_query::LlmQueryState::new(tmp.path().to_path_buf()); + let ingestion_service: IngestionServiceState = tokio::sync::RwLock::new(None); + let app = test::init_service( + App::new() + .app_data(web::Data::new(ingestion_service)) + .app_data(web::Data::new(llm_state)) + .app_data(web::Data::new(crate::server::startup::ConfigDir( + tmp.path().to_path_buf(), + ))) + .route("/config", web::post().to(save_ingestion_config)), + ) + .await; + + let body = serde_json::json!({ + "provider": "Ollama", + "ollama": { + "model": "llama3.2", + "base_url": "http://localhost:11434" + } + }); + let req = test::TestRequest::post() + .uri("/config") + .set_json(&body) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status().as_u16(), 200, "Ollama save must succeed"); + assert_eq!( + server.received_requests().await.unwrap_or_default().len(), + 0 + ); + } + + /// `***configured***` is the redaction placeholder served by GET + /// /api/ingestion/config when a real key already lives on disk. A save + /// that echoes that placeholder back must NOT probe (we don't have the + /// cleartext) and must succeed — matching the existing "preserve key + /// when empty/redacted" semantics in IngestionConfig::save_to_file. + #[allow(clippy::await_holding_lock)] + #[actix_web::test] + async fn save_config_does_not_probe_redacted_placeholder() { + let _guard = crate::ingestion::config::anthropic_api_key_env_lock(); + + let server = MockServer::start().await; + let _probe = ProbeBaseOverride::set(&server.uri()); + + let tmp = tempfile::tempdir().expect("tempdir"); + let llm_state = crate::fold_node::llm_query::LlmQueryState::new(tmp.path().to_path_buf()); + let ingestion_service: IngestionServiceState = tokio::sync::RwLock::new(None); + let app = test::init_service( + App::new() + .app_data(web::Data::new(ingestion_service)) + .app_data(web::Data::new(llm_state)) + .app_data(web::Data::new(crate::server::startup::ConfigDir( + tmp.path().to_path_buf(), + ))) + .route("/config", web::post().to(save_ingestion_config)), + ) + .await; + + let body = serde_json::json!({ + "provider": "Anthropic", + "anthropic": { + "api_key": "***configured***", + "model": "claude-haiku-4-5-20251001", + "base_url": "https://api.anthropic.com" + } + }); + let req = test::TestRequest::post() + .uri("/config") + .set_json(&body) + .to_request(); + let resp = test::call_service(&app, req).await; + assert_eq!(resp.status().as_u16(), 200, "redacted save must 200"); + assert_eq!( + server.received_requests().await.unwrap_or_default().len(), + 0 + ); + } } diff --git a/src/server/routes/setup.rs b/src/server/routes/setup.rs index 5bdb5156..1f7babed 100644 --- a/src/server/routes/setup.rs +++ b/src/server/routes/setup.rs @@ -83,6 +83,23 @@ pub struct BootstrapResponse { /// Set when cloud registration succeeded. #[serde(skip_serializing_if = "Option::is_none")] pub cloud: Option, + /// Set when an Anthropic key was supplied but couldn't be definitively + /// validated due to a transient failure (DNS, 5xx, timeout). The key is + /// still persisted; the UI should show this so the user knows the next + /// ingestion attempt may surface a key problem the probe couldn't catch. + #[serde(skip_serializing_if = "Option::is_none")] + pub warning: Option, +} + +/// Soft-warning payload attached to a successful `POST /api/setup/bootstrap` +/// response. Mirrors the `{warning, detail}` shape used by +/// `POST /api/ingestion/config` so the UI can render both paths uniformly. +#[derive(Debug, Serialize, utoipa::ToSchema)] +pub struct BootstrapWarning { + /// Machine-readable warning code (e.g. `"could_not_validate_key"`). + pub warning: String, + /// Human-readable detail — typically the upstream error message. + pub detail: String, } #[derive(Debug, Serialize, utoipa::ToSchema)] @@ -154,6 +171,13 @@ pub async fn bootstrap( let req = req.into_inner(); match run_bootstrap(state.get_ref(), config_dir.get_ref(), &marker_path, req).await { Ok(resp) => HttpResponse::Ok().json(resp), + Err(BootstrapError::InvalidAnthropicKey { detail }) => { + HttpResponse::BadRequest().json(json!({ + "ok": false, + "error": "invalid_anthropic_key", + "detail": detail, + })) + } Err(BootstrapError::Conflict(msg)) => HttpResponse::Conflict().json(json!({ "ok": false, "error": "cloud_conflict", @@ -169,6 +193,11 @@ pub async fn bootstrap( #[derive(Debug)] enum BootstrapError { + /// Anthropic auth probe returned 401/403 for the supplied key. Surfaced + /// as HTTP 400 so the wizard can highlight the key field and refuse to + /// advance until the user fixes it. We fail BEFORE persisting the key + /// or running the rest of bootstrap — there's no rollback needed. + InvalidAnthropicKey { detail: String }, /// Recovery phrase + invite code disagreed with Exemem's existing /// registration for that key. Surfaced as HTTP 409. Conflict(String), @@ -191,6 +220,19 @@ async fn run_bootstrap( marker_path: &std::path::Path, req: BootstrapRequest, ) -> Result { + // ---- (1) Validate any supplied Anthropic key BEFORE we mint an + // identity or touch anything else on disk. A definitive 401/403 + // returns a clean 400 with no rollback needed; transient failures + // (DNS, 5xx, timeout) attach a warning to the success response and + // proceed. We can't do this inside run_bootstrap_post_identity + // because by then we've already provisioned the identity tree, and + // rolling that back over a typo'd key wastes work the user can fix + // by just re-submitting. + let transient_warning = match probe_anthropic_key_if_needed(&req).await { + Ok(w) => w, + Err(detail) => return Err(BootstrapError::InvalidAnthropicKey { detail }), + }; + // ---- (2-3) Identity: derive from phrase or generate fresh, then // persist ENC:-prefixed under the keychain master key when // `os-keychain` is on. Both branches MUST mint the master @@ -283,9 +325,51 @@ async fn run_bootstrap( user_hash, recovery_phrase, cloud: cloud_info, + warning: transient_warning, }) } +/// Probe the Anthropic API with the supplied key when the request asks for +/// the Anthropic provider. Returns: +/// - `Ok(None)` when no probe is needed (provider != anthropic, no key, etc.) +/// or when the key passed validation cleanly. +/// - `Ok(Some(BootstrapWarning))` when the probe failed transiently — +/// bootstrap should still persist the key and attach the warning to the +/// success response. +/// - `Err(detail)` on a definitive 401/403 — bootstrap should reject the +/// request with HTTP 400 and not persist anything. +async fn probe_anthropic_key_if_needed( + req: &BootstrapRequest, +) -> Result, String> { + if req.ai_provider.as_deref() != Some("anthropic") { + return Ok(None); + } + let Some(key) = req.anthropic_api_key.as_deref().filter(|s| !s.is_empty()) else { + // Empty / missing key here is caught later by the anthropic arm of + // run_bootstrap_post_identity, which returns an explicit error. + // Don't probe — there's nothing to validate. + return Ok(None); + }; + match crate::ingestion::anthropic::validate_key(key).await { + Ok(()) => Ok(None), + Err(crate::ingestion::anthropic::AnthropicValidationError::Invalid { + upstream_message, + .. + }) => Err(upstream_message), + Err(crate::ingestion::anthropic::AnthropicValidationError::Transient { detail }) => { + tracing::warn!( + target: "fold_node::bootstrap", + detail = %detail, + "Bootstrap proceeding despite transient Anthropic key probe failure" + ); + Ok(Some(BootstrapWarning { + warning: "could_not_validate_key".to_string(), + detail, + })) + } + } +} + /// Steps 4–8: identity card, optional cloud registration, AI config, and /// marker write. Pulled out so the rollback path in [`run_bootstrap`] /// stays linear. @@ -849,4 +933,168 @@ mod tests { .expect("parse ingestion_config.json"); assert_eq!(on_disk, cfg, "round-trip JSON must match input"); } + + // ── Anthropic key validation on bootstrap ──────────────────────── + // + // The bootstrap handler now probes /v1/models with the supplied + // Anthropic key before doing anything else. Definitive 401/403 → + // HTTP 400 + structured error, identity is NEVER provisioned. + // Transient failure → HTTP 200 with a `warning` field, key IS + // persisted. These tests pin both paths through the route. The + // helper module's wiremock-backed tests cover the probe itself; here + // we just verify the route's response shape and persistence + // behavior. + + use wiremock::matchers::{method as wm_method, path as wm_path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + /// RAII override of FOLD_ANTHROPIC_PROBE_BASE_URL so the route's + /// validate_key call hits the test wiremock instead of api.anthropic.com. + struct ProbeBaseOverride; + impl ProbeBaseOverride { + fn set(base: &str) -> Self { + std::env::set_var("FOLD_ANTHROPIC_PROBE_BASE_URL", base); + Self + } + } + impl Drop for ProbeBaseOverride { + fn drop(&mut self) { + std::env::remove_var("FOLD_ANTHROPIC_PROBE_BASE_URL"); + } + } + + #[actix_web::test] + #[allow(clippy::await_holding_lock)] + async fn bootstrap_rejects_bad_anthropic_key_with_400_and_no_side_effects() { + let _g = home_lock(); + let tmp = tempfile::tempdir().unwrap(); + std::env::set_var(FOLDDB_HOME_VAR, tmp.path()); + + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/v1/models")) + .respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({ + "type": "error", + "error": { + "type": "authentication_error", + "message": "invalid x-api-key" + } + }))) + .mount(&server) + .await; + let _probe = ProbeBaseOverride::set(&server.uri()); + + let (state, config_dir) = build_app_state(tmp.path()); + let body = web::Json(BootstrapRequest { + name: "test".into(), + email: None, + birthday: None, + ai_provider: Some("anthropic".to_string()), + anthropic_api_key: Some("sk-ant-bogus".to_string()), + ollama_url: None, + ollama_model: None, + enable_cloud: false, + invite_code: None, + recovery_phrase: None, + }); + let resp = bootstrap(state.clone(), config_dir.clone(), body) + .await + .respond_to(&actix_web::test::TestRequest::default().to_http_request()); + assert_eq!(resp.status(), 400, "bad anthropic key must return 400"); + + // No identity should be persisted — the probe failed before any of + // the post-identity steps ran. + let pool = state.node_manager.get_or_init_sled_pool().await; + let raw = crate::identity::peek_raw_identity_value(&pool).expect("peek raw identity"); + assert!( + raw.is_none(), + "bad-key bootstrap must NOT persist an identity blob; got: {raw:?}" + ); + + // No ingestion_config.json or sensitive key store file either. + assert!( + !config_dir.as_path().join("ingestion_config.json").exists(), + "bad-key bootstrap must NOT write ingestion_config.json" + ); + assert!( + crate::ingestion::anthropic_key_store::load(config_dir.as_path()) + .expect("load store") + .is_none(), + "bad-key bootstrap must NOT write the sensitive key store" + ); + + // And no onboarding marker — bootstrap must remain re-runnable + // until the user fixes the key. + let marker = tmp.path().join("data").join(".onboarding_complete"); + assert!( + !marker.exists(), + "bad-key bootstrap must not write the onboarding marker" + ); + + std::env::remove_var(FOLDDB_HOME_VAR); + } + + #[actix_web::test] + #[allow(clippy::await_holding_lock)] + async fn bootstrap_soft_warns_on_transient_anthropic_probe_failure() { + let _g = home_lock(); + let tmp = tempfile::tempdir().unwrap(); + std::env::set_var(FOLDDB_HOME_VAR, tmp.path()); + + let server = MockServer::start().await; + Mock::given(wm_method("GET")) + .and(wm_path("/v1/models")) + .respond_with(ResponseTemplate::new(503).set_body_string("upstream down")) + .mount(&server) + .await; + let _probe = ProbeBaseOverride::set(&server.uri()); + + let (state, config_dir) = build_app_state(tmp.path()); + let body = web::Json(BootstrapRequest { + name: "test".into(), + email: None, + birthday: None, + ai_provider: Some("anthropic".to_string()), + anthropic_api_key: Some("sk-ant-likely-fine".to_string()), + ollama_url: None, + ollama_model: None, + enable_cloud: false, + invite_code: None, + recovery_phrase: None, + }); + let resp = bootstrap(state, config_dir.clone(), body) + .await + .respond_to(&actix_web::test::TestRequest::default().to_http_request()); + assert_eq!( + resp.status(), + 200, + "transient probe failure must still complete bootstrap" + ); + let body_bytes = actix_web::body::to_bytes(resp.into_body()) + .await + .unwrap_or_else(|_| panic!("failed to read bootstrap response body")); + let parsed: serde_json::Value = + serde_json::from_slice(&body_bytes).expect("parse bootstrap response"); + assert_eq!(parsed["warning"]["warning"], "could_not_validate_key"); + assert!( + parsed["warning"]["detail"].as_str().is_some(), + "transient warning must carry a detail string" + ); + + // The key was good enough for save (we just couldn't confirm) so it + // must be persisted alongside the ingestion config. + assert!( + config_dir.as_path().join("ingestion_config.json").exists(), + "transient warning must still persist ingestion config" + ); + assert_eq!( + crate::ingestion::anthropic_key_store::load(config_dir.as_path()) + .expect("load store") + .as_deref(), + Some("sk-ant-likely-fine"), + "transient warning must still persist the api key" + ); + + std::env::remove_var(FOLDDB_HOME_VAR); + } } diff --git a/src/server/static-react/src/api/clients/systemClient.ts b/src/server/static-react/src/api/clients/systemClient.ts index 684a3067..fb9b7f58 100644 --- a/src/server/static-react/src/api/clients/systemClient.ts +++ b/src/server/static-react/src/api/clients/systemClient.ts @@ -150,12 +150,23 @@ export interface BootstrapCloudInfo { exemem_user_hash: string; } +/// Soft-warning attached when the Anthropic key probe couldn't return a +/// definitive verdict (DNS, 5xx, timeout). Mirrors the `{warning, detail}` +/// shape returned by `POST /api/ingestion/config` for transient failures. +export interface BootstrapWarning { + warning: string; + detail: string; +} + export interface BootstrapResponse { public_key: string; user_hash: string; // Present on fresh-mint; absent when the request supplied a recovery phrase. recovery_phrase?: string[]; cloud?: BootstrapCloudInfo; + // Present only when the Anthropic key probe failed transiently. A + // definitive bad-key answer is returned as HTTP 400, not 200+warning. + warning?: BootstrapWarning; } export interface SyncTriggerResponse { diff --git a/src/server/static-react/src/components/onboarding/ConfigureAiStep.tsx b/src/server/static-react/src/components/onboarding/ConfigureAiStep.tsx index b16a5836..5779aafd 100644 --- a/src/server/static-react/src/components/onboarding/ConfigureAiStep.tsx +++ b/src/server/static-react/src/components/onboarding/ConfigureAiStep.tsx @@ -22,9 +22,13 @@ interface ConfigureAiStepProps { onChange: (next: Partial) => void onNext: () => void onSkip: () => void + /// Surfaced when bootstrap returned `invalid_anthropic_key`. Shown inline + /// next to the API key field so the user can fix the typo without + /// hunting through a generic toast. + apiKeyError?: string | null } -export default function ConfigureAiStep({ fields, onChange, onNext, onSkip }: ConfigureAiStepProps) { +export default function ConfigureAiStep({ fields, onChange, onNext, onSkip, apiKeyError }: ConfigureAiStepProps) { const [ollamaModels, setOllamaModels] = useState([]) const [ollamaModelsLoading, setOllamaModelsLoading] = useState(false) const [ollamaModelsError, setOllamaModelsError] = useState(null) @@ -153,6 +157,14 @@ export default function ConfigureAiStep({ fields, onChange, onNext, onSkip }: Co className="input" data-testid="api-key-input" /> + {apiKeyError && ( +

+ {apiKeyError} +

+ )}

0 ? detail : fallback +} + interface ProgressIndicatorProps { currentStep: StepId steps: StepDef[] @@ -131,6 +147,16 @@ export default function OnboardingWizard({ onComplete }: OnboardingWizardProps) const [submitting, setSubmitting] = useState(false) const [submitError, setSubmitError] = useState(null) + // Surfaced on ConfigureAiStep when the backend probe definitively rejects + // the supplied Anthropic key (HTTP 400 `invalid_anthropic_key`). Set when + // bootstrap bounces the user back to the AI step so the input field can + // render an inline error next to the bad value. + const [aiKeyError, setAiKeyError] = useState(null) + // Soft-warning attached to a successful bootstrap when the Anthropic + // probe couldn't run (DNS, 5xx, timeout). Shown as a banner above the + // recovery-phrase / next-step view so the user knows ingestion may still + // surface a key problem the probe couldn't catch. + const [bootstrapWarning, setBootstrapWarning] = useState(null) const [recoveryWords, setRecoveryWords] = useState(null) const markCompleted = useCallback((stepId: StepId) => { @@ -181,6 +207,8 @@ export default function OnboardingWizard({ onComplete }: OnboardingWizardProps) }) => { setSubmitting(true) setSubmitError(null) + setAiKeyError(null) + setBootstrapWarning(null) try { const req = buildBootstrapRequest(opts) const resp = await systemClient.bootstrap(req) @@ -189,6 +217,14 @@ export default function OnboardingWizard({ onComplete }: OnboardingWizardProps) throw new Error('Bootstrap response missing data') } + // The backend's Anthropic key probe couldn't return a definitive + // verdict (DNS, 5xx, timeout). Bootstrap still succeeded; surface + // the warning so the user knows the next ingestion attempt may + // bump into a real key problem this probe missed. + if (data.warning) { + setBootstrapWarning(data.warning.detail || data.warning.warning) + } + if (data.cloud?.enabled) { // Persist `exemem_api_key` so the rest of the UI (and a future page // refresh) sees cloud as already active. The bootstrap handler @@ -222,6 +258,15 @@ export default function OnboardingWizard({ onComplete }: OnboardingWizardProps) goToStep('apple-data') } } catch (e) { + // Bad-key probe answer: bounce the user back to the AI step and + // render the upstream error inline next to the API key field + // (don't navigate forward until they fix or change it). + if (isApiError(e) && e.status === 400 && isInvalidAnthropicKeyError(e.response)) { + const detail = extractInvalidKeyDetail(e.response) + setAiKeyError(detail) + goToStep('welcome') + return + } const message = isApiError(e) ? (e.toUserMessage() || e.message) : e instanceof Error @@ -258,12 +303,21 @@ export default function OnboardingWizard({ onComplete }: OnboardingWizardProps) return ( setAiFields(prev => ({ ...prev, ...next }))} + onChange={(next) => { + // Any keystroke on the API key field clears the stale + // server-rejection error so the user gets clean feedback + // when they re-submit. + if (aiKeyError && next.anthropicApiKey !== undefined) { + setAiKeyError(null) + } + setAiFields(prev => ({ ...prev, ...next })) + }} onNext={() => { markCompleted('welcome') goToStep(cloudActive ? 'apple-data' : 'cloud-backup') }} onSkip={() => goToStep(cloudActive ? 'apple-data' : 'cloud-backup')} + apiKeyError={aiKeyError} /> ) case 'cloud-backup': @@ -356,6 +410,22 @@ export default function OnboardingWizard({ onComplete }: OnboardingWizardProps) + {bootstrapWarning && currentStep !== 'welcome' && ( +

+ )} +
{renderStep()}
diff --git a/src/server/static-react/src/test/components/onboarding/ConfigureAiStep.test.tsx b/src/server/static-react/src/test/components/onboarding/ConfigureAiStep.test.tsx index 922ec94e..d600e498 100644 --- a/src/server/static-react/src/test/components/onboarding/ConfigureAiStep.test.tsx +++ b/src/server/static-react/src/test/components/onboarding/ConfigureAiStep.test.tsx @@ -17,7 +17,13 @@ const mockedClient = vi.mocked(ingestionClient as unknown as { const apiOk = (data: T) => ({ success: true as const, data, status: 200 }); -function Harness({ initial }: { initial?: Partial }) { +function Harness({ + initial, + apiKeyError, +}: { + initial?: Partial; + apiKeyError?: string | null; +}) { const [fields, setFields] = useState({ provider: 'Anthropic', anthropicApiKey: '', @@ -31,7 +37,13 @@ function Harness({ initial }: { initial?: Partial }) { [], ); return ( - + ); } @@ -86,6 +98,27 @@ describe('ConfigureAiStep — Ollama Setup hint', () => { expect(screen.queryByTestId('ollama-setup-detected')).toBeNull(); }); + // Bootstrap returns HTTP 400 `invalid_anthropic_key` when Anthropic + // rejects the supplied key. OnboardingWizard bounces back to this step + // and passes the upstream detail via `apiKeyError`. The step must + // render it inline next to the API key input so the user can act on + // it without hunting through a generic toast. + it('renders apiKeyError inline next to the API key field', () => { + render( + , + ); + const error = screen.getByTestId('api-key-error'); + expect(error.textContent).toMatch(/invalid x-api-key/); + }); + + it('does not render the api-key-error block when apiKeyError is null', () => { + render(); + expect(screen.queryByTestId('api-key-error')).toBeNull(); + }); + it('uses the typed model in the pull hint when Ollama is unreachable', async () => { mockedClient.listOllamaModels.mockResolvedValue(apiOk({ models: [] }));