feat(core): shed embedded deps behind inference/documents/crash-reporting/http-server gates (#5048) (#5068)

This commit is contained in:
YellowSnnowmann
2026-07-22 05:02:07 +03:00
committed by GitHub
parent 31fca029d2
commit ddeee5b5d6
44 changed files with 1402 additions and 123 deletions
+117 -17
View File
@@ -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 "<explicit list without
# http-server>"`, 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 "<explicit list without inference>"`, 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 "<explicit list without
# documents>"`, 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
# "<explicit list without crash-reporting>"`, 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"]
+7
View File
@@ -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:<port>/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 }
+16
View File
@@ -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:<port>/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:<port>/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();
+2
View File
@@ -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 })?;
+4 -1
View File
@@ -355,7 +355,10 @@ fn build_registered_controllers() -> Vec<GroupedController> {
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,
+82
View File
@@ -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`
+32
View File
@@ -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<String> = OnceLock::new();
@@ -85,6 +99,7 @@ static RPC_TOKEN: OnceLock<String> = 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<String> {
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();
+20
View File
@@ -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<String> = 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 })
+54
View File
@@ -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:<port>/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);
}
}
+68 -3
View File
@@ -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<Value, String>`.
#[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<AppState>, Json(req): Json<RpcRequest>) -> 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#"<!DOCTYPE html>
@@ -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('&', "&amp;")
.replace('<', "&lt;")
@@ -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<String>,
@@ -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<OAuthMcpCallbackQuery>,
) -> 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 (`<img>`, 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<TelegramAuthQuery>,
@@ -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<DesktopAuthQuery>,
@@ -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<DictationQuery>,
@@ -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 <token>` 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<AppState>) -> impl IntoResponse {
(StatusCode::OK, Json(build_http_schema_dump())).into_response()
}
@@ -1405,6 +1457,7 @@ async fn schema_handler(State(_state): State<AppState>) -> 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<EventsQuery>,
@@ -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, std::convert::Infallible>(
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<HttpMethodSchema> = 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;
+36 -3
View File
@@ -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#"<script>alert("x&y'z")</script>"#;
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.
+107
View File
@@ -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<Vec<(Regex, &'static str)>> = Lazy::new(|| {
vec![
// Matches "Bearer <token>" and redacts the token.
(Regex::new(r"(?i)(bearer\s+)\S+").unwrap(), "${1}[REDACTED]"),
// Matches "api-key: <key>" or "api_key=<key>" and redacts the key.
(
Regex::new(r"(?i)(api[_-]?key[=:\s]+)\S+").unwrap(),
"${1}[REDACTED]",
),
// \b anchor prevents matching `cancellation_token=` etc.
(
Regex::new(r"(?i)\b(token[=:\s]+)\S+").unwrap(),
"${1}[REDACTED]",
),
// Anthropic keys (sk-ant-api03-...) contain hyphens the generic
// sk- pattern below won't match.
(
Regex::new(r"sk-ant-[A-Za-z0-9\-_]{16,}").unwrap(),
"[REDACTED]",
),
// OpenAI admin keys (sk-admin-...).
(
Regex::new(r"sk-admin-[A-Za-z0-9\-_]{12,}").unwrap(),
"[REDACTED]",
),
// OpenAI project-scoped and org-scoped keys (sk-proj-... / sk-org-...).
(
Regex::new(r"sk-(?:proj|org)-[A-Za-z0-9\-_]{12,}").unwrap(),
"[REDACTED]",
),
// Generic catch-all for any sk- format not covered above. 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);
}
}
+13
View File
@@ -472,6 +472,7 @@ fn build_env_filter(verbose: bool, default_scope: CliLogDefault) -> tracing_subs
})
}
#[cfg(feature = "crash-reporting")]
fn sentry_tracing_layer<S>() -> impl Layer<S>
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<S>() -> impl Layer<S>
where
S: tracing::Subscriber + for<'a> LookupSpan<'a>,
{
tracing_subscriber::layer::Identity::new()
}
#[cfg(test)]
mod tests {
use super::*;
+5
View File
@@ -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;
+142
View File
@@ -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<String, String> =
@@ -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.
+55
View File
@@ -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<tokio::sync::oneshot::Sender<EmbeddedReadySignal>>,
@@ -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<tokio::sync::oneshot::Sender<EmbeddedReadySignal>>,
shutdown_token: Option<CancellationToken>,
) -> anyhow::Result<()> {
// --- Host / port resolution ---
let (resolved_port, port_source) = match self.port {
Some(p) => (p, "builder port"),
+17
View File
@@ -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;
+6 -2
View File
@@ -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;
+31 -1
View File
@@ -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: "<hex>" } })` 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::<AuthedConnection>().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<bool>,
}
#[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<String>,
}
#[cfg(feature = "http-server")]
#[derive(Debug, Deserialize)]
struct ChatCancelPayload {
thread_id: String,
@@ -379,6 +398,7 @@ struct ChatCancelPayload {
request_id: Option<String>,
}
#[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<String> {
// 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<String> {
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};
+13 -47
View File
@@ -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<Vec<(Regex, &'static str)>> = Lazy::new(|| {
vec![
// Matches "Bearer <token>" and redacts the token.
(Regex::new(r"(?i)(bearer\s+)\S+").unwrap(), "${1}[REDACTED]"),
// Matches "api-key: <key>" or "api_key=<key>" and redacts the key.
(
Regex::new(r"(?i)(api[_-]?key[=:\s]+)\S+").unwrap(),
"${1}[REDACTED]",
),
// \b anchor prevents matching `cancellation_token=` etc.
(
Regex::new(r"(?i)\b(token[=:\s]+)\S+").unwrap(),
"${1}[REDACTED]",
),
// Anthropic keys (sk-ant-api03-...) contain hyphens the generic
// sk- pattern below won't match.
(
Regex::new(r"sk-ant-[A-Za-z0-9\-_]{16,}").unwrap(),
"[REDACTED]",
),
// OpenAI admin keys (sk-admin-...).
(
Regex::new(r"sk-admin-[A-Za-z0-9\-_]{12,}").unwrap(),
"[REDACTED]",
),
// OpenAI project-scoped and org-scoped keys (sk-proj-... / sk-org-...).
(
Regex::new(r"sk-(?:proj|org)-[A-Za-z0-9\-_]{12,}").unwrap(),
"[REDACTED]",
),
// Generic catch-all for any sk- format not covered above.
(Regex::new(r"sk-[a-zA-Z0-9]{20,}").unwrap(), "[REDACTED]"),
]
});
/// Replaces patterns that look like secrets with `[REDACTED]`.
/// 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::*;
+17 -2
View File
@@ -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
+11
View File
@@ -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<String, String> {
/// 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<u8>) -> Result<String, String> {
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<u8>) -> Result<String, String> {
}
}
/// 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<u8>) -> Result<String, String> {
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
+67 -12
View File
@@ -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<Vec<AgentDefinition>> {
let defs: Vec<AgentDefinition> = BUILTINS.iter().map(parse_builtin).collect::<Result<_>>()?;
let defs: Vec<AgentDefinition> = BUILTINS
.iter()
.filter(|b| builtin_enabled(b))
.map(parse_builtin)
.collect::<Result<_>>()?;
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 {
+12 -2
View File
@@ -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;
+43 -4
View File
@@ -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<RpcOutcome<Value>, 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<RpcOutcome<Value>, 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;
+2
View File
@@ -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();
+8 -1
View File
@@ -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::*;
+7
View File
@@ -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;
@@ -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};
@@ -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<f32>,
/// 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.
@@ -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<Mutex<Option<_>>>` shape so `LocalAiService.whisper` still constructs.
pub type WhisperEngineHandle = Arc<Mutex<Option<()>>>;
/// 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<PathBuf> {
None
}
pub fn transcribe_pcm_f32(
_handle: &WhisperEngineHandle,
_audio_f32: &[f32],
_language: Option<&str>,
_initial_prompt: Option<&str>,
) -> Result<TranscriptionResult, String> {
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<TranscriptionResult, String> {
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<TranscriptionResult, String> {
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<TranscriptionResult, String> {
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"));
}
}
@@ -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<f32>,
/// Number of segments accepted / total segments produced by Whisper.
pub segments_accepted: usize,
pub segments_total: usize,
}
+8
View File
@@ -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;
+5
View File
@@ -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;
+44 -1
View File
@@ -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<Option<RunningServer>> = 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<LocalMcpEndpoint> {
let mut guard = LOCAL_SERVER.lock().await;
@@ -82,7 +94,38 @@ pub async fn ensure_local_http() -> anyhow::Result<LocalMcpEndpoint> {
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<LocalMcpEndpoint> {
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::*;
+7 -2
View File
@@ -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};
+27 -11
View File
@@ -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(())
+6
View File
@@ -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;
+21
View File
@@ -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;
+4
View File
@@ -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::*;
+2
View File
@@ -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(),
+70
View File
@@ -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();
+5 -1
View File
@@ -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")]
+5
View File
@@ -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;