diff --git a/desktop/src/api/client.test.ts b/desktop/src/api/client.test.ts index 35a4c3d1..7db7116e 100644 --- a/desktop/src/api/client.test.ts +++ b/desktop/src/api/client.test.ts @@ -1,11 +1,51 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { api, rawRecordDiagnosticEvent } from './client' +import { + api, + getDefaultBaseUrl, + rawRecordDiagnosticEvent, + setAuthToken, + setBaseUrl, +} from './client' describe('api diagnostics reporting', () => { afterEach(() => { + setAuthToken(null) + setBaseUrl(getDefaultBaseUrl()) vi.restoreAllMocks() }) + it('does not send Authorization for default local requests', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + + await api.get('/api/status') + + const [, init] = fetchMock.mock.calls[0]! + expect((init as RequestInit).headers).toMatchObject({ + 'Content-Type': 'application/json', + }) + expect((init as RequestInit & { headers?: Record }).headers?.Authorization).toBeUndefined() + }) + + it('adds Authorization when an H5 token is configured', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + + setAuthToken('h5_x') + await api.get('/api/status') + + const [, init] = fetchMock.mock.calls[0]! + expect((init as RequestInit & { headers?: Record }).headers).toMatchObject({ + Authorization: 'Bearer h5_x', + }) + }) + it('reports non-diagnostics API failures without request bodies', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock @@ -31,6 +71,27 @@ describe('api diagnostics reporting', () => { expect(JSON.stringify(body)).not.toContain('sk-should-not-report') }) + it('does not leak the H5 token in diagnostics payloads', async () => { + const fetchMock = vi.spyOn(globalThis, 'fetch') + fetchMock + .mockResolvedValueOnce(new Response(JSON.stringify({ message: 'Unauthorized' }), { + status: 401, + headers: { 'Content-Type': 'application/json' }, + })) + .mockResolvedValueOnce(new Response(JSON.stringify({ ok: true }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + })) + + setAuthToken('h5_super_secret') + + await expect(api.get('/api/status')).rejects.toThrow('Unauthorized') + + const [, diagnosticInit] = fetchMock.mock.calls[1]! + const body = JSON.parse(String((diagnosticInit as RequestInit).body)) + expect(JSON.stringify(body)).not.toContain('h5_super_secret') + }) + it('does not recursively report diagnostics endpoint failures', async () => { const fetchMock = vi.spyOn(globalThis, 'fetch') fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({ message: 'diagnostics down' }), { diff --git a/desktop/src/api/client.ts b/desktop/src/api/client.ts index 9d5c8207..efa1e090 100644 --- a/desktop/src/api/client.ts +++ b/desktop/src/api/client.ts @@ -8,6 +8,7 @@ const ENV_BASE_URL = const DEFAULT_BASE_URL = ENV_BASE_URL || 'http://127.0.0.1:3456' let baseUrl = DEFAULT_BASE_URL +let authToken: string | null = null const DIAGNOSTICS_PATH = '/api/diagnostics/events' function getErrorMessage(status: number, body: unknown) { @@ -30,6 +31,15 @@ export function getBaseUrl() { return baseUrl } +export function setAuthToken(token: string | null) { + const trimmed = token?.trim() ?? '' + authToken = trimmed.length > 0 ? trimmed : null +} + +export function getAuthToken() { + return authToken +} + export function getDefaultBaseUrl() { return DEFAULT_BASE_URL } @@ -46,9 +56,7 @@ export class ApiError extends Error { async function request(method: string, path: string, body?: unknown, options?: { timeout?: number }): Promise { const url = `${baseUrl}${path}` - const headers: Record = { - 'Content-Type': 'application/json', - } + const headers = buildHeaders() const controller = new AbortController() const timeoutMs = options?.timeout ?? 30_000 @@ -88,12 +96,12 @@ function reportApiFailure(method: string, path: string, error: unknown) { method, path, errorName: error instanceof Error ? error.name : typeof error, - message: error instanceof Error ? error.message : String(error), + message: sanitizeDiagnosticValue(error instanceof Error ? error.message : String(error)), } if (error instanceof ApiError) { details.status = error.status - details.response = error.body + details.response = sanitizeDiagnosticValue(error.body) } void rawRecordDiagnosticEvent({ @@ -113,11 +121,43 @@ export function rawRecordDiagnosticEvent(event: { }) { return fetch(`${baseUrl}${DIAGNOSTICS_PATH}`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: buildHeaders(), body: JSON.stringify(event), }).catch(() => undefined) } +function buildHeaders(): Record { + const headers: Record = { + 'Content-Type': 'application/json', + } + + if (authToken) { + headers.Authorization = `Bearer ${authToken}` + } + + return headers +} + +function sanitizeDiagnosticValue(value: unknown): unknown { + if (!authToken) return value + + if (typeof value === 'string') { + return value.split(authToken).join('[redacted]') + } + + if (Array.isArray(value)) { + return value.map((entry) => sanitizeDiagnosticValue(entry)) + } + + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, sanitizeDiagnosticValue(entry)]), + ) + } + + return value +} + export const api = { get: (path: string, options?: { timeout?: number }) => request('GET', path, undefined, options), post: (path: string, body?: unknown, options?: { timeout?: number }) => request('POST', path, body, options), diff --git a/desktop/src/api/websocket.test.ts b/desktop/src/api/websocket.test.ts index 7481319e..8cbead42 100644 --- a/desktop/src/api/websocket.test.ts +++ b/desktop/src/api/websocket.test.ts @@ -1,10 +1,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -vi.mock('./client', () => ({ - getBaseUrl: () => 'http://127.0.0.1:3456', +const clientMocks = vi.hoisted(() => ({ + baseUrl: 'http://127.0.0.1:3456', + authToken: null as string | null, })) -import { wsManager } from './websocket' +vi.mock('./client', () => ({ + getBaseUrl: () => clientMocks.baseUrl, + getAuthToken: () => clientMocks.authToken, +})) + +import { buildSessionWebSocketUrl, wsManager } from './websocket' type SocketHandler = (() => void) | ((event: { data: string }) => void) @@ -53,6 +59,8 @@ describe('wsManager reconnect buffering', () => { beforeEach(() => { vi.useFakeTimers() + clientMocks.baseUrl = 'http://127.0.0.1:3456' + clientMocks.authToken = null FakeWebSocket.instances = [] globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket wsManager.disconnectAll() @@ -89,4 +97,21 @@ describe('wsManager reconnect buffering', () => { JSON.stringify({ type: 'user_message', content: 'queued while offline' }), ]) }) + + it('builds websocket URLs from http and encodes token query params', () => { + clientMocks.baseUrl = 'http://10.0.0.2:3456' + clientMocks.authToken = 'h5 token/with?chars' + + expect(buildSessionWebSocketUrl('session-reconnect')).toBe( + 'ws://10.0.0.2:3456/ws/session-reconnect?token=h5+token%2Fwith%3Fchars', + ) + }) + + it('upgrades https backends to wss', () => { + clientMocks.baseUrl = 'https://remote.example.com' + + expect(buildSessionWebSocketUrl('secure-session')).toBe( + 'wss://remote.example.com/ws/secure-session', + ) + }) }) diff --git a/desktop/src/api/websocket.ts b/desktop/src/api/websocket.ts index 2e10622b..aff11dbc 100644 --- a/desktop/src/api/websocket.ts +++ b/desktop/src/api/websocket.ts @@ -1,5 +1,5 @@ import type { ClientMessage, ServerMessage } from '../types/chat' -import { getBaseUrl } from './client' +import { getAuthToken, getBaseUrl } from './client' type MessageHandler = (msg: ServerMessage) => void @@ -39,8 +39,7 @@ class WebSocketManager { return } - const wsUrl = getBaseUrl().replace(/^http/, 'ws') - const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`) + const ws = new WebSocket(buildSessionWebSocketUrl(sessionId)) const conn: Connection = { ws, @@ -178,4 +177,19 @@ class WebSocketManager { } } +export function buildSessionWebSocketUrl(sessionId: string) { + const url = new URL(getBaseUrl()) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + url.pathname = `/ws/${encodeURIComponent(sessionId)}` + + const token = getAuthToken() + if (token) { + url.searchParams.set('token', token) + } else { + url.searchParams.delete('token') + } + + return url.toString() +} + export const wsManager = new WebSocketManager() diff --git a/desktop/src/components/layout/AppShell.test.tsx b/desktop/src/components/layout/AppShell.test.tsx index 171c1a45..ded1fee4 100644 --- a/desktop/src/components/layout/AppShell.test.tsx +++ b/desktop/src/components/layout/AppShell.test.tsx @@ -1,9 +1,10 @@ -import { render, screen, waitFor } from '@testing-library/react' +import { fireEvent, render, screen, waitFor } from '@testing-library/react' import '@testing-library/jest-dom' import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ initializeDesktopServerUrl: vi.fn(), + isTauriRuntime: false, fetchAll: vi.fn(), restoreTabs: vi.fn(), connectToSession: vi.fn(), @@ -15,7 +16,9 @@ const mocks = vi.hoisted(() => ({ vi.mock('../../lib/desktopRuntime', () => ({ initializeDesktopServerUrl: mocks.initializeDesktopServerUrl, - isTauriRuntime: () => false, + isTauriRuntime: () => mocks.isTauriRuntime, + isH5ConnectionRequiredError: (error: unknown) => + error instanceof Error && error.name === 'H5ConnectionRequiredError', })) vi.mock('../../stores/settingsStore', () => ({ @@ -68,6 +71,16 @@ vi.mock('./TabBar', () => ({ TabBar: () => , })) +vi.mock('./H5ConnectionView', () => ({ + H5ConnectionView: ({ error, onConnected }: { error?: string | null; onConnected: () => void }) => ( +
+
h5 connection view
+
{error}
+ +
+ ), +})) + vi.mock('../shared/Toast', () => ({ ToastContainer: () => null, })) @@ -81,6 +94,7 @@ import { AppShell } from './AppShell' describe('AppShell boot flow', () => { beforeEach(() => { vi.clearAllMocks() + mocks.isTauriRuntime = false mocks.initializeDesktopServerUrl.mockResolvedValue('http://127.0.0.1:3456') mocks.fetchAll.mockResolvedValue(undefined) mocks.restoreTabs.mockResolvedValue(undefined) @@ -139,4 +153,54 @@ describe('AppShell boot flow', () => { expect(mocks.connectToSession).toHaveBeenCalledWith('session-1') }) }) + + it('shows the H5 connection view in browser mode when startup needs H5 auth', async () => { + mocks.initializeDesktopServerUrl.mockRejectedValueOnce( + Object.assign(new Error('Enter your H5 token to continue.'), { + name: 'H5ConnectionRequiredError', + serverUrl: 'https://remote.example.com', + }), + ) + + render() + + expect(await screen.findByText('h5 connection view')).toBeInTheDocument() + expect(screen.getByText('Enter your H5 token to continue.')).toBeInTheDocument() + expect(screen.queryByText('app.serverFailed')).not.toBeInTheDocument() + }) + + it('retries bootstrap after a successful H5 connection', async () => { + mocks.initializeDesktopServerUrl + .mockRejectedValueOnce( + Object.assign(new Error('The saved H5 token is no longer valid.'), { + name: 'H5ConnectionRequiredError', + serverUrl: 'https://remote.example.com', + }), + ) + .mockResolvedValueOnce('https://remote.example.com') + + render() + + expect(await screen.findByText('h5 connection view')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'retry h5 bootstrap' })) + + await screen.findByText('sidebar loaded') + expect(mocks.initializeDesktopServerUrl).toHaveBeenCalledTimes(2) + expect(mocks.fetchAll).toHaveBeenCalledTimes(1) + }) + + it('keeps the Tauri startup error path unchanged', async () => { + mocks.isTauriRuntime = true + mocks.initializeDesktopServerUrl.mockRejectedValueOnce( + Object.assign(new Error('desktop server startup failed'), { + name: 'H5ConnectionRequiredError', + serverUrl: 'https://remote.example.com', + }), + ) + + render() + + expect(await screen.findByText('app.serverFailed')).toBeInTheDocument() + expect(screen.queryByText('h5 connection view')).not.toBeInTheDocument() + }) }) diff --git a/desktop/src/components/layout/AppShell.tsx b/desktop/src/components/layout/AppShell.tsx index 24be8f26..11147d9a 100644 --- a/desktop/src/components/layout/AppShell.tsx +++ b/desktop/src/components/layout/AppShell.tsx @@ -6,24 +6,39 @@ import { UpdateChecker } from '../shared/UpdateChecker' import { useSettingsStore } from '../../stores/settingsStore' import { useUIStore, type SettingsTab } from '../../stores/uiStore' import { useKeyboardShortcuts } from '../../hooks/useKeyboardShortcuts' -import { initializeDesktopServerUrl, isTauriRuntime } from '../../lib/desktopRuntime' +import { + H5ConnectionRequiredError, + initializeDesktopServerUrl, + isH5ConnectionRequiredError, + isTauriRuntime, +} from '../../lib/desktopRuntime' import { TabBar } from './TabBar' import { StartupErrorView } from './StartupErrorView' import { useTabStore, SETTINGS_TAB_ID } from '../../stores/tabStore' import { useChatStore } from '../../stores/chatStore' import { useTranslation } from '../../i18n' +import { H5ConnectionView } from './H5ConnectionView' export function AppShell() { const fetchSettings = useSettingsStore((s) => s.fetchAll) const sidebarOpen = useUIStore((s) => s.sidebarOpen) const [ready, setReady] = useState(false) const [startupError, setStartupError] = useState(null) + const [h5StartupError, setH5StartupError] = useState(null) + const [bootstrapNonce, setBootstrapNonce] = useState(0) const t = useTranslation() + const tauriRuntime = isTauriRuntime() useEffect(() => { let cancelled = false const bootstrap = async () => { + if (!cancelled) { + setReady(false) + setStartupError(null) + setH5StartupError(null) + } + try { await initializeDesktopServerUrl() await fetchSettings() @@ -43,7 +58,13 @@ export function AppShell() { })().catch(() => {}) } catch (error) { if (!cancelled) { - setStartupError(error instanceof Error ? error.message : String(error)) + if (!tauriRuntime && isH5ConnectionRequiredError(error)) { + setH5StartupError(error) + setStartupError(null) + } else { + setStartupError(error instanceof Error ? error.message : String(error)) + setH5StartupError(null) + } setReady(false) } } @@ -54,11 +75,11 @@ export function AppShell() { return () => { cancelled = true } - }, [fetchSettings]) + }, [bootstrapNonce, fetchSettings, tauriRuntime]) // Listen for macOS native menu navigation events (About / Settings) useEffect(() => { - if (!isTauriRuntime()) return + if (!tauriRuntime) return let unlisten: (() => void) | undefined import('@tauri-apps/api/event') .then(({ listen }) => @@ -77,6 +98,16 @@ export function AppShell() { useKeyboardShortcuts() + if (!tauriRuntime && h5StartupError) { + return ( + setBootstrapNonce((value) => value + 1)} + /> + ) + } + if (startupError) { return } diff --git a/desktop/src/components/layout/H5ConnectionView.tsx b/desktop/src/components/layout/H5ConnectionView.tsx new file mode 100644 index 00000000..55b17164 --- /dev/null +++ b/desktop/src/components/layout/H5ConnectionView.tsx @@ -0,0 +1,84 @@ +import type { FormEvent } from 'react' +import { useState } from 'react' +import { saveAndVerifyH5Connection } from '../../lib/desktopRuntime' +import { Button } from '../shared/Button' +import { Input } from '../shared/Input' + +type H5ConnectionViewProps = { + initialServerUrl?: string | null + error?: string | null + onConnected: () => void +} + +export function H5ConnectionView({ + initialServerUrl, + error: initialError, + onConnected, +}: H5ConnectionViewProps) { + const [serverUrl, setServerUrl] = useState(initialServerUrl ?? '') + const [token, setToken] = useState('') + const [error, setError] = useState(initialError ?? '') + const [submitting, setSubmitting] = useState(false) + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault() + setSubmitting(true) + setError('') + + try { + await saveAndVerifyH5Connection(serverUrl, token) + onConnected() + } catch (submitError) { + setError( + submitError instanceof Error ? submitError.message : 'Unable to connect to the H5 server.', + ) + } finally { + setSubmitting(false) + } + } + + return ( +
+
+
+

+ Connect to H5 Access +

+

+ Enter the server URL and H5 access token from the desktop app. +

+
+ +
+ setServerUrl(event.target.value)} + autoComplete="url" + required + /> + setToken(event.target.value)} + autoComplete="current-password" + required + /> + + {error ? ( +
+ {error} +
+ ) : null} + + +
+
+
+ ) +} diff --git a/desktop/src/lib/desktopRuntime.ts b/desktop/src/lib/desktopRuntime.ts index 630aec95..10c5c48a 100644 --- a/desktop/src/lib/desktopRuntime.ts +++ b/desktop/src/lib/desktopRuntime.ts @@ -1,28 +1,115 @@ -import { getDefaultBaseUrl, setBaseUrl } from '../api/client' +import { api, getDefaultBaseUrl, setAuthToken, setBaseUrl } from '../api/client' + +export const H5_SERVER_URL_STORAGE_KEY = 'cc-haha-h5-server-url' +export const H5_TOKEN_STORAGE_KEY = 'cc-haha-h5-token' + +type H5ConnectionFailureReason = 'missing-token' | 'invalid-token' | 'verify-failed' + +export type StoredH5Connection = { + serverUrl: string | null + token: string | null +} + +export class H5ConnectionRequiredError extends Error { + readonly serverUrl: string + readonly reason: H5ConnectionFailureReason + + constructor(message: string, serverUrl: string, reason: H5ConnectionFailureReason) { + super(message) + this.name = 'H5ConnectionRequiredError' + this.serverUrl = serverUrl + this.reason = reason + } +} export function isTauriRuntime() { if (typeof window === 'undefined') return false return '__TAURI_INTERNALS__' in window || '__TAURI__' in window } +export function isBrowserH5Runtime() { + return typeof window !== 'undefined' && !isTauriRuntime() +} + +export function readStoredH5Connection(): StoredH5Connection { + if (typeof window === 'undefined') { + return { serverUrl: null, token: null } + } + + try { + return { + serverUrl: normalizeServerUrl(window.localStorage.getItem(H5_SERVER_URL_STORAGE_KEY)), + token: normalizeToken(window.localStorage.getItem(H5_TOKEN_STORAGE_KEY)), + } + } catch { + return { serverUrl: null, token: null } + } +} + +export function clearStoredH5Connection() { + if (typeof window !== 'undefined') { + try { + window.localStorage.removeItem(H5_SERVER_URL_STORAGE_KEY) + window.localStorage.removeItem(H5_TOKEN_STORAGE_KEY) + } catch { + // Ignore storage failures + } + } + + setAuthToken(null) +} + +export async function saveAndVerifyH5Connection(serverUrl: string, token: string) { + const normalizedServerUrl = normalizeServerUrl(serverUrl) + const normalizedToken = normalizeToken(token) + + if (!normalizedServerUrl) { + throw new Error('Enter a valid server URL.') + } + + if (!normalizedToken) { + throw new Error('Enter your H5 access token.') + } + + setBaseUrl(normalizedServerUrl) + setAuthToken(normalizedToken) + + try { + await waitForHealth(normalizedServerUrl) + await verifyH5Access() + } catch (error) { + setAuthToken(null) + throw normalizeH5VerificationError(error, normalizedServerUrl) + } + + if (typeof window !== 'undefined') { + try { + window.localStorage.setItem(H5_SERVER_URL_STORAGE_KEY, normalizedServerUrl) + window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, normalizedToken) + } catch { + // Ignore storage failures after a successful verification. + } + } + + return normalizedServerUrl +} + +export function isH5ConnectionRequiredError(error: unknown): error is H5ConnectionRequiredError { + return error instanceof H5ConnectionRequiredError +} + export async function initializeDesktopServerUrl() { const fallbackUrl = getDefaultBaseUrl() - const queryUrl = - typeof window !== 'undefined' - ? new URLSearchParams(window.location.search).get('serverUrl') - : null - const requestedUrl = queryUrl?.trim() || fallbackUrl if (!isTauriRuntime()) { - setBaseUrl(requestedUrl) - await waitForHealth(requestedUrl) - return requestedUrl + return initializeBrowserServerUrl(fallbackUrl) } try { const { invoke } = await import('@tauri-apps/api/core') const serverUrl = await invoke('get_server_url') setBaseUrl(serverUrl) + setAuthToken(null) await waitForHealth(serverUrl) return serverUrl } catch (error) { @@ -33,6 +120,42 @@ export async function initializeDesktopServerUrl() { } } +async function initializeBrowserServerUrl(fallbackUrl: string) { + const queryUrl = + typeof window !== 'undefined' + ? new URLSearchParams(window.location.search).get('serverUrl') + : null + const stored = readStoredH5Connection() + const requestedUrl = normalizeServerUrl(queryUrl) ?? stored.serverUrl ?? fallbackUrl + const token = stored.token + + setBaseUrl(requestedUrl) + setAuthToken(token) + await waitForHealth(requestedUrl) + + if (!requiresH5Auth(requestedUrl)) { + return requestedUrl + } + + if (!token) { + clearStoredH5Connection() + throw new H5ConnectionRequiredError( + 'Enter your H5 token to continue.', + requestedUrl, + 'missing-token', + ) + } + + try { + await verifyH5Access() + } catch (error) { + clearStoredH5Connection() + throw normalizeH5VerificationError(error, requestedUrl) + } + + return requestedUrl +} + async function waitForHealth(serverUrl: string) { let lastError: unknown @@ -54,7 +177,55 @@ async function waitForHealth(serverUrl: string) { throw new Error( lastError instanceof Error - ? `Local server healthcheck failed: ${lastError.message}` - : 'Local server healthcheck failed', + ? `Server healthcheck failed: ${lastError.message}` + : 'Server healthcheck failed', + ) +} + +async function verifyH5Access() { + await api.post<{ ok: true }>('/api/h5-access/verify') +} + +function normalizeServerUrl(value: string | null | undefined) { + const trimmed = value?.trim() + if (!trimmed) return null + + try { + return new URL(trimmed).toString().replace(/\/$/, '') + } catch { + return null + } +} + +function normalizeToken(value: string | null | undefined) { + const trimmed = value?.trim() + return trimmed ? trimmed : null +} + +function requiresH5Auth(serverUrl: string) { + try { + const hostname = new URL(serverUrl).hostname + return hostname !== '127.0.0.1' && hostname !== 'localhost' && hostname !== '::1' + } catch { + return false + } +} + +function normalizeH5VerificationError(error: unknown, serverUrl: string) { + if (error instanceof H5ConnectionRequiredError) { + return error + } + + if (error instanceof Error && error.message.startsWith('Server healthcheck failed')) { + return new Error(`Unable to reach ${serverUrl}. Check the server URL or network access.`) + } + + const message = + error instanceof Error ? error.message : 'Unable to verify the H5 access token.' + const unauthorized = message.includes('401') || message.toLowerCase().includes('unauthorized') + return new H5ConnectionRequiredError( + unauthorized ? 'The saved H5 token is no longer valid.' : 'Unable to verify the H5 access token.', + serverUrl, + unauthorized ? 'invalid-token' : 'verify-failed', ) }