diff --git a/app/src/components/oauth/__tests__/oauthAuthReadiness.test.ts b/app/src/components/oauth/__tests__/oauthAuthReadiness.test.ts index 56bbcbeb6..53ac95982 100644 --- a/app/src/components/oauth/__tests__/oauthAuthReadiness.test.ts +++ b/app/src/components/oauth/__tests__/oauthAuthReadiness.test.ts @@ -4,7 +4,7 @@ import { getCoreStateSnapshot } from '../../../lib/coreState/store'; import { bootCheckTransport } from '../../../services/bootCheckService'; import { testCoreRpcConnection } from '../../../services/coreRpcClient'; import { isTauri } from '../../../services/webviewAccountService'; -import { getStoredCoreMode } from '../../../utils/configPersistence'; +import { getStoredCoreMode, getStoredCoreToken } from '../../../utils/configPersistence'; import { oauthAuthReadinessUserMessage, prepareOAuthLoginLaunch, @@ -22,7 +22,10 @@ vi.mock('../../../services/bootCheckService', () => ({ bootCheckTransport: { invokeCmd: vi.fn().mockResolvedValue(undefined), callRpc: vi.fn() }, })); -vi.mock('../../../utils/configPersistence', () => ({ getStoredCoreMode: vi.fn() })); +vi.mock('../../../utils/configPersistence', () => ({ + getStoredCoreMode: vi.fn(), + getStoredCoreToken: vi.fn().mockReturnValue(null), +})); vi.mock('../../../services/webviewAccountService', () => ({ isTauri: vi.fn().mockReturnValue(true), @@ -135,4 +138,30 @@ describe('oauthAuthReadiness', () => { vi.useRealTimers(); } }); + + it('returns cloud-specific message for core_unreachable when mode is cloud', () => { + vi.mocked(getStoredCoreMode).mockReturnValue('cloud'); + const msg = oauthAuthReadinessUserMessage('core_unreachable'); + expect(msg).toMatch(/remote.*cloud/i); + expect(msg).toMatch(/RPC URL/i); + }); + + it('returns local-specific message for core_unreachable when mode is local', () => { + vi.mocked(getStoredCoreMode).mockReturnValue('local'); + const msg = oauthAuthReadinessUserMessage('core_unreachable'); + expect(msg).toMatch(/local runtime/i); + expect(msg).toMatch(/Quit and reopen/i); + }); + + it('passes cloud token to testCoreRpcConnection when mode is cloud', async () => { + vi.mocked(getStoredCoreMode).mockReturnValue('cloud'); + vi.mocked(getStoredCoreToken).mockReturnValue('cloud-bearer-token'); + + await waitForOAuthAuthReadiness(2_000); + + expect(testCoreRpcConnection).toHaveBeenCalledWith( + 'http://127.0.0.1:7788/rpc', + 'cloud-bearer-token' + ); + }); }); diff --git a/app/src/components/oauth/oauthAuthReadiness.ts b/app/src/components/oauth/oauthAuthReadiness.ts index 3d519c4eb..154295fb0 100644 --- a/app/src/components/oauth/oauthAuthReadiness.ts +++ b/app/src/components/oauth/oauthAuthReadiness.ts @@ -4,7 +4,7 @@ import { getCoreStateSnapshot } from '../../lib/coreState/store'; import { bootCheckTransport } from '../../services/bootCheckService'; import { getCoreRpcUrl, testCoreRpcConnection } from '../../services/coreRpcClient'; import { isTauri } from '../../services/webviewAccountService'; -import { getStoredCoreMode } from '../../utils/configPersistence'; +import { getStoredCoreMode, getStoredCoreToken } from '../../utils/configPersistence'; const logPrefix = '[oauth-auth-readiness]'; const log = debug('oauth:auth-readiness'); @@ -27,7 +27,17 @@ const delay = (ms: number): Promise => async function pingCoreRpc(): Promise { try { const rpcUrl = await getCoreRpcUrl(); - const response = await testCoreRpcConnection(rpcUrl); + // In cloud mode, pass the stored cloud token explicitly to avoid + // getCoreRpcToken() resolving to a stale local-core token. See issue #2377. + const cloudToken = getStoredCoreMode() === 'cloud' ? getStoredCoreToken() : null; + log(`${logPrefix} core.ping probe`, { + rpcUrl, + mode: getStoredCoreMode(), + hasCloudToken: Boolean(cloudToken), + }); + const response = cloudToken + ? await testCoreRpcConnection(rpcUrl, cloudToken) + : await testCoreRpcConnection(rpcUrl); return response.ok; } catch (err) { log(`${logPrefix} core.ping probe failed`, err); @@ -112,11 +122,19 @@ export function oauthAuthReadinessUserMessage(reason: OAuthAuthReadinessFailure) 'Finish choosing how OpenHuman runs (tap Continue on the setup screen), ' + 'then try signing in again.' ); - case 'core_unreachable': + case 'core_unreachable': { + const mode = getStoredCoreMode(); + if (mode === 'cloud') { + return ( + 'OpenHuman could not reach its remote (cloud) runtime. ' + + 'Check your RPC URL and token in Settings, then try signing in again.' + ); + } return ( 'OpenHuman could not reach its local runtime. Quit and reopen the app, ' + 'then try signing in again.' ); + } default: return 'Sign-in is still starting up. Wait a few seconds and try again.'; } diff --git a/app/src/lib/i18n/chunks/de-5.ts b/app/src/lib/i18n/chunks/de-5.ts index 79f041cc1..c8a26af5f 100644 --- a/app/src/lib/i18n/chunks/de-5.ts +++ b/app/src/lib/i18n/chunks/de-5.ts @@ -523,28 +523,6 @@ const de5: TranslationMap = { 'settings.mascot.colorYellow': 'Gelb', 'settings.mascot.libraryUnavailable': 'OpenHuman Bibliothek nicht verfügbar', 'settings.mascot.title': 'OpenHuman', - 'settings.developerMenu.mcpServer.title': 'MCP-Server', - 'settings.developerMenu.mcpServer.desc': - 'Externe MCP-Clients zur Verbindung mit OpenHuman konfigurieren', - 'settings.mcpServer.title': 'MCP-Server', - 'settings.mcpServer.toolsSectionTitle': 'Verfügbare Tools', - 'settings.mcpServer.toolsSectionDesc': - 'Tools, die über den MCP-Stdio-Server bereitgestellt werden, wenn openhuman-core mcp ausgeführt wird', - 'settings.mcpServer.configSectionTitle': 'Client-Konfiguration', - 'settings.mcpServer.configSectionDesc': - 'Wählen Sie Ihren MCP-Client aus, um den passenden Konfigurations-Schnipsel zu erzeugen', - 'settings.mcpServer.copySnippet': 'In Zwischenablage kopieren', - 'settings.mcpServer.copied': 'Kopiert!', - 'settings.mcpServer.openConfigFile': 'Konfigurationsdatei öffnen', - 'settings.mcpServer.binaryPathNotFound': - 'OpenHuman-Binary nicht gefunden. Wenn Sie aus dem Quellcode arbeiten, bauen Sie mit: cargo build --bin openhuman-core', - 'settings.mcpServer.openConfigError': 'Konfigurationsdatei konnte nicht geöffnet werden', - 'settings.mcpServer.clientClaudeDesktop': 'Claude Desktop', - 'settings.mcpServer.clientCursor': 'Cursor', - 'settings.mcpServer.clientCodex': 'Codex', - 'settings.mcpServer.clientZed': 'Zed', - 'settings.mcpServer.configFilePath': 'Konfigurationsdatei', - 'settings.mcpServer.clientSelectorAriaLabel': 'MCP-Client-Auswahl', }; export default de5; diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index 42ca0abca..45944dfe6 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -617,6 +617,23 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) ); const lastReauthAtRef = useRef(0); + const suppressReauthUntilRef = useRef(0); + + // Listen for deep-link auth suppression signals so that an in-flight + // `auth_store_session` call (OAuth deep link) does not race with the + // `core-rpc-auth-expired` handler and clear the session mid-delivery. + // See issue #2377. + useEffect(() => { + const onSuppressReauth = (event: Event) => { + const until = (event as CustomEvent<{ until: number }>).detail?.until ?? 0; + suppressReauthUntilRef.current = until; + log('[CoreState] suppress-reauth updated until=%d', until); + }; + window.addEventListener('core-state:suppress-reauth', onSuppressReauth as EventListener); + return () => { + window.removeEventListener('core-state:suppress-reauth', onSuppressReauth as EventListener); + }; + }, []); const clearSession = useCallback(async () => { logoutGuardUntilRef.current = Date.now() + 5_000; @@ -665,6 +682,14 @@ export default function CoreStateProvider({ children }: { children: ReactNode }) useEffect(() => { const runReauth = (method: string, source: string) => { const now = Date.now(); + if (now < suppressReauthUntilRef.current) { + log( + '[CoreState] auth-expired suppressed during deep-link auth delivery (method=%s source=%s)', + method, + source + ); + return; + } if (now - lastReauthAtRef.current < 10_000) { log('auth-expired debounced (method=%s source=%s)', method, source); return; diff --git a/app/src/providers/__tests__/CoreStateProvider.test.tsx b/app/src/providers/__tests__/CoreStateProvider.test.tsx index 65b53218a..ea4aae915 100644 --- a/app/src/providers/__tests__/CoreStateProvider.test.tsx +++ b/app/src/providers/__tests__/CoreStateProvider.test.tsx @@ -550,6 +550,75 @@ describe('CoreStateProvider — identity-change cache clearing', () => { expect(vi.mocked(tauriCommands.logout)).toHaveBeenCalledTimes(1); }); + it('core-state:suppress-reauth suppresses auth-expired clearSession during deep-link delivery (#2377)', async () => { + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' })); + listTeams.mockResolvedValue([]); + vi.mocked(tauriCommands.logout).mockReset(); + vi.mocked(tauriCommands.logout).mockResolvedValue(undefined as never); + + render( + + + + ); + + await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready')); + + // Arm the suppress window so core-rpc-auth-expired is silenced. + await act(async () => { + window.dispatchEvent( + new CustomEvent('core-state:suppress-reauth', { detail: { until: Date.now() + 30_000 } }) + ); + }); + + // auth-expired during the suppress window must not call logout. + await act(async () => { + window.dispatchEvent( + new CustomEvent('core-rpc-auth-expired', { + detail: { method: 'openhuman.auth_store_session', source: 'rpc' }, + }) + ); + }); + + expect(vi.mocked(tauriCommands.logout)).not.toHaveBeenCalled(); + }); + + it('core-state:suppress-reauth with until=0 re-enables auth-expired handling after deep-link delivery (#2377)', async () => { + fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' })); + listTeams.mockResolvedValue([]); + vi.mocked(tauriCommands.logout).mockReset(); + vi.mocked(tauriCommands.logout).mockResolvedValue(undefined as never); + + render( + + + + ); + + await waitFor(() => expect(screen.getByTestId('ready').textContent).toBe('ready')); + + // Arm then immediately disarm so clearSession is allowed again. + await act(async () => { + window.dispatchEvent( + new CustomEvent('core-state:suppress-reauth', { detail: { until: Date.now() + 30_000 } }) + ); + }); + await act(async () => { + window.dispatchEvent(new CustomEvent('core-state:suppress-reauth', { detail: { until: 0 } })); + }); + + // auth-expired after suppress cleared must call logout. + await act(async () => { + window.dispatchEvent( + new CustomEvent('core-rpc-auth-expired', { + detail: { method: 'openhuman.team_get_usage', source: 'rpc' }, + }) + ); + }); + + await waitFor(() => expect(vi.mocked(tauriCommands.logout)).toHaveBeenCalledTimes(1)); + }); + it('ignores forged session-token-updated events that do not match the core snapshot (#1937)', async () => { fetchSnapshot.mockResolvedValue(makeSnapshot({ userId: 'u1', sessionToken: 'tok1' })); listTeams.mockResolvedValue([]); diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index 647c77cf1..8ceba7a1b 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -467,6 +467,12 @@ export async function callCoreRpc({ try { const [rpcUrl, token] = await Promise.all([getCoreRpcUrl(), getCoreRpcToken()]); coreRpcLog('HTTP request', { id: payload.id, method: payload.method }); + if (normalizedMethod === 'openhuman.auth_store_session') { + coreRpcLog('[rpc] auth_store_session routing', { + rpcUrl, + tokenSource: getStoredCoreToken() ? 'cloud-stored' : 'local-resolved', + }); + } if (coreIsTauri() && !token) { throw new Error('Core RPC token unavailable in Tauri; local RPC auth cannot be satisfied'); } diff --git a/app/src/utils/__tests__/desktopDeepLinkListener.test.ts b/app/src/utils/__tests__/desktopDeepLinkListener.test.ts index f253cb1d2..eb41269c9 100644 --- a/app/src/utils/__tests__/desktopDeepLinkListener.test.ts +++ b/app/src/utils/__tests__/desktopDeepLinkListener.test.ts @@ -2,14 +2,22 @@ import { isTauri } from '@tauri-apps/api/core'; import { getCurrent, onOpenUrl } from '@tauri-apps/plugin-deep-link'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { clearCoreRpcTokenCache, clearCoreRpcUrlCache } from '../../services/coreRpcClient'; import { completeDeepLinkAuthProcessing, getDeepLinkAuthState, subscribeDeepLinkAuthState, } from '../../store/deepLinkAuthState'; +import { getStoredCoreMode } from '../configPersistence'; import { setupDesktopDeepLinkListener } from '../desktopDeepLinkListener'; import { storeSession } from '../tauriCommands'; +vi.mock('../configPersistence', () => ({ getStoredCoreMode: vi.fn() })); +vi.mock('../../services/coreRpcClient', () => ({ + clearCoreRpcUrlCache: vi.fn(), + clearCoreRpcTokenCache: vi.fn(), +})); + const waitForAuthSettled = (): Promise => new Promise(resolve => { if (!getDeepLinkAuthState().isProcessing) { @@ -33,13 +41,6 @@ const waitForOAuthAuthReadiness = vi.hoisted(() => vi.fn().mockResolvedValue({ ready: true as const }) ); -const coreRpcCache = vi.hoisted(() => ({ - clearCoreRpcUrlCache: vi.fn(), - clearCoreRpcTokenCache: vi.fn(), -})); - -vi.mock('../../services/coreRpcClient', () => coreRpcCache); - vi.mock('../oauthAppVersionGate', async importOriginal => { const actual = await importOriginal(); return { @@ -66,8 +67,9 @@ describe('desktopDeepLinkListener', () => { waitForOAuthAuthReadiness.mockResolvedValue({ ready: true }); vi.mocked(storeSession).mockReset(); vi.mocked(storeSession).mockResolvedValue(undefined); - coreRpcCache.clearCoreRpcUrlCache.mockClear(); - coreRpcCache.clearCoreRpcTokenCache.mockClear(); + vi.mocked(getStoredCoreMode).mockReturnValue(null); + vi.mocked(clearCoreRpcUrlCache).mockClear(); + vi.mocked(clearCoreRpcTokenCache).mockClear(); windowControls.show.mockClear(); windowControls.unminimize.mockClear(); windowControls.setFocus.mockClear(); @@ -181,8 +183,6 @@ describe('desktopDeepLinkListener', () => { await waitForAuthSettled(); expect(storeSession).toHaveBeenCalledWith('abc', {}); - expect(coreRpcCache.clearCoreRpcUrlCache).toHaveBeenCalledTimes(1); - expect(coreRpcCache.clearCoreRpcTokenCache).toHaveBeenCalledTimes(1); expect(getDeepLinkAuthState().isProcessing).toBe(false); }); @@ -205,4 +205,47 @@ describe('desktopDeepLinkListener', () => { 'OAuth sign-in failed before OpenHuman received authorization. Check the provider app settings and try again.', }); }); + + it('busts RPC caches before storeSession in cloud mode', async () => { + vi.mocked(getStoredCoreMode).mockReturnValue('cloud'); + vi.mocked(getCurrent).mockResolvedValue(['openhuman://auth?token=abc&key=auth']); + + await setupDesktopDeepLinkListener(); + await waitForAuthSettled(); + + expect(clearCoreRpcUrlCache).toHaveBeenCalledTimes(1); + expect(clearCoreRpcTokenCache).toHaveBeenCalledTimes(1); + expect(storeSession).toHaveBeenCalledWith('abc', {}); + }); + + it('does NOT bust RPC caches before storeSession in local mode', async () => { + vi.mocked(getStoredCoreMode).mockReturnValue('local'); + vi.mocked(getCurrent).mockResolvedValue(['openhuman://auth?token=abc&key=auth']); + + await setupDesktopDeepLinkListener(); + await waitForAuthSettled(); + + expect(clearCoreRpcUrlCache).not.toHaveBeenCalled(); + expect(clearCoreRpcTokenCache).not.toHaveBeenCalled(); + expect(storeSession).toHaveBeenCalledWith('abc', {}); + }); + + it('dispatches suppress-reauth before storeSession and clears it after in cloud mode', async () => { + vi.mocked(getStoredCoreMode).mockReturnValue('cloud'); + vi.mocked(getCurrent).mockResolvedValue(['openhuman://auth?token=abc&key=auth']); + + const suppressEvents: Array<{ until: number }> = []; + window.addEventListener('core-state:suppress-reauth', event => { + suppressEvents.push((event as CustomEvent<{ until: number }>).detail); + }); + + await setupDesktopDeepLinkListener(); + await waitForAuthSettled(); + + // First event: non-zero until (suppress on) + expect(suppressEvents.length).toBeGreaterThanOrEqual(2); + expect(suppressEvents[0].until).toBeGreaterThan(0); + // Last event: until=0 (suppress cleared) + expect(suppressEvents[suppressEvents.length - 1].until).toBe(0); + }); }); diff --git a/app/src/utils/desktopDeepLinkListener.ts b/app/src/utils/desktopDeepLinkListener.ts index 87d2d6c25..ac4e4e196 100644 --- a/app/src/utils/desktopDeepLinkListener.ts +++ b/app/src/utils/desktopDeepLinkListener.ts @@ -10,6 +10,7 @@ import { completeDeepLinkAuthProcessing, failDeepLinkAuthProcessing, } from '../store/deepLinkAuthState'; +import { getStoredCoreMode } from './configPersistence'; import { BILLING_DASHBOARD_URL } from './links'; import { evaluateOAuthAppVersionGate, @@ -77,9 +78,24 @@ const focusMainWindow = async () => { }; const applySessionToken = async (sessionToken: string): Promise => { - clearCoreRpcUrlCache(); - clearCoreRpcTokenCache(); - await storeSession(sessionToken, {}); + // In cloud mode, bust any stale RPC URL/token caches so auth_store_session + // targets the user's configured remote core. See issue #2377. + const currentCoreMode = getStoredCoreMode(); + if (currentCoreMode === 'cloud') { + console.debug('[DeepLink] cloud mode: busting RPC caches before session delivery'); + clearCoreRpcUrlCache(); + clearCoreRpcTokenCache(); + } + + // Signal CoreStateProvider to hold off clearing session during token delivery. + window.dispatchEvent( + new CustomEvent('core-state:suppress-reauth', { detail: { until: Date.now() + 15_000 } }) + ); + try { + await storeSession(sessionToken, {}); + } finally { + window.dispatchEvent(new CustomEvent('core-state:suppress-reauth', { detail: { until: 0 } })); + } patchCoreStateSnapshot({ snapshot: { sessionToken } }); window.dispatchEvent(new CustomEvent(SESSION_TOKEN_UPDATED_EVENT, { detail: { sessionToken } })); };