diff --git a/app/src-tauri/permissions/allow-core-process.toml b/app/src-tauri/permissions/allow-core-process.toml index a2b60a42f..8147cb78c 100644 --- a/app/src-tauri/permissions/allow-core-process.toml +++ b/app/src-tauri/permissions/allow-core-process.toml @@ -11,6 +11,11 @@ allow = [ # ========================= "core_rpc_url", "core_rpc_token", + # `relay_http_rpc` proxies a JSON-RPC POST through the Rust host so the + # secure webview can reach a self-hosted runtime on a cleartext LAN IP + # without tripping mixed-content/CORS (#3865). Without this allow entry the + # invoke is rejected with "Command not allowed by ACL". + "relay_http_rpc", # `start_core_process` is invoked by BootCheckGate after the user picks # Local mode, before redux-persist hydrates the rest of the app (#1316). # Without this allow entry the invoke is rejected with "Command not diff --git a/app/src-tauri/src/core_rpc.rs b/app/src-tauri/src/core_rpc.rs index b49392dc9..11e53ab7d 100644 --- a/app/src-tauri/src/core_rpc.rs +++ b/app/src-tauri/src/core_rpc.rs @@ -1,6 +1,9 @@ //! Shared helpers for authenticated calls from the Tauri host to the local core RPC. +use std::time::Duration; + use reqwest::RequestBuilder; +use serde::Serialize; const CORE_RPC_URL_ENV: &str = "OPENHUMAN_CORE_RPC_URL"; pub(crate) fn core_rpc_url_value() -> String { @@ -17,3 +20,125 @@ pub(crate) fn apply_auth(builder: RequestBuilder) -> Result) -> Option { + token + .map(str::trim) + .filter(|t| !t.is_empty()) + .map(|t| format!("Bearer {t}")) +} + +/// Redact a relay URL before it lands in a log line or error string: drop the +/// query, fragment, and any userinfo (which can carry tokens/credentials), +/// keeping just `scheme://host[:port]/path` so transport diagnostics stay +/// useful without persisting secrets. Falls back to a coarse sentinel when the +/// URL can't be parsed. +fn redact_url_for_log(url: &str) -> String { + url.parse::() + .map(|mut parsed| { + parsed.set_query(None); + parsed.set_fragment(None); + let _ = parsed.set_username(""); + let _ = parsed.set_password(None); + parsed.to_string() + }) + .unwrap_or_else(|_| "".to_string()) +} + +/// POST a JSON-RPC body to an arbitrary self-hosted runtime URL from the Rust +/// host instead of the webview. +/// +/// Why this exists (#3865): the desktop webview origin is `tauri://localhost`, +/// a *secure context*. Chromium treats `http://127.0.0.1` / `localhost` as +/// "potentially trustworthy", so browser `fetch()` to the embedded local core +/// works — but a self-hosted runtime on a LAN IP (e.g. +/// `http://192.168.1.74:7788`) is plain cleartext from a secure context, so the +/// fetch is blocked as mixed content before any request leaves the browser +/// ("Failed to fetch", and the runtime never logs a `/rpc` hit) even though the +/// endpoint is healthy and reachable from curl/Safari. Issuing the request from +/// the Rust host with `reqwest` bypasses the webview's mixed-content / CORS +/// restrictions entirely — the same way the shell already talks to the local +/// core. +/// +/// Returns the upstream status + body verbatim (including JSON-RPC error +/// envelopes and any 4xx/5xx) so the renderer keeps its existing handling; only +/// transport-level failures (DNS, connect, timeout) surface as `Err`. +#[tauri::command] +pub(crate) async fn relay_http_rpc( + url: String, + token: Option, + body: String, +) -> Result { + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(30)) + .build() + .map_err(|e| format!("failed to build HTTP client: {e}"))?; + + let mut builder = client + .post(&url) + .header("Content-Type", "application/json") + .body(body); + + let bearer = relay_bearer_header(token.as_deref()); + if let Some(value) = bearer.as_deref() { + builder = builder.header("Authorization", value); + } + + let safe_url = redact_url_for_log(&url); + log::debug!( + "[core_rpc][relay] POST {safe_url} (auth={})", + bearer.is_some() + ); + + let resp = builder + .send() + .await + .map_err(|e| format!("request to {safe_url} failed: {e}"))?; + let status = resp.status().as_u16(); + let text = resp + .text() + .await + .map_err(|e| format!("failed to read response body from {safe_url}: {e}"))?; + log::debug!( + "[core_rpc][relay] ← {safe_url} status={status} body_len={}", + text.len() + ); + Ok(RelayHttpResponse { status, body: text }) +} + +#[cfg(test)] +mod tests { + use super::relay_bearer_header; + + #[test] + fn bearer_header_present_for_real_token() { + assert_eq!( + relay_bearer_header(Some("tok123")).as_deref(), + Some("Bearer tok123") + ); + // Surrounding whitespace is trimmed before formatting. + assert_eq!( + relay_bearer_header(Some(" tok123 ")).as_deref(), + Some("Bearer tok123") + ); + } + + #[test] + fn bearer_header_absent_for_missing_or_blank_token() { + assert_eq!(relay_bearer_header(None), None); + assert_eq!(relay_bearer_header(Some("")), None); + assert_eq!(relay_bearer_header(Some(" ")), None); + } +} diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index c6708aed0..bf688c927 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -3525,6 +3525,10 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ core_rpc_url, core_rpc_token, + // Host-side HTTP relay so the renderer can reach a self-hosted + // runtime on a LAN IP that the secure `tauri://localhost` webview + // cannot fetch directly (cleartext mixed content). See #3865. + core_rpc::relay_http_rpc, overlay_parent_rpc_url, process_diagnostics_list_owned, // `mod artifact_commands;` is `#[cfg(any(target_os = "macos", target_os = "linux"))]` diff --git a/app/src/services/__tests__/coreRpcClient.selfHostedRelay.test.ts b/app/src/services/__tests__/coreRpcClient.selfHostedRelay.test.ts new file mode 100644 index 000000000..11876b2fc --- /dev/null +++ b/app/src/services/__tests__/coreRpcClient.selfHostedRelay.test.ts @@ -0,0 +1,107 @@ +/** + * Regression tests for #3865 — connecting to a self-hosted runtime on a LAN IP + * from the macOS desktop app. + * + * The desktop webview origin is the secure `tauri://localhost` context, so a + * direct `fetch()` to a non-loopback cleartext-http runtime (e.g. + * `http://192.168.1.74:7788/rpc`) is blocked as mixed content ("Failed to + * fetch") before the request ever leaves the browser. The fix relays such + * requests through the Rust host (`relay_http_rpc` Tauri command); loopback and + * https URLs keep using the direct fetch path. + */ +import { invoke } from '@tauri-apps/api/core'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { rpcUrlNeedsShellRelay, testCoreRpcConnection } from '../coreRpcClient'; + +vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn(() => true) })); +vi.mock('../../utils/tauriCommands/common', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, isTauri: vi.fn(() => true) }; +}); +vi.mock('../../lib/ai/localCoreAiMemory', () => ({ + dispatchLocalAiMethod: vi.fn(async () => ({ source: 'local-ai' })), +})); + +describe('rpcUrlNeedsShellRelay', () => { + test('non-loopback cleartext-http URLs need the shell relay', () => { + expect(rpcUrlNeedsShellRelay('http://192.168.1.74:7788/rpc')).toBe(true); + expect(rpcUrlNeedsShellRelay('http://10.0.0.5:7788/rpc')).toBe(true); + expect(rpcUrlNeedsShellRelay('http://my-nas:7788/rpc')).toBe(true); + }); + + test('loopback http is fetchable directly (potentially trustworthy)', () => { + expect(rpcUrlNeedsShellRelay('http://127.0.0.1:7788/rpc')).toBe(false); + expect(rpcUrlNeedsShellRelay('http://localhost:7788/rpc')).toBe(false); + expect(rpcUrlNeedsShellRelay('http://[::1]:7788/rpc')).toBe(false); + expect(rpcUrlNeedsShellRelay('http://app.localhost:7788/rpc')).toBe(false); + }); + + test('https is never mixed content, so no relay needed', () => { + expect(rpcUrlNeedsShellRelay('https://192.168.1.74:7788/rpc')).toBe(false); + expect(rpcUrlNeedsShellRelay('https://core.example.com/rpc')).toBe(false); + }); + + test('malformed URLs do not trigger the relay', () => { + expect(rpcUrlNeedsShellRelay('not a url')).toBe(false); + expect(rpcUrlNeedsShellRelay('')).toBe(false); + }); +}); + +describe('testCoreRpcConnection (self-hosted runtime, #3865)', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('fetch', vi.fn()); + }); + + test('relays through the Rust host for a non-loopback http runtime', async () => { + vi.mocked(invoke).mockResolvedValueOnce({ + status: 200, + body: '{"jsonrpc":"2.0","id":1,"result":{"ok":true}}', + }); + + const res = await testCoreRpcConnection('http://192.168.1.74:7788/rpc', 'tok123'); + + expect(invoke).toHaveBeenCalledWith('relay_http_rpc', { + url: 'http://192.168.1.74:7788/rpc', + token: 'tok123', + body: expect.stringContaining('core.ping'), + }); + expect(fetch).not.toHaveBeenCalled(); + expect(res.status).toBe(200); + expect(res.ok).toBe(true); + const json = (await res.json()) as { result: { ok: boolean } }; + expect(json.result.ok).toBe(true); + }); + + test('normalizes a base URL without /rpc before relaying', async () => { + vi.mocked(invoke).mockResolvedValueOnce({ status: 200, body: '{}' }); + + await testCoreRpcConnection('http://192.168.1.74:7788', 'tok123'); + + expect(invoke).toHaveBeenCalledWith( + 'relay_http_rpc', + expect.objectContaining({ url: 'http://192.168.1.74:7788/rpc' }) + ); + }); + + test('uses a direct fetch for a loopback runtime (no relay)', async () => { + vi.mocked(fetch).mockResolvedValueOnce( + new Response('{"jsonrpc":"2.0","id":1,"result":{"ok":true}}', { status: 200 }) + ); + + await testCoreRpcConnection('http://127.0.0.1:7788/rpc', 'tok123'); + + expect(fetch).toHaveBeenCalledTimes(1); + expect(invoke).not.toHaveBeenCalledWith('relay_http_rpc', expect.anything()); + }); + + test('surfaces an upstream 401 verbatim through the relay', async () => { + vi.mocked(invoke).mockResolvedValueOnce({ status: 401, body: 'Unauthorized' }); + + const res = await testCoreRpcConnection('http://192.168.1.74:7788/rpc', 'bad-token'); + + expect(res.status).toBe(401); + expect(res.ok).toBe(false); + }); +}); diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts index e82d577cf..f075b009f 100644 --- a/app/src/services/__tests__/coreRpcClient.test.ts +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -622,7 +622,10 @@ describe('coreRpcClient', () => { const fetchMock = vi.mocked(fetch); fetchMock.mockResolvedValueOnce({ ok: true, status: 200 } as Response); - await testCoreRpcConnection('http://example.test:7788/rpc'); + // Trustworthy localhost http stays on the direct fetch path even in + // Tauri (no shell relay), so the bearer header is attached here. A + // non-trustworthy LAN host would relay instead — covered separately below. + await testCoreRpcConnection('http://127.0.0.1:7788/rpc'); const requestInit = fetchMock.mock.calls[0][1] as RequestInit; const headers = requestInit.headers as Record; @@ -643,6 +646,86 @@ describe('coreRpcClient', () => { expect(response).toBe(probe); expect(response.status).toBe(405); }); + + test('relays through the Rust host for non-trustworthy http URLs in Tauri (#3865)', async () => { + vi.resetModules(); + vi.mocked(isTauri).mockReturnValue(true); + const invokeMock = vi.mocked(invoke); + invokeMock.mockImplementation(async (cmd: string) => { + if (cmd === 'core_rpc_token') return 'deadbeef'; + if (cmd === 'relay_http_rpc') { + return { status: 200, body: '{"jsonrpc":"2.0","id":1,"result":{}}' }; + } + throw new Error(`unexpected command: ${cmd}`); + }); + const { testCoreRpcConnection } = await import('../coreRpcClient'); + + const response = await testCoreRpcConnection('http://192.168.1.50:7788/rpc'); + + // LAN http can't be fetched cross-origin from the secure tauri webview, + // so it must be relayed through the Rust host carrying the bearer token. + const relayCall = invokeMock.mock.calls.find(call => call[0] === 'relay_http_rpc'); + expect(relayCall).toBeDefined(); + const relayArgs = relayCall![1] as { url: string; token: string | null; body: string }; + expect(relayArgs.url).toContain('192.168.1.50'); + expect(relayArgs.token).toBe('deadbeef'); + expect(response.status).toBe(200); + }); + + test('rpcUrlNeedsShellRelay flags only non-trustworthy http URLs', async () => { + vi.resetModules(); + const { rpcUrlNeedsShellRelay } = await import('../coreRpcClient'); + expect(rpcUrlNeedsShellRelay('http://192.168.1.50:7788/rpc')).toBe(true); + expect(rpcUrlNeedsShellRelay('http://127.0.0.1:7788/rpc')).toBe(false); + expect(rpcUrlNeedsShellRelay('http://localhost:7788/rpc')).toBe(false); + expect(rpcUrlNeedsShellRelay('https://example.test:7788/rpc')).toBe(false); + expect(rpcUrlNeedsShellRelay('not a url')).toBe(false); + }); + + test('rejects with AbortError when the relay signal is already aborted', async () => { + vi.resetModules(); + vi.mocked(isTauri).mockReturnValue(true); + vi.mocked(invoke).mockImplementation(async (cmd: string) => { + if (cmd === 'core_rpc_token') return 'deadbeef'; + if (cmd === 'relay_http_rpc') return { status: 200, body: '{}' }; + throw new Error(`unexpected command: ${cmd}`); + }); + const { testCoreRpcConnection } = await import('../coreRpcClient'); + + const controller = new AbortController(); + controller.abort(); + await expect( + testCoreRpcConnection('http://192.168.1.50:7788/rpc', undefined, { + signal: controller.signal, + }) + ).rejects.toThrow(/abort/i); + }); + }); + + describe('callCoreRpc shell relay (#3865)', () => { + test('relays via the Rust host for a non-trustworthy http core URL in Tauri', async () => { + vi.resetModules(); + vi.mocked(isTauri).mockReturnValue(true); + const invokeMock = vi.mocked(invoke); + invokeMock.mockImplementation(async (cmd: string) => { + if (cmd === 'core_rpc_url') return 'http://192.168.1.50:7788/rpc'; + if (cmd === 'core_rpc_token') return 'deadbeef'; + if (cmd === 'relay_http_rpc') { + return { status: 200, body: '{"jsonrpc":"2.0","id":1,"result":{"ok":true}}' }; + } + throw new Error(`unexpected command: ${cmd}`); + }); + const fetchMock = vi.mocked(fetch); + const { callCoreRpc } = await import('../coreRpcClient'); + + const result = await callCoreRpc<{ ok: boolean }>({ method: 'openhuman.threads_list' }); + + // A LAN http core URL must be relayed through the Rust host (with the + // call's abort signal), never fetched cross-origin from the webview. + expect(result).toEqual({ ok: true }); + expect(fetchMock).not.toHaveBeenCalled(); + expect(invokeMock.mock.calls.some(call => call[0] === 'relay_http_rpc')).toBe(true); + }); }); }); diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index 2925f3e10..e2ededbde 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -6,7 +6,12 @@ import { CORE_RPC_TIMEOUT_MS, CORE_RPC_URL } from '../utils/config'; import { getStoredCoreToken, normalizeRpcUrl, peekStoredRpcUrl } from '../utils/configPersistence'; import { redactRpcUrlForLog } from '../utils/redactRpcUrlForLog'; import { sanitizeError } from '../utils/sanitize'; -import { isTauri as coreIsTauri } from '../utils/tauriCommands/common'; +// The bridge-gap-aware Tauri guard: returns true only when the IPC bridge +// (`__TAURI_INTERNALS__.invoke`) is actually wired, not merely when running +// under Tauri — so command invokes below (incl. relay_http_rpc) never fire +// before the bridge is usable. Prefer this over the bare @tauri-apps/api/core +// `isTauri`, which doesn't verify bridge readiness. +import { isTauri } from '../utils/tauriCommands/common'; import { normalizeRpcMethod } from './rpcMethods'; import type { CoreTransport } from './transport/CoreTransport'; @@ -318,7 +323,7 @@ export async function getCoreRpcUrl(): Promise { return resolvedCoreRpcUrl; } - if (!coreIsTauri()) { + if (!isTauri()) { // Web environment: respect any user-stored URL (including one that // happens to equal the build-time default). `peekStoredRpcUrl` returns // null when nothing is stored, which lets us distinguish "user hasn't @@ -413,7 +418,7 @@ export async function getCoreRpcToken(): Promise { return resolvedCoreRpcToken; } - if (!coreIsTauri()) return null; + if (!isTauri()) return null; if (resolvingCoreRpcToken) return resolvingCoreRpcToken; resolvingCoreRpcToken = (async () => { @@ -436,6 +441,81 @@ export async function getCoreRpcToken(): Promise { return resolvingCoreRpcToken; } +/** + * Hosts the webview can reach over cleartext http from its secure + * `tauri://localhost` origin. Chromium treats these as "potentially + * trustworthy" (W3C secure-contexts), so browser `fetch()` works; any other + * host over plain http is mixed content and is blocked. + */ +function isPotentiallyTrustworthyHost(host: string): boolean { + const h = host.replace(/^\[|\]$/g, '').toLowerCase(); + return h === '127.0.0.1' || h === 'localhost' || h === '::1' || h.endsWith('.localhost'); +} + +/** + * Whether `rpcUrl` must be relayed through the Rust host instead of a direct + * webview `fetch()`. + * + * The desktop webview origin is `tauri://localhost`, a secure context. Plain + * `http://` to a non-loopback host (e.g. a self-hosted runtime at + * `http://192.168.1.74:7788`) is active mixed content → Chromium blocks it + * before the request leaves the browser ("Failed to fetch"), which is exactly + * the #3865 symptom. Loopback http and any https URL are fine to fetch + * directly, so only non-loopback http needs the shell relay. + */ +export function rpcUrlNeedsShellRelay(rpcUrl: string): boolean { + let parsed: URL; + try { + parsed = new URL(rpcUrl); + } catch { + return false; + } + if (parsed.protocol !== 'http:') return false; + return !isPotentiallyTrustworthyHost(parsed.hostname); +} + +/** + * Perform a JSON-RPC POST via the Rust host (`relay_http_rpc` Tauri command), + * returning a synthesized `Response` so callers reuse their existing + * `.ok` / `.status` / `.json()` / `.text()` handling unchanged. Used for + * self-hosted runtimes the webview cannot fetch directly (#3865). The Rust + * client carries a 30s timeout; the optional `signal` lets the caller's own + * timeout/abort still reject the await. + */ +async function relayRpcViaShell( + rpcUrl: string, + token: string | null, + body: string, + signal?: AbortSignal +): Promise { + const invokePromise = invoke<{ status: number; body: string }>('relay_http_rpc', { + url: rpcUrl, + token: token ?? null, + body, + }).then( + res => + new Response(res.body, { + status: res.status, + headers: { 'Content-Type': 'application/json' }, + }) + ); + + if (!signal) return invokePromise; + + return Promise.race([ + invokePromise, + new Promise((_resolve, reject) => { + if (signal.aborted) { + reject(new DOMException('Aborted', 'AbortError')); + return; + } + signal.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')), { + once: true, + }); + }), + ]); +} + /** * Probe an arbitrary core RPC URL with `core.ping`. Used by the * Welcome page's "Test Connection" affordance to validate a user-entered @@ -457,16 +537,21 @@ export async function testCoreRpcConnection( ): Promise { const rpcUrl = normalizeRpcUrl(url); const token = tokenOverride?.trim() || (await getCoreRpcToken()); + const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'core.ping', params: {} }); + + // Self-hosted runtime on a LAN IP: the secure `tauri://localhost` webview + // cannot fetch cleartext http cross-origin (mixed content → "Failed to + // fetch"), so relay the request through the Rust host (#3865). + if (isTauri() && rpcUrlNeedsShellRelay(rpcUrl)) { + coreRpcLog('[rpc] test connection relaying via shell url=%s', redactRpcUrlForLog(rpcUrl)); + return relayRpcViaShell(rpcUrl, token ?? null, body, init?.signal); + } + const headers: Record = { 'Content-Type': 'application/json' }; if (token) { headers.Authorization = `Bearer ${token}`; } - return fetch(rpcUrl, { - method: 'POST', - headers, - body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'core.ping', params: {} }), - signal: init?.signal, - }); + return fetch(rpcUrl, { method: 'POST', headers, body, signal: init?.signal }); } export async function getCoreHttpBaseUrl(): Promise { @@ -545,7 +630,7 @@ export async function callCoreRpc({ tokenSource: getStoredCoreToken() ? 'cloud-stored' : 'local-resolved', }); } - if (coreIsTauri() && !token) { + if (isTauri() && !token) { throw new Error('Core RPC token unavailable in Tauri; local RPC auth cannot be satisfied'); } @@ -563,12 +648,29 @@ export async function callCoreRpc({ const timeoutId = setTimeout(() => controller.abort(), effectiveTimeoutMs); let response: Response; try { - response = await fetch(rpcUrl, { - method: 'POST', - headers, - body: JSON.stringify(payload), - signal: controller.signal, - }); + if (isTauri() && rpcUrlNeedsShellRelay(rpcUrl)) { + // Self-hosted runtime on a LAN IP — the secure `tauri://localhost` + // webview can't fetch cleartext http cross-origin (mixed content), so + // relay through the Rust host. Loopback (local core) and https stay on + // the direct fetch path below. See #3865. + coreRpcLog( + '[rpc] relaying via shell (non-loopback http) url=%s', + redactRpcUrlForLog(rpcUrl) + ); + response = await relayRpcViaShell( + rpcUrl, + token, + JSON.stringify(payload), + controller.signal + ); + } else { + response = await fetch(rpcUrl, { + method: 'POST', + headers, + body: JSON.stringify(payload), + signal: controller.signal, + }); + } } catch (fetchErr) { if (controller.signal.aborted) { // Throw a fully-classified `CoreRpcError` here so the outer catch