Files
openhuman/src/main.rs
T

374 lines
16 KiB
Rust

//! 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() {
restore_default_sigpipe();
// 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;
}
if openhuman_core::core::observability::is_all_transient_provider_exhaustion_event(
&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).
// Defense-in-depth: drop opaque "GET /auth/me" events from the
// `openhuman.auth_get_me` RPC. The primary fix in
// `credentials::ops::auth_get_me` walks the full anyhow context
// chain so `is_transient_message_failure` can demote transient
// transport failures at the rpc dispatcher. This catches any
// future regression where a sibling call site collapses the
// chain via `e.to_string()` and reproduces TAURI-RUST-10
// (~409 events / 17 users).
if openhuman_core::core::observability::is_auth_get_me_opaque_transport_event(&event) {
log::debug!(
"[sentry-auth-get-me-opaque-filter] dropping opaque transport event_id={:?}",
event.event_id
);
return None;
}
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<String> = 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);
}
}
#[cfg(unix)]
fn restore_default_sigpipe() {
// Rust ignores SIGPIPE at startup. That makes writes to a closed pipe
// return EPIPE, which the print macros turn into a panic. CLI tools should
// instead terminate quietly when a downstream reader such as `head` exits.
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
}
#[cfg(not(unix))]
fn restore_default_sigpipe() {}
// ---------------------------------------------------------------------------
// Release / environment resolution for Sentry
// ---------------------------------------------------------------------------
/// Canonical release tag: `openhuman@<version>[+<short_sha>]`.
///
/// 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<Vec<(Regex, &'static str)>> = Lazy::new(|| {
vec![
// Matches "Bearer <token>" and redacts the token.
(Regex::new(r"(?i)(bearer\s+)\S+").unwrap(), "${1}[REDACTED]"),
// Matches "api-key: <key>" or "api_key=<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]");
}
}