From b843f7e90ee04d14998c33a27b89cdbc2df84e1b Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 2 Jul 2026 16:20:12 +0530 Subject: [PATCH 1/3] feat(settings): first-class remote-core connection setting + live status (GH-4396) Promote the pre-existing cloud core mode (persisted remote-core RPC URL + bearer token, previously reachable only from the pre-router BootCheckGate picker) into a first-class Settings > Core connection panel, and add a live connect/failure status indicator. - New CoreConnectionPanel: live status dot + Recheck, a 'use remote core' toggle, persisted URL + token fields with Test connection, and a Save & restart action that re-enters the existing BootCheckGate flow. - Reuses existing plumbing (coreModeSlice, configPersistence, testCoreRpcConnection); does not introduce a second remote-core mechanism. - Boot-gate hard-fail/fallback semantics unchanged: the panel only surfaces connection state. - Env-var attach path (OPENHUMAN_CORE_REUSE_EXISTING) left as documented dev-only; not surfaced in the UI. - Token stays on the existing localStorage path (audit U3 keychain migration noted in-code as a known follow-up). - Registry + route wiring; 12 new settings.core.* i18n keys translated across all 13 locales. - Vitest coverage for status rendering, the reveal toggle, unreachable state, and the persist/dispatch/restart save flow. --- .../settings/panels/CoreConnectionPanel.tsx | 431 ++++++++++++++++++ .../__tests__/CoreConnectionPanel.test.tsx | 130 ++++++ .../settings/settingsRouteElements.tsx | 4 + .../settings/settingsRouteRegistry.ts | 24 + app/src/lib/i18n/ar.ts | 13 + app/src/lib/i18n/bn.ts | 13 + app/src/lib/i18n/de.ts | 13 + app/src/lib/i18n/en.ts | 14 + app/src/lib/i18n/es.ts | 13 + app/src/lib/i18n/fr.ts | 13 + app/src/lib/i18n/hi.ts | 13 + app/src/lib/i18n/id.ts | 13 + app/src/lib/i18n/it.ts | 13 + app/src/lib/i18n/ko.ts | 13 + app/src/lib/i18n/pl.ts | 13 + app/src/lib/i18n/pt.ts | 13 + app/src/lib/i18n/ru.ts | 13 + app/src/lib/i18n/zh-CN.ts | 13 + 18 files changed, 772 insertions(+) create mode 100644 app/src/components/settings/panels/CoreConnectionPanel.tsx create mode 100644 app/src/components/settings/panels/__tests__/CoreConnectionPanel.test.tsx diff --git a/app/src/components/settings/panels/CoreConnectionPanel.tsx b/app/src/components/settings/panels/CoreConnectionPanel.tsx new file mode 100644 index 0000000000..f7dfc0722c --- /dev/null +++ b/app/src/components/settings/panels/CoreConnectionPanel.tsx @@ -0,0 +1,431 @@ +/** + * CoreConnectionPanel — Settings → Core connection. + * + * Promotes the pre-existing "cloud" core mode (a persisted remote-core RPC + * URL + bearer token, previously reachable only from the pre-router + * BootCheckGate picker) into a first-class, in-app setting, and adds a live + * connect/failure status indicator. + * + * This deliberately reuses the existing cloud-mode plumbing — the `coreMode` + * Redux slice, `configPersistence` storage keys, and + * `testCoreRpcConnection` — rather than introducing a second remote-core + * mechanism. The shell-level env-var attach path (`OPENHUMAN_CORE_REUSE_EXISTING`, + * `OPENHUMAN_CORE_TOKEN`) is intentionally left as a documented dev-only + * override and is not surfaced here (GH-4396). + * + * Boot-gate hard-fail/fallback semantics are unchanged: switching mode here + * persists the choice and restarts the app so the normal BootCheckGate flow + * re-runs against the new mode. This panel only *surfaces* connection state; + * it does not change what happens when a configured core is unreachable. + */ +import { invoke } from '@tauri-apps/api/core'; +import debug from 'debug'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import { CORE_RPC_URL } from '../../../utils/config'; +import { + clearCoreRpcTokenCache, + clearCoreRpcUrlCache, + testCoreRpcConnection, +} from '../../../services/coreRpcClient'; +import { type CoreMode, setCoreMode } from '../../../store/coreModeSlice'; +import { useAppDispatch, useAppSelector } from '../../../store/hooks'; +import { + clearStoredCoreToken, + isLocalOrPrivateNetworkHost, + isTauriEnvironment, + normalizeRpcUrl, + storeCoreMode, + storeCoreToken, + storeRpcUrl, +} from '../../../utils/configPersistence'; +import { restartApp } from '../../../utils/tauriCommands/core'; +import Button from '../../ui/Button'; +import { SettingsRow, SettingsSection, SettingsSwitch, SettingsTextField } from '../controls'; +import SettingsPanel from '../layout/SettingsPanel'; + +const log = debug('settings:core'); + +/** Live reachability of the currently-active core. */ +type LiveStatus = + | { kind: 'checking' } + | { kind: 'connected' } + | { kind: 'authFailed' } + | { kind: 'unreachable'; reason: string }; + +/** Result of a one-shot "Test connection" against the typed remote inputs. */ +type TestStatus = + | { kind: 'idle' } + | { kind: 'testing' } + | { kind: 'ok' } + | { kind: 'auth' } + | { kind: 'unreachable'; reason: string }; + +/** + * Resolve the URL the active core is actually reachable at. Cloud mode stores + * the user's chosen URL in Redux; local mode picks a dynamic port at launch, + * so the authoritative value lives in the Tauri shell (`core_rpc_url`). + */ +async function resolveActiveCoreUrl(coreMode: CoreMode): Promise { + if (coreMode.kind === 'cloud') return coreMode.url; + if (!isTauriEnvironment()) return CORE_RPC_URL; + try { + return await invoke('core_rpc_url'); + } catch (err) { + log('resolveActiveCoreUrl: core_rpc_url invoke failed: %o', err); + return null; + } +} + +const CoreConnectionPanel = () => { + const { t } = useT(); + const dispatch = useAppDispatch(); + const coreMode = useAppSelector(state => state.coreMode.mode); + + // ── Editable form state ──────────────────────────────────────────────── + // Seeded from the persisted cloud-mode config so the panel reflects the + // current setting on open. + const [useRemote, setUseRemote] = useState(coreMode.kind === 'cloud'); + const [url, setUrl] = useState(coreMode.kind === 'cloud' ? coreMode.url : ''); + const [token, setToken] = useState(coreMode.kind === 'cloud' ? (coreMode.token ?? '') : ''); + const [formError, setFormError] = useState(null); + const [testStatus, setTestStatus] = useState({ kind: 'idle' }); + const [saving, setSaving] = useState(false); + + // ── Live status indicator (against the currently-active core) ─────────── + const [liveStatus, setLiveStatus] = useState({ kind: 'checking' }); + const [activeUrl, setActiveUrl] = useState(null); + const checkSeq = useRef(0); + + const runLiveCheck = useCallback(async () => { + const seq = ++checkSeq.current; + setLiveStatus({ kind: 'checking' }); + log('runLiveCheck: mode=%s', coreMode.kind); + const resolved = await resolveActiveCoreUrl(coreMode); + if (seq !== checkSeq.current) return; // superseded by a newer check + setActiveUrl(resolved); + if (!resolved) { + setLiveStatus({ kind: 'unreachable', reason: t('settings.about.serverUrlUnavailable') }); + return; + } + try { + const response = await testCoreRpcConnection(resolved); + if (seq !== checkSeq.current) return; + if (response.status === 401 || response.status === 403) { + log('runLiveCheck: auth failed (status=%d)', response.status); + setLiveStatus({ kind: 'authFailed' }); + return; + } + if (!response.ok) { + log('runLiveCheck: HTTP %d', response.status); + setLiveStatus({ kind: 'unreachable', reason: `HTTP ${response.status}` }); + return; + } + // Drain the body so the connection can be reused; a JSON-RPC error body + // on a 200 does not disprove reachability. + try { + await response.json(); + } catch { + /* non-JSON body is unusual but still reachable */ + } + log('runLiveCheck: connected'); + setLiveStatus({ kind: 'connected' }); + } catch (err) { + if (seq !== checkSeq.current) return; + const reason = err instanceof Error ? err.message : 'Connection failed'; + log('runLiveCheck: errored: %o', err); + setLiveStatus({ kind: 'unreachable', reason }); + } + }, [coreMode, t]); + + useEffect(() => { + void runLiveCheck(); + }, [runLiveCheck]); + + // ── Validation (mirrors the BootCheckGate cloud picker) ───────────────── + const validate = (): { url: string; token: string } | null => { + const rawUrl = url.trim(); + if (!rawUrl) { + setFormError(t('bootCheck.invalidUrl')); + return null; + } + const normalized = normalizeRpcUrl(rawUrl); + try { + const parsed = new URL(normalized); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + setFormError(t('bootCheck.urlMustStartWith')); + return null; + } + } catch { + setFormError(t('bootCheck.validUrlRequired')); + return null; + } + const trimmedToken = token.trim(); + if (!trimmedToken) { + setFormError(t('bootCheck.tokenRequired')); + return null; + } + setFormError(null); + return { url: normalized, token: trimmedToken }; + }; + + const httpWarning = (() => { + if (!useRemote) return null; + const trimmed = url.trim(); + if (!trimmed) return null; + try { + const parsed = new URL(normalizeRpcUrl(trimmed)); + if (parsed.protocol === 'http:' && !isLocalOrPrivateNetworkHost(parsed.hostname)) { + return t('bootCheck.httpPublicWarning'); + } + } catch { + /* validate() surfaces parse errors on save */ + } + return null; + })(); + + const handleTest = async () => { + const validated = validate(); + if (!validated) return; + setTestStatus({ kind: 'testing' }); + log('handleTest: url=%s tokenLen=%d', validated.url, validated.token.length); + try { + const response = await testCoreRpcConnection(validated.url, validated.token); + if (response.status === 401 || response.status === 403) { + setTestStatus({ kind: 'auth' }); + return; + } + if (!response.ok) { + setTestStatus({ kind: 'unreachable', reason: `HTTP ${response.status}` }); + return; + } + try { + await response.json(); + } catch { + /* reachable regardless of body shape */ + } + setTestStatus({ kind: 'ok' }); + } catch (err) { + const reason = err instanceof Error ? err.message : 'Connection failed'; + setTestStatus({ kind: 'unreachable', reason }); + } + }; + + // ── Dirty detection ───────────────────────────────────────────────────── + // Enable Save only when the desired mode differs from the persisted one. + const isDirty = (() => { + if (!useRemote) return coreMode.kind !== 'local'; + if (coreMode.kind !== 'cloud') return true; + return normalizeRpcUrl(url.trim() || '') !== coreMode.url || token.trim() !== (coreMode.token ?? ''); + })(); + + const handleSave = async () => { + if (saving) return; + if (useRemote) { + const validated = validate(); + if (!validated) return; + log('handleSave: switching to remote core url=%s tokenLen=%d', validated.url, validated.token.length); + setSaving(true); + // NOTE: the bearer is persisted in plain localStorage via storeCoreToken, + // matching the existing cloud-mode picker. A renderer XSS could read it + // (security audit U3). Migrating this to the OS keychain is a known + // follow-up tracked with the rest of cloud-mode token storage; this panel + // intentionally does not block on it (GH-4396 scope decision). + storeRpcUrl(validated.url); + storeCoreToken(validated.token); + storeCoreMode('cloud'); + clearCoreRpcUrlCache(); + clearCoreRpcTokenCache(); + dispatch(setCoreMode({ kind: 'cloud', url: validated.url, token: validated.token })); + } else { + log('handleSave: switching to local core'); + setSaving(true); + storeRpcUrl(''); + clearStoredCoreToken(); + storeCoreMode('local'); + clearCoreRpcUrlCache(); + clearCoreRpcTokenCache(); + dispatch(setCoreMode({ kind: 'local' })); + } + // Restart so BootCheckGate re-runs against the new mode (unchanged + // boot-gate semantics). In dev this is a renderer reload. + await restartApp(); + }; + + // ── Live status rendering ─────────────────────────────────────────────── + const statusText = (() => { + switch (liveStatus.kind) { + case 'checking': + return t('settings.core.statusChecking'); + case 'connected': + return coreMode.kind === 'cloud' + ? t('settings.core.statusConnectedRemote') + : t('settings.core.statusConnectedLocal'); + case 'authFailed': + return t('settings.core.statusAuthFailed'); + case 'unreachable': + return `${t('settings.core.statusUnreachable')} — ${liveStatus.reason}`; + } + })(); + + const statusDotClass = (() => { + switch (liveStatus.kind) { + case 'connected': + return 'bg-sage-500'; + case 'checking': + return 'bg-amber-400 animate-pulse'; + default: + return 'bg-coral-500'; + } + })(); + + return ( + + {/* Live status indicator */} + + + + + {/* Remote-core toggle + config */} + + { + setUseRemote(next); + setTestStatus({ kind: 'idle' }); + setFormError(null); + }} + aria-label={t('settings.core.useRemoteToggle')} + data-testid="core-use-remote-toggle" + /> + } + /> + + {useRemote && ( +
+
+ + { + setUrl(e.target.value); + setFormError(null); + setTestStatus({ kind: 'idle' }); + }} + /> + {httpWarning && ( +

{httpWarning}

+ )} +
+ +
+ + { + setToken(e.target.value); + setFormError(null); + setTestStatus({ kind: 'idle' }); + }} + /> +

+ {t('bootCheck.storedLocally')} Authorization: Bearer …{' '} + {t('bootCheck.rpcAuthSuffix')} +

+
+ +
+ + {testStatus.kind === 'ok' && ( + + {t('bootCheck.connectedOk')} + + )} + {testStatus.kind === 'auth' && ( + + {t('bootCheck.authFailed')} + + )} + {testStatus.kind === 'unreachable' && ( + + {t('bootCheck.unreachablePrefix')} {testStatus.reason} + + )} +
+
+ )} + + {formError &&

{formError}

} + +
+

+ {t('settings.core.applyRestartNote')} +

+ +
+
+
+ ); +}; + +export default CoreConnectionPanel; diff --git a/app/src/components/settings/panels/__tests__/CoreConnectionPanel.test.tsx b/app/src/components/settings/panels/__tests__/CoreConnectionPanel.test.tsx new file mode 100644 index 0000000000..58e8bffd46 --- /dev/null +++ b/app/src/components/settings/panels/__tests__/CoreConnectionPanel.test.tsx @@ -0,0 +1,130 @@ +/** + * Tests for CoreConnectionPanel (GH-4396) — the first-class Settings surface + * that promotes cloud-mode remote-core config and adds a live status + * indicator. Covers: live status rendering per mode, the remote toggle + * revealing the URL/token form, and the save flow persisting + dispatching + + * restarting. + */ +import { fireEvent, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { renderWithProviders } from '../../../../test/test-utils'; + +const hoisted = vi.hoisted(() => ({ + testCoreRpcConnection: vi.fn(), + clearCoreRpcUrlCache: vi.fn(), + clearCoreRpcTokenCache: vi.fn(), + restartApp: vi.fn(), +})); + +vi.mock('../../../../services/coreRpcClient', () => ({ + testCoreRpcConnection: hoisted.testCoreRpcConnection, + clearCoreRpcUrlCache: hoisted.clearCoreRpcUrlCache, + clearCoreRpcTokenCache: hoisted.clearCoreRpcTokenCache, +})); + +vi.mock('../../../../utils/tauriCommands/core', () => ({ + restartApp: hoisted.restartApp, +})); + +function okResponse() { + return { ok: true, status: 200, json: async () => ({ jsonrpc: '2.0', id: 1, result: {} }) }; +} + +async function importPanel() { + const mod = await import('../CoreConnectionPanel'); + return mod.default; +} + +describe('CoreConnectionPanel', () => { + beforeEach(() => { + vi.resetModules(); + hoisted.testCoreRpcConnection.mockReset(); + hoisted.clearCoreRpcUrlCache.mockReset(); + hoisted.clearCoreRpcTokenCache.mockReset(); + hoisted.restartApp.mockReset(); + hoisted.restartApp.mockResolvedValue(undefined); + localStorage.clear(); + }); + + test('local mode shows the local connected status once the live check passes', async () => { + hoisted.testCoreRpcConnection.mockResolvedValue(okResponse()); + const Panel = await importPanel(); + renderWithProviders(, { preloadedState: { coreMode: { mode: { kind: 'local' } } } }); + + await waitFor(() => expect(screen.getByText('Connected to local core')).toBeInTheDocument()); + // Remote toggle is off in local mode → no URL field. + expect(screen.queryByLabelText(/Runtime URL/i)).not.toBeInTheDocument(); + }); + + test('cloud mode surfaces the remote URL and remote connected status', async () => { + hoisted.testCoreRpcConnection.mockResolvedValue(okResponse()); + const Panel = await importPanel(); + renderWithProviders(, { + preloadedState: { + coreMode: { + mode: { kind: 'cloud', url: 'https://core.example.com/rpc', token: 'tok-123456' }, + }, + }, + }); + + await waitFor(() => + expect(screen.getByText('Connected to remote core')).toBeInTheDocument() + ); + // Toggle on → the URL field is pre-filled with the persisted value. + expect(screen.getByDisplayValue('https://core.example.com/rpc')).toBeInTheDocument(); + }); + + test('unreachable core surfaces the failure status', async () => { + hoisted.testCoreRpcConnection.mockRejectedValue(new Error('boom')); + const Panel = await importPanel(); + renderWithProviders(, { preloadedState: { coreMode: { mode: { kind: 'local' } } } }); + + await waitFor(() => + expect(screen.getByText(/Cannot reach the core/i)).toBeInTheDocument() + ); + }); + + test('switching to remote core persists, dispatches, and restarts', async () => { + hoisted.testCoreRpcConnection.mockResolvedValue(okResponse()); + const Panel = await importPanel(); + const { store } = renderWithProviders(, { + preloadedState: { coreMode: { mode: { kind: 'local' } } }, + }); + + await waitFor(() => expect(screen.getByText('Connected to local core')).toBeInTheDocument()); + + // Flip the remote toggle on to reveal the form. + fireEvent.click(screen.getByTestId('core-use-remote-toggle')); + + fireEvent.change(screen.getByLabelText(/Runtime URL/i), { + target: { value: 'https://core.example.com/rpc' }, + }); + fireEvent.change(screen.getByLabelText(/Auth Token/i), { + target: { value: 'remote-token-xyz' }, + }); + + fireEvent.click(screen.getByTestId('core-save-btn')); + + await waitFor(() => expect(hoisted.restartApp).toHaveBeenCalledTimes(1)); + + // Redux is now in cloud mode with the typed URL + token. + const mode = store.getState().coreMode.mode as { + kind: string; + url?: string; + token?: string; + }; + expect(mode.kind).toBe('cloud'); + expect(mode.url).toBe('https://core.example.com/rpc'); + expect(mode.token).toBe('remote-token-xyz'); + + // Persisted synchronously to localStorage (mirrors the cloud-mode picker). + expect(localStorage.getItem('openhuman_core_mode')).toBe('cloud'); + expect(localStorage.getItem('openhuman_core_rpc_url')).toBe('https://core.example.com/rpc'); + expect(localStorage.getItem('openhuman_core_rpc_token')).toBe('remote-token-xyz'); + + // Caches cleared so the new endpoint takes effect on restart. + expect(hoisted.clearCoreRpcUrlCache).toHaveBeenCalled(); + expect(hoisted.clearCoreRpcTokenCache).toHaveBeenCalled(); + }); +}); diff --git a/app/src/components/settings/settingsRouteElements.tsx b/app/src/components/settings/settingsRouteElements.tsx index 641bb6a514..e7f415b911 100644 --- a/app/src/components/settings/settingsRouteElements.tsx +++ b/app/src/components/settings/settingsRouteElements.tsx @@ -17,6 +17,7 @@ import AutocompleteDebugPanel from './panels/AutocompleteDebugPanel'; import AutocompletePanel from './panels/AutocompletePanel'; import BillingPanel from './panels/BillingPanel'; import CompanionPanel from './panels/CompanionPanel'; +import CoreConnectionPanel from './panels/CoreConnectionPanel'; import ComposioTriagePanel from './panels/ComposioTriagePanel'; import CronJobsPanel from './panels/CronJobsPanel'; import DesktopAgentPanel from './panels/DesktopAgentPanel'; @@ -154,6 +155,9 @@ export function settingsRouteElements(): ReactNode { )} /> {/* ── System ──────────────────────────────────────────────── */} + {/* Core connection — promotes cloud-mode remote-core config into a + first-class setting with a live status indicator (GH-4396). */} + )} /> )} /> )} /> )} /> diff --git a/app/src/components/settings/settingsRouteRegistry.ts b/app/src/components/settings/settingsRouteRegistry.ts index c3e5e47930..ba8efd13b0 100644 --- a/app/src/components/settings/settingsRouteRegistry.ts +++ b/app/src/components/settings/settingsRouteRegistry.ts @@ -286,6 +286,30 @@ export const SETTINGS_ROUTE_REGISTRY: SettingsRegistryEntry[] = [ navGroup: 'general', navOrder: 98, }, + { + // Core connection — promotes cloud-mode remote-core config (persisted + // RPC URL + token) into a first-class setting plus a live status + // indicator (GH-4396). Sits just above About in General. + id: 'core', + titleKey: 'settings.core.title', + descriptionKey: 'settings.core.menuDesc', + section: 'home', + searchKeywords: [ + 'core', + 'remote', + 'rpc', + 'url', + 'token', + 'cloud', + 'local', + 'connection', + 'server', + 'attach', + 'self-hosted', + ], + navGroup: 'general', + navOrder: 97, + }, { id: 'about', titleKey: 'settings.about', diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index fbde7a5175..39299f7556 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -5,6 +5,19 @@ import type { TranslationMap } from './types'; const messages: TranslationMap = { 'skills.recallCalendar.title': 'تقويم Google', 'skills.recallCalendar.description': 'الانضمام تلقائيًا إلى مكالمات Google Meet عبر Recall.ai', + // Core connection panel (GH-4396) + 'settings.core.title': 'اتصال النواة', + 'settings.core.menuDesc': 'استخدم النواة المحلية المدمجة أو اتصل بنواة بعيدة.', + 'settings.core.useRemoteToggle': 'استخدام نواة بعيدة', + 'settings.core.useRemoteToggleDesc': 'الاتصال بنواة بعيدة عبر HTTP بدلاً من النواة المحلية المدمجة.', + 'settings.core.statusConnectedRemote': 'متصل بالنواة البعيدة', + 'settings.core.statusConnectedLocal': 'متصل بالنواة المحلية', + 'settings.core.statusChecking': 'جارٍ التحقق من الاتصال…', + 'settings.core.statusAuthFailed': 'يمكن الوصول إليه، لكن تم رفض الرمز', + 'settings.core.statusUnreachable': 'تعذّر الوصول إلى النواة', + 'settings.core.recheck': 'إعادة التحقق', + 'settings.core.save': 'حفظ وإعادة التشغيل', + 'settings.core.applyRestartNote': 'يؤدي الحفظ إلى إعادة تشغيل OpenHuman لإعادة الاتصال.', // Cross-host vault (#4278) 'crossHostVault.title': 'الخزنة موجودة على مضيف النواة.', 'crossHostVault.message': diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 3f9ed538d5..674858fc24 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -6,6 +6,19 @@ const messages: TranslationMap = { 'skills.recallCalendar.title': 'Google Calendar', 'skills.recallCalendar.description': 'Recall.ai-এর মাধ্যমে Google Meet কলে স্বয়ংক্রিয়ভাবে যোগ দিন', + // Core connection panel (GH-4396) + 'settings.core.title': 'কোর সংযোগ', + 'settings.core.menuDesc': 'অন্তর্নির্মিত স্থানীয় কোর ব্যবহার করুন বা একটি রিমোট কোরে সংযোগ করুন।', + 'settings.core.useRemoteToggle': 'রিমোট কোর ব্যবহার করুন', + 'settings.core.useRemoteToggleDesc': 'অন্তর্নির্মিত স্থানীয় কোরের পরিবর্তে HTTP-এর মাধ্যমে একটি রিমোট কোরে সংযোগ করুন।', + 'settings.core.statusConnectedRemote': 'রিমোট কোরে সংযুক্ত', + 'settings.core.statusConnectedLocal': 'স্থানীয় কোরে সংযুক্ত', + 'settings.core.statusChecking': 'সংযোগ পরীক্ষা করা হচ্ছে…', + 'settings.core.statusAuthFailed': 'পৌঁছানো যায়, তবে টোকেন প্রত্যাখ্যাত হয়েছে', + 'settings.core.statusUnreachable': 'কোরে পৌঁছানো যাচ্ছে না', + 'settings.core.recheck': 'পুনরায় পরীক্ষা করুন', + 'settings.core.save': 'সংরক্ষণ করে পুনরায় চালু করুন', + 'settings.core.applyRestartNote': 'সংরক্ষণ করলে পুনরায় সংযোগের জন্য OpenHuman পুনরায় চালু হয়।', // Cross-host vault (#4278) 'crossHostVault.title': 'ভল্টটি কোর হোস্টে রয়েছে।', 'crossHostVault.message': diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 8d2ba158d3..b01d10bd89 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -5,6 +5,19 @@ import type { TranslationMap } from './types'; const messages: TranslationMap = { 'skills.recallCalendar.title': 'Google Kalender', 'skills.recallCalendar.description': 'Google Meet-Anrufen automatisch über Recall.ai beitreten', + // Core connection panel (GH-4396) + 'settings.core.title': 'Core-Verbindung', + 'settings.core.menuDesc': 'Den integrierten lokalen Core verwenden oder mit einem Remote-Core verbinden.', + 'settings.core.useRemoteToggle': 'Remote-Core verwenden', + 'settings.core.useRemoteToggleDesc': 'Über HTTP mit einem Remote-Core verbinden statt den integrierten lokalen Core zu nutzen.', + 'settings.core.statusConnectedRemote': 'Mit Remote-Core verbunden', + 'settings.core.statusConnectedLocal': 'Mit lokalem Core verbunden', + 'settings.core.statusChecking': 'Verbindung wird geprüft…', + 'settings.core.statusAuthFailed': 'Erreichbar, aber das Token wurde abgelehnt', + 'settings.core.statusUnreachable': 'Core nicht erreichbar', + 'settings.core.recheck': 'Erneut prüfen', + 'settings.core.save': 'Speichern & neu starten', + 'settings.core.applyRestartNote': 'Beim Speichern startet OpenHuman neu, um die Verbindung herzustellen.', // Cross-host vault (#4278) 'crossHostVault.title': 'Der Vault liegt auf dem Core-Host.', 'crossHostVault.message': diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 7111c751bc..aa5976f084 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -1481,6 +1481,20 @@ const en: TranslationMap = { 'Spawned in-process by the Tauri shell on app launch. The port is chosen at startup, so this URL changes between launches.', 'settings.about.connectionHelperCloud': 'Connected to a remote core. Change this in BootCheck or the cloud mode picker.', + // Core connection panel (GH-4396) + 'settings.core.title': 'Core connection', + 'settings.core.menuDesc': 'Use the built-in local core or connect to a remote core.', + 'settings.core.useRemoteToggle': 'Use remote core', + 'settings.core.useRemoteToggleDesc': + 'Connect to a remote core over HTTP instead of the built-in local core.', + 'settings.core.statusConnectedRemote': 'Connected to remote core', + 'settings.core.statusConnectedLocal': 'Connected to local core', + 'settings.core.statusChecking': 'Checking connection…', + 'settings.core.statusAuthFailed': 'Reachable, but the token was rejected', + 'settings.core.statusUnreachable': 'Cannot reach the core', + 'settings.core.recheck': 'Recheck', + 'settings.core.save': 'Save & restart', + 'settings.core.applyRestartNote': 'Saving restarts OpenHuman to reconnect.', 'settings.heartbeat.title': 'Heartbeat & loops', 'settings.usage.title': 'Usage & Limits', 'settings.usage.menuDesc': 'Costs, token usage, budgets, and background activity', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index ca549b36cd..8eb8fd81cf 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -6,6 +6,19 @@ const messages: TranslationMap = { 'skills.recallCalendar.title': 'Google Calendar', 'skills.recallCalendar.description': 'Unirse automáticamente a las llamadas de Google Meet con Recall.ai', + // Core connection panel (GH-4396) + 'settings.core.title': 'Conexión del core', + 'settings.core.menuDesc': 'Usa el core local integrado o conéctate a un core remoto.', + 'settings.core.useRemoteToggle': 'Usar core remoto', + 'settings.core.useRemoteToggleDesc': 'Conéctate a un core remoto por HTTP en lugar del core local integrado.', + 'settings.core.statusConnectedRemote': 'Conectado al core remoto', + 'settings.core.statusConnectedLocal': 'Conectado al core local', + 'settings.core.statusChecking': 'Comprobando la conexión…', + 'settings.core.statusAuthFailed': 'Accesible, pero el token fue rechazado', + 'settings.core.statusUnreachable': 'No se puede conectar con el core', + 'settings.core.recheck': 'Volver a comprobar', + 'settings.core.save': 'Guardar y reiniciar', + 'settings.core.applyRestartNote': 'Al guardar, OpenHuman se reinicia para volver a conectarse.', // Cross-host vault (#4278) 'crossHostVault.title': 'El vault está en el host del core.', 'crossHostVault.message': diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 5caf845b29..1143c05f1c 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -6,6 +6,19 @@ const messages: TranslationMap = { 'skills.recallCalendar.title': 'Google Agenda', 'skills.recallCalendar.description': 'Rejoindre automatiquement les appels Google Meet via Recall.ai', + // Core connection panel (GH-4396) + 'settings.core.title': 'Connexion au core', + 'settings.core.menuDesc': 'Utilisez le core local intégré ou connectez-vous à un core distant.', + 'settings.core.useRemoteToggle': 'Utiliser un core distant', + 'settings.core.useRemoteToggleDesc': 'Se connecter à un core distant via HTTP au lieu du core local intégré.', + 'settings.core.statusConnectedRemote': 'Connecté au core distant', + 'settings.core.statusConnectedLocal': 'Connecté au core local', + 'settings.core.statusChecking': 'Vérification de la connexion…', + 'settings.core.statusAuthFailed': 'Accessible, mais le jeton a été refusé', + 'settings.core.statusUnreachable': 'Core inaccessible', + 'settings.core.recheck': 'Revérifier', + 'settings.core.save': 'Enregistrer et redémarrer', + 'settings.core.applyRestartNote': 'Enregistrer redémarre OpenHuman pour se reconnecter.', // Cross-host vault (#4278) 'crossHostVault.title': "Le coffre se trouve sur l'hôte du cœur.", 'crossHostVault.message': diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 7db36e6675..13e528529c 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -5,6 +5,19 @@ import type { TranslationMap } from './types'; const messages: TranslationMap = { 'skills.recallCalendar.title': 'Google Calendar', 'skills.recallCalendar.description': 'Recall.ai के ज़रिए Google Meet कॉल में अपने-आप शामिल हों', + // Core connection panel (GH-4396) + 'settings.core.title': 'कोर कनेक्शन', + 'settings.core.menuDesc': 'अंतर्निहित स्थानीय कोर का उपयोग करें या किसी रिमोट कोर से कनेक्ट करें।', + 'settings.core.useRemoteToggle': 'रिमोट कोर का उपयोग करें', + 'settings.core.useRemoteToggleDesc': 'अंतर्निहित स्थानीय कोर के बजाय HTTP के माध्यम से किसी रिमोट कोर से कनेक्ट करें।', + 'settings.core.statusConnectedRemote': 'रिमोट कोर से कनेक्ट हो गया', + 'settings.core.statusConnectedLocal': 'स्थानीय कोर से कनेक्ट हो गया', + 'settings.core.statusChecking': 'कनेक्शन जाँचा जा रहा है…', + 'settings.core.statusAuthFailed': 'पहुँच योग्य, लेकिन टोकन अस्वीकृत हो गया', + 'settings.core.statusUnreachable': 'कोर तक नहीं पहुँचा जा सकता', + 'settings.core.recheck': 'फिर से जाँचें', + 'settings.core.save': 'सहेजें और पुनरारंभ करें', + 'settings.core.applyRestartNote': 'सहेजने पर OpenHuman फिर से कनेक्ट होने के लिए पुनरारंभ होता है।', // Cross-host vault (#4278) 'crossHostVault.title': 'वॉल्ट कोर होस्ट पर है।', 'crossHostVault.message': diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 76c8941be3..4f35e4f9dc 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -6,6 +6,19 @@ const messages: TranslationMap = { 'skills.recallCalendar.title': 'Google Kalender', 'skills.recallCalendar.description': 'Bergabung otomatis ke panggilan Google Meet melalui Recall.ai', + // Core connection panel (GH-4396) + 'settings.core.title': 'Koneksi core', + 'settings.core.menuDesc': 'Gunakan core lokal bawaan atau hubungkan ke core jarak jauh.', + 'settings.core.useRemoteToggle': 'Gunakan core jarak jauh', + 'settings.core.useRemoteToggleDesc': 'Hubungkan ke core jarak jauh melalui HTTP alih-alih core lokal bawaan.', + 'settings.core.statusConnectedRemote': 'Terhubung ke core jarak jauh', + 'settings.core.statusConnectedLocal': 'Terhubung ke core lokal', + 'settings.core.statusChecking': 'Memeriksa koneksi…', + 'settings.core.statusAuthFailed': 'Dapat dijangkau, tetapi token ditolak', + 'settings.core.statusUnreachable': 'Tidak dapat menjangkau core', + 'settings.core.recheck': 'Periksa ulang', + 'settings.core.save': 'Simpan & mulai ulang', + 'settings.core.applyRestartNote': 'Menyimpan akan memulai ulang OpenHuman untuk menyambung kembali.', // Cross-host vault (#4278) 'crossHostVault.title': 'Vault berada di host core.', 'crossHostVault.message': diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 613a8cbca8..042c722d6a 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -6,6 +6,19 @@ const messages: TranslationMap = { 'skills.recallCalendar.title': 'Google Calendar', 'skills.recallCalendar.description': 'Partecipa automaticamente alle chiamate Google Meet tramite Recall.ai', + // Core connection panel (GH-4396) + 'settings.core.title': 'Connessione al core', + 'settings.core.menuDesc': 'Usa il core locale integrato o connettiti a un core remoto.', + 'settings.core.useRemoteToggle': 'Usa core remoto', + 'settings.core.useRemoteToggleDesc': 'Connettiti a un core remoto via HTTP invece del core locale integrato.', + 'settings.core.statusConnectedRemote': 'Connesso al core remoto', + 'settings.core.statusConnectedLocal': 'Connesso al core locale', + 'settings.core.statusChecking': 'Verifica della connessione…', + 'settings.core.statusAuthFailed': 'Raggiungibile, ma il token è stato rifiutato', + 'settings.core.statusUnreachable': 'Impossibile raggiungere il core', + 'settings.core.recheck': 'Ricontrolla', + 'settings.core.save': 'Salva e riavvia', + 'settings.core.applyRestartNote': 'Il salvataggio riavvia OpenHuman per riconnettersi.', // Cross-host vault (#4278) 'crossHostVault.title': "Il vault è sull'host del core.", 'crossHostVault.message': diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 0a5c175f42..fd703a525a 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -5,6 +5,19 @@ import type { TranslationMap } from './types'; const messages: TranslationMap = { 'skills.recallCalendar.title': 'Google 캘린더', 'skills.recallCalendar.description': 'Recall.ai를 통해 Google Meet 통화에 자동 참여', + // Core connection panel (GH-4396) + 'settings.core.title': '코어 연결', + 'settings.core.menuDesc': '내장 로컬 코어를 사용하거나 원격 코어에 연결하세요.', + 'settings.core.useRemoteToggle': '원격 코어 사용', + 'settings.core.useRemoteToggleDesc': '내장 로컬 코어 대신 HTTP를 통해 원격 코어에 연결합니다.', + 'settings.core.statusConnectedRemote': '원격 코어에 연결됨', + 'settings.core.statusConnectedLocal': '로컬 코어에 연결됨', + 'settings.core.statusChecking': '연결 확인 중…', + 'settings.core.statusAuthFailed': '접근 가능하지만 토큰이 거부되었습니다', + 'settings.core.statusUnreachable': '코어에 연결할 수 없습니다', + 'settings.core.recheck': '다시 확인', + 'settings.core.save': '저장 후 재시작', + 'settings.core.applyRestartNote': '저장하면 다시 연결하기 위해 OpenHuman이 재시작됩니다.', // Cross-host vault (#4278) 'crossHostVault.title': '보관소가 코어 호스트에 있습니다.', 'crossHostVault.message': diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 971aa31720..d29ed4b64f 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -6,6 +6,19 @@ const messages: TranslationMap = { 'skills.recallCalendar.title': 'Kalendarz Google', 'skills.recallCalendar.description': 'Automatyczne dołączanie do połączeń Google Meet przez Recall.ai', + // Core connection panel (GH-4396) + 'settings.core.title': 'Połączenie z core', + 'settings.core.menuDesc': 'Użyj wbudowanego lokalnego core lub połącz się ze zdalnym core.', + 'settings.core.useRemoteToggle': 'Użyj zdalnego core', + 'settings.core.useRemoteToggleDesc': 'Połącz się ze zdalnym core przez HTTP zamiast wbudowanego lokalnego core.', + 'settings.core.statusConnectedRemote': 'Połączono ze zdalnym core', + 'settings.core.statusConnectedLocal': 'Połączono z lokalnym core', + 'settings.core.statusChecking': 'Sprawdzanie połączenia…', + 'settings.core.statusAuthFailed': 'Osiągalny, ale token został odrzucony', + 'settings.core.statusUnreachable': 'Nie można połączyć się z core', + 'settings.core.recheck': 'Sprawdź ponownie', + 'settings.core.save': 'Zapisz i uruchom ponownie', + 'settings.core.applyRestartNote': 'Zapisanie uruchamia ponownie OpenHuman, aby połączyć się ponownie.', // Cross-host vault (#4278) 'crossHostVault.title': 'Skarbiec znajduje się na hoście rdzenia.', 'crossHostVault.message': diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 15755004db..b982748012 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -6,6 +6,19 @@ const messages: TranslationMap = { 'skills.recallCalendar.title': 'Google Agenda', 'skills.recallCalendar.description': 'Entrar automaticamente nas chamadas do Google Meet via Recall.ai', + // Core connection panel (GH-4396) + 'settings.core.title': 'Conexão do core', + 'settings.core.menuDesc': 'Use o core local integrado ou conecte-se a um core remoto.', + 'settings.core.useRemoteToggle': 'Usar core remoto', + 'settings.core.useRemoteToggleDesc': 'Conecte-se a um core remoto por HTTP em vez do core local integrado.', + 'settings.core.statusConnectedRemote': 'Conectado ao core remoto', + 'settings.core.statusConnectedLocal': 'Conectado ao core local', + 'settings.core.statusChecking': 'Verificando a conexão…', + 'settings.core.statusAuthFailed': 'Acessível, mas o token foi rejeitado', + 'settings.core.statusUnreachable': 'Não é possível acessar o core', + 'settings.core.recheck': 'Verificar novamente', + 'settings.core.save': 'Salvar e reiniciar', + 'settings.core.applyRestartNote': 'Salvar reinicia o OpenHuman para reconectar.', // Cross-host vault (#4278) 'crossHostVault.title': 'O vault está no host do core.', 'crossHostVault.message': diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index b7a17b96e8..9df22bff2d 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -6,6 +6,19 @@ const messages: TranslationMap = { 'skills.recallCalendar.title': 'Google Календарь', 'skills.recallCalendar.description': 'Автоматически подключаться к звонкам Google Meet через Recall.ai', + // Core connection panel (GH-4396) + 'settings.core.title': 'Подключение к ядру', + 'settings.core.menuDesc': 'Используйте встроенное локальное ядро или подключитесь к удалённому ядру.', + 'settings.core.useRemoteToggle': 'Использовать удалённое ядро', + 'settings.core.useRemoteToggleDesc': 'Подключиться к удалённому ядру по HTTP вместо встроенного локального ядра.', + 'settings.core.statusConnectedRemote': 'Подключено к удалённому ядру', + 'settings.core.statusConnectedLocal': 'Подключено к локальному ядру', + 'settings.core.statusChecking': 'Проверка подключения…', + 'settings.core.statusAuthFailed': 'Доступно, но токен отклонён', + 'settings.core.statusUnreachable': 'Не удаётся подключиться к ядру', + 'settings.core.recheck': 'Проверить снова', + 'settings.core.save': 'Сохранить и перезапустить', + 'settings.core.applyRestartNote': 'После сохранения OpenHuman перезапустится для повторного подключения.', // Cross-host vault (#4278) 'crossHostVault.title': 'Хранилище находится на хосте ядра.', 'crossHostVault.message': diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 78453ad354..f1c37bda8a 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -5,6 +5,19 @@ import type { TranslationMap } from './types'; const messages: TranslationMap = { 'skills.recallCalendar.title': 'Google 日历', 'skills.recallCalendar.description': '通过 Recall.ai 自动加入 Google Meet 通话', + // Core connection panel (GH-4396) + 'settings.core.title': '核心连接', + 'settings.core.menuDesc': '使用内置的本地核心,或连接到远程核心。', + 'settings.core.useRemoteToggle': '使用远程核心', + 'settings.core.useRemoteToggleDesc': '通过 HTTP 连接到远程核心,而不是使用内置的本地核心。', + 'settings.core.statusConnectedRemote': '已连接到远程核心', + 'settings.core.statusConnectedLocal': '已连接到本地核心', + 'settings.core.statusChecking': '正在检查连接…', + 'settings.core.statusAuthFailed': '可访问,但令牌被拒绝', + 'settings.core.statusUnreachable': '无法连接到核心', + 'settings.core.recheck': '重新检查', + 'settings.core.save': '保存并重启', + 'settings.core.applyRestartNote': '保存后 OpenHuman 将重启以重新连接。', // Cross-host vault (#4278) 'crossHostVault.title': '记忆库位于核心主机上。', 'crossHostVault.message': From 27e82035b806a91befa3a5ef9d6cf3329ed96ed4 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Thu, 2 Jul 2026 16:42:29 +0530 Subject: [PATCH 2/3] fix(settings): prettier + eslint + coverage for core connection panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Apply Prettier to the new panel, test, route wiring, and locale files (frontend format:check lane was red). - Suppress the intentional set-state-in-effect lint warning on the live-check effect (matches existing convention). - Add Vitest cases (Test connection ok/auth/unreachable, live auth-rejected + non-ok statuses, validation errors, switch-back-to-local save) — raises changed-line coverage on CoreConnectionPanel.tsx to ~90% (was ~67%), over the 80% diff gate. --- .../settings/panels/CoreConnectionPanel.tsx | 32 +++-- .../__tests__/CoreConnectionPanel.test.tsx | 128 ++++++++++++++++-- .../settings/settingsRouteElements.tsx | 2 +- app/src/lib/i18n/ar.ts | 3 +- app/src/lib/i18n/bn.ts | 6 +- app/src/lib/i18n/de.ts | 9 +- app/src/lib/i18n/es.ts | 3 +- app/src/lib/i18n/fr.ts | 3 +- app/src/lib/i18n/hi.ts | 9 +- app/src/lib/i18n/id.ts | 6 +- app/src/lib/i18n/it.ts | 3 +- app/src/lib/i18n/pl.ts | 6 +- app/src/lib/i18n/pt.ts | 3 +- app/src/lib/i18n/ru.ts | 9 +- 14 files changed, 177 insertions(+), 45 deletions(-) diff --git a/app/src/components/settings/panels/CoreConnectionPanel.tsx b/app/src/components/settings/panels/CoreConnectionPanel.tsx index f7dfc0722c..03eebf348a 100644 --- a/app/src/components/settings/panels/CoreConnectionPanel.tsx +++ b/app/src/components/settings/panels/CoreConnectionPanel.tsx @@ -23,7 +23,6 @@ import debug from 'debug'; import { useCallback, useEffect, useRef, useState } from 'react'; import { useT } from '../../../lib/i18n/I18nContext'; -import { CORE_RPC_URL } from '../../../utils/config'; import { clearCoreRpcTokenCache, clearCoreRpcUrlCache, @@ -31,6 +30,7 @@ import { } from '../../../services/coreRpcClient'; import { type CoreMode, setCoreMode } from '../../../store/coreModeSlice'; import { useAppDispatch, useAppSelector } from '../../../store/hooks'; +import { CORE_RPC_URL } from '../../../utils/config'; import { clearStoredCoreToken, isLocalOrPrivateNetworkHost, @@ -140,6 +140,10 @@ const CoreConnectionPanel = () => { }, [coreMode, t]); useEffect(() => { + // runLiveCheck flips the status to `checking` synchronously; that is the + // intended entry transition for the live probe (also used by Recheck), not + // a cascading render. + // eslint-disable-next-line react-hooks/set-state-in-effect void runLiveCheck(); }, [runLiveCheck]); @@ -217,7 +221,9 @@ const CoreConnectionPanel = () => { const isDirty = (() => { if (!useRemote) return coreMode.kind !== 'local'; if (coreMode.kind !== 'cloud') return true; - return normalizeRpcUrl(url.trim() || '') !== coreMode.url || token.trim() !== (coreMode.token ?? ''); + return ( + normalizeRpcUrl(url.trim() || '') !== coreMode.url || token.trim() !== (coreMode.token ?? '') + ); })(); const handleSave = async () => { @@ -225,7 +231,11 @@ const CoreConnectionPanel = () => { if (useRemote) { const validated = validate(); if (!validated) return; - log('handleSave: switching to remote core url=%s tokenLen=%d', validated.url, validated.token.length); + log( + 'handleSave: switching to remote core url=%s tokenLen=%d', + validated.url, + validated.token.length + ); setSaving(true); // NOTE: the bearer is persisted in plain localStorage via storeCoreToken, // matching the existing cloud-mode picker. A renderer XSS could read it @@ -336,7 +346,9 @@ const CoreConnectionPanel = () => { {useRemote && (
-
-
diff --git a/app/src/components/settings/panels/__tests__/CoreConnectionPanel.test.tsx b/app/src/components/settings/panels/__tests__/CoreConnectionPanel.test.tsx index 58e8bffd46..ec9aa66734 100644 --- a/app/src/components/settings/panels/__tests__/CoreConnectionPanel.test.tsx +++ b/app/src/components/settings/panels/__tests__/CoreConnectionPanel.test.tsx @@ -23,14 +23,21 @@ vi.mock('../../../../services/coreRpcClient', () => ({ clearCoreRpcTokenCache: hoisted.clearCoreRpcTokenCache, })); -vi.mock('../../../../utils/tauriCommands/core', () => ({ - restartApp: hoisted.restartApp, -})); +vi.mock('../../../../utils/tauriCommands/core', () => ({ restartApp: hoisted.restartApp })); function okResponse() { return { ok: true, status: 200, json: async () => ({ jsonrpc: '2.0', id: 1, result: {} }) }; } +/** A Response-shaped stub for an arbitrary HTTP status. */ +function statusResponse(status: number) { + return { ok: status >= 200 && status < 300, status, json: async () => ({}) }; +} + +const CLOUD_STATE = { + coreMode: { mode: { kind: 'cloud', url: 'https://core.example.com/rpc', token: 'tok-123456' } }, +}; + async function importPanel() { const mod = await import('../CoreConnectionPanel'); return mod.default; @@ -68,9 +75,7 @@ describe('CoreConnectionPanel', () => { }, }); - await waitFor(() => - expect(screen.getByText('Connected to remote core')).toBeInTheDocument() - ); + await waitFor(() => expect(screen.getByText('Connected to remote core')).toBeInTheDocument()); // Toggle on → the URL field is pre-filled with the persisted value. expect(screen.getByDisplayValue('https://core.example.com/rpc')).toBeInTheDocument(); }); @@ -80,9 +85,7 @@ describe('CoreConnectionPanel', () => { const Panel = await importPanel(); renderWithProviders(, { preloadedState: { coreMode: { mode: { kind: 'local' } } } }); - await waitFor(() => - expect(screen.getByText(/Cannot reach the core/i)).toBeInTheDocument() - ); + await waitFor(() => expect(screen.getByText(/Cannot reach the core/i)).toBeInTheDocument()); }); test('switching to remote core persists, dispatches, and restarts', async () => { @@ -109,11 +112,7 @@ describe('CoreConnectionPanel', () => { await waitFor(() => expect(hoisted.restartApp).toHaveBeenCalledTimes(1)); // Redux is now in cloud mode with the typed URL + token. - const mode = store.getState().coreMode.mode as { - kind: string; - url?: string; - token?: string; - }; + const mode = store.getState().coreMode.mode as { kind: string; url?: string; token?: string }; expect(mode.kind).toBe('cloud'); expect(mode.url).toBe('https://core.example.com/rpc'); expect(mode.token).toBe('remote-token-xyz'); @@ -127,4 +126,105 @@ describe('CoreConnectionPanel', () => { expect(hoisted.clearCoreRpcUrlCache).toHaveBeenCalled(); expect(hoisted.clearCoreRpcTokenCache).toHaveBeenCalled(); }); + + test('a rejected token surfaces the token-rejected live status', async () => { + hoisted.testCoreRpcConnection.mockResolvedValue(statusResponse(401)); + const Panel = await importPanel(); + renderWithProviders(, { preloadedState: CLOUD_STATE }); + + await waitFor(() => expect(screen.getByText(/the token was rejected/i)).toBeInTheDocument()); + }); + + test('a non-ok response surfaces the unreachable live status with the HTTP code', async () => { + hoisted.testCoreRpcConnection.mockResolvedValue(statusResponse(503)); + const Panel = await importPanel(); + renderWithProviders(, { preloadedState: CLOUD_STATE }); + + await waitFor(() => + expect(screen.getByText(/Cannot reach the core — HTTP 503/i)).toBeInTheDocument() + ); + }); + + test('Test connection reports success for the typed remote inputs', async () => { + hoisted.testCoreRpcConnection.mockResolvedValue(okResponse()); + const Panel = await importPanel(); + renderWithProviders(, { preloadedState: CLOUD_STATE }); + + // Wait for the mount live-check to settle first. + await waitFor(() => expect(screen.getByText('Connected to remote core')).toBeInTheDocument()); + + fireEvent.click(screen.getByText('Test Connection')); + await waitFor(() => expect(screen.getByTestId('core-test-ok')).toBeInTheDocument()); + }); + + test('Test connection reports an auth failure', async () => { + hoisted.testCoreRpcConnection.mockResolvedValue(statusResponse(403)); + const Panel = await importPanel(); + renderWithProviders(, { preloadedState: CLOUD_STATE }); + + fireEvent.click(screen.getByText('Test Connection')); + await waitFor(() => expect(screen.getByTestId('core-test-auth')).toBeInTheDocument()); + }); + + test('Test connection reports an unreachable endpoint', async () => { + hoisted.testCoreRpcConnection.mockRejectedValue(new Error('network down')); + const Panel = await importPanel(); + renderWithProviders(, { preloadedState: CLOUD_STATE }); + + fireEvent.click(screen.getByText('Test Connection')); + await waitFor(() => expect(screen.getByTestId('core-test-unreachable')).toBeInTheDocument()); + }); + + test('validation blocks the form when the URL is empty and when the token is missing', async () => { + hoisted.testCoreRpcConnection.mockResolvedValue(okResponse()); + const Panel = await importPanel(); + renderWithProviders(, { preloadedState: { coreMode: { mode: { kind: 'local' } } } }); + + await waitFor(() => expect(screen.getByText('Connected to local core')).toBeInTheDocument()); + // Reveal the empty remote form. + fireEvent.click(screen.getByTestId('core-use-remote-toggle')); + + // Empty URL → invalid-URL error. + fireEvent.click(screen.getByText('Test Connection')); + await waitFor(() => expect(screen.getByText(/enter a runtime URL/i)).toBeInTheDocument()); + + // Valid URL but empty token → token-required error. + fireEvent.change(screen.getByLabelText(/Runtime URL/i), { + target: { value: 'https://core.example.com/rpc' }, + }); + fireEvent.click(screen.getByText('Test Connection')); + await waitFor(() => + expect(screen.getByText(/need an auth token to connect/i)).toBeInTheDocument() + ); + + // The typed connection was never attempted (validation short-circuits). + expect(hoisted.testCoreRpcConnection).not.toHaveBeenCalledWith( + 'https://core.example.com/rpc', + '' + ); + }); + + test('switching from remote back to local clears persistence, dispatches, and restarts', async () => { + hoisted.testCoreRpcConnection.mockResolvedValue(okResponse()); + // Seed persisted cloud values so we can assert they are cleared. + localStorage.setItem('openhuman_core_mode', 'cloud'); + localStorage.setItem('openhuman_core_rpc_url', 'https://core.example.com/rpc'); + localStorage.setItem('openhuman_core_rpc_token', 'tok-123456'); + + const Panel = await importPanel(); + const { store } = renderWithProviders(, { preloadedState: CLOUD_STATE }); + + await waitFor(() => expect(screen.getByText('Connected to remote core')).toBeInTheDocument()); + + // Flip remote off → local, then save. + fireEvent.click(screen.getByTestId('core-use-remote-toggle')); + fireEvent.click(screen.getByTestId('core-save-btn')); + + await waitFor(() => expect(hoisted.restartApp).toHaveBeenCalledTimes(1)); + + expect(store.getState().coreMode.mode.kind).toBe('local'); + expect(localStorage.getItem('openhuman_core_mode')).toBe('local'); + expect(localStorage.getItem('openhuman_core_rpc_url')).toBeNull(); + expect(localStorage.getItem('openhuman_core_rpc_token')).toBeNull(); + }); }); diff --git a/app/src/components/settings/settingsRouteElements.tsx b/app/src/components/settings/settingsRouteElements.tsx index e7f415b911..e1425d6a27 100644 --- a/app/src/components/settings/settingsRouteElements.tsx +++ b/app/src/components/settings/settingsRouteElements.tsx @@ -17,8 +17,8 @@ import AutocompleteDebugPanel from './panels/AutocompleteDebugPanel'; import AutocompletePanel from './panels/AutocompletePanel'; import BillingPanel from './panels/BillingPanel'; import CompanionPanel from './panels/CompanionPanel'; -import CoreConnectionPanel from './panels/CoreConnectionPanel'; import ComposioTriagePanel from './panels/ComposioTriagePanel'; +import CoreConnectionPanel from './panels/CoreConnectionPanel'; import CronJobsPanel from './panels/CronJobsPanel'; import DesktopAgentPanel from './panels/DesktopAgentPanel'; import DeveloperOptionsPanel from './panels/DeveloperOptionsPanel'; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 39299f7556..8a5ae8b684 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -9,7 +9,8 @@ const messages: TranslationMap = { 'settings.core.title': 'اتصال النواة', 'settings.core.menuDesc': 'استخدم النواة المحلية المدمجة أو اتصل بنواة بعيدة.', 'settings.core.useRemoteToggle': 'استخدام نواة بعيدة', - 'settings.core.useRemoteToggleDesc': 'الاتصال بنواة بعيدة عبر HTTP بدلاً من النواة المحلية المدمجة.', + 'settings.core.useRemoteToggleDesc': + 'الاتصال بنواة بعيدة عبر HTTP بدلاً من النواة المحلية المدمجة.', 'settings.core.statusConnectedRemote': 'متصل بالنواة البعيدة', 'settings.core.statusConnectedLocal': 'متصل بالنواة المحلية', 'settings.core.statusChecking': 'جارٍ التحقق من الاتصال…', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 674858fc24..fe8eae2208 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -8,9 +8,11 @@ const messages: TranslationMap = { 'Recall.ai-এর মাধ্যমে Google Meet কলে স্বয়ংক্রিয়ভাবে যোগ দিন', // Core connection panel (GH-4396) 'settings.core.title': 'কোর সংযোগ', - 'settings.core.menuDesc': 'অন্তর্নির্মিত স্থানীয় কোর ব্যবহার করুন বা একটি রিমোট কোরে সংযোগ করুন।', + 'settings.core.menuDesc': + 'অন্তর্নির্মিত স্থানীয় কোর ব্যবহার করুন বা একটি রিমোট কোরে সংযোগ করুন।', 'settings.core.useRemoteToggle': 'রিমোট কোর ব্যবহার করুন', - 'settings.core.useRemoteToggleDesc': 'অন্তর্নির্মিত স্থানীয় কোরের পরিবর্তে HTTP-এর মাধ্যমে একটি রিমোট কোরে সংযোগ করুন।', + 'settings.core.useRemoteToggleDesc': + 'অন্তর্নির্মিত স্থানীয় কোরের পরিবর্তে HTTP-এর মাধ্যমে একটি রিমোট কোরে সংযোগ করুন।', 'settings.core.statusConnectedRemote': 'রিমোট কোরে সংযুক্ত', 'settings.core.statusConnectedLocal': 'স্থানীয় কোরে সংযুক্ত', 'settings.core.statusChecking': 'সংযোগ পরীক্ষা করা হচ্ছে…', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index b01d10bd89..80a42b5194 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -7,9 +7,11 @@ const messages: TranslationMap = { 'skills.recallCalendar.description': 'Google Meet-Anrufen automatisch über Recall.ai beitreten', // Core connection panel (GH-4396) 'settings.core.title': 'Core-Verbindung', - 'settings.core.menuDesc': 'Den integrierten lokalen Core verwenden oder mit einem Remote-Core verbinden.', + 'settings.core.menuDesc': + 'Den integrierten lokalen Core verwenden oder mit einem Remote-Core verbinden.', 'settings.core.useRemoteToggle': 'Remote-Core verwenden', - 'settings.core.useRemoteToggleDesc': 'Über HTTP mit einem Remote-Core verbinden statt den integrierten lokalen Core zu nutzen.', + 'settings.core.useRemoteToggleDesc': + 'Über HTTP mit einem Remote-Core verbinden statt den integrierten lokalen Core zu nutzen.', 'settings.core.statusConnectedRemote': 'Mit Remote-Core verbunden', 'settings.core.statusConnectedLocal': 'Mit lokalem Core verbunden', 'settings.core.statusChecking': 'Verbindung wird geprüft…', @@ -17,7 +19,8 @@ const messages: TranslationMap = { 'settings.core.statusUnreachable': 'Core nicht erreichbar', 'settings.core.recheck': 'Erneut prüfen', 'settings.core.save': 'Speichern & neu starten', - 'settings.core.applyRestartNote': 'Beim Speichern startet OpenHuman neu, um die Verbindung herzustellen.', + 'settings.core.applyRestartNote': + 'Beim Speichern startet OpenHuman neu, um die Verbindung herzustellen.', // Cross-host vault (#4278) 'crossHostVault.title': 'Der Vault liegt auf dem Core-Host.', 'crossHostVault.message': diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 8eb8fd81cf..7bb4099851 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -10,7 +10,8 @@ const messages: TranslationMap = { 'settings.core.title': 'Conexión del core', 'settings.core.menuDesc': 'Usa el core local integrado o conéctate a un core remoto.', 'settings.core.useRemoteToggle': 'Usar core remoto', - 'settings.core.useRemoteToggleDesc': 'Conéctate a un core remoto por HTTP en lugar del core local integrado.', + 'settings.core.useRemoteToggleDesc': + 'Conéctate a un core remoto por HTTP en lugar del core local integrado.', 'settings.core.statusConnectedRemote': 'Conectado al core remoto', 'settings.core.statusConnectedLocal': 'Conectado al core local', 'settings.core.statusChecking': 'Comprobando la conexión…', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 1143c05f1c..39448c526e 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -10,7 +10,8 @@ const messages: TranslationMap = { 'settings.core.title': 'Connexion au core', 'settings.core.menuDesc': 'Utilisez le core local intégré ou connectez-vous à un core distant.', 'settings.core.useRemoteToggle': 'Utiliser un core distant', - 'settings.core.useRemoteToggleDesc': 'Se connecter à un core distant via HTTP au lieu du core local intégré.', + 'settings.core.useRemoteToggleDesc': + 'Se connecter à un core distant via HTTP au lieu du core local intégré.', 'settings.core.statusConnectedRemote': 'Connecté au core distant', 'settings.core.statusConnectedLocal': 'Connecté au core local', 'settings.core.statusChecking': 'Vérification de la connexion…', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 13e528529c..61310a5380 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -7,9 +7,11 @@ const messages: TranslationMap = { 'skills.recallCalendar.description': 'Recall.ai के ज़रिए Google Meet कॉल में अपने-आप शामिल हों', // Core connection panel (GH-4396) 'settings.core.title': 'कोर कनेक्शन', - 'settings.core.menuDesc': 'अंतर्निहित स्थानीय कोर का उपयोग करें या किसी रिमोट कोर से कनेक्ट करें।', + 'settings.core.menuDesc': + 'अंतर्निहित स्थानीय कोर का उपयोग करें या किसी रिमोट कोर से कनेक्ट करें।', 'settings.core.useRemoteToggle': 'रिमोट कोर का उपयोग करें', - 'settings.core.useRemoteToggleDesc': 'अंतर्निहित स्थानीय कोर के बजाय HTTP के माध्यम से किसी रिमोट कोर से कनेक्ट करें।', + 'settings.core.useRemoteToggleDesc': + 'अंतर्निहित स्थानीय कोर के बजाय HTTP के माध्यम से किसी रिमोट कोर से कनेक्ट करें।', 'settings.core.statusConnectedRemote': 'रिमोट कोर से कनेक्ट हो गया', 'settings.core.statusConnectedLocal': 'स्थानीय कोर से कनेक्ट हो गया', 'settings.core.statusChecking': 'कनेक्शन जाँचा जा रहा है…', @@ -17,7 +19,8 @@ const messages: TranslationMap = { 'settings.core.statusUnreachable': 'कोर तक नहीं पहुँचा जा सकता', 'settings.core.recheck': 'फिर से जाँचें', 'settings.core.save': 'सहेजें और पुनरारंभ करें', - 'settings.core.applyRestartNote': 'सहेजने पर OpenHuman फिर से कनेक्ट होने के लिए पुनरारंभ होता है।', + 'settings.core.applyRestartNote': + 'सहेजने पर OpenHuman फिर से कनेक्ट होने के लिए पुनरारंभ होता है।', // Cross-host vault (#4278) 'crossHostVault.title': 'वॉल्ट कोर होस्ट पर है।', 'crossHostVault.message': diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 4f35e4f9dc..ed8711eedf 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -10,7 +10,8 @@ const messages: TranslationMap = { 'settings.core.title': 'Koneksi core', 'settings.core.menuDesc': 'Gunakan core lokal bawaan atau hubungkan ke core jarak jauh.', 'settings.core.useRemoteToggle': 'Gunakan core jarak jauh', - 'settings.core.useRemoteToggleDesc': 'Hubungkan ke core jarak jauh melalui HTTP alih-alih core lokal bawaan.', + 'settings.core.useRemoteToggleDesc': + 'Hubungkan ke core jarak jauh melalui HTTP alih-alih core lokal bawaan.', 'settings.core.statusConnectedRemote': 'Terhubung ke core jarak jauh', 'settings.core.statusConnectedLocal': 'Terhubung ke core lokal', 'settings.core.statusChecking': 'Memeriksa koneksi…', @@ -18,7 +19,8 @@ const messages: TranslationMap = { 'settings.core.statusUnreachable': 'Tidak dapat menjangkau core', 'settings.core.recheck': 'Periksa ulang', 'settings.core.save': 'Simpan & mulai ulang', - 'settings.core.applyRestartNote': 'Menyimpan akan memulai ulang OpenHuman untuk menyambung kembali.', + 'settings.core.applyRestartNote': + 'Menyimpan akan memulai ulang OpenHuman untuk menyambung kembali.', // Cross-host vault (#4278) 'crossHostVault.title': 'Vault berada di host core.', 'crossHostVault.message': diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 042c722d6a..4d4068734b 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -10,7 +10,8 @@ const messages: TranslationMap = { 'settings.core.title': 'Connessione al core', 'settings.core.menuDesc': 'Usa il core locale integrato o connettiti a un core remoto.', 'settings.core.useRemoteToggle': 'Usa core remoto', - 'settings.core.useRemoteToggleDesc': 'Connettiti a un core remoto via HTTP invece del core locale integrato.', + 'settings.core.useRemoteToggleDesc': + 'Connettiti a un core remoto via HTTP invece del core locale integrato.', 'settings.core.statusConnectedRemote': 'Connesso al core remoto', 'settings.core.statusConnectedLocal': 'Connesso al core locale', 'settings.core.statusChecking': 'Verifica della connessione…', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index d29ed4b64f..4fef934468 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -10,7 +10,8 @@ const messages: TranslationMap = { 'settings.core.title': 'Połączenie z core', 'settings.core.menuDesc': 'Użyj wbudowanego lokalnego core lub połącz się ze zdalnym core.', 'settings.core.useRemoteToggle': 'Użyj zdalnego core', - 'settings.core.useRemoteToggleDesc': 'Połącz się ze zdalnym core przez HTTP zamiast wbudowanego lokalnego core.', + 'settings.core.useRemoteToggleDesc': + 'Połącz się ze zdalnym core przez HTTP zamiast wbudowanego lokalnego core.', 'settings.core.statusConnectedRemote': 'Połączono ze zdalnym core', 'settings.core.statusConnectedLocal': 'Połączono z lokalnym core', 'settings.core.statusChecking': 'Sprawdzanie połączenia…', @@ -18,7 +19,8 @@ const messages: TranslationMap = { 'settings.core.statusUnreachable': 'Nie można połączyć się z core', 'settings.core.recheck': 'Sprawdź ponownie', 'settings.core.save': 'Zapisz i uruchom ponownie', - 'settings.core.applyRestartNote': 'Zapisanie uruchamia ponownie OpenHuman, aby połączyć się ponownie.', + 'settings.core.applyRestartNote': + 'Zapisanie uruchamia ponownie OpenHuman, aby połączyć się ponownie.', // Cross-host vault (#4278) 'crossHostVault.title': 'Skarbiec znajduje się na hoście rdzenia.', 'crossHostVault.message': diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index b982748012..31517f35a5 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -10,7 +10,8 @@ const messages: TranslationMap = { 'settings.core.title': 'Conexão do core', 'settings.core.menuDesc': 'Use o core local integrado ou conecte-se a um core remoto.', 'settings.core.useRemoteToggle': 'Usar core remoto', - 'settings.core.useRemoteToggleDesc': 'Conecte-se a um core remoto por HTTP em vez do core local integrado.', + 'settings.core.useRemoteToggleDesc': + 'Conecte-se a um core remoto por HTTP em vez do core local integrado.', 'settings.core.statusConnectedRemote': 'Conectado ao core remoto', 'settings.core.statusConnectedLocal': 'Conectado ao core local', 'settings.core.statusChecking': 'Verificando a conexão…', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 9df22bff2d..f1880a9427 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -8,9 +8,11 @@ const messages: TranslationMap = { 'Автоматически подключаться к звонкам Google Meet через Recall.ai', // Core connection panel (GH-4396) 'settings.core.title': 'Подключение к ядру', - 'settings.core.menuDesc': 'Используйте встроенное локальное ядро или подключитесь к удалённому ядру.', + 'settings.core.menuDesc': + 'Используйте встроенное локальное ядро или подключитесь к удалённому ядру.', 'settings.core.useRemoteToggle': 'Использовать удалённое ядро', - 'settings.core.useRemoteToggleDesc': 'Подключиться к удалённому ядру по HTTP вместо встроенного локального ядра.', + 'settings.core.useRemoteToggleDesc': + 'Подключиться к удалённому ядру по HTTP вместо встроенного локального ядра.', 'settings.core.statusConnectedRemote': 'Подключено к удалённому ядру', 'settings.core.statusConnectedLocal': 'Подключено к локальному ядру', 'settings.core.statusChecking': 'Проверка подключения…', @@ -18,7 +20,8 @@ const messages: TranslationMap = { 'settings.core.statusUnreachable': 'Не удаётся подключиться к ядру', 'settings.core.recheck': 'Проверить снова', 'settings.core.save': 'Сохранить и перезапустить', - 'settings.core.applyRestartNote': 'После сохранения OpenHuman перезапустится для повторного подключения.', + 'settings.core.applyRestartNote': + 'После сохранения OpenHuman перезапустится для повторного подключения.', // Cross-host vault (#4278) 'crossHostVault.title': 'Хранилище находится на хосте ядра.', 'crossHostVault.message': From bb52215de9924420d40eac6b96e2210be844ec4d Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 3 Jul 2026 21:43:24 +0530 Subject: [PATCH 3/3] =?UTF-8?q?fix(settings):=20address=20CoreConnectionPa?= =?UTF-8?q?nel=20review=20=E2=80=94=20security=20+=20stability?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit review on #4396: - Gate local mode off in non-Tauri web builds: a browser can't start a local core, so the toggle now force-remotes + disables when !isTauriEnvironment(), preventing a persisted core_mode=local that bricks the next boot. - Mask the persisted remote-core token: field is type=password by default with a Show/Hide reveal, so opening Settings no longer exposes the bearer during screen-sharing/shoulder-surfing. - Bound the connection checks: testCoreRpcConnection now runs through a 10s AbortController wrapper (live check + manual Test), so a non-responsive endpoint resolves to "unreachable" instead of hanging in checking/testing. - Reject credentials embedded in the RPC URL (user:pass@host) so a secret can't be persisted + echoed back in the active-URL description. - Recover on restart failure: handleSave wraps restartApp in try/catch and resets `saving` (+ surfaces an error) instead of wedging the Save button. Tests: mock isTauriEnvironment/invoke so desktop-vs-web-build gating is controllable; new test asserts a web build force-disables the local toggle. 12 tests pass; pnpm typecheck clean. --- .../settings/panels/CoreConnectionPanel.tsx | 75 ++++++++++++++++--- .../__tests__/CoreConnectionPanel.test.tsx | 36 +++++++++ 2 files changed, 99 insertions(+), 12 deletions(-) diff --git a/app/src/components/settings/panels/CoreConnectionPanel.tsx b/app/src/components/settings/panels/CoreConnectionPanel.tsx index 03eebf348a..4a2563e09b 100644 --- a/app/src/components/settings/panels/CoreConnectionPanel.tsx +++ b/app/src/components/settings/panels/CoreConnectionPanel.tsx @@ -78,20 +78,44 @@ async function resolveActiveCoreUrl(coreMode: CoreMode): Promise } } +const CONNECTION_TEST_TIMEOUT_MS = 10_000; + +/** + * `testCoreRpcConnection` with a bounded deadline. `testCoreRpcConnection` + * supports an `AbortSignal` but the callers didn't pass one, so a non-responsive + * endpoint left the live status / Test button stuck in `checking`/`testing` + * until the platform socket timeout (minutes). Abort after + * CONNECTION_TEST_TIMEOUT_MS so the UI resolves to "unreachable" promptly. + */ +async function testCoreRpcConnectionWithTimeout(url: string, token?: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), CONNECTION_TEST_TIMEOUT_MS); + try { + return await testCoreRpcConnection(url, token, { signal: controller.signal }); + } finally { + clearTimeout(timer); + } +} + const CoreConnectionPanel = () => { const { t } = useT(); const dispatch = useAppDispatch(); const coreMode = useAppSelector(state => state.coreMode.mode); + // A non-Tauri web build cannot start a local core (no `start_core_process`), + // so the boot picker forces cloud mode there; keep that invariant here so a + // web user can't persist `local` and brick the next boot. + const canUseLocal = isTauriEnvironment(); // ── Editable form state ──────────────────────────────────────────────── // Seeded from the persisted cloud-mode config so the panel reflects the // current setting on open. - const [useRemote, setUseRemote] = useState(coreMode.kind === 'cloud'); + const [useRemote, setUseRemote] = useState(!canUseLocal || coreMode.kind === 'cloud'); const [url, setUrl] = useState(coreMode.kind === 'cloud' ? coreMode.url : ''); const [token, setToken] = useState(coreMode.kind === 'cloud' ? (coreMode.token ?? '') : ''); const [formError, setFormError] = useState(null); const [testStatus, setTestStatus] = useState({ kind: 'idle' }); const [saving, setSaving] = useState(false); + const [showToken, setShowToken] = useState(false); // ── Live status indicator (against the currently-active core) ─────────── const [liveStatus, setLiveStatus] = useState({ kind: 'checking' }); @@ -110,7 +134,7 @@ const CoreConnectionPanel = () => { return; } try { - const response = await testCoreRpcConnection(resolved); + const response = await testCoreRpcConnectionWithTimeout(resolved); if (seq !== checkSeq.current) return; if (response.status === 401 || response.status === 403) { log('runLiveCheck: auth failed (status=%d)', response.status); @@ -161,6 +185,13 @@ const CoreConnectionPanel = () => { setFormError(t('bootCheck.urlMustStartWith')); return null; } + // The separate token field is the only credential path; a + // `user:pass@host` URL would be persisted and echoed back in the active-URL + // description, leaking a secret. Reject it. + if (parsed.username || parsed.password) { + setFormError(t('bootCheck.validUrlRequired')); + return null; + } } catch { setFormError(t('bootCheck.validUrlRequired')); return null; @@ -195,7 +226,7 @@ const CoreConnectionPanel = () => { setTestStatus({ kind: 'testing' }); log('handleTest: url=%s tokenLen=%d', validated.url, validated.token.length); try { - const response = await testCoreRpcConnection(validated.url, validated.token); + const response = await testCoreRpcConnectionWithTimeout(validated.url, validated.token); if (response.status === 401 || response.status === 403) { setTestStatus({ kind: 'auth' }); return; @@ -259,8 +290,16 @@ const CoreConnectionPanel = () => { dispatch(setCoreMode({ kind: 'local' })); } // Restart so BootCheckGate re-runs against the new mode (unchanged - // boot-gate semantics). In dev this is a renderer reload. - await restartApp(); + // boot-gate semantics). In dev this is a renderer reload. The mode is + // already persisted + dispatched above, so on restart failure recover the + // button instead of wedging it in `saving` forever. + try { + await restartApp(); + } catch (err) { + log('handleSave: restartApp failed: %o', err); + setSaving(false); + setFormError(t('common.error')); + } }; // ── Live status rendering ─────────────────────────────────────────────── @@ -332,7 +371,10 @@ const CoreConnectionPanel = () => { { + // Web builds can't run a local core — refuse to switch remote off. + if (!canUseLocal && !next) return; setUseRemote(next); setTestStatus({ kind: 'idle' }); setFormError(null); @@ -368,15 +410,24 @@ const CoreConnectionPanel = () => {
- +
+ + +
({ clearCoreRpcUrlCache: vi.fn(), clearCoreRpcTokenCache: vi.fn(), restartApp: vi.fn(), + // Default to a desktop (Tauri) context so local mode is available; the + // web-build test flips this to false to assert the toggle is gated off. + isTauriEnvironment: vi.fn(() => true), + invoke: vi.fn(async () => 'http://127.0.0.1:7788/rpc'), })); vi.mock('../../../../services/coreRpcClient', () => ({ @@ -25,6 +29,15 @@ vi.mock('../../../../services/coreRpcClient', () => ({ vi.mock('../../../../utils/tauriCommands/core', () => ({ restartApp: hoisted.restartApp })); +vi.mock('@tauri-apps/api/core', () => ({ invoke: hoisted.invoke })); + +// Keep the real configPersistence (storeCoreMode/localStorage etc.) but control +// isTauriEnvironment so the desktop-vs-web-build gating is testable. +vi.mock('../../../../utils/configPersistence', async importOriginal => { + const actual = await importOriginal(); + return { ...actual, isTauriEnvironment: hoisted.isTauriEnvironment }; +}); + function okResponse() { return { ok: true, status: 200, json: async () => ({ jsonrpc: '2.0', id: 1, result: {} }) }; } @@ -51,6 +64,10 @@ describe('CoreConnectionPanel', () => { hoisted.clearCoreRpcTokenCache.mockReset(); hoisted.restartApp.mockReset(); hoisted.restartApp.mockResolvedValue(undefined); + hoisted.isTauriEnvironment.mockReset(); + hoisted.isTauriEnvironment.mockReturnValue(true); + hoisted.invoke.mockReset(); + hoisted.invoke.mockResolvedValue('http://127.0.0.1:7788/rpc'); localStorage.clear(); }); @@ -204,6 +221,25 @@ describe('CoreConnectionPanel', () => { ); }); + test('web build (non-Tauri) forces remote and disables the local toggle', async () => { + hoisted.isTauriEnvironment.mockReturnValue(false); + hoisted.testCoreRpcConnection.mockResolvedValue(okResponse()); + const Panel = await importPanel(); + renderWithProviders(, { + preloadedState: { + coreMode: { + mode: { kind: 'cloud', url: 'https://core.example.com/rpc', token: 'tok-123456' }, + }, + }, + }); + + await waitFor(() => expect(screen.getByText('Connected to remote core')).toBeInTheDocument()); + // A browser can't start a local core, so the toggle is forced on + disabled. + const toggle = screen.getByTestId('core-use-remote-toggle'); + expect(toggle).toBeDisabled(); + expect(screen.getByDisplayValue('https://core.example.com/rpc')).toBeInTheDocument(); + }); + test('switching from remote back to local clears persistence, dispatches, and restarts', async () => { hoisted.testCoreRpcConnection.mockResolvedValue(okResponse()); // Seed persisted cloud values so we can assert they are cleared.