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
This commit is contained in:
@@ -57,7 +57,7 @@ import { MobileFilesSurface } from './MobileFilesSurface';
|
|||||||
import { MobileSessionsSheet } from './MobileSessionsSheet';
|
import { MobileSessionsSheet } from './MobileSessionsSheet';
|
||||||
import { MobileSurfaceShell } from './MobileSurfaceShell';
|
import { MobileSurfaceShell } from './MobileSurfaceShell';
|
||||||
import { DedicatedMobileAppProvider, type MobileAppActions } from './mobileAppContext';
|
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 { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
|
||||||
import { resetAppForRuntimeEndpointChange } from './runtimeEndpointReset';
|
import { resetAppForRuntimeEndpointChange } from './runtimeEndpointReset';
|
||||||
import { useAppFontEffects } from './useAppFontEffects';
|
import { useAppFontEffects } from './useAppFontEffects';
|
||||||
@@ -516,6 +516,12 @@ const mobileInputKeyboardProps = {
|
|||||||
|
|
||||||
const NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS = 1_000;
|
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 getProjectLabel = (path: string): string => {
|
||||||
const normalized = normalizePath(path);
|
const normalized = normalizePath(path);
|
||||||
if (!normalized) return '';
|
if (!normalized) return '';
|
||||||
@@ -2184,18 +2190,33 @@ export function MobileApp({ apis }: MobileAppProps) {
|
|||||||
const [autoConnectPhase, setAutoConnectPhase] = React.useState<'pending' | 'attempting' | 'done'>('pending');
|
const [autoConnectPhase, setAutoConnectPhase] = React.useState<'pending' | 'attempting' | 'done'>('pending');
|
||||||
const isNativeMobileApp = React.useMemo(() => isCapacitorMobileApp(), []);
|
const isNativeMobileApp = React.useMemo(() => isCapacitorMobileApp(), []);
|
||||||
const lastNativeResumeSyncEventAtRef = React.useRef(0);
|
const lastNativeResumeSyncEventAtRef = React.useRef(0);
|
||||||
|
const nativeResumeValidationSeqRef = React.useRef(0);
|
||||||
|
|
||||||
const handleNativeResume = React.useCallback(() => {
|
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();
|
const now = Date.now();
|
||||||
if (now - lastNativeResumeSyncEventAtRef.current >= NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS) {
|
if (now - lastNativeResumeSyncEventAtRef.current >= NATIVE_RESUME_SYNC_EVENT_THROTTLE_MS) {
|
||||||
lastNativeResumeSyncEventAtRef.current = now;
|
lastNativeResumeSyncEventAtRef.current = now;
|
||||||
window.dispatchEvent(new Event('openchamber:system-resume'));
|
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]);
|
}, [agentsCount, apis.github, initializeApp, loadAgents, loadProviders, providersCount, refreshGitHubAuthStatus]);
|
||||||
|
|
||||||
useNativeMobileChrome();
|
useNativeMobileChrome();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -452,6 +452,31 @@ export const autoConnectLastInstance = async (): Promise<boolean> => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const validateMobileConnectionSession = async (input: {
|
||||||
|
url: string;
|
||||||
|
clientToken?: string | null;
|
||||||
|
}): Promise<boolean> => {
|
||||||
|
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
|
// Shared connection controller
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user