From 5e95aeed8a97acee5823d73b6dc8e92f04af00fb Mon Sep 17 00:00:00 2001 From: YUHAO-corn Date: Fri, 22 May 2026 12:11:19 +0800 Subject: [PATCH 1/3] fix(app): normalize cloud core RPC URLs --- .../BootCheckGate/BootCheckGate.tsx | 10 ++-- .../__tests__/BootCheckGate.test.tsx | 46 ++++++++++++++++++ .../services/__tests__/coreRpcClient.test.ts | 47 +++++++++++++++++++ app/src/services/coreRpcClient.ts | 15 +++--- app/src/store/coreModeSlice.test.ts | 19 +++++++- app/src/store/coreModeSlice.ts | 3 +- .../utils/__tests__/configPersistence.test.ts | 22 +++++++-- app/src/utils/configPersistence.ts | 22 +++++++-- 8 files changed, 163 insertions(+), 21 deletions(-) diff --git a/app/src/components/BootCheckGate/BootCheckGate.tsx b/app/src/components/BootCheckGate/BootCheckGate.tsx index e531b6919a..4df60a84a7 100644 --- a/app/src/components/BootCheckGate/BootCheckGate.tsx +++ b/app/src/components/BootCheckGate/BootCheckGate.tsx @@ -26,6 +26,7 @@ import { clearStoredCoreMode, clearStoredCoreToken, isLocalOrPrivateNetworkHost, + normalizeRpcUrl, storeCoreMode, storeCoreToken, storeRpcUrl, @@ -122,13 +123,14 @@ function ModePicker({ onConfirm }: PickerProps) { * paths are passed through verbatim without the bearer value. */ const validateInputs = (): { url: string; token: string } | null => { - const trimmedUrl = cloudUrl.trim(); - if (!trimmedUrl) { + const rawUrl = cloudUrl.trim(); + if (!rawUrl) { setUrlError(t('bootCheck.invalidUrl')); return null; } + const normalizedUrl = normalizeRpcUrl(rawUrl); try { - const parsed = new URL(trimmedUrl); + const parsed = new URL(normalizedUrl); if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { setUrlError(t('bootCheck.urlMustStartWith')); return null; @@ -152,7 +154,7 @@ function ModePicker({ onConfirm }: PickerProps) { } setTokenError(null); - return { url: trimmedUrl, token: trimmedToken }; + return { url: normalizedUrl, token: trimmedToken }; }; const handleTestConnection = async () => { diff --git a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx index cb754fa336..973e718068 100644 --- a/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx +++ b/app/src/components/BootCheckGate/__tests__/BootCheckGate.test.tsx @@ -187,6 +187,32 @@ describe('BootCheckGate — picker (unset mode)', () => { ); }); + it('normalizes a cloud core base URL to the /rpc endpoint before continuing', async () => { + mockRunBootCheck.mockResolvedValue({ kind: 'match' }); + + renderGate(); + fireEvent.click(screen.getByText('Run on the Cloud (Complex)')); + fireEvent.change(screen.getByPlaceholderText(/https:\/\/core\.example\.com/), { + target: { value: 'https://example.trycloudflare.com/' }, + }); + fireEvent.change(screen.getByPlaceholderText(/Bearer token/i), { + target: { value: 'tok-1234' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Continue' })); + + await waitFor(() => { + expect(screen.getByTestId('app-content')).toBeInTheDocument(); + }); + expect(mockRunBootCheck).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'cloud', + url: 'https://example.trycloudflare.com/rpc', + token: 'tok-1234', + }), + expect.any(Object) + ); + }); + it('rejects public HTTP cloud URLs', () => { renderGate(); @@ -274,6 +300,26 @@ describe('BootCheckGate — picker test connection', () => { ); }); + it('tests /rpc when the user enters a cloud core base URL', async () => { + mockTestCoreRpcConnection.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ result: { ok: true } }), + } as unknown as Response); + + renderGate(); + fillCloudInputs('https://example.trycloudflare.com/'); + fireEvent.click(screen.getByRole('button', { name: 'Test Connection' })); + + await waitFor(() => { + expect(screen.getByTestId('test-status-ok')).toBeInTheDocument(); + }); + expect(mockTestCoreRpcConnection).toHaveBeenCalledWith( + 'https://example.trycloudflare.com/rpc', + 'tok-abc' + ); + }); + it('shows Auth failed on a 401 response', async () => { mockTestCoreRpcConnection.mockResolvedValue({ ok: false, diff --git a/app/src/services/__tests__/coreRpcClient.test.ts b/app/src/services/__tests__/coreRpcClient.test.ts index 166b367b1e..707c853d56 100644 --- a/app/src/services/__tests__/coreRpcClient.test.ts +++ b/app/src/services/__tests__/coreRpcClient.test.ts @@ -582,6 +582,19 @@ describe('coreRpcClient', () => { }); }); + test('normalizes a supplied core base URL before probing', async () => { + vi.resetModules(); + vi.mocked(isTauri).mockReturnValue(false); + const { testCoreRpcConnection } = await import('../coreRpcClient'); + const fetchMock = vi.mocked(fetch); + fetchMock.mockResolvedValueOnce({ ok: true, status: 200 } as Response); + + await testCoreRpcConnection('https://example.trycloudflare.com/'); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(fetchMock.mock.calls[0][0]).toBe('https://example.trycloudflare.com/rpc'); + }); + test('omits Authorization header when no bearer token is available (non-Tauri)', async () => { vi.resetModules(); vi.mocked(isTauri).mockReturnValue(false); @@ -854,6 +867,11 @@ describe('coreRpcClient — typed errors + auth-expired event', () => { }); describe('getCoreRpcUrl', () => { + const normalizeMockRpcUrl = (url: string) => { + const trimmed = url.replace(/\/+$/, ''); + return trimmed.endsWith('/rpc') ? trimmed : `${trimmed}/rpc`; + }; + // Each test gets a fresh module so module-level caches are cleared beforeEach(() => { vi.resetModules(); @@ -865,6 +883,7 @@ describe('getCoreRpcUrl', () => { vi.doMock('../../utils/configPersistence', () => ({ peekStoredRpcUrl: () => 'http://custom-host:9999/rpc', getStoredCoreToken: () => null, + normalizeRpcUrl: normalizeMockRpcUrl, })); vi.mocked(isTauri).mockReturnValue(false); @@ -873,10 +892,24 @@ describe('getCoreRpcUrl', () => { expect(url).toBe('http://custom-host:9999/rpc'); }); + test('in web mode normalizes a stored core base URL', async () => { + vi.doMock('../../utils/configPersistence', () => ({ + peekStoredRpcUrl: () => 'https://example.trycloudflare.com/', + getStoredCoreToken: () => null, + normalizeRpcUrl: normalizeMockRpcUrl, + })); + vi.mocked(isTauri).mockReturnValue(false); + + const { getCoreRpcUrl: freshGetCoreRpcUrl } = await import('../coreRpcClient'); + const url = await freshGetCoreRpcUrl(); + expect(url).toBe('https://example.trycloudflare.com/rpc'); + }); + test('in web mode returns default CORE_RPC_URL when nothing is stored', async () => { vi.doMock('../../utils/configPersistence', () => ({ peekStoredRpcUrl: () => null, getStoredCoreToken: () => null, + normalizeRpcUrl: normalizeMockRpcUrl, })); vi.mocked(isTauri).mockReturnValue(false); @@ -893,6 +926,7 @@ describe('getCoreRpcUrl', () => { return null; }, getStoredCoreToken: () => null, + normalizeRpcUrl: normalizeMockRpcUrl, })); vi.mocked(isTauri).mockReturnValue(false); @@ -909,6 +943,7 @@ describe('getCoreRpcUrl', () => { vi.doMock('../../utils/configPersistence', () => ({ peekStoredRpcUrl: () => storedValue, getStoredCoreToken: () => null, + normalizeRpcUrl: normalizeMockRpcUrl, })); vi.mocked(isTauri).mockReturnValue(false); @@ -930,6 +965,7 @@ describe('getCoreRpcUrl', () => { vi.doMock('../../utils/configPersistence', () => ({ peekStoredRpcUrl: () => null, getStoredCoreToken: () => null, + normalizeRpcUrl: normalizeMockRpcUrl, })); vi.mocked(isTauri).mockReturnValue(true); vi.mocked(invoke).mockImplementation(async (cmd: string) => { @@ -947,6 +983,7 @@ describe('getCoreRpcUrl', () => { vi.doMock('../../utils/configPersistence', () => ({ peekStoredRpcUrl: () => 'http://stored-override:4444/rpc', getStoredCoreToken: () => null, + normalizeRpcUrl: normalizeMockRpcUrl, })); vi.mocked(isTauri).mockReturnValue(true); vi.mocked(invoke).mockImplementation(async (cmd: string) => { @@ -968,6 +1005,7 @@ describe('getCoreRpcUrl', () => { vi.doMock('../../utils/configPersistence', () => ({ peekStoredRpcUrl: () => 'http://127.0.0.1:7788/rpc', getStoredCoreToken: () => null, + normalizeRpcUrl: normalizeMockRpcUrl, })); vi.mocked(isTauri).mockReturnValue(true); vi.mocked(invoke).mockImplementation(async (cmd: string) => { @@ -987,6 +1025,7 @@ describe('getCoreRpcUrl', () => { vi.doMock('../../utils/configPersistence', () => ({ peekStoredRpcUrl: () => null, getStoredCoreToken: () => null, + normalizeRpcUrl: normalizeMockRpcUrl, })); vi.mocked(isTauri).mockReturnValue(true); vi.mocked(invoke).mockRejectedValue(new Error('invoke failed')); @@ -999,6 +1038,11 @@ describe('getCoreRpcUrl', () => { }); describe('getCoreRpcToken (cloud-mode persistence)', () => { + const normalizeMockRpcUrl = (url: string) => { + const trimmed = url.replace(/\/+$/, ''); + return trimmed.endsWith('/rpc') ? trimmed : `${trimmed}/rpc`; + }; + beforeEach(() => { vi.resetModules(); vi.clearAllMocks(); @@ -1009,6 +1053,7 @@ describe('getCoreRpcToken (cloud-mode persistence)', () => { vi.doMock('../../utils/configPersistence', () => ({ peekStoredRpcUrl: () => 'https://core.example.com/rpc', getStoredCoreToken: () => 'cloud-token-abc', + normalizeRpcUrl: normalizeMockRpcUrl, })); vi.mocked(isTauri).mockReturnValue(true); vi.mocked(invoke).mockImplementation(async (cmd: string) => { @@ -1038,6 +1083,7 @@ describe('getCoreRpcToken (cloud-mode persistence)', () => { vi.doMock('../../utils/configPersistence', () => ({ peekStoredRpcUrl: () => 'https://core.example.com/rpc', getStoredCoreToken: () => storedToken, + normalizeRpcUrl: normalizeMockRpcUrl, })); vi.mocked(isTauri).mockReturnValue(true); const fetchMock = vi.mocked(fetch); @@ -1065,6 +1111,7 @@ describe('getCoreRpcToken (cloud-mode persistence)', () => { vi.doMock('../../utils/configPersistence', () => ({ peekStoredRpcUrl: () => null, getStoredCoreToken: () => null, + normalizeRpcUrl: normalizeMockRpcUrl, })); vi.mocked(isTauri).mockReturnValue(true); vi.mocked(invoke).mockImplementation(async (cmd: string) => { diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index 610b3fecb8..8cd90297f3 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -3,7 +3,7 @@ import debug from 'debug'; import { dispatchLocalAiMethod } from '../lib/ai/localCoreAiMemory'; import { CORE_RPC_TIMEOUT_MS, CORE_RPC_URL } from '../utils/config'; -import { getStoredCoreToken, peekStoredRpcUrl } from '../utils/configPersistence'; +import { getStoredCoreToken, normalizeRpcUrl, peekStoredRpcUrl } from '../utils/configPersistence'; import { sanitizeError } from '../utils/sanitize'; import { isTauri as coreIsTauri } from '../utils/tauriCommands/common'; import { normalizeRpcMethod } from './rpcMethods'; @@ -278,7 +278,7 @@ export async function getCoreRpcUrl(): Promise { // null when nothing is stored, which lets us distinguish "user hasn't // chosen yet" from "user chose a value identical to the default". const storedUrl = peekStoredRpcUrl(); - resolvedCoreRpcUrl = storedUrl ?? CORE_RPC_URL; + resolvedCoreRpcUrl = normalizeRpcUrl(storedUrl ?? CORE_RPC_URL); return resolvedCoreRpcUrl; } @@ -296,8 +296,8 @@ export async function getCoreRpcUrl(): Promise { // cloud mode where no local sidecar is running. const storedUrl = peekStoredRpcUrl(); if (storedUrl) { - resolvedCoreRpcUrl = storedUrl; - return storedUrl; + resolvedCoreRpcUrl = normalizeRpcUrl(storedUrl); + return resolvedCoreRpcUrl; } const url = await invoke('core_rpc_url'); @@ -307,14 +307,14 @@ export async function getCoreRpcUrl(): Promise { fallback: CORE_RPC_URL, }); } - resolvedCoreRpcUrl = trimmed || CORE_RPC_URL; + resolvedCoreRpcUrl = normalizeRpcUrl(trimmed || CORE_RPC_URL); return resolvedCoreRpcUrl || CORE_RPC_URL; } catch (err) { // Tauri invoke failed — fall back to stored URL if any, then the // build-time default. Keep the underlying invoke failure visible so // port mismatches and shell misconfiguration are diagnosable. const storedUrl = peekStoredRpcUrl(); - resolvedCoreRpcUrl = storedUrl ?? CORE_RPC_URL; + resolvedCoreRpcUrl = normalizeRpcUrl(storedUrl ?? CORE_RPC_URL); coreRpcError('core_rpc_url invoke failed; using fallback RPC URL', { fallback: resolvedCoreRpcUrl, usedStoredUrl: Boolean(storedUrl), @@ -397,12 +397,13 @@ export async function testCoreRpcConnection( tokenOverride?: string, init?: { signal?: AbortSignal } ): Promise { + const rpcUrl = normalizeRpcUrl(url); const token = tokenOverride?.trim() || (await getCoreRpcToken()); const headers: Record = { 'Content-Type': 'application/json' }; if (token) { headers.Authorization = `Bearer ${token}`; } - return fetch(url, { + return fetch(rpcUrl, { method: 'POST', headers, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'core.ping', params: {} }), diff --git a/app/src/store/coreModeSlice.test.ts b/app/src/store/coreModeSlice.test.ts index 05b91bb988..c40e7ed6e4 100644 --- a/app/src/store/coreModeSlice.test.ts +++ b/app/src/store/coreModeSlice.test.ts @@ -67,7 +67,10 @@ describe('coreModeSlice — sync-localStorage-derived initial state', () => { it('uses local mode when the E2E default core mode config is local', async () => { localStorage.clear(); vi.resetModules(); - vi.doMock('../utils/config', () => ({ E2E_DEFAULT_CORE_MODE: 'local' })); + vi.doMock('../utils/config', () => ({ + CORE_RPC_URL: 'http://127.0.0.1:7788/rpc', + E2E_DEFAULT_CORE_MODE: 'local', + })); try { const mod = await import('./coreModeSlice'); const state = mod.default(undefined, { type: '@@INIT' }); @@ -100,6 +103,20 @@ describe('coreModeSlice — sync-localStorage-derived initial state', () => { }); }); + it('normalizes restored cloud base URLs to the /rpc endpoint', async () => { + localStorage.clear(); + localStorage.setItem('openhuman_core_mode', 'cloud'); + localStorage.setItem('openhuman_core_rpc_url', 'https://example.trycloudflare.com/'); + localStorage.setItem('openhuman_core_rpc_token', 'tok-abc'); + const mod = await freshImport(); + const state = mod.default(undefined, { type: '@@INIT' }); + expect(state.mode).toEqual({ + kind: 'cloud', + url: 'https://example.trycloudflare.com/rpc', + token: 'tok-abc', + }); + }); + it('falls back to unset when cloud marker exists but URL or token is missing', async () => { localStorage.clear(); localStorage.setItem('openhuman_core_mode', 'cloud'); diff --git a/app/src/store/coreModeSlice.ts b/app/src/store/coreModeSlice.ts index f68a3914d1..8ece391b2c 100644 --- a/app/src/store/coreModeSlice.ts +++ b/app/src/store/coreModeSlice.ts @@ -13,6 +13,7 @@ import { createSlice, type PayloadAction } from '@reduxjs/toolkit'; import { E2E_DEFAULT_CORE_MODE } from '../utils/config'; +import { normalizeRpcUrl } from '../utils/configPersistence'; export type CoreMode = | { kind: 'unset' } @@ -64,7 +65,7 @@ function deriveInitialMode(): CoreMode { if (mode === 'cloud') { const url = localStorage.getItem(RPC_URL_STORAGE_KEY)?.trim(); const token = localStorage.getItem(CORE_TOKEN_STORAGE_KEY)?.trim(); - if (url && token) return { kind: 'cloud', url, token }; + if (url && token) return { kind: 'cloud', url: normalizeRpcUrl(url), token }; } } catch { /* localStorage unavailable — fall through to unset */ diff --git a/app/src/utils/__tests__/configPersistence.test.ts b/app/src/utils/__tests__/configPersistence.test.ts index b1b9b537de..e8ae83a667 100644 --- a/app/src/utils/__tests__/configPersistence.test.ts +++ b/app/src/utils/__tests__/configPersistence.test.ts @@ -142,7 +142,7 @@ describe('configPersistence', () => { it('removes trailing slashes', () => { expect(normalizeRpcUrl('http://localhost:7788/rpc/')).toBe('http://localhost:7788/rpc'); - expect(normalizeRpcUrl('http://localhost:7788/')).toBe('http://localhost:7788'); + expect(normalizeRpcUrl('http://localhost:7788/')).toBe('http://localhost:7788/rpc'); }); it('handles multiple trailing slashes', () => { @@ -258,8 +258,11 @@ describe('configPersistence', () => { }); describe('normalizeRpcUrl — edge cases', () => { - it('does not add /rpc suffix when missing (normalizeRpcUrl only strips, not appends)', () => { - expect(normalizeRpcUrl('http://127.0.0.1:7788')).toBe('http://127.0.0.1:7788'); + it('adds /rpc suffix when given a core base URL', () => { + expect(normalizeRpcUrl('http://127.0.0.1:7788')).toBe('http://127.0.0.1:7788/rpc'); + expect(normalizeRpcUrl('https://example.trycloudflare.com/')).toBe( + 'https://example.trycloudflare.com/rpc' + ); }); it('does not double-add /rpc — leaves existing /rpc alone', () => { @@ -285,6 +288,19 @@ describe('configPersistence', () => { }); describe('storeRpcUrl + getStoredRpcUrl — round-trip', () => { + it('stores normalized base core URLs as RPC endpoints', () => { + storeRpcUrl('https://remote.example.com'); + expect(localStorage.getItem(STORAGE_KEY)).toBe('https://remote.example.com/rpc'); + expect(getStoredRpcUrl()).toBe('https://remote.example.com/rpc'); + expect(peekStoredRpcUrl()).toBe('https://remote.example.com/rpc'); + }); + + it('normalizes previously persisted base core URLs on read', () => { + localStorage.setItem(STORAGE_KEY, 'https://old.example.com/'); + expect(getStoredRpcUrl()).toBe('https://old.example.com/rpc'); + expect(peekStoredRpcUrl()).toBe('https://old.example.com/rpc'); + }); + it('round-trips an HTTPS URL', () => { storeRpcUrl('https://remote.example.com/rpc'); expect(getStoredRpcUrl()).toBe('https://remote.example.com/rpc'); diff --git a/app/src/utils/configPersistence.ts b/app/src/utils/configPersistence.ts index 1e2e62b1cb..1b1bdf58e8 100644 --- a/app/src/utils/configPersistence.ts +++ b/app/src/utils/configPersistence.ts @@ -43,7 +43,7 @@ export function getStoredRpcUrl(): string { try { const stored = localStorage.getItem(RPC_URL_STORAGE_KEY); if (stored && stored.trim().length > 0) { - return stored.trim(); + return normalizeRpcUrl(stored); } } catch { // localStorage might be unavailable in some environments @@ -68,7 +68,7 @@ export function peekStoredRpcUrl(): string | null { try { const stored = localStorage.getItem(RPC_URL_STORAGE_KEY); if (stored && stored.trim().length > 0) { - return stored.trim(); + return normalizeRpcUrl(stored); } } catch { console.warn('[configPersistence] Unable to access localStorage'); @@ -84,8 +84,9 @@ export function peekStoredRpcUrl(): string | null { export function storeRpcUrl(url: string): void { try { if (url && url.trim().length > 0) { - localStorage.setItem(RPC_URL_STORAGE_KEY, url.trim()); - console.debug('[configPersistence] Stored RPC URL:', { url: url.trim() }); + const normalized = normalizeRpcUrl(url); + localStorage.setItem(RPC_URL_STORAGE_KEY, normalized); + console.debug('[configPersistence] Stored RPC URL:', { url: normalized }); } else { // Allow clearing the stored URL to reset to default localStorage.removeItem(RPC_URL_STORAGE_KEY); @@ -174,12 +175,23 @@ export function isAllowedCloudRpcUrl(url: string): boolean { /** * Normalize an RPC URL by trimming whitespace and trailing slashes. + * When the user provides a core base URL with no path, treat it as the + * JSON-RPC endpoint base and append `/rpc`. * * @param url - The URL to normalize * @returns The normalized URL */ export function normalizeRpcUrl(url: string): string { - return url.trim().replace(/\/+$/, ''); + const normalized = url.trim().replace(/\/+$/, ''); + try { + const parsed = new URL(normalized); + if (parsed.pathname === '/' || parsed.pathname === '') { + return `${parsed.origin}/rpc${parsed.search}${parsed.hash}`; + } + } catch { + // Validation reports malformed URLs. Keep this helper side-effect free. + } + return normalized; } /** From b189e2f2843953502b9469961915e2dfbd501e29 Mon Sep 17 00:00:00 2001 From: YUHAO-corn Date: Fri, 22 May 2026 13:44:58 +0800 Subject: [PATCH 2/3] fix(app): add missing German translations --- app/src/lib/i18n/chunks/de-3.ts | 2 ++ app/src/lib/i18n/chunks/de-5.ts | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/app/src/lib/i18n/chunks/de-3.ts b/app/src/lib/i18n/chunks/de-3.ts index 8cbb4e8ae7..e1b209a9b5 100644 --- a/app/src/lib/i18n/chunks/de-3.ts +++ b/app/src/lib/i18n/chunks/de-3.ts @@ -104,6 +104,8 @@ const de3: TranslationMap = { 'subconscious.failed': 'gescheitert', 'subconscious.tickInterval': 'Tick-Intervall', 'subconscious.runNow': 'Jetzt ausführen', + 'subconscious.providerUnavailableTitle': 'Subconscious ist pausiert', + 'subconscious.providerSettings': 'KI-Einstellungen', 'subconscious.approvalNeeded': 'Genehmigung erforderlich', 'subconscious.requiresApproval': 'Erfordert eine Genehmigung', 'subconscious.fixInConnections': 'Fix in Verbindungen', diff --git a/app/src/lib/i18n/chunks/de-5.ts b/app/src/lib/i18n/chunks/de-5.ts index c698c292fd..c0d499bdf2 100644 --- a/app/src/lib/i18n/chunks/de-5.ts +++ b/app/src/lib/i18n/chunks/de-5.ts @@ -501,6 +501,28 @@ 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 für die 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 verfügbar sind, wenn openhuman-core mcp läuft', + 'settings.mcpServer.configSectionTitle': 'Client-Konfiguration', + 'settings.mcpServer.configSectionDesc': + 'Wähle deinen MCP-Client aus, um den passenden Konfigurationsausschnitt zu erzeugen', + 'settings.mcpServer.copySnippet': 'In die Zwischenablage kopieren', + 'settings.mcpServer.copied': 'Kopiert!', + 'settings.mcpServer.openConfigFile': 'Konfigurationsdatei öffnen', + 'settings.mcpServer.binaryPathNotFound': + 'OpenHuman-Binary nicht gefunden. Wenn du aus dem Quellcode ausführst, baue es 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; From a89c2076f5e0544b71742ade27300231115bf48e Mon Sep 17 00:00:00 2001 From: YUHAO-corn Date: Fri, 22 May 2026 13:58:05 +0800 Subject: [PATCH 3/3] fix(app): address RPC URL review feedback --- app/src/services/coreRpcClient.ts | 3 +- .../utils/__tests__/configPersistence.test.ts | 23 +++++++++++ app/src/utils/configPersistence.ts | 39 +++++++++++++++---- app/src/utils/redactRpcUrlForLog.ts | 12 ++++++ 4 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 app/src/utils/redactRpcUrlForLog.ts diff --git a/app/src/services/coreRpcClient.ts b/app/src/services/coreRpcClient.ts index 8cd90297f3..647c77cf17 100644 --- a/app/src/services/coreRpcClient.ts +++ b/app/src/services/coreRpcClient.ts @@ -4,6 +4,7 @@ import debug from 'debug'; import { dispatchLocalAiMethod } from '../lib/ai/localCoreAiMemory'; import { CORE_RPC_TIMEOUT_MS, CORE_RPC_URL } from '../utils/config'; import { getStoredCoreToken, normalizeRpcUrl, peekStoredRpcUrl } from '../utils/configPersistence'; +import { redactRpcUrlForLog } from '../utils/redactRpcUrlForLog'; import { sanitizeError } from '../utils/sanitize'; import { isTauri as coreIsTauri } from '../utils/tauriCommands/common'; import { normalizeRpcMethod } from './rpcMethods'; @@ -316,7 +317,7 @@ export async function getCoreRpcUrl(): Promise { const storedUrl = peekStoredRpcUrl(); resolvedCoreRpcUrl = normalizeRpcUrl(storedUrl ?? CORE_RPC_URL); coreRpcError('core_rpc_url invoke failed; using fallback RPC URL', { - fallback: resolvedCoreRpcUrl, + fallback: redactRpcUrlForLog(resolvedCoreRpcUrl), usedStoredUrl: Boolean(storedUrl), error: sanitizeError(err), }); diff --git a/app/src/utils/__tests__/configPersistence.test.ts b/app/src/utils/__tests__/configPersistence.test.ts index e8ae83a667..7354e9a12b 100644 --- a/app/src/utils/__tests__/configPersistence.test.ts +++ b/app/src/utils/__tests__/configPersistence.test.ts @@ -17,6 +17,7 @@ import { isValidRpcUrl, normalizeRpcUrl, peekStoredRpcUrl, + redactRpcUrlForLog, storeCoreMode, storeCoreToken, storeRpcUrl, @@ -152,6 +153,28 @@ describe('configPersistence', () => { it('preserves URL without trailing slash', () => { expect(normalizeRpcUrl('http://localhost:7788/rpc')).toBe('http://localhost:7788/rpc'); }); + + it('preserves query and hash values when normalizing paths', () => { + expect(normalizeRpcUrl('https://host.example?next=/')).toBe( + 'https://host.example/rpc?next=/' + ); + expect(normalizeRpcUrl('https://host.example/#/')).toBe('https://host.example/rpc#/'); + expect(normalizeRpcUrl('https://host.example/rpc/?next=/#/')).toBe( + 'https://host.example/rpc?next=/#/' + ); + }); + }); + + describe('redactRpcUrlForLog', () => { + it('removes credentials, query, and hash values before logging', () => { + expect(redactRpcUrlForLog('https://user:pass@host.example/rpc?token=secret#/token')).toBe( + 'https://host.example/rpc' + ); + }); + + it('returns a sentinel for malformed URLs', () => { + expect(redactRpcUrlForLog('not a url')).toBe('[invalid-url]'); + }); }); describe('getDefaultRpcUrl', () => { diff --git a/app/src/utils/configPersistence.ts b/app/src/utils/configPersistence.ts index 1b1bdf58e8..3023cf0079 100644 --- a/app/src/utils/configPersistence.ts +++ b/app/src/utils/configPersistence.ts @@ -4,9 +4,16 @@ * Handles storing/retrieving user preferences like RPC URL using * localStorage (web) or Tauri store (desktop). */ +import debug from 'debug'; + import { CORE_RPC_URL, E2E_DEFAULT_CORE_MODE } from './config'; +import { redactRpcUrlForLog } from './redactRpcUrlForLog'; import { isTauri } from './tauriCommands'; +export { redactRpcUrlForLog } from './redactRpcUrlForLog'; + +const log = debug('config-persistence'); + // Storage key for RPC URL preference const RPC_URL_STORAGE_KEY = 'openhuman_core_rpc_url'; @@ -86,7 +93,7 @@ export function storeRpcUrl(url: string): void { if (url && url.trim().length > 0) { const normalized = normalizeRpcUrl(url); localStorage.setItem(RPC_URL_STORAGE_KEY, normalized); - console.debug('[configPersistence] Stored RPC URL:', { url: normalized }); + log('Stored RPC URL: %s', redactRpcUrlForLog(normalized)); } else { // Allow clearing the stored URL to reset to default localStorage.removeItem(RPC_URL_STORAGE_KEY); @@ -182,16 +189,34 @@ export function isAllowedCloudRpcUrl(url: string): boolean { * @returns The normalized URL */ export function normalizeRpcUrl(url: string): string { - const normalized = url.trim().replace(/\/+$/, ''); + const trimmed = url.trim(); try { - const parsed = new URL(normalized); - if (parsed.pathname === '/' || parsed.pathname === '') { - return `${parsed.origin}/rpc${parsed.search}${parsed.hash}`; - } + // Parse before trimming path slashes so query/hash values such as ?next=/ + // or #/ stay byte-for-byte intact. + new URL(trimmed); + + const suffixStart = firstUrlSuffixIndex(trimmed); + const base = suffixStart === -1 ? trimmed : trimmed.slice(0, suffixStart); + const suffix = suffixStart === -1 ? '' : trimmed.slice(suffixStart); + const pathStart = base.indexOf('/', base.indexOf('://') + 3); + const origin = pathStart === -1 ? base : base.slice(0, pathStart); + const path = pathStart === -1 ? '' : base.slice(pathStart); + const pathWithoutTrailingSlashes = path.replace(/\/+$/, ''); + const normalizedPath = pathWithoutTrailingSlashes || '/rpc'; + + return `${origin}${normalizedPath}${suffix}`; } catch { // Validation reports malformed URLs. Keep this helper side-effect free. } - return normalized; + return trimmed.replace(/\/+$/, ''); +} + +function firstUrlSuffixIndex(url: string): number { + const searchIndex = url.indexOf('?'); + const hashIndex = url.indexOf('#'); + if (searchIndex === -1) return hashIndex; + if (hashIndex === -1) return searchIndex; + return Math.min(searchIndex, hashIndex); } /** diff --git a/app/src/utils/redactRpcUrlForLog.ts b/app/src/utils/redactRpcUrlForLog.ts new file mode 100644 index 0000000000..53da709abc --- /dev/null +++ b/app/src/utils/redactRpcUrlForLog.ts @@ -0,0 +1,12 @@ +export function redactRpcUrlForLog(url: string): string { + try { + const parsed = new URL(url); + parsed.username = ''; + parsed.password = ''; + parsed.search = ''; + parsed.hash = ''; + return parsed.origin + parsed.pathname; + } catch { + return '[invalid-url]'; + } +}