From 336d811ca0ca610b49e82c16a11259c7a4014f28 Mon Sep 17 00:00:00 2001 From: oxoxDev <164490987+oxoxDev@users.noreply.github.com> Date: Fri, 29 May 2026 04:35:33 +0530 Subject: [PATCH] feat(core): pass in-process RPC bearer via internal handle, not process env (#2709) --- .env.example | 4 +- AGENTS.md | 6 +- CLAUDE.md | 6 +- app/src-tauri/src/core_process.rs | 43 +++++-- app/src-tauri/src/core_process_tests.rs | 54 ++++++++ docs/SECURITY_AUDIT.md | 8 +- scripts/test-webhook-flow.sh | 84 ++++++++---- src/core/auth.rs | 164 +++++++++++++++++++----- src/core/jsonrpc.rs | 84 +++++++++--- src/openhuman/inference/http/tests.rs | 20 ++- 10 files changed, 379 insertions(+), 94 deletions(-) diff --git a/.env.example b/.env.example index f063f747b..ab3147376 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/AGENTS.md b/AGENTS.md index 39e6809ea..fc63ecee3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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:/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:/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:/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:/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. --- diff --git a/CLAUDE.md b/CLAUDE.md index 08985a39d..01d45efc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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:/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:/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:/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:/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). diff --git a/app/src-tauri/src/core_process.rs b/app/src-tauri/src/core_process.rs index a8f7f4911..838910302 100644 --- a/app/src-tauri/src/core_process.rs +++ b/app/src-tauri/src/core_process.rs @@ -69,10 +69,15 @@ pub struct CoreProcessHandle { active_port: Arc>, last_port_fallback: Arc>>, /// 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//environ` + /// (Linux) and out of `sysctl KERN_PROCARGS2` / `ps eww -p ` (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, } @@ -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//environ + // (Linux) or sysctl KERN_PROCARGS2 / ps eww -p + // (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"); } diff --git a/app/src-tauri/src/core_process_tests.rs b/app/src-tauri/src/core_process_tests.rs index 7c2605c37..207ea78f2 100644 --- a/app/src-tauri/src/core_process_tests.rs +++ b/app/src-tauri/src/core_process_tests.rs @@ -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//environ` (Linux) or `sysctl KERN_PROCARGS2` / +/// `ps eww -p ` (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] diff --git a/docs/SECURITY_AUDIT.md b/docs/SECURITY_AUDIT.md index 8db0453a3..21050fe24 100644 --- a/docs/SECURITY_AUDIT.md +++ b/docs/SECURITY_AUDIT.md @@ -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`, 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 --- diff --git a/scripts/test-webhook-flow.sh b/scripts/test-webhook-flow.sh index f6a0116ec..c8fb43ebc 100755 --- a/scripts/test-webhook-flow.sh +++ b/scripts/test-webhook-flow.sh @@ -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= 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= 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 diff --git a/src/core/auth.rs b/src/core/auth.rs index 543859f52..f70861161 100644 --- a/src/core/auth.rs +++ b/src/core/auth.rs @@ -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 `. 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//environ` (Linux) and +//! out of `sysctl KERN_PROCARGS2` / `ps eww -p ` (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//environ` (Linux) and out of `sysctl +/// KERN_PROCARGS2` / `ps eww -p ` (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); diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index 0c6d3c5fe..fd006343e 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -1261,7 +1261,7 @@ pub async fn run_server( port: Option, 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, socketio_enabled: bool, shutdown_token: CancellationToken, ready_tx: tokio::sync::oneshot::Sender, + rpc_token: Option>, ) -> 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, ready_tx: Option>, + rpc_token: Option>, ) -> 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//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" diff --git a/src/openhuman/inference/http/tests.rs b/src/openhuman/inference/http/tests.rs index 52682e933..2fbea3b76 100644 --- a/src/openhuman/inference/http/tests.rs +++ b/src/openhuman/inference/http/tests.rs @@ -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",