//! The entry point for the OpenHuman core application. //! //! This file is responsible for: //! - Initializing error tracking with Sentry. //! - Setting up secret scrubbing for outgoing error reports. //! - Dispatching command-line arguments to the core logic in `openhuman_core`. use once_cell::sync::Lazy; use regex::Regex; /// Main application entry point. /// /// It initializes the Sentry SDK for error monitoring, ensuring that sensitive /// information is redacted before being sent to the server. After setup, it /// delegates execution to the core library based on CLI arguments. fn main() { // Load `.env` before `sentry::init` so a DSN defined only in the dotenv // file is visible to the Sentry client at startup. `dotenvy::dotenv()` is // a no-op for variables already present in the process environment, and // the CLI dispatcher later calls `load_dotenv_for_cli` which honors // `OPENHUMAN_DOTENV_PATH`; this early call handles the common default // case (repo-local `.env`) so startup-time consumers (Sentry, config // overrides) see the same values as runtime RPC handlers. let _ = dotenvy::dotenv(); // Initialize Sentry as the very first operation so the guard outlives everything. // Resolves the core Sentry DSN by checking, in order: // 1. `OPENHUMAN_CORE_SENTRY_DSN` at runtime (preferred, namespaced name) // 2. `OPENHUMAN_SENTRY_DSN` at runtime (legacy unprefixed name — kept // so existing CI vars and contributor `.env` files keep working until // the GH org-level variable can be renamed) // 3. Each of the same names baked at compile time via `option_env!` // If none resolve to a non-empty value, `sentry::init` returns a no-op guard. let _sentry_guard = sentry::init(sentry::ClientOptions { dsn: std::env::var("OPENHUMAN_CORE_SENTRY_DSN") .ok() .filter(|s| !s.is_empty()) .or_else(|| std::env::var("OPENHUMAN_SENTRY_DSN").ok()) .filter(|s| !s.is_empty()) .or_else(|| option_env!("OPENHUMAN_CORE_SENTRY_DSN").map(|s| s.to_string())) .filter(|s| !s.is_empty()) .or_else(|| option_env!("OPENHUMAN_SENTRY_DSN").map(|s| s.to_string())) .filter(|s| !s.is_empty()) .and_then(|s| s.parse().ok()), release: Some(std::borrow::Cow::Owned(build_release_tag())), environment: Some(std::borrow::Cow::Owned(resolve_environment())), send_default_pii: false, before_send: Some(std::sync::Arc::new(|mut event| { // Defense-in-depth: drop transient-upstream provider failures that // slipped past the call-site classifier. The reliable-provider // layer already retries 429/408/502/503/504 with backoff + // fallback, and the aggregate "all providers exhausted" event // still fires for genuine outages. Per-attempt reports flood // Sentry — see OPENHUMAN-TAURI-2E (~1393 events), -84 (~1050), // -T (~871). The primary fix lives in // `openhuman::inference::provider::ops::should_report_provider_http_failure` // (transient codes excluded). This filter catches any future call // site that bypasses it. if openhuman_core::core::observability::is_transient_provider_http_failure(&event) { return None; } // Defense-in-depth: drop managed-backend `errorCode` events (#870) // the backend owns (F2/F4) — primary suppression lives in // `api_error` / the streaming gates and the `web_channel` // re-report classifier. The malformed `BAD_REQUEST` carve-out // (F8) is excluded by the underlying decision, so a client-built // bad payload still pages. if openhuman_core::core::observability::is_backend_error_code_event(&event) { return None; } // Defense-in-depth: drop transient streaming transport blips // (domain=llm_provider, failure=transport) — flaky-network // timeouts/resets recovered by retry/fallback (F7). The primary // gate lives at the `stream_chat` / `stream_chat_history` emit // sites. if openhuman_core::core::observability::is_transient_provider_transport_failure(&event) { return None; } // Defense-in-depth for budget-exhausted 400s. Emit sites demote the // known backend responses before they hit Sentry; this catches any // future non_2xx/status=400 event that carries the same tight body // phrases. if openhuman_core::core::observability::is_budget_event(&event) { return None; } // Defense-in-depth for insufficient-credits 402s. The native_chat // emit site demotes them, but the compatible provider reports the // same out-of-balance 402 from chat_with_system / chat_with_history // / the streaming gates / api_error too; this is the single net // that catches every path (TAURI-RUST-C62). if openhuman_core::core::observability::is_insufficient_credits_event(&event) { return None; } // Defense-in-depth: drop max-tool-iterations cap events that // slipped past the call-site filters in // `agent::harness::session::runtime::run_single`, // `channels::runtime::dispatch`, and // `channels::providers::web::run_chat_task`. The cap is a // deterministic agent-state outcome surfaced to the user via // the chat-rendered "Error: …" message — Sentry is the wrong // surface for it (OPENHUMAN-TAURI-99 / -98). if openhuman_core::core::observability::is_max_iterations_event(&event) { return None; } if openhuman_core::core::observability::is_transient_backend_api_failure(&event) || openhuman_core::core::observability::is_transient_integrations_failure(&event) || openhuman_core::core::observability::is_updater_transient_event(&event) { return None; } // Defense-in-depth: 404 on PATCH/DELETE to a channel-message path // is an expected state (provider-side delete or backend GC). Primary // suppression lives in `authed_json`; this catches any future call // site that bypasses it. Targets OPENHUMAN-TAURI-R7 (28 events). if openhuman_core::core::observability::is_channel_message_not_found_event(&event) { return None; } // Drop 401 "Session expired. Please log in again." bodies surfaced // by llm_provider / backend_api, plus pre-flight "no session token // stored" guards from the rpc dispatcher. Primary suppression // lives at the call sites (`openhuman::inference::provider::ops::api_error` // publishes a SessionExpired event_bus signal and short-circuits; // the rpc dispatcher's `is_session_expired_error` skip-path in // `src/core/jsonrpc.rs` redirects to a tracing::info). This // filter catches any future call site that re-emits the same // shape — keeping OPENHUMAN-TAURI-25 / -1Q / -27 / -1G off // Sentry permanently (~185 events/day combined). if openhuman_core::core::observability::is_session_expired_event(&event) { // Metadata-only log shape — `event.message` carries the raw // backend response body (often a JSON envelope with the // session JWT context attached) which CLAUDE.md forbids from // local logs. `event.event_id` is a correlation-safe Sentry // uuid that lets triage match the dropped event against the // breadcrumb chain without leaking the payload. log::debug!( "[sentry-session-expired-filter] dropping session-expired event_id={:?}", event.event_id ); return None; } // Strip server_name (hostname) to avoid leaking machine identity event.server_name = None; // Attach the cached account uid so Sentry can count unique users // affected by an issue. We only carry `id` — never email, name, // or IP — so this stays consistent with `send_default_pii: false`. // // Issue #3135: the primary source for `event.user` is now the // Sentry scope, bound proactively at session boundaries // (credentials::store_session / clear_session) and at server boot // (run_server_inner). The `app_state_snapshot` cache is kept as a // fallback so any pre-boot / pre-login event that still rides // the legacy path retains its previous attribution behaviour — // but we only consult it when the scope hasn't already bound a // user, otherwise we'd silently clobber the scope binding when // the cache is empty (root cause of the original userCount=0). if event.user.is_none() { event.user = openhuman_core::openhuman::app_state::peek_cached_current_user_identity() .and_then(|identity| identity.id) .map(|id| sentry::User { id: Some(id), ..Default::default() }); } // Scrub secrets from exception values and top-level message. for exc in &mut event.exception.values { if let Some(ref value) = exc.value { exc.value = Some(scrub_secrets(value)); } } if let Some(msg) = event.message.take() { event.message = Some(scrub_secrets(&msg)); } Some(event) })), sample_rate: 1.0, ..sentry::ClientOptions::default() }); // Collect command-line arguments, skipping the binary name. let args: Vec = std::env::args().skip(1).collect(); // Delegate to the core library to handle the command. if let Err(err) = openhuman_core::run_core_from_args(&args) { eprintln!("{err}"); std::process::exit(1); } } // --------------------------------------------------------------------------- // Release / environment resolution for Sentry // --------------------------------------------------------------------------- /// Canonical release tag: `openhuman@[+]`. /// /// Matches the string the frontend reports (`SENTRY_RELEASE` in /// `app/src/utils/config.ts`) so events from every surface group under /// the same release in the Sentry dashboard and benefit from the same /// source-map upload. fn build_release_tag() -> String { let version = env!("CARGO_PKG_VERSION"); let sha = option_env!("OPENHUMAN_BUILD_SHA").unwrap_or("").trim(); let sha_short: String = sha.chars().take(12).collect(); if sha_short.is_empty() { format!("openhuman@{version}") } else { format!("openhuman@{version}+{sha_short}") } } /// Resolve the deployment environment reported to Sentry. /// /// Honors `OPENHUMAN_APP_ENV` at runtime (`staging` / `production`) so the /// same binary could in principle be redeployed between environments; falls /// back to debug/release detection when unset. fn resolve_environment() -> String { if let Ok(value) = std::env::var("OPENHUMAN_APP_ENV") { let trimmed = value.trim().to_ascii_lowercase(); if !trimmed.is_empty() { return trimmed; } } if cfg!(debug_assertions) { "development".to_string() } else { "production".to_string() } } // --------------------------------------------------------------------------- // Secret scrubbing // --------------------------------------------------------------------------- /// Ordered most-specific → least-specific. Keep in sync with /// `src/openhuman/memory/safety/mod.rs`. static SECRET_PATTERNS: Lazy> = Lazy::new(|| { vec![ // Matches "Bearer " and redacts the token. (Regex::new(r"(?i)(bearer\s+)\S+").unwrap(), "${1}[REDACTED]"), // Matches "api-key: " or "api_key=" and redacts the key. ( Regex::new(r"(?i)(api[_-]?key[=:\s]+)\S+").unwrap(), "${1}[REDACTED]", ), // \b anchor prevents matching `cancellation_token=` etc. ( Regex::new(r"(?i)\b(token[=:\s]+)\S+").unwrap(), "${1}[REDACTED]", ), // Anthropic keys (sk-ant-api03-...) contain hyphens the generic // sk- pattern below won't match. ( Regex::new(r"sk-ant-[A-Za-z0-9\-_]{16,}").unwrap(), "[REDACTED]", ), // OpenAI admin keys (sk-admin-...). ( Regex::new(r"sk-admin-[A-Za-z0-9\-_]{12,}").unwrap(), "[REDACTED]", ), // OpenAI project-scoped and org-scoped keys (sk-proj-... / sk-org-...). ( Regex::new(r"sk-(?:proj|org)-[A-Za-z0-9\-_]{12,}").unwrap(), "[REDACTED]", ), // Generic catch-all for any sk- format not covered above. (Regex::new(r"sk-[a-zA-Z0-9]{20,}").unwrap(), "[REDACTED]"), ] }); /// Replaces patterns that look like secrets with `[REDACTED]`. fn scrub_secrets(input: &str) -> String { let mut result = input.to_string(); for (re, replacement) in SECRET_PATTERNS.iter() { result = re.replace_all(&result, *replacement).into_owned(); } result } #[cfg(test)] mod tests { use super::*; #[test] fn scrubs_bearer_token() { assert_eq!( scrub_secrets("Authorization: Bearer abc123xyz"), "Authorization: Bearer [REDACTED]" ); } #[test] fn scrubs_api_key() { assert_eq!(scrub_secrets("api_key=sk-abc123"), "api_key=[REDACTED]"); } #[test] fn scrubs_anthropic_key() { assert_eq!( scrub_secrets("key: sk-ant-api03-abcdefghijklmnop"), "key: [REDACTED]" ); } #[test] fn scrubs_openai_admin_key() { assert_eq!( scrub_secrets("key: sk-admin-abcdefghijkl"), "key: [REDACTED]" ); } #[test] fn scrubs_openai_proj_key() { assert_eq!( scrub_secrets("key: sk-proj-abcdefghijkl"), "key: [REDACTED]" ); } #[test] fn scrubs_generic_sk_key() { assert_eq!(scrub_secrets("sk-abcdefghijklmnopqrstuvwx"), "[REDACTED]"); } #[test] fn token_word_boundary_no_false_positive() { let input = "cancellation_token=abc123 next_page_token=xyz789"; let result = scrub_secrets(input); assert_eq!(result, input, "should not scrub compound token fields"); } #[test] fn standalone_token_is_scrubbed() { assert_eq!(scrub_secrets("token=secret_value_here"), "token=[REDACTED]"); } }