diff --git a/src/api/config.rs b/src/api/config.rs index 8ad398886..107967800 100644 --- a/src/api/config.rs +++ b/src/api/config.rs @@ -228,7 +228,13 @@ pub fn effective_backend_api_url(api_url: &Option) -> String { } } if let Some(env_url) = api_base_from_env() { - return env_url; + // Strip any inference-style path that slipped through the env / + // compile-time bake (`BACKEND_URL=https://api.tinyhumans.ai/openai/v1/chat/completions` + // produces a backend base that 404s every domain path — see Sentry + // `OPENHUMAN-TAURI-H6 / -HN`, issue #2075). The override branch + // above already normalizes; without normalizing here the env path + // silently bypassed it. + return normalize_backend_api_base_url(&env_url); } default_api_base_url_for_env(app_env_from_env().as_deref()).to_string() } @@ -239,9 +245,21 @@ pub fn effective_backend_api_url(api_url: &Option) -> String { /// as `https://api.tinyhumans.ai/openai/v1/chat/completions`. Backend /// callers append domain-specific paths, so the LLM-specific path must not /// survive into the backend base. -fn normalize_backend_api_base_url(url: &str) -> String { +pub(crate) fn normalize_backend_api_base_url(url: &str) -> String { let normalized = normalize_api_base_url(url); - let Ok(mut parsed) = url::Url::parse(&normalized) else { + if normalized.is_empty() { + return normalized; + } + // Try parsing as-is first; if it fails (no scheme — e.g. a misbaked + // `BACKEND_URL=api.tinyhumans.ai/openai/v1/chat/completions`), + // retry with an `https://` prefix so we can still strip the path + // before the value is used as a base. Without this fallback, a + // scheme-less override carrying an inference path fell straight + // through to `api_url()` + `fallback_concat()`, reproducing the + // exact 404 URLs in Sentry `OPENHUMAN-TAURI-H6 / -HN` (issue #2075). + let parsed = + url::Url::parse(&normalized).or_else(|_| url::Url::parse(&format!("https://{normalized}"))); + let Ok(mut parsed) = parsed else { return normalized; }; @@ -272,7 +290,7 @@ fn warn_backend_url_fallback_once(local_url: &str) { }); } -fn redact_url_for_log(raw: &str) -> String { +pub(crate) fn redact_url_for_log(raw: &str) -> String { let trimmed = raw.trim(); // Attempt bare-host parsing (e.g. "localhost:1234") before giving up so // that non-scheme URLs are still redacted rather than returned verbatim. @@ -946,4 +964,44 @@ mod tests { assert_eq!(integrations, api); } + + #[test] + fn effective_backend_api_url_strips_inference_path_from_env() { + // Regression for issue #2075 / Sentry OPENHUMAN-TAURI-H6, -HN: a + // misconfigured `BACKEND_URL` baked an inference path into the + // env-fallback branch, which silently fell through to integration + // callers as e.g. + // …/openai/v1/chat/completions/agent-integrations/composio/connections + let _guard = env_lock(); + let _env = EnvSnapshot::clear_backend_env(); + std::env::set_var( + "BACKEND_URL", + "https://api.tinyhumans.ai/openai/v1/chat/completions", + ); + + let result = effective_backend_api_url(&None); + + assert_eq!(result, "https://api.tinyhumans.ai"); + } + + #[test] + fn normalize_backend_api_base_url_handles_schemeless_input() { + // Defensive: env files / compile-time bakes sometimes drop the + // scheme. Without the `https://` fallback we used to return the + // raw string unchanged, leaving the inference path attached. + let cleaned = + normalize_backend_api_base_url("api.tinyhumans.ai/openai/v1/chat/completions"); + assert_eq!(cleaned, "https://api.tinyhumans.ai"); + } + + #[test] + fn normalize_backend_api_base_url_passes_through_clean_root() { + let cleaned = normalize_backend_api_base_url("https://api.tinyhumans.ai/"); + assert_eq!(cleaned, "https://api.tinyhumans.ai"); + } + + #[test] + fn normalize_backend_api_base_url_empty_string_is_idempotent() { + assert_eq!(normalize_backend_api_base_url(""), ""); + } } diff --git a/src/openhuman/integrations/client.rs b/src/openhuman/integrations/client.rs index ed7c255d1..1b3546816 100644 --- a/src/openhuman/integrations/client.rs +++ b/src/openhuman/integrations/client.rs @@ -37,6 +37,38 @@ pub(crate) fn extract_error_detail(body: &str, max_bytes: usize) -> String { crate::openhuman::util::truncate_at_byte_boundary(body, max_bytes) } +/// Strip any inference-style path that snuck into a backend URL before +/// it becomes the [`IntegrationClient::backend_url`] field. Idempotent — +/// returns the input unchanged when already clean. +/// +/// See issue #2075 / Sentry `OPENHUMAN-TAURI-H6`, `-HN`: a misconfigured +/// `BACKEND_URL` env (e.g. `https://api.tinyhumans.ai/openai/v1/chat/completions`) +/// baked into a build silently produced 404 URLs like +/// `…/openai/v1/chat/completions/agent-integrations/composio/connections` +/// because every `IntegrationClient` method joins paths onto this field +/// via [`crate::api::config::api_url`]. +fn sanitize_backend_url(backend_url: &str) -> String { + let cleaned = crate::api::config::normalize_backend_api_base_url(backend_url); + let trimmed = backend_url.trim().trim_end_matches('/'); + if !cleaned.is_empty() && cleaned != trimmed { + // Redact userinfo (username/password) before logging — a + // misconfigured URL could carry credentials in the authority + // segment. The helper preserves host/path for diagnosability + // while scrubbing secrets. + tracing::warn!( + input = %crate::api::config::redact_url_for_log(trimmed), + cleaned = %crate::api::config::redact_url_for_log(&cleaned), + "[integrations] backend_url carried an inference / non-root path; \ + stripping before use (issue #2075)" + ); + } + if cleaned.is_empty() { + backend_url.to_string() + } else { + cleaned + } +} + /// Shared client for all integration tools. Holds backend URL, auth token, /// a reusable `reqwest::Client`, and a lazily-fetched pricing cache. pub struct IntegrationClient { @@ -48,6 +80,18 @@ pub struct IntegrationClient { impl IntegrationClient { pub fn new(backend_url: String, auth_token: String) -> Self { + // Defense-in-depth (issue #2075 / Sentry OPENHUMAN-TAURI-H6, -HN): + // every prod call site routes `backend_url` through + // `effective_backend_api_url` which strips inference-style paths, + // but any future caller that forgets that step would silently + // produce 404 URLs like + // https://api.tinyhumans.ai/openai/v1/chat/completions/agent-integrations/composio/connections + // (the inference path concatenated with every domain path). We + // re-strip here so the field invariant — "backend_url has no + // inference path" — holds locally, and `warn!` once when we have + // to fix up the input so the regression is observable in logs. + let backend_url = sanitize_backend_url(&backend_url); + // Match the TLS config used by `BackendOAuthClient` in // `src/api/rest.rs`: force rustls + HTTP/1.1 so we get the same // consistent cross-platform behaviour every other backend-proxied diff --git a/src/openhuman/integrations/client_tests.rs b/src/openhuman/integrations/client_tests.rs index a31568d1a..d95f06224 100644 --- a/src/openhuman/integrations/client_tests.rs +++ b/src/openhuman/integrations/client_tests.rs @@ -376,3 +376,37 @@ async fn jira_generic_400_classifies_as_backend_user_error() { "Jira generic 400 must classify as BackendUserError; got: {msg}" ); } + +// ── Unit: `sanitize_backend_url` (issue #2075) ──────────────────── + +#[test] +fn sanitize_backend_url_strips_inference_path() { + // Regression: a misconfigured `BACKEND_URL` baked into the build + // (`https://api.tinyhumans.ai/openai/v1/chat/completions`) used to + // become every integration call's prefix, producing 404s such as + // `…/openai/v1/chat/completions/agent-integrations/composio/connections`. + let cleaned = sanitize_backend_url("https://api.tinyhumans.ai/openai/v1/chat/completions"); + assert_eq!(cleaned, "https://api.tinyhumans.ai"); +} + +#[test] +fn sanitize_backend_url_idempotent_on_clean_root() { + let cleaned = sanitize_backend_url("https://api.tinyhumans.ai"); + assert_eq!(cleaned, "https://api.tinyhumans.ai"); +} + +#[test] +fn sanitize_backend_url_preserves_empty_input() { + // Empty / unparseable input must round-trip unchanged so we don't + // overwrite a caller's explicit "no backend" sentinel. + assert_eq!(sanitize_backend_url(""), ""); +} + +#[test] +fn integration_client_new_strips_inference_path_from_backend_url() { + let client = IntegrationClient::new( + "https://api.tinyhumans.ai/openai/v1/chat/completions".to_string(), + "token".to_string(), + ); + assert_eq!(client.backend_url, "https://api.tinyhumans.ai"); +}