-
-
-
- OpenHuman is in early beta
+
+
+
+
+
+
+
+
+ OpenHuman is in early beta
+
-
+
diff --git a/app/src/AppRoutes.tsx b/app/src/AppRoutes.tsx
index 3c3444396..e7b4dbe4f 100644
--- a/app/src/AppRoutes.tsx
+++ b/app/src/AppRoutes.tsx
@@ -4,6 +4,7 @@ import { Navigate, Route, Routes } from 'react-router-dom';
import DefaultRedirect from './components/DefaultRedirect';
import ProtectedRoute from './components/ProtectedRoute';
import PublicRoute from './components/PublicRoute';
+import RouteLoadingScreen from './components/RouteLoadingScreen';
import Agents from './pages/Agents';
import Conversations from './pages/Conversations';
import Home from './pages/Home';
@@ -34,7 +35,7 @@ const OnboardingRoute = ({
const hasEncryptionKey = useAppSelector(selectHasEncryptionKey);
const shouldSkipOnboarding = isOnboarded || hasWorkspaceOnboardingFlag;
- if (isWorkspaceFlagLoading) return null;
+ if (isWorkspaceFlagLoading) return
;
if (shouldSkipOnboarding && !hasEncryptionKey) return
;
if (shouldSkipOnboarding) return
;
return
;
diff --git a/app/src/components/DefaultRedirect.tsx b/app/src/components/DefaultRedirect.tsx
index 1ca5f7060..54c8fdcf1 100644
--- a/app/src/components/DefaultRedirect.tsx
+++ b/app/src/components/DefaultRedirect.tsx
@@ -1,6 +1,7 @@
import { Navigate } from 'react-router-dom';
import { useAppSelector } from '../store/hooks';
+import RouteLoadingScreen from './RouteLoadingScreen';
/**
* Default redirect component that routes users based on their auth status.
@@ -9,6 +10,11 @@ import { useAppSelector } from '../store/hooks';
*/
const DefaultRedirect = () => {
const token = useAppSelector(state => state.auth.token);
+ const isAuthBootstrapComplete = useAppSelector(state => state.auth.isAuthBootstrapComplete);
+
+ if (!isAuthBootstrapComplete) {
+ return
;
+ }
if (token) {
return
;
diff --git a/app/src/components/MiniSidebar.tsx b/app/src/components/MiniSidebar.tsx
index 40cea9349..cb85a07d8 100644
--- a/app/src/components/MiniSidebar.tsx
+++ b/app/src/components/MiniSidebar.tsx
@@ -152,9 +152,12 @@ const MiniSidebar = () => {
}).length;
});
- // Hide sidebar when not authenticated or on public/onboarding routes
- const hiddenPaths = ['/', '/login', '/onboarding'];
- if (!token || hiddenPaths.includes(location.pathname)) {
+ // Hide sidebar on public/setup routes and when not authenticated.
+ const hiddenPaths = ['/', '/login', '/onboarding', '/mnemonic'];
+ if (
+ !token ||
+ hiddenPaths.some(path => location.pathname === path || location.pathname.startsWith(`${path}/`))
+ ) {
return null;
}
diff --git a/app/src/components/ProtectedRoute.tsx b/app/src/components/ProtectedRoute.tsx
index 025a283cb..337bd0f0f 100644
--- a/app/src/components/ProtectedRoute.tsx
+++ b/app/src/components/ProtectedRoute.tsx
@@ -2,6 +2,7 @@ import { Navigate } from 'react-router-dom';
import { selectIsOnboarded } from '../store/authSelectors';
import { useAppSelector } from '../store/hooks';
+import RouteLoadingScreen from './RouteLoadingScreen';
interface ProtectedRouteProps {
children: React.ReactNode;
@@ -20,8 +21,13 @@ const ProtectedRoute = ({
redirectTo,
}: ProtectedRouteProps) => {
const token = useAppSelector(state => state.auth.token);
+ const isAuthBootstrapComplete = useAppSelector(state => state.auth.isAuthBootstrapComplete);
const isOnboarded = useAppSelector(selectIsOnboarded);
+ if (!isAuthBootstrapComplete) {
+ return
;
+ }
+
// If auth is required but user is not logged in
if (requireAuth && !token) {
return
;
diff --git a/app/src/components/PublicRoute.tsx b/app/src/components/PublicRoute.tsx
index 61df14373..b9856cf77 100644
--- a/app/src/components/PublicRoute.tsx
+++ b/app/src/components/PublicRoute.tsx
@@ -1,6 +1,7 @@
import { Navigate } from 'react-router-dom';
import { useAppSelector } from '../store/hooks';
+import RouteLoadingScreen from './RouteLoadingScreen';
interface PublicRouteProps {
children: React.ReactNode;
@@ -13,6 +14,11 @@ interface PublicRouteProps {
*/
const PublicRoute = ({ children, redirectTo }: PublicRouteProps) => {
const token = useAppSelector(state => state.auth.token);
+ const isAuthBootstrapComplete = useAppSelector(state => state.auth.isAuthBootstrapComplete);
+
+ if (!isAuthBootstrapComplete) {
+ return
;
+ }
// If user is logged in, always go to home.
// Home itself will redirect to onboarding if needed.
diff --git a/app/src/components/RouteLoadingScreen.tsx b/app/src/components/RouteLoadingScreen.tsx
new file mode 100644
index 000000000..e22d9c79a
--- /dev/null
+++ b/app/src/components/RouteLoadingScreen.tsx
@@ -0,0 +1,15 @@
+interface RouteLoadingScreenProps {
+ label?: string;
+}
+
+const RouteLoadingScreen = ({ label = 'Initializing OpenHuman...' }: RouteLoadingScreenProps) => {
+ return (
+
+ );
+};
+
+export default RouteLoadingScreen;
diff --git a/app/src/components/__tests__/ProtectedRoute.test.tsx b/app/src/components/__tests__/ProtectedRoute.test.tsx
index e659d77e7..5a3a6dd11 100644
--- a/app/src/components/__tests__/ProtectedRoute.test.tsx
+++ b/app/src/components/__tests__/ProtectedRoute.test.tsx
@@ -20,7 +20,12 @@ describe('ProtectedRoute', () => {
,
{
preloadedState: {
- auth: { token: 'valid-jwt', isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
+ auth: {
+ token: 'valid-jwt',
+ isAuthBootstrapComplete: true,
+ isOnboardedByUser: {},
+ isAnalyticsEnabledByUser: {},
+ },
},
}
);
@@ -44,7 +49,12 @@ describe('ProtectedRoute', () => {
{
initialEntries: ['/dashboard'],
preloadedState: {
- auth: { token: null, isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
+ auth: {
+ token: null,
+ isAuthBootstrapComplete: true,
+ isOnboardedByUser: {},
+ isAnalyticsEnabledByUser: {},
+ },
},
}
);
@@ -69,7 +79,12 @@ describe('ProtectedRoute', () => {
{
initialEntries: ['/dashboard'],
preloadedState: {
- auth: { token: null, isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
+ auth: {
+ token: null,
+ isAuthBootstrapComplete: true,
+ isOnboardedByUser: {},
+ isAnalyticsEnabledByUser: {},
+ },
},
}
);
@@ -93,7 +108,12 @@ describe('ProtectedRoute', () => {
{
initialEntries: ['/home'],
preloadedState: {
- auth: { token: 'valid-jwt', isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
+ auth: {
+ token: 'valid-jwt',
+ isAuthBootstrapComplete: true,
+ isOnboardedByUser: {},
+ isAnalyticsEnabledByUser: {},
+ },
user: { user: { _id: 'u1' }, isLoading: false, error: null },
},
}
diff --git a/app/src/components/__tests__/PublicRoute.test.tsx b/app/src/components/__tests__/PublicRoute.test.tsx
index b9dcaa987..f56fc7c7c 100644
--- a/app/src/components/__tests__/PublicRoute.test.tsx
+++ b/app/src/components/__tests__/PublicRoute.test.tsx
@@ -20,7 +20,12 @@ describe('PublicRoute', () => {
,
{
preloadedState: {
- auth: { token: null, isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
+ auth: {
+ token: null,
+ isAuthBootstrapComplete: true,
+ isOnboardedByUser: {},
+ isAnalyticsEnabledByUser: {},
+ },
},
}
);
@@ -43,7 +48,12 @@ describe('PublicRoute', () => {
,
{
preloadedState: {
- auth: { token: 'jwt-token', isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
+ auth: {
+ token: 'jwt-token',
+ isAuthBootstrapComplete: true,
+ isOnboardedByUser: {},
+ isAnalyticsEnabledByUser: {},
+ },
},
}
);
@@ -67,7 +77,12 @@ describe('PublicRoute', () => {
,
{
preloadedState: {
- auth: { token: 'jwt-token', isOnboardedByUser: {}, isAnalyticsEnabledByUser: {} },
+ auth: {
+ token: 'jwt-token',
+ isAuthBootstrapComplete: true,
+ isOnboardedByUser: {},
+ isAnalyticsEnabledByUser: {},
+ },
},
}
);
diff --git a/app/src/components/daemon/ServiceBlockingGate.tsx b/app/src/components/daemon/ServiceBlockingGate.tsx
new file mode 100644
index 000000000..e88095127
--- /dev/null
+++ b/app/src/components/daemon/ServiceBlockingGate.tsx
@@ -0,0 +1,225 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+
+import {
+ isTauri,
+ openhumanAgentServerStatus,
+ openhumanServiceInstall,
+ openhumanServiceStart,
+ openhumanServiceStatus,
+ openhumanServiceStop,
+ openhumanServiceUninstall,
+ type ServiceState,
+} from '../../utils/tauriCommands';
+
+interface ServiceBlockingGateProps {
+ children: React.ReactNode;
+}
+
+type GateStatus = 'checking' | 'ready' | 'blocked';
+const SERVICE_GATE_POLL_MS = 3000;
+type RefreshOptions = { showChecking?: boolean; clearError?: boolean };
+
+const normalizeServiceState = (state: ServiceState | undefined): string => {
+ if (!state) return 'Unknown';
+ if (typeof state === 'string') return state;
+ if ('Unknown' in state) return `Unknown(${state.Unknown})`;
+ return 'Unknown';
+};
+
+const ServiceBlockingGate = ({ children }: ServiceBlockingGateProps) => {
+ const [gateStatus, setGateStatus] = useState
('checking');
+ const [serviceStateText, setServiceStateText] = useState('Unknown');
+ const [agentRunning, setAgentRunning] = useState(false);
+ const [isOperating, setIsOperating] = useState(false);
+ const [error, setError] = useState(null);
+
+ const refreshStatus = useCallback(async (options: RefreshOptions = {}) => {
+ const { showChecking = false, clearError = false } = options;
+ if (!isTauri()) {
+ console.info('[ServiceBlockingGate] Non-Tauri environment detected; gate is ready');
+ setGateStatus('ready');
+ return;
+ }
+
+ if (clearError) {
+ setError(null);
+ }
+ if (showChecking) {
+ setGateStatus('checking');
+ }
+ console.info('[ServiceBlockingGate] Refreshing service + agent status');
+
+ try {
+ const [service, agent] = await Promise.all([
+ openhumanServiceStatus(),
+ openhumanAgentServerStatus(),
+ ]);
+ const serviceState = service?.result?.state;
+ const normalized = normalizeServiceState(serviceState);
+ const serviceRunning = normalized === 'Running';
+ const agentIsRunning = !!agent?.result?.running;
+
+ setServiceStateText(prev => (prev === normalized ? prev : normalized));
+ setAgentRunning(prev => (prev === agentIsRunning ? prev : agentIsRunning));
+ setGateStatus(prev => {
+ const next = serviceRunning && agentIsRunning ? 'ready' : 'blocked';
+ return prev === next ? prev : next;
+ });
+ setError(prev => (prev ? null : prev));
+ console.info('[ServiceBlockingGate] Status refreshed', {
+ serviceState: normalized,
+ agentRunning: agentIsRunning,
+ nextGateStatus: serviceRunning && agentIsRunning ? 'ready' : 'blocked',
+ });
+ } catch (err) {
+ setServiceStateText('Unknown');
+ setAgentRunning(false);
+ setGateStatus('blocked');
+ const message = err instanceof Error ? err.message : String(err);
+ setError(message);
+ console.error('[ServiceBlockingGate] Failed to refresh status', { error: message });
+ }
+ }, []);
+
+ useEffect(() => {
+ void refreshStatus({ showChecking: true });
+ }, [refreshStatus]);
+
+ useEffect(() => {
+ if (!isTauri()) {
+ return;
+ }
+
+ console.info('[ServiceBlockingGate] Starting periodic health polling', {
+ pollMs: SERVICE_GATE_POLL_MS,
+ });
+ const interval = window.setInterval(() => {
+ void refreshStatus();
+ }, SERVICE_GATE_POLL_MS);
+
+ const onVisible = () => {
+ if (document.visibilityState === 'visible') {
+ console.info('[ServiceBlockingGate] App visible; forcing immediate status refresh');
+ void refreshStatus();
+ }
+ };
+ document.addEventListener('visibilitychange', onVisible);
+
+ return () => {
+ console.info('[ServiceBlockingGate] Stopping periodic health polling');
+ window.clearInterval(interval);
+ document.removeEventListener('visibilitychange', onVisible);
+ };
+ }, [refreshStatus]);
+
+ const installed = useMemo(() => serviceStateText !== 'NotInstalled', [serviceStateText]);
+ const serviceRunning = useMemo(() => serviceStateText === 'Running', [serviceStateText]);
+
+ const runOperation = useCallback(
+ async (op: () => Promise) => {
+ const opName = op.name || 'anonymous-operation';
+ console.info('[ServiceBlockingGate] Running operation', { operation: opName });
+ setIsOperating(true);
+ setError(null);
+ try {
+ await op();
+ console.info('[ServiceBlockingGate] Operation completed', { operation: opName });
+ } catch (err) {
+ const message = err instanceof Error ? err.message : String(err);
+ setError(message);
+ console.error('[ServiceBlockingGate] Operation failed', {
+ operation: opName,
+ error: message,
+ });
+ } finally {
+ setIsOperating(false);
+ await refreshStatus();
+ }
+ },
+ [refreshStatus]
+ );
+
+ const restartService = useCallback(async () => {
+ console.info('[ServiceBlockingGate] Restart requested: stop -> start');
+ await openhumanServiceStop();
+ await openhumanServiceStart();
+ }, []);
+
+ if (gateStatus === 'ready') {
+ return <>{children}>;
+ }
+
+ return (
+
+
+
OpenHuman Service Required
+
+ The desktop service must be installed and running before the app can continue.
+
+
+
+
+
Service
+
{serviceStateText}
+
+
+
Agent Server
+
{agentRunning ? 'Running' : 'Not Running'}
+
+
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default ServiceBlockingGate;
diff --git a/app/src/components/daemon/__tests__/ServiceBlockingGate.test.tsx b/app/src/components/daemon/__tests__/ServiceBlockingGate.test.tsx
new file mode 100644
index 000000000..145e303dc
--- /dev/null
+++ b/app/src/components/daemon/__tests__/ServiceBlockingGate.test.tsx
@@ -0,0 +1,60 @@
+import { fireEvent, render, screen, waitFor } from '@testing-library/react';
+import { describe, expect, it, type Mock } from 'vitest';
+
+import * as tauriCommands from '../../../utils/tauriCommands';
+import ServiceBlockingGate from '../ServiceBlockingGate';
+
+describe('ServiceBlockingGate', () => {
+ const mockIsTauri = tauriCommands.isTauri as Mock;
+ const mockServiceStatus = tauriCommands.openhumanServiceStatus as Mock;
+ const mockAgentStatus = tauriCommands.openhumanAgentServerStatus as Mock;
+ const mockInstall = tauriCommands.openhumanServiceInstall as Mock;
+ const mockStart = tauriCommands.openhumanServiceStart as Mock;
+
+ it('renders children directly outside Tauri', async () => {
+ mockIsTauri.mockReturnValue(false);
+
+ render(
+
+ App Content
+
+ );
+
+ await waitFor(() => expect(screen.getByText('App Content')).toBeInTheDocument());
+ });
+
+ it('shows blocking screen when service is not installed', async () => {
+ mockIsTauri.mockReturnValue(true);
+ mockServiceStatus.mockResolvedValue({ result: { state: 'NotInstalled' }, logs: [] });
+ mockAgentStatus.mockResolvedValue({ result: { running: false }, logs: [] });
+
+ render(
+
+ App Content
+
+ );
+
+ await waitFor(() => expect(screen.getByText('OpenHuman Service Required')).toBeInTheDocument());
+ expect(screen.queryByText('App Content')).not.toBeInTheDocument();
+ expect(screen.getByText('NotInstalled')).toBeInTheDocument();
+ });
+
+ it('runs install and start actions from blocker', async () => {
+ mockIsTauri.mockReturnValue(true);
+ mockServiceStatus.mockResolvedValue({ result: { state: 'NotInstalled' }, logs: [] });
+ mockAgentStatus.mockResolvedValue({ result: { running: false }, logs: [] });
+ mockInstall.mockResolvedValue({ result: { state: 'Stopped' }, logs: [] });
+ mockStart.mockResolvedValue({ result: { state: 'Running' }, logs: [] });
+
+ render(
+
+ App Content
+
+ );
+
+ await waitFor(() => expect(screen.getByText('OpenHuman Service Required')).toBeInTheDocument());
+
+ fireEvent.click(screen.getByRole('button', { name: 'Install Service' }));
+ await waitFor(() => expect(mockInstall).toHaveBeenCalled());
+ });
+});
diff --git a/app/src/components/oauth/OAuthProviderButton.tsx b/app/src/components/oauth/OAuthProviderButton.tsx
index 1b630e93c..051b6fca2 100644
--- a/app/src/components/oauth/OAuthProviderButton.tsx
+++ b/app/src/components/oauth/OAuthProviderButton.tsx
@@ -1,5 +1,6 @@
import { useState } from 'react';
+import { getBackendUrl } from '../../services/backendUrl';
import type { OAuthProviderConfig } from '../../types/oauth';
import { IS_DEV } from '../../utils/config';
import { openUrl } from '../../utils/openUrl';
@@ -23,23 +24,26 @@ const OAuthProviderButton = ({
console.log(`Starting ${provider.name} OAuth login`, isTauri());
- if (IS_DEV) {
- console.log(`[dev] OAuth debug mode enabled. OAuth URL: ${provider.loginUrl}`);
- console.log('[dev] In debug mode, OAuth will return JSON response instead of redirect.');
- console.log(
- '[dev] After OAuth completion, copy the loginToken and use: window.__simulateDeepLink("openhuman://auth?token=YOUR_TOKEN")'
- );
- }
-
setIsLoading(true);
try {
+ const backendUrl = await getBackendUrl();
+ const loginUrl = `${backendUrl}/auth/${provider.id}/login${IS_DEV ? '?responseType=json' : ''}`;
+
+ if (IS_DEV) {
+ console.log(`[dev] OAuth debug mode enabled. OAuth URL: ${loginUrl}`);
+ console.log('[dev] In debug mode, OAuth will return JSON response instead of redirect.');
+ console.log(
+ '[dev] After OAuth completion, copy the loginToken and use: window.__simulateDeepLink("openhuman://auth?token=YOUR_TOKEN")'
+ );
+ }
+
// Desktop (Tauri): use system browser → backend OAuth → deep link back to app
if (isTauri()) {
- await openUrl(provider.loginUrl);
+ await openUrl(loginUrl);
} else {
// Web fallback: direct OAuth flow in current window
- window.location.href = provider.loginUrl;
+ window.location.href = loginUrl;
}
} catch (error) {
console.error(`Failed to initiate ${provider.name} OAuth login:`, error);
diff --git a/app/src/components/oauth/providerConfigs.tsx b/app/src/components/oauth/providerConfigs.tsx
index 601395fbd..bc17fa736 100644
--- a/app/src/components/oauth/providerConfigs.tsx
+++ b/app/src/components/oauth/providerConfigs.tsx
@@ -2,7 +2,6 @@
* OAuth provider configurations with brand colors and icons
*/
import type { OAuthProviderConfig } from '../../types/oauth';
-import { BACKEND_URL, IS_DEV } from '../../utils/config';
// Provider Icons
const GoogleIcon = ({ className = '' }: { className?: string }) => (
@@ -52,7 +51,6 @@ export const oauthProviderConfigs: OAuthProviderConfig[] = [
color: 'bg-white border border-gray-200',
hoverColor: 'hover:bg-gray-50 hover:border-gray-300',
textColor: 'text-gray-900',
- loginUrl: `${BACKEND_URL}/auth/google/login?${IS_DEV ? 'responseType=json' : ''}`,
},
{
id: 'github',
@@ -61,7 +59,6 @@ export const oauthProviderConfigs: OAuthProviderConfig[] = [
color: 'bg-gray-900 border border-gray-800',
hoverColor: 'hover:bg-gray-800 hover:border-gray-700',
textColor: 'text-white',
- loginUrl: `${BACKEND_URL}/auth/github/login?${IS_DEV ? 'responseType=json' : ''}`,
},
{
id: 'twitter',
@@ -70,7 +67,6 @@ export const oauthProviderConfigs: OAuthProviderConfig[] = [
color: 'bg-black border border-gray-800',
hoverColor: 'hover:bg-gray-900 hover:border-gray-700',
textColor: 'text-white',
- loginUrl: `${BACKEND_URL}/auth/twitter/login?${IS_DEV ? 'responseType=json' : ''}`,
},
{
id: 'discord',
@@ -79,7 +75,6 @@ export const oauthProviderConfigs: OAuthProviderConfig[] = [
color: 'bg-indigo-600 border border-indigo-500',
hoverColor: 'hover:bg-indigo-700 hover:border-indigo-600',
textColor: 'text-white',
- loginUrl: `${BACKEND_URL}/auth/discord/login?${IS_DEV ? 'responseType=json' : ''}`,
},
];
diff --git a/app/src/components/settings/SettingsHome.tsx b/app/src/components/settings/SettingsHome.tsx
index ea04bb646..f97329adf 100644
--- a/app/src/components/settings/SettingsHome.tsx
+++ b/app/src/components/settings/SettingsHome.tsx
@@ -23,7 +23,7 @@ const SettingsHome = () => {
} catch (err) {
console.warn('[Settings] Rust logout failed:', err);
}
- window.location.hash = '/login';
+ window.location.hash = '/';
};
const clearAllAppData = async () => {
@@ -47,7 +47,7 @@ const SettingsHome = () => {
}
// Complete reset - redirect to login for fresh start
- window.location.hash = '/login';
+ window.location.hash = '/';
};
const handleLogoutAndClearData = async () => {
diff --git a/app/src/components/settings/panels/LocalModelPanel.tsx b/app/src/components/settings/panels/LocalModelPanel.tsx
index 06f01ba6f..24536e3ea 100644
--- a/app/src/components/settings/panels/LocalModelPanel.tsx
+++ b/app/src/components/settings/panels/LocalModelPanel.tsx
@@ -1,8 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import {
- isTauri,
type LocalAiAssetsStatus,
+ type LocalAiDownloadsProgress,
type LocalAiEmbeddingResult,
type LocalAiSpeechResult,
type LocalAiStatus,
@@ -10,7 +10,9 @@ import {
type LocalAiTtsResult,
openhumanLocalAiAssetsStatus,
openhumanLocalAiDownload,
+ openhumanLocalAiDownloadAllAssets,
openhumanLocalAiDownloadAsset,
+ openhumanLocalAiDownloadsProgress,
openhumanLocalAiEmbed,
openhumanLocalAiPrompt,
openhumanLocalAiStatus,
@@ -77,6 +79,12 @@ const progressFromStatus = (status: LocalAiStatus | null): number => {
}
};
+const progressFromDownloads = (downloads: LocalAiDownloadsProgress | null): number | null => {
+ if (!downloads) return null;
+ if (typeof downloads.progress !== 'number') return null;
+ return Math.max(0, Math.min(1, downloads.progress));
+};
+
const formatBytes = (bytes?: number | null): string => {
if (typeof bytes !== 'number' || !Number.isFinite(bytes) || bytes < 0) return '0 B';
if (bytes < 1024) return `${Math.round(bytes)} B`;
@@ -104,6 +112,7 @@ const LocalModelPanel = () => {
const { navigateBack } = useSettingsNavigation();
const [status, setStatus] = useState(null);
const [assets, setAssets] = useState(null);
+ const [downloads, setDownloads] = useState(null);
const [statusError, setStatusError] = useState('');
const [isTriggeringDownload, setIsTriggeringDownload] = useState(false);
const [assetDownloadBusy, setAssetDownloadBusy] = useState>({});
@@ -139,37 +148,44 @@ const LocalModelPanel = () => {
const [ttsOutput, setTtsOutput] = useState(null);
const [isTtsLoading, setIsTtsLoading] = useState(false);
- const progress = useMemo(() => progressFromStatus(status), [status]);
+ const progress = useMemo(() => {
+ const downloadProgress = progressFromDownloads(downloads);
+ if (downloadProgress != null) return downloadProgress;
+ return progressFromStatus(status);
+ }, [downloads, status]);
const isIndeterminateDownload =
- status?.state === 'downloading' && typeof status.download_progress !== 'number';
+ (downloads?.state ?? status?.state) === 'downloading' &&
+ typeof downloads?.progress !== 'number' &&
+ typeof status?.download_progress !== 'number';
+ const downloadedBytes = downloads?.downloaded_bytes ?? status?.downloaded_bytes;
+ const totalBytes = downloads?.total_bytes ?? status?.total_bytes;
+ const speedBps = downloads?.speed_bps ?? status?.download_speed_bps;
+ const etaSeconds = downloads?.eta_seconds ?? status?.eta_seconds;
const downloadedText =
- typeof status?.downloaded_bytes === 'number'
- ? `${formatBytes(status.downloaded_bytes)}${typeof status?.total_bytes === 'number' ? ` / ${formatBytes(status.total_bytes)}` : ''}`
+ typeof downloadedBytes === 'number'
+ ? `${formatBytes(downloadedBytes)}${typeof totalBytes === 'number' ? ` / ${formatBytes(totalBytes)}` : ''}`
: '';
const speedText =
- typeof status?.download_speed_bps === 'number' && status.download_speed_bps > 0
- ? `${formatBytes(status.download_speed_bps)}/s`
- : '';
- const etaText = formatEta(status?.eta_seconds);
+ typeof speedBps === 'number' && speedBps > 0 ? `${formatBytes(speedBps)}/s` : '';
+ const etaText = formatEta(etaSeconds);
const loadStatus = async () => {
- if (!isTauri()) {
- setStatusError('Local model tools are available only in Tauri desktop builds.');
- setStatus(null);
- return;
- }
-
try {
- const response = await openhumanLocalAiStatus();
- const assetResponse = await openhumanLocalAiAssetsStatus();
- setStatus(response.result);
- setAssets(assetResponse.result);
+ const [statusResponse, assetsResponse, downloadsResponse] = await Promise.all([
+ openhumanLocalAiStatus(),
+ openhumanLocalAiAssetsStatus(),
+ openhumanLocalAiDownloadsProgress(),
+ ]);
+ setStatus(statusResponse.result);
+ setAssets(assetsResponse.result);
+ setDownloads(downloadsResponse.result);
setStatusError('');
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to read local model status';
setStatusError(message);
setStatus(null);
setAssets(null);
+ setDownloads(null);
}
};
@@ -182,11 +198,11 @@ const LocalModelPanel = () => {
}, []);
const triggerDownload = async (force: boolean) => {
- if (!isTauri()) return;
setIsTriggeringDownload(true);
setStatusError('');
try {
await openhumanLocalAiDownload(force);
+ await openhumanLocalAiDownloadAllAssets(force);
await loadStatus();
} catch (err) {
const message =
@@ -198,7 +214,7 @@ const LocalModelPanel = () => {
};
const runSummaryTest = async () => {
- if (!summaryInput.trim() || !isTauri()) return;
+ if (!summaryInput.trim()) return;
setIsSummaryLoading(true);
setSummaryOutput('');
setStatusError('');
@@ -215,7 +231,7 @@ const LocalModelPanel = () => {
};
const runSuggestTest = async () => {
- if (!suggestInput.trim() || !isTauri()) return;
+ if (!suggestInput.trim()) return;
setIsSuggestLoading(true);
setSuggestions([]);
setStatusError('');
@@ -232,7 +248,7 @@ const LocalModelPanel = () => {
};
const runPromptTest = async () => {
- if (!promptInput.trim() || !isTauri()) return;
+ if (!promptInput.trim()) return;
setIsPromptLoading(true);
setPromptOutput('');
setStatusError('');
@@ -249,7 +265,7 @@ const LocalModelPanel = () => {
};
const runVisionTest = async () => {
- if (!visionPromptInput.trim() || !visionImageInput.trim() || !isTauri()) return;
+ if (!visionPromptInput.trim() || !visionImageInput.trim()) return;
setIsVisionLoading(true);
setVisionOutput('');
setStatusError('');
@@ -270,7 +286,7 @@ const LocalModelPanel = () => {
};
const runEmbeddingTest = async () => {
- if (!embeddingInput.trim() || !isTauri()) return;
+ if (!embeddingInput.trim()) return;
setIsEmbeddingLoading(true);
setEmbeddingOutput(null);
setStatusError('');
@@ -291,7 +307,7 @@ const LocalModelPanel = () => {
};
const runTranscribeTest = async () => {
- if (!audioPathInput.trim() || !isTauri()) return;
+ if (!audioPathInput.trim()) return;
setIsTranscribeLoading(true);
setTranscribeOutput(null);
setStatusError('');
@@ -308,7 +324,7 @@ const LocalModelPanel = () => {
};
const runTtsTest = async () => {
- if (!ttsInput.trim() || !isTauri()) return;
+ if (!ttsInput.trim()) return;
setIsTtsLoading(true);
setTtsOutput(null);
setStatusError('');
@@ -330,7 +346,6 @@ const LocalModelPanel = () => {
const triggerAssetDownload = async (
capability: 'chat' | 'vision' | 'embedding' | 'stt' | 'tts'
) => {
- if (!isTauri()) return;
setAssetDownloadBusy(prev => ({ ...prev, [capability]: true }));
setStatusError('');
try {
@@ -364,7 +379,7 @@ const LocalModelPanel = () => {
State
- {status ? statusLabel(status.state) : 'Unavailable'}
+ {status ? statusLabel(downloads?.state ?? status.state) : 'Unavailable'}
@@ -436,13 +451,13 @@ const LocalModelPanel = () => {
@@ -475,7 +490,7 @@ const LocalModelPanel = () => {
)}
@@ -500,7 +515,7 @@ const LocalModelPanel = () => {
@@ -528,7 +543,7 @@ const LocalModelPanel = () => {
@@ -572,7 +587,7 @@ const LocalModelPanel = () => {
@@ -605,12 +620,7 @@ const LocalModelPanel = () => {
/>
@@ -633,7 +643,7 @@ const LocalModelPanel = () => {
/>
@@ -658,7 +668,7 @@ const LocalModelPanel = () => {
/>
@@ -688,7 +698,7 @@ const LocalModelPanel = () => {
/>
diff --git a/app/src/components/settings/panels/TauriCommandsPanel.tsx b/app/src/components/settings/panels/TauriCommandsPanel.tsx
index e7584dd79..eb253cc47 100644
--- a/app/src/components/settings/panels/TauriCommandsPanel.tsx
+++ b/app/src/components/settings/panels/TauriCommandsPanel.tsx
@@ -10,7 +10,6 @@ import {
import { useCallback, useEffect, useMemo, useState } from 'react';
import { formatRelativeTime, useDaemonHealth } from '../../../hooks/useDaemonHealth';
-import { BACKEND_URL } from '../../../utils/config';
import {
isTauri,
openhumanAgentChat,
@@ -640,7 +639,7 @@ const TauriCommandsPanel = () => {
onChange={setApiUrl}
error={fieldErrors.apiUrl}
type="url"
- placeholder={BACKEND_URL}
+ placeholder="Resolved by core binary"
helpText="REST API origin for your OpenHuman backend (chat at /openai/v1/chat/completions)."
validation={
!apiUrl
diff --git a/app/src/hooks/useConsciousItems.ts b/app/src/hooks/useConsciousItems.ts
index c5f31ba49..9a9db97d9 100644
--- a/app/src/hooks/useConsciousItems.ts
+++ b/app/src/hooks/useConsciousItems.ts
@@ -17,13 +17,13 @@ import { listen } from '@tauri-apps/api/event';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
+import { getBackendUrl } from '../services/backendUrl';
import type { RootState } from '../store';
import type {
ActionableItem,
ActionableItemPriority,
ActionableItemSource,
} from '../types/intelligence';
-import { BACKEND_URL } from '../utils/config';
import { consciousLoopRun, isTauri, memoryQueryNamespace } from '../utils/tauriCommands';
// ─── Types from conscious_loop.rs (mirrored) ────────────────────────────────
@@ -178,7 +178,7 @@ export function useConsciousItems(): UseConsciousItemsResult {
const triggerAnalysis = useCallback(async () => {
if (!isTauri() || !authToken || isRunning) return;
try {
- await consciousLoopRun(authToken, BACKEND_URL);
+ await consciousLoopRun(authToken, await getBackendUrl());
} catch (err) {
console.warn('[conscious] Failed to trigger analysis:', err);
}
diff --git a/app/src/lib/ai/__tests__/loader.test.ts b/app/src/lib/ai/__tests__/loader.test.ts
index e4beb60ce..cf0035cfc 100644
--- a/app/src/lib/ai/__tests__/loader.test.ts
+++ b/app/src/lib/ai/__tests__/loader.test.ts
@@ -60,6 +60,10 @@ describe('Unified AI Loader', () => {
beforeEach(() => {
vi.clearAllMocks();
+ // Reset persisted mockReturnValue from tests that stub localStorage (e.g. cache hit test).
+ localStorageMock.getItem.mockReset();
+ localStorageMock.setItem.mockReset();
+ localStorageMock.removeItem.mockReset();
clearAICache();
(loadSoul as any).mockResolvedValue(mockSoulConfig);
(loadTools as any).mockResolvedValue(mockToolsConfig);
diff --git a/app/src/main.tsx b/app/src/main.tsx
index df5fa60e0..3aadd5fa5 100644
--- a/app/src/main.tsx
+++ b/app/src/main.tsx
@@ -9,8 +9,20 @@ import './polyfills';
import { initSentry } from './services/analytics';
import { setupDesktopDeepLinkListener } from './utils/desktopDeepLinkListener';
+const ensureDefaultHashRoute = () => {
+ const hash = window.location.hash;
+ if (!hash || hash === '#') {
+ window.location.replace(`${window.location.pathname}${window.location.search}#/`);
+ return;
+ }
+ if (!hash.startsWith('#/')) {
+ window.location.hash = '/';
+ }
+};
+
// Initialize Sentry early (before React renders)
initSentry();
+ensureDefaultHashRoute();
// Deep link listener — try/catch handles non-Tauri environments
setupDesktopDeepLinkListener().catch(err => {
diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx
index 07dbe370f..8e1961a58 100644
--- a/app/src/pages/Conversations.tsx
+++ b/app/src/pages/Conversations.tsx
@@ -5,6 +5,7 @@ import { useNavigate } from 'react-router-dom';
import { creditsApi, type TeamUsage } from '../services/api/creditsApi';
import { inferenceApi, type ModelInfo } from '../services/api/inferenceApi';
+import { getBackendUrl } from '../services/backendUrl';
import {
chatCancel,
chatSend,
@@ -26,7 +27,6 @@ import {
setSelectedThread,
} from '../store/threadSlice';
import type { ThreadMessage } from '../types/thread';
-import { BACKEND_URL } from '../utils/config';
import {
openhumanAgentChat,
openhumanLocalAiTranscribeBytes,
@@ -446,7 +446,7 @@ const Conversations = () => {
message: trimmed,
model: selectedModel,
authToken,
- backendUrl: BACKEND_URL,
+ backendUrl: await getBackendUrl(),
messages: chatMessages,
notionContext: notionCtx,
});
diff --git a/app/src/providers/SkillProvider.tsx b/app/src/providers/SkillProvider.tsx
index c3cb4399b..7225d48d9 100644
--- a/app/src/providers/SkillProvider.tsx
+++ b/app/src/providers/SkillProvider.tsx
@@ -12,7 +12,7 @@ import type { SkillManifest } from '../lib/skills/types';
import { buildManualSentryEvent, enqueueError } from '../services/errorReportQueue';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { setSkillError, setSkillState, setSkillStatus } from '../store/skillsSlice';
-import { DEV_AUTO_LOAD_SKILL, IS_DEV } from '../utils/config';
+import { IS_DEV } from '../utils/config';
import { runtimeDiscoverSkills } from '../utils/tauriCommands';
// ---------------------------------------------------------------------------
@@ -146,18 +146,6 @@ export default function SkillProvider({ children }: { children: ReactNode }) {
skillManager.registerSkill(manifest);
}
- // Auto-start skill specified in DEV_AUTO_LOAD_SKILL env variable (dev only)
- if (DEV_AUTO_LOAD_SKILL) {
- const autoLoadManifest = manifests.find(m => m.id === DEV_AUTO_LOAD_SKILL);
- if (autoLoadManifest) {
- try {
- await skillManager.startSkill(autoLoadManifest);
- } catch (err) {
- console.error(`[SkillProvider] Failed to auto-load skill ${DEV_AUTO_LOAD_SKILL}:`, err);
- }
- }
- }
-
// Auto-start skills with completed setup
for (const manifest of manifests) {
const existing = skillsState[manifest.id];
diff --git a/app/src/providers/UserProvider.tsx b/app/src/providers/UserProvider.tsx
index 34ac924bf..fe3bc9d55 100644
--- a/app/src/providers/UserProvider.tsx
+++ b/app/src/providers/UserProvider.tsx
@@ -1,10 +1,25 @@
-import { useEffect, useRef } from 'react';
+import { useEffect } from 'react';
-import { clearToken, setToken } from '../store/authSlice';
+import { clearToken, setAuthBootstrapComplete, setToken } from '../store/authSlice';
import { useAppDispatch, useAppSelector } from '../store/hooks';
import { fetchTeams } from '../store/teamSlice';
import { fetchCurrentUser } from '../store/userSlice';
-import { getSessionToken } from '../utils/tauriCommands';
+import { getAuthState, getSessionToken, isTauri } from '../utils/tauriCommands';
+
+const AUTH_BOOTSTRAP_TIMEOUT_MS = 5000;
+
+async function withTimeout
(promise: Promise, timeoutMs: number): Promise {
+ let timeoutId: ReturnType | null = null;
+ const timeoutPromise = new Promise((_, reject) => {
+ timeoutId = setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs);
+ });
+
+ try {
+ return await Promise.race([promise, timeoutPromise]);
+ } finally {
+ if (timeoutId) clearTimeout(timeoutId);
+ }
+}
/**
* UserProvider automatically fetches user data when JWT token is available.
@@ -13,28 +28,43 @@ import { getSessionToken } from '../utils/tauriCommands';
const UserProvider = ({ children }: { children: React.ReactNode }) => {
const dispatch = useAppDispatch();
const token = useAppSelector(state => state.auth.token);
- const attemptedSessionRestoreRef = useRef(false);
+ const isAuthBootstrapComplete = useAppSelector(state => state.auth.isAuthBootstrapComplete);
useEffect(() => {
- if (token || attemptedSessionRestoreRef.current) return;
- attemptedSessionRestoreRef.current = true;
+ if (isAuthBootstrapComplete) return;
let mounted = true;
void (async () => {
+ if (!isTauri()) {
+ if (mounted) dispatch(setAuthBootstrapComplete(true));
+ return;
+ }
+
try {
- const sessionToken = await getSessionToken();
- if (mounted && sessionToken) {
- dispatch(setToken(sessionToken));
+ const [authState, sessionToken] = await withTimeout(
+ Promise.all([getAuthState(), getSessionToken()]),
+ AUTH_BOOTSTRAP_TIMEOUT_MS
+ );
+ if (!mounted) return;
+
+ if (authState.is_authenticated && sessionToken) {
+ if (sessionToken !== token) {
+ dispatch(setToken(sessionToken));
+ }
+ } else if (!authState.is_authenticated && token) {
+ await dispatch(clearToken());
}
} catch (err) {
console.warn('[auth] Failed to restore session token from core RPC:', err);
+ } finally {
+ if (mounted) dispatch(setAuthBootstrapComplete(true));
}
})();
return () => {
mounted = false;
};
- }, [token, dispatch]);
+ }, [token, dispatch, isAuthBootstrapComplete]);
useEffect(() => {
if (!token) return;
diff --git a/app/src/services/api/__tests__/userApi.test.ts b/app/src/services/api/__tests__/userApi.test.ts
index 53673188d..7f1a4e181 100644
--- a/app/src/services/api/__tests__/userApi.test.ts
+++ b/app/src/services/api/__tests__/userApi.test.ts
@@ -1,15 +1,16 @@
-import { http, HttpResponse } from 'msw';
import { describe, expect, it, vi } from 'vitest';
-import { server } from '../../../test/server';
+// @ts-ignore - test-only JS module outside app/src
+import { setMockBehavior } from '../../../../../scripts/mock-api-core.mjs';
// Mock the store import that apiClient depends on
vi.mock('../../../store', () => ({
store: { getState: () => ({ auth: { token: 'test-jwt-token' } }) },
}));
-// Mock the config to use test backend URL
-vi.mock('../../../utils/config', () => ({ BACKEND_URL: 'http://localhost:5005', IS_DEV: true }));
+vi.mock('../../../services/backendUrl', () => ({
+ getBackendUrl: vi.fn().mockResolvedValue('http://localhost:5005'),
+}));
// Import after mocks
const { userApi } = await import('../userApi');
@@ -25,31 +26,22 @@ describe('userApi.getMe', () => {
});
it('throws when API returns error response', async () => {
- server.use(
- http.get('http://localhost:5005/telegram/me', () => {
- return HttpResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
- })
- );
+ setMockBehavior('telegramMeStatus', '401');
+ setMockBehavior('telegramMeError', 'Unauthorized');
await expect(userApi.getMe()).rejects.toThrow();
});
it('throws when API returns success=false', async () => {
- server.use(
- http.get('http://localhost:5005/telegram/me', () => {
- return HttpResponse.json({ success: false, error: 'Invalid token' });
- })
- );
+ setMockBehavior('telegramMeStatus', '200');
+ setMockBehavior('telegramMeError', 'Invalid token');
await expect(userApi.getMe()).rejects.toThrow('Invalid token');
});
it('throws on network error', async () => {
- server.use(
- http.get('http://localhost:5005/telegram/me', () => {
- return HttpResponse.error();
- })
- );
+ setMockBehavior('telegramMeStatus', '503');
+ setMockBehavior('telegramMeError', 'Service unavailable');
await expect(userApi.getMe()).rejects.toBeDefined();
});
diff --git a/app/src/services/api/authApi.ts b/app/src/services/api/authApi.ts
index e4344c916..e182a7b7c 100644
--- a/app/src/services/api/authApi.ts
+++ b/app/src/services/api/authApi.ts
@@ -1,14 +1,6 @@
-import { isTauri as coreIsTauri } from '@tauri-apps/api/core';
-
import { base64ToBytes, encryptIntegrationTokens } from '../../utils/integrationTokensCrypto';
-import { apiClient } from '../apiClient';
import { callCoreRpc } from '../coreRpcClient';
-interface ConsumeLoginTokenResponse {
- success: boolean;
- data: { jwtToken: string };
-}
-
interface IntegrationTokensResponse {
success: boolean;
data?: { encrypted: string };
@@ -41,28 +33,15 @@ function normalizeKeyToHex(rawKey: string): string {
* POST /telegram/login-tokens/:token/consume (no auth required)
*/
export async function consumeLoginToken(loginToken: string): Promise {
- if (coreIsTauri()) {
- const response = await callCoreRpc<{ result: { jwtToken: string } }>({
- method: 'openhuman.auth.consume_login_token',
- params: { loginToken },
- });
- const jwtToken = response.result?.jwtToken;
- if (!jwtToken) {
- throw new Error('Login token invalid or expired');
- }
- return jwtToken;
- }
-
- const response = await apiClient.post(
- `/telegram/login-tokens/${encodeURIComponent(loginToken)}/consume`,
- undefined,
- { requireAuth: false }
- );
- console.log('[ConsumeLoginToken] Response', response);
- if (!response.success || !response.data?.jwtToken) {
+ const response = await callCoreRpc<{ result: { jwtToken: string } }>({
+ method: 'openhuman.auth.consume_login_token',
+ params: { loginToken },
+ });
+ const jwtToken = response.result?.jwtToken;
+ if (!jwtToken) {
throw new Error('Login token invalid or expired');
}
- return response.data.jwtToken;
+ return jwtToken;
}
/**
@@ -73,29 +52,22 @@ export async function fetchIntegrationTokens(
integrationId: string,
key: string
): Promise {
- if (coreIsTauri()) {
- const response = await callCoreRpc<{ result: IntegrationTokensPayload }>({
- method: 'openhuman.auth.oauth_fetch_integration_tokens',
- params: { integrationId, key },
- });
- const tokens = response.result;
- if (!tokens?.accessToken || !tokens?.expiresAt) {
- throw new Error('Integration token handoff did not return required fields');
- }
-
- const encrypted = await encryptIntegrationTokens(
- JSON.stringify({
- accessToken: tokens.accessToken,
- refreshToken: tokens.refreshToken ?? '',
- expiresAt: tokens.expiresAt,
- }),
- normalizeKeyToHex(key)
- );
- return { success: true, data: { encrypted } };
+ const response = await callCoreRpc<{ result: IntegrationTokensPayload }>({
+ method: 'openhuman.auth.oauth_fetch_integration_tokens',
+ params: { integrationId, key },
+ });
+ const tokens = response.result;
+ if (!tokens?.accessToken || !tokens?.expiresAt) {
+ throw new Error('Integration token handoff did not return required fields');
}
- return apiClient.post(
- `/auth/integrations/${encodeURIComponent(integrationId)}/tokens`,
- { key }
+ const encrypted = await encryptIntegrationTokens(
+ JSON.stringify({
+ accessToken: tokens.accessToken,
+ refreshToken: tokens.refreshToken ?? '',
+ expiresAt: tokens.expiresAt,
+ }),
+ normalizeKeyToHex(key)
);
+ return { success: true, data: { encrypted } };
}
diff --git a/app/src/services/api/channelConnectionsApi.ts b/app/src/services/api/channelConnectionsApi.ts
index 7781e71aa..7fb38ff14 100644
--- a/app/src/services/api/channelConnectionsApi.ts
+++ b/app/src/services/api/channelConnectionsApi.ts
@@ -1,13 +1,9 @@
-import { isTauri as coreIsTauri } from '@tauri-apps/api/core';
-
-import type { ApiResponse } from '../../types/api';
import type {
ChannelAuthMode,
ChannelConnection,
ChannelConnectionsByMode,
ChannelType,
} from '../../types/channels';
-import { apiClient } from '../apiClient';
import { callCoreRpc } from '../coreRpcClient';
interface ConnectChannelPayload {
@@ -63,133 +59,106 @@ function makeConnectedChannelConnection(
export const channelConnectionsApi = {
listConnections: async (): Promise => {
- if (coreIsTauri()) {
- const [profilesResponse, integrationsResponse] = await Promise.all([
- callCoreRpc<{ result: AuthProfileSummary[] }>({
- method: 'openhuman.auth.list_provider_credentials',
- params: {},
- }),
- callCoreRpc<{ result: OAuthIntegrationSummary[] }>({
- method: 'openhuman.auth.oauth_list_integrations',
- params: {},
- }),
- ]);
+ const [profilesResponse, integrationsResponse] = await Promise.all([
+ callCoreRpc<{ result: AuthProfileSummary[] }>({
+ method: 'openhuman.auth.list_provider_credentials',
+ params: {},
+ }),
+ callCoreRpc<{ result: OAuthIntegrationSummary[] }>({
+ method: 'openhuman.auth.oauth_list_integrations',
+ params: {},
+ }),
+ ]);
- const output = emptyChannelConnectionsResponse();
- const profiles = profilesResponse.result ?? [];
- const integrations = integrationsResponse.result ?? [];
+ const output = emptyChannelConnectionsResponse();
+ const profiles = profilesResponse.result ?? [];
+ const integrations = integrationsResponse.result ?? [];
- for (const profile of profiles) {
- if (!isSupportedChannel(profile.provider)) continue;
- const authMode = profile.profileName as ChannelAuthMode;
- if (!SUPPORTED_AUTH_MODES.includes(authMode) || authMode === 'oauth') continue;
- output.connections[profile.provider][authMode] = makeConnectedChannelConnection(
- profile.provider,
- authMode
- );
- }
-
- for (const integration of integrations) {
- if (!isSupportedChannel(integration.provider)) continue;
- output.connections[integration.provider].oauth = makeConnectedChannelConnection(
- integration.provider,
- 'oauth'
- );
- }
-
- return output;
+ for (const profile of profiles) {
+ if (!isSupportedChannel(profile.provider)) continue;
+ const authMode = profile.profileName as ChannelAuthMode;
+ if (!SUPPORTED_AUTH_MODES.includes(authMode) || authMode === 'oauth') continue;
+ output.connections[profile.provider][authMode] = makeConnectedChannelConnection(
+ profile.provider,
+ authMode
+ );
}
- const response =
- await apiClient.get>('/channels/connections');
- return response.data;
+ for (const integration of integrations) {
+ if (!isSupportedChannel(integration.provider)) continue;
+ output.connections[integration.provider].oauth = makeConnectedChannelConnection(
+ integration.provider,
+ 'oauth'
+ );
+ }
+
+ return output;
},
connectChannel: async (
channel: ChannelType,
payload: ConnectChannelPayload
): Promise => {
- if (coreIsTauri()) {
- if (payload.authMode === 'oauth') {
- const response = await callCoreRpc<{ result: { oauthUrl: string } }>({
- method: 'openhuman.auth.oauth_connect',
- params: { provider: channel, skillId: channel },
- });
- return {
- oauthUrl: response.result.oauthUrl,
- connection: makeConnectedChannelConnection(channel, payload.authMode),
- };
- }
-
- const token =
- payload.authMode === 'bot_token'
- ? payload.credentials?.botToken?.trim()
- : payload.authMode === 'api_key'
- ? payload.credentials?.apiKey?.trim()
- : undefined;
- await callCoreRpc({
- method: 'openhuman.auth.store_provider_credentials',
- params: {
- provider: channel,
- profile: payload.authMode,
- token,
- fields: { authMode: payload.authMode },
- setActive: true,
- },
+ if (payload.authMode === 'oauth') {
+ const response = await callCoreRpc<{ result: { oauthUrl: string } }>({
+ method: 'openhuman.auth.oauth_connect',
+ params: { provider: channel, skillId: channel },
});
-
- return { connection: makeConnectedChannelConnection(channel, payload.authMode) };
+ return {
+ oauthUrl: response.result.oauthUrl,
+ connection: makeConnectedChannelConnection(channel, payload.authMode),
+ };
}
- const response = await apiClient.post>(
- `/channels/${encodeURIComponent(channel)}/connect`,
- payload
- );
- return response.data;
+ const token =
+ payload.authMode === 'bot_token'
+ ? payload.credentials?.botToken?.trim()
+ : payload.authMode === 'api_key'
+ ? payload.credentials?.apiKey?.trim()
+ : undefined;
+
+ await callCoreRpc({
+ method: 'openhuman.auth.store_provider_credentials',
+ params: {
+ provider: channel,
+ profile: payload.authMode,
+ token,
+ fields: { authMode: payload.authMode },
+ setActive: true,
+ },
+ });
+
+ return { connection: makeConnectedChannelConnection(channel, payload.authMode) };
},
disconnectChannel: async (channel: ChannelType, authMode: ChannelAuthMode): Promise => {
- if (coreIsTauri()) {
- if (authMode === 'oauth') {
- const listResponse = await callCoreRpc<{ result: OAuthIntegrationSummary[] }>({
- method: 'openhuman.auth.oauth_list_integrations',
- params: {},
- });
- const integrationIds = (listResponse.result ?? [])
- .filter(item => item.provider === channel)
- .map(item => item.id);
- await Promise.all(
- integrationIds.map(integrationId =>
- callCoreRpc({
- method: 'openhuman.auth.oauth_revoke_integration',
- params: { integrationId },
- })
- )
- );
- return;
- }
-
- await callCoreRpc({
- method: 'openhuman.auth.remove_provider_credentials',
- params: { provider: channel, profile: authMode },
+ if (authMode === 'oauth') {
+ const listResponse = await callCoreRpc<{ result: OAuthIntegrationSummary[] }>({
+ method: 'openhuman.auth.oauth_list_integrations',
+ params: {},
});
+ const integrationIds = (listResponse.result ?? [])
+ .filter(item => item.provider === channel)
+ .map(item => item.id);
+
+ await Promise.all(
+ integrationIds.map(integrationId =>
+ callCoreRpc({
+ method: 'openhuman.auth.oauth_revoke_integration',
+ params: { integrationId },
+ })
+ )
+ );
return;
}
- await apiClient.post>(
- `/channels/${encodeURIComponent(channel)}/disconnect`,
- { authMode }
- );
+ await callCoreRpc({
+ method: 'openhuman.auth.remove_provider_credentials',
+ params: { provider: channel, profile: authMode },
+ });
},
updatePreferences: async (defaultMessagingChannel: ChannelType): Promise => {
- if (coreIsTauri()) {
- void defaultMessagingChannel;
- return;
- }
-
- await apiClient.patch>('/channels/preferences', {
- defaultMessagingChannel,
- });
+ void defaultMessagingChannel;
},
};
diff --git a/app/src/services/apiClient.ts b/app/src/services/apiClient.ts
index d8336e0d4..34df4b87a 100644
--- a/app/src/services/apiClient.ts
+++ b/app/src/services/apiClient.ts
@@ -1,5 +1,5 @@
import type { ApiError } from '../types/api';
-import { BACKEND_URL } from '../utils/config';
+import { getBackendUrl } from './backendUrl';
type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
@@ -29,12 +29,6 @@ export function setStoreForApiClient(getToken: () => string | null) {
* Handles authentication, error handling, and response typing
*/
class ApiClient {
- private baseUrl: string;
-
- constructor(baseUrl: string) {
- this.baseUrl = baseUrl;
- }
-
private getToken(): string | null {
return _getToken ? _getToken() : null;
}
@@ -65,7 +59,8 @@ class ApiClient {
private async request(endpoint: string, options: RequestOptions = {}): Promise {
const { method = 'GET', body, requireAuth = true, timeout = 120_000 } = options;
- const url = `${this.baseUrl}${endpoint}`;
+ const baseUrl = await getBackendUrl();
+ const url = `${baseUrl}${endpoint}`;
const headers = this.buildHeaders({ ...options, requireAuth });
const controller = new AbortController();
@@ -170,6 +165,4 @@ class ApiClient {
}
// Export singleton instance
-export const apiClient = new ApiClient(BACKEND_URL);
-
-console.log('[ApiClient] Backend URL', BACKEND_URL);
+export const apiClient = new ApiClient();
diff --git a/app/src/services/backendUrl.ts b/app/src/services/backendUrl.ts
new file mode 100644
index 000000000..033dd80b4
--- /dev/null
+++ b/app/src/services/backendUrl.ts
@@ -0,0 +1,48 @@
+import { isTauri as coreIsTauri } from '@tauri-apps/api/core';
+
+import { callCoreRpc } from './coreRpcClient';
+
+let resolvedBackendUrl: string | null = null;
+let resolvingBackendUrl: Promise | null = null;
+
+function normalizeBaseUrl(url: string): string {
+ return url.trim().replace(/\/+$/, '');
+}
+
+function webFallbackBackendUrl(): string {
+ if (typeof window !== 'undefined' && window.location?.origin) {
+ return normalizeBaseUrl(window.location.origin);
+ }
+ return 'http://127.0.0.1:3000';
+}
+
+export async function getBackendUrl(): Promise {
+ if (resolvedBackendUrl) {
+ return resolvedBackendUrl;
+ }
+
+ if (!coreIsTauri()) {
+ resolvedBackendUrl = webFallbackBackendUrl();
+ return resolvedBackendUrl;
+ }
+
+ if (resolvingBackendUrl) {
+ return resolvingBackendUrl;
+ }
+
+ resolvingBackendUrl = (async () => {
+ const response = await callCoreRpc<{ api_url?: string; apiUrl?: string }>({
+ method: 'openhuman.config_resolve_api_url',
+ });
+ const resolved = String(response.api_url ?? response.apiUrl ?? '').trim();
+ if (!resolved) {
+ throw new Error('Core returned an empty backend URL');
+ }
+ resolvedBackendUrl = normalizeBaseUrl(resolved);
+ return resolvedBackendUrl;
+ })().finally(() => {
+ resolvingBackendUrl = null;
+ });
+
+ return resolvingBackendUrl;
+}
diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts
index da47d0059..fc9cdbb16 100644
--- a/app/src/services/coreRpcClient.ts
+++ b/app/src/services/coreRpcClient.ts
@@ -1,6 +1,7 @@
-import { invoke } from '@tauri-apps/api/core';
+import { isTauri as coreIsTauri, invoke } from '@tauri-apps/api/core';
import { dispatchLocalAiMethod } from '../lib/ai/localCoreAiMemory';
+import { CORE_RPC_URL } from '../utils/config';
interface CoreRpcRelayRequest {
method: string;
@@ -8,6 +9,44 @@ interface CoreRpcRelayRequest {
serviceManaged?: boolean;
}
+interface JsonRpcRequestBody {
+ jsonrpc: '2.0';
+ id: number;
+ method: string;
+ params: unknown;
+}
+
+interface JsonRpcError {
+ code: number;
+ message: string;
+ data?: unknown;
+}
+
+interface JsonRpcResponse {
+ jsonrpc?: string;
+ id?: number | string | null;
+ result?: T;
+ error?: JsonRpcError;
+}
+
+const LEGACY_METHOD_ALIASES: Record = {
+ 'openhuman.get_config': 'openhuman.config_get',
+ 'openhuman.get_runtime_flags': 'openhuman.config_get_runtime_flags',
+ 'openhuman.set_browser_allow_all': 'openhuman.config_set_browser_allow_all',
+ 'openhuman.update_browser_settings': 'openhuman.config_update_browser_settings',
+ 'openhuman.update_memory_settings': 'openhuman.config_update_memory_settings',
+ 'openhuman.update_model_settings': 'openhuman.config_update_model_settings',
+ 'openhuman.update_runtime_settings': 'openhuman.config_update_runtime_settings',
+ 'openhuman.update_screen_intelligence_settings':
+ 'openhuman.config_update_screen_intelligence_settings',
+ 'openhuman.update_tunnel_settings': 'openhuman.config_update_tunnel_settings',
+ 'openhuman.workspace_onboarding_flag_exists': 'openhuman.config_workspace_onboarding_flag_exists',
+};
+
+let nextJsonRpcId = 1;
+let resolvedCoreRpcUrl: string | null = null;
+let resolvingCoreRpcUrl: Promise | null = null;
+
function coreRpcErrorMessage(err: unknown): string {
if (err instanceof Error && err.message) {
return err.message;
@@ -28,18 +67,95 @@ function coreRpcErrorMessage(err: unknown): string {
return 'Unknown core RPC error';
}
+function normalizeLegacyMethod(method: string): string {
+ if (method in LEGACY_METHOD_ALIASES) {
+ return LEGACY_METHOD_ALIASES[method];
+ }
+
+ if (method.startsWith('openhuman.auth.')) {
+ return `openhuman.auth_${method.slice('openhuman.auth.'.length).split('.').join('_')}`;
+ }
+
+ if (method.startsWith('openhuman.accessibility_')) {
+ return method.replace('openhuman.accessibility_', 'openhuman.screen_intelligence_');
+ }
+
+ return method;
+}
+
+async function getCoreRpcUrl(): Promise {
+ if (resolvedCoreRpcUrl) {
+ return resolvedCoreRpcUrl;
+ }
+
+ if (!coreIsTauri()) {
+ resolvedCoreRpcUrl = CORE_RPC_URL;
+ return CORE_RPC_URL;
+ }
+
+ if (resolvingCoreRpcUrl) {
+ return resolvingCoreRpcUrl;
+ }
+
+ const resolvePromise: Promise = (async () => {
+ try {
+ const url = await invoke('core_rpc_url');
+ const trimmed = String(url || '').trim();
+ resolvedCoreRpcUrl = trimmed || CORE_RPC_URL;
+ return resolvedCoreRpcUrl || CORE_RPC_URL;
+ } catch {
+ resolvedCoreRpcUrl = CORE_RPC_URL;
+ return CORE_RPC_URL;
+ } finally {
+ resolvingCoreRpcUrl = null;
+ }
+ })();
+ resolvingCoreRpcUrl = resolvePromise;
+
+ return resolvePromise;
+}
+
export async function callCoreRpc({
method,
params,
- serviceManaged = false,
+ serviceManaged = false, // kept for compatibility; direct frontend RPC does not use relay-level routing.
}: CoreRpcRelayRequest): Promise {
+ void serviceManaged;
+
if (method.startsWith('ai.')) {
return dispatchLocalAiMethod(method, (params ?? {}) as Record) as T;
}
+
+ const normalizedMethod = normalizeLegacyMethod(method);
+ const payload: JsonRpcRequestBody = {
+ jsonrpc: '2.0',
+ id: nextJsonRpcId++,
+ method: normalizedMethod,
+ params: params ?? {},
+ };
+
try {
- return await invoke('core_rpc_relay', {
- request: { method, params: params ?? {}, serviceManaged },
+ const rpcUrl = await getCoreRpcUrl();
+ const response = await fetch(rpcUrl, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
});
+
+ if (!response.ok) {
+ const text = await response.text();
+ throw new Error(`Core RPC HTTP ${response.status}: ${text || response.statusText}`);
+ }
+
+ const json = (await response.json()) as JsonRpcResponse;
+ if (json.error) {
+ throw new Error(json.error.message || 'Core RPC returned an error');
+ }
+ if (!Object.prototype.hasOwnProperty.call(json, 'result')) {
+ throw new Error('Core RPC response missing result');
+ }
+
+ return json.result as T;
} catch (err) {
throw new Error(coreRpcErrorMessage(err));
}
diff --git a/app/src/services/socketService.ts b/app/src/services/socketService.ts
index edb47c629..adbfdc5da 100644
--- a/app/src/services/socketService.ts
+++ b/app/src/services/socketService.ts
@@ -4,11 +4,12 @@ import { io, Socket } from 'socket.io-client';
import { MCPTool, MCPToolCall, SocketIOMCPTransportImpl } from '../lib/mcp';
import { skillManager, syncToolsToBackend } from '../lib/skills';
+import { getBackendUrl } from '../services/backendUrl';
import { store } from '../store';
import { upsertChannelConnection } from '../store/channelConnectionsSlice';
import { resetForUser, setSocketIdForUser, setStatusForUser } from '../store/socketSlice';
import type { ChannelAuthMode, ChannelConnectionStatus, ChannelType } from '../types/channels';
-import { BACKEND_URL, IS_DEV } from '../utils/config';
+import { IS_DEV } from '../utils/config';
import { createSafeLogData, sanitizeError } from '../utils/sanitize';
// Socket service logger using debug package
@@ -99,6 +100,10 @@ class SocketService {
* handles the connection. The frontend calls `connectRustSocket()` instead.
*/
connect(token: string): void {
+ void this.connectAsync(token);
+ }
+
+ private async connectAsync(token: string): Promise {
if (!token) return;
// In Tauri mode, Rust handles the socket connection.
@@ -126,12 +131,10 @@ class SocketService {
this.token = token;
const uid = getSocketUserId();
-
- socketLog('Connecting', { userId: uid, backendUrl: BACKEND_URL });
-
store.dispatch(setStatusForUser({ userId: uid, status: 'connecting' }));
- const backendUrl = BACKEND_URL;
+ const backendUrl = await getBackendUrl();
+ socketLog('Connecting', { userId: uid, backendUrl });
// Ensure we're not connecting to the wrong URL
if (backendUrl.includes('localhost:1420') || backendUrl.includes(':1420')) {
diff --git a/app/src/store/authSlice.ts b/app/src/store/authSlice.ts
index 2b607d352..7d26d19e4 100644
--- a/app/src/store/authSlice.ts
+++ b/app/src/store/authSlice.ts
@@ -5,6 +5,8 @@ import { clearUser } from './userSlice';
export interface AuthState {
token: string | null;
+ /** True once startup auth/session bootstrap has checked core binary state */
+ isAuthBootstrapComplete: boolean;
/** Onboarding completion per user id */
isOnboardedByUser: Record;
/** Additional onboarding task progress per user id */
@@ -28,6 +30,7 @@ export interface UserOnboardingTasks {
const initialState: AuthState = {
token: null,
+ isAuthBootstrapComplete: false,
isOnboardedByUser: {},
onboardingTasksByUser: {},
hasIncompleteOnboardingByUser: {},
@@ -43,6 +46,9 @@ const authSlice = createSlice({
setToken: (state, action: PayloadAction) => {
state.token = action.payload;
},
+ setAuthBootstrapComplete: (state, action: PayloadAction) => {
+ state.isAuthBootstrapComplete = action.payload;
+ },
_clearToken: state => {
state.token = null;
state.onboardingTasksByUser = {};
@@ -92,6 +98,7 @@ export const clearToken = createAsyncThunk('auth/clearToken', async (_, { dispat
export const {
setToken,
+ setAuthBootstrapComplete,
setOnboardedForUser,
setAnalyticsForUser,
setOnboardingTasksForUser,
diff --git a/app/src/store/index.ts b/app/src/store/index.ts
index 026955fa6..2bfaf5f36 100644
--- a/app/src/store/index.ts
+++ b/app/src/store/index.ts
@@ -14,7 +14,11 @@ import storage from 'redux-persist/lib/storage';
import { setStoreForApiClient } from '../services/apiClient';
import { IS_DEV } from '../utils/config';
-import { storeSession, syncMemoryClientToken } from '../utils/tauriCommands';
+import {
+ logout as clearRustSession,
+ storeSession,
+ syncMemoryClientToken,
+} from '../utils/tauriCommands';
import accessibilityReducer from './accessibilitySlice';
import aiReducer from './aiSlice';
import authReducer, { setOnboardedForUser, setToken } from './authSlice';
@@ -77,34 +81,49 @@ const persistedChannelConnectionsReducer = persistReducer(
* Middleware that syncs the JWT token to the Rust SESSION_SERVICE whenever
* setToken is dispatched or auth state is rehydrated from persist.
*/
-const syncTokenToRust: Middleware = () => next => action => {
- const result = next(action);
+const syncTokenToRust: Middleware = () => {
+ let lastSyncedToken: string | null = null;
+ return next => action => {
+ const result = next(action);
- const syncToken = (token: string) => {
- // Pass a minimal user object — the token is what matters for SESSION_SERVICE
- storeSession(token, { id: '' }).catch(err =>
- console.warn('[syncTokenToRust] Failed to sync token:', err)
- );
- syncMemoryClientToken(token).catch(err =>
- console.warn('[syncTokenToRust] Failed to sync memory token:', err)
- );
- };
+ const syncToken = (token: string) => {
+ if (token === lastSyncedToken) return;
+ lastSyncedToken = token;
- // Sync on explicit setToken
- if (setToken.match(action) && action.payload) {
- syncToken(action.payload);
- }
+ // Pass a minimal user object — the token is what matters for SESSION_SERVICE
+ storeSession(token, { id: '' }).catch(err =>
+ console.warn('[syncTokenToRust] Failed to sync token:', err)
+ );
+ syncMemoryClientToken(token).catch(err =>
+ console.warn('[syncTokenToRust] Failed to sync memory token:', err)
+ );
+ };
- // Sync on rehydration (app restart — persist loads token from localStorage)
- const a = action as { type?: string; key?: string; payload?: { token?: string } };
- if (a.type === REHYDRATE && a.key === 'auth') {
- const token = a.payload?.token;
- if (token) {
- syncToken(token);
+ // Sync on explicit setToken
+ if (setToken.match(action) && action.payload) {
+ syncToken(action.payload);
}
- }
- return result;
+ if ((action as { type?: string }).type === 'auth/_clearToken') {
+ lastSyncedToken = null;
+ clearRustSession().catch(err =>
+ console.warn('[syncTokenToRust] Failed to clear core session:', err)
+ );
+ }
+
+ // Sync on rehydration (app restart — persist loads token from localStorage)
+ const a = action as { type?: string; key?: string; payload?: { token?: string } };
+ if (a.type === REHYDRATE && a.key === 'auth') {
+ const token = a.payload?.token;
+ if (token) {
+ syncToken(token);
+ } else {
+ lastSyncedToken = null;
+ }
+ }
+
+ return result;
+ };
};
export const store = configureStore({
diff --git a/app/src/test/handlers.ts b/app/src/test/handlers.ts
deleted file mode 100644
index 6c6447fe0..000000000
--- a/app/src/test/handlers.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-/**
- * MSW request handlers for API mocking in tests.
- *
- * These provide deterministic API responses for testing components
- * that depend on backend data.
- */
-import { http, HttpResponse } from 'msw';
-
-const BACKEND_URL = 'http://localhost:5005';
-
-export const handlers = [
- // GET /telegram/me - Current user profile
- http.get(`${BACKEND_URL}/telegram/me`, () => {
- return HttpResponse.json({
- success: true,
- data: {
- _id: 'user-123',
- telegramId: 12345678,
- hasAccess: true,
- magicWord: 'alpha',
- firstName: 'Test',
- lastName: 'User',
- username: 'testuser',
- role: 'user' as const,
- activeTeamId: 'team-1',
- referral: {},
- subscription: { hasActiveSubscription: false, plan: 'FREE' as const },
- settings: {
- dailySummariesEnabled: false,
- dailySummaryChatIds: [],
- autoCompleteEnabled: false,
- autoCompleteVisibility: 'always' as const,
- autoCompleteWhitelistChatIds: [],
- autoCompleteBlacklistChatIds: [],
- },
- usage: {
- cycleBudgetUsd: 10,
- spentThisCycleUsd: 0,
- spentTodayUsd: 0,
- cycleStartDate: new Date().toISOString(),
- },
- autoDeleteTelegramMessagesAfterDays: 30,
- autoDeleteThreadsAfterDays: 30,
- },
- });
- }),
-
- // GET /billing/current-plan
- http.get(`${BACKEND_URL}/billing/current-plan`, () => {
- return HttpResponse.json({
- success: true,
- data: { plan: 'FREE', hasActiveSubscription: false, planExpiry: null, subscription: null },
- });
- }),
-
- // GET /teams
- http.get(`${BACKEND_URL}/teams`, () => {
- return HttpResponse.json({ success: true, data: [] });
- }),
-
- // POST /auth/desktop-exchange
- http.post(`${BACKEND_URL}/auth/desktop-exchange`, () => {
- return HttpResponse.json({
- sessionToken: 'mock-session-token',
- user: { id: 'user-123', firstName: 'Test', username: 'testuser' },
- });
- }),
-];
diff --git a/app/src/test/server.ts b/app/src/test/server.ts
deleted file mode 100644
index 1e9023783..000000000
--- a/app/src/test/server.ts
+++ /dev/null
@@ -1,8 +0,0 @@
-/**
- * MSW server instance for use in Vitest tests.
- */
-import { setupServer } from 'msw/node';
-
-import { handlers } from './handlers';
-
-export const server = setupServer(...handlers);
diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts
index 7ab99ffcb..0d9db568d 100644
--- a/app/src/test/setup.ts
+++ b/app/src/test/setup.ts
@@ -2,7 +2,7 @@
* Global test setup for Vitest.
*
* - Extends expect with @testing-library/jest-dom matchers
- * - Sets up MSW server for API mocking
+ * - Starts local HTTP mock backend for API mocking
* - Silences console output during tests (unless DEBUG_TESTS=1)
* - Mocks Tauri-specific modules that aren't available in test env
* - Resets rate limiter module-level state between tests
@@ -12,10 +12,15 @@ import { cleanup } from '@testing-library/react';
import type React from 'react';
import { afterAll, afterEach, beforeAll, beforeEach, vi } from 'vitest';
-import { server } from './server';
+// @ts-ignore - test-only JS module outside app/src
+import {
+ clearRequestLog,
+ resetMockBehavior,
+ startMockServer,
+ stopMockServer,
+} from '../../../scripts/mock-api-core.mjs';
// Mock import.meta.env defaults for tests
-vi.stubEnv('VITE_BACKEND_URL', 'http://localhost:5005');
vi.stubEnv('DEV', true);
vi.stubEnv('MODE', 'test');
@@ -32,21 +37,34 @@ vi.mock('@tauri-apps/plugin-os', () => ({ platform: vi.fn().mockResolvedValue('m
// Mock tauriCommands to prevent Tauri API calls in tests
vi.mock('../utils/tauriCommands', () => ({
- isTauri: () => false,
+ isTauri: vi.fn(() => false),
storeSession: vi.fn().mockResolvedValue(undefined),
+ getSessionToken: vi.fn().mockResolvedValue(null),
getAuthState: vi.fn().mockResolvedValue({ is_authenticated: false }),
+ logout: vi.fn().mockResolvedValue(undefined),
+ syncMemoryClientToken: vi.fn().mockResolvedValue(undefined),
+ openhumanServiceInstall: vi.fn().mockResolvedValue({ result: { state: 'Running' }, logs: [] }),
+ openhumanServiceStart: vi.fn().mockResolvedValue({ result: { state: 'Running' }, logs: [] }),
+ openhumanServiceStop: vi.fn().mockResolvedValue({ result: { state: 'Stopped' }, logs: [] }),
+ openhumanServiceStatus: vi.fn().mockResolvedValue({ result: { state: 'Running' }, logs: [] }),
+ openhumanServiceUninstall: vi
+ .fn()
+ .mockResolvedValue({ result: { state: 'NotInstalled' }, logs: [] }),
+ openhumanAgentServerStatus: vi.fn().mockResolvedValue({ result: { running: true }, logs: [] }),
exchangeToken: vi.fn(),
invoke: vi.fn(),
}));
// Mock the config module
vi.mock('../utils/config', () => ({
- BACKEND_URL: 'http://localhost:5005',
TELEGRAM_BOT_USERNAME: 'test_bot',
TELEGRAM_BOT_ID: '12345',
IS_DEV: true,
SKILLS_GITHUB_REPO: 'test/skills',
- DEV_AUTO_LOAD_SKILL: undefined,
+}));
+
+vi.mock('../services/backendUrl', () => ({
+ getBackendUrl: vi.fn().mockResolvedValue('http://localhost:5005'),
}));
// Mock redux-persist to avoid CJS/ESM issues in vitest
@@ -109,16 +127,21 @@ if (!process.env.DEBUG_TESTS) {
vi.spyOn(console, 'error').mockImplementation(() => {});
}
-// MSW server lifecycle
-beforeAll(() => server.listen({ onUnhandledRequest: 'bypass' }));
+// Shared mock API server lifecycle for unit tests (default)
+beforeAll(async () => {
+ await startMockServer(5005);
+});
afterEach(() => {
- server.resetHandlers();
+ clearRequestLog();
cleanup();
});
-afterAll(() => server.close());
+afterAll(async () => {
+ await stopMockServer();
+});
// Reset rate limiter per-request counter before each test.
beforeEach(async () => {
+ resetMockBehavior();
try {
const { resetRequestCallCount } = await import('../lib/mcp/rateLimiter');
if (typeof resetRequestCallCount === 'function') {
diff --git a/app/src/types/modules.d.ts b/app/src/types/modules.d.ts
new file mode 100644
index 000000000..1c043c3b6
--- /dev/null
+++ b/app/src/types/modules.d.ts
@@ -0,0 +1 @@
+declare module '*.mjs';
diff --git a/app/src/types/oauth.ts b/app/src/types/oauth.ts
index 93c0b5f21..84ac484e8 100644
--- a/app/src/types/oauth.ts
+++ b/app/src/types/oauth.ts
@@ -11,7 +11,6 @@ export interface OAuthProviderConfig {
color: string;
hoverColor: string;
textColor: string;
- loginUrl: string;
}
export interface OAuthLoginResponse {
diff --git a/app/src/utils/__tests__/authFlow.e2e.test.tsx b/app/src/utils/__tests__/authFlow.e2e.test.tsx
new file mode 100644
index 000000000..bccd84798
--- /dev/null
+++ b/app/src/utils/__tests__/authFlow.e2e.test.tsx
@@ -0,0 +1,113 @@
+import { isTauri as tauriRuntimeIsTauri } from '@tauri-apps/api/core';
+import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link';
+import { render, screen, waitFor } from '@testing-library/react';
+import { Provider } from 'react-redux';
+import { MemoryRouter, Route, Routes } from 'react-router-dom';
+import { beforeEach, describe, expect, it, type Mock, vi } from 'vitest';
+
+import * as tauriCommands from '../tauriCommands';
+// @ts-ignore - test-only JS module outside app/src
+import { clearRequestLog, getRequestLog } from '../../../../scripts/mock-api-core.mjs';
+import PublicRoute from '../../components/PublicRoute';
+import UserProvider from '../../providers/UserProvider';
+import { callCoreRpc } from '../../services/coreRpcClient';
+import { store } from '../../store';
+import { clearToken, setAuthBootstrapComplete } from '../../store/authSlice';
+import { setupDesktopDeepLinkListener } from '../desktopDeepLinkListener';
+
+vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
+
+describe('Auth flow e2e (binary + OAuth callback)', () => {
+ const mockIsTauriRuntime = tauriRuntimeIsTauri as Mock;
+ const mockGetCurrent = getCurrent as Mock;
+ const mockOnOpenUrl = onOpenUrl as Mock;
+ const mockCallCoreRpc = callCoreRpc as Mock;
+
+ const mockIsTauriCommand = tauriCommands.isTauri as Mock;
+ const mockGetAuthState = tauriCommands.getAuthState as Mock;
+ const mockGetSessionToken = tauriCommands.getSessionToken as Mock;
+ const mockStoreSession = tauriCommands.storeSession as Mock;
+ const mockSyncMemoryClientToken = tauriCommands.syncMemoryClientToken as Mock;
+ const mockLogout = tauriCommands.logout as Mock;
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ clearRequestLog();
+ await store.dispatch(clearToken());
+ store.dispatch(setAuthBootstrapComplete(false));
+ window.location.hash = '/';
+
+ mockIsTauriRuntime.mockReturnValue(false);
+ mockGetCurrent.mockResolvedValue(null);
+ mockOnOpenUrl.mockResolvedValue(() => {});
+
+ mockIsTauriCommand.mockReturnValue(false);
+ mockGetAuthState.mockResolvedValue({ is_authenticated: false, user: null });
+ mockGetSessionToken.mockResolvedValue(null);
+ mockStoreSession.mockResolvedValue(undefined);
+ mockSyncMemoryClientToken.mockResolvedValue(undefined);
+ mockLogout.mockResolvedValue(undefined);
+ });
+
+ it('bootstraps token from core auth state and routes public entry to /home', async () => {
+ mockIsTauriCommand.mockReturnValue(true);
+ mockGetAuthState.mockResolvedValue({ is_authenticated: true, user: { _id: 'user-123' } });
+ mockGetSessionToken.mockResolvedValue('jwt-from-core');
+
+ render(
+
+
+
+
+
+ Welcome
+
+ }
+ />
+ Home} />
+
+
+
+
+ );
+
+ await waitFor(() => expect(screen.getByText('Home')).toBeInTheDocument());
+ expect(store.getState().auth.token).toBe('jwt-from-core');
+ expect(mockGetAuthState).toHaveBeenCalledTimes(1);
+ expect(mockGetSessionToken).toHaveBeenCalledTimes(1);
+ await waitFor(() => expect(mockStoreSession).toHaveBeenCalledWith('jwt-from-core', { id: '' }));
+
+ await waitFor(() => {
+ const requests = getRequestLog() as Array<{ method: string; url: string }>;
+ expect(requests.some(req => req.method === 'GET' && req.url.startsWith('/telegram/me'))).toBe(
+ true
+ );
+ });
+ });
+
+ it('consumes OAuth login token from deep link and updates auth token + redirect', async () => {
+ mockIsTauriRuntime.mockReturnValue(true);
+ mockCallCoreRpc.mockResolvedValue({ result: { jwtToken: 'jwt-from-login-token' }, logs: [] });
+
+ await setupDesktopDeepLinkListener();
+ expect(mockOnOpenUrl).toHaveBeenCalledTimes(1);
+
+ const openUrlHandler = mockOnOpenUrl.mock.calls[0][0] as (urls: string[]) => void;
+ openUrlHandler(['openhuman://auth?token=oauth-login-token-123']);
+
+ await waitFor(() =>
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({
+ method: 'openhuman.auth.consume_login_token',
+ params: { loginToken: 'oauth-login-token-123' },
+ })
+ );
+ await waitFor(() => expect(store.getState().auth.token).toBe('jwt-from-login-token'));
+ expect(window.location.hash).toBe('#/home');
+ await waitFor(() =>
+ expect(mockStoreSession).toHaveBeenCalledWith('jwt-from-login-token', { id: '' })
+ );
+ });
+});
diff --git a/app/src/utils/__tests__/tauriCoreBridge.e2e.test.ts b/app/src/utils/__tests__/tauriCoreBridge.e2e.test.ts
index 89a0d19e6..7d5442632 100644
--- a/app/src/utils/__tests__/tauriCoreBridge.e2e.test.ts
+++ b/app/src/utils/__tests__/tauriCoreBridge.e2e.test.ts
@@ -1,13 +1,15 @@
-import { invoke, isTauri } from '@tauri-apps/api/core';
+import { isTauri } from '@tauri-apps/api/core';
import { beforeEach, describe, expect, type Mock, test, vi } from 'vitest';
+import { callCoreRpc } from '../../services/coreRpcClient';
import type { ServiceState } from '../tauriCommands';
vi.mock('@tauri-apps/api/core', () => ({ invoke: vi.fn(), isTauri: vi.fn() }));
+vi.mock('../../services/coreRpcClient', () => ({ callCoreRpc: vi.fn() }));
-describe('Web → Tauri → Core bridge', () => {
- const mockInvoke = invoke as Mock;
+describe('Web → frontend JSON-RPC → Core bridge', () => {
const mockIsTauri = isTauri as Mock;
+ const mockCallCoreRpc = callCoreRpc as Mock;
let openhumanServiceStatus: typeof import('../tauriCommands').openhumanServiceStatus;
let openhumanAgentServerStatus: typeof import('../tauriCommands').openhumanAgentServerStatus;
@@ -19,34 +21,30 @@ describe('Web → Tauri → Core bridge', () => {
openhumanAgentServerStatus = actual.openhumanAgentServerStatus;
});
- test('routes service status request through Tauri command and returns core payload', async () => {
+ test('routes service status via JSON-RPC client and returns core payload', async () => {
const expectedState: ServiceState = 'Running';
- const tauriResponse = { result: { state: expectedState }, logs: ['service status fetched'] };
+ const rpcResponse = { result: { state: expectedState }, logs: ['service status fetched'] };
- mockInvoke.mockResolvedValueOnce(tauriResponse);
+ mockCallCoreRpc.mockResolvedValueOnce(rpcResponse);
const response = await openhumanServiceStatus();
- expect(mockInvoke).toHaveBeenCalledWith('core_rpc_relay', {
- request: { method: 'openhuman.service_status', params: {}, serviceManaged: false },
- });
- expect(response).toEqual(tauriResponse);
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.service_status' });
+ expect(response).toEqual(rpcResponse);
expect(response.result.state).toBe(expectedState);
});
- test('routes agent server status through Tauri command and returns core_rpc status', async () => {
- const tauriResponse = {
+ test('routes agent server status via JSON-RPC client', async () => {
+ const rpcResponse = {
result: { running: true, url: 'http://127.0.0.1:8421/rpc' },
logs: ['agent server status checked'],
};
- mockInvoke.mockResolvedValueOnce(tauriResponse);
+ mockCallCoreRpc.mockResolvedValueOnce(rpcResponse);
const response = await openhumanAgentServerStatus();
- expect(mockInvoke).toHaveBeenCalledWith('core_rpc_relay', {
- request: { method: 'openhuman.agent_server_status', params: {}, serviceManaged: false },
- });
+ expect(mockCallCoreRpc).toHaveBeenCalledWith({ method: 'openhuman.agent_server_status' });
expect(response.result.running).toBe(true);
expect(response.result.url).toContain('127.0.0.1');
});
@@ -55,6 +53,6 @@ describe('Web → Tauri → Core bridge', () => {
mockIsTauri.mockReturnValue(false);
await expect(openhumanServiceStatus()).rejects.toThrow('Not running in Tauri');
- expect(mockInvoke).not.toHaveBeenCalled();
+ expect(mockCallCoreRpc).not.toHaveBeenCalled();
});
});
diff --git a/app/src/utils/config.ts b/app/src/utils/config.ts
index 7db9253f8..a80ae9780 100644
--- a/app/src/utils/config.ts
+++ b/app/src/utils/config.ts
@@ -1,8 +1,9 @@
-export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.tinyhumans.ai';
+export const CORE_RPC_URL =
+ import.meta.env.OPENHUMAN_CORE_RPC_URL ||
+ import.meta.env.VITE_OPENHUMAN_CORE_RPC_URL ||
+ 'http://127.0.0.1:7788/rpc';
export const IS_DEV = import.meta.env.DEV;
export const SKILLS_GITHUB_REPO =
import.meta.env.VITE_SKILLS_GITHUB_REPO || 'tinyhumansai/openhuman-skills';
-
-export const DEV_AUTO_LOAD_SKILL = import.meta.env.VITE_DEV_AUTO_LOAD_SKILL || undefined;
diff --git a/app/src/utils/desktopDeepLinkListener.ts b/app/src/utils/desktopDeepLinkListener.ts
index e3fa0f3bf..6a59d44bd 100644
--- a/app/src/utils/desktopDeepLinkListener.ts
+++ b/app/src/utils/desktopDeepLinkListener.ts
@@ -56,11 +56,11 @@ const handleAuthDeepLink = async (parsed: URL) => {
if (key === 'auth') {
store.dispatch(setToken(token));
- window.location.hash = '/onboarding';
+ window.location.hash = '/home';
} else {
const jwtToken = await consumeLoginToken(token);
store.dispatch(setToken(jwtToken));
- window.location.hash = '/onboarding';
+ window.location.hash = '/home';
}
};
diff --git a/app/src/utils/tauriCommands.ts b/app/src/utils/tauriCommands.ts
index 83213ef4f..28e8eb530 100644
--- a/app/src/utils/tauriCommands.ts
+++ b/app/src/utils/tauriCommands.ts
@@ -1107,9 +1107,6 @@ export async function openhumanAgentChat(
}
export async function openhumanLocalAiStatus(): Promise