From c18adc90c33b45577422de4929edb9034e854941 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 18 Feb 2026 04:18:57 +0700 Subject: [PATCH] fix(ui): harden cliproxy panel and proxy edge handling - make iframe auto-login/reload flow race-safe and lint-compliant - normalize API base joins and replace conflict sentinels with typed errors - tighten proxy port validation and defer cursor preset apply until models load - add ui api-client unit coverage for path and conflict helpers --- tests/unit/ui-api-client.test.ts | 32 +++++++ .../cliproxy/control-panel-embed.tsx | 94 ++++++++++--------- .../config-form/use-copilot-config-form.ts | 3 +- ui/src/hooks/use-copilot.ts | 4 +- ui/src/hooks/use-cursor.ts | 4 +- ui/src/lib/api-client.ts | 76 ++++++++++++++- ui/src/pages/cursor.tsx | 19 +++- .../pages/settings/sections/proxy/index.tsx | 37 ++++++-- .../sections/proxy/local-proxy-card.tsx | 5 +- 9 files changed, 210 insertions(+), 64 deletions(-) create mode 100644 tests/unit/ui-api-client.test.ts diff --git a/tests/unit/ui-api-client.test.ts b/tests/unit/ui-api-client.test.ts new file mode 100644 index 00000000..c5edc307 --- /dev/null +++ b/tests/unit/ui-api-client.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'bun:test'; +import { + API_BASE_URL, + API_CONFLICT_ERROR_CODE, + ApiConflictError, + isApiConflictError, + withApiBase, +} from '../../ui/src/lib/api-client'; + +describe('ui api-client helpers', () => { + it('normalizes relative paths with API base prefix', () => { + expect(withApiBase('/cliproxy/status')).toBe('/api/cliproxy/status'); + expect(withApiBase('cliproxy/status')).toBe('/api/cliproxy/status'); + }); + + it('preserves paths that already include API base', () => { + expect(withApiBase('/api/cliproxy/status')).toBe('/api/cliproxy/status'); + expect(withApiBase('/api')).toBe('/api'); + }); + + it('handles empty and absolute URLs safely', () => { + expect(withApiBase('')).toBe(API_BASE_URL); + expect(withApiBase('https://example.com/api')).toBe('https://example.com/api'); + }); + + it('identifies typed API conflict errors', () => { + const conflict = new ApiConflictError('conflict'); + expect(conflict.code).toBe(API_CONFLICT_ERROR_CODE); + expect(isApiConflictError(conflict)).toBe(true); + expect(isApiConflictError(new Error('plain'))).toBe(false); + }); +}); diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx index e29ea502..a92125b0 100644 --- a/ui/src/components/cliproxy/control-panel-embed.tsx +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -9,7 +9,7 @@ import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { RefreshCw, AlertCircle, Key, X, Gauge, Globe, Settings } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; -import { api } from '@/lib/api-client'; +import { api, withApiBase } from '@/lib/api-client'; import type { CliproxyServerConfig } from '@/lib/api-client'; import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; @@ -24,7 +24,8 @@ interface ControlPanelEmbedProps { export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanelEmbedProps) { const iframeRef = useRef(null); - const [isLoading, setIsLoading] = useState(true); + const [loadedUrl, setLoadedUrl] = useState(null); + const [iframeRevision, setIframeRevision] = useState(0); const [error, setError] = useState(null); const [isConnected, setIsConnected] = useState(false); const [showLoginHint, setShowLoginHint] = useState(true); @@ -40,7 +41,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel const { data: authTokens } = useQuery({ queryKey: ['auth-tokens-raw'], queryFn: async () => { - const response = await fetch('/api/settings/auth/tokens/raw'); + const response = await fetch(withApiBase('/settings/auth/tokens/raw')); if (!response.ok) throw new Error('Failed to fetch auth tokens'); return response.json(); }, @@ -60,8 +61,8 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel if (remote?.enabled && remote?.host) { const protocol = remote.protocol || 'http'; - // Use port from config, or default based on protocol (443 for https, 80 for http) - const remotePort = remote.port || (protocol === 'https' ? 443 : 80); + // Use port from config, or default based on protocol (443 for https, 8317 for http) + const remotePort = remote.port || (protocol === 'https' ? 443 : CLIPROXY_DEFAULT_PORT); // Only include port in URL if it's non-standard const portSuffix = (protocol === 'https' && remotePort === 443) || (protocol === 'http' && remotePort === 80) @@ -89,6 +90,9 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel }; }, [cliproxyConfig, authTokens, port]); + const iframeLoaded = loadedUrl === managementUrl; + const isLoading = !iframeLoaded; + // Check if CLIProxy is running useEffect(() => { const controller = new AbortController(); @@ -130,48 +134,53 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel return () => controller.abort(); }, [checkUrl, isRemote, displayHost]); - // Handle iframe load - attempt to auto-login via postMessage - const handleIframeLoad = useCallback(() => { - setIsLoading(false); - - // Try to inject credentials via postMessage - // The management.html needs to listen for this message - // If it doesn't support it, user will see the login page - if (iframeRef.current?.contentWindow && authToken) { - try { - // Derive apiBase from checkUrl (remove trailing slash) - const apiBase = checkUrl.replace(/\/$/, ''); - - // Security: Validate iframe src matches target origin before sending credentials - const iframeSrc = iframeRef.current.src; - if (!iframeSrc.startsWith(apiBase)) { - console.warn('[ControlPanelEmbed] Iframe origin mismatch, skipping postMessage'); - return; - } - - // Send credentials to iframe - iframeRef.current.contentWindow.postMessage( - { - type: 'ccs-auto-login', - apiBase, - managementKey: authToken, - }, - apiBase - ); - } catch (e) { - // Cross-origin restriction - expected if not same origin - console.debug('[ControlPanelEmbed] postMessage failed - cross-origin:', e); - } + const postAutoLoginCredentials = useCallback(() => { + // Auto-login can only run when iframe has loaded and authToken is available. + if (!iframeLoaded || !iframeRef.current?.contentWindow || !authToken) { + return; } - }, [checkUrl, authToken]); + + try { + // Derive apiBase from checkUrl (remove trailing slash) + const apiBase = checkUrl.replace(/\/$/, ''); + + // Security: Validate iframe src matches target origin before sending credentials + const iframeSrc = iframeRef.current.src; + if (!iframeSrc.startsWith(apiBase)) { + console.warn('[ControlPanelEmbed] Iframe origin mismatch, skipping postMessage'); + return; + } + + // Send credentials to iframe + iframeRef.current.contentWindow.postMessage( + { + type: 'ccs-auto-login', + apiBase, + managementKey: authToken, + }, + apiBase + ); + } catch (e) { + // Cross-origin restriction - expected if not same origin + console.debug('[ControlPanelEmbed] postMessage failed - cross-origin:', e); + } + }, [authToken, checkUrl, iframeLoaded]); + + // Retry auto-login when token/checkUrl arrive after iframe onLoad. + useEffect(() => { + postAutoLoginCredentials(); + }, [postAutoLoginCredentials]); + + // Handle iframe load - mark ready then let effect post credentials. + const handleIframeLoad = useCallback(() => { + setLoadedUrl(managementUrl); + }, [managementUrl]); const handleRefresh = () => { - setIsLoading(true); + setLoadedUrl(null); + setIframeRevision((value) => value + 1); setError(null); setIsConnected(false); - if (iframeRef.current) { - iframeRef.current.src = managementUrl; - } }; // Show error state if CLIProxy is not running @@ -264,6 +273,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel {/* Iframe */}