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:
obchain
2026-05-19 16:26:13 +05:30
committed by GitHub
parent 764eda65ea
commit 4384cd1a40
3 changed files with 140 additions and 4 deletions
+62 -4
View File
@@ -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(""), "");
}
}
+44
View File
@@ -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");
}