feat(core): pass in-process RPC bearer via internal handle, not process env (#2709)

This commit is contained in:
oxoxDev
2026-05-29 04:35:33 +05:30
committed by GitHub
parent 4657d16c7c
commit 336d811ca0
10 changed files with 379 additions and 94 deletions
+3 -1
View File
@@ -56,7 +56,9 @@ OPENHUMAN_CORE_RPC_URL=http://127.0.0.1:7788/rpc
# Set this when serving a private web UI preview from a non-loopback origin.
# OPENHUMAN_CORE_ALLOWED_ORIGINS=https://openhuman-ui.example.com
# Core RPC bearer token. Single source of truth for /rpc auth.
# - Tauri desktop: set automatically by the shell — leave blank.
# - Tauri desktop: leave blank. The shell generates a fresh per-launch
# bearer and hands it to the in-process core in-memory; nothing is
# read from this variable.
# - Docker / cloud / VPS: REQUIRED. Generate with `openssl rand -hex 32`.
# Same value goes in the desktop's app/.env.local (or paste into the
# first-run picker). See gitbooks/features/cloud-deploy.md.
+3 -3
View File
@@ -27,7 +27,7 @@ Commands in documentation assume the **repo root** unless noted: `pnpm dev` runs
- **Shipped product**: desktop — Windows, macOS, Linux (see [`gitbooks/developing/architecture.md`](gitbooks/developing/architecture.md) "Platform reach").
- **Tauri host** (`app/src-tauri`): **desktop-only**. Do not add Android/iOS branches.
- **Core runs in-process** as a tokio task inside the Tauri host (sidecar removed in PR #1061). The host owns its lifetime via `core_process::CoreProcessHandle` in `app/src-tauri/src/core_process.rs`. Frontend RPC still goes over HTTP to `http://127.0.0.1:<port>/rpc` authenticated with a per-launch hex bearer in `OPENHUMAN_CORE_TOKEN`; the Tauri command `core_rpc_token` exposes it to the renderer. Set `OPENHUMAN_CORE_REUSE_EXISTING=1` to attach to an externally-started `openhuman-core` process for debugging.
- **Core runs in-process** as a tokio task inside the Tauri host (sidecar removed in PR #1061). The host owns its lifetime via `core_process::CoreProcessHandle` in `app/src-tauri/src/core_process.rs`. Frontend RPC still goes over HTTP to `http://127.0.0.1:<port>/rpc` authenticated with a per-launch hex bearer; the Tauri shell generates it in `CoreProcessHandle::new()` and hands it to the embedded server in-memory via `run_server_embedded_with_ready(rpc_token: Some(_))``OPENHUMAN_CORE_TOKEN` is no longer set on the process env by the desktop shell (CLI / docker / cloud env-as-config is preserved). The Tauri command `core_rpc_token` exposes the bearer to the renderer. Set `OPENHUMAN_CORE_REUSE_EXISTING=1` to attach to an externally-started `openhuman-core` process for debugging.
**Where logic lives**
@@ -301,7 +301,7 @@ Bundled prompts live under **`src/openhuman/agent/prompts/`** at the **repositor
Thin desktop host. Top-level modules: `core_process`, `core_rpc`, `cdp`, `cef_preflight`, `cef_profile`, `dictation_hotkeys`, `file_logging`, `mascot_native_window`, `native_notifications`, `notification_settings`, `process_kill`, `process_recovery`, `screen_capture`, `window_state`, plus per-provider scanners (`discord_scanner`, `gmessages_scanner`, `imessage_scanner`, `meet_scanner`, `slack_scanner`, `telegram_scanner`, `whatsapp_scanner`), `meet_audio` / `meet_call` / `meet_video`, `fake_camera`, `webview_accounts`, `webview_apis`.
**Core lifecycle**: `core_process::CoreProcessHandle` spawns the in-process JSON-RPC server and authenticates inbound RPC with a hex bearer (`OPENHUMAN_CORE_TOKEN`). Stale-listener policy (#1130): on conflict the handle probes `GET /`, decides if the listener is an OpenHuman core, then `kill_pid_term``kill_pid_force` with PID revalidation guarding against PID reuse. `restart_core_process` / `start_core_process` Tauri commands let the frontend cycle it for updates.
**Core lifecycle**: `core_process::CoreProcessHandle` spawns the in-process JSON-RPC server and authenticates inbound RPC with a hex bearer that the shell hands the embedded server in-memory via `run_server_embedded_with_ready(rpc_token: Some(_))` (no env-var crossing). Stale-listener policy (#1130): on conflict the handle probes `GET /`, decides if the listener is an OpenHuman core, then `kill_pid_term``kill_pid_force` with PID revalidation guarding against PID reuse. `restart_core_process` / `start_core_process` Tauri commands let the frontend cycle it for updates.
Registered IPC commands (see [`gitbooks/developing/architecture/tauri-shell.md`](gitbooks/developing/architecture/tauri-shell.md)) include `greet`, `write_ai_config_file`, `ai_get_config`, `ai_refresh_config`, `core_rpc_relay`, `core_rpc_token`, `start_core_process`, `restart_core_process`, window commands, and `openhuman_*` daemon helpers.
@@ -539,7 +539,7 @@ Follow this order so behavior is **specified**, **proven in Rust**, **proven ove
- **macOS deep links**: Often require a built **`.app`** bundle; not only `tauri dev`.
- **`window.__TAURI__`**: Not assumed at module load; use `isTauri()` (from `app/src/services/webviewAccountService.ts`) or wrap `invoke(...)` in `try/catch`.
- **Core is in-process**: `core_rpc` reaches `http://127.0.0.1:<port>/rpc` (default port `7788`) authenticated with `OPENHUMAN_CORE_TOKEN`. `scripts/stage-core-sidecar.mjs` no longer exists; `pnpm core:stage` is a no-op echo (sidecar removed in PR #1061). For standalone debugging: `./target/debug/openhuman-core serve` writes its token to `{workspace}/core.token` (default `~/.openhuman-staging/core.token` under `OPENHUMAN_APP_ENV=staging`); public endpoints `GET /health`, `GET /schema`, `GET /events` need no auth.
- **Core is in-process**: `core_rpc` reaches `http://127.0.0.1:<port>/rpc` (default port `7788`) authenticated with a per-launch hex bearer. The desktop shell hands the bearer to the embedded server in-memory (no `OPENHUMAN_CORE_TOKEN` on the process env); docker / cloud / VPS operators still supply the bearer via `OPENHUMAN_CORE_TOKEN` (env-as-config). `scripts/stage-core-sidecar.mjs` no longer exists; `pnpm core:stage` is a no-op echo (sidecar removed in PR #1061). For standalone debugging: `./target/debug/openhuman-core serve` writes its token to `{workspace}/core.token` (default `~/.openhuman-staging/core.token` under `OPENHUMAN_APP_ENV=staging`); public endpoints `GET /health`, `GET /schema`, `GET /events` need no auth.
---
+3 -3
View File
@@ -23,7 +23,7 @@ Commands assume the **repo root**; `pnpm dev` delegates to the `app` workspace.
- **Shipped product**: desktop — Windows, macOS, Linux.
- **Tauri host** (`app/src-tauri`): desktop-only. No Android/iOS branches.
- **Core runs in-process** inside the Tauri host as a tokio task — there is **no sidecar binary anymore** (removed in PR #1061). The lifecycle is owned by `core_process::CoreProcessHandle` in `app/src-tauri/src/core_process.rs`; on Cmd+Q the core dies with the GUI. Frontend RPC still goes over HTTP (`core_rpc_relay` + `core_rpc` client) to `http://127.0.0.1:<port>/rpc`, authenticated with a per-launch bearer in `OPENHUMAN_CORE_TOKEN`. Set `OPENHUMAN_CORE_REUSE_EXISTING=1` to attach to an externally-started `openhuman-core` process (e.g. a debug harness).
- **Core runs in-process** inside the Tauri host as a tokio task — there is **no sidecar binary anymore** (removed in PR #1061). The lifecycle is owned by `core_process::CoreProcessHandle` in `app/src-tauri/src/core_process.rs`; on Cmd+Q the core dies with the GUI. Frontend RPC still goes over HTTP (`core_rpc_relay` + `core_rpc` client) to `http://127.0.0.1:<port>/rpc`, authenticated with a per-launch bearer the shell hands the embedded server in-memory via `run_server_embedded_with_ready(rpc_token: Some(_))`. The renderer reads the same bearer via the `core_rpc_token` Tauri command. `OPENHUMAN_CORE_TOKEN` is still honoured for CLI / docker / cloud env-as-config (operator-supplied) but is no longer set on the process env by the desktop shell. Set `OPENHUMAN_CORE_REUSE_EXISTING=1` to attach to an externally-started `openhuman-core` process (e.g. a debug harness).
**Where logic lives**
- **Rust core**: business logic, execution, domains, RPC, persistence, CLI. Authoritative.
@@ -197,7 +197,7 @@ No `UserProvider` / `AIProvider` / `SkillProvider` — auth and core snapshot li
Thin desktop host. Top-level modules: `core_process`, `core_rpc`, `cdp`, `cef_preflight`, `cef_profile`, `dictation_hotkeys`, `file_logging`, `mascot_native_window`, `native_notifications`, `notification_settings`, `process_kill`, `process_recovery`, `screen_capture`, `window_state`, plus the per-provider scanner modules (`discord_scanner`, `gmessages_scanner`, `imessage_scanner`, `meet_scanner`, `slack_scanner`, `telegram_scanner`, `whatsapp_scanner`), `meet_audio` / `meet_call` / `meet_video`, `fake_camera`, `webview_accounts`, `webview_apis`.
**Core lifecycle**: `core_process::CoreProcessHandle` spawns the JSON-RPC server as an in-process tokio task and authenticates inbound RPC with a per-launch hex bearer (`OPENHUMAN_CORE_TOKEN`). On stale-listener detection (#1130) the handle revalidates the PID before force-killing so PID reuse can't kill an unrelated process. `restart_core_process` / `start_core_process` Tauri commands let the frontend cycle it for updates.
**Core lifecycle**: `core_process::CoreProcessHandle` spawns the JSON-RPC server as an in-process tokio task and authenticates inbound RPC with a per-launch hex bearer. The bearer is generated in `CoreProcessHandle::new()` and handed to the embedded server in-memory through `run_server_embedded_with_ready(rpc_token: Some(_))` — never set on the process env. On stale-listener detection (#1130) the handle revalidates the PID before force-killing so PID reuse can't kill an unrelated process. `restart_core_process` / `start_core_process` Tauri commands let the frontend cycle it for updates.
Registered IPC (see [`gitbooks/developing/architecture/tauri-shell.md`](gitbooks/developing/architecture/tauri-shell.md)) includes `greet`, `write_ai_config_file`, `ai_get_config`, `ai_refresh_config`, `core_rpc_relay`, `core_rpc_token`, `start_core_process`, `restart_core_process`, window commands, and `openhuman_*` daemon helpers. Always use `invoke('core_rpc_relay', ...)` for in-process RPC (avoids CORS preflight that `fetch()` would trigger).
@@ -357,4 +357,4 @@ Specify → prove in Rust → prove over RPC → surface in the UI → test.
- **macOS deep links**: often require a built `.app` bundle, not just `tauri dev`.
- **Windows deep links**: `openhuman://` is registered to `HKCU\Software\Classes\openhuman\shell\open\command` by `tauri-plugin-deep-link::register_all` at first launch (per-user, no UAC). The Tauri shell now reads that key back after `register_all` returns and emits `log::error!` with the actual state (`NotRegistered` / `MissingCommand` / `Stale` / `ReadError`) when the value is missing or doesn't point at the running exe — without it, OAuth callbacks via `openhuman://auth?…` never reach the app (issue #2699). The check lives in [`app/src-tauri/src/deep_link_registration_check.rs`](app/src-tauri/src/deep_link_registration_check.rs); a manual repair script for affected users is in [`gitbooks/overview/troubleshooting-sign-in.md`](gitbooks/overview/troubleshooting-sign-in.md).
- **Tauri environment guard**: use `isTauri()` (from `app/src/services/webviewAccountService.ts`) or wrap `invoke(...)` in `try/catch`; do not check `window.__TAURI__` directly — it is not present at module load and bypasses the established wrapper contract.
- **Core is in-process** (no sidecar): `core_rpc` reaches the embedded server at `http://127.0.0.1:<port>/rpc` with bearer auth via `OPENHUMAN_CORE_TOKEN`. `scripts/stage-core-sidecar.mjs` no longer exists; `pnpm core:stage` is a no-op echo. To run the core standalone for debugging, use `./target/debug/openhuman-core serve` (token at `{workspace}/core.token`, default `~/.openhuman-staging/core.token` under `OPENHUMAN_APP_ENV=staging`).
- **Core is in-process** (no sidecar): `core_rpc` reaches the embedded server at `http://127.0.0.1:<port>/rpc` with bearer auth. The Tauri shell hands the bearer to the embedded server in-memory (no `OPENHUMAN_CORE_TOKEN` on the process env). `scripts/stage-core-sidecar.mjs` no longer exists; `pnpm core:stage` is a no-op echo. To run the core standalone for debugging, use `./target/debug/openhuman-core serve` (token at `{workspace}/core.token`, default `~/.openhuman-staging/core.token` under `OPENHUMAN_APP_ENV=staging`); docker / cloud deployments still supply the bearer via `OPENHUMAN_CORE_TOKEN` in the environment (operator-supplied).
+32 -11
View File
@@ -69,10 +69,15 @@ pub struct CoreProcessHandle {
active_port: Arc<RwLock<u16>>,
last_port_fallback: Arc<RwLock<Option<PortFallbackNotice>>>,
/// Bearer token the embedded server validates on every inbound request.
/// Passed to the embedded server through the `OPENHUMAN_CORE_TOKEN`
/// process env var (set in `ensure_running` before spawn) and exposed to
/// the frontend via the `core_rpc_token` Tauri command so every RPC call
/// can include `Authorization: Bearer`.
///
/// Handed to the embedded server **in-memory** (via the `rpc_token`
/// argument of [`openhuman_core::core::jsonrpc::run_server_embedded_with_ready`])
/// rather than through `OPENHUMAN_CORE_TOKEN` on the process environment.
/// Avoiding the env crossing keeps the bearer off `/proc/<pid>/environ`
/// (Linux) and out of `sysctl KERN_PROCARGS2` / `ps eww -p <pid>` (macOS)
/// where any same-UID process could otherwise read it without entitlement.
/// The same value is exposed to the renderer via the `core_rpc_token`
/// Tauri command so every RPC call can attach `Authorization: Bearer`.
rpc_token: Arc<String>,
}
@@ -80,7 +85,8 @@ impl CoreProcessHandle {
pub fn new(port: u16) -> Self {
// CURRENT_RPC_TOKEN is intentionally NOT set here. It is published by
// ensure_running() only after the embedded server has been spawned
// with OPENHUMAN_CORE_TOKEN in scope. Setting it here would advertise
// with this token handed over via the in-memory `rpc_token` arg of
// `run_server_embedded_with_ready`. Setting it here would advertise
// a token that an existing process listening on the port (the
// harness-attach fast-path) has never seen, causing 401s on every
// authenticated call.
@@ -214,11 +220,18 @@ impl CoreProcessHandle {
let mut guard = self.task.lock().await;
if guard.is_none() {
let port = self.preferred_port;
// Set OPENHUMAN_CORE_TOKEN as a process-global env var before
// spawning the embedded server. Same-process tokio task reads
// the same env, matching what a child sidecar would have
// received via Command::env.
std::env::set_var("OPENHUMAN_CORE_TOKEN", self.rpc_token.as_str());
// RPC bearer is handed to the embedded server in-memory
// via the `rpc_token` argument of
// run_server_embedded_with_ready (see below) — never
// through OPENHUMAN_CORE_TOKEN on the process env.
// Sidecar-era env-var transport was a leftover from the
// PR #1061 cleanup; with the core in-process there is no
// child process that needs the env crossing, and
// sharing the bearer via env put it within reach of any
// same-UID process that could read /proc/<pid>/environ
// (Linux) or sysctl KERN_PROCARGS2 / ps eww -p <pid>
// (macOS).
let token_for_core = self.rpc_token.clone();
// Surface the Tauri shell version to the in-process core so
// backend-bound HTTP requests can attach `x-tauri-version`
// analytics headers alongside `x-core-version`.
@@ -273,12 +286,20 @@ impl CoreProcessHandle {
true,
shutdown_token,
ready_tx,
// In-memory bearer handoff: the embedded server
// seeds its auth subsystem from this value via
// `auth::init_rpc_token_with_value`, so the token
// never crosses OPENHUMAN_CORE_TOKEN on the
// process env.
Some(token_for_core),
)
.await
});
*guard = Some(task);
// Publish only after the embedded server has been spawned
// with OPENHUMAN_CORE_TOKEN in scope.
// with the in-memory bearer in scope. Setting this earlier
// would advertise a token to the frontend that the server
// hadn't loaded yet.
*CURRENT_RPC_TOKEN.write() = Some(self.rpc_token.to_string());
log::debug!("[auth] CURRENT_RPC_TOKEN set after embedded spawn");
}
+54
View File
@@ -83,6 +83,60 @@ fn ready_signal_updates_runtime_port_and_fallback_notice() {
);
}
/// Regression: `ensure_running` must NOT publish the per-launch RPC bearer
/// to the `OPENHUMAN_CORE_TOKEN` environment variable.
///
/// The bearer is now handed to the in-process core in-memory via the
/// `rpc_token` argument of `run_server_embedded_with_ready`; setting it on
/// the process env would put it within reach of any same-UID process
/// reading `/proc/<pid>/environ` (Linux) or `sysctl KERN_PROCARGS2` /
/// `ps eww -p <pid>` (macOS).
#[test]
fn ensure_running_does_not_publish_token_to_env() {
let _env_lock = env_lock();
let _unset = EnvGuard::unset("OPENHUMAN_CORE_REUSE_EXISTING");
// Force a clean slate so we can assert on the post-spawn value.
let _wipe = EnvGuard::unset("OPENHUMAN_CORE_TOKEN");
let rt = tokio::runtime::Runtime::new().expect("runtime");
let (result, env_after, expected_token, env_during_spawn) = rt.block_on(async {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
.await
.expect("bind test listener");
let port = listener.local_addr().expect("local addr").port();
drop(listener);
// Brief yield to let the OS fully release the port.
tokio::time::sleep(tokio::time::Duration::from_millis(50)).await;
let handle = CoreProcessHandle::new(port);
let expected_token = handle.rpc_token().to_string();
let result = handle.ensure_running().await;
// Capture env immediately after spawn returns Ok — before any
// tokio task could plausibly have set the var.
let env_after = std::env::var("OPENHUMAN_CORE_TOKEN").ok();
// Also peek midway via spawning a tiny check task in the same
// runtime — guards against the codepath setting+removing the var
// within the spawn window.
let env_during_spawn = std::env::var("OPENHUMAN_CORE_TOKEN").ok();
handle.shutdown().await;
(result, env_after, expected_token, env_during_spawn)
});
assert!(
result.is_ok(),
"ensure_running should succeed against a freed port: {result:?}"
);
assert!(
env_after.is_none(),
"ensure_running must NOT publish OPENHUMAN_CORE_TOKEN to the process env \
(sidecar-era leak channel removed). Found: {env_after:?} (handle token was {expected_token:?})"
);
assert!(
env_during_spawn.is_none(),
"OPENHUMAN_CORE_TOKEN must remain unset even momentarily during spawn. \
Found: {env_during_spawn:?}"
);
}
/// Issue #1613: when the preferred port is occupied by a non-OpenHuman
/// listener, startup should fall back to a nearby port instead of failing.
#[test]
+5 -3
View File
@@ -64,7 +64,9 @@ request_native_global(method, req) → call and wait for response
- HTTP JSON-RPC protected by **HTTP Basic Auth**
- Realm: `"OpenHuman Hosted Directory"`
- Per-launch bearer token stored in `OPENHUMAN_CORE_TOKEN` env var
- Per-launch bearer token, transported differently per deployment shape:
- **Desktop / Tauri shell**: bearer is generated in `CoreProcessHandle::new()` and held in-memory as `CoreProcessHandle.rpc_token: Arc<String>`, then handed to the embedded server via an internal in-memory handle (`run_server_embedded_with_ready(rpc_token: Some(_))`). **Not** published to the process environment.
- **Standalone CLI / Docker / cloud**: bearer is read from the `OPENHUMAN_CORE_TOKEN` env var (via `init_rpc_token`) or from the `{workspace}/core.token` file. This is the operator-supplied configuration surface for those deployments and is intentional.
- Frontend obtains bearer via `invoke('core_rpc_token')` Tauri command
### Stored Credentials
@@ -164,7 +166,7 @@ Frontend settings → core RPC (JSON-RPC over HTTP + Basic Auth)
3. **Config directory permissions**`~/.config/openhuman/` and `mcp.json` permission model not reviewed
4. **Credential encryption**`encryption` domain used for at-rest encryption; key management model unclear
5. **WebView CSP** — embedded webviews (Telegram, Discord, etc.) loaded under CEF — need to verify CSP headers and iframe restrictions
6. **`OPENHUMAN_CORE_TOKEN` in process env** — bearer token in env var; visible via `/proc/self/environ` on Linux or process inspection on macOS
6. ~~**`OPENHUMAN_CORE_TOKEN` in process env** — bearer token in env var; visible via `/proc/self/environ` on Linux or process inspection on macOS~~**resolved**: in-process core now receives the bearer via an in-memory handoff (`run_server_embedded_with_ready(rpc_token: Some(_))`); the env-var crossing has been removed from the Tauri shell's spawn path. CLI / docker / cloud env-as-config remains the intended operator surface for those deployments.
7. **No rate limiting observed** on HTTP JSON-RPC endpoint
### Positive Signals
@@ -183,7 +185,7 @@ Frontend settings → core RPC (JSON-RPC over HTTP + Basic Auth)
- [ ] Check file permissions on `~/.config/openhuman/`
- [ ] Add rate limiting to HTTP JSON-RPC endpoint
- [ ] Document MCP tool output handling expectations
- [ ] Review `OPENHUMAN_CORE_TOKEN` lifetime and exposure scope
- [x] Review `OPENHUMAN_CORE_TOKEN` lifetime and exposure scope — in-process core now uses in-memory handoff (no env crossing); CLI/docker/cloud retain env-as-config
---
+60 -24
View File
@@ -15,38 +15,74 @@ CORE_PORT="${OPENHUMAN_CORE_PORT:-7788}"
CORE_RPC_URL="${CORE_RPC_URL:-http://${CORE_HOST}:${CORE_PORT}/rpc}"
# Resolve the core RPC bearer token. Resolution order:
# 1. OPENHUMAN_CORE_TOKEN env var (set by caller or Tauri shell)
# 2. core.token file in workspace dir (written by standalone `openhuman core run`)
# 3. Live process environment of the running openhuman-core child (Tauri-managed:
# token is injected via env var, never written to disk)
# 1. OPENHUMAN_CORE_TOKEN env var (set by caller or via .env / docker / cloud).
# Always wins — explicit operator configuration.
# 2. Whichever of the two on-disk token files is FRESHEST (newest mtime):
# a. config-style core.token file in workspace dir (written by
# standalone `openhuman core run`)
# b. Debug-only e2e token file written by the Tauri shell at
# ${TMPDIR:-/tmp}/openhuman-e2e-rpc-token (mode 0600). Only present
# in debug builds — release builds do not write it. The Tauri shell
# hands its bearer to the in-process core in-memory; no env-var or
# ps-readable surface exists.
#
# Freshest-wins matters because both files commonly coexist on dev
# machines (a previous standalone run leaves $workspace/core.token
# behind; the live debug Tauri app writes a new e2e token on each
# launch). Static "workspace first" precedence would let a stale
# standalone token shadow the live debug bearer.
_file_mtime() {
# Print the modification time (epoch seconds) of $1 to stdout.
# Falls back to 0 if stat is missing or the file does not exist.
local f="$1"
if [[ ! -f "$f" ]]; then
echo 0
return
fi
# macOS (BSD stat) uses -f %m; Linux (GNU stat) uses -c %Y.
if stat -f %m "$f" >/dev/null 2>&1; then
stat -f %m "$f"
elif stat -c %Y "$f" >/dev/null 2>&1; then
stat -c %Y "$f"
else
echo 0
fi
}
_resolve_rpc_token() {
if [[ -n "${OPENHUMAN_CORE_TOKEN:-}" ]]; then
echo "core-token source: OPENHUMAN_CORE_TOKEN env var" >&2
echo "$OPENHUMAN_CORE_TOKEN"
return
fi
local workspace="${OPENHUMAN_WORKSPACE:-$HOME/.openhuman}"
local token_file="$workspace/core.token"
if [[ -f "$token_file" ]]; then
cat "$token_file"
return
local workspace_token_file="$workspace/core.token"
local e2e_token_file="${TMPDIR:-/tmp}/openhuman-e2e-rpc-token"
e2e_token_file="${e2e_token_file%/}"
local workspace_mtime
local e2e_mtime
workspace_mtime="$(_file_mtime "$workspace_token_file")"
e2e_mtime="$(_file_mtime "$e2e_token_file")"
if [[ "$workspace_mtime" -eq 0 && "$e2e_mtime" -eq 0 ]]; then
echo "ERROR: core RPC token not found. Options:" >&2
echo " 1. Set OPENHUMAN_CORE_TOKEN=<token> before running this script" >&2
echo " 2. Start the core standalone: openhuman core run (writes $workspace_token_file)" >&2
echo " 3. Run the OpenHuman app in debug mode (writes $e2e_token_file)" >&2
exit 1
fi
# Tauri-managed core: token is in the child process environment, not on disk.
# Read it from the running openhuman-core process via ps.
local core_pid
core_pid="$(pgrep -f 'openhuman-core.*run' 2>/dev/null | head -1)"
if [[ -n "$core_pid" ]]; then
local tok
tok="$(ps eww -p "$core_pid" 2>/dev/null | tr ' ' '\n' | grep '^OPENHUMAN_CORE_TOKEN=' | cut -d= -f2)"
if [[ -n "$tok" ]]; then
echo "$tok"
return
fi
# Pick whichever exists. If both exist, prefer the freshest by mtime —
# that's the live debug bearer, not the stale standalone leftover.
if [[ "$e2e_mtime" -gt "$workspace_mtime" ]]; then
echo "core-token source: debug e2e file ($e2e_token_file, mtime=$e2e_mtime)" >&2
cat "$e2e_token_file"
else
echo "core-token source: workspace file ($workspace_token_file, mtime=$workspace_mtime)" >&2
cat "$workspace_token_file"
fi
echo "ERROR: core RPC token not found. Options:" >&2
echo " 1. Set OPENHUMAN_CORE_TOKEN=<token> before running this script" >&2
echo " 2. Start the core standalone: openhuman core run (writes $token_file)" >&2
echo " 3. Open the OpenHuman app (token auto-detected from process env)" >&2
exit 1
}
RPC_TOKEN="$(_resolve_rpc_token)"
KEEP_TUNNEL=0
+135 -29
View File
@@ -1,20 +1,31 @@
//! Per-process RPC bearer-token authentication.
//!
//! At server startup, [`init_rpc_token`] either reads the token from the
//! `OPENHUMAN_CORE_TOKEN` environment variable (Tauri-spawned path) or
//! generates a 256-bit cryptographically-random token and writes it to
//! `{workspace_dir}/core.token` (owner-read-only on Unix, standalone CLI path),
//! then stores it in a process-global [`OnceLock`].
//! Three initialization paths feed the process-global [`OnceLock`] that holds
//! the active bearer token:
//!
//! **Tauri path**: the Tauri shell generates the token in
//! `CoreProcessHandle::new()`, injects it as `OPENHUMAN_CORE_TOKEN` before
//! spawning the core process, and holds it in memory via
//! `CoreProcessHandle.rpc_token`. The shell includes the token in every
//! request as `Authorization: Bearer <token>`. The `core.token` file is
//! never written in this path.
//! 1. **In-memory handoff (preferred for the in-process core)** —
//! [`init_rpc_token_with_value`] sets the token directly from a value the
//! Tauri shell already holds in `CoreProcessHandle.rpc_token`. No env var
//! is read or set; the token never crosses a process-global env surface.
//! This is the path the Tauri host uses now that the core runs in-process
//! (PR #1061) — same-process handoff makes the env crossing unnecessary,
//! and avoiding it keeps the token off `/proc/<pid>/environ` (Linux) and
//! out of `sysctl KERN_PROCARGS2` / `ps eww -p <pid>` (macOS) where any
//! same-UID process could read it without entitlement.
//! 2. **Env-as-config fallback** — when no in-memory token is supplied,
//! [`init_rpc_token`] reads `OPENHUMAN_CORE_TOKEN` from the environment.
//! This is the legitimate operator-supplied transport for Docker / cloud /
//! VPS deployments where the bearer must come from `fly secrets set …`,
//! `docker run -e …`, or a systemd unit file — there is no live shell
//! handing it to the binary in-memory.
//! 3. **Standalone CLI fallback** — when neither path supplies a token, the
//! core generates a fresh 256-bit token and writes it to
//! `{workspace_dir}/core.token` (owner-read-only on Unix) so external CLI
//! clients can authenticate.
//!
//! **Standalone CLI path**: the core generates a fresh token and writes it to
//! `{workspace_dir}/core.token` so that CLI clients can read and use it.
//! Once set, the in-memory `OnceLock` is the single source of truth — all
//! transports ([`rpc_auth_middleware`], Socket.IO, SSE query-token fallback,
//! the approval-gate session id) read via [`get_rpc_token`].
//!
//! Endpoints exempt from auth (checked by [`rpc_auth_middleware`]):
//! - `GET /` — public info page
@@ -89,29 +100,51 @@ const PUBLIC_PATHS: &[&str] = &[
/// (#1339) is the next planned addition.
const QUERY_TOKEN_PATHS: &[&str] = &["/events/webhooks"];
/// The environment variable the Tauri shell sets before spawning the core.
/// Operator-supplied environment variable that carries the RPC bearer in
/// non-desktop deployments.
///
/// When this variable is present the core uses its value as the RPC token
/// (no file I/O needed). When absent (standalone `openhuman core run`) the
/// core generates a token and writes it to `{workspace_dir}/core.token` so
/// **The Tauri desktop shell does NOT set this variable.** Since PR #1061
/// the core runs in-process inside the Tauri host, and the shell hands the
/// per-launch bearer to the embedded server via an internal in-memory handle
/// (see [`init_rpc_token_with_value`]). The desktop boot flow never crosses
/// a process-global env surface.
///
/// `OPENHUMAN_CORE_TOKEN` remains the canonical configuration surface for
/// **standalone CLI / Docker / cloud** deployments only — where the bearer
/// must come from `fly secrets set …`, `docker run -e …`, a systemd unit
/// file, or a developer running `openhuman-core serve` from a shell with the
/// env var pre-set. In those shapes there is no live host process to hand
/// the token over in-memory, so env-as-config is the appropriate transport.
///
/// When this variable is present [`init_rpc_token`] uses its value (no file
/// I/O). When absent and no in-memory token was seeded, `init_rpc_token`
/// generates a fresh token and writes it to `{workspace_dir}/core.token` so
/// CLI clients can authenticate.
pub const CORE_TOKEN_ENV_VAR: &str = "OPENHUMAN_CORE_TOKEN";
/// Initialize the per-process RPC token.
/// Initialize the per-process RPC token from env-or-file (non-desktop path).
///
/// **Preferred path — Tauri-spawned core**: reads the token from the
/// `OPENHUMAN_CORE_TOKEN` environment variable set by the Tauri shell. No
/// file is written; the token is always available the instant the process
/// starts.
/// **Not the desktop path.** The Tauri shell passes the per-launch bearer
/// to the embedded server via the internal in-memory handle (see
/// [`init_rpc_token_with_value`]); it does **not** set
/// `OPENHUMAN_CORE_TOKEN`. This function is the bootstrap path for
/// standalone CLI / Docker / cloud deployments.
///
/// **Fallback — standalone CLI**: generates a fresh 256-bit token, writes it
/// to `{workspace_dir}/core.token` (owner-read-only on Unix) for external
/// callers, and stores it in the process global.
/// **Env-as-config (preferred for non-desktop)**: when
/// `OPENHUMAN_CORE_TOKEN` is set in the process environment (typically by
/// the container runtime, secrets manager, or systemd unit file), the core
/// uses its value as the RPC token. No file is written; the token is
/// available the instant the process starts.
///
/// **Standalone CLI fallback**: when no env var is supplied, the core
/// generates a fresh 256-bit token, writes it to `{workspace_dir}/core.token`
/// (owner-read-only on Unix) for external callers, and stores it in the
/// process global.
///
/// # Errors
///
/// Returns an error only in the fallback path, if the token file cannot be
/// written.
/// Returns an error only in the standalone fallback path, if the token file
/// cannot be written.
pub fn init_rpc_token(workspace_dir: &Path) -> anyhow::Result<()> {
// Idempotency guard: if the token is already set, do nothing. A second
// call must never write a new token to disk while the process still
@@ -122,12 +155,16 @@ pub fn init_rpc_token(workspace_dir: &Path) -> anyhow::Result<()> {
return Ok(());
}
// Fast path: token pre-seeded by the Tauri shell via env var.
// Env-as-config path: bearer supplied by the operator via
// OPENHUMAN_CORE_TOKEN. Used by Docker / cloud / systemd / a developer
// running `openhuman-core serve` from a pre-configured shell. Desktop
// (Tauri) does NOT set this variable — it uses `init_rpc_token_with_value`
// for an in-memory handoff instead.
if let Ok(env_token) = std::env::var(CORE_TOKEN_ENV_VAR) {
let env_token = env_token.trim().to_string();
if !env_token.is_empty() {
let _ = RPC_TOKEN.set(env_token);
log::info!("[auth] core RPC token loaded from environment (Tauri-managed)");
log::info!("[auth] core RPC token loaded from environment (operator-supplied)");
return Ok(());
}
}
@@ -144,6 +181,38 @@ pub fn init_rpc_token(workspace_dir: &Path) -> anyhow::Result<()> {
Ok(())
}
/// Seed the per-process RPC token directly from a caller-supplied value.
///
/// **In-memory handoff path** — used by the Tauri shell to inject the bearer
/// the host generated in `CoreProcessHandle::new()` into the in-process core
/// without round-tripping through `OPENHUMAN_CORE_TOKEN` in the process
/// environment. The token never lands on a process-global env surface, which
/// keeps it off `/proc/<pid>/environ` (Linux) and out of `sysctl
/// KERN_PROCARGS2` / `ps eww -p <pid>` (macOS) where any same-UID process
/// could otherwise read it without entitlement.
///
/// Idempotent: a second call is a no-op (matches [`init_rpc_token`] — flipping
/// the in-memory bearer mid-life would 401 every in-flight client).
///
/// # Errors
///
/// Returns an error only if `token` is empty after trimming. A non-empty
/// token is accepted as-is — callers are expected to have generated a
/// CSPRNG hex string (see `CoreProcessHandle::generate_rpc_token`).
pub fn init_rpc_token_with_value(token: &str) -> anyhow::Result<()> {
let trimmed = token.trim();
if trimmed.is_empty() {
anyhow::bail!("init_rpc_token_with_value: supplied token is empty");
}
if RPC_TOKEN.get().is_some() {
log::debug!("[auth] init_rpc_token_with_value: already initialized, skipping");
return Ok(());
}
let _ = RPC_TOKEN.set(trimmed.to_string());
log::info!("[auth] core RPC token loaded via in-memory handoff (no env crossing)");
Ok(())
}
/// Returns the active RPC token, if initialized.
pub fn get_rpc_token() -> Option<&'static str> {
RPC_TOKEN.get().map(String::as_str)
@@ -415,6 +484,43 @@ mod tests {
assert!(!verify_bearer_token(""));
}
#[test]
fn init_rpc_token_with_value_rejects_empty() {
// Trimmed-empty values must error rather than seed an empty bearer.
assert!(init_rpc_token_with_value("").is_err());
assert!(init_rpc_token_with_value(" ").is_err());
}
/// `init_rpc_token_with_value` populates the same `RPC_TOKEN` OnceLock
/// that `get_rpc_token` reads — i.e. the in-memory handoff path produces
/// the bearer everyone else (HTTP middleware, Socket.IO verifier,
/// approval-gate session_id) reads from. We can't deterministically
/// assert the *value* set here (the OnceLock may already be seeded by a
/// sibling test that ran first in the same binary), but we can assert
/// the OnceLock is initialised after this call returns Ok, and that the
/// helper is idempotent.
#[test]
fn init_rpc_token_with_value_seeds_and_is_idempotent() {
// First call: either we seed, or a sibling test already did. Either
// way the helper must return Ok and leave `get_rpc_token` populated.
let token = "cafebabe1234567890abcdef0123456789abcdef0123456789abcdef01234567";
init_rpc_token_with_value(token).expect("seed succeeds");
assert!(
get_rpc_token().is_some(),
"after init_rpc_token_with_value, get_rpc_token must return Some"
);
// Second call is a no-op (matching init_rpc_token semantics) — must
// not error, must not flip the in-memory value.
let before = get_rpc_token().map(str::to_string);
init_rpc_token_with_value("a-different-value-that-must-be-ignored")
.expect("idempotent re-init succeeds");
let after = get_rpc_token().map(str::to_string);
assert_eq!(
before, after,
"second init_rpc_token_with_value must not flip the in-memory bearer"
);
}
#[test]
fn extract_query_token_returns_none_on_missing_query() {
assert_eq!(extract_query_token(None), None);
+69 -15
View File
@@ -1261,7 +1261,7 @@ pub async fn run_server(
port: Option<u16>,
socketio_enabled: bool,
) -> anyhow::Result<()> {
run_server_inner(host, port, socketio_enabled, false, None, None).await
run_server_inner(host, port, socketio_enabled, false, None, None, None).await
}
/// Like [`run_server`] but marks the instance as embedded.
@@ -1278,17 +1278,26 @@ pub async fn run_server_embedded(
true,
Some(shutdown_token),
None,
None,
)
.await
}
/// Embedded entrypoint with an explicit readiness callback.
///
/// When the caller already holds the per-launch RPC bearer in memory (the
/// Tauri shell now that the core runs in-process — PR #1061), it should
/// pass `Some(token)` so the embedded server can seed its auth subsystem
/// via [`crate::core::auth::init_rpc_token_with_value`] without ever
/// reading `OPENHUMAN_CORE_TOKEN` from the process environment. Passing
/// `None` preserves the env-as-config fallback (CLI / docker / cloud).
pub async fn run_server_embedded_with_ready(
host: Option<&str>,
port: Option<u16>,
socketio_enabled: bool,
shutdown_token: CancellationToken,
ready_tx: tokio::sync::oneshot::Sender<EmbeddedReadySignal>,
rpc_token: Option<std::sync::Arc<String>>,
) -> anyhow::Result<()> {
run_server_inner(
host,
@@ -1297,6 +1306,7 @@ pub async fn run_server_embedded_with_ready(
true,
Some(shutdown_token),
Some(ready_tx),
rpc_token,
)
.await
}
@@ -1309,6 +1319,7 @@ async fn run_server_inner(
embedded_core: bool,
shutdown_token: Option<CancellationToken>,
ready_tx: Option<tokio::sync::oneshot::Sender<EmbeddedReadySignal>>,
rpc_token: Option<std::sync::Arc<String>>,
) -> anyhow::Result<()> {
// Ensure all controllers are registered before starting.
let _ = all::all_registered_controllers();
@@ -1319,13 +1330,30 @@ async fn run_server_inner(
crate::openhuman::keyring::init_master_key();
// Initialize the per-process RPC bearer token.
// Written to {workspace_dir}/core.token so the Tauri shell can read it.
let token_dir = crate::openhuman::config::default_root_openhuman_dir().unwrap_or_else(|_| {
dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".openhuman")
});
crate::core::auth::init_rpc_token(&token_dir)?;
//
// Preferred path (in-process core spawned by the Tauri shell): the caller
// passes the bearer it already holds in `CoreProcessHandle.rpc_token` as
// `rpc_token: Some(_)`. The token is seeded directly into the auth
// subsystem without ever crossing `OPENHUMAN_CORE_TOKEN` on the process
// environment — closing the same-UID readback channel (sysctl
// KERN_PROCARGS2 / ps eww on macOS, /proc/<pid>/environ on Linux).
//
// Fallback (standalone CLI / docker / cloud `openhuman core run`):
// `rpc_token: None` lets `init_rpc_token` read `OPENHUMAN_CORE_TOKEN`
// from the environment when present (env-as-config — legit operator
// surface), or generate a fresh token and write `{workspace_dir}/core.token`
// (0o600 on Unix) so CLI callers can authenticate.
if let Some(token) = rpc_token.as_deref() {
crate::core::auth::init_rpc_token_with_value(token)?;
} else {
let token_dir =
crate::openhuman::config::default_root_openhuman_dir().unwrap_or_else(|_| {
dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("."))
.join(".openhuman")
});
crate::core::auth::init_rpc_token(&token_dir)?;
}
// Initialize the global MemoryClient so composio providers
// (gmail/slack/notion) can persist their sync_state via kv_get/kv_set,
@@ -1413,11 +1441,34 @@ async fn run_server_inner(
// explicit RPC token. Without this, the entire RPC surface (tool
// execution, file access, credentials) is unauthenticated and reachable
// from the network. See: https://github.com/tinyhumansai/openhuman/issues/1919
//
// "Explicit token" means any of:
// - An in-memory bearer supplied by the embedded caller via the
// `rpc_token` parameter (the Tauri shell hands its
// `CoreProcessHandle.rpc_token` in this way — see
// `init_rpc_token_with_value`). This never lands on the process env.
// - `OPENHUMAN_CORE_TOKEN` set in the process environment (operator
// config for standalone CLI / Docker / cloud).
//
// Checking only the env var would emit a false security warning whenever
// an embedded caller binds on a non-loopback host with an in-memory
// bearer — the server is already protected in that case.
if crate::openhuman::security::pairing::is_public_bind(&resolved_host) {
let has_explicit_token = std::env::var(crate::core::auth::CORE_TOKEN_ENV_VAR)
let has_in_memory_token = rpc_token
.as_deref()
.map(|s| !s.trim().is_empty())
.unwrap_or(false);
let has_env_token = std::env::var(crate::core::auth::CORE_TOKEN_ENV_VAR)
.ok()
.filter(|s| !s.trim().is_empty())
.is_some();
// Auth subsystem was already initialised above; fall back to the live
// token if neither input matched but somehow a token is seeded (e.g.
// a future caller route that doesn't thread the value through here).
let has_initialized_token = crate::core::auth::get_rpc_token()
.map(|t| !t.trim().is_empty())
.unwrap_or(false);
let has_explicit_token = has_in_memory_token || has_env_token || has_initialized_token;
if !has_explicit_token {
log::error!(
"[core] ⚠️ SECURITY WARNING: Binding on public address {resolved_host} without \
@@ -1864,16 +1915,19 @@ pub async fn bootstrap_core_runtime(embedded_core: bool) {
})
.unwrap_or(true)
{
let (session_id, ephemeral) = match std::env::var("OPENHUMAN_CORE_TOKEN")
.ok()
.filter(|s| !s.is_empty())
{
Some(token) => (token, false),
// Read the active bearer from the in-memory auth subsystem instead of
// OPENHUMAN_CORE_TOKEN — same value (init_rpc_token / init_rpc_token_with_value
// both populate the OnceLock that get_rpc_token reads), no env crossing.
// Init ordering: run_server_inner seeds the auth subsystem above
// (line ~1340) before bootstrap_core_runtime runs, so the bearer is
// always present here when supplied.
let (session_id, ephemeral) = match crate::core::auth::get_rpc_token() {
Some(token) => (token.to_string(), false),
None => (format!("session-{}", uuid::Uuid::new_v4()), true),
};
if ephemeral {
log::debug!(
"[runtime] OPENHUMAN_CORE_TOKEN unset; generated ephemeral session_id={session_id} \
"[runtime] auth bearer uninitialized; generated ephemeral session_id={session_id} \
for approval gate — `approval_list_pending` is session-agnostic so pending rows \
from prior launches will still be visible, but per-session audit grouping will not \
correlate across restarts"
+15 -5
View File
@@ -22,7 +22,13 @@ const TEST_RPC_TOKEN: &str = "inference-http-tests-token";
/// Initialize the per-process RPC bearer token exactly once, so that the
/// auth middleware can answer 401 instead of 500 ("auth subsystem not
/// initialized") in tests that don't spin up a real core.
fn ensure_test_rpc_auth() {
///
/// Returns the bearer that is actually installed in `RPC_TOKEN` after init.
/// This may not equal `TEST_RPC_TOKEN`: the `RPC_TOKEN` `OnceLock` is shared
/// across the whole test binary, and a sibling test (e.g. the in-memory-seed
/// regression in `src/core/auth.rs`) may have populated it first. Token-
/// agnostic callers should treat the return value as the source of truth.
fn ensure_test_rpc_auth() -> String {
static INIT: Once = Once::new();
INIT.call_once(|| {
// SAFETY: test-only init; we serialize via `Once`, and live_routing_e2e
@@ -32,11 +38,14 @@ fn ensure_test_rpc_auth() {
let tmp = tempfile::tempdir().expect("tempdir for token file");
crate::core::auth::init_rpc_token(tmp.path()).expect("init rpc auth token for http tests");
});
crate::core::auth::get_rpc_token()
.expect("rpc bearer must be installed after ensure_test_rpc_auth")
.to_string()
}
/// Build the test router (Socket.IO disabled — no real runtime needed).
fn test_router() -> axum::Router {
ensure_test_rpc_auth();
let _ = ensure_test_rpc_auth();
build_core_http_router(false)
}
@@ -93,9 +102,10 @@ async fn test_models_no_bearer_returns_401() {
/// test only asserts that auth passed.
#[tokio::test]
async fn test_chat_completions_with_bearer_not_rejected_as_auth_error() {
// Use the same token that `ensure_test_rpc_auth` installed via the
// `Once` initializer in this module.
let token = TEST_RPC_TOKEN.to_string();
// Read whatever token is actually installed in `RPC_TOKEN`. A sibling
// test in `src/core/auth.rs` may seed the `OnceLock` first with its
// own value, so we cannot rely on `TEST_RPC_TOKEN` matching.
let token = ensure_test_rpc_auth();
let body = serde_json::json!({
"model": "ollama:llama3",