From d6ace5a5bb824a51256860413140a8d24361e1b7 Mon Sep 17 00:00:00 2001 From: CodeGhost21 <164498022+CodeGhost21@users.noreply.github.com> Date: Sat, 9 May 2026 07:30:17 +0530 Subject: [PATCH] feat(boot): cloud-mode picker auth + reload-resilient core mode (#1357) Co-authored-by: Steven Enamakel <31011319+senamakel@users.noreply.github.com> --- app/src-tauri/src/imessage_scanner/mod.rs | 10 +- .../BootCheckGate/BootCheckGate.tsx | 108 ++++++++++--- .../__tests__/BootCheckGate.test.tsx | 70 +++++++++ .../settings/panels/DeveloperOptionsPanel.tsx | 68 ++++++++ .../__tests__/DeveloperOptionsPanel.test.tsx | 51 ++++++ app/src/hooks/useDictationHotkey.ts | 25 ++- app/src/overlay/OverlayApp.tsx | 20 +-- app/src/pages/__tests__/Welcome.test.tsx | 7 + .../services/__tests__/coreRpcClient.test.ts | 146 ++++++++++++++++-- app/src/services/coreRpcClient.ts | 88 +++++++---- .../store/__tests__/userScopedStorage.test.ts | 93 +++++++++++ app/src/store/coreModeSlice.test.ts | 63 +++++++- app/src/store/coreModeSlice.ts | 52 ++++++- app/src/store/userScopedStorage.ts | 30 ++-- app/src/test/test-utils.tsx | 2 + .../utils/__tests__/configPersistence.test.ts | 102 ++++++++++++ app/src/utils/configPersistence.ts | 111 +++++++++++++ 17 files changed, 942 insertions(+), 104 deletions(-) create mode 100644 app/src/store/__tests__/userScopedStorage.test.ts diff --git a/app/src-tauri/src/imessage_scanner/mod.rs b/app/src-tauri/src/imessage_scanner/mod.rs index 8263dcebe..7fcaacd2c 100644 --- a/app/src-tauri/src/imessage_scanner/mod.rs +++ b/app/src-tauri/src/imessage_scanner/mod.rs @@ -152,7 +152,15 @@ async fn run_scanner(app: AppHandle, 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); + } } } diff --git a/app/src/components/BootCheckGate/BootCheckGate.tsx b/app/src/components/BootCheckGate/BootCheckGate.tsx index 0fe7ea5d2..c5ff540c8 100644 --- a/app/src/components/BootCheckGate/BootCheckGate.tsx +++ b/app/src/components/BootCheckGate/BootCheckGate.tsx @@ -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(null); + const [tokenError, setTokenError] = useState(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) { {selected === 'cloud' && ( -
- { - 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 &&

{urlError}

} +
+
+ + { + 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 &&

{urlError}

} +
+
+ + { + 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 &&

{tokenError}

} +

+ Stored on this device only. Required for remote cores — the desktop sends it as{' '} + Authorization: Bearer … on every RPC. +

+
)}
@@ -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); diff --git a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx index e3b935e59..31a97830b 100644 --- a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx +++ b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx @@ -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', () => { diff --git a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx index 08a41a8c6..1296a664d 100644 --- a/app/src/components/settings/panels/DeveloperOptionsPanel.tsx +++ b/app/src/components/settings/panels/DeveloperOptionsPanel.tsx @@ -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 ( +
+
Core mode: not set
+
+ The boot-check picker hasn't been confirmed yet. Use Switch mode on the picker to + choose Local or Cloud. +
+
+ ); + } + + if (mode.kind === 'local') { + return ( +
+
+ + Local + + Embedded core sidecar +
+
+ Spawned in-process by the Tauri shell on app launch. +
+
+ ); + } + + // Cloud — show URL + token status. Token value itself is never rendered. + return ( +
+
+ + Cloud + + Remote core RPC +
+
+
URL:
+
+ {mode.url} +
+
Token:
+
+ {mode.token ? ( + ••••••{mode.token.slice(-4)} + ) : ( + not set — RPC will 401 + )} +
+
+
+ ); +}; + type SentryTestStatus = | { kind: 'idle' } | { kind: 'sending' } @@ -341,6 +408,7 @@ const DeveloperOptionsPanel = () => { />
+ {showSentryTest && } {developerItems.map((item, index) => ( diff --git a/app/src/components/settings/panels/__tests__/DeveloperOptionsPanel.test.tsx b/app/src/components/settings/panels/__tests__/DeveloperOptionsPanel.test.tsx index dfc493a0c..5b0134b9c 100644 --- a/app/src/components/settings/panels/__tests__/DeveloperOptionsPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/DeveloperOptionsPanel.test.tsx @@ -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(, { 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(, { + 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(, { + 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(, { preloadedState: { coreMode: { mode: { kind: 'unset' } } } }); + expect(screen.getByText(/Core mode: not set/i)).toBeInTheDocument(); + }); +}); + describe('DeveloperOptionsPanel — Sentry test row', () => { beforeEach(() => { hoisted.trigger.mockReset(); diff --git a/app/src/hooks/useDictationHotkey.ts b/app/src/hooks/useDictationHotkey.ts index 66488b39f..acae08ac6 100644 --- a/app/src/hooks/useDictationHotkey.ts +++ b/app/src/hooks/useDictationHotkey.ts @@ -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 { - let rpcUrl = CORE_RPC_URL; - if (isTauri()) { - try { - const url = await invoke('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 { diff --git a/app/src/overlay/OverlayApp.tsx b/app/src/overlay/OverlayApp.tsx index 9853f3ec4..f20afe868 100644 --- a/app/src/overlay/OverlayApp.tsx +++ b/app/src/overlay/OverlayApp.tsx @@ -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 { - let rpcUrl = CORE_RPC_URL; - if (isTauri()) { - try { - const url = await invoke('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 ──────────────────────────────── diff --git a/app/src/pages/__tests__/Welcome.test.tsx b/app/src/pages/__tests__/Welcome.test.tsx index 4c02b4053..f5f926c16 100644 --- a/app/src/pages/__tests__/Welcome.test.tsx +++ b/app/src/pages/__tests__/Welcome.test.tsx @@ -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; diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts index d6fb82b79..42ca2b109 100644 --- a/app/src/services/__tests__/coreRpcClient.test.ts +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -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; + 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).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).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; + expect(headers.Authorization).toBe('Bearer local-sidecar-token'); + }); +}); diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index 70773f743..8c62d5b15 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -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 { } 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 { const resolvePromise: Promise = (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:/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 { const url = await invoke('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 { 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 { } /** - * 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 { 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 { * 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 { - const token = await getCoreRpcToken(); +export async function testCoreRpcConnection( + url: string, + tokenOverride?: string +): Promise { + const token = tokenOverride?.trim() || (await getCoreRpcToken()); const headers: Record = { 'Content-Type': 'application/json' }; if (token) { headers.Authorization = `Bearer ${token}`; diff --git a/app/src/store/__tests__/userScopedStorage.test.ts b/app/src/store/__tests__/userScopedStorage.test.ts new file mode 100644 index 000000000..cbdb5df3b --- /dev/null +++ b/app/src/store/__tests__/userScopedStorage.test.ts @@ -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(); + } + }); +}); diff --git a/app/src/store/coreModeSlice.test.ts b/app/src/store/coreModeSlice.test.ts index b58be0787..e3b9382a7 100644 --- a/app/src/store/coreModeSlice.test.ts +++ b/app/src/store/coreModeSlice.test.ts @@ -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' }); + }); +}); diff --git a/app/src/store/coreModeSlice.ts b/app/src/store/coreModeSlice.ts index a013019c3..250dd2b0c 100644 --- a/app/src/store/coreModeSlice.ts +++ b/app/src/store/coreModeSlice.ts @@ -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', diff --git a/app/src/store/userScopedStorage.ts b/app/src/store/userScopedStorage.ts index 8fdd957fb..fa8341c33 100644 --- a/app/src/store/userScopedStorage.ts +++ b/app/src/store/userScopedStorage.ts @@ -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(); } diff --git a/app/src/test/test-utils.tsx b/app/src/test/test-utils.tsx index 5e4564c4f..a5c1996cb 100644 --- a/app/src/test/test-utils.tsx +++ b/app/src/test/test-utils.tsx @@ -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, }); diff --git a/app/src/utils/__tests__/configPersistence.test.ts b/app/src/utils/__tests__/configPersistence.test.ts index 875f62aae..7e159da7b 100644 --- a/app/src/utils/__tests__/configPersistence.test.ts +++ b/app/src/utils/__tests__/configPersistence.test.ts @@ -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(); + }); + }); }); diff --git a/app/src/utils/configPersistence.ts b/app/src/utils/configPersistence.ts index d2369b756..2feaf1564 100644 --- a/app/src/utils/configPersistence.ts +++ b/app/src/utils/configPersistence.ts @@ -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'); + } +}