mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(core): shed embedded deps behind inference/documents/crash-reporting/http-server gates (#5048) (#5068)
This commit is contained in:
+117
-17
@@ -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"]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
@@ -526,6 +559,7 @@ fn escape_html(s: &str) -> String {
|
||||
}
|
||||
|
||||
/// Returns the HTML for an error page.
|
||||
#[cfg(feature = "http-server")]
|
||||
fn error_html(message: &str) -> String {
|
||||
let escaped_message = escape_html(message);
|
||||
format!(
|
||||
@@ -556,6 +590,7 @@ fn error_html(message: &str) -> String {
|
||||
}
|
||||
|
||||
/// Query params for the MCP browser-OAuth callback (`/oauth/mcp/callback`).
|
||||
#[cfg(feature = "http-server")]
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
struct OAuthMcpCallbackQuery {
|
||||
code: Option<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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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::*;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
@@ -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};
|
||||
+2
-13
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user