fix(desktop): connect to self-hosted runtime over LAN http on macOS (#3965)

This commit is contained in:
Mega Mind
2026-06-23 10:27:51 -07:00
committed by GitHub
parent e4c99171c4
commit 5eeedcb2a4
6 changed files with 443 additions and 17 deletions
@@ -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
+125
View File
@@ -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<RequestBuilder, Stri
.ok_or_else(|| "core RPC token is not initialized".to_string())?;
Ok(builder.header("Authorization", format!("Bearer {token}")))
}
/// Verbatim status + body from an upstream runtime, mirrored back to the
/// renderer so it can reuse its existing JSON-RPC envelope parsing.
#[derive(Serialize)]
pub(crate) struct RelayHttpResponse {
pub status: u16,
pub body: String,
}
/// Normalize an optional bearer token into the header value to send, if any.
/// A `None` or blank/whitespace token yields `None` so we never emit an empty
/// `Authorization` header (local OpenAI-compatible runtimes that need no auth
/// would otherwise see a malformed bearer).
fn relay_bearer_header(token: Option<&str>) -> Option<String> {
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::<url::Url>()
.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(|_| "<invalid relay url>".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<String>,
body: String,
) -> Result<RelayHttpResponse, String> {
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);
}
}
+4
View File
@@ -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"))]`
@@ -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<typeof import('../../utils/tauriCommands/common')>();
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);
});
});
@@ -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<string, string>;
@@ -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);
});
});
});
+118 -16
View File
@@ -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<string> {
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<string | null> {
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<string | null> {
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<Response> {
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<Response>((_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<Response> {
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<string, string> = { '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<string> {
@@ -545,7 +630,7 @@ export async function callCoreRpc<T>({
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<T>({
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