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:
CodeGhost21
2026-05-08 19:00:17 -07:00
committed by GitHub
co-authored by Steven Enamakel
parent b8922c2e28
commit d6ace5a5bb
17 changed files with 942 additions and 104 deletions
+9 -1
View File
@@ -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&apos;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();
+10 -15
View File
@@ -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 {
+5 -15
View File
@@ -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 ────────────────────────────────
+7
View File
@@ -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;
+133 -13
View File
@@ -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');
});
});
+61 -27
View File
@@ -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();
}
});
});
+62 -1
View File
@@ -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' });
});
});
+50 -2
View File
@@ -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',
+20 -10
View File
@@ -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();
}
+2
View File
@@ -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();
});
});
});
+111
View File
@@ -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');
}
}