From a6edc7baee239d8c70371ffd782929fa1dd509a8 Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 5 Jul 2026 23:51:11 +0300 Subject: [PATCH] fix(capacitor): validate mobile connection on app resume Checks the runtime session before restoring a mobile connection Disconnects and resets state when the session is no longer valid Adds tests for reachable, unreachable, and unauthenticated runtimes --- packages/ui/src/apps/MobileApp.tsx | 33 +++++++-- .../ui/src/apps/mobileConnections.test.ts | 71 +++++++++++++++++++ packages/ui/src/apps/mobileConnections.ts | 25 +++++++ 3 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 packages/ui/src/apps/mobileConnections.test.ts diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index 4581b1d9..bc7f9a2c 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -57,7 +57,7 @@ import { MobileFilesSurface } from './MobileFilesSurface'; import { MobileSessionsSheet } from './MobileSessionsSheet'; import { MobileSurfaceShell } from './MobileSurfaceShell'; import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext'; -import { autoConnectLastInstance, isSameConnectionUrl, useMobileConnection } from './mobileConnections'; +import { autoConnectLastInstance, isSameConnectionUrl, useMobileConnection, validateMobileConnectionSession } from './mobileConnections'; import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan'; import { resetAppForRuntimeEndpointChange } from './runtimeEndpointReset'; import { useAppFontEffects } from './useAppFontEffects'; @@ -516,6 +516,12 @@ const mobileInputKeyboardProps = { const NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS = 1_000; +const getRuntimeClientToken = (): string => { + if (typeof window === 'undefined') return ''; + const token = (window as typeof window & { __OPENCHAMBER_CLIENT_TOKEN__?: string }).__OPENCHAMBER_CLIENT_TOKEN__; + return typeof token === 'string' ? token.trim() : ''; +}; + const getProjectLabel = (path: string): string => { const normalized = normalizePath(path); if (!normalized) return ''; @@ -2184,18 +2190,33 @@ export function MobileApp({ apis }: MobileAppProps) { const [autoConnectPhase, setAutoConnectPhase] = React.useState<'pending' | 'attempting' | 'done'>('pending'); const isNativeMobileApp = React.useMemo(() => isCapacitorMobileApp(), []); const lastNativeResumeSyncEventAtRef = React.useRef(0); + const nativeResumeValidationSeqRef = React.useRef(0); const handleNativeResume = React.useCallback(() => { - if (!getRuntimeApiBaseUrl()) return; + const apiBaseUrl = getRuntimeApiBaseUrl(); + if (!apiBaseUrl) return; + const validationSeq = nativeResumeValidationSeqRef.current + 1; + nativeResumeValidationSeqRef.current = validationSeq; + + void validateMobileConnectionSession({ url: apiBaseUrl, clientToken: getRuntimeClientToken() }).then((isValid) => { + if (nativeResumeValidationSeqRef.current !== validationSeq) return; + if (!isValid) { + switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' }); + setConnectionEpoch((value) => value + 1); + return; + } + + void initializeApp(); + void refreshGitHubAuthStatus(apis.github, { force: true }); + if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' }); + if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' }); + }); + const now = Date.now(); if (now - lastNativeResumeSyncEventAtRef.current >= NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS) { lastNativeResumeSyncEventAtRef.current = now; window.dispatchEvent(new Event('openchamber:system-resume')); } - void initializeApp(); - void refreshGitHubAuthStatus(apis.github, { force: true }); - if (providersCount === 0) void loadProviders({ source: 'mobileApp:nativeResume' }); - if (agentsCount === 0) void loadAgents({ source: 'mobileApp:nativeResume' }); }, [agentsCount, apis.github, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus]); useNativeMobileChrome(); diff --git a/packages/ui/src/apps/mobileConnections.test.ts b/packages/ui/src/apps/mobileConnections.test.ts new file mode 100644 index 00000000..0f16f3c5 --- /dev/null +++ b/packages/ui/src/apps/mobileConnections.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, mock, test } from 'bun:test'; + +import { validateMobileConnectionSession } from './mobileConnections'; + +const originalFetch = globalThis.fetch; +const originalWindow = globalThis.window; + +const installTestWindow = () => { + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + setTimeout: globalThis.setTimeout.bind(globalThis), + clearTimeout: globalThis.clearTimeout.bind(globalThis), + location: { protocol: 'https:' }, + }, + }); +}; + +const restoreGlobals = () => { + globalThis.fetch = originalFetch; + Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow }); +}; + +describe('validateMobileConnectionSession', () => { + test('accepts a reachable authenticated runtime', async () => { + const fetchMock = mock(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith('/health')) return Response.json({ ok: true }); + if (url.endsWith('/auth/session')) return Response.json({ authenticated: true, scope: 'client' }); + return new Response(null, { status: 404 }); + }); + try { + installTestWindow(); + globalThis.fetch = fetchMock as typeof fetch; + + const result = await validateMobileConnectionSession({ url: 'https://runtime.example', clientToken: 'token' }); + expect(result).toBe(true); + } finally { + restoreGlobals(); + } + }); + + test('rejects unreachable runtimes', async () => { + try { + installTestWindow(); + globalThis.fetch = mock(async () => new Response(null, { status: 503 })) as typeof fetch; + + const result = await validateMobileConnectionSession({ url: 'https://runtime.example', clientToken: 'token' }); + expect(result).toBe(false); + } finally { + restoreGlobals(); + } + }); + + test('rejects invalid or unauthenticated sessions', async () => { + const fetchMock = mock(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith('/health')) return Response.json({ ok: true }); + return Response.json({ authenticated: false }, { status: 401 }); + }); + try { + installTestWindow(); + globalThis.fetch = fetchMock as typeof fetch; + + const result = await validateMobileConnectionSession({ url: 'https://runtime.example', clientToken: 'expired' }); + expect(result).toBe(false); + } finally { + restoreGlobals(); + } + }); +}); diff --git a/packages/ui/src/apps/mobileConnections.ts b/packages/ui/src/apps/mobileConnections.ts index 35a748af..a2307cf0 100644 --- a/packages/ui/src/apps/mobileConnections.ts +++ b/packages/ui/src/apps/mobileConnections.ts @@ -452,6 +452,31 @@ export const autoConnectLastInstance = async (): Promise => { return true; }; +export const validateMobileConnectionSession = async (input: { + url: string; + clientToken?: string | null; +}): Promise => { + let url = ''; + try { + url = normalizeConnectionUrl(input.url); + } catch { + return false; + } + if (!url) return false; + + const token = input.clientToken?.trim() || undefined; + const headers = token ? { Authorization: `Bearer ${token}` } : undefined; + + const health = await requestWithTimeout(`${url}/health`, { method: 'GET', headers }); + if (!health?.ok) return false; + + const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }); + if (!session || (!session.ok && session.status !== 404)) return false; + + const status = await readSessionStatus(session); + return !(status && status.disabled !== true && status.authenticated === false); +}; + // --------------------------------------------------------------------------- // Shared connection controller // ---------------------------------------------------------------------------