mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-28 13:32:23 +00:00
400 lines
18 KiB
Rust
400 lines
18 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;
|
|
}
|
|
// Drop provider monthly-quota exhausted events — third-party plan
|
|
// allotment spent (e.g. Kiro `MONTHLY_REQUEST_COUNT`, sometimes
|
|
// wrapped in a 500 envelope so the 402-gated credits filter above
|
|
// misses it). No local lever (TAURI-RUST-C9A).
|
|
if openhuman_core::core::observability::is_quota_exhausted_event(&event) {
|
|
return None;
|
|
}
|
|
// Defense-in-depth for Ollama Cloud hosted-inference 500s. The
|
|
// native_chat / streaming_chat / api_error emit sites demote them and
|
|
// the agent re-report routes through `TransientUpstreamHttp`, but the
|
|
// compatible provider can report the same `Internal Server Error
|
|
// (ref: …)` body from other paths; this is the single net that
|
|
// catches every path (TAURI-RUST-5MV).
|
|
if openhuman_core::core::observability::is_ollama_cloud_internal_500_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)
|
|
|| openhuman_core::core::observability::is_skill_install_user_fetch_failure(&event)
|
|
{
|
|
return None;
|
|
}
|
|
// Defense-in-depth: drop skill-install fetch 4xx (esp. 404/410) —
|
|
// a missing/renamed catalog `SKILL.md` is expected user-input state
|
|
// surfaced to the UI, not a Sentry-actionable defect. Primary
|
|
// suppression lives at the `install_workflow_from_url_with_home`
|
|
// emit site; this catches any future skills call site that reports
|
|
// a 4xx. 5xx (genuine remote failure) still reports. TAURI-RUST-CGE.
|
|
if openhuman_core::core::observability::is_skills_install_client_error_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]");
|
|
}
|
|
}
|