diff --git a/Cargo.toml b/Cargo.toml index 1086c0e21..70d41d6c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,10 @@ path = "src/bin/test_mcp_stub.rs" [[bin]] name = "openhuman-fleet" path = "src/bin/fleet.rs" +# The fleet supervisor embeds the axum control-plane server, so it only builds +# with the HTTP transport compiled in (#5048). Under `--no-default-features` +# (no `http-server`) this target is skipped rather than failing to link. +required-features = ["http-server"] # Embedded-RSS benchmark harness (#5046). Gated behind the default-OFF # `rss-bench` feature so no benchmark code enters the shipped build. Build with @@ -118,7 +122,15 @@ serde_yaml = "0.9" # stripper (`fast_html_to_text` in # providers/gmail/post_process.rs) and prefer the email's # `text/plain` MIME part when available.) -reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "native-tls", "stream", "http2", "multipart", "socks"] } +# TLS: rustls only in the base declaration, so Linux/macOS — including the +# headless embedding target (#5046) — link a single TLS stack + cert set +# (Mozilla webpki-roots). Windows re-adds `native-tls` via the +# `[target.'cfg(windows)'.dependencies]` block below (Cargo unions features +# per target), because `src/openhuman/tls/mod.rs` deliberately routes the +# Windows client through the SChannel/OS cert store for corporate MITM + +# AV root CAs. So the two TLS backends coexist only on Windows, never on +# the RAM-sensitive platforms. +reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "stream", "http2", "multipart", "socks"] } # Already in-tree via reqwest/hyper; named directly so `IntegrationClient::get_bytes` # can return `bytes::Bytes` without a copy. bytes = "1" @@ -211,16 +223,22 @@ sysinfo = { version = "0.33", default-features = false, features = ["system"] } keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] } clap = { version = "4.5", features = ["derive"] } lettre = { version = "0.11.22", default-features = false, features = ["builder", "smtp-transport", "rustls-tls"], optional = true } -axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "query", "ws", "macros"] } -tower = { version = "0.5", default-features = false } -sentry = { version = "0.47.0", default-features = false, features = ["backtrace", "contexts", "panic", "tracing", "debug-images", "reqwest", "rustls"] } +# HTTP + Socket.IO server transport — the `/rpc` JSON-RPC endpoint, `/v1` +# OpenAI-compat router, agentbox/http_host sub-servers, and the Socket.IO +# live-event bridge. Exclusive to the default-ON `http-server` feature (#5048): +# a slim/embedded build without it (`--no-default-features`) sheds `axum` + +# `socketioxide` and never binds a listener — the core still runs background +# services and answers over the CLI/native dispatch surface. See the +# `http-server` feature note below and `src/core/http_server_status.rs`. +axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "query", "ws", "macros"], optional = true } +sentry = { version = "0.47.0", default-features = false, optional = true, features = ["backtrace", "contexts", "panic", "tracing", "debug-images", "reqwest", "rustls"] } tokio-stream = { version = "0.1.18", features = ["full"] } url = "2" -socketioxide = { version = "0.15", features = ["extensions"] } -whisper-rs = "0.16" +socketioxide = { version = "0.15", features = ["extensions"], optional = true } +whisper-rs = { version = "0.16", optional = true } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } tempfile = "3" -cpal = "0.15" +cpal = { version = "0.15", optional = true } hound = { version = "3.5", optional = true } enigo = "0.3" arboard = "3" @@ -249,11 +267,11 @@ coins-bip39 = "0.8" curve25519-dalek = { version = "4", default-features = false, features = ["alloc"], optional = true } fantoccini = { version = "0.22.0", optional = true, default-features = false, features = ["rustls-tls"] } -pdf-extract = "0.10" +pdf-extract = { version = "0.10", optional = true } # The WhatsApp Web provider (and its `whatsapp-rust` / `wacore` / `serde-big-array` # stack) now lives in the tinychannels crate; the `whatsapp-web` feature forwards # to `tinychannels/whatsapp-web`. -ppt-rs = "0.2.14" +ppt-rs = { version = "0.2.14", optional = true } # Terminal chat UI (`openhuman tui` / `chat`). Exclusive to the default-ON # `tui` feature (see `[features]` below): a slim / headless build without `tui` # drops both `ratatui` and `crossterm`. Kept in lockstep — ratatui 0.30 @@ -271,13 +289,19 @@ unicode-width = { version = "0.2", optional = true } # `ppt-rs` presentation engine: synthesise bytes in-process, hand them to # the byte-agnostic artifact pipeline. `default-features` keeps the crate's # image support (unused here, but avoids a bespoke feature list drifting). -docx-rs = "0.4.20" +docx-rs = { version = "0.4.20", optional = true } [target.'cfg(windows)'.dependencies] # Windows: tokio-tungstenite uses native-tls (schannel) so wss:// # connections honor the Windows cert store, including corporate CAs # installed by AV / TLS-inspection proxies. See run-dev-win.sh notes. tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "handshake", "native-tls"] } +# Windows re-adds reqwest's native-tls backend (dropped from the base decl in +# `[dependencies]`) so `tls::tls_client_builder()` can call `.use_native_tls()` +# and reach the SChannel/OS cert store — same rationale as tokio-tungstenite +# above. Cargo unions these features with the base rustls decl, so on Windows +# reqwest carries both backends; Linux/macOS keep rustls only. +reqwest = { version = "0.12", default-features = false, features = ["native-tls"] } # AppContainer / process-jail backend in `openhuman::cwd_jail`. # Feature list mirrors the Win32 surface used by cwd_jail/windows.rs: # AppContainer profile APIs, ACL editing, STARTUPINFOEXW process spawn, @@ -305,7 +329,7 @@ uiautomation = { version = "0.25", optional = true } tokio-tungstenite = { version = "0.24", default-features = false, features = ["connect", "handshake", "rustls-tls-webpki-roots"] } [target.'cfg(target_os = "macos")'.dependencies] -whisper-rs = { version = "0.16", features = ["metal"] } +whisper-rs = { version = "0.16", features = ["metal"], optional = true } # Contacts framework bindings for address book seeding. objc2 = "0.6" objc2-foundation = { version = "0.3", features = ["NSArray", "NSError", "NSObject", "NSString", "NSPredicate"] } @@ -324,6 +348,18 @@ rppal = { version = "0.22", optional = true } # crates we never use (and that bloat the dev Cargo.lock noticeably). # TestTransport only needs the `test` feature. sentry = { version = "0.47.0", default-features = false, features = ["test"] } +# axum is optional in `[dependencies]` (exclusive to the default-ON +# `http-server` feature, #5048), but ~17 `#[cfg(test)]` modules stand up +# in-process axum mock servers / call `build_core_http_router` regardless of +# the feature set under test. Declaring a plain (non-optional) dev-dep keeps +# those tests compiling in ALL test builds without per-file `#[cfg]` — mirrors +# the `sentry` dual-declaration above. The prod fns those tests exercise are +# still gated, so the *tests* naming them carry `#[cfg(feature = "http-server")]`. +axum = { version = "0.8", default-features = false, features = ["http1", "json", "tokio", "query", "ws", "macros"] } +# `tower` was a plain runtime dep only for its re-exports in the axum server +# path; with `http-server` optional it is test-only (tower::ServiceExt::oneshot +# drives the mock routers), so it lives here as a dev-dep now. +tower = { version = "0.5", default-features = false } # Mock HTTP server for provider E2E tests (inference_provider_e2e). wiremock = "0.6" # Used in json_rpc_e2e to backdate mtime on stale lock files. @@ -338,12 +374,56 @@ tokio = { version = "1", features = ["test-util"] } proptest = "1" [features] -default = ["tokenjuice-treesitter", "voice", "web3", "media", "meet", "skills", "flows", "mcp", "desktop-automation", "tui"] +default = ["tokenjuice-treesitter", "inference", "voice", "web3", "media", "documents", "meet", "skills", "flows", "mcp", "crash-reporting", "http-server", "desktop-automation", "tui"] +# HTTP + Socket.IO server transport (#5048): the `/rpc` JSON-RPC endpoint and +# its auth middleware/CORS layer (`core::jsonrpc`, `core::auth`), the `/v1` +# OpenAI-compatible router (`inference::http`), the ad-hoc static-dir file +# server (`openhuman::http_host`), the AgentBox `/run` HTTP surface +# (`agentbox::http`), the WebSocket dictation stream +# (`inference::voice::streaming`), the `openhuman text-input run` dev server, +# the MCP Streamable-HTTP transport (`mcp_server::http`, additionally gated by +# `mcp`), and the Socket.IO live-event bridge (`core::socketio`). Default-ON — +# the desktop shell REQUIRES it (see the `HTTP_SERVER_COMPILED_IN` compile +# assert in `app/src-tauri/src/lib.rs`). Slim / headless-embedding builds opt +# out via `--no-default-features --features ""`, which drops the exclusive `axum` + `socketioxide` deps; the +# core then runs background services without binding a listener (`serve()` +# returns early) and is driven over the CLI / native dispatch surface instead. +# +# TYPE CARVE-OUT (see AGENTS.md): `core::socketio`'s inert event payload types +# (`WebChannelEvent`, `TurnUsagePayload`, `SubagentUsagePayload`, +# `SubagentProgressDetail`) stay compiled in BOTH builds — ~10 always-on +# domains construct them — so `pub mod socketio;` is UNGATED and only the +# socketioxide/axum-touching bodies are gated. Likewise `inference::http::types` +# and `EXTERNAL_OPENAI_COMPAT_PROVIDER` stay compiled for `core::auth`. +http-server = ["dep:axum", "dep:socketioxide"] # AST-aware code compression (tree-sitter Rust/TS/Python grammars; C build). # On by default; disable to fall back to the brace-depth heuristic. tokenjuice-treesitter = [ "tinyjuice/tinyjuice-treesitter", ] +# In-process inference engine: the whisper.cpp STT engine +# (`inference::local::service::whisper_engine`) plus the `cpal` audio-device +# probe shared by voice capture and the accessibility microphone-permission +# check. Default-ON. Slim / headless builds opt out via +# `--no-default-features --features ""`, which +# drops the exclusive `whisper-rs` (including the macOS `metal` variant and the +# `whisper-rs-sys` patch, which go inert once whisper-rs leaves the graph) and +# `cpal` dependencies. When off, the whisper facade resolves to +# `whisper_engine::stub` — in-process STT returns a disabled error, while the +# local-AI service still reaches Ollama / LM Studio over HTTP — and the +# microphone probe reports `Unknown`. `voice` requires this gate, so building +# `voice` always pulls `inference` in transitively. +inference = ["dep:whisper-rs", "dep:cpal"] +# Office-document tools: the `generate_presentation` (ppt-rs) and +# `generate_document` (docx-rs) agent tools, plus `pdf-extract` for PDF text +# extraction during multimodal file ingest. Default-ON. Slim / headless builds +# opt out via `--no-default-features --features ""`, which drops all three crates. Leaf gate (no stub facade): when +# off, the two tools are absent from the tool list rather than degraded to an +# error, and PDF ingest degrades the file to a reference instead of extracted +# text (`agent::multimodal::extract_pdf_text`). +documents = ["dep:pdf-extract", "dep:ppt-rs", "dep:docx-rs"] # Voice + audio_toolkit domains: STT/TTS providers, the standalone dictation # server, always-on listening, and podcast audio generation/email delivery. # Default-ON — the desktop app always ships with voice. Slim / headless builds @@ -351,10 +431,9 @@ tokenjuice-treesitter = [ # which also drops the exclusive `hound` (WAV I/O) + `lettre` (podcast email) # dependencies. Composes with the runtime `DomainSet::voice` flag (#4796): the # feature narrows the compile-time surface, `DomainSet` gates it at runtime. -# NOTE: this gate does NOT drop whisper-rs / llama / cpal — those live in the -# inference domain (shared with accessibility for cpal) and await a separate -# `inference` gate. -voice = ["dep:hound", "dep:lettre"] +# Requires `inference` (the whisper engine + the cpal capture stack), so +# enabling `voice` turns `inference` on transitively. +voice = ["inference", "dep:hound", "dep:lettre"] # Web3 domains: openhuman::wallet + openhuman::web3 + openhuman::x402 — the # crypto wallet (multi-chain sign/broadcast), the high-level swap/bridge/dapp # surface, and the x402 machine-payment protocol. Default-ON — the desktop app @@ -449,6 +528,28 @@ skills = [] # `sanitize::sanitize_for_llm`). The gate follows the real dependency graph, # not the directory name. mcp = [] +# Sentry crash/error reporting: the `sentry::init` guard + secret-scrubbing +# `before_send` hook (src/main.rs), the sentry-tracing bridge layer +# (core::logging), the ~24 `before_send` Event classifiers + the two +# `report_*_message` capture paths (core::observability), the session-boundary +# scope binding (credentials::sentry_scope), and the `openhuman sentry-test` +# CLI probe. Default-ON — the desktop app always ships with crash reporting. +# Slim / headless builds opt out via `--no-default-features --features +# ""`, which drops the exclusive +# `sentry` dependency (and its transitive backtrace/contexts/panic/tracing/ +# debug-images/reqwest/rustls stack) from the non-test build. +# +# TYPE CARVE-OUT (see AGENTS.md "Compile-time domain gates"): the sentry-free +# string classifiers (`is_transient_message_failure`, +# `is_insufficient_credits_message`, `contains_transient_transport_phrase`, …) +# and the `report_*_message` / `sentry_scope::{bind,clear}` signatures stay +# compiled in BOTH builds — only the `sentry::`-touching bodies are gated — so +# NO stub file is needed and always-on callers need no per-call `#[cfg]`. When +# off, `report_error_message`/`report_warning_message` still emit their +# `tracing::{error,warn}!` diagnostic, `sentry_tracing_layer()` degrades to a +# no-op `Identity` layer, and `openhuman sentry-test` returns a "built without +# the crash-reporting feature" error. +crash-reporting = ["dep:sentry"] # Desktop-automation cluster (#5049): the five modules that read/drive the local # desktop UI — `openhuman::accessibility` (macOS AX / Windows UIA FFI middleware), @@ -484,7 +585,6 @@ desktop-automation = ["dep:uiautomation"] # build-fact "tui feature disabled at compile time" error from the untouched # `"tui" | "chat"` CLI arm (mirrors the `mcp` stub pattern). tui = ["dep:ratatui", "dep:crossterm", "dep:unicode-width"] - sandbox-landlock = ["dep:landlock"] sandbox-bubblewrap = [] peripheral-rpi = ["dep:rppal"] diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 5b23d5e93..8e20776d8 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -163,12 +163,19 @@ cef = { version = "=146.4.1", default-features = false } openhuman_core = { path = "../..", package = "openhuman", default-features = false, features = [ "media", "tokenjuice-treesitter", + "inference", "voice", "web3", + "documents", "flows", "meet", "skills", "mcp", + "crash-reporting", + # The desktop shell reaches the in-process core only over + # http://127.0.0.1:/rpc, so it REQUIRES the HTTP + Socket.IO transport + # (#5048). Enforced by the HTTP_SERVER_COMPILED_IN compile assert in lib.rs. + "http-server", "desktop-automation", ] } tinyjuice = { version = "0.2.1", default-features = false } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index b59745832..7f4a0d3c5 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -17,6 +17,21 @@ const _: () = assert!( Add \"voice\" to the openhuman_core `features` list in app/src-tauri/Cargo.toml." ); +// The shell talks to the in-process core only over http://127.0.0.1:/rpc, +// so the core MUST embed the HTTP + Socket.IO transport (#5048). Same failure +// class as #4901: with `http-server` dropped the core never binds a listener +// and every RPC is unreachable — silent and runtime-only. The marker lives in +// the core's always-compiled facade (`core::http_server_status`) precisely so +// this assert can observe the core's feature state (a dependent's own +// `#[cfg(feature = ...)]` would test THIS crate's features, not the core's). +const _: () = assert!( + openhuman_core::core::http_server_status::HTTP_SERVER_COMPILED_IN, + "openhuman_core must be built with the `http-server` feature: the desktop app reaches \ + the core only over http://127.0.0.1:/rpc, and without it the core binds no \ + listener so every RPC is unreachable (#5048). \ + Add \"http-server\" to the openhuman_core `features` list in app/src-tauri/Cargo.toml." +); + mod app_update; // Artifact export commands (#2779, #3162) — both cross-platform // (macOS/Windows/Linux): native Save-As dialog (rfd) + Downloads copy. @@ -2387,6 +2402,7 @@ pub fn run() { let custom_runtime = tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_stack_size(openhuman_core::core::runtime::AGENT_WORKER_STACK_BYTES) + .max_blocking_threads(openhuman_core::core::runtime::MAX_BLOCKING_THREADS) .build() .expect("build custom tokio runtime for tauri async surface"); let handle = custom_runtime.handle().clone(); diff --git a/src/core/agent_cli.rs b/src/core/agent_cli.rs index 4a51e47c7..b35a4a3d2 100644 --- a/src/core/agent_cli.rs +++ b/src/core/agent_cli.rs @@ -123,6 +123,7 @@ fn run_dump_all(args: &[String]) -> Result<()> { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_stack_size(crate::core::runtime::AGENT_WORKER_STACK_BYTES) + .max_blocking_threads(crate::core::runtime::MAX_BLOCKING_THREADS) .build()?; log::debug!("[agent-cli] run_dump_all: calling dump_all_agent_prompts"); let dumps = rt.block_on(async { @@ -255,6 +256,7 @@ fn run_dump_prompt(args: &[String]) -> Result<()> { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_stack_size(crate::core::runtime::AGENT_WORKER_STACK_BYTES) + .max_blocking_threads(crate::core::runtime::MAX_BLOCKING_THREADS) .build()?; log::debug!("[agent-cli] run_dump_prompt: calling dump_agent_prompt"); let dumped = rt.block_on(async { dump_agent_prompt(options).await })?; diff --git a/src/core/all.rs b/src/core/all.rs index 9374b78bc..5881e8cab 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -355,7 +355,10 @@ fn build_registered_controllers() -> Vec { DomainGroup::Platform, crate::openhuman::heartbeat::all_heartbeat_registered_controllers(), ); - // Ad-hoc static directory HTTP hosting for local file sharing / previews + // Ad-hoc static directory HTTP hosting for local file sharing / previews. + // Gated with the `http-server` feature (#5048): the domain is an axum server, + // so a slim build has no `http_host.*` controllers to register. + #[cfg(feature = "http-server")] push( &mut controllers, DomainGroup::Platform, diff --git a/src/core/all_tests.rs b/src/core/all_tests.rs index cdad5265c..efecb1597 100644 --- a/src/core/all_tests.rs +++ b/src/core/all_tests.rs @@ -233,6 +233,38 @@ fn voice_and_audio_controllers_absent_when_feature_off() { ); } +/// With the `inference` feature on (the default), the in-process whisper STT +/// engine is compiled in — `INFERENCE_COMPILED_IN` reflects that, and +/// `whisper-rs` + `cpal` are linked (dependency shed proven separately by +/// `cargo tree -i whisper-rs` / `cargo tree -i cpal`). +#[test] +#[cfg(feature = "inference")] +fn inference_engine_compiled_in_when_feature_on() { + assert!(crate::openhuman::inference::INFERENCE_COMPILED_IN); +} + +/// With the `inference` feature off, the whisper engine is compiled out: the +/// marker flips and the always-compiled `whisper_engine` facade resolves to the +/// disabled stub — every transcription call returns the disabled error, while +/// the local-AI service still reaches Ollama / LM Studio over HTTP. This is the +/// compile-time stub-facade correctness gate (see +/// `inference::local::service::whisper_engine::stub`); `whisper-rs` + `cpal` +/// leave the dependency graph. +#[test] +#[cfg(not(feature = "inference"))] +fn inference_engine_compiled_out_when_feature_off() { + use crate::openhuman::inference::local::service::whisper_engine; + assert!(!crate::openhuman::inference::INFERENCE_COMPILED_IN); + let handle = whisper_engine::new_handle(); + assert!(!whisper_engine::is_loaded(&handle)); + let err = whisper_engine::transcribe_pcm_f32(&handle, &[0.0; 16], None, None) + .expect_err("in-process STT must be disabled when `inference` is off"); + assert!( + err.contains("inference"), + "disabled error should name the gate: {err}" + ); +} + /// With the `skills` feature on (the default), all three skill domains are /// compiled in and registered — the desktop build is byte-identical. #[test] @@ -1095,6 +1127,56 @@ fn meet_controllers_absent_when_feature_off() { } } +/// With the `http-server` feature on (the default), the HTTP + Socket.IO +/// transport is compiled in — `HTTP_SERVER_COMPILED_IN` reflects that, and +/// `socketioxide` is linked. `socketioxide` is the only dependency this gate +/// actually sheds (proven by `cargo tree -i socketioxide`); `axum` stays in the +/// graph either way because `tinychannels` pulls it transitively. +#[test] +#[cfg(feature = "http-server")] +fn http_server_compiled_in_when_feature_on() { + assert!(crate::core::http_server_status::HTTP_SERVER_COMPILED_IN); +} + +/// With the `http-server` feature off, the transport is compiled out: the +/// marker flips, `serve()` returns without binding a listener, and the +/// exclusive `socketioxide` dependency leaves the graph (`axum` remains, pulled +/// transitively by `tinychannels`). The desktop shell's compile-time assert on +/// this marker (`app/src-tauri/src/lib.rs`) turns a silent listener-less core +/// into a build failure (cf. voice #4901). +#[test] +#[cfg(not(feature = "http-server"))] +fn http_server_compiled_out_when_feature_off() { + assert!(!crate::core::http_server_status::HTTP_SERVER_COMPILED_IN); +} + +/// With `http-server` on, the `http_host` static-directory server registers its +/// controllers, so the `http_host.*` RPC surface is present in `/schema`. +#[test] +#[cfg(feature = "http-server")] +fn http_host_controllers_registered_when_http_server_on() { + let schemas = all_controller_schemas(); + assert!( + schemas.iter().any(|s| s.namespace == "http_host"), + "`http_host` controllers must be registered when the `http-server` feature is on" + ); +} + +/// With `http-server` off, the whole `http_host` axum domain is compiled out and +/// its controller-registration push in `core::all` is gated in lockstep, so the +/// `http_host` namespace never enters the registry (unknown-method over `/rpc`, +/// absent from `/schema`). This is the negative half that proves the gate +/// removes the surface. +#[test] +#[cfg(not(feature = "http-server"))] +fn http_host_controllers_absent_when_http_server_off() { + let schemas = all_controller_schemas(); + assert!( + !schemas.iter().any(|s| s.namespace == "http_host"), + "`http_host` controllers must be compiled out when the `http-server` feature is off" + ); +} + /// All three desktop-automation namespaces register under /// `DomainGroup::DesktopAutomation` when the `desktop-automation` feature is on /// (#5049). Paired with `desktop_automation_controllers_absent_when_feature_off` diff --git a/src/core/auth.rs b/src/core/auth.rs index fa03c688e..8bcce4905 100644 --- a/src/core/auth.rs +++ b/src/core/auth.rs @@ -62,14 +62,28 @@ use std::sync::OnceLock; #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt as _; +// The axum RPC-auth middleware + the `/v1` external-inference bearer helpers +// are exclusive to the `http-server` feature (#5048). The always-compiled token +// primitives (`verify_bearer_token`, `init_rpc_token`, `get_rpc_token`, +// `init_rpc_token_with_value`, `bearer_matches`) stay ungated — non-HTTP +// transports and `CoreBuilder` call them in every build. `Config`/`AuthService`/ +// the provider-id import are consumed only by the gated `/v1` helpers. +#[cfg(feature = "http-server")] use axum::http::{header, Method, StatusCode}; +#[cfg(feature = "http-server")] use axum::middleware::Next; +#[cfg(feature = "http-server")] use axum::response::{IntoResponse, Response}; +#[cfg(feature = "http-server")] use axum::Json; +#[cfg(feature = "http-server")] use serde_json::json; +#[cfg(feature = "http-server")] use crate::openhuman::config::Config; +#[cfg(feature = "http-server")] use crate::openhuman::credentials::AuthService; +#[cfg(feature = "http-server")] use crate::openhuman::inference::http::EXTERNAL_OPENAI_COMPAT_PROVIDER; static RPC_TOKEN: OnceLock = OnceLock::new(); @@ -85,6 +99,7 @@ static RPC_TOKEN: OnceLock = OnceLock::new(); /// is bearer-gated by this middleware via [`QUERY_TOKEN_PATHS`] (header or /// `?token=`) so an unauthenticated upgrade is rejected with 401 before the /// WebSocket handshake; the handler adds an origin check on top (finding C4). +#[cfg(feature = "http-server")] const PUBLIC_PATHS: &[&str] = &[ "/", "/health", @@ -106,6 +121,7 @@ const PUBLIC_PATHS: &[&str] = &[ /// /// Use this only when the suffix is dynamic (path params). For exact paths, /// add to [`PUBLIC_PATHS`] instead. +#[cfg(feature = "http-server")] const PUBLIC_PATH_PREFIXES: &[&str] = &[ // AgentBox `GET /jobs/{job_id}` — `{job_id}` is a UUID per submission. "/jobs/", @@ -115,6 +131,7 @@ const PUBLIC_PATH_PREFIXES: &[&str] = &[ /// /// A path is public when it appears in [`PUBLIC_PATHS`] (exact match) or /// begins with any entry in [`PUBLIC_PATH_PREFIXES`] (prefix match). +#[cfg(feature = "http-server")] fn is_public_path(path: &str) -> bool { PUBLIC_PATHS.contains(&path) || PUBLIC_PATH_PREFIXES @@ -135,6 +152,7 @@ fn is_public_path(path: &str) -> bool { /// Add new entries here only for SSE / WebSocket routes whose clients cannot /// send headers and that carry per-user data. The follow-up approvals stream /// (#1339) is the next planned addition. +#[cfg(feature = "http-server")] const QUERY_TOKEN_PATHS: &[&str] = &["/events/webhooks", "/ws/dictation"]; /// Operator-supplied environment variable that carries the RPC bearer in @@ -279,6 +297,7 @@ pub fn verify_bearer_token(supplied: &str) -> bool { /// bypass this check. `/rpc` requires the exact per-launch bearer token that /// was written to `core.token` at startup; `/v1/*` additionally accepts a /// stable user-managed external API key. +#[cfg(feature = "http-server")] pub async fn rpc_auth_middleware(req: axum::extract::Request, next: Next) -> Response { let path = req.uri().path().to_string(); @@ -369,10 +388,12 @@ fn constant_time_eq(a: &str, b: &str) -> bool { (len_diff == 0) & (byte_diff == 0) } +#[cfg(feature = "http-server")] fn is_external_inference_path(path: &str) -> bool { path == "/v1" || path.starts_with("/v1/") } +#[cfg(feature = "http-server")] fn verify_external_inference_bearer_for_config(config: &Config, supplied: &str) -> bool { if supplied.trim().is_empty() { return false; @@ -389,6 +410,7 @@ fn verify_external_inference_bearer_for_config(config: &Config, supplied: &str) } } +#[cfg(feature = "http-server")] async fn verify_external_inference_bearer(supplied: &str) -> bool { if supplied.trim().is_empty() { return false; @@ -411,6 +433,7 @@ async fn verify_external_inference_bearer(supplied: &str) -> bool { /// value is empty after trimming. URL decoding is delegated to /// [`url::form_urlencoded`] so percent-encoded tokens decode the same way /// they were encoded by the FE via `encodeURIComponent`. +#[cfg(feature = "http-server")] fn extract_query_token(query: Option<&str>) -> Option { let query = query?; for (key, value) in url::form_urlencoded::parse(query.as_bytes()) { @@ -558,22 +581,26 @@ mod tests { ); } + #[cfg(feature = "http-server")] #[test] fn extract_query_token_returns_none_on_missing_query() { assert_eq!(extract_query_token(None), None); } + #[cfg(feature = "http-server")] #[test] fn extract_query_token_returns_none_when_key_absent() { assert_eq!(extract_query_token(Some("other=1&foo=bar")), None); } + #[cfg(feature = "http-server")] #[test] fn extract_query_token_returns_none_on_empty_value() { assert_eq!(extract_query_token(Some("token=")), None); assert_eq!(extract_query_token(Some("token=%20%20")), None); } + #[cfg(feature = "http-server")] #[test] fn extract_query_token_returns_first_value_on_duplicate_keys() { // Last-wins vs first-wins is a question the FE never hits; pin @@ -584,6 +611,7 @@ mod tests { ); } + #[cfg(feature = "http-server")] #[test] fn extract_query_token_url_decodes_value() { // `encodeURIComponent` on the FE may percent-encode a hex token @@ -594,11 +622,13 @@ mod tests { ); } + #[cfg(feature = "http-server")] #[test] fn public_paths_include_desktop_auth_callback() { assert!(PUBLIC_PATHS.contains(&"/auth")); } + #[cfg(feature = "http-server")] #[test] fn agentbox_run_and_jobs_paths_are_public() { // AgentBox marketplace surface bypasses bearer auth (gated externally @@ -625,6 +655,7 @@ mod tests { std::fs::remove_dir_all(&tmp).ok(); } + #[cfg(feature = "http-server")] #[test] fn is_external_inference_path_matches_only_v1_routes() { assert!(is_external_inference_path("/v1")); @@ -634,6 +665,7 @@ mod tests { assert!(!is_external_inference_path("/v10/models")); } + #[cfg(feature = "http-server")] #[test] fn verify_external_inference_bearer_for_config_accepts_stored_key() { let tmp = tempfile::tempdir().unwrap(); diff --git a/src/core/cli.rs b/src/core/cli.rs index 87e67be5c..78fa79390 100644 --- a/src/core/cli.rs +++ b/src/core/cli.rs @@ -113,6 +113,12 @@ pub fn run_from_cli_args(args: &[String]) -> Result<()> { /// alias) or baked into the binary at build time via `option_env!`. Absent a /// DSN, the command exits non-zero with a diagnostic instead of silently /// producing no telemetry. +/// +/// Only compiled with the `crash-reporting` feature; the `#[cfg(not(...))]` +/// companion below returns a disabled-build error (mirrors the `mcp` CLI +/// precedent, where the subcommand arm + top-level help stay compiled and the +/// handler reports the build fact rather than a bogus "unknown command"). +#[cfg(feature = "crash-reporting")] fn run_sentry_test_command(args: &[String]) -> Result<()> { let mut message: Option = None; let mut do_panic = false; @@ -197,6 +203,17 @@ fn run_sentry_test_command(args: &[String]) -> Result<()> { Ok(()) } +/// Disabled-build stand-in for [`run_sentry_test_command`]. Same signature as +/// the `crash-reporting` version; reports that the probe is unavailable in a +/// build compiled without the feature rather than pretending to succeed. +#[cfg(not(feature = "crash-reporting"))] +fn run_sentry_test_command(_args: &[String]) -> Result<()> { + Err(anyhow::anyhow!( + "sentry-test unavailable: built without the crash-reporting feature — \ + rebuild with `--features crash-reporting`" + )) +} + /// Loads key/value pairs from a `.env` file into the process environment. /// /// This is used for all CLI entrypoints so direct namespace commands pick up @@ -311,6 +328,7 @@ fn run_server_command(args: &[String]) -> Result<()> { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_stack_size(crate::core::runtime::AGENT_WORKER_STACK_BYTES) + .max_blocking_threads(crate::core::runtime::MAX_BLOCKING_THREADS) .build()?; rt.block_on(async { if headless_api { @@ -368,6 +386,7 @@ fn run_call_command(args: &[String]) -> Result<()> { let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_stack_size(crate::core::runtime::AGENT_WORKER_STACK_BYTES) + .max_blocking_threads(crate::core::runtime::MAX_BLOCKING_THREADS) .build()?; let value = rt .block_on(async { invoke_method(default_state(), &method, params).await }) @@ -449,6 +468,7 @@ fn run_namespace_command( let rt = tokio::runtime::Builder::new_multi_thread() .enable_all() .thread_stack_size(crate::core::runtime::AGENT_WORKER_STACK_BYTES) + .max_blocking_threads(crate::core::runtime::MAX_BLOCKING_THREADS) .build()?; let value = rt .block_on(async { invoke_method(default_state(), &method, Value::Object(params)).await }) diff --git a/src/core/http_server_status.rs b/src/core/http_server_status.rs new file mode 100644 index 000000000..1243f7b8e --- /dev/null +++ b/src/core/http_server_status.rs @@ -0,0 +1,54 @@ +//! Compile-time visibility into the `http-server` gate. +//! +//! Deliberately **ungated**: unlike the rest of the transport surface, this +//! module is compiled in both feature states, because its whole purpose is to +//! report which state the binary ended up in. It mirrors +//! [`crate::openhuman::voice::VOICE_COMPILED_IN`] and +//! [`crate::openhuman::inference::INFERENCE_COMPILED_IN`]. + +/// Whether the real HTTP + Socket.IO server transport was compiled into this +/// binary. +/// +/// Cargo features are per-crate and invisible to dependents' `#[cfg]`, so a +/// consumer that *requires* the transport (the desktop shell, which reaches the +/// core only over `http://127.0.0.1:/rpc`) has no other way to detect +/// that it silently got a slim build with no listener — exactly the class of +/// silent drop that shipped `voice` broken from v0.58.19 (#4901). +/// +/// The shell asserts this at compile time (`const _: () = assert!(...)` in +/// `app/src-tauri/src/lib.rs`), turning that silent runtime failure (every RPC +/// unreachable — the frontend can't talk to a core that never bound a socket) +/// into a build failure. When `false`, the direct `socketioxide` dependency is +/// dropped from the graph (verify with `cargo tree -i socketioxide`); `axum` +/// stays linked transitively via `tinychannels`, so only the gated HTTP + +/// Socket.IO transport surface — not `axum` itself — leaves the slim build. +pub const HTTP_SERVER_COMPILED_IN: bool = cfg!(feature = "http-server"); + +#[cfg(test)] +mod tests { + use super::HTTP_SERVER_COMPILED_IN; + + /// Pins the constant to the gate rather than to a hardcoded value: the + /// assertion inverts with the feature, so it holds for both the default + /// build and the slim (`--no-default-features`) build. + #[test] + fn reports_the_compiled_gate_state() { + assert_eq!(HTTP_SERVER_COMPILED_IN, cfg!(feature = "http-server")); + } + + /// The default build ships the HTTP transport; this is the state the + /// desktop app requires. Skipped when the slim build is under test. + #[test] + #[cfg(feature = "http-server")] + fn is_true_when_the_http_server_feature_is_on() { + assert!(HTTP_SERVER_COMPILED_IN); + } + + /// The slim build must report honestly, otherwise the shell's const assert + /// would pass against a listener-less core. + #[test] + #[cfg(not(feature = "http-server"))] + fn is_false_when_the_http_server_feature_is_off() { + assert!(!HTTP_SERVER_COMPILED_IN); + } +} diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 4b4244eab..5eb751c23 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -6,22 +6,48 @@ //! - SSE (Server-Sent Events) for real-time event streaming. //! - Helper routes for health checks, schema discovery, and Telegram authentication. +// Only the gated router build (`build_core_http_router`) uses the bare `Arc`; +// the kept dispatch/bootstrap paths qualify it (`std::sync::Arc`) or import it +// locally, so this module-level import is `http-server`-only (#5048). +#[cfg(feature = "http-server")] use std::sync::Arc; +// The axum server surface — router, handlers, middleware, extractors, SSE — is +// exclusive to the `http-server` feature (#5048). Only the RPC-dispatch surface +// (`invoke_method`, `parse_json_params`, `default_state`, the `run_server*` +// CoreBuilder shims, `register_domain_subscribers`, `bootstrap_core_runtime`) +// stays compiled; a slim build drives it over the CLI / native dispatch path +// without binding a listener. `Map`/`Value` stay ungated (dispatch uses them). +#[cfg(feature = "http-server")] use axum::extract::{DefaultBodyLimit, Query, State, WebSocketUpgrade}; +#[cfg(feature = "http-server")] use axum::http::{header, HeaderValue, Method, StatusCode}; +#[cfg(feature = "http-server")] use axum::middleware::{self, Next}; +#[cfg(feature = "http-server")] use axum::response::sse::{Event, KeepAlive, Sse}; +#[cfg(feature = "http-server")] use axum::response::{IntoResponse, Response}; +#[cfg(feature = "http-server")] use axum::routing::{get, post}; +#[cfg(feature = "http-server")] use axum::{extract::Request, Json, Router}; +#[cfg(feature = "http-server")] use serde::Serialize; -use serde_json::{json, Map, Value}; +#[cfg(feature = "http-server")] +use serde_json::json; +use serde_json::{Map, Value}; +#[cfg(feature = "http-server")] use tokio_stream::StreamExt; use tokio_util::sync::CancellationToken; use crate::core::all; -use crate::core::types::{AppState, RpcError, RpcFailure, RpcRequest, RpcSuccess}; +use crate::core::types::AppState; +// The JSON-RPC envelope types are only shaped into HTTP responses by the gated +// `rpc_handler`; the kept dispatch surface returns `Result`. +#[cfg(feature = "http-server")] +use crate::core::types::{RpcError, RpcFailure, RpcRequest, RpcSuccess}; +#[cfg(feature = "http-server")] use crate::rpc::StructuredRpcError; /// Axum handler for JSON-RPC POST requests. @@ -36,6 +62,7 @@ use crate::rpc::StructuredRpcError; /// /// * `state` - The application state, injected by Axum. /// * `req` - The parsed [`RpcRequest`]. +#[cfg(feature = "http-server")] pub async fn rpc_handler(State(state): State, Json(req): Json) -> Response { let id = req.id.clone(); let method = req.method.clone(); @@ -374,6 +401,7 @@ fn is_unconfirmed_unauthorized_error(msg: &str) -> bool { /// session-expired predicate uses `.contains()` because session-expired markers /// can appear mid-message — flip these to match and the test /// `is_param_validation_error_does_not_match_unrelated_errors` will break. +#[cfg(feature = "http-server")] fn is_param_validation_error(msg: &str) -> bool { msg.starts_with("unknown param '") || msg.starts_with("missing required param '") @@ -397,6 +425,7 @@ fn is_param_validation_error(msg: &str) -> bool { /// Matched against the shared wallet constant (exact equality) so a wording /// change in the wallet layer fails the coupling test in `jsonrpc_tests.rs` /// rather than silently letting the noise back into Sentry. +#[cfg(feature = "http-server")] fn is_wallet_not_configured_error(msg: &str) -> bool { msg == crate::openhuman::wallet::WALLET_NOT_CONFIGURED_MESSAGE } @@ -472,6 +501,7 @@ pub fn default_state() -> AppState { // --- HTTP server (Axum) ---------------------------------------------------- /// Query parameters for the Telegram authentication callback. +#[cfg(feature = "http-server")] #[derive(Debug, serde::Deserialize)] struct TelegramAuthQuery { /// The one-time login token received from the Telegram bot. @@ -479,6 +509,7 @@ struct TelegramAuthQuery { } /// Query parameters for the generic desktop auth callback. +#[cfg(feature = "http-server")] #[derive(Debug, serde::Deserialize)] struct DesktopAuthQuery { /// One-time login token consumed through the backend. @@ -488,6 +519,7 @@ struct DesktopAuthQuery { } /// Returns the HTML for a successful connection page. +#[cfg(feature = "http-server")] fn success_html(message: &str) -> String { let escaped_message = escape_html(message); r#" @@ -517,6 +549,7 @@ fn success_html(message: &str) -> String { } /// Simple HTML escaping for error messages. +#[cfg(feature = "http-server")] fn escape_html(s: &str) -> String { s.replace('&', "&") .replace('<', "<") @@ -526,6 +559,7 @@ fn escape_html(s: &str) -> String { } /// Returns the HTML for an error page. +#[cfg(feature = "http-server")] fn error_html(message: &str) -> String { let escaped_message = escape_html(message); format!( @@ -556,6 +590,7 @@ fn error_html(message: &str) -> String { } /// Query params for the MCP browser-OAuth callback (`/oauth/mcp/callback`). +#[cfg(feature = "http-server")] #[derive(Debug, serde::Deserialize)] struct OAuthMcpCallbackQuery { code: Option, @@ -568,6 +603,7 @@ struct OAuthMcpCallbackQuery { /// server redirects the browser here with `?code=…&state=…`; we hand it to /// `mcp_registry::oauth::complete`, which exchanges the code for a token, stores /// it as the server's `Authorization` header, and reconnects. +#[cfg(feature = "http-server")] async fn oauth_mcp_callback_handler( Query(query): Query, ) -> impl IntoResponse { @@ -647,6 +683,7 @@ async fn oauth_mcp_callback_handler( /// The preferred Tauri loopback listener has a per-login state nonce. This /// legacy core fallback cannot rely on that state, so it must reject embedded /// resource loads (``, iframe, fetch, script) before token exchange. +#[cfg(feature = "http-server")] fn desktop_callback_navigation_ok(headers: &axum::http::HeaderMap) -> Result<(), &'static str> { let get_str = |name: &str| -> Option<&str> { headers @@ -696,6 +733,7 @@ fn desktop_callback_navigation_ok(headers: &axum::http::HeaderMap) -> Result<(), /// iframe embeds from malicious pages). /// - `Sec-Fetch-Site: cross-site` with a `Referer`/`Origin` that is not /// `https://t.me/...` (CSRF redirect from a third-party site). +#[cfg(feature = "http-server")] fn telegram_callback_origin_ok(headers: &axum::http::HeaderMap) -> Result<(), &'static str> { let get_str = |name: &str| -> Option<&str> { headers @@ -756,6 +794,7 @@ fn telegram_callback_origin_ok(headers: &axum::http::HeaderMap) -> Result<(), &' /// /// It consumes a one-time token, exchanges it for a JWT from the backend, /// and stores the session locally. +#[cfg(feature = "http-server")] async fn telegram_auth_handler( headers: axum::http::HeaderMap, Query(query): Query, @@ -890,6 +929,7 @@ async fn telegram_auth_handler( /// flows can fall back to the local core callback (`/auth`). This route is /// public because the callback carries its own one-time login token; raw /// session JWT callbacks are intentionally rejected on this public surface. +#[cfg(feature = "http-server")] async fn desktop_auth_handler( headers: axum::http::HeaderMap, Query(query): Query, @@ -1010,6 +1050,7 @@ async fn desktop_auth_handler( /// the FE forwards the per-process core bearer as a `?token=…` query param — /// validated against the same in-process RPC token via [`verify_bearer_token`] /// (single source of truth, no separate credential). +#[cfg(feature = "http-server")] #[derive(Debug, serde::Deserialize)] struct DictationQuery { #[serde(default)] @@ -1024,6 +1065,7 @@ struct DictationQuery { /// set headers), and — when an `Origin` header is present — that origin must be /// on the local-app allowlist, mirroring the Socket.IO handshake check. Missing /// or wrong credentials are rejected with 401 and the socket is never upgraded. +#[cfg(feature = "http-server")] async fn dictation_ws_handler( headers: axum::http::HeaderMap, Query(query): Query, @@ -1100,6 +1142,7 @@ async fn dictation_ws_handler( /// maximum image payload — 4 × 8 MiB raw ≈ 43 MiB once base64-encoded into /// `[IMAGE:data:…]` markers — plus message text and JSON-RPC envelope overhead. /// Axum's 2 MiB default would otherwise reject any image attachment (#3205). +#[cfg(feature = "http-server")] const MAX_RPC_BODY_BYTES: usize = 64 * 1024 * 1024; /// Builds the main Axum router for the core HTTP server. @@ -1111,6 +1154,7 @@ const MAX_RPC_BODY_BYTES: usize = 64 * 1024 * 1024; /// 1. `cors_middleware` — handles `OPTIONS` preflight and adds CORS headers /// 2. `rpc_auth_middleware` — validates `Authorization: Bearer ` on protected paths /// 3. `http_request_log_middleware` — logs non-RPC HTTP requests with timing +#[cfg(feature = "http-server")] pub fn build_core_http_router(socketio_enabled: bool) -> Router { let mut router = Router::new() .route("/", get(root_handler)) @@ -1202,6 +1246,7 @@ pub fn build_core_http_router(socketio_enabled: bool) -> Router { /// /// The `/rpc` path is logged inside [`rpc_handler`] instead (with the /// JSON-RPC method name), so we skip it here to avoid a redundant line. +#[cfg(feature = "http-server")] async fn http_request_log_middleware(req: Request, next: Next) -> Response { let method = req.method().clone(); let path = req.uri().path().to_string(); @@ -1229,6 +1274,7 @@ async fn http_request_log_middleware(req: Request, next: Next) -> Response { /// Environment variable for additional comma-separated origins to allow. /// Intended for debug harnesses and E2E setups that don't run on loopback — /// e.g. `OPENHUMAN_CORE_ALLOWED_ORIGINS=https://e2e.internal,http://my-debugger:8080`. +#[cfg(feature = "http-server")] const ALLOWED_ORIGINS_ENV: &str = "OPENHUMAN_CORE_ALLOWED_ORIGINS"; /// Decides whether a browser `Origin` header value is allowed to make @@ -1245,11 +1291,13 @@ const ALLOWED_ORIGINS_ENV: &str = "OPENHUMAN_CORE_ALLOWED_ORIGINS"; /// token via leaked logs / screenshots / a compromised third-party origin /// loaded in a CEF child webview) must be refused — the bearer token alone /// is not enough authorization without an origin binding. +#[cfg(feature = "http-server")] pub(super) fn is_origin_allowed(origin: &str) -> bool { let extra_origins = std::env::var(ALLOWED_ORIGINS_ENV).ok(); is_origin_allowed_with_extra(origin, extra_origins.as_deref()) } +#[cfg(feature = "http-server")] pub(super) fn is_origin_allowed_with_extra(origin: &str, extra_origins: Option<&str>) -> bool { // Tauri v2 webview origins. Windows uses an HTTP(S) custom host; macOS // and Linux use the `tauri://` scheme. We accept both for portability. @@ -1290,6 +1338,7 @@ pub(super) fn is_origin_allowed_with_extra(origin: &str, extra_origins: Option<& /// /// Reads the request's `Origin` header before invoking the inner handler so /// the same value can be echoed back (when allowed) on the response. +#[cfg(feature = "http-server")] async fn cors_middleware(req: Request, next: Next) -> Response { let origin = req .headers() @@ -1317,6 +1366,7 @@ async fn cors_middleware(req: Request, next: Next) -> Response { /// For Docker / cloud deployments where the server binds to `0.0.0.0`, /// extend the allowlist via the `OPENHUMAN_CORE_ALLOWED_ORIGINS` env var /// (comma-separated) rather than wildcarding `Access-Control-Allow-Origin`. +#[cfg(feature = "http-server")] pub(super) fn with_cors_headers(mut response: Response, origin: Option<&str>) -> Response { let headers = response.headers_mut(); headers.append(header::VARY, HeaderValue::from_static("Origin")); @@ -1354,6 +1404,7 @@ pub(super) fn with_cors_headers(mut response: Response, origin: Option<&str>) -> /// `health::CRITICAL_COMPONENTS`); otherwise it returns 200 — with a `degraded` /// flag and per-component buckets in the body so readiness probes and operators /// can still see partial failures. +#[cfg(feature = "http-server")] async fn health_handler() -> impl IntoResponse { let snapshot = crate::openhuman::health::snapshot(); let verdict = crate::openhuman::health::verdict(&snapshot); @@ -1394,6 +1445,7 @@ async fn health_handler() -> impl IntoResponse { } /// Handler for the schema discovery endpoint. +#[cfg(feature = "http-server")] async fn schema_handler(State(_state): State) -> impl IntoResponse { (StatusCode::OK, Json(build_http_schema_dump())).into_response() } @@ -1405,6 +1457,7 @@ async fn schema_handler(State(_state): State) -> impl IntoResponse { /// Both are required — browser `EventSource` cannot attach an /// `Authorization` header, so the bind token is the only credential the /// endpoint accepts. +#[cfg(feature = "http-server")] #[derive(Debug, serde::Deserialize)] struct EventsQuery { client_id: String, @@ -1426,6 +1479,7 @@ struct EventsQuery { /// /// Both paths converge on the same broadcast stream filtered by /// `client_id`. +#[cfg(feature = "http-server")] async fn events_handler( headers: axum::http::HeaderMap, Query(query): Query, @@ -1503,6 +1557,7 @@ async fn events_handler( } /// Handler for the webhook debug events SSE endpoint. +#[cfg(feature = "http-server")] async fn webhook_events_handler() -> Response { let stream = tokio_stream::once(Ok::( Event::default() @@ -1518,6 +1573,7 @@ async fn webhook_events_handler() -> Response { /// /// Requires bearer auth. Streams all domain events as JSON with event type /// set to the domain name (agent, tool, memory, etc.). +#[cfg(feature = "http-server")] async fn domain_events_handler(headers: axum::http::HeaderMap) -> Response { let bearer = headers .get(header::AUTHORIZATION) @@ -1610,6 +1666,7 @@ async fn domain_events_handler(headers: axum::http::HeaderMap) -> Response { } /// Handler for the root endpoint, returning server information and available endpoints. +#[cfg(feature = "http-server")] async fn root_handler() -> impl IntoResponse { let api_server = match crate::openhuman::config::Config::load_or_init().await { Ok(cfg) => crate::api::config::effective_backend_api_url(&cfg.api_url), @@ -1640,6 +1697,7 @@ async fn root_handler() -> impl IntoResponse { } /// Fallback handler for unknown routes. +#[cfg(feature = "http-server")] async fn not_found_handler() -> impl IntoResponse { ( StatusCode::NOT_FOUND, @@ -1652,6 +1710,7 @@ async fn not_found_handler() -> impl IntoResponse { } /// Resolves the port for the core server from environment variables or defaults. +#[cfg(feature = "http-server")] pub(crate) fn core_port() -> u16 { std::env::var("OPENHUMAN_CORE_PORT") .ok() @@ -1660,6 +1719,7 @@ pub(crate) fn core_port() -> u16 { } /// Resolves the bind address host for the core server from environment variables or defaults. +#[cfg(feature = "http-server")] pub(crate) fn core_host() -> String { std::env::var("OPENHUMAN_CORE_HOST") .ok() @@ -2481,6 +2541,7 @@ pub fn start_core_runtime_services( } /// JSON-serializable wrapper for the entire RPC schema dump. +#[cfg(feature = "http-server")] #[derive(Serialize)] struct HttpSchemaDump { /// List of all available RPC methods and their schemas. @@ -2488,6 +2549,7 @@ struct HttpSchemaDump { } /// JSON-serializable schema for a single RPC method. +#[cfg(feature = "http-server")] #[derive(Serialize)] struct HttpMethodSchema { /// Fully qualified JSON-RPC method name. @@ -2507,6 +2569,7 @@ struct HttpMethodSchema { /// Aggregates schemas from all registered controllers into a single dump. /// /// Also includes built-in core methods like `core.ping` and `core.version`. +#[cfg(feature = "http-server")] fn build_http_schema_dump() -> HttpSchemaDump { let mut methods: Vec = all::all_http_method_schemas() .into_iter() @@ -2530,6 +2593,8 @@ fn build_http_schema_dump() -> HttpSchemaDump { #[path = "jsonrpc_tests.rs"] mod tests; -#[cfg(test)] +// Every cors test names a gated CORS symbol (`is_origin_allowed`, +// `with_cors_headers`), so the whole module gates in lockstep (#5048). +#[cfg(all(test, feature = "http-server"))] #[path = "jsonrpc_cors_tests.rs"] mod cors_tests; diff --git a/src/core/jsonrpc_tests.rs b/src/core/jsonrpc_tests.rs index 50198a032..0de528d68 100644 --- a/src/core/jsonrpc_tests.rs +++ b/src/core/jsonrpc_tests.rs @@ -6,9 +6,16 @@ use std::time::Duration; use tokio_util::sync::CancellationToken; use super::{ - build_http_schema_dump, default_state, escape_html, invoke_method, is_param_validation_error, - is_session_expired_error, is_unconfirmed_unauthorized_error, is_wallet_not_configured_error, - params_to_object, parse_json_params, rpc_handler, type_name, DomainSubscriberPlan, + default_state, invoke_method, is_session_expired_error, is_unconfirmed_unauthorized_error, + params_to_object, parse_json_params, type_name, DomainSubscriberPlan, +}; +// These are the `http-server`-gated RPC-surface symbols (#5048); the tests that +// name them below carry the same `#[cfg]` so the disabled-build test compile +// (`cargo test --no-default-features`) stays green. +#[cfg(feature = "http-server")] +use super::{ + build_http_schema_dump, escape_html, is_param_validation_error, is_wallet_not_configured_error, + rpc_handler, }; // ---- domain-subscriber gating (#4796 DoD item 3) ---------------------------- @@ -476,6 +483,7 @@ async fn invoke_migrate_hermes_rejects_unknown_param() { } #[test] +#[cfg(feature = "http-server")] fn http_schema_dump_includes_openhuman_and_core_methods() { let dump = build_http_schema_dump(); let methods = dump.methods; @@ -681,6 +689,7 @@ async fn team_revoke_invite_missing_invite_id_fails_validation() { } #[tokio::test] +#[cfg(feature = "http-server")] async fn schema_dump_includes_new_billing_and_team_methods() { let dump = build_http_schema_dump(); let methods: Vec<&str> = dump.methods.iter().map(|m| m.method.as_str()).collect(); @@ -954,6 +963,7 @@ fn is_session_expired_error_skips_discord_rewrap_for_2285() { } #[test] +#[cfg(feature = "http-server")] fn is_param_validation_error_matches_the_three_validator_shapes() { // Regression guard for OPENHUMAN-TAURI-20: pre-#1467 cores rejected // `api_key` because it wasn't in the schema yet. The error string @@ -973,6 +983,7 @@ fn is_param_validation_error_matches_the_three_validator_shapes() { } #[test] +#[cfg(feature = "http-server")] fn is_param_validation_error_does_not_match_unrelated_errors() { // Handler-side / network / auth failures must still be reported. assert!(!is_param_validation_error( @@ -1009,6 +1020,7 @@ fn is_session_expired_error_matches_missing_backend_session_token() { } #[tokio::test(flavor = "current_thread")] +#[cfg(feature = "http-server")] async fn structured_rpc_error_envelope_passes_through_generic_dispatch() { // The transport layer must surface any controller-emitted // `StructuredRpcError` payload without inspecting the method name — @@ -1048,7 +1060,9 @@ async fn structured_rpc_error_envelope_passes_through_generic_dispatch() { assert!(message.contains("thread-ghost")); } +#[cfg(feature = "crash-reporting")] #[tokio::test(flavor = "current_thread")] +#[cfg(feature = "http-server")] async fn thread_not_found_rpc_error_does_not_report_to_sentry() { use axum::body::to_bytes; use axum::extract::State; @@ -1161,7 +1175,9 @@ async fn thread_not_found_rpc_error_does_not_report_to_sentry() { ); } +#[cfg(feature = "crash-reporting")] #[tokio::test(flavor = "current_thread")] +#[cfg(feature = "http-server")] async fn unknown_method_severity_split_by_probe_allow_list() { // #3567: prove the full severity split at the transport boundary — // (1) an allow-listed probe name is NOT captured to Sentry (debug-only), @@ -1289,6 +1305,7 @@ fn is_session_expired_error_matches_session_jwt_required() { } #[test] +#[cfg(feature = "http-server")] fn escape_html_escapes_all_special_chars() { let raw = r#""#; let escaped = escape_html(raw); @@ -1305,6 +1322,7 @@ fn escape_html_escapes_all_special_chars() { } #[test] +#[cfg(feature = "http-server")] fn escape_html_is_noop_for_safe_text() { assert_eq!(escape_html("safe text 123"), "safe text 123"); assert_eq!(escape_html(""), ""); @@ -1312,6 +1330,7 @@ fn escape_html_is_noop_for_safe_text() { // --- telegram callback fetch-metadata gate -------------------------------- +#[cfg(feature = "http-server")] fn hdr_map(pairs: &[(&str, &str)]) -> axum::http::HeaderMap { let mut m = axum::http::HeaderMap::new(); for (k, v) in pairs { @@ -1324,6 +1343,7 @@ fn hdr_map(pairs: &[(&str, &str)]) -> axum::http::HeaderMap { } #[test] +#[cfg(feature = "http-server")] fn telegram_callback_origin_ok_accepts_no_metadata_headers() { // Older browsers and CLI clients (curl) send neither Sec-Fetch-* nor // Origin/Referer. The legacy flow has to keep working — reject only @@ -1333,6 +1353,7 @@ fn telegram_callback_origin_ok_accepts_no_metadata_headers() { } #[test] +#[cfg(feature = "http-server")] fn telegram_callback_origin_ok_accepts_legit_top_nav_from_telegram() { let headers = hdr_map(&[ ("sec-fetch-mode", "navigate"), @@ -1344,6 +1365,7 @@ fn telegram_callback_origin_ok_accepts_legit_top_nav_from_telegram() { } #[test] +#[cfg(feature = "http-server")] fn telegram_callback_origin_ok_accepts_same_origin_local_nav() { let headers = hdr_map(&[ ("sec-fetch-mode", "navigate"), @@ -1354,6 +1376,7 @@ fn telegram_callback_origin_ok_accepts_same_origin_local_nav() { } #[test] +#[cfg(feature = "http-server")] fn telegram_callback_origin_ok_rejects_image_embed() { let headers = hdr_map(&[ ("sec-fetch-mode", "no-cors"), @@ -1364,6 +1387,7 @@ fn telegram_callback_origin_ok_rejects_image_embed() { } #[test] +#[cfg(feature = "http-server")] fn telegram_callback_origin_ok_rejects_iframe_embed() { let headers = hdr_map(&[ ("sec-fetch-mode", "navigate"), @@ -1374,6 +1398,7 @@ fn telegram_callback_origin_ok_rejects_iframe_embed() { } #[test] +#[cfg(feature = "http-server")] fn telegram_callback_origin_ok_rejects_cross_site_from_non_telegram() { let headers = hdr_map(&[ ("sec-fetch-mode", "navigate"), @@ -1385,12 +1410,14 @@ fn telegram_callback_origin_ok_rejects_cross_site_from_non_telegram() { } #[test] +#[cfg(feature = "http-server")] fn telegram_callback_origin_ok_rejects_non_telegram_referer_without_fetch_metadata() { let headers = hdr_map(&[("referer", "https://attacker.example/post")]); assert!(super::telegram_callback_origin_ok(&headers).is_err()); } #[test] +#[cfg(feature = "http-server")] fn telegram_callback_origin_ok_rejects_localhost_host_prefix_decoy() { // Regression: prefix-matching the referer accepted hostnames like // `http://localhost.attacker.example/...`. With exact-host parsing @@ -1472,6 +1499,7 @@ async fn invoke_method_core_version_via_tier1_reflects_state() { } #[tokio::test] +#[cfg(feature = "http-server")] async fn test_http_health_handler_returns_correct_status() { use axum::body::to_bytes; use axum::http::StatusCode; @@ -1533,6 +1561,7 @@ async fn test_http_health_handler_returns_correct_status() { } #[tokio::test] +#[cfg(feature = "http-server")] async fn desktop_auth_rejects_deprecated_direct_session_token_marker() { use axum::body::to_bytes; use axum::extract::Query; @@ -1560,6 +1589,7 @@ async fn desktop_auth_rejects_deprecated_direct_session_token_marker() { } #[tokio::test] +#[cfg(feature = "http-server")] async fn desktop_auth_rejects_embedded_fetch_metadata() { use axum::body::to_bytes; use axum::extract::Query; @@ -1590,6 +1620,7 @@ async fn desktop_auth_rejects_embedded_fetch_metadata() { } #[test] +#[cfg(feature = "http-server")] fn is_wallet_not_configured_error_matches_wallet_constant() { // The classifier keys off the wallet layer's exact "not configured" // message so a wallet-less user's tinyplace RPC stays out of Sentry. @@ -1599,6 +1630,7 @@ fn is_wallet_not_configured_error_matches_wallet_constant() { } #[test] +#[cfg(feature = "http-server")] fn is_wallet_not_configured_error_is_coupled_to_the_wallet_constant() { // Drift guard: if the wallet wording changes without updating the shared // constant the classifier matches, this fails — preventing the noise from @@ -1610,6 +1642,7 @@ fn is_wallet_not_configured_error_is_coupled_to_the_wallet_constant() { } #[test] +#[cfg(feature = "http-server")] fn is_wallet_not_configured_error_does_not_match_other_errors() { // Other wallet/seed-derivation failures (decrypt, key derivation, locked // keychain) are real defects and must keep reaching Sentry. diff --git a/src/core/log_redaction.rs b/src/core/log_redaction.rs new file mode 100644 index 000000000..48522a6b4 --- /dev/null +++ b/src/core/log_redaction.rs @@ -0,0 +1,107 @@ +//! Shared secret-scrubbing for anything written to stderr / file logs. +//! +//! Diagnostic log lines (e.g. `core::observability::report_error_message`) can +//! carry error strings that embed bearer tokens, API keys, or other secrets. In +//! slim builds compiled without `crash-reporting` there is no Sentry +//! `before_send` hook to sanitise them, and even in full builds the +//! `before_send` hook only scrubs the *Sentry event* — not the parallel +//! `tracing` log line. This module owns the one redaction pass used by both the +//! Sentry path (`src/main.rs`) and the always-on log path, so the patterns +//! cannot drift between them. Always compiled (no feature gate). + +use once_cell::sync::Lazy; +use regex::Regex; + +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. Includes `-` + // and `_` in the suffix so a separator mid-token can't leave a trailing + // fragment unredacted (e.g. `sk-…_uv` → `[REDACTED]_uv`). + (Regex::new(r"sk-[A-Za-z0-9_-]{20,}").unwrap(), "[REDACTED]"), + ] +}); + +/// Replace substrings that look like secrets with `[REDACTED]`. +/// +/// Intended for anything about to be written to a log sink or an error report; +/// it redacts the secret-looking span in place and leaves the rest of the +/// diagnostic message intact (unlike a whole-value prefix redaction). +pub 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::scrub_secrets; + + #[test] + fn scrubs_bearer_token() { + assert_eq!( + scrub_secrets("Authorization: Bearer abc123xyz"), + "Authorization: Bearer [REDACTED]" + ); + } + + #[test] + fn scrubs_api_key_assignment() { + 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_bare_generic_sk_key() { + assert_eq!(scrub_secrets("sk-abcdefghijklmnopqrstuvwx"), "[REDACTED]"); + } + + #[test] + fn scrubs_generic_sk_key_with_separators() { + // A `_` or `-` mid-suffix must not leave a trailing fragment unredacted. + assert_eq!(scrub_secrets("sk-abcdefghijklmnopqrst_uv"), "[REDACTED]"); + assert_eq!(scrub_secrets("sk-abcdefghij-klmnopqrst_uv"), "[REDACTED]"); + } + + #[test] + fn leaves_plain_diagnostics_intact() { + let msg = "profile 42: derived rate clamp exceeded (max_iterations=8)"; + assert_eq!(scrub_secrets(msg), msg); + } +} diff --git a/src/core/logging.rs b/src/core/logging.rs index 639d247aa..fe26d0ccb 100644 --- a/src/core/logging.rs +++ b/src/core/logging.rs @@ -472,6 +472,7 @@ fn build_env_filter(verbose: bool, default_scope: CliLogDefault) -> tracing_subs }) } +#[cfg(feature = "crash-reporting")] fn sentry_tracing_layer() -> impl Layer where S: tracing::Subscriber + for<'a> LookupSpan<'a>, @@ -492,6 +493,18 @@ where }) } +/// Sentry-free build: the Sentry breadcrumb/event bridge collapses to a no-op +/// `Identity` layer so the two `.with(sentry_tracing_layer())` call sites keep +/// compiling unchanged (they add a layer that does nothing). Same signature as +/// the `crash-reporting` version above. +#[cfg(not(feature = "crash-reporting"))] +fn sentry_tracing_layer() -> impl Layer +where + S: tracing::Subscriber + for<'a> LookupSpan<'a>, +{ + tracing_subscriber::layer::Identity::new() +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/core/mod.rs b/src/core/mod.rs index 52c448e4a..3b7b03053 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -14,8 +14,13 @@ pub mod cli; pub mod dispatch; pub mod event_bind_tokens; pub mod event_bus; +// Ungated compile-time marker for the `http-server` gate (#5048) — the desktop +// shell asserts `HTTP_SERVER_COMPILED_IN` so a listener-less core fails the +// build instead of shipping silently (cf. voice #4901). +pub mod http_server_status; pub mod jsonrpc; pub mod legacy_aliases; +pub mod log_redaction; pub mod logging; pub mod memory_cli; pub mod observability; diff --git a/src/core/observability.rs b/src/core/observability.rs index 1ae514c17..1f3ff24d3 100644 --- a/src/core/observability.rs +++ b/src/core/observability.rs @@ -2394,6 +2394,17 @@ pub(crate) fn report_error_message( operation: &str, extra: &[Tag<'_>], ) { + // Redact secret-looking spans (bearer tokens, API keys, `sk-` keys) before + // `message` reaches any log sink or Sentry event. The parallel `tracing` + // log line below is emitted in every build — including slim builds with no + // `crash-reporting` `before_send` hook — so scrub once, up front. + let scrubbed = crate::core::log_redaction::scrub_secrets(message); + let message = scrubbed.as_str(); + // Sentry-touching behaviour is gated behind `crash-reporting`. The + // diagnostic `tracing::error!` stays compiled in both builds (see the + // `#[cfg(not(...))]` companion below) so stderr / file appenders keep the + // record even in a sentry-free build. + #[cfg(feature = "crash-reporting")] sentry::with_scope( |scope| { scope.set_tag("domain", domain); @@ -2419,6 +2430,20 @@ pub(crate) fn report_error_message( ); }, ); + #[cfg(not(feature = "crash-reporting"))] + { + // Sentry compiled out: `extra` tags have no scope to attach to, so + // discard them explicitly to avoid an unused-variable warning while + // still emitting the diagnostic log line. + let _ = extra; + tracing::error!( + target: REPORT_ERROR_TRACING_TARGET, + domain = domain, + operation = operation, + error = %message, + "[observability] {domain}.{operation} failed: {message}" + ); + } } /// Capture a message to Sentry at **warning** severity with structured tags. @@ -2436,12 +2461,24 @@ pub(crate) fn report_error_message( /// `sentry::capture_message` rather than the `sentry-tracing` bridge; the /// accompanying diagnostic line is tagged with [`REPORT_ERROR_TRACING_TARGET`] /// so the production layer ignores it and we never double-report. +// Its sole caller is the `http-server`-gated RPC handler (unrecognised-method +// reporting, #3567), so it has no caller in a slim build (#5048). Kept compiled +// for the crash-reporting carve-out; the allow keeps the disabled build quiet. +#[cfg_attr(not(feature = "http-server"), allow(dead_code))] pub(crate) fn report_warning_message( message: &str, domain: &str, operation: &str, extra: &[Tag<'_>], ) { + // Redact secret-looking spans before `message` reaches any log sink or + // Sentry event — see the note in `report_error_message`. + let scrubbed = crate::core::log_redaction::scrub_secrets(message); + let message = scrubbed.as_str(); + // Sentry-touching behaviour is gated behind `crash-reporting`; the + // diagnostic `tracing::warn!` stays compiled in both builds (see the + // `#[cfg(not(...))]` companion below). + #[cfg(feature = "crash-reporting")] sentry::with_scope( |scope| { scope.set_tag("domain", domain); @@ -2461,6 +2498,17 @@ pub(crate) fn report_warning_message( ); }, ); + #[cfg(not(feature = "crash-reporting"))] + { + let _ = extra; + tracing::warn!( + target: REPORT_ERROR_TRACING_TARGET, + domain = domain, + operation = operation, + message = %message, + "[observability] {domain}.{operation} warning: {message}" + ); + } } /// Returns true when a Sentry event is a per-attempt provider HTTP failure @@ -2480,6 +2528,7 @@ pub(crate) fn report_warning_message( /// for its own reasons doesn't get silently dropped /// - tag `failure == "non_2xx"` (the marker set by `ops::api_error`) /// - tag `status` parses to one of [`TRANSIENT_PROVIDER_HTTP_STATUSES`] +#[cfg(feature = "crash-reporting")] pub fn is_transient_provider_http_failure(event: &sentry::protocol::Event<'_>) -> bool { let tags = &event.tags; if tags.get("domain").map(String::as_str) != Some("llm_provider") { @@ -2507,6 +2556,7 @@ pub fn is_transient_provider_http_failure(event: &sentry::protocol::Event<'_>) - /// single-source [`crate::openhuman::inference::provider::managed_error_skips_sentry`] /// (managed-envelope gated, so a BYO payload carrying an `errorCode`-shaped /// field is not wrongly dropped) so the layers can't drift. +#[cfg(feature = "crash-reporting")] pub fn is_backend_error_code_event(event: &sentry::protocol::Event<'_>) -> bool { let direct = event.message.as_deref(); let from_logentry = event.logentry.as_ref().map(|log| log.message.as_str()); @@ -2534,6 +2584,7 @@ pub fn is_backend_error_code_event(event: &sentry::protocol::Event<'_>) -> bool /// suppressed. A non-streaming `domain=llm_provider, failure=transport` event /// carries a different `operation` tag and must keep paging, so the /// observability blind spot stays as narrow as F7 intends. +#[cfg(feature = "crash-reporting")] pub fn is_transient_provider_transport_failure(event: &sentry::protocol::Event<'_>) -> bool { let tags = &event.tags; if tags.get("domain").map(String::as_str) != Some("llm_provider") { @@ -2559,6 +2610,7 @@ pub fn is_transient_provider_transport_failure(event: &sentry::protocol::Event<' /// where the aggregate body starts with the reliable-provider exhaustion /// prefix and contains transient HTTP/transport wording already classified by /// [`is_transient_message_failure`]. +#[cfg(feature = "crash-reporting")] pub fn is_all_transient_provider_exhaustion_event(event: &sentry::protocol::Event<'_>) -> bool { let tags = &event.tags; if tags.get("domain").map(String::as_str) != Some("llm_provider") { @@ -2577,6 +2629,7 @@ pub fn is_all_transient_provider_exhaustion_event(event: &sentry::protocol::Even .any(all_provider_attempts_are_transient) } +#[cfg(feature = "crash-reporting")] fn all_provider_attempts_are_transient(message: &str) -> bool { let Some(attempts) = message.strip_prefix("All providers/models failed. Attempts:") else { return false; @@ -2610,6 +2663,7 @@ fn all_provider_attempts_are_transient(message: &str) -> bool { /// the last exception's `value` (the shape `sentry-tracing` produces when /// stacktraces are attached). Both fields are checked for the canonical /// prefix so the filter stays robust to future Sentry plumbing changes. +#[cfg(feature = "crash-reporting")] pub fn is_max_iterations_event(event: &sentry::protocol::Event<'_>) -> bool { let direct = event.message.as_deref(); let from_exception = event.exception.last().and_then(|e| e.value.as_deref()); @@ -2633,6 +2687,7 @@ pub fn is_max_iterations_event(event: &sentry::protocol::Event<'_>) -> bool { /// Scope: only the three domains that surface session-expired today /// (`llm_provider`, `backend_api`, `rpc`). Composio's OAuth-state 401 /// is excluded — that's actionable and must reach Sentry. +#[cfg(feature = "crash-reporting")] pub fn is_session_expired_event(event: &sentry::protocol::Event<'_>) -> bool { let tags = &event.tags; let Some(domain) = tags.get("domain").map(String::as_str) else { @@ -2695,6 +2750,7 @@ pub fn is_session_expired_event(event: &sentry::protocol::Event<'_>) -> bool { /// - `event.message` (or last exception `value`) trims to **exactly** /// `"GET /auth/me"` — strict equality, not `contains`, so a body with /// the chain appended still surfaces. +#[cfg(feature = "crash-reporting")] pub fn is_auth_get_me_opaque_transport_event(event: &sentry::protocol::Event<'_>) -> bool { let tags = &event.tags; if tags.get("domain").map(String::as_str) != Some("rpc") { @@ -2743,6 +2799,7 @@ pub fn is_updater_transient_message(message: &str) -> bool { .any(|phrase| lower.contains(phrase)) } +#[cfg(feature = "crash-reporting")] fn event_has_transient_transport_phrase(event: &sentry::protocol::Event<'_>) -> bool { event .message @@ -2760,6 +2817,7 @@ fn event_has_transient_transport_phrase(event: &sentry::protocol::Event<'_>) -> }) } +#[cfg(feature = "crash-reporting")] fn event_has_updater_transient_message(event: &sentry::protocol::Event<'_>) -> bool { event .message @@ -2777,6 +2835,7 @@ fn event_has_updater_transient_message(event: &sentry::protocol::Event<'_>) -> b }) } +#[cfg(feature = "crash-reporting")] fn event_has_updater_domain(event: &sentry::protocol::Event<'_>) -> bool { matches!( event.tags.get("domain").map(String::as_str), @@ -2784,6 +2843,7 @@ fn event_has_updater_domain(event: &sentry::protocol::Event<'_>) -> bool { ) } +#[cfg(feature = "crash-reporting")] fn is_transient_domain_failure(event: &sentry::protocol::Event<'_>, domain: &str) -> bool { let tags = &event.tags; if tags.get("domain").map(String::as_str) != Some(domain) { @@ -2801,6 +2861,7 @@ fn is_transient_domain_failure(event: &sentry::protocol::Event<'_>, domain: &str /// Transient backend API failures (gateway hiccups, scheduled downtime). /// Match by event tags written by report_error at the authed_json call site. +#[cfg(feature = "crash-reporting")] pub fn is_transient_backend_api_failure(event: &sentry::protocol::Event<'_>) -> bool { is_transient_domain_failure(event, "backend_api") } @@ -2811,6 +2872,7 @@ pub fn is_transient_backend_api_failure(event: &sentry::protocol::Event<'_>) -> /// path is missing, private, or otherwise unavailable to that user. The install /// RPC still returns the error so the UI can surface it, but Sentry should keep /// reporting server-side and transport failures only. +#[cfg(feature = "crash-reporting")] pub fn is_skill_install_user_fetch_failure(event: &sentry::protocol::Event<'_>) -> bool { let tags = &event.tags; if tags.get("domain").map(String::as_str) != Some("skills") { @@ -2841,6 +2903,7 @@ pub fn is_skill_install_user_fetch_failure(event: &sentry::protocol::Event<'_>) /// would otherwise escape the integrations-scoped filter (OPENHUMAN-TAURI-35 /// ~139ev, -2H ~26ev: `[composio] list_connections failed: Backend returned /// 502 …` events that landed in Sentry under `domain=composio`). +#[cfg(feature = "crash-reporting")] pub fn is_transient_integrations_failure(event: &sentry::protocol::Event<'_>) -> bool { is_transient_domain_failure(event, "integrations") || is_transient_domain_failure(event, "composio") @@ -2858,6 +2921,7 @@ pub fn is_transient_integrations_failure(event: &sentry::protocol::Event<'_>) -> /// `domain=skills`, `failure=non_2xx`, and a 4xx `status`. A 5xx is a genuine /// remote failure and stays reportable. Drops TAURI-RUST-CGE (~1,446 events / /// 72 users on `openhuman@0.57.53`). +#[cfg(feature = "crash-reporting")] pub fn is_skills_install_client_error_event(event: &sentry::protocol::Event<'_>) -> bool { let tags = &event.tags; if tags.get("domain").map(String::as_str) != Some("skills") { @@ -2879,6 +2943,7 @@ pub fn is_skills_install_client_error_event(event: &sentry::protocol::Event<'_>) /// `"failed to check for updates: error sending request for url (...latest.json)"`. /// Match both shapes, but never drop an arbitrary update-domain event unless /// it also has a transient status/transport marker. +#[cfg(feature = "crash-reporting")] pub fn is_updater_transient_event(event: &sentry::protocol::Event<'_>) -> bool { if event_has_updater_transient_message(event) { return true; @@ -2967,6 +3032,7 @@ pub fn is_suppressed_usage_probe_backoff(msg: &str) -> bool { /// the emit-site classifier — any non_2xx/400 event that carries the /// budget-exhausted phrasing is dropped regardless of which domain produced /// it, so a future re-emitter under a different tag still gets filtered. +#[cfg(feature = "crash-reporting")] pub fn is_budget_event(event: &sentry::protocol::Event<'_>) -> bool { let tags = &event.tags; if tags.get("failure").map(String::as_str) != Some("non_2xx") { @@ -3033,6 +3099,7 @@ pub fn is_insufficient_credits_message(text: &str) -> bool { /// failure (`"402"` or `"payment required"`), AND /// - that same text carries an insufficient-credits phrase /// (`provider::body_indicates_insufficient_credits`). +#[cfg(feature = "crash-reporting")] pub fn is_insufficient_credits_event(event: &sentry::protocol::Event<'_>) -> bool { if event .message @@ -3073,6 +3140,7 @@ pub fn is_quota_exhausted_message(text: &str) -> bool { /// that catches all of them, keyed on the formatted message rather than tags so /// it matches regardless of which path emitted it (and regardless of whether /// the upstream wrapped the 402 in a 500 envelope). +#[cfg(feature = "crash-reporting")] pub fn is_quota_exhausted_event(event: &sentry::protocol::Event<'_>) -> bool { if event .message @@ -3117,6 +3185,7 @@ pub fn is_ollama_cloud_internal_500_message_any(text: &str) -> bool { /// net for any other compatible-provider path (`chat_with_system`, /// `chat_with_history`, the non-native cascades) that reports the same body, /// keyed on the message rather than tags so it matches regardless of emitter. +#[cfg(feature = "crash-reporting")] pub fn is_ollama_cloud_internal_500_event(event: &sentry::protocol::Event<'_>) -> bool { if event .message @@ -3145,6 +3214,7 @@ pub fn is_ollama_cloud_internal_500_event(event: &sentry::protocol::Event<'_>) - /// - tag `status == "404"` /// - tag `method == "PATCH"` or `"DELETE"` /// - event message or exception value contains both `"/channels/"` and `"/messages/"` +#[cfg(feature = "crash-reporting")] pub fn is_channel_message_not_found_event(event: &sentry::protocol::Event<'_>) -> bool { let tags = &event.tags; if tags.get("domain").map(String::as_str) != Some("backend_api") { @@ -3163,6 +3233,7 @@ pub fn is_channel_message_not_found_event(event: &sentry::protocol::Event<'_>) - event_contains_channel_message_path(event) } +#[cfg(feature = "crash-reporting")] fn event_contains_channel_message_path(event: &sentry::protocol::Event<'_>) -> bool { let has_pattern = |s: &str| s.contains("/channels/") && s.contains("/messages/"); if event.message.as_deref().is_some_and(has_pattern) { @@ -3175,6 +3246,7 @@ fn event_contains_channel_message_path(event: &sentry::protocol::Event<'_>) -> b .any(|exc| exc.value.as_deref().is_some_and(has_pattern)) } +#[cfg(feature = "crash-reporting")] fn event_contains_budget_exhausted_message(event: &sentry::protocol::Event<'_>) -> bool { if event .message @@ -6286,6 +6358,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] fn event_with_tags(pairs: &[(&str, &str)]) -> sentry::protocol::Event<'static> { let mut event = sentry::protocol::Event::default(); let mut tags: std::collections::BTreeMap = @@ -6297,6 +6370,7 @@ mod tests { event } + #[cfg(feature = "crash-reporting")] fn event_with_tags_and_message( pairs: &[(&str, &str)], message: &str, @@ -6306,6 +6380,7 @@ mod tests { event } + #[cfg(feature = "crash-reporting")] #[test] fn transient_filter_drops_429_408_502_503_504() { for status in ["429", "408", "502", "503", "504"] { @@ -6321,6 +6396,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn custom_openai_502_event_shape_is_transient_provider_http() { let event = event_with_tags_and_message( @@ -6338,6 +6414,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn transient_filter_keeps_permanent_failures() { for status in ["400", "401", "403", "404", "500"] { @@ -6353,6 +6430,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn transient_filter_keeps_aggregate_all_exhausted() { let event = event_with_tags(&[ @@ -6366,6 +6444,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn transient_filter_keeps_events_with_no_status_tag() { let event = event_with_tags(&[("domain", "llm_provider"), ("failure", "non_2xx")]); @@ -6382,6 +6461,7 @@ mod tests { // "llm_provider", ..)` so the domain tag is consistent), but the broader // point is: any future caller that re-uses the same tag set for a // different domain must NOT be silently dropped by this filter. + #[cfg(feature = "crash-reporting")] #[test] fn transient_filter_keeps_events_with_no_domain_tag() { let event = event_with_tags(&[("failure", "non_2xx"), ("status", "503")]); @@ -6391,6 +6471,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn transient_filter_keeps_events_from_other_domains() { let event = event_with_tags(&[ @@ -6404,6 +6485,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn backend_api_filter_drops_transient_statuses() { for status in TRANSIENT_HTTP_STATUSES { @@ -6419,6 +6501,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn backend_api_filter_drops_transient_transport_phrases() { for phrase in TRANSIENT_TRANSPORT_PHRASES { @@ -6433,6 +6516,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn backend_api_filter_keeps_non_transient_failures() { for status in ["404", "500"] { @@ -6467,6 +6551,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn skills_install_fetch_filter_drops_client_error_statuses() { for status in ["400", "401", "403", "404", "410", "499"] { @@ -6483,6 +6568,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn skills_install_fetch_filter_keeps_server_and_wrong_shape_failures() { for status in ["500", "502", "503"] { @@ -6526,6 +6612,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn integrations_filter_drops_transient_statuses() { for status in TRANSIENT_HTTP_STATUSES { @@ -6541,6 +6628,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn integrations_filter_drops_transient_transport_phrases() { for phrase in TRANSIENT_TRANSPORT_PHRASES { @@ -6555,6 +6643,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn integrations_filter_keeps_non_transient_failures() { for status in ["404", "500"] { @@ -6598,6 +6687,7 @@ mod tests { /// `SKILL.md`) is expected user-input state — the before_send net must drop /// it, while a genuine 5xx remote failure and unrelated domains stay /// reportable. + #[cfg(feature = "crash-reporting")] #[test] fn skills_install_client_error_filter_drops_4xx_keeps_5xx() { // 4xx (esp. 404/410) = missing skill / wrong URL → dropped. @@ -6646,6 +6736,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn composio_domain_routes_through_integrations_filter() { // OPENHUMAN-TAURI-35 (~139 events) / -2H (~26 events): @@ -6697,6 +6788,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn composio_list_connections_503_504_wrappers_stay_filtered() { for (status, reason) in [("503", "Service Unavailable"), ("504", "Gateway Timeout")] { @@ -6738,6 +6830,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn updater_transient_403_is_dropped() { let event = event_with_tags_and_message( @@ -6755,6 +6848,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn updater_github_403_message_only_shapes_are_dropped() { for event in [ @@ -6768,6 +6862,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn updater_transient_502_is_dropped() { let event = event_with_tags_and_message( @@ -6784,6 +6879,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn updater_real_panic_still_reported() { let event = event_with_tags_and_message( @@ -6796,6 +6892,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn updater_endpoint_non_success_message_is_dropped() { // TAURI-RUST-CD (~151 events / 9 days, Windows): `tauri-plugin-updater` @@ -6817,6 +6914,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn updater_endpoint_non_success_anchor_does_not_silence_unrelated_errors() { // The new anchor is the literal plugin string. Other updater failures @@ -6887,6 +6985,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn budget_filter_drops_budget_message_on_tagged_400() { let event = event_with_tags_and_message( @@ -6897,6 +6996,7 @@ mod tests { assert!(is_budget_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn budget_filter_drops_budget_exception_on_tagged_400() { let mut event = event_with_tags(&[("failure", "non_2xx"), ("status", "400")]); @@ -6908,6 +7008,7 @@ mod tests { assert!(is_budget_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn budget_filter_keeps_non_budget_400() { let event = event_with_tags_and_message( @@ -6918,6 +7019,7 @@ mod tests { assert!(!is_budget_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn budget_filter_requires_non_2xx_failure_and_400_status() { let message = "Budget exceeded — add credits to continue"; @@ -6964,12 +7066,14 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] fn event_with_message(msg: &str) -> sentry::protocol::Event<'static> { let mut event = sentry::protocol::Event::default(); event.message = Some(msg.to_string()); event } + #[cfg(feature = "crash-reporting")] fn event_with_exception_value(value: &str) -> sentry::protocol::Event<'static> { let mut event = sentry::protocol::Event::default(); event.exception = vec![sentry::protocol::Exception { @@ -6980,6 +7084,7 @@ mod tests { event } + #[cfg(feature = "crash-reporting")] #[test] fn quota_exhausted_filter_matches_500_wrapped_kiro_event() { // TAURI-RUST-C9A: verbatim message as formatted by the provider emit @@ -6994,6 +7099,7 @@ mod tests { assert!(is_quota_exhausted_message(body)); } + #[cfg(feature = "crash-reporting")] #[test] fn quota_exhausted_filter_matches_responses_usage_limit_reached_event() { // TAURI-RUST-AFE: verbatim message as formatted by the `chat_via_responses` @@ -7013,6 +7119,7 @@ mod tests { assert!(is_quota_exhausted_message(body)); } + #[cfg(feature = "crash-reporting")] #[test] fn quota_exhausted_filter_ignores_generic_500_and_rate_limit() { // A generic 500 outage and a 429 rate-limit are not plan-quota @@ -7025,6 +7132,7 @@ mod tests { ))); } + #[cfg(feature = "crash-reporting")] #[test] fn insufficient_credits_filter_matches_message_path() { // Verbatim TAURI-RUST-C62 message as formatted by the provider emit @@ -7036,6 +7144,7 @@ mod tests { assert!(is_insufficient_credits_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn insufficient_credits_filter_matches_exception_path() { let event = event_with_exception_value( @@ -7044,6 +7153,7 @@ mod tests { assert!(is_insufficient_credits_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn insufficient_credits_filter_requires_both_402_and_credit_phrase() { // A 402 with no credit phrase must NOT be swallowed (could be another @@ -7058,6 +7168,7 @@ mod tests { ))); } + #[cfg(feature = "crash-reporting")] #[test] fn insufficient_credits_filter_ignores_402_digits_in_a_non_402_body() { // A non-402 error whose body merely contains the digits "402" and a @@ -7112,6 +7223,7 @@ mod tests { )); } + #[cfg(feature = "crash-reporting")] #[test] fn is_insufficient_credits_event_delegates_to_message_matcher() { // Parity: the event-level filter is now a thin wrapper over the @@ -7141,6 +7253,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn ollama_cloud_internal_500_before_send_matches_raw_and_reraised_shapes() { // The outermost net catches BOTH the raw emit body (any compatible @@ -7170,6 +7283,7 @@ mod tests { ))); } + #[cfg(feature = "crash-reporting")] #[test] fn session_expired_before_send_matches_core_401_events() { let msg = "SESSION_EXPIRED: backend session not active — sign in to resume LLM work"; @@ -7189,6 +7303,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn session_expired_before_send_stays_domain_scoped() { let event = event_with_tags_and_message( @@ -7201,6 +7316,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn max_iterations_filter_matches_message_path() { // `report_error_message` calls `sentry::capture_message`, which @@ -7210,6 +7326,7 @@ mod tests { assert!(is_max_iterations_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn max_iterations_filter_matches_exception_path() { // sentry-tracing with attach_stacktrace=true populates the @@ -7221,6 +7338,7 @@ mod tests { assert!(is_max_iterations_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn max_iterations_filter_keeps_unrelated_events() { assert!(!is_max_iterations_event(&event_with_message( @@ -7232,6 +7350,7 @@ mod tests { // ── is_channel_message_not_found_event (TAURI-R7) ──────────────────────── + #[cfg(feature = "crash-reporting")] fn channel_message_404_event(method: &str) -> sentry::protocol::Event<'static> { let mut event = sentry::protocol::Event::default(); event.tags.insert("domain".into(), "backend_api".into()); @@ -7245,6 +7364,7 @@ mod tests { event } + #[cfg(feature = "crash-reporting")] #[test] fn channel_message_not_found_filter_matches_patch() { // Canonical TAURI-R7 shape: PATCH 404 on a channel-message path. @@ -7253,6 +7373,7 @@ mod tests { )); } + #[cfg(feature = "crash-reporting")] #[test] fn channel_message_not_found_filter_matches_delete() { assert!(is_channel_message_not_found_event( @@ -7260,6 +7381,7 @@ mod tests { )); } + #[cfg(feature = "crash-reporting")] #[test] fn channel_message_not_found_filter_ignores_get_404() { // GET 404 on a channel-message path is NOT an expected state — must keep Sentry signal. @@ -7268,6 +7390,7 @@ mod tests { )); } + #[cfg(feature = "crash-reporting")] #[test] fn channel_message_not_found_filter_ignores_non_channel_path() { let mut event = channel_message_404_event("PATCH"); @@ -7275,6 +7398,7 @@ mod tests { assert!(!is_channel_message_not_found_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn channel_message_not_found_filter_ignores_wrong_status() { let mut event = channel_message_404_event("PATCH"); @@ -7282,6 +7406,7 @@ mod tests { assert!(!is_channel_message_not_found_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn channel_message_not_found_filter_ignores_wrong_domain() { let mut event = channel_message_404_event("PATCH"); @@ -7289,6 +7414,7 @@ mod tests { assert!(!is_channel_message_not_found_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn channel_message_not_found_filter_matches_exception_path() { // sentry-tracing with attach_stacktrace=true populates exception list. @@ -7709,6 +7835,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn expected_kind_ignores_byo_errors_that_carry_an_error_code_token() { // CodeRabbit: a BYO / direct-provider envelope whose body happens to @@ -7727,6 +7854,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn before_send_filter_drops_backend_owned_error_code_events() { for code in [ @@ -7760,6 +7888,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn before_send_filter_keeps_malformed_bad_request_event() { let event = event_with_message( @@ -7772,12 +7901,14 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn before_send_filter_matches_error_code_in_exception_value() { let event = event_with_exception_value(&managed_body("500", "INTERNAL_ERROR")); assert!(is_backend_error_code_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn transient_provider_transport_filter_drops_flaky_network_blips() { // F7: a streaming transport timeout/reset under @@ -7806,6 +7937,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn transient_provider_transport_filter_keeps_non_transient_transport() { // A genuine, non-transient transport failure (e.g. an unexpected @@ -7821,6 +7953,7 @@ mod tests { assert!(!is_transient_provider_transport_failure(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn transient_provider_transport_filter_scoped_to_streaming_operations() { // CodeRabbit: a NON-streaming llm_provider transport failure with the @@ -7847,6 +7980,7 @@ mod tests { assert!(!is_transient_provider_transport_failure(&no_op)); } + #[cfg(feature = "crash-reporting")] #[test] fn transient_provider_transport_filter_scoped_to_llm_provider() { // Same shape under a different domain must not be claimed by this @@ -7870,6 +8004,7 @@ mod tests { // `openhuman::credentials::ops::auth_get_me` for the broader // context. + #[cfg(feature = "crash-reporting")] fn auth_get_me_tags() -> Vec<(&'static str, &'static str)> { vec![ ("domain", "rpc"), @@ -7879,6 +8014,7 @@ mod tests { ] } + #[cfg(feature = "crash-reporting")] #[test] fn auth_get_me_opaque_filter_drops_bare_method_path_message() { let event = event_with_tags_and_message(&auth_get_me_tags(), "GET /auth/me"); @@ -7888,6 +8024,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn auth_get_me_opaque_filter_tolerates_surrounding_whitespace() { let event = event_with_tags_and_message(&auth_get_me_tags(), " GET /auth/me "); @@ -7897,6 +8034,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn auth_get_me_opaque_filter_keeps_full_anyhow_chain_message() { // Post-fix shape from `auth_get_me` now using `format!("{e:#}")`. @@ -7912,6 +8050,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn auth_get_me_opaque_filter_keeps_other_rpc_methods() { // Same opaque shape but for a different RPC must NOT be dropped — @@ -7937,6 +8076,7 @@ mod tests { } } + #[cfg(feature = "crash-reporting")] #[test] fn auth_get_me_opaque_filter_requires_rpc_invoke_method_domain() { // Wrong domain → must surface. @@ -7956,6 +8096,7 @@ mod tests { assert!(!is_auth_get_me_opaque_transport_event(&event)); } + #[cfg(feature = "crash-reporting")] #[test] fn auth_get_me_opaque_filter_matches_exception_value_path() { // sentry-tracing path: message empty, exception last value carries @@ -7971,6 +8112,7 @@ mod tests { ); } + #[cfg(feature = "crash-reporting")] #[test] fn auth_get_me_opaque_filter_ignores_empty_and_unrelated() { // No message and no exception → false. diff --git a/src/core/runtime/builder.rs b/src/core/runtime/builder.rs index 8af72b7d3..a1a5c1a27 100644 --- a/src/core/runtime/builder.rs +++ b/src/core/runtime/builder.rs @@ -389,6 +389,12 @@ impl CoreRuntime { /// When `rpc_http` is not selected this returns immediately (a harness-only /// embedder has no transport to run); background services selected in the /// [`ServiceSet`] are still spawned. + /// + /// In a slim build compiled without the `http-server` feature an `rpc_http` + /// request cannot be honoured — the axum / Socket.IO transport is compiled + /// out — so `serve` returns a build-feature `Err` rather than binding no + /// listener and reporting success. The no-transport (`!rpc_http`) path above + /// is unaffected and still returns `Ok(())`. pub async fn serve( &self, ready_tx: Option>, @@ -401,6 +407,55 @@ impl CoreRuntime { return Ok(()); } + // Transport compiled out (#5048): run the selected background services + // and return without binding an HTTP/Socket.IO listener — same shape as + // the no-`rpc_http` guard above. The desktop shell always ships + // `http-server`; this keeps slim / headless-embedding builds linkable. + #[cfg(not(feature = "http-server"))] + { + // `rpc_http` was requested (we passed the guard above) but the HTTP + + // Socket.IO transport is compiled out of this slim build. Fail loudly + // rather than returning Ok with no listener bound — a supervisor / CLI + // (`openhuman run`, `serve`, `--headless-api`) would otherwise observe + // a clean start while the requested API is unavailable. Embedders that + // genuinely want no transport leave `ServiceSet::rpc_http` unset, which + // is handled by the early return above. + // + // The bind inputs are only read by the compiled-out `serve_http`; touch + // them so they don't read as dead fields in the slim build. + let _ = ( + ready_tx, + shutdown_token, + self.has_operator_token, + self.host.as_ref(), + self.port, + ); + anyhow::bail!( + "rpc_http transport was requested but this build was compiled \ + without the `http-server` feature; rebuild with the default \ + `http-server` feature, or use an embedding that does not set \ + `ServiceSet::rpc_http`" + ); + } + + #[cfg(feature = "http-server")] + { + self.serve_http(ready_tx, shutdown_token).await + } + } + + /// HTTP + Socket.IO transport body of [`Self::serve`]. + /// + /// Compiled only under the `http-server` feature (#5048): builds the axum + /// router, binds the listener, starts the selected background services, and + /// serves until shutdown. With the feature off, [`serve`](Self::serve) runs + /// background services and returns without binding (see the arms above). + #[cfg(feature = "http-server")] + async fn serve_http( + &self, + ready_tx: Option>, + shutdown_token: Option, + ) -> anyhow::Result<()> { // --- Host / port resolution --- let (resolved_port, port_source) = match self.port { Some(p) => (p, "builder port"), diff --git a/src/core/runtime/mod.rs b/src/core/runtime/mod.rs index 4b3e3a314..299a6a15f 100644 --- a/src/core/runtime/mod.rs +++ b/src/core/runtime/mod.rs @@ -24,6 +24,23 @@ //! on every multi-thread runtime that may host an agent turn. pub const AGENT_WORKER_STACK_BYTES: usize = 16 * 1024 * 1024; +/// Upper bound on tokio's blocking-thread pool for the long-lived multi-thread +/// runtimes tuned with [`AGENT_WORKER_STACK_BYTES`] (the desktop Tauri host and +/// the `openhuman-core` JSON-RPC / `agent_cli` servers). +/// +/// Tokio defaults `max_blocking_threads` to **512**. That is doubly wasteful on +/// these runtimes: `thread_stack_size` sizes *blocking* threads too, not just +/// workers, so an idle pool that grew to the cap could pin up to +/// `512 × 16 MiB` of stack — the opposite of the embedded RAM budget in #5046. +/// `spawn_blocking` on these paths backs SQLite, filesystem grep/glob, document +/// parsing, and URL guarding: bounded, bursty concurrency. 64 leaves generous +/// headroom over any realistic concurrent-blocking count while capping the idle +/// footprint, and threads still retire after tokio's 10 s idle timeout. +/// +/// Set `.max_blocking_threads(MAX_BLOCKING_THREADS)` alongside +/// `.thread_stack_size(AGENT_WORKER_STACK_BYTES)` on every such runtime. +pub const MAX_BLOCKING_THREADS: usize = 64; + pub mod builder; pub mod context; pub mod services; diff --git a/src/core/shutdown.rs b/src/core/shutdown.rs index fe93ecb0f..4d569f4d7 100644 --- a/src/core/shutdown.rs +++ b/src/core/shutdown.rs @@ -56,8 +56,12 @@ async fn run_hooks() { /// signal (SIGINT on all platforms, plus SIGTERM on Unix), then runs all /// registered shutdown hooks. /// -/// This is intended to be used with [`axum::serve`]'s `with_graceful_shutdown` -/// method or in the main loop to handle clean exits. +/// This is intended to be used with `axum::serve`'s `with_graceful_shutdown` +/// method or in the main loop to handle clean exits. (Plain code span, not an +/// intra-doc link: the direct `axum` dependency/API surface is unavailable in +/// slim builds — the `http-server` feature (#5048) gates it, and it remains +/// only transitively via `tinychannels` — where an intra-doc link to it would +/// fail rustdoc.) pub async fn signal() { // Wait for the OS to send a termination signal. wait_for_signal().await; diff --git a/src/core/socketio.rs b/src/core/socketio.rs index 1b35bcec7..663a36a5d 100644 --- a/src/core/socketio.rs +++ b/src/core/socketio.rs @@ -1,7 +1,18 @@ use serde::Deserialize; use serde::Serialize; +// `json!` + socketioxide are used only by the socketioxide event-transport +// bodies below, all gated with the `http-server` feature (#5048). The inert +// event payload types further down (`WebChannelEvent`, `TurnUsagePayload`, +// `SubagentUsagePayload`, `SubagentProgressDetail`) stay compiled in every build +// — ~10 always-on domains (web_chat, cron, channels, agent, agentbox, …) +// construct them — so `serde` stays ungated and only the transport surface is +// gated (type carve-out; see AGENTS.md and this module's `pub mod` in +// `core::mod`, which is intentionally NOT gated). +#[cfg(feature = "http-server")] use serde_json::json; +#[cfg(feature = "http-server")] use socketioxide::extract::{Data, SocketRef, TryData}; +#[cfg(feature = "http-server")] use socketioxide::SocketIo; /// Marker stored in [`SocketRef::extensions`] once a connection has presented a @@ -11,6 +22,7 @@ use socketioxide::SocketIo; /// into the JSON-RPC dispatcher or the web-chat orchestrator: an unauthenticated /// socket that never picked up the marker is allowed to receive broadcast-style /// events (read-only) but cannot trigger executable work. +#[cfg(feature = "http-server")] #[derive(Clone, Copy, Debug)] struct AuthedConnection; @@ -20,6 +32,7 @@ struct AuthedConnection; /// headers, so the handshake `auth` map is the only header-equivalent slot /// available for our per-process bearer. The socket-IO Node/JS clients all /// surface `io(url, { auth: { token: "" } })` for this. +#[cfg(feature = "http-server")] #[derive(Debug, Default, Deserialize)] struct HandshakeAuth { #[serde(default)] @@ -47,6 +60,7 @@ struct HandshakeAuth { /// A missing `Origin` header is treated as a native (non-browser) client /// and accepted — only the cross-origin browser-page case is the targeted /// bad actor here. +#[cfg(feature = "http-server")] pub(crate) fn origin_is_allowed(origin: Option<&str>) -> bool { let Some(origin) = origin else { return true; // native clients (CLI, Tauri shell) — no Origin header @@ -73,6 +87,7 @@ pub(crate) fn origin_is_allowed(origin: Option<&str>) -> bool { } /// True when `socket` finished the handshake with a valid bearer token. +#[cfg(feature = "http-server")] fn socket_is_authed(socket: &SocketRef) -> bool { socket.extensions.get::().is_some() } @@ -80,6 +95,7 @@ fn socket_is_authed(socket: &SocketRef) -> bool { /// Best-effort disconnect. Called when we discover an unauthenticated socket /// inside an event handler — the connect path already disconnects the bad /// origins / wrong tokens, so this is purely a defense-in-depth path. +#[cfg(feature = "http-server")] fn drop_unauthed(socket: &SocketRef, reason: &'static str) { log::warn!( "[socketio] dropping unauthenticated socket id={} reason={}", @@ -343,6 +359,7 @@ pub struct SubagentProgressDetail { pub dirty_status: Option, } +#[cfg(feature = "http-server")] #[derive(Debug, Deserialize)] struct SocketRpcRequest { id: serde_json::Value, @@ -351,6 +368,7 @@ struct SocketRpcRequest { params: serde_json::Value, } +#[cfg(feature = "http-server")] #[derive(Debug, Deserialize)] struct ChatStartPayload { thread_id: String, @@ -369,6 +387,7 @@ struct ChatStartPayload { queue_mode: Option, } +#[cfg(feature = "http-server")] #[derive(Debug, Deserialize)] struct ChatCancelPayload { thread_id: String, @@ -379,6 +398,7 @@ struct ChatCancelPayload { request_id: Option, } +#[cfg(feature = "http-server")] #[derive(Debug, Deserialize)] struct ThreadSubscribePayload { thread_id: String, @@ -391,6 +411,7 @@ struct ThreadSubscribePayload { /// - `rpc:request`: Invoking JSON-RPC methods over WebSocket. /// - `chat:start`: Initiating a new chat turn. /// - `chat:cancel`: Aborting an active chat turn. +#[cfg(feature = "http-server")] pub fn attach_socketio() -> (socketioxide::layer::SocketIoLayer, SocketIo) { let (layer, io) = SocketIo::new_layer(); @@ -612,6 +633,7 @@ pub fn attach_socketio() -> (socketioxide::layer::SocketIoLayer, SocketIo) { /// 3. **Overlay Bridge**: Forwards attention bubble events to all clients. /// 4. **Core Notification Bridge**: Forwards core notification events to all clients. /// 5. **Transcription Bridge**: Forwards real-time speech-to-text results to all clients. +#[cfg(feature = "http-server")] pub fn spawn_web_channel_bridge(io: SocketIo) { // 1. Web channel events → per-client rooms. let io_web = io.clone(); @@ -1462,6 +1484,7 @@ pub fn spawn_web_channel_bridge(io: SocketIo) { /// listener for telegram/discord); `last_error` carries the disconnect reason. /// Matches the shape consumed by the frontend /// `normalizeChannelConnectionUpdatePayload`. +#[cfg(feature = "http-server")] pub(crate) fn channel_connection_update_payload( channel: &str, status: &str, @@ -1486,6 +1509,7 @@ pub(crate) fn channel_connection_update_payload( /// so both the happy and error paths are logged with enough context /// (room name + client id) to diagnose missing welcome messages from /// logs alone. +#[cfg(feature = "http-server")] fn join_room_logged(socket: &SocketRef, room: &str, client_id: &str) { match socket.join(room.to_string()) { Ok(()) => log::debug!("[socketio] joined room '{room}' for client {client_id}"), @@ -1493,6 +1517,7 @@ fn join_room_logged(socket: &SocketRef, room: &str, client_id: &str) { } } +#[cfg(feature = "http-server")] fn emit_web_channel_event(io: &SocketIo, event: WebChannelEvent) { let name = event.event.clone(); // Deliver to the initiating client's own room AND the per-thread room. The @@ -1555,8 +1580,10 @@ fn emit_web_channel_event(io: &SocketIo, event: WebChannelEvent) { /// is suppressed for exactly these. Enumerated explicitly rather than matched by /// a `*_delta` suffix, so a future *discrete* event whose name happens to end in /// `_delta` still gets its compat alias instead of being silently dropped. +#[cfg(feature = "http-server")] const STREAMING_DELTA_EVENTS: &[&str] = &["text_delta", "thinking_delta", "tool_args_delta"]; +#[cfg(feature = "http-server")] fn event_alias(name: &str) -> Option { // Match against the canonical underscore form after stripping a `subagent_` // prefix (subagent streaming mirrors the parent's deltas), so `text_delta`, @@ -1576,6 +1603,7 @@ fn event_alias(name: &str) -> Option { None } +#[cfg(feature = "http-server")] fn emit_with_aliases(socket: &SocketRef, name: &str, payload: &serde_json::Value) { let _ = socket.emit(name, payload); if let Some(alias) = event_alias(name) { @@ -1583,7 +1611,9 @@ fn emit_with_aliases(socket: &SocketRef, name: &str, payload: &serde_json::Value } } -#[cfg(test)] +// Every test here names a gated fn (`channel_connection_update_payload`, +// `event_alias`, `origin_is_allowed`), so the module gates in lockstep (#5048). +#[cfg(all(test, feature = "http-server"))] mod tests { use super::{channel_connection_update_payload, event_alias, origin_is_allowed}; diff --git a/src/main.rs b/src/main.rs index fbd3b4167..b958d7a02 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,9 +5,6 @@ //! - 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 @@ -33,6 +30,10 @@ fn main() { // 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. + // + // The whole init (guard + secret-scrubbing `before_send`) is gated on the + // `crash-reporting` feature; a slim build compiles it out entirely. + #[cfg(feature = "crash-reporting")] let _sentry_guard = sentry::init(sentry::ClientOptions { dsn: std::env::var("OPENHUMAN_CORE_SENTRY_DSN") .ok() @@ -259,6 +260,7 @@ fn restore_default_sigpipe() {} /// `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. +#[cfg(feature = "crash-reporting")] fn build_release_tag() -> String { let version = env!("CARGO_PKG_VERSION"); let sha = option_env!("OPENHUMAN_BUILD_SHA").unwrap_or("").trim(); @@ -275,6 +277,7 @@ fn build_release_tag() -> String { /// 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. +#[cfg(feature = "crash-reporting")] fn resolve_environment() -> String { if let Ok(value) = std::env::var("OPENHUMAN_APP_ENV") { let trimmed = value.trim().to_ascii_lowercase(); @@ -293,53 +296,16 @@ fn resolve_environment() -> 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]`. +/// Sentry `before_send` secret scrubbing. Delegates to the shared, always-on +/// [`openhuman_core::core::log_redaction::scrub_secrets`] so the redaction +/// patterns stay a single source of truth (the same pass also runs on the +/// always-on diagnostic logs in `core::observability`). +#[cfg(feature = "crash-reporting")] 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 + openhuman_core::core::log_redaction::scrub_secrets(input) } -#[cfg(test)] +#[cfg(all(test, feature = "crash-reporting"))] mod tests { use super::*; diff --git a/src/openhuman/accessibility/permissions.rs b/src/openhuman/accessibility/permissions.rs index 1fda7a7c0..eeb03b542 100644 --- a/src/openhuman/accessibility/permissions.rs +++ b/src/openhuman/accessibility/permissions.rs @@ -148,7 +148,7 @@ pub fn detect_input_monitoring_permission() -> PermissionState { /// /// **Linux** standard desktops don't enforce per-app permissions; Flatpak/Snap /// sandboxes are detected separately. -#[cfg(any(target_os = "macos", target_os = "windows"))] +#[cfg(all(feature = "inference", any(target_os = "macos", target_os = "windows")))] pub fn detect_microphone_permission() -> PermissionState { use cpal::traits::HostTrait; let host = cpal::default_host(); @@ -168,7 +168,7 @@ pub fn detect_microphone_permission() -> PermissionState { } } -#[cfg(target_os = "linux")] +#[cfg(all(feature = "inference", target_os = "linux"))] pub fn detect_microphone_permission() -> PermissionState { // Standard Linux desktops (PulseAudio/PipeWire) don't enforce app-level mic permissions. // Detect Flatpak sandbox — if sandboxed, probe CPAL as a permission proxy. @@ -189,6 +189,21 @@ pub fn detect_microphone_permission() -> PermissionState { } } +/// With the `inference` feature off, the `cpal` audio-device probe is compiled +/// out along with the whisper engine, so the microphone cannot be inspected. +/// Report `Unknown` on otherwise-supported desktop platforms rather than a +/// misleading `Granted`/`Denied`. +#[cfg(all( + not(feature = "inference"), + any(target_os = "macos", target_os = "windows", target_os = "linux") +))] +pub fn detect_microphone_permission() -> PermissionState { + log::debug!( + "[permissions] microphone probe unavailable (built without the `inference` feature)" + ); + PermissionState::Unknown +} + #[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))] pub fn detect_microphone_permission() -> PermissionState { PermissionState::Unsupported diff --git a/src/openhuman/agent/multimodal.rs b/src/openhuman/agent/multimodal.rs index bba7579ea..f5a7e5a2e 100644 --- a/src/openhuman/agent/multimodal.rs +++ b/src/openhuman/agent/multimodal.rs @@ -32,6 +32,7 @@ const FILE_MARKER_PREFIX: &str = "[FILE:"; /// may run before the worker is abandoned and the file degrades to a /// metadata-only reference. PDFs known to choke the parser (extremely /// large, encrypted, malformed) must not stall a chat turn. +#[cfg(feature = "documents")] const PDF_EXTRACTION_TIMEOUT: Duration = Duration::from_secs(60); /// Worst-case length budget reserved for the rendered truncation @@ -1550,6 +1551,7 @@ fn extract_utf8_text(bytes: &[u8]) -> Result { /// extracted text on success; on timeout / panic / parse error the /// caller degrades the file to [`FilePayload::Reference`] rather than /// surface the failure to the user (avoids Sentry noise on broken PDFs). +#[cfg(feature = "documents")] async fn extract_pdf_text(bytes: Vec) -> Result { let extraction = tokio::task::spawn_blocking(move || { pdf_extract::extract_text_from_mem(&bytes).map_err(|error| error.to_string()) @@ -1566,6 +1568,15 @@ async fn extract_pdf_text(bytes: Vec) -> Result { } } +/// Disabled variant when the `documents` feature is off: `pdf-extract` is not +/// compiled in, so signal failure and let the caller degrade the file to +/// [`FilePayload::Reference`] — the same path a parse error / timeout takes. +#[cfg(not(feature = "documents"))] +async fn extract_pdf_text(_bytes: Vec) -> Result { + log::debug!("[multimodal] pdf text extraction skipped: built without the `documents` feature"); + Err("pdf text extraction disabled (built without the `documents` feature)".to_string()) +} + /// Truncate `text` to at most `max_chars` Unicode scalar values, leaving /// room for the rendered `"\n[…truncated {dropped} chars]"` suffix. /// The reservation uses [`TEXT_TRUNCATION_SUFFIX_BUDGET`] — the diff --git a/src/openhuman/agent_registry/agents/loader.rs b/src/openhuman/agent_registry/agents/loader.rs index 6d32e7106..ed7288714 100644 --- a/src/openhuman/agent_registry/agents/loader.rs +++ b/src/openhuman/agent_registry/agents/loader.rs @@ -340,12 +340,32 @@ pub const BUILTINS: &[BuiltinAgent] = &[ /// baked into the binary and therefore must always be valid. Unit tests /// below keep that invariant honest. pub fn load_builtins() -> Result> { - let defs: Vec = BUILTINS.iter().map(parse_builtin).collect::>()?; + let defs: Vec = BUILTINS + .iter() + .filter(|b| builtin_enabled(b)) + .map(parse_builtin) + .collect::>()?; validate_tier_hierarchy(&defs) .context("built-in agents violate the spawn-hierarchy contract")?; Ok(defs) } +/// Compile-time gate for built-ins whose deck/document tool is feature-gated. +/// +/// `presentation_agent` delegates deck creation to `generate_presentation`, +/// which only registers under the `documents` feature (see `tools::ops`). In a +/// slim build without `documents`, the agent would still be advertised as +/// `make_presentation` while its filtered tool surface no longer contains any +/// tool able to produce a deck, so it is dropped from the registry in lockstep +/// with its tool. +fn builtin_enabled(_b: &BuiltinAgent) -> bool { + #[cfg(not(feature = "documents"))] + if _b.id == "presentation_agent" { + return false; + } + true +} + /// Validate the cross-agent spawn-hierarchy contract documented on /// [`AgentTier`]. /// @@ -464,7 +484,35 @@ mod tests { #[test] fn all_builtins_parse() { let defs = load_builtins().expect("built-in TOML must parse"); - assert_eq!(defs.len(), BUILTINS.len()); + // `load_builtins` filters feature-gated built-ins (e.g. `presentation_agent` + // when `documents` is off), so compare against the same filtered count + // rather than the raw `BUILTINS` length. + let expected = BUILTINS.iter().filter(|b| builtin_enabled(b)).count(); + assert_eq!(defs.len(), expected); + } + + /// Pins the `presentation_agent` compile-time gate, both directions: it is + /// registered under the `documents` feature (its `generate_presentation` + /// deck tool lives there) and filtered out of the registry without it, so + /// slim builds never advertise `make_presentation` with no tool to fulfil it. + #[cfg(feature = "documents")] + #[test] + fn presentation_agent_registered_when_documents_on() { + let defs = load_builtins().expect("built-in TOML must parse"); + assert!( + defs.iter().any(|d| d.id == "presentation_agent"), + "presentation_agent must register when the `documents` feature is on" + ); + } + + #[cfg(not(feature = "documents"))] + #[test] + fn presentation_agent_absent_when_documents_off() { + let defs = load_builtins().expect("built-in TOML must parse"); + assert!( + !defs.iter().any(|d| d.id == "presentation_agent"), + "presentation_agent must be filtered from the registry when `documents` is off" + ); } #[test] @@ -1305,18 +1353,25 @@ mod tests { other => panic!("scheduler_agent must use Named tool scope, got {other:?}"), } - let presentation = find("presentation_agent"); - match &presentation.tools { - ToolScope::Named(names) => { - assert!(names.iter().any(|name| name == "generate_presentation")); - assert!(!names.iter().any(|name| name == "call_memory_agent")); - assert!(names.iter().any(|name| name == "web_search_tool")); + // `presentation_agent` is only registered under the `documents` feature + // (its deck tool `generate_presentation` is gated there and the agent is + // filtered from the registry in lockstep — see `builtin_enabled`), so + // skip its assertions in slim builds where it is intentionally absent. + #[cfg(feature = "documents")] + { + let presentation = find("presentation_agent"); + match &presentation.tools { + ToolScope::Named(names) => { + assert!(names.iter().any(|name| name == "generate_presentation")); + assert!(!names.iter().any(|name| name == "call_memory_agent")); + assert!(names.iter().any(|name| name == "web_search_tool")); + } + other => panic!("presentation_agent must use Named tool scope, got {other:?}"), } - other => panic!("presentation_agent must use Named tool scope, got {other:?}"), + // Memory pre-fetch is no longer eager; `omit_memory_context = false` + // still gives the deck builder the cheap per-turn recall. + assert_eq!(presentation.trigger_memory_agent, TriggerMemoryAgent::Never); } - // Memory pre-fetch is no longer eager; `omit_memory_context = false` - // still gives the deck builder the cheap per-turn recall. - assert_eq!(presentation.trigger_memory_agent, TriggerMemoryAgent::Never); let desktop = find("desktop_control_agent"); match &desktop.tools { diff --git a/src/openhuman/agentbox/mod.rs b/src/openhuman/agentbox/mod.rs index 29c786f22..9d359edae 100644 --- a/src/openhuman/agentbox/mod.rs +++ b/src/openhuman/agentbox/mod.rs @@ -7,6 +7,12 @@ //! See `docs/superpowers/specs/2026-06-12-agentbox-marketplace-integration-design.md`. pub mod env; +// The `/run` + `/jobs/{id}` HTTP surface is axum-only, so it and the +// `agentbox_router` re-export are exclusive to the `http-server` feature +// (#5048). The axum-free `ops`/`status`/`store`/`schemas`/`invoker` stay +// compiled — the AgentBox controllers + status RPC remain available in slim +// builds; only the router (merged by the gated `core::jsonrpc` router) is shed. +#[cfg(feature = "http-server")] pub mod http; pub mod invoker; pub mod ops; @@ -16,17 +22,21 @@ pub mod store; pub mod types; pub use env::{agentbox_mode_enabled, register_gmi_provider_if_present}; +#[cfg(feature = "http-server")] pub use http::router as agentbox_router; pub use schemas::{all_agentbox_controller_schemas, all_agentbox_registered_controllers}; pub use status::agentbox_status; pub use store::JobStore; pub use types::{AgentBoxProviderInfo, AgentBoxStatus}; -#[cfg(test)] +// Exercises `build_core_http_router` (axum) — gated in lockstep (#5048). +#[cfg(all(test, feature = "http-server"))] mod disabled_mode_tests; #[cfg(test)] mod env_tests; -#[cfg(test)] +// Drives the gated `agentbox::http::router` via `tower::ServiceExt` — gated in +// lockstep (#5048). +#[cfg(all(test, feature = "http-server"))] mod http_tests; #[cfg(test)] mod ops_tests; diff --git a/src/openhuman/artifacts/ops.rs b/src/openhuman/artifacts/ops.rs index 892436437..8dedc4153 100644 --- a/src/openhuman/artifacts/ops.rs +++ b/src/openhuman/artifacts/ops.rs @@ -1,12 +1,19 @@ use serde_json::{json, Value}; -use crate::openhuman::approval::{ApprovalChatContext, APPROVAL_CHAT_CONTEXT}; use crate::openhuman::config::Config; -use crate::openhuman::security::SecurityPolicy; -use crate::openhuman::tools::traits::Tool; -use crate::openhuman::tools::PresentationTool; use crate::rpc::RpcOutcome; +// Imports used only by the `documents`-gated presentation regeneration helper +// below; when the feature is off the PresentationTool is compiled out. +#[cfg(feature = "documents")] +use crate::openhuman::approval::{ApprovalChatContext, APPROVAL_CHAT_CONTEXT}; +#[cfg(feature = "documents")] +use crate::openhuman::security::SecurityPolicy; +#[cfg(feature = "documents")] +use crate::openhuman::tools::traits::Tool; +#[cfg(feature = "documents")] +use crate::openhuman::tools::PresentationTool; + use super::store; use super::types::ArtifactKind; @@ -177,6 +184,20 @@ pub async fn ai_regenerate( )); } + regenerate_presentation(config, artifact_id, thread_id, client_id).await +} + +/// Re-run the presentation producer tool against an existing artifact id. +/// Extracted so the whole `documents`-gated tool path (PresentationTool + +/// approval scope) compiles only when the feature is on; `ai_regenerate` stays +/// a thin, always-compiled RPC handler. +#[cfg(feature = "documents")] +async fn regenerate_presentation( + config: &Config, + artifact_id: &str, + thread_id: &str, + client_id: &str, +) -> Result, String> { let args = store::read_artifact_args(&config.workspace_dir, artifact_id).await?; // A fresh policy from the live config — cheap, sync, and mirrors how @@ -223,6 +244,24 @@ pub async fn ai_regenerate( } } +/// Disabled variant: with the `documents` feature off the PresentationTool is +/// compiled out (and no presentation artifacts can exist), so report the build +/// limitation rather than pretend to regenerate. +#[cfg(not(feature = "documents"))] +async fn regenerate_presentation( + _config: &Config, + artifact_id: &str, + _thread_id: &str, + _client_id: &str, +) -> Result, String> { + log::debug!( + "[artifacts] presentation regeneration rejected for id={artifact_id}: built without the `documents` feature" + ); + Err(format!( + "[artifacts] presentation regeneration is unavailable for id={artifact_id}: built without the `documents` feature" + )) +} + #[cfg(test)] #[path = "ops_tests.rs"] mod tests; diff --git a/src/openhuman/composio/ops_tests.rs b/src/openhuman/composio/ops_tests.rs index be050c62b..a85cbae19 100644 --- a/src/openhuman/composio/ops_tests.rs +++ b/src/openhuman/composio/ops_tests.rs @@ -2316,6 +2316,7 @@ fn extract_backend_returned_status_handles_mixed_case() { // `report_composio_op_error` events flood Sentry again with no test in // the composio crate to catch it. These guards make the link explicit. +#[cfg(feature = "crash-reporting")] #[test] fn composio_domain_502_is_dropped_by_before_send() { let mut event = sentry::protocol::Event::default(); @@ -2330,6 +2331,7 @@ fn composio_domain_502_is_dropped_by_before_send() { ); } +#[cfg(feature = "crash-reporting")] #[test] fn composio_transport_timeout_is_dropped_by_before_send() { let mut event = sentry::protocol::Event::default(); diff --git a/src/openhuman/credentials/sentry_scope.rs b/src/openhuman/credentials/sentry_scope.rs index 76b40d626..d800b2200 100644 --- a/src/openhuman/credentials/sentry_scope.rs +++ b/src/openhuman/credentials/sentry_scope.rs @@ -30,6 +30,10 @@ pub fn bind(id: &str) { return; } let id = trimmed.to_string(); + // Sentry-touching body gated on `crash-reporting`; the signature and the + // diagnostic log line stay compiled in both builds. `id` is still consumed + // by the `tracing::debug!` below, so no unused-variable guard is needed. + #[cfg(feature = "crash-reporting")] sentry::configure_scope(|scope| { scope.set_user(Some(sentry::User { id: Some(id.clone()), @@ -43,13 +47,16 @@ pub fn bind(id: &str) { /// background loops that survive the teardown grace window are not /// mis-attributed to the previously signed-in account. pub fn clear() { + #[cfg(feature = "crash-reporting")] sentry::configure_scope(|scope| { scope.set_user(None); }); tracing::debug!("[sentry] scope user cleared"); } -#[cfg(test)] +// All four tests use `sentry::test::with_captured_events`, so the module is +// gated on `crash-reporting` in addition to `test`. +#[cfg(all(test, feature = "crash-reporting"))] mod tests { use super::*; diff --git a/src/openhuman/inference/http/mod.rs b/src/openhuman/inference/http/mod.rs index 9984bed9b..bfaa4e765 100644 --- a/src/openhuman/inference/http/mod.rs +++ b/src/openhuman/inference/http/mod.rs @@ -18,7 +18,14 @@ /// secret encrypted at rest and scoped to the active user workspace. pub const EXTERNAL_OPENAI_COMPAT_PROVIDER: &str = "external-openai-compat"; +// The `/v1/*` axum router lives here and is exclusive to the `http-server` +// feature (#5048). CARVE-OUT: `EXTERNAL_OPENAI_COMPAT_PROVIDER` (above) and +// `types` stay UNGATED — `core::auth` consumes the provider id (and its inert +// request/response types are dep-free) in ALL builds, so only the axum +// `server`/`router` surface is gated. +#[cfg(feature = "http-server")] pub mod server; pub mod types; +#[cfg(feature = "http-server")] pub use server::router; diff --git a/src/openhuman/inference/local/service/whisper_engine/mod.rs b/src/openhuman/inference/local/service/whisper_engine/mod.rs new file mode 100644 index 000000000..3254608f2 --- /dev/null +++ b/src/openhuman/inference/local/service/whisper_engine/mod.rs @@ -0,0 +1,53 @@ +//! In-process whisper.cpp STT engine, gated behind the `inference` feature. +//! +//! This is the whisper/`cpal` dependency shed the `voice` gate deferred (see +//! the `inference` feature in the root `Cargo.toml`). Structure follows the +//! type-carve-out variant of the repo's facade + stub pattern (AGENTS.md): +//! +//! - [`types`] holds `TranscriptionResult` — an inert, dependency-free data +//! type named by always-compiled callers (the local-AI service in +//! `../speech.rs`/`../bootstrap.rs`) and by `inference::voice::streaming`. +//! It stays compiled in **both** build states, so its fields can never drift. +//! - `real` owns the actual `whisper-rs` / `WhisperContext` engine and is +//! compiled only with `--features inference`. +//! - `stub` mirrors `real`'s function + handle surface exactly with +//! disabled-error / no-op bodies, so those always-compiled callers need no +//! per-call `#[cfg]`. +//! +//! The stub signatures must match `real` exactly — the disabled build +//! (`--no-default-features --features tokenjuice-treesitter`) is the only thing +//! that catches drift, so run it after touching either side. + +mod types; +// The facade re-exports the whole engine surface, but which items a given build +// actually names depends on downstream feature gates — the voice STT factory +// (`voice`) consumes `looks_like_wav` / `transcribe_wav_bytes` / +// `loaded_model_path` etc., while the always-compiled local-AI service uses only +// a subset. Allow unused re-exports so the enabled and disabled builds keep an +// identical public surface instead of drifting on which subset they pull. +#[allow(unused_imports)] +pub use types::TranscriptionResult; + +#[cfg(feature = "inference")] +mod real; +#[cfg(feature = "inference")] +#[allow(unused_imports)] +pub use real::{ + is_loaded, load_engine, loaded_model_path, new_handle, transcribe_pcm_f32, transcribe_pcm_i16, + transcribe_wav_file, unload_engine, WhisperEngineHandle, +}; +#[cfg(feature = "inference")] +#[allow(unused_imports)] +pub(crate) use real::{looks_like_wav, transcribe_wav_bytes}; + +#[cfg(not(feature = "inference"))] +mod stub; +#[cfg(not(feature = "inference"))] +#[allow(unused_imports)] +pub use stub::{ + is_loaded, load_engine, loaded_model_path, new_handle, transcribe_pcm_f32, transcribe_pcm_i16, + transcribe_wav_file, unload_engine, WhisperEngineHandle, +}; +#[cfg(not(feature = "inference"))] +#[allow(unused_imports)] +pub(crate) use stub::{looks_like_wav, transcribe_wav_bytes}; diff --git a/src/openhuman/inference/local/service/whisper_engine.rs b/src/openhuman/inference/local/service/whisper_engine/real.rs similarity index 97% rename from src/openhuman/inference/local/service/whisper_engine.rs rename to src/openhuman/inference/local/service/whisper_engine/real.rs index 02d552a5f..1a50b3ffd 100644 --- a/src/openhuman/inference/local/service/whisper_engine.rs +++ b/src/openhuman/inference/local/service/whisper_engine/real.rs @@ -14,25 +14,14 @@ use whisper_rs::{FullParams, SamplingStrategy, WhisperContext, WhisperContextPar use crate::openhuman::util::utf8_safe_prefix_at_byte_boundary; +use super::types::TranscriptionResult; + /// Per-segment confidence threshold: reject segments with avg log-probability below this. const SEGMENT_LOGPROB_REJECT: f32 = -0.7; /// Per-segment entropy threshold: reject segments with entropy above this. const SEGMENT_ENTROPY_REJECT: f32 = 2.4; -/// Result of a transcription call, including confidence metadata. -#[derive(Debug, Clone)] -pub struct TranscriptionResult { - /// The transcribed text (may be empty if all segments were rejected). - pub text: String, - /// Average log-probability across accepted segments (higher = more confident). - /// `None` if no segments were accepted. - pub avg_logprob: Option, - /// Number of segments accepted / total segments produced by Whisper. - pub segments_accepted: usize, - pub segments_total: usize, -} - const LOG_PREFIX: &str = "[whisper_engine]"; /// Wraps a loaded `WhisperContext` for reuse across transcription calls. diff --git a/src/openhuman/inference/local/service/whisper_engine/stub.rs b/src/openhuman/inference/local/service/whisper_engine/stub.rs new file mode 100644 index 000000000..894108dcb --- /dev/null +++ b/src/openhuman/inference/local/service/whisper_engine/stub.rs @@ -0,0 +1,131 @@ +//! Disabled facade for the whisper engine — compiled when the `inference` +//! feature is OFF (`whisper-rs` and `cpal` are dropped from the build). +//! +//! Mirrors `real`'s public function + handle surface exactly so the +//! always-compiled callers (`../speech.rs`, `../bootstrap.rs`, +//! `inference::voice::streaming`, and the voice STT factory when `voice` is on) +//! need no per-call `#[cfg]`. Every transcription path returns the disabled +//! error; loading is a no-op and nothing is ever "loaded". + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use parking_lot::Mutex; + +use super::types::TranscriptionResult; + +const DISABLED: &str = "in-process whisper STT is disabled: this build was compiled without the `inference` feature (rebuild with `--features inference`)"; + +/// Whisper-free mirror of the real engine handle. Always empty — with the +/// engine compiled out nothing loads — but keeps the same +/// `Arc>>` shape so `LocalAiService.whisper` still constructs. +pub type WhisperEngineHandle = Arc>>; + +/// Create a new (permanently empty) engine handle. +pub fn new_handle() -> WhisperEngineHandle { + Arc::new(Mutex::new(None)) +} + +/// No-op: there is no engine to load. Returns the disabled error so callers +/// log/fall back exactly as they would on a real load failure. +pub fn load_engine( + _handle: &WhisperEngineHandle, + _model_path: &Path, + _has_gpu: bool, + _gpu_description: Option<&str>, +) -> Result<(), String> { + log::debug!("[whisper_engine::stub] load_engine no-op — {DISABLED}"); + Err(DISABLED.to_string()) +} + +/// No-op: nothing is ever loaded. +pub fn unload_engine(_handle: &WhisperEngineHandle) {} + +/// Always `false` — the in-process engine is compiled out. +pub fn is_loaded(_handle: &WhisperEngineHandle) -> bool { + false +} + +/// Always `None` — no model can be loaded. +pub fn loaded_model_path(_handle: &WhisperEngineHandle) -> Option { + None +} + +pub fn transcribe_pcm_f32( + _handle: &WhisperEngineHandle, + _audio_f32: &[f32], + _language: Option<&str>, + _initial_prompt: Option<&str>, +) -> Result { + log::debug!("[whisper] transcribe_pcm_f32 unavailable: built without the `inference` feature"); + Err(DISABLED.to_string()) +} + +pub fn transcribe_pcm_i16( + _handle: &WhisperEngineHandle, + _audio_i16: &[i16], + _language: Option<&str>, + _initial_prompt: Option<&str>, +) -> Result { + log::debug!("[whisper] transcribe_pcm_i16 unavailable: built without the `inference` feature"); + Err(DISABLED.to_string()) +} + +pub fn transcribe_wav_file( + _handle: &WhisperEngineHandle, + _wav_path: &Path, + _language: Option<&str>, + _initial_prompt: Option<&str>, +) -> Result { + log::debug!("[whisper] transcribe_wav_file unavailable: built without the `inference` feature"); + Err(DISABLED.to_string()) +} + +/// Cheap RIFF/WAVE header sniff — dependency-free, so the stub keeps the real +/// behaviour rather than a misleading constant (matches `real::looks_like_wav`). +pub(crate) fn looks_like_wav(bytes: &[u8]) -> bool { + bytes.len() >= 12 && &bytes[0..4] == b"RIFF" && &bytes[8..12] == b"WAVE" +} + +pub(crate) fn transcribe_wav_bytes( + _handle: &WhisperEngineHandle, + _wav_bytes: &[u8], + _language: Option<&str>, + _initial_prompt: Option<&str>, +) -> Result { + log::debug!( + "[whisper] transcribe_wav_bytes unavailable: built without the `inference` feature" + ); + Err(DISABLED.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn stub_handle_never_loads_and_transcribe_errors() { + let h = new_handle(); + assert!(!is_loaded(&h)); + assert!(loaded_model_path(&h).is_none()); + let err = transcribe_pcm_f32(&h, &[0.0; 16], None, None).unwrap_err(); + assert!( + err.contains("inference"), + "disabled error names the gate: {err}" + ); + // i16 + wav paths error the same way. + assert!(transcribe_pcm_i16(&h, &[0i16; 16], None, None).is_err()); + assert!(transcribe_wav_bytes(&h, b"not a wav", None, None).is_err()); + } + + #[test] + fn stub_looks_like_wav_matches_real_behaviour() { + let mut wav = Vec::new(); + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&[0u8; 4]); + wav.extend_from_slice(b"WAVE"); + assert!(looks_like_wav(&wav)); + assert!(!looks_like_wav(b"OggS....")); + assert!(!looks_like_wav(b"RIFF")); + } +} diff --git a/src/openhuman/inference/local/service/whisper_engine/types.rs b/src/openhuman/inference/local/service/whisper_engine/types.rs new file mode 100644 index 000000000..550649775 --- /dev/null +++ b/src/openhuman/inference/local/service/whisper_engine/types.rs @@ -0,0 +1,18 @@ +//! Inert transcription result type — dependency-free and compiled in both +//! build states. The `inference` feature gates only the engine (`real`), not +//! this data type, so always-compiled callers (`../speech.rs`, +//! `inference::voice::streaming`) name one stable definition regardless of the +//! feature. See the module docs in `mod.rs` for the carve-out rationale. + +/// Result of a transcription call, including confidence metadata. +#[derive(Debug, Clone)] +pub struct TranscriptionResult { + /// The transcribed text (may be empty if all segments were rejected). + pub text: String, + /// Average log-probability across accepted segments (higher = more confident). + /// `None` if no segments were accepted. + pub avg_logprob: Option, + /// Number of segments accepted / total segments produced by Whisper. + pub segments_accepted: usize, + pub segments_total: usize, +} diff --git a/src/openhuman/inference/mod.rs b/src/openhuman/inference/mod.rs index b0c4bfb29..019050cd2 100644 --- a/src/openhuman/inference/mod.rs +++ b/src/openhuman/inference/mod.rs @@ -12,6 +12,14 @@ //! The RPC surface is `inference.*`; old `local_ai_*` RPC names are resolved //! by the legacy alias layer for backwards compatibility. +/// `true` when the crate was compiled with the `inference` feature (the +/// default), i.e. the in-process whisper.cpp STT engine and the `cpal` audio +/// probe are linked. Lets tests and callers distinguish a slim/headless build +/// from the desktop build without naming gated symbols. When `false`, +/// `whisper-rs` and `cpal` are dropped from the dependency graph (verify with +/// `cargo tree -i whisper-rs` / `cargo tree -i cpal`). +pub const INFERENCE_COMPILED_IN: bool = cfg!(feature = "inference"); + pub mod device; pub mod http; pub mod local; diff --git a/src/openhuman/inference/voice/mod.rs b/src/openhuman/inference/voice/mod.rs index b43640864..4b35ab314 100644 --- a/src/openhuman/inference/voice/mod.rs +++ b/src/openhuman/inference/voice/mod.rs @@ -9,4 +9,9 @@ pub mod hallucination; pub mod local_speech; pub mod local_transcribe; pub mod postprocess; +// The dictation WebSocket handler (`handle_dictation_ws`) is the module's whole +// public surface and axum-only, and its sole caller is the gated core HTTP +// router (`core::jsonrpc::dictation_ws_handler`). The module is therefore +// exclusive to the `http-server` feature (#5048) — nothing else references it. +#[cfg(feature = "http-server")] pub mod streaming; diff --git a/src/openhuman/mcp_server/local.rs b/src/openhuman/mcp_server/local.rs index e1acbc7d8..af63f17e3 100644 --- a/src/openhuman/mcp_server/local.rs +++ b/src/openhuman/mcp_server/local.rs @@ -14,9 +14,17 @@ use std::net::SocketAddr; +// The in-process HTTP MCP server is axum-only, so everything that starts it is +// gated with `http-server` (#5048). `LocalMcpEndpoint` (an inert addr+token +// record) and a disabled-error `ensure_local_http` stay compiled so the +// always-on Claude-Code driver keeps a stable call surface — with the feature +// off, `ensure_local_http` returns a built-without-http-server error. +#[cfg(feature = "http-server")] use tokio::sync::Mutex; +#[cfg(feature = "http-server")] use uuid::Uuid; +#[cfg(feature = "http-server")] use super::http::{run_http_reporting, HttpServerConfig}; /// Endpoint of the running in-process MCP server: its loopback address and the @@ -27,6 +35,7 @@ pub struct LocalMcpEndpoint { pub token: String, } +#[cfg(feature = "http-server")] struct RunningServer { endpoint: LocalMcpEndpoint, /// Liveness handle. If the server task has exited (bind drop, fatal error), @@ -35,9 +44,11 @@ struct RunningServer { handle: tokio::task::JoinHandle<()>, } +#[cfg(feature = "http-server")] static LOCAL_SERVER: Mutex> = Mutex::const_new(None); /// 256-bit random bearer token (two v4 UUIDs, hex). Loopback-only, per process. +#[cfg(feature = "http-server")] fn mint_token() -> String { format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()) } @@ -47,6 +58,7 @@ fn mint_token() -> String { /// and reused across turns; if the previous instance has exited, it is /// transparently restarted (and a fresh token minted) so callers never receive /// a stale, dead URL. +#[cfg(feature = "http-server")] pub async fn ensure_local_http() -> anyhow::Result { let mut guard = LOCAL_SERVER.lock().await; @@ -82,7 +94,38 @@ pub async fn ensure_local_http() -> anyhow::Result { Ok(endpoint) } -#[cfg(test)] +/// Disabled build: the in-process HTTP MCP server is axum-only and needs the +/// `http-server` feature (#5048). Returns an error so the always-on Claude-Code +/// driver falls back gracefully instead of pointing at a server that was never +/// started. Keeps `ensure_local_http` resolvable under `mcp` in both builds. +#[cfg(not(feature = "http-server"))] +pub async fn ensure_local_http() -> anyhow::Result { + Err(anyhow::anyhow!( + "in-process MCP HTTP server unavailable: built without the http-server feature" + )) +} + +// The real-server tests below are `http-server`-gated; this pins the slim +// build's disabled fallback so both feature branches are covered by the matrix. +#[cfg(all(test, not(feature = "http-server")))] +mod disabled_tests { + use super::*; + + #[tokio::test] + async fn ensure_local_http_reports_unavailable_without_http_server() { + let err = ensure_local_http() + .await + .expect_err("slim build without `http-server` must not start a server"); + assert!( + err.to_string().contains("http-server feature"), + "error must name the missing feature, got: {err}" + ); + } +} + +// Every test here starts the real HTTP server (`ensure_local_http`) or mints a +// token, both gated, so the module gates in lockstep (#5048). +#[cfg(all(test, feature = "http-server"))] mod tests { use super::*; diff --git a/src/openhuman/mcp_server/mod.rs b/src/openhuman/mcp_server/mod.rs index ed2ae4972..944f63b17 100644 --- a/src/openhuman/mcp_server/mod.rs +++ b/src/openhuman/mcp_server/mod.rs @@ -22,7 +22,12 @@ //! `Value` record consumed by the always-compiled `tool_registry` — is the //! same real type in both builds and cannot drift. -#[cfg(feature = "mcp")] +// The Streamable-HTTP + SSE transport is axum-only, so it needs BOTH `mcp` and +// `http-server` (#5048). The stdio transport (below) works under `mcp` alone; +// `local`/`stdio` gate their own HTTP-serve paths so `openhuman mcp` (stdio) +// and the Claude-Code in-process MCP bridge still degrade gracefully when +// `http-server` is off. +#[cfg(all(feature = "mcp", feature = "http-server"))] mod http; #[cfg(feature = "mcp")] mod local; @@ -43,7 +48,7 @@ mod write_dispatch; // so `McpToolSpec` survives the gate (see the module note above). mod tools; -#[cfg(feature = "mcp")] +#[cfg(all(feature = "mcp", feature = "http-server"))] pub use http::{run_http, run_http_reporting, HttpServerConfig}; #[cfg(feature = "mcp")] pub use local::{ensure_local_http, LocalMcpEndpoint}; diff --git a/src/openhuman/mcp_server/stdio.rs b/src/openhuman/mcp_server/stdio.rs index 73f3cee14..148deb271 100644 --- a/src/openhuman/mcp_server/stdio.rs +++ b/src/openhuman/mcp_server/stdio.rs @@ -1,9 +1,14 @@ use anyhow::{bail, Result}; +// `SocketAddr` + the `http` transport are only reached by the `--transport http` +// arm, which is axum-only and gated with `http-server` (#5048). The stdio arm +// (the default, used by Claude Desktop / Cursor) works under `mcp` alone. +#[cfg(feature = "http-server")] use std::net::SocketAddr; use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader}; use crate::core::logging::CliLogDefault; +#[cfg(feature = "http-server")] use super::http::{run_http, HttpServerConfig}; use super::{protocol, session::McpSession}; @@ -84,17 +89,28 @@ pub fn run_stdio_from_cli(args: &[String]) -> Result<()> { rt.block_on(async { run_stdio(tokio::io::stdin(), tokio::io::stdout()).await })?; } McpTransport::Http => { - let bind_addr: SocketAddr = format!("{bind_host}:{port}").parse().map_err(|err| { - anyhow::anyhow!("invalid bind address `{bind_host}:{port}`: {err}") - })?; - log::debug!( - "[mcp_server] starting HTTP/SSE MCP server bind={bind_addr} auth={}", - auth_token.is_some() - ); - rt.block_on(run_http(HttpServerConfig { - bind_addr, - auth_token, - }))?; + #[cfg(feature = "http-server")] + { + let bind_addr: SocketAddr = + format!("{bind_host}:{port}").parse().map_err(|err| { + anyhow::anyhow!("invalid bind address `{bind_host}:{port}`: {err}") + })?; + log::debug!( + "[mcp_server] starting HTTP/SSE MCP server bind={bind_addr} auth={}", + auth_token.is_some() + ); + rt.block_on(run_http(HttpServerConfig { + bind_addr, + auth_token, + }))?; + } + // Built without the axum transport (#5048): the stdio path above + // still works; `--transport http` reports the build fact. + #[cfg(not(feature = "http-server"))] + { + let _ = (&bind_host, port, &auth_token); + bail!("mcp --transport http unavailable: built without the http-server feature"); + } } } Ok(()) diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 72ac30aef..4fcfb0551 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -57,6 +57,12 @@ pub mod flows; pub mod harness_init; pub mod health; pub mod heartbeat; +// The whole http_host domain is an axum static-directory server, so it is +// exclusive to the `http-server` feature (#5048). Its only outside reference is +// the controller-registration push in `core::all`, itself gated in lockstep, so +// no stub facade is needed — a slim build simply omits the `http_host.*` RPC +// surface (unknown-method over `/rpc`, absent from `/schema`). +#[cfg(feature = "http-server")] pub mod http_host; #[cfg(feature = "media")] pub mod image; diff --git a/src/openhuman/text_input/mod.rs b/src/openhuman/text_input/mod.rs index c35c6cd63..93d560c4b 100644 --- a/src/openhuman/text_input/mod.rs +++ b/src/openhuman/text_input/mod.rs @@ -4,7 +4,28 @@ //! Thin orchestration layer consumed by autocomplete, voice control, and other //! text-aware features. All platform work delegates to `accessibility::*`. +// `openhuman text-input run` stands up an axum JSON-RPC dev server, so the +// whole CLI is exclusive to the `http-server` feature (#5048). When it is off, +// an inline stub keeps `text_input::cli::run_text_input_command` resolvable for +// the always-compiled dispatch arm in `core::cli` (mcp precedent) and returns a +// built-without-the-feature error. The axum-free `ops` (read/insert/ghost, +// called from `voice::server`) and controllers stay compiled either way. +#[cfg(feature = "http-server")] pub(crate) mod cli; +#[cfg(not(feature = "http-server"))] +pub(crate) mod cli { + //! Disabled `text-input` CLI facade — the real server needs `http-server`. + use anyhow::Result; + + /// Stub for [`super::cli::run_text_input_command`] when built without the + /// `http-server` feature. Mirrors the real signature so `core::cli`'s + /// dispatch arm compiles unchanged. + pub(crate) fn run_text_input_command(_args: &[String]) -> Result<()> { + Err(anyhow::anyhow!( + "text-input server unavailable: built without the http-server feature" + )) + } +} pub mod ops; mod schemas; mod types; diff --git a/src/openhuman/tools/impl/mod.rs b/src/openhuman/tools/impl/mod.rs index fa92ff96c..c4bdb4894 100644 --- a/src/openhuman/tools/impl/mod.rs +++ b/src/openhuman/tools/impl/mod.rs @@ -5,17 +5,21 @@ pub mod browser; // (not error-degraded) when off. #[cfg(feature = "desktop-automation")] pub mod computer; +#[cfg(feature = "documents")] pub mod document; pub mod filesystem; pub mod network; +#[cfg(feature = "documents")] pub mod presentation; pub mod system; pub use browser::*; #[cfg(feature = "desktop-automation")] pub use computer::*; +#[cfg(feature = "documents")] pub use document::DocumentTool; pub use filesystem::*; pub use network::*; +#[cfg(feature = "documents")] pub use presentation::PresentationTool; pub use system::*; diff --git a/src/openhuman/tools/ops.rs b/src/openhuman/tools/ops.rs index 1b9f89430..a892e9031 100644 --- a/src/openhuman/tools/ops.rs +++ b/src/openhuman/tools/ops.rs @@ -783,6 +783,7 @@ pub fn all_tools_with_runtime( // backed) as of the #2780-follow-up rust-engine refactor — no // managed Python venv, no first-call install latency. Always // registered. + #[cfg(feature = "documents")] tools.push(Box::new(PresentationTool::new( root_config.workspace_dir.clone(), security.clone(), @@ -792,6 +793,7 @@ pub fn all_tools_with_runtime( // (docx-rs backed) — no managed runtime, no subprocess — emitting a // real `.docx` through the same byte-agnostic artifact pipeline as // the presentation tool. Always registered; same constructor shape. + #[cfg(feature = "documents")] tools.push(Box::new(DocumentTool::new( root_config.workspace_dir.clone(), security.clone(), diff --git a/src/openhuman/tools/ops_tests.rs b/src/openhuman/tools/ops_tests.rs index 414830009..33f848529 100644 --- a/src/openhuman/tools/ops_tests.rs +++ b/src/openhuman/tools/ops_tests.rs @@ -294,6 +294,76 @@ fn media_tools_absent_when_feature_off() { ); } +// Compile-time `documents` feature gate (#5048). The office-document agent +// tools (`generate_presentation`, `generate_document`) are present only when +// the `documents` feature is compiled in — leaf gate, no stub facade, so the +// disabled build must drop both from the tool list entirely. +#[cfg(feature = "documents")] +#[test] +fn document_tools_registered_when_feature_on() { + let tmp = TempDir::new().unwrap(); + let security = Arc::new(SecurityPolicy::default()); + let mem = test_memory(&tmp); + let browser = BrowserConfig { + enabled: false, + ..BrowserConfig::default() + }; + let http = crate::openhuman::config::HttpRequestConfig::default(); + let cfg = test_config(&tmp); + let tools = all_tools( + Arc::new(Config::default()), + &security, + AuditLogger::disabled(), + mem, + &browser, + &http, + tmp.path(), + &HashMap::new(), + &cfg, + ); + let names = tool_names(&tools); + assert!( + names.iter().any(|n| n == "generate_presentation"), + "generate_presentation must register with `documents` on; got: {names:?}" + ); + assert!( + names.iter().any(|n| n == "generate_document"), + "generate_document must register with `documents` on; got: {names:?}" + ); +} + +#[cfg(not(feature = "documents"))] +#[test] +fn document_tools_absent_when_feature_off() { + let tmp = TempDir::new().unwrap(); + let security = Arc::new(SecurityPolicy::default()); + let mem = test_memory(&tmp); + let browser = BrowserConfig { + enabled: false, + ..BrowserConfig::default() + }; + let http = crate::openhuman::config::HttpRequestConfig::default(); + let cfg = test_config(&tmp); + let tools = all_tools( + Arc::new(Config::default()), + &security, + AuditLogger::disabled(), + mem, + &browser, + &http, + tmp.path(), + &HashMap::new(), + &cfg, + ); + let names = tool_names(&tools); + assert!( + !names + .iter() + .any(|n| n == "generate_presentation" || n == "generate_document"), + "no document tools may register when the `documents` feature is off; got: {names:?}" + ); +} + #[test] fn all_tools_registers_gitbooks_when_enabled() { let tmp = TempDir::new().unwrap(); diff --git a/src/openhuman/voice/mod.rs b/src/openhuman/voice/mod.rs index 50b3508d5..7282ee0fe 100644 --- a/src/openhuman/voice/mod.rs +++ b/src/openhuman/voice/mod.rs @@ -72,7 +72,11 @@ pub use crate::openhuman::inference::voice::local_speech; pub use crate::openhuman::inference::voice::local_transcribe; #[cfg(feature = "voice")] pub use crate::openhuman::inference::voice::postprocess; -#[cfg(feature = "voice")] +// `streaming` (the dictation WebSocket handler) is axum-only, so it is compiled +// only when BOTH `voice` and `http-server` are on (#5048). With `http-server` +// off, its sole caller (the gated core HTTP router) is absent too, so nothing +// needs `voice::streaming`. +#[cfg(all(feature = "voice", feature = "http-server"))] pub use crate::openhuman::inference::voice::streaming; #[cfg(feature = "voice")] diff --git a/src/openhuman/voice/stub.rs b/src/openhuman/voice/stub.rs index d9ee2d2e2..91928449c 100644 --- a/src/openhuman/voice/stub.rs +++ b/src/openhuman/voice/stub.rs @@ -187,6 +187,11 @@ pub mod always_on { // streaming::handle_dictation_ws (re-exported from inference::voice in real) // --------------------------------------------------------------------------- +// axum-only, and its sole caller (`core::jsonrpc::dictation_ws_handler`) is +// gated the same way, so the stub's dictation-WS surface is exclusive to the +// `http-server` feature too (#5048): voice-OFF + http-server-OFF needs no +// `voice::streaming` at all. +#[cfg(feature = "http-server")] pub mod streaming { use std::sync::Arc;