From b22830d1f6a172c9637889284df99d3728385ea9 Mon Sep 17 00:00:00 2001 From: Alexander Khrushkov Date: Fri, 19 Jun 2026 19:54:48 +0300 Subject: [PATCH] fix: custom-agent ?url= deep link - normalize scheme + dev SPA fallback Opening an external app via /agents/custom?url= failed two ways. (1) AgentPage called new URL() on the raw param, so a scheme-less host (boxy-run.fly.dev) threw and fell back to the prompt instead of the iframe; it now normalizes the scheme via a shared src/utils/normalizeUrl.ts that the in-app prompt (DesktopLayout) also uses. (2) The dev SPA fallback in vite.config.ts classified requests by the full URL including the query, so a query value with dots (?url=...fly.dev) was treated as a static asset and 404'd; it now strips the query before the asset check. The GitHub Pages 404.html redirect is unchanged (it already preserves the query). --- src/components/desktop/DesktopLayout.tsx | 9 ++++----- src/pages/AgentPage.tsx | 10 +++++++--- src/utils/normalizeUrl.ts | 14 ++++++++++++++ vite.config.ts | 9 +++++++-- 4 files changed, 32 insertions(+), 10 deletions(-) create mode 100644 src/utils/normalizeUrl.ts diff --git a/src/components/desktop/DesktopLayout.tsx b/src/components/desktop/DesktopLayout.tsx index 6b8b54b7..ac24f71c 100644 --- a/src/components/desktop/DesktopLayout.tsx +++ b/src/components/desktop/DesktopLayout.tsx @@ -14,6 +14,7 @@ import { WalletPanel } from '../wallet/WalletPanel'; import { WalletRequiredBlocker } from '../agents/WalletRequiredBlocker'; import { ActivityTicker } from '../activity'; import { Footer } from '../layout/Footer'; +import { normalizeUrl } from '../../utils/normalizeUrl'; const CUSTOM_URL_PRESETS = [ { label: 'Sphere Connect Example', url: 'https://unicity-sphere.github.io/sphere-sdk-connect-example/' }, @@ -45,11 +46,9 @@ export function DesktopLayout() { const handleCustomUrlSubmit = (e: React.FormEvent) => { e.preventDefault(); - let url = customUrlInput.trim(); - if (!url) return; - if (!url.startsWith('http://') && !url.startsWith('https://')) { - url = url.includes('localhost') || url.match(/^\d/) ? `http://${url}` : `https://${url}`; - } + const input = customUrlInput.trim(); + if (!input) return; + const url = normalizeUrl(input); openTab('custom', { url, label: new URL(url).hostname }); navigate(`/agents/custom?url=${encodeURIComponent(url)}`); setCustomUrlInput(''); diff --git a/src/pages/AgentPage.tsx b/src/pages/AgentPage.tsx index b8cd0ac2..7ac8eb8a 100644 --- a/src/pages/AgentPage.tsx +++ b/src/pages/AgentPage.tsx @@ -3,6 +3,7 @@ import { useParams, useSearchParams, Navigate } from 'react-router-dom'; import { DesktopLayout } from '../components/desktop/DesktopLayout'; import { getAgentConfig } from '../config/activities'; import { useDesktopState } from '../hooks/useDesktopState'; +import { normalizeUrl } from '../utils/normalizeUrl'; export function AgentPage() { const { agentId } = useParams<{ agentId: string }>(); @@ -16,11 +17,14 @@ export function AgentPage() { useEffect(() => { if (!agentId) return; - // Custom agent with ?url= parameter — open iframe directly + // Custom agent with ?url= parameter — open iframe directly. + // Normalize the scheme first (a bare host like "boxy-run.fly.dev" would make + // `new URL()` throw, falling back to the prompt) so it matches the in-app + // prompt path, which already prepends a scheme via normalizeUrl. if (agentId === 'custom' && customUrl) { try { - const hostname = new URL(customUrl).hostname; - openTab('custom', { url: customUrl, label: hostname }); + const url = normalizeUrl(customUrl); + openTab('custom', { url, label: new URL(url).hostname }); } catch { // Invalid URL — fall through to open the custom URL prompt openTab(agentId); diff --git a/src/utils/normalizeUrl.ts b/src/utils/normalizeUrl.ts new file mode 100644 index 00000000..4875b8fa --- /dev/null +++ b/src/utils/normalizeUrl.ts @@ -0,0 +1,14 @@ +/** + * Prepend a scheme to a bare host so it can be parsed by `new URL()` and loaded + * in an iframe. localhost / bare IPs default to http, everything else to https. + * + * Used by BOTH the in-app custom-URL prompt (DesktopLayout) and the + * `/agents/custom?url=` deep-link handler (AgentPage) so a value like + * `boxy-run.fly.dev` resolves to `https://boxy-run.fly.dev` in both paths. + * A URL that already carries a scheme is returned unchanged (idempotent). + */ +export function normalizeUrl(input: string): string { + const url = input.trim(); + if (url.startsWith('http://') || url.startsWith('https://')) return url; + return url.includes('localhost') || /^\d/.test(url) ? `http://${url}` : `https://${url}`; +} diff --git a/vite.config.ts b/vite.config.ts index 09fab016..b2a9b046 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -64,8 +64,13 @@ export default defineConfig(({ mode }) => { // both "/" and deep routes like "/home", "/agents/dm", etc. server.middlewares.use((req, _res, next) => { const url = req.url || ''; - const isProxied = proxyPaths.some((p) => url.startsWith(p)); - const isAsset = url.startsWith('/src/') || url.startsWith('/node_modules/') || url.startsWith('/@') || url.includes('.'); + // Classify by the PATH ONLY — stripping the query first. Otherwise a + // route whose query value contains a dot (e.g. + // /agents/custom?url=boxy-run.fly.dev) is misread as a static asset + // by the `.` check below and never falls back to index.html (404). + const pathname = url.split('?')[0]; + const isProxied = proxyPaths.some((p) => pathname.startsWith(p)); + const isAsset = pathname.startsWith('/src/') || pathname.startsWith('/node_modules/') || pathname.startsWith('/@') || pathname.includes('.'); if (!isProxied && !isAsset) { req.url = '/src/index.html'; }