mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat(boot): cloud-mode picker auth + reload-resilient core mode (#1357)
Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com>
This commit is contained in:
co-authored by
Steven Enamakel
parent
b8922c2e28
commit
d6ace5a5bb
@@ -152,7 +152,15 @@ async fn run_scanner<R: Runtime>(app: AppHandle<R>, account_id: String) {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[imessage] tick failed err={}", e);
|
||||
let msg = e.to_string();
|
||||
// Cloud-mode users have no local core sidecar, so the local
|
||||
// RPC token is never initialized — every tick would otherwise
|
||||
// spam WARN. Drop those to debug; everything else stays loud.
|
||||
if msg.contains("core RPC token is not initialized") {
|
||||
log::debug!("[imessage] local core not running — skipping tick");
|
||||
} else {
|
||||
log::warn!("[imessage] tick failed err={}", msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,16 @@ import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
|
||||
import { type BootCheckResult, runBootCheck } from '../../lib/bootCheck';
|
||||
import { bootCheckTransport } from '../../services/bootCheckService';
|
||||
import { clearCoreRpcUrlCache } from '../../services/coreRpcClient';
|
||||
import { clearCoreRpcTokenCache, clearCoreRpcUrlCache } from '../../services/coreRpcClient';
|
||||
import { type CoreMode, resetCoreMode, setCoreMode } from '../../store/coreModeSlice';
|
||||
import { useAppDispatch, useAppSelector } from '../../store/hooks';
|
||||
import { storeRpcUrl } from '../../utils/configPersistence';
|
||||
import {
|
||||
clearStoredCoreMode,
|
||||
clearStoredCoreToken,
|
||||
storeCoreMode,
|
||||
storeCoreToken,
|
||||
storeRpcUrl,
|
||||
} from '../../utils/configPersistence';
|
||||
|
||||
const log = debug('boot-check');
|
||||
const logError = debug('boot-check:error');
|
||||
@@ -60,7 +66,9 @@ interface PickerProps {
|
||||
function ModePicker({ onConfirm }: PickerProps) {
|
||||
const [selected, setSelected] = useState<'local' | 'cloud'>('local');
|
||||
const [cloudUrl, setCloudUrl] = useState('');
|
||||
const [cloudToken, setCloudToken] = useState('');
|
||||
const [urlError, setUrlError] = useState<string | null>(null);
|
||||
const [tokenError, setTokenError] = useState<string | null>(null);
|
||||
|
||||
const handleContinue = () => {
|
||||
if (selected === 'local') {
|
||||
@@ -70,13 +78,13 @@ function ModePicker({ onConfirm }: PickerProps) {
|
||||
}
|
||||
|
||||
// Basic URL validation: must be http(s)
|
||||
const trimmed = cloudUrl.trim();
|
||||
if (!trimmed) {
|
||||
const trimmedUrl = cloudUrl.trim();
|
||||
if (!trimmedUrl) {
|
||||
setUrlError('Please enter a core URL.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
const parsed = new URL(trimmedUrl);
|
||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||
setUrlError('URL must start with http:// or https://');
|
||||
return;
|
||||
@@ -85,10 +93,24 @@ function ModePicker({ onConfirm }: PickerProps) {
|
||||
setUrlError('Please enter a valid URL (e.g. https://core.example.com/rpc)');
|
||||
return;
|
||||
}
|
||||
|
||||
setUrlError(null);
|
||||
log('[boot-check] picker — user selected cloud mode url=%s', trimmed);
|
||||
onConfirm({ kind: 'cloud', url: trimmed });
|
||||
|
||||
// Cloud cores require a bearer token (OPENHUMAN_CORE_TOKEN). Without one
|
||||
// every /rpc call would 401 and the user would be stuck on the boot
|
||||
// gate; require it up front rather than failing later.
|
||||
const trimmedToken = cloudToken.trim();
|
||||
if (!trimmedToken) {
|
||||
setTokenError('Please enter the core auth token.');
|
||||
return;
|
||||
}
|
||||
setTokenError(null);
|
||||
|
||||
log(
|
||||
'[boot-check] picker — user selected cloud mode url=%s tokenLen=%d',
|
||||
trimmedUrl,
|
||||
trimmedToken.length
|
||||
);
|
||||
onConfirm({ kind: 'cloud', url: trimmedUrl, token: trimmedToken });
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -130,18 +152,43 @@ function ModePicker({ onConfirm }: PickerProps) {
|
||||
</button>
|
||||
|
||||
{selected === 'cloud' && (
|
||||
<div className="mt-1 flex flex-col gap-1">
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://core.example.com/rpc"
|
||||
value={cloudUrl}
|
||||
onChange={e => {
|
||||
setCloudUrl(e.target.value);
|
||||
setUrlError(null);
|
||||
}}
|
||||
className="rounded-lg border border-stone-600 bg-stone-800 px-3 py-2 text-sm text-white placeholder-stone-500 focus:border-ocean-500 focus:outline-none"
|
||||
/>
|
||||
{urlError && <p className="text-xs text-coral-400">{urlError}</p>}
|
||||
<div className="mt-1 flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-stone-300">Core RPC URL</label>
|
||||
<input
|
||||
type="url"
|
||||
placeholder="https://core.example.com/rpc"
|
||||
value={cloudUrl}
|
||||
onChange={e => {
|
||||
setCloudUrl(e.target.value);
|
||||
setUrlError(null);
|
||||
}}
|
||||
className="rounded-lg border border-stone-600 bg-stone-800 px-3 py-2 text-sm text-white placeholder-stone-500 focus:border-ocean-500 focus:outline-none"
|
||||
/>
|
||||
{urlError && <p className="text-xs text-coral-400">{urlError}</p>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-stone-300">
|
||||
Auth token (<code className="text-[10px]">OPENHUMAN_CORE_TOKEN</code>)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="Bearer token configured on the remote core"
|
||||
value={cloudToken}
|
||||
onChange={e => {
|
||||
setCloudToken(e.target.value);
|
||||
setTokenError(null);
|
||||
}}
|
||||
className="rounded-lg border border-stone-600 bg-stone-800 px-3 py-2 text-sm text-white placeholder-stone-500 focus:border-ocean-500 focus:outline-none"
|
||||
/>
|
||||
{tokenError && <p className="text-xs text-coral-400">{tokenError}</p>}
|
||||
<p className="text-[11px] text-stone-500">
|
||||
Stored on this device only. Required for remote cores — the desktop sends it as{' '}
|
||||
<code>Authorization: Bearer …</code> on every RPC.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -429,6 +476,24 @@ export default function BootCheckGate({ children }: BootCheckGateProps) {
|
||||
const handlePickerConfirm = useCallback(
|
||||
(mode: CoreMode) => {
|
||||
log('[boot-check] gate — picker confirmed mode=%s', mode.kind);
|
||||
// Persist URL + token for cloud mode so getCoreRpcUrl/Token resolve
|
||||
// correctly on the boot-check probe (and every subsequent RPC) without
|
||||
// waiting for redux-persist's async rehydrate to complete. Also write
|
||||
// the synchronous `openhuman_core_mode` marker so a reload triggered
|
||||
// mid-flight (e.g. `handleIdentityFlip` → `restartApp`) recovers the
|
||||
// chosen mode from localStorage before redux-persist flushes. Clear
|
||||
// caches so any prior local-mode resolution doesn't leak into cloud.
|
||||
if (mode.kind === 'cloud') {
|
||||
storeRpcUrl(mode.url);
|
||||
storeCoreToken(mode.token ?? '');
|
||||
storeCoreMode('cloud');
|
||||
} else {
|
||||
storeRpcUrl('');
|
||||
clearStoredCoreToken();
|
||||
storeCoreMode('local');
|
||||
}
|
||||
clearCoreRpcUrlCache();
|
||||
clearCoreRpcTokenCache();
|
||||
dispatch(setCoreMode(mode));
|
||||
setPhase('checking');
|
||||
},
|
||||
@@ -441,7 +506,10 @@ export default function BootCheckGate({ children }: BootCheckGateProps) {
|
||||
const handleSwitchMode = useCallback(() => {
|
||||
log('[boot-check] gate — switch mode requested');
|
||||
storeRpcUrl('');
|
||||
clearStoredCoreToken();
|
||||
clearStoredCoreMode();
|
||||
clearCoreRpcUrlCache();
|
||||
clearCoreRpcTokenCache();
|
||||
dispatch(resetCoreMode());
|
||||
setPhase('picker');
|
||||
setResult(null);
|
||||
|
||||
@@ -27,10 +27,15 @@ vi.mock('../../../lib/bootCheck', () => ({
|
||||
vi.mock('../../../services/coreRpcClient', () => ({
|
||||
callCoreRpc: vi.fn(),
|
||||
clearCoreRpcUrlCache: vi.fn(),
|
||||
clearCoreRpcTokenCache: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../utils/configPersistence', () => ({
|
||||
storeRpcUrl: vi.fn(),
|
||||
storeCoreToken: vi.fn(),
|
||||
clearStoredCoreToken: vi.fn(),
|
||||
storeCoreMode: vi.fn(),
|
||||
clearStoredCoreMode: vi.fn(),
|
||||
isValidRpcUrl: vi.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
@@ -114,6 +119,71 @@ describe('BootCheckGate — picker (unset mode)', () => {
|
||||
|
||||
expect(screen.getByText(/must start with http/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows URL validation error for malformed URL string', () => {
|
||||
renderGate();
|
||||
|
||||
fireEvent.click(screen.getByText('Cloud'));
|
||||
const input = screen.getByPlaceholderText(/https:\/\/core\.example\.com/);
|
||||
fireEvent.change(input, { target: { value: 'not a url at all' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
|
||||
expect(screen.getByText(/Please enter a valid URL/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows token validation error when cloud URL is valid but token is missing', () => {
|
||||
renderGate();
|
||||
|
||||
fireEvent.click(screen.getByText('Cloud'));
|
||||
const urlInput = screen.getByPlaceholderText(/https:\/\/core\.example\.com/);
|
||||
fireEvent.change(urlInput, { target: { value: 'https://core.example.com/rpc' } });
|
||||
// Token left blank.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
|
||||
expect(screen.getByText(/Please enter the core auth token/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clears the token error as soon as the user types into the token field', () => {
|
||||
renderGate();
|
||||
|
||||
fireEvent.click(screen.getByText('Cloud'));
|
||||
fireEvent.change(screen.getByPlaceholderText(/https:\/\/core\.example\.com/), {
|
||||
target: { value: 'https://core.example.com/rpc' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
expect(screen.getByText(/Please enter the core auth token/i)).toBeInTheDocument();
|
||||
|
||||
const tokenInput = screen.getByPlaceholderText(/Bearer token/i);
|
||||
fireEvent.change(tokenInput, { target: { value: 'tok' } });
|
||||
|
||||
expect(screen.queryByText(/Please enter the core auth token/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('advances past picker and triggers boot check when cloud URL + token are both set', async () => {
|
||||
mockRunBootCheck.mockResolvedValue({ kind: 'match' });
|
||||
|
||||
renderGate();
|
||||
fireEvent.click(screen.getByText('Cloud'));
|
||||
fireEvent.change(screen.getByPlaceholderText(/https:\/\/core\.example\.com/), {
|
||||
target: { value: 'https://core.example.com/rpc' },
|
||||
});
|
||||
fireEvent.change(screen.getByPlaceholderText(/Bearer token/i), {
|
||||
target: { value: 'tok-1234' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Continue' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('app-content')).toBeInTheDocument();
|
||||
});
|
||||
expect(mockRunBootCheck).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: 'cloud',
|
||||
url: 'https://core.example.com/rpc',
|
||||
token: 'tok-1234',
|
||||
}),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BootCheckGate — checking state', () => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { invoke, isTauri } from '@tauri-apps/api/core';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { triggerSentryTestEvent } from '../../../services/analytics';
|
||||
import { useAppSelector } from '../../../store/hooks';
|
||||
import { APP_ENVIRONMENT } from '../../../utils/config';
|
||||
import SettingsHeader from '../components/SettingsHeader';
|
||||
import SettingsMenuItem from '../components/SettingsMenuItem';
|
||||
@@ -209,6 +210,72 @@ const developerItems = [
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Small badge showing whether the desktop is talking to the embedded local
|
||||
* core or a user-configured remote (cloud) core. Read straight from the
|
||||
* `coreMode` Redux slice so it always reflects what `coreRpcClient` will
|
||||
* resolve on the next call. For cloud mode also surfaces the (masked) URL
|
||||
* + a "token set" indicator so users debugging a misconfigured cloud
|
||||
* deployment can verify they actually entered both pieces in the picker.
|
||||
*/
|
||||
const CoreModeBadge = () => {
|
||||
const mode = useAppSelector(state => state.coreMode.mode);
|
||||
|
||||
if (mode.kind === 'unset') {
|
||||
return (
|
||||
<div className="px-4 py-3 mb-3 rounded-lg border border-coral-300 bg-coral-50">
|
||||
<div className="text-sm font-semibold text-coral-900">Core mode: not set</div>
|
||||
<div className="text-xs text-coral-800 mt-0.5">
|
||||
The boot-check picker hasn't been confirmed yet. Use Switch mode on the picker to
|
||||
choose Local or Cloud.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode.kind === 'local') {
|
||||
return (
|
||||
<div className="px-4 py-3 mb-3 rounded-lg border border-ocean-300 bg-ocean-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 rounded-full bg-ocean-600 text-white text-[11px] font-medium">
|
||||
Local
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-ocean-900">Embedded core sidecar</span>
|
||||
</div>
|
||||
<div className="text-xs text-ocean-800 mt-1">
|
||||
Spawned in-process by the Tauri shell on app launch.
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Cloud — show URL + token status. Token value itself is never rendered.
|
||||
return (
|
||||
<div className="px-4 py-3 mb-3 rounded-lg border border-sage-300 bg-sage-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="px-2 py-0.5 rounded-full bg-sage-600 text-white text-[11px] font-medium">
|
||||
Cloud
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-sage-900">Remote core RPC</span>
|
||||
</div>
|
||||
<dl className="mt-2 grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs">
|
||||
<dt className="text-sage-700">URL:</dt>
|
||||
<dd className="font-mono text-sage-900 truncate" title={mode.url}>
|
||||
{mode.url}
|
||||
</dd>
|
||||
<dt className="text-sage-700">Token:</dt>
|
||||
<dd className="text-sage-900">
|
||||
{mode.token ? (
|
||||
<span className="font-mono">••••••{mode.token.slice(-4)}</span>
|
||||
) : (
|
||||
<span className="text-coral-600">not set — RPC will 401</span>
|
||||
)}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type SentryTestStatus =
|
||||
| { kind: 'idle' }
|
||||
| { kind: 'sending' }
|
||||
@@ -341,6 +408,7 @@ const DeveloperOptionsPanel = () => {
|
||||
/>
|
||||
|
||||
<div>
|
||||
<CoreModeBadge />
|
||||
<LogsFolderRow />
|
||||
{showSentryTest && <SentryTestRow />}
|
||||
{developerItems.map((item, index) => (
|
||||
|
||||
@@ -39,6 +39,57 @@ async function importPanel() {
|
||||
return mod.default;
|
||||
}
|
||||
|
||||
describe('DeveloperOptionsPanel — CoreModeBadge', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.invoke.mockReset();
|
||||
hoisted.invoke.mockResolvedValue(null);
|
||||
hoisted.isTauri.mockReset();
|
||||
hoisted.isTauri.mockReturnValue(true);
|
||||
hoisted.appEnvironment = 'production';
|
||||
});
|
||||
|
||||
test('shows "Local" pill when coreMode is local', async () => {
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />, { preloadedState: { coreMode: { mode: { kind: 'local' } } } });
|
||||
expect(screen.getByText('Local')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Embedded core sidecar/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows "Cloud" pill plus URL and masked token tail when coreMode is cloud', async () => {
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />, {
|
||||
preloadedState: {
|
||||
coreMode: {
|
||||
mode: { kind: 'cloud', url: 'https://core.example.com/rpc', token: 'abc1234' },
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(screen.getByText('Cloud')).toBeInTheDocument();
|
||||
expect(screen.getByText('https://core.example.com/rpc')).toBeInTheDocument();
|
||||
expect(screen.getByText('••••••1234')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('flags missing token in cloud mode', async () => {
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />, {
|
||||
preloadedState: {
|
||||
coreMode: { mode: { kind: 'cloud', url: 'https://core.example.com/rpc' } },
|
||||
},
|
||||
});
|
||||
expect(screen.getByText(/not set — RPC will 401/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows "not set" warning when coreMode is unset', async () => {
|
||||
vi.resetModules();
|
||||
const Panel = await importPanel();
|
||||
renderWithProviders(<Panel />, { preloadedState: { coreMode: { mode: { kind: 'unset' } } } });
|
||||
expect(screen.getByText(/Core mode: not set/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeveloperOptionsPanel — Sentry test row', () => {
|
||||
beforeEach(() => {
|
||||
hoisted.trigger.mockReset();
|
||||
|
||||
@@ -18,26 +18,21 @@
|
||||
* - `activationMode`: "toggle" or "push"
|
||||
* - `hotkey`: the configured hotkey string
|
||||
*/
|
||||
import { invoke, isTauri } from '@tauri-apps/api/core';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
|
||||
import { callCoreRpc } from '../services/coreRpcClient';
|
||||
import { CORE_RPC_URL } from '../utils/config';
|
||||
import { callCoreRpc, getCoreHttpBaseUrl } from '../services/coreRpcClient';
|
||||
|
||||
/** Resolve the core process base URL (without /rpc suffix) for Socket.IO. */
|
||||
/** Resolve the core process base URL (without /rpc suffix) for Socket.IO.
|
||||
*
|
||||
* Delegates to `getCoreHttpBaseUrl` so the cloud-mode override set in the
|
||||
* BootCheckGate picker is honoured — previously this called
|
||||
* `invoke('core_rpc_url')` directly and would fall back to
|
||||
* `http://127.0.0.1:7788` whenever the user picked cloud mode (no local
|
||||
* sidecar to reply to the invoke), spamming `ERR_CONNECTION_REFUSED`.
|
||||
*/
|
||||
async function resolveCoreSocketUrl(): Promise<string> {
|
||||
let rpcUrl = CORE_RPC_URL;
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const url = await invoke<string>('core_rpc_url');
|
||||
if (url) rpcUrl = String(url);
|
||||
} catch {
|
||||
// fall through to default
|
||||
}
|
||||
}
|
||||
const trimmed = rpcUrl.trim().replace(/\/+$/, '');
|
||||
return trimmed.endsWith('/rpc') ? trimmed.slice(0, -4) : trimmed;
|
||||
return getCoreHttpBaseUrl();
|
||||
}
|
||||
|
||||
interface DictationSettings {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*
|
||||
* There is **no** demo loop — the overlay is entirely event-driven.
|
||||
*/
|
||||
import { invoke, isTauri } from '@tauri-apps/api/core';
|
||||
import { invoke } from '@tauri-apps/api/core';
|
||||
import {
|
||||
currentMonitor,
|
||||
getCurrentWindow,
|
||||
@@ -34,8 +34,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { io, Socket } from 'socket.io-client';
|
||||
|
||||
import RotatingTetrahedronCanvas from '../components/RotatingTetrahedronCanvas';
|
||||
import { callCoreRpc } from '../services/coreRpcClient';
|
||||
import { CORE_RPC_URL } from '../utils/config';
|
||||
import { callCoreRpc, getCoreHttpBaseUrl } from '../services/coreRpcClient';
|
||||
|
||||
const OVERLAY_IDLE_WIDTH = 50;
|
||||
const OVERLAY_IDLE_HEIGHT = 50;
|
||||
@@ -102,19 +101,10 @@ function bubbleToneClass(tone: BubbleTone) {
|
||||
}
|
||||
|
||||
/** Resolve the core process base URL (without /rpc suffix) for Socket.IO.
|
||||
* Mirrors `useDictationHotkey.resolveCoreSocketUrl`. */
|
||||
* Mirrors `useDictationHotkey.resolveCoreSocketUrl`. Delegates to
|
||||
* `getCoreHttpBaseUrl` so cloud-mode overrides flow through. */
|
||||
async function resolveCoreSocketUrl(): Promise<string> {
|
||||
let rpcUrl = CORE_RPC_URL;
|
||||
if (isTauri()) {
|
||||
try {
|
||||
const url = await invoke<string>('core_rpc_url');
|
||||
if (url) rpcUrl = String(url);
|
||||
} catch {
|
||||
// fall through to default
|
||||
}
|
||||
}
|
||||
const trimmed = rpcUrl.trim().replace(/\/+$/, '');
|
||||
return trimmed.endsWith('/rpc') ? trimmed.slice(0, -4) : trimmed;
|
||||
return getCoreHttpBaseUrl();
|
||||
}
|
||||
|
||||
// ── Bubble chip with typewriter animation ────────────────────────────────
|
||||
|
||||
@@ -64,8 +64,15 @@ vi.mock('../../services/backendUrl', () => ({
|
||||
|
||||
vi.mock('../../utils/configPersistence', () => ({
|
||||
getStoredRpcUrl: vi.fn(() => 'http://127.0.0.1:7788/rpc'),
|
||||
peekStoredRpcUrl: vi.fn(() => null),
|
||||
storeRpcUrl: vi.fn(),
|
||||
clearStoredRpcUrl: vi.fn(),
|
||||
getStoredCoreToken: vi.fn(() => null),
|
||||
storeCoreToken: vi.fn(),
|
||||
clearStoredCoreToken: vi.fn(),
|
||||
getStoredCoreMode: vi.fn(() => null),
|
||||
storeCoreMode: vi.fn(),
|
||||
clearStoredCoreMode: vi.fn(),
|
||||
getDefaultRpcUrl: vi.fn(() => 'http://127.0.0.1:7788/rpc'),
|
||||
isValidRpcUrl: vi.fn((url: string) => {
|
||||
if (!url || url.trim().length === 0) return false;
|
||||
|
||||
@@ -503,9 +503,10 @@ describe('getCoreRpcUrl', () => {
|
||||
vi.mocked(invoke).mockReset();
|
||||
});
|
||||
|
||||
test('in web mode returns stored URL when getStoredRpcUrl returns a non-default value', async () => {
|
||||
test('in web mode returns stored URL when one is stored', async () => {
|
||||
vi.doMock('../../utils/configPersistence', () => ({
|
||||
getStoredRpcUrl: () => 'http://custom-host:9999/rpc',
|
||||
peekStoredRpcUrl: () => 'http://custom-host:9999/rpc',
|
||||
getStoredCoreToken: () => null,
|
||||
}));
|
||||
vi.mocked(isTauri).mockReturnValue(false);
|
||||
|
||||
@@ -514,9 +515,10 @@ describe('getCoreRpcUrl', () => {
|
||||
expect(url).toBe('http://custom-host:9999/rpc');
|
||||
});
|
||||
|
||||
test('in web mode returns default CORE_RPC_URL when nothing custom is stored', async () => {
|
||||
test('in web mode returns default CORE_RPC_URL when nothing is stored', async () => {
|
||||
vi.doMock('../../utils/configPersistence', () => ({
|
||||
getStoredRpcUrl: () => 'http://127.0.0.1:7788/rpc',
|
||||
peekStoredRpcUrl: () => null,
|
||||
getStoredCoreToken: () => null,
|
||||
}));
|
||||
vi.mocked(isTauri).mockReturnValue(false);
|
||||
|
||||
@@ -528,10 +530,11 @@ describe('getCoreRpcUrl', () => {
|
||||
test('in web mode caches the result — second call does not change the returned value', async () => {
|
||||
let callCount = 0;
|
||||
vi.doMock('../../utils/configPersistence', () => ({
|
||||
getStoredRpcUrl: () => {
|
||||
peekStoredRpcUrl: () => {
|
||||
callCount++;
|
||||
return 'http://127.0.0.1:7788/rpc';
|
||||
return null;
|
||||
},
|
||||
getStoredCoreToken: () => null,
|
||||
}));
|
||||
vi.mocked(isTauri).mockReturnValue(false);
|
||||
|
||||
@@ -539,13 +542,16 @@ describe('getCoreRpcUrl', () => {
|
||||
const first = await freshGetCoreRpcUrl();
|
||||
const second = await freshGetCoreRpcUrl();
|
||||
expect(first).toBe(second);
|
||||
// getStoredRpcUrl should only have been called once due to caching
|
||||
// peekStoredRpcUrl should only have been called once due to caching
|
||||
expect(callCount).toBe(1);
|
||||
});
|
||||
|
||||
test('returns fresh value after clearCoreRpcUrlCache()', async () => {
|
||||
let storedValue = 'http://127.0.0.1:7788/rpc';
|
||||
vi.doMock('../../utils/configPersistence', () => ({ getStoredRpcUrl: () => storedValue }));
|
||||
let storedValue: string | null = null;
|
||||
vi.doMock('../../utils/configPersistence', () => ({
|
||||
peekStoredRpcUrl: () => storedValue,
|
||||
getStoredCoreToken: () => null,
|
||||
}));
|
||||
vi.mocked(isTauri).mockReturnValue(false);
|
||||
|
||||
const { getCoreRpcUrl: freshGetCoreRpcUrl, clearCoreRpcUrlCache: freshClear } =
|
||||
@@ -562,9 +568,10 @@ describe('getCoreRpcUrl', () => {
|
||||
expect(second).toBe('http://new-host:8888/rpc');
|
||||
});
|
||||
|
||||
test('in Tauri mode calls invoke("core_rpc_url") when no stored URL is customised', async () => {
|
||||
test('in Tauri mode calls invoke("core_rpc_url") when no stored URL', async () => {
|
||||
vi.doMock('../../utils/configPersistence', () => ({
|
||||
getStoredRpcUrl: () => 'http://127.0.0.1:7788/rpc',
|
||||
peekStoredRpcUrl: () => null,
|
||||
getStoredCoreToken: () => null,
|
||||
}));
|
||||
vi.mocked(isTauri).mockReturnValue(true);
|
||||
vi.mocked(invoke).mockImplementation(async (cmd: string) => {
|
||||
@@ -580,7 +587,8 @@ describe('getCoreRpcUrl', () => {
|
||||
|
||||
test('in Tauri mode stored URL takes priority over invoke result', async () => {
|
||||
vi.doMock('../../utils/configPersistence', () => ({
|
||||
getStoredRpcUrl: () => 'http://stored-override:4444/rpc',
|
||||
peekStoredRpcUrl: () => 'http://stored-override:4444/rpc',
|
||||
getStoredCoreToken: () => null,
|
||||
}));
|
||||
vi.mocked(isTauri).mockReturnValue(true);
|
||||
vi.mocked(invoke).mockImplementation(async (cmd: string) => {
|
||||
@@ -595,9 +603,32 @@ describe('getCoreRpcUrl', () => {
|
||||
expect(vi.mocked(invoke)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cloud-picker URL identical to build-time default still wins over local sidecar', async () => {
|
||||
// Regression: in the old `storedUrl !== CORE_RPC_URL` check the picker's
|
||||
// value was discarded when it coincided with `VITE_OPENHUMAN_CORE_RPC_URL`,
|
||||
// silently routing cloud-mode RPC back to the local sidecar.
|
||||
vi.doMock('../../utils/configPersistence', () => ({
|
||||
peekStoredRpcUrl: () => 'http://127.0.0.1:7788/rpc',
|
||||
getStoredCoreToken: () => null,
|
||||
}));
|
||||
vi.mocked(isTauri).mockReturnValue(true);
|
||||
vi.mocked(invoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'core_rpc_url') {
|
||||
throw new Error('should not be consulted when a stored URL exists');
|
||||
}
|
||||
throw new Error(`unexpected: ${cmd}`);
|
||||
});
|
||||
|
||||
const { getCoreRpcUrl: freshGetCoreRpcUrl } = await import('../coreRpcClient');
|
||||
const url = await freshGetCoreRpcUrl();
|
||||
expect(url).toBe('http://127.0.0.1:7788/rpc');
|
||||
expect(vi.mocked(invoke)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('in Tauri mode falls back to CORE_RPC_URL when invoke fails and no stored URL', async () => {
|
||||
vi.doMock('../../utils/configPersistence', () => ({
|
||||
getStoredRpcUrl: () => 'http://127.0.0.1:7788/rpc',
|
||||
peekStoredRpcUrl: () => null,
|
||||
getStoredCoreToken: () => null,
|
||||
}));
|
||||
vi.mocked(isTauri).mockReturnValue(true);
|
||||
vi.mocked(invoke).mockRejectedValue(new Error('invoke failed'));
|
||||
@@ -608,3 +639,92 @@ describe('getCoreRpcUrl', () => {
|
||||
expect(url).toBe('http://127.0.0.1:7788/rpc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCoreRpcToken (cloud-mode persistence)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
});
|
||||
|
||||
test('uses stored cloud-mode token before invoking Tauri sidecar token', async () => {
|
||||
vi.doMock('../../utils/configPersistence', () => ({
|
||||
peekStoredRpcUrl: () => 'https://core.example.com/rpc',
|
||||
getStoredCoreToken: () => 'cloud-token-abc',
|
||||
}));
|
||||
vi.mocked(isTauri).mockReturnValue(true);
|
||||
vi.mocked(invoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'core_rpc_url') return 'https://core.example.com/rpc';
|
||||
if (cmd === 'core_rpc_token') {
|
||||
throw new Error('should not be called when stored token exists');
|
||||
}
|
||||
throw new Error(`unexpected invoke: ${cmd}`);
|
||||
});
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ jsonrpc: '2.0', id: 1, result: { ok: true } }),
|
||||
} as Response);
|
||||
|
||||
const { callCoreRpc: freshCallCoreRpc } = await import('../coreRpcClient');
|
||||
await freshCallCoreRpc({ method: 'openhuman.ping' });
|
||||
|
||||
expect(vi.mocked(invoke)).not.toHaveBeenCalledWith('core_rpc_token', expect.anything());
|
||||
const requestInit = fetchMock.mock.calls[0][1] as RequestInit;
|
||||
const headers = requestInit.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe('Bearer cloud-token-abc');
|
||||
});
|
||||
|
||||
test('clearCoreRpcTokenCache forces a re-resolve on the next call', async () => {
|
||||
let storedToken: string | null = 'first-token';
|
||||
vi.doMock('../../utils/configPersistence', () => ({
|
||||
peekStoredRpcUrl: () => 'https://core.example.com/rpc',
|
||||
getStoredCoreToken: () => storedToken,
|
||||
}));
|
||||
vi.mocked(isTauri).mockReturnValue(true);
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ jsonrpc: '2.0', id: 1, result: { ok: true } }),
|
||||
} as Response);
|
||||
|
||||
const { callCoreRpc: freshCallCoreRpc, clearCoreRpcTokenCache } =
|
||||
await import('../coreRpcClient');
|
||||
await freshCallCoreRpc({ method: 'openhuman.ping' });
|
||||
let headers = fetchMock.mock.calls[0][1] as RequestInit;
|
||||
expect((headers.headers as Record<string, string>).Authorization).toBe('Bearer first-token');
|
||||
|
||||
// Rotate the stored token; without clearing the cache the old value
|
||||
// persists. Clearing it makes the next call re-resolve.
|
||||
storedToken = 'second-token';
|
||||
clearCoreRpcTokenCache();
|
||||
await freshCallCoreRpc({ method: 'openhuman.ping' });
|
||||
headers = fetchMock.mock.calls[1][1] as RequestInit;
|
||||
expect((headers.headers as Record<string, string>).Authorization).toBe('Bearer second-token');
|
||||
});
|
||||
|
||||
test('falls back to Tauri sidecar token when no stored cloud token', async () => {
|
||||
vi.doMock('../../utils/configPersistence', () => ({
|
||||
peekStoredRpcUrl: () => null,
|
||||
getStoredCoreToken: () => null,
|
||||
}));
|
||||
vi.mocked(isTauri).mockReturnValue(true);
|
||||
vi.mocked(invoke).mockImplementation(async (cmd: string) => {
|
||||
if (cmd === 'core_rpc_url') return 'http://127.0.0.1:7788/rpc';
|
||||
if (cmd === 'core_rpc_token') return 'local-sidecar-token';
|
||||
throw new Error(`unexpected invoke: ${cmd}`);
|
||||
});
|
||||
const fetchMock = vi.mocked(fetch);
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ jsonrpc: '2.0', id: 1, result: { ok: true } }),
|
||||
} as Response);
|
||||
|
||||
const { callCoreRpc: freshCallCoreRpc } = await import('../coreRpcClient');
|
||||
await freshCallCoreRpc({ method: 'openhuman.ping' });
|
||||
|
||||
const requestInit = fetchMock.mock.calls[0][1] as RequestInit;
|
||||
const headers = requestInit.headers as Record<string, string>;
|
||||
expect(headers.Authorization).toBe('Bearer local-sidecar-token');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import debug from 'debug';
|
||||
|
||||
import { dispatchLocalAiMethod } from '../lib/ai/localCoreAiMemory';
|
||||
import { CORE_RPC_TIMEOUT_MS, CORE_RPC_URL } from '../utils/config';
|
||||
import { getStoredRpcUrl } from '../utils/configPersistence';
|
||||
import { getStoredCoreToken, peekStoredRpcUrl } from '../utils/configPersistence';
|
||||
import { sanitizeError } from '../utils/sanitize';
|
||||
import { normalizeRpcMethod } from './rpcMethods';
|
||||
|
||||
@@ -49,6 +49,18 @@ export function clearCoreRpcUrlCache(): void {
|
||||
resolvedCoreRpcUrl = null;
|
||||
resolvingCoreRpcUrl = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate the cached core RPC bearer token so the next call to
|
||||
* `getCoreRpcToken()` re-resolves from `getStoredCoreToken()` or the Tauri
|
||||
* sidecar. Call after the user saves a new cloud-mode token (or switches
|
||||
* mode) so in-flight changes take effect without a full reload.
|
||||
*/
|
||||
export function clearCoreRpcTokenCache(): void {
|
||||
resolvedCoreRpcToken = null;
|
||||
didResolveCoreRpcToken = false;
|
||||
resolvingCoreRpcToken = null;
|
||||
}
|
||||
const coreRpcLog = debug('core-rpc');
|
||||
const coreRpcError = debug('core-rpc:error');
|
||||
|
||||
@@ -78,14 +90,13 @@ export async function getCoreRpcUrl(): Promise<string> {
|
||||
}
|
||||
|
||||
if (!coreIsTauri()) {
|
||||
// Web environment: check for user-configured RPC URL first
|
||||
const storedUrl = getStoredRpcUrl();
|
||||
if (storedUrl && storedUrl !== CORE_RPC_URL) {
|
||||
resolvedCoreRpcUrl = storedUrl;
|
||||
return storedUrl;
|
||||
}
|
||||
resolvedCoreRpcUrl = CORE_RPC_URL;
|
||||
return CORE_RPC_URL;
|
||||
// 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
|
||||
// chosen yet" from "user chose a value identical to the default".
|
||||
const storedUrl = peekStoredRpcUrl();
|
||||
resolvedCoreRpcUrl = storedUrl ?? CORE_RPC_URL;
|
||||
return resolvedCoreRpcUrl;
|
||||
}
|
||||
|
||||
if (resolvingCoreRpcUrl) {
|
||||
@@ -94,9 +105,14 @@ export async function getCoreRpcUrl(): Promise<string> {
|
||||
|
||||
const resolvePromise: Promise<string> = (async () => {
|
||||
try {
|
||||
// Tauri: check for user-configured URL first
|
||||
const storedUrl = getStoredRpcUrl();
|
||||
if (storedUrl && storedUrl !== CORE_RPC_URL) {
|
||||
// Tauri: any user-stored URL (cloud picker output) wins. Without this
|
||||
// a cloud-mode user whose picker URL coincides with the build-time
|
||||
// `VITE_OPENHUMAN_CORE_RPC_URL` would be silently routed to whatever
|
||||
// `core_rpc_url` returns (typically the local sidecar's
|
||||
// `http://127.0.0.1:<port>/rpc`), producing ERR_CONNECTION_REFUSED in
|
||||
// cloud mode where no local sidecar is running.
|
||||
const storedUrl = peekStoredRpcUrl();
|
||||
if (storedUrl) {
|
||||
resolvedCoreRpcUrl = storedUrl;
|
||||
return storedUrl;
|
||||
}
|
||||
@@ -104,9 +120,6 @@ export async function getCoreRpcUrl(): Promise<string> {
|
||||
const url = await invoke<string>('core_rpc_url');
|
||||
const trimmed = String(url || '').trim();
|
||||
if (!trimmed) {
|
||||
// The Tauri command succeeded but returned an empty string. That's
|
||||
// almost certainly a shell misconfiguration — prefer the build-time
|
||||
// default but make the fallback visible rather than silent.
|
||||
coreRpcError('core_rpc_url returned empty; using build-time default', {
|
||||
fallback: CORE_RPC_URL,
|
||||
});
|
||||
@@ -114,11 +127,11 @@ export async function getCoreRpcUrl(): Promise<string> {
|
||||
resolvedCoreRpcUrl = trimmed || CORE_RPC_URL;
|
||||
return resolvedCoreRpcUrl || CORE_RPC_URL;
|
||||
} catch (err) {
|
||||
// Fallback to a stored override first, then the build-time default.
|
||||
// Keep the underlying invoke failure visible so port mismatches and
|
||||
// shell misconfiguration are diagnosable in dev logs.
|
||||
const storedUrl = getStoredRpcUrl();
|
||||
resolvedCoreRpcUrl = storedUrl || CORE_RPC_URL;
|
||||
// Tauri invoke failed — fall back to stored URL if any, then the
|
||||
// build-time default. Keep the underlying invoke failure visible so
|
||||
// port mismatches and shell misconfiguration are diagnosable.
|
||||
const storedUrl = peekStoredRpcUrl();
|
||||
resolvedCoreRpcUrl = storedUrl ?? CORE_RPC_URL;
|
||||
coreRpcError('core_rpc_url invoke failed; using fallback RPC URL', {
|
||||
fallback: resolvedCoreRpcUrl,
|
||||
usedStoredUrl: Boolean(storedUrl),
|
||||
@@ -135,15 +148,30 @@ export async function getCoreRpcUrl(): Promise<string> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the per-process RPC bearer token written by the core binary to
|
||||
* `~/.openhuman/core.token` at startup. The token is fetched once via a
|
||||
* Tauri command and then cached for the lifetime of the frontend process.
|
||||
* Returns the bearer token for authenticating against the core RPC endpoint.
|
||||
*
|
||||
* Returns `null` in non-Tauri environments (e.g. Vitest) where the command
|
||||
* is not available so existing tests remain unaffected.
|
||||
* Resolution order:
|
||||
* 1. `getStoredCoreToken()` — token entered by the user in the cloud-mode
|
||||
* picker. When set, the desktop is talking to a remote core and the
|
||||
* local-sidecar token would be wrong. Takes priority so cloud mode
|
||||
* always sends the user's own token.
|
||||
* 2. Tauri `core_rpc_token` command — the embedded sidecar's per-process
|
||||
* token, written by the core binary to `~/.openhuman/core.token` at
|
||||
* startup. Cached for the lifetime of the frontend process.
|
||||
* 3. `null` in non-Tauri environments (e.g. Vitest, web preview) when no
|
||||
* stored token is set so existing tests remain unaffected.
|
||||
*/
|
||||
async function getCoreRpcToken(): Promise<string | null> {
|
||||
if (didResolveCoreRpcToken) return resolvedCoreRpcToken;
|
||||
|
||||
const storedToken = getStoredCoreToken();
|
||||
if (storedToken) {
|
||||
resolvedCoreRpcToken = storedToken;
|
||||
didResolveCoreRpcToken = true;
|
||||
coreRpcLog('core RPC token loaded from cloud-mode persistence');
|
||||
return resolvedCoreRpcToken;
|
||||
}
|
||||
|
||||
if (!coreIsTauri()) return null;
|
||||
if (resolvingCoreRpcToken) return resolvingCoreRpcToken;
|
||||
|
||||
@@ -177,9 +205,15 @@ async function getCoreRpcToken(): Promise<string | null> {
|
||||
* inside the service per the project guideline ("Keep Tauri IPC and RPC
|
||||
* client calls localized to services … do not scatter `invoke()` or
|
||||
* direct RPC calls throughout components").
|
||||
*
|
||||
* `tokenOverride` lets the cloud-mode picker test a freshly-typed token
|
||||
* before it's persisted; without it, falls back to the normal resolution.
|
||||
*/
|
||||
export async function testCoreRpcConnection(url: string): Promise<Response> {
|
||||
const token = await getCoreRpcToken();
|
||||
export async function testCoreRpcConnection(
|
||||
url: string,
|
||||
tokenOverride?: string
|
||||
): Promise<Response> {
|
||||
const token = tokenOverride?.trim() || (await getCoreRpcToken());
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Tests for `userScopedStorage` — focused on the boot-time prime semantics
|
||||
* that gate the cloud-mode reload-survival fix. The single-letter test names
|
||||
* mirror the comment block at the top of the source file: each scenario
|
||||
* covers one path through `primeActiveUserId(...)` + `setActiveUserId(...)`.
|
||||
*
|
||||
* Use `vi.resetModules()` between tests because `userScopedStorage` reads
|
||||
* `localStorage` once at module load (`safeGetActiveUserIdSync`) and gates
|
||||
* subsequent prime calls behind a one-shot `primed` flag — fresh imports
|
||||
* exercise the boot path cleanly.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
|
||||
|
||||
const ACTIVE_USER_KEY = 'OPENHUMAN_ACTIVE_USER_ID';
|
||||
|
||||
async function importModule() {
|
||||
vi.resetModules();
|
||||
return import('../userScopedStorage');
|
||||
}
|
||||
|
||||
describe('userScopedStorage', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
test('primeActiveUserId(id) writes the seed to localStorage and getActiveUserId returns it', async () => {
|
||||
const mod = await importModule();
|
||||
mod.primeActiveUserId('user-123');
|
||||
expect(mod.getActiveUserId()).toBe('user-123');
|
||||
expect(localStorage.getItem(ACTIVE_USER_KEY)).toBe('user-123');
|
||||
});
|
||||
|
||||
test('primeActiveUserId(null) preserves existing seed (cloud-mode reload survival)', async () => {
|
||||
// Seed a prior value, as if `setActiveUserId(X)` ran in the previous
|
||||
// session before `handleIdentityFlip → restartApp`.
|
||||
localStorage.setItem(ACTIVE_USER_KEY, 'prior-user');
|
||||
const mod = await importModule();
|
||||
|
||||
// Cloud-mode boot can't read `~/.openhuman/active_user.toml` (no local
|
||||
// core), so `getActiveUserIdFromCore()` resolves to null. The fix:
|
||||
// prime(null) must NOT wipe the seed, otherwise the next snapshot's
|
||||
// identity-flip detection re-triggers the loop.
|
||||
mod.primeActiveUserId(null);
|
||||
expect(mod.getActiveUserId()).toBe('prior-user');
|
||||
expect(localStorage.getItem(ACTIVE_USER_KEY)).toBe('prior-user');
|
||||
});
|
||||
|
||||
test('primeActiveUserId(null) with no prior seed leaves activeUserId null', async () => {
|
||||
const mod = await importModule();
|
||||
mod.primeActiveUserId(null);
|
||||
expect(mod.getActiveUserId()).toBeNull();
|
||||
expect(localStorage.getItem(ACTIVE_USER_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
test('primeActiveUserId is one-shot — second call has no effect', async () => {
|
||||
const mod = await importModule();
|
||||
mod.primeActiveUserId('first');
|
||||
mod.primeActiveUserId('second');
|
||||
expect(mod.getActiveUserId()).toBe('first');
|
||||
});
|
||||
|
||||
test('setActiveUserId(id) writes through to localStorage', async () => {
|
||||
const mod = await importModule();
|
||||
mod.setActiveUserId('after-login');
|
||||
expect(mod.getActiveUserId()).toBe('after-login');
|
||||
expect(localStorage.getItem(ACTIVE_USER_KEY)).toBe('after-login');
|
||||
});
|
||||
|
||||
test('setActiveUserId(null) clears the seed (sign-out path)', async () => {
|
||||
localStorage.setItem(ACTIVE_USER_KEY, 'someone');
|
||||
const mod = await importModule();
|
||||
mod.setActiveUserId(null);
|
||||
expect(mod.getActiveUserId()).toBeNull();
|
||||
expect(localStorage.getItem(ACTIVE_USER_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
test('setActiveUserId tolerates localStorage failures without throwing', async () => {
|
||||
const mod = await importModule();
|
||||
const setItemSpy = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {
|
||||
throw new Error('blocked');
|
||||
});
|
||||
try {
|
||||
// Must not throw — the in-memory ref still drives reads.
|
||||
expect(() => mod.setActiveUserId('x')).not.toThrow();
|
||||
expect(mod.getActiveUserId()).toBe('x');
|
||||
} finally {
|
||||
setItemSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import reducer, { resetCoreMode, setCoreMode } from './coreModeSlice';
|
||||
|
||||
@@ -21,6 +21,18 @@ describe('coreModeSlice', () => {
|
||||
expect(state.mode).toEqual({ kind: 'cloud', url: 'https://core.example.com/rpc' });
|
||||
});
|
||||
|
||||
it('sets cloud mode with url + token', () => {
|
||||
const state = reducer(
|
||||
undefined,
|
||||
setCoreMode({ kind: 'cloud', url: 'https://core.example.com/rpc', token: 'tok-1234' })
|
||||
);
|
||||
expect(state.mode).toEqual({
|
||||
kind: 'cloud',
|
||||
url: 'https://core.example.com/rpc',
|
||||
token: 'tok-1234',
|
||||
});
|
||||
});
|
||||
|
||||
it('resets to unset', () => {
|
||||
const withLocal = reducer(undefined, setCoreMode({ kind: 'local' }));
|
||||
const reset = reducer(withLocal, resetCoreMode());
|
||||
@@ -42,3 +54,52 @@ describe('coreModeSlice', () => {
|
||||
expect(setCoreMode.type).toMatch(/^coreMode\//);
|
||||
});
|
||||
});
|
||||
|
||||
describe('coreModeSlice — sync-localStorage-derived initial state', () => {
|
||||
// The slice's initialState comes from `deriveInitialMode()` which reads
|
||||
// `localStorage` at module load. We re-import per test to exercise each
|
||||
// branch of that derivation.
|
||||
async function freshImport() {
|
||||
vi.resetModules();
|
||||
return import('./coreModeSlice');
|
||||
}
|
||||
|
||||
it('hydrates to local when openhuman_core_mode=local', async () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem('openhuman_core_mode', 'local');
|
||||
const mod = await freshImport();
|
||||
const state = mod.default(undefined, { type: '@@INIT' });
|
||||
expect(state.mode).toEqual({ kind: 'local' });
|
||||
});
|
||||
|
||||
it('hydrates to cloud with url + token when all three keys are present', async () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem('openhuman_core_mode', 'cloud');
|
||||
localStorage.setItem('openhuman_core_rpc_url', 'https://core.example.com/rpc');
|
||||
localStorage.setItem('openhuman_core_rpc_token', 'tok-abc');
|
||||
const mod = await freshImport();
|
||||
const state = mod.default(undefined, { type: '@@INIT' });
|
||||
expect(state.mode).toEqual({
|
||||
kind: 'cloud',
|
||||
url: 'https://core.example.com/rpc',
|
||||
token: 'tok-abc',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to unset when cloud marker exists but URL or token is missing', async () => {
|
||||
localStorage.clear();
|
||||
localStorage.setItem('openhuman_core_mode', 'cloud');
|
||||
localStorage.setItem('openhuman_core_rpc_url', 'https://core.example.com/rpc');
|
||||
// Token deliberately missing.
|
||||
const mod = await freshImport();
|
||||
const state = mod.default(undefined, { type: '@@INIT' });
|
||||
expect(state.mode).toEqual({ kind: 'unset' });
|
||||
});
|
||||
|
||||
it('returns unset when no marker is stored', async () => {
|
||||
localStorage.clear();
|
||||
const mod = await freshImport();
|
||||
const state = mod.default(undefined, { type: '@@INIT' });
|
||||
expect(state.mode).toEqual({ kind: 'unset' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,13 +12,61 @@
|
||||
*/
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
export type CoreMode = { kind: 'unset' } | { kind: 'local' } | { kind: 'cloud'; url: string };
|
||||
export type CoreMode =
|
||||
| { kind: 'unset' }
|
||||
| { kind: 'local' }
|
||||
| {
|
||||
kind: 'cloud';
|
||||
url: string;
|
||||
/**
|
||||
* Bearer token for the remote core. Cloud cores require auth (see
|
||||
* `OPENHUMAN_CORE_TOKEN` in docs/CLOUD_DEPLOY.md). Optional in the type
|
||||
* so persisted state from older builds (which stored cloud mode without
|
||||
* a token) still hydrates; the BootCheckGate picker requires a value.
|
||||
*/
|
||||
token?: string;
|
||||
};
|
||||
|
||||
export interface CoreModeState {
|
||||
mode: CoreMode;
|
||||
}
|
||||
|
||||
const initialState: CoreModeState = { mode: { kind: 'unset' } };
|
||||
/** Synchronous localStorage keys mirrored by `configPersistence.ts`. */
|
||||
const RPC_URL_STORAGE_KEY = 'openhuman_core_rpc_url';
|
||||
const CORE_TOKEN_STORAGE_KEY = 'openhuman_core_rpc_token';
|
||||
const CORE_MODE_STORAGE_KEY = 'openhuman_core_mode';
|
||||
|
||||
/**
|
||||
* Derive the initial mode synchronously from `localStorage`.
|
||||
*
|
||||
* redux-persist saves slice state asynchronously (debounced). When the app
|
||||
* reloads (e.g. `handleIdentityFlip` → `restartApp` after the cloud core
|
||||
* returns a logged-in user that doesn't match the device's seed), the
|
||||
* persisted `coreMode` blob may not have been flushed before the reload.
|
||||
* Falling back to plain unset would put the user back on the picker even
|
||||
* though they just chose cloud, producing an infinite picker → reload loop.
|
||||
*
|
||||
* The picker writes `openhuman_core_rpc_url`, `openhuman_core_rpc_token`,
|
||||
* and `openhuman_core_mode` synchronously before any async dispatch, so we
|
||||
* can recover the exact mode on reload regardless of the persist flush race.
|
||||
*/
|
||||
function deriveInitialMode(): CoreMode {
|
||||
if (typeof localStorage === 'undefined') return { kind: 'unset' };
|
||||
try {
|
||||
const mode = localStorage.getItem(CORE_MODE_STORAGE_KEY)?.trim();
|
||||
if (mode === 'local') return { kind: 'local' };
|
||||
if (mode === 'cloud') {
|
||||
const url = localStorage.getItem(RPC_URL_STORAGE_KEY)?.trim();
|
||||
const token = localStorage.getItem(CORE_TOKEN_STORAGE_KEY)?.trim();
|
||||
if (url && token) return { kind: 'cloud', url, token };
|
||||
}
|
||||
} catch {
|
||||
/* localStorage unavailable — fall through to unset */
|
||||
}
|
||||
return { kind: 'unset' };
|
||||
}
|
||||
|
||||
const initialState: CoreModeState = { mode: deriveInitialMode() };
|
||||
|
||||
const coreModeSlice = createSlice({
|
||||
name: 'coreMode',
|
||||
|
||||
@@ -68,9 +68,18 @@ let primed = false;
|
||||
* Mark `userScopedStorage` as primed with the boot-time active user id.
|
||||
*
|
||||
* Called once by `main.tsx` after `getActiveUserIdFromCore()` returns.
|
||||
* Pass `null` for "no user logged in yet" — storage reads/writes then
|
||||
* fall through as no-ops until a real id is supplied later via
|
||||
* `setActiveUserId`.
|
||||
* Pass `null` for "core couldn't tell us who's active" — most commonly:
|
||||
*
|
||||
* 1. fresh device with no local `~/.openhuman/active_user.toml`
|
||||
* 2. cloud-mode boot where the local Rust core isn't running at all
|
||||
* 3. transient `getActiveUserIdFromCore` failure (`.catch(() => prime(null))`)
|
||||
*
|
||||
* In any of those cases we **fall back** to whatever `OPENHUMAN_ACTIVE_USER_ID`
|
||||
* already has in plain `localStorage` from a prior `setActiveUserId` write
|
||||
* rather than wiping it. Without this fallback, `handleIdentityFlip`'s
|
||||
* `setActiveUserId(X) → restartApp` cycle is reset on every reload (because
|
||||
* the next boot's `prime(null)` removes X again), trapping cloud-mode users
|
||||
* in an infinite picker → snapshot → flip → reload loop.
|
||||
*
|
||||
* Safe to call before `setActiveUserId` for an initial seed; subsequent
|
||||
* `primeActiveUserId(...)` calls have no effect (the gate is one-shot).
|
||||
@@ -78,15 +87,16 @@ let primed = false;
|
||||
export function primeActiveUserId(id: string | null): void {
|
||||
if (primed) return;
|
||||
primed = true;
|
||||
activeUserId = id;
|
||||
try {
|
||||
if (id) {
|
||||
if (id) {
|
||||
activeUserId = id;
|
||||
try {
|
||||
localStorage.setItem(ACTIVE_USER_KEY, id);
|
||||
} else {
|
||||
localStorage.removeItem(ACTIVE_USER_KEY);
|
||||
} catch {
|
||||
// localStorage may be unavailable; in-memory ref still drives reads
|
||||
}
|
||||
} catch {
|
||||
// localStorage may be unavailable; in-memory ref still drives reads
|
||||
} else {
|
||||
// Don't wipe — keep whatever a prior session wrote.
|
||||
activeUserId = safeGetActiveUserIdSync();
|
||||
}
|
||||
activeUserIdResolve();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import channelConnectionsReducer from '../store/channelConnectionsSlice';
|
||||
import coreModeReducer from '../store/coreModeSlice';
|
||||
import socketReducer from '../store/socketSlice';
|
||||
|
||||
/**
|
||||
@@ -17,6 +18,7 @@ import socketReducer from '../store/socketSlice';
|
||||
*/
|
||||
const testRootReducer = combineReducers({
|
||||
channelConnections: channelConnectionsReducer,
|
||||
coreMode: coreModeReducer,
|
||||
socket: socketReducer,
|
||||
});
|
||||
|
||||
|
||||
@@ -5,25 +5,38 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
clearStoredCoreMode,
|
||||
clearStoredCoreToken,
|
||||
clearStoredRpcUrl,
|
||||
getDefaultRpcUrl,
|
||||
getStoredCoreMode,
|
||||
getStoredCoreToken,
|
||||
getStoredRpcUrl,
|
||||
isValidRpcUrl,
|
||||
normalizeRpcUrl,
|
||||
peekStoredRpcUrl,
|
||||
storeCoreMode,
|
||||
storeCoreToken,
|
||||
storeRpcUrl,
|
||||
} from '../configPersistence';
|
||||
|
||||
const STORAGE_KEY = 'openhuman_core_rpc_url';
|
||||
const TOKEN_STORAGE_KEY = 'openhuman_core_rpc_token';
|
||||
const MODE_STORAGE_KEY = 'openhuman_core_mode';
|
||||
|
||||
describe('configPersistence', () => {
|
||||
beforeEach(() => {
|
||||
// Clear localStorage before each test
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
localStorage.removeItem(MODE_STORAGE_KEY);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up after each test
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(TOKEN_STORAGE_KEY);
|
||||
localStorage.removeItem(MODE_STORAGE_KEY);
|
||||
});
|
||||
|
||||
describe('getStoredRpcUrl', () => {
|
||||
@@ -264,4 +277,93 @@ describe('configPersistence', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStoredCoreToken / storeCoreToken / clearStoredCoreToken', () => {
|
||||
it('returns null when no token is stored', () => {
|
||||
expect(getStoredCoreToken()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the stored token', () => {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, 'abc-123');
|
||||
expect(getStoredCoreToken()).toBe('abc-123');
|
||||
});
|
||||
|
||||
it('trims whitespace around the stored token', () => {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, ' xyz ');
|
||||
expect(getStoredCoreToken()).toBe('xyz');
|
||||
});
|
||||
|
||||
it('treats whitespace-only / empty stored values as null', () => {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, ' ');
|
||||
expect(getStoredCoreToken()).toBeNull();
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, '');
|
||||
expect(getStoredCoreToken()).toBeNull();
|
||||
});
|
||||
|
||||
it('storeCoreToken persists trimmed value', () => {
|
||||
storeCoreToken(' hello ');
|
||||
expect(localStorage.getItem(TOKEN_STORAGE_KEY)).toBe('hello');
|
||||
});
|
||||
|
||||
it('storeCoreToken with empty string clears the stored value', () => {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, 'something');
|
||||
storeCoreToken('');
|
||||
expect(localStorage.getItem(TOKEN_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('clearStoredCoreToken removes the value', () => {
|
||||
localStorage.setItem(TOKEN_STORAGE_KEY, 'something');
|
||||
clearStoredCoreToken();
|
||||
expect(localStorage.getItem(TOKEN_STORAGE_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when localStorage is unavailable', () => {
|
||||
const getItemSpy = vi.spyOn(Storage.prototype, 'getItem').mockImplementation(() => {
|
||||
throw new Error('blocked');
|
||||
});
|
||||
try {
|
||||
expect(getStoredCoreToken()).toBeNull();
|
||||
} finally {
|
||||
getItemSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('peekStoredRpcUrl', () => {
|
||||
it('returns null when nothing is stored', () => {
|
||||
expect(peekStoredRpcUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('returns the stored value (trimmed) — even when it equals the build-time default', () => {
|
||||
// Regression: legacy `getStoredRpcUrl !== CORE_RPC_URL` check threw away
|
||||
// user-explicit URLs that happened to equal the default, silently
|
||||
// routing cloud-mode RPC back to the local sidecar.
|
||||
localStorage.setItem(STORAGE_KEY, ' http://127.0.0.1:7788/rpc ');
|
||||
expect(peekStoredRpcUrl()).toBe('http://127.0.0.1:7788/rpc');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStoredCoreMode / storeCoreMode / clearStoredCoreMode', () => {
|
||||
it('returns null by default', () => {
|
||||
expect(getStoredCoreMode()).toBeNull();
|
||||
});
|
||||
|
||||
it('round-trips local and cloud markers', () => {
|
||||
storeCoreMode('local');
|
||||
expect(getStoredCoreMode()).toBe('local');
|
||||
storeCoreMode('cloud');
|
||||
expect(getStoredCoreMode()).toBe('cloud');
|
||||
});
|
||||
|
||||
it('treats unrecognised stored values as null', () => {
|
||||
localStorage.setItem(MODE_STORAGE_KEY, 'gibberish');
|
||||
expect(getStoredCoreMode()).toBeNull();
|
||||
});
|
||||
|
||||
it('clearStoredCoreMode removes the marker', () => {
|
||||
storeCoreMode('cloud');
|
||||
clearStoredCoreMode();
|
||||
expect(getStoredCoreMode()).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,19 @@ import { isTauri } from './tauriCommands';
|
||||
// Storage key for RPC URL preference
|
||||
const RPC_URL_STORAGE_KEY = 'openhuman_core_rpc_url';
|
||||
|
||||
// Storage key for cloud-mode bearer token. Pre-login and per-device, parallel
|
||||
// to the URL key. Held in plain localStorage because the cloud picker runs
|
||||
// before any user session exists.
|
||||
const CORE_TOKEN_STORAGE_KEY = 'openhuman_core_rpc_token';
|
||||
|
||||
// Storage key for the user-chosen core mode ('local' | 'cloud'). Mirrors the
|
||||
// redux-persist `coreMode` blob synchronously so reloads (notably the dev-mode
|
||||
// `window.location.reload()` triggered by `handleIdentityFlip`) can recover
|
||||
// the chosen mode before redux-persist's async flush completes — without this
|
||||
// the BootCheckGate flips back to the picker after every reload, producing an
|
||||
// infinite picker → flip → reload loop in cloud mode.
|
||||
const CORE_MODE_STORAGE_KEY = 'openhuman_core_mode';
|
||||
|
||||
// Default RPC URL — canonical value from config.ts so they can never drift
|
||||
const DEFAULT_RPC_URL = CORE_RPC_URL;
|
||||
|
||||
@@ -39,6 +52,30 @@ export function getStoredRpcUrl(): string {
|
||||
return DEFAULT_RPC_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Peek at the stored RPC URL **without** falling back to the build-time
|
||||
* default — returns `null` when nothing is stored.
|
||||
*
|
||||
* Use this to distinguish "user has explicitly chosen a URL" from "nothing
|
||||
* stored yet, you're seeing the default". The masked-by-default behavior of
|
||||
* `getStoredRpcUrl` makes that distinction impossible: when a user chooses a
|
||||
* URL that happens to equal `CORE_RPC_URL` (e.g. the build-time fallback in
|
||||
* `app/.env.local` matches their cloud picker input), `getStoredRpcUrl` and
|
||||
* the default are indistinguishable, so callers that want to honour the
|
||||
* explicit choice unambiguously must read this instead.
|
||||
*/
|
||||
export function peekStoredRpcUrl(): string | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(RPC_URL_STORAGE_KEY);
|
||||
if (stored && stored.trim().length > 0) {
|
||||
return stored.trim();
|
||||
}
|
||||
} catch {
|
||||
console.warn('[configPersistence] Unable to access localStorage');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the RPC URL preference.
|
||||
*
|
||||
@@ -105,3 +142,77 @@ export function normalizeRpcUrl(url: string): string {
|
||||
export function getDefaultRpcUrl(): string {
|
||||
return CORE_RPC_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the stored cloud-mode bearer token, if any.
|
||||
*
|
||||
* Returns null when no token is stored (the common case for local-mode users)
|
||||
* so the caller can fall back to the local sidecar's per-process token.
|
||||
*/
|
||||
export function getStoredCoreToken(): string | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(CORE_TOKEN_STORAGE_KEY);
|
||||
if (stored && stored.trim().length > 0) {
|
||||
return stored.trim();
|
||||
}
|
||||
} catch {
|
||||
console.warn('[configPersistence] Unable to access localStorage');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store the cloud-mode bearer token. An empty string clears the stored value
|
||||
* so the caller can flip back to local-sidecar auth without manual cleanup.
|
||||
*/
|
||||
export function storeCoreToken(token: string): void {
|
||||
try {
|
||||
if (token && token.trim().length > 0) {
|
||||
localStorage.setItem(CORE_TOKEN_STORAGE_KEY, token.trim());
|
||||
console.debug('[configPersistence] Stored core token (cloud mode)');
|
||||
} else {
|
||||
localStorage.removeItem(CORE_TOKEN_STORAGE_KEY);
|
||||
console.debug('[configPersistence] Cleared stored core token');
|
||||
}
|
||||
} catch {
|
||||
console.warn('[configPersistence] Unable to store core token in localStorage');
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear the stored cloud-mode bearer token. */
|
||||
export function clearStoredCoreToken(): void {
|
||||
storeCoreToken('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the synchronous core-mode marker. Returns `null` when nothing has
|
||||
* been written yet (first launch, or after `clearStoredCoreMode`).
|
||||
*/
|
||||
export function getStoredCoreMode(): 'local' | 'cloud' | null {
|
||||
try {
|
||||
const stored = localStorage.getItem(CORE_MODE_STORAGE_KEY)?.trim();
|
||||
if (stored === 'local' || stored === 'cloud') return stored;
|
||||
} catch {
|
||||
console.warn('[configPersistence] Unable to access localStorage');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Persist the synchronous core-mode marker. */
|
||||
export function storeCoreMode(mode: 'local' | 'cloud'): void {
|
||||
try {
|
||||
localStorage.setItem(CORE_MODE_STORAGE_KEY, mode);
|
||||
console.debug('[configPersistence] Stored core mode:', { mode });
|
||||
} catch {
|
||||
console.warn('[configPersistence] Unable to store core mode in localStorage');
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove the synchronous core-mode marker (returns the picker to first-launch state). */
|
||||
export function clearStoredCoreMode(): void {
|
||||
try {
|
||||
localStorage.removeItem(CORE_MODE_STORAGE_KEY);
|
||||
} catch {
|
||||
console.warn('[configPersistence] Unable to clear core mode in localStorage');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user