mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(integrations): strip inference path from backend URL (#2075)
Normalize `backend_url` before it reaches `IntegrationClient` so inference-style paths (e.g. `/openai/v1/chat/completions`) cannot produce broken composio URLs. Three-layer fix: 1. `effective_backend_api_url` env-fallback branch now calls `normalize_backend_api_base_url` (same as the override branch) 2. `normalize_backend_api_base_url` handles scheme-less inputs with `https://` fallback retry 3. `IntegrationClient::new` defense-in-depth sanitization with redacted `warn!` log on fixup Closes #2075
This commit is contained in:
+62
-4
@@ -228,7 +228,13 @@ pub fn effective_backend_api_url(api_url: &Option<String>) -> 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>) -> 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(""), "");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user