Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ JWT_TOKEN=
OPENHUMAN_CORE_PORT=7788
# [optional] Default: http://127.0.0.1:7788/rpc
OPENHUMAN_CORE_RPC_URL=http://127.0.0.1:7788/rpc
# [optional] Pre-seed the core RPC bearer token (Tauri sets this automatically;
# for standalone CLI use only)
# OPENHUMAN_CORE_TOKEN=
# [optional] Run mode: child (default, spawns sidecar) | inprocess
OPENHUMAN_CORE_RUN_MODE=child
# [optional] Override path to openhuman core binary (leave blank for auto-detection)
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion app/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions app/src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ tokio-tungstenite = { version = "0.24", default-features = false, features = ["c
futures-util = { version = "0.3", default-features = false, features = ["sink", "std"] }

reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
rand = "0.9"
hex = "0.4"
# Tauri's vendored dev-server proxy (see `vendor/tauri-cef/.../protocol/tauri.rs`)
# builds a reqwest 0.13 client that requires a process-wide rustls
# `CryptoProvider`. Without one, `ClientBuilder::build()` panics with
Expand Down
1 change: 1 addition & 0 deletions app/src-tauri/permissions/allow-core-process.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ description = "Core RPC URL, sidecar restart, dictation hotkey, webview-account,
[permission.commands]
allow = [
"core_rpc_url",
"core_rpc_token",
"restart_core_process",
"service_install_direct",
"service_start_direct",
Expand Down
46 changes: 46 additions & 0 deletions app/src-tauri/src/core_process.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
use std::io::IsTerminal;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::LazyLock;

use parking_lot::RwLock;
use tokio::net::TcpStream;
use tokio::process::{Child, Command};
use tokio::sync::Mutex;
Expand Down Expand Up @@ -46,6 +48,24 @@ pub enum CoreRunMode {
ChildProcess,
}

/// Generate a 256-bit cryptographically-random bearer token as a hex string.
///
/// Uses the same encoding as `openhuman_core::core::auth::generate_token`
/// (`hex::encode`) so the token format never silently diverges between the
/// Tauri-side generator and the core-side validator.
pub fn generate_rpc_token() -> String {
use rand::RngCore as _;
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
hex::encode(bytes)
}

static CURRENT_RPC_TOKEN: LazyLock<RwLock<Option<String>>> = LazyLock::new(|| RwLock::new(None));

pub fn current_rpc_token() -> Option<String> {
CURRENT_RPC_TOKEN.read().clone()
}

#[derive(Clone)]
pub struct CoreProcessHandle {
child: Arc<Mutex<Option<Child>>>,
Expand All @@ -56,10 +76,20 @@ pub struct CoreProcessHandle {
/// Override path set by the auto-updater after staging a new binary.
core_bin_override: Arc<Mutex<Option<PathBuf>>>,
run_mode: CoreRunMode,
/// Bearer token passed to the core via `OPENHUMAN_CORE_TOKEN` and returned
/// to the frontend so every RPC request can include `Authorization: Bearer`.
rpc_token: Arc<String>,
}

impl CoreProcessHandle {
pub fn new(port: u16, core_bin: Option<PathBuf>, run_mode: CoreRunMode) -> Self {
let rpc_token = generate_rpc_token();
// CURRENT_RPC_TOKEN is intentionally NOT set here. It is published by
// ensure_running() only after the child process that received
// OPENHUMAN_CORE_TOKEN has been successfully spawned. Setting it here
// would advertise a token that the running core (which may be a stale
// process the handle did not spawn) has never seen, causing 401s on
// every subsequent authenticated call.
Self {
child: Arc::new(Mutex::new(None)),
task: Arc::new(Mutex::new(None)),
Expand All @@ -68,9 +98,15 @@ impl CoreProcessHandle {
core_bin,
core_bin_override: Arc::new(Mutex::new(None)),
run_mode,
rpc_token: Arc::new(rpc_token),
}
}

/// The bearer token the core process uses to authenticate inbound RPC requests.
pub fn rpc_token(&self) -> &str {
&self.rpc_token
}

pub fn rpc_url(&self) -> String {
format!("http://127.0.0.1:{}/rpc", self.port)
}
Expand Down Expand Up @@ -158,10 +194,15 @@ impl CoreProcessHandle {
};
apply_core_color_env(&mut cmd);
apply_core_no_window(&mut cmd);
cmd.env("OPENHUMAN_CORE_TOKEN", self.rpc_token.as_str());
let child = cmd
.spawn()
.map_err(|e| format!("failed to spawn core process: {e}"))?;
*guard = Some(child);
// Publish only after the child that holds OPENHUMAN_CORE_TOKEN
// has been spawned successfully.
*CURRENT_RPC_TOKEN.write() = Some(self.rpc_token.to_string());
log::debug!("[auth] CURRENT_RPC_TOKEN set after in-process spawn");
}
}
CoreRunMode::ChildProcess => {
Expand Down Expand Up @@ -197,11 +238,16 @@ impl CoreProcessHandle {

apply_core_color_env(&mut cmd);
apply_core_no_window(&mut cmd);
cmd.env("OPENHUMAN_CORE_TOKEN", self.rpc_token.as_str());
let child = cmd
.spawn()
.map_err(|e| format!("failed to spawn core process: {e}"))?;

*guard = Some(child);
// Publish only after the child that holds OPENHUMAN_CORE_TOKEN
// has been spawned successfully.
*CURRENT_RPC_TOKEN.write() = Some(self.rpc_token.to_string());
log::debug!("[auth] CURRENT_RPC_TOKEN set after child process spawn");
}
}
}
Expand Down
91 changes: 89 additions & 2 deletions app/src-tauri/src/core_process_tests.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
//! Sibling tests extracted from core_process.rs — see PR #835.

use super::{
default_core_bin, default_core_port, default_core_run_mode, same_executable_path,
CoreProcessHandle, CoreRunMode,
current_rpc_token, default_core_bin, default_core_port, default_core_run_mode,
generate_rpc_token, same_executable_path, CoreProcessHandle, CoreRunMode,
};
use std::io::Write;
use std::path::PathBuf;
Expand Down Expand Up @@ -226,6 +226,93 @@ fn ensure_running_returns_ok_when_rpc_port_already_open() {
);
}

// ---------------------------------------------------------------------------
// Token generation tests
// ---------------------------------------------------------------------------

/// `generate_rpc_token` must produce a 64-character lowercase hex string
/// (32 bytes × 2 hex digits = 64 chars), matching the format expected by the
/// core's auth middleware.
#[test]
fn generate_rpc_token_produces_64_hex_chars() {
let token = generate_rpc_token();
assert_eq!(
token.len(),
64,
"256-bit token → 64 hex chars, got {token:?}"
);
assert!(
token.chars().all(|c| c.is_ascii_hexdigit()),
"token must be hex, got {token:?}"
);
assert!(
token.chars().all(|c| !c.is_uppercase()),
"token must be lowercase hex, got {token:?}"
);
}

/// Each call generates a different token (CSPRNG — not a constant).
#[test]
fn generate_rpc_token_is_not_constant() {
assert_ne!(
generate_rpc_token(),
generate_rpc_token(),
"two consecutive tokens must differ"
);
}

/// `CoreProcessHandle::new` must produce a non-empty, correctly-formatted
/// bearer token immediately — no file I/O or timing dependency.
#[test]
fn core_process_handle_new_token_is_valid() {
let handle = CoreProcessHandle::new(19001, None, CoreRunMode::ChildProcess);
let token = handle.rpc_token();
assert_eq!(token.len(), 64, "handle token must be 64 hex chars");
assert!(
token.chars().all(|c| c.is_ascii_hexdigit()),
"handle token must be hex"
);
}

/// `CoreProcessHandle::new()` must NOT publish the token to the global
/// `CURRENT_RPC_TOKEN`. The global is set only after `ensure_running()`
/// successfully spawns the child that received `OPENHUMAN_CORE_TOKEN`.
/// Advertising the token before spawn would cause 401s when the port is
/// already held by a stale process that never received this token.
#[test]
fn new_does_not_publish_global_token() {
// Capture current global state before constructing the handle.
let before = current_rpc_token();
let handle = CoreProcessHandle::new(19002, None, CoreRunMode::ChildProcess);
let after = current_rpc_token();

// The global must not have changed to this handle's token.
assert_ne!(
after.as_deref(),
Some(handle.rpc_token()),
"new() must not publish its token to CURRENT_RPC_TOKEN before ensure_running() spawns"
);
// Whatever was in the global before must still be there (or still None).
assert_eq!(
before, after,
"new() must leave CURRENT_RPC_TOKEN unchanged"
);
}

/// Two handles constructed sequentially must each have a unique token,
/// but neither should update the global until ensure_running() spawns.
#[test]
fn each_handle_has_unique_token() {
let h1 = CoreProcessHandle::new(19003, None, CoreRunMode::ChildProcess);
let h2 = CoreProcessHandle::new(19004, None, CoreRunMode::ChildProcess);

assert_ne!(
h1.rpc_token(),
h2.rpc_token(),
"each handle must have a unique token"
);
}

// Tests for logging/diagnostics (grep-friendly patterns)
#[test]
fn core_bin_resolution_logs_expected_patterns() {
Expand Down
19 changes: 19 additions & 0 deletions app/src-tauri/src/core_rpc.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
//! Shared helpers for authenticated calls from the Tauri host to the local core RPC.

use reqwest::RequestBuilder;

const CORE_RPC_URL_ENV: &str = "OPENHUMAN_CORE_RPC_URL";
pub(crate) fn core_rpc_url_value() -> String {
std::env::var(CORE_RPC_URL_ENV).unwrap_or_else(|_| {
format!(
"http://127.0.0.1:{}/rpc",
crate::core_process::default_core_port()
)
})
}

pub(crate) fn apply_auth(builder: RequestBuilder) -> Result<RequestBuilder, String> {
let token = crate::core_process::current_rpc_token()
.ok_or_else(|| "core RPC token is not initialized".to_string())?;
Ok(builder.header("Authorization", format!("Bearer {token}")))
}
10 changes: 6 additions & 4 deletions app/src-tauri/src/core_update.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ pub struct CoreUpdateInfo {
}

/// Query the running core's version via JSON-RPC.
pub async fn query_core_version(rpc_url: &str) -> Result<String, String> {
pub async fn query_core_version(rpc_url: &str, rpc_token: &str) -> Result<String, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(5))
.build()
Expand All @@ -67,6 +67,7 @@ pub async fn query_core_version(rpc_url: &str) -> Result<String, String> {

let resp = client
.post(rpc_url)
.header("Authorization", format!("Bearer {rpc_token}"))
.json(&body)
.send()
.await
Expand Down Expand Up @@ -104,8 +105,8 @@ pub fn is_outdated(running: &str, target: &str) -> bool {
}

/// Full check: query running version, compare against minimum AND latest GitHub release.
pub async fn check_full(rpc_url: &str) -> Result<CoreUpdateInfo, String> {
let running = query_core_version(rpc_url).await?;
pub async fn check_full(rpc_url: &str, rpc_token: &str) -> Result<CoreUpdateInfo, String> {
let running = query_core_version(rpc_url, rpc_token).await?;
let minimum = MINIMUM_CORE_VERSION;
let outdated = is_outdated(&running, minimum);

Expand Down Expand Up @@ -393,6 +394,7 @@ pub async fn check_and_update_core(
force: bool,
) -> Result<(), String> {
let rpc_url = handle.rpc_url();
let rpc_token = handle.rpc_token().to_string();
log::info!(
"[core-update] checking core version at {} (minimum: {}, force: {})",
rpc_url,
Expand All @@ -401,7 +403,7 @@ pub async fn check_and_update_core(
);

// Step 1: Query running version.
let running_version = match query_core_version(&rpc_url).await {
let running_version = match query_core_version(&rpc_url, &rpc_token).await {
Ok(v) => v,
Err(e) => {
log::warn!("[core-update] could not query core version: {e}");
Expand Down
12 changes: 6 additions & 6 deletions app/src-tauri/src/imessage_scanner/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -179,15 +179,15 @@ fn chat_allowed(chat_id: &str, allowed: &[String]) -> bool {
/// - `Err(_)` on transport or parse errors (caller should retry next tick)
#[cfg(target_os = "macos")]
async fn fetch_imessage_gate() -> anyhow::Result<Option<Vec<String>>> {
let url = std::env::var("OPENHUMAN_CORE_RPC_URL")
.unwrap_or_else(|_| "http://127.0.0.1:7788/rpc".into());
let url = crate::core_rpc::core_rpc_url_value();
let body = json!({
"jsonrpc": "2.0",
"id": 1,
"method": "openhuman.config_get",
"params": {}
});
let res = http_client().post(&url).json(&body).send().await?;
let req = crate::core_rpc::apply_auth(http_client().post(&url)).map_err(anyhow::Error::msg)?;
let res = req.json(&body).send().await?;
if !res.status().is_success() {
anyhow::bail!("config_get http {}", res.status());
}
Expand Down Expand Up @@ -407,8 +407,7 @@ fn message_body(m: &chatdb::Message) -> String {
#[cfg(target_os = "macos")]
async fn ingest_group(account_id: &str, key: &str, transcript: String) -> anyhow::Result<()> {
let (chat_id, day) = key.split_once(':').unwrap_or((key, ""));
let url = std::env::var("OPENHUMAN_CORE_RPC_URL")
.unwrap_or_else(|_| "http://127.0.0.1:7788/rpc".into());
let url = crate::core_rpc::core_rpc_url_value();

let body = json!({
"jsonrpc": "2.0",
Expand All @@ -430,7 +429,8 @@ async fn ingest_group(account_id: &str, key: &str, transcript: String) -> anyhow
}
});

let res = http_client().post(&url).json(&body).send().await?;
let req = crate::core_rpc::apply_auth(http_client().post(&url)).map_err(anyhow::Error::msg)?;
let res = req.json(&body).send().await?;

if !res.status().is_success() {
anyhow::bail!("core rpc {}: {}", res.status(), res.text().await?);
Expand Down
Loading
Loading