diff --git a/apps/scan/src/app/(app)/(home)/resources/register/_components/form.tsx b/apps/scan/src/app/(app)/(home)/resources/register/_components/form.tsx index 893f5a863..8cff9f08d 100644 --- a/apps/scan/src/app/(app)/(home)/resources/register/_components/form.tsx +++ b/apps/scan/src/app/(app)/(home)/resources/register/_components/form.tsx @@ -49,10 +49,15 @@ interface ManualRegistrationResult { function getErrorMessageFromRegisterResult(result: { success: false; error: { - type: 'parseErrors' | 'no402'; + type: 'parseErrors' | 'no402' | 'tunnel'; + message?: string; parseErrors?: string[]; }; }): string { + if (result.error.type === 'tunnel') { + return result.error.message ?? 'Tunnel URLs are not supported'; + } + if (result.error.type === 'parseErrors') { const parseErrors = result.error.parseErrors ?? []; if (parseErrors.length > 0) { diff --git a/apps/scan/src/lib/discovery/register-endpoint.ts b/apps/scan/src/lib/discovery/register-endpoint.ts index 552b5211f..2999460ec 100644 --- a/apps/scan/src/lib/discovery/register-endpoint.ts +++ b/apps/scan/src/lib/discovery/register-endpoint.ts @@ -1,6 +1,7 @@ import { probeX402Endpoint } from './probe'; import { registerResource } from '@/lib/resources'; import { discoverSiblingResources } from './discover-siblings'; +import { isTunnelUrl } from '@/lib/url-helpers'; /** * Single orchestrator for registering a single x402 resource. @@ -16,6 +17,18 @@ export async function registerEndpoint( originMetadataFallback?: { title?: string; description?: string }; } ) { + // 0. Reject ephemeral tunnel URLs + if (isTunnelUrl(url)) { + return { + success: false as const, + error: { + type: 'tunnel' as const, + message: + "Tunnel URLs are ephemeral and can't be reliably discovered by agents. Deploy your API to a permanent URL to register.", + }, + }; + } + // 1. Probe the endpoint for a 402 response const probeResult = await probeX402Endpoint(url); diff --git a/apps/scan/src/lib/url-helpers.test.ts b/apps/scan/src/lib/url-helpers.test.ts new file mode 100644 index 000000000..461a440d4 --- /dev/null +++ b/apps/scan/src/lib/url-helpers.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { isTunnelUrl } from './url-helpers'; + +describe('isTunnelUrl', () => { + it('detects trycloudflare tunnels', () => { + expect( + isTunnelUrl( + 'https://peter-microphone-longitude-lee.trycloudflare.com/api/test' + ) + ).toBe(true); + }); + + it('detects ngrok tunnels', () => { + expect(isTunnelUrl('https://abc123.ngrok.io/path')).toBe(true); + expect(isTunnelUrl('https://abc123.ngrok-free.app/path')).toBe(true); + expect(isTunnelUrl('https://abc123.ngrok.app/path')).toBe(true); + }); + + it('detects other tunnel services', () => { + expect(isTunnelUrl('https://abc.loca.lt')).toBe(true); + expect(isTunnelUrl('https://abc.serveo.net')).toBe(true); + expect(isTunnelUrl('https://abc.localhost.run')).toBe(true); + expect(isTunnelUrl('https://abc.bore.pub')).toBe(true); + expect(isTunnelUrl('https://abc.tunnelmole.com')).toBe(true); + }); + + it('allows permanent domains', () => { + expect(isTunnelUrl('https://api.example.com/path')).toBe(false); + expect(isTunnelUrl('https://stableenrich.dev/api')).toBe(false); + expect(isTunnelUrl('https://x402scan.com')).toBe(false); + }); + + it('does not false-positive on domains containing tunnel names', () => { + expect(isTunnelUrl('https://ngrok-docs.example.com')).toBe(false); + expect(isTunnelUrl('https://my-trycloudflare.com')).toBe(false); + }); + + it('returns false for invalid URLs', () => { + expect(isTunnelUrl('not-a-url')).toBe(false); + expect(isTunnelUrl('')).toBe(false); + }); +}); diff --git a/apps/scan/src/lib/url-helpers.ts b/apps/scan/src/lib/url-helpers.ts index 97b22139f..e909908f7 100644 --- a/apps/scan/src/lib/url-helpers.ts +++ b/apps/scan/src/lib/url-helpers.ts @@ -39,6 +39,33 @@ export function isLocalUrl(url: string): boolean { } } +const TUNNEL_DOMAIN_SUFFIXES = [ + 'trycloudflare.com', + 'ngrok.io', + 'ngrok-free.app', + 'ngrok.app', + 'loca.lt', + 'serveo.net', + 'localhost.run', + 'bore.pub', + 'tunnelmole.com', +]; + +/** + * Checks if a URL points to a known ephemeral tunnel service whose + * addresses are not stable enough for agent discovery. + */ +export function isTunnelUrl(url: string): boolean { + try { + const hostname = new URL(url).hostname.toLowerCase(); + return TUNNEL_DOMAIN_SUFFIXES.some( + suffix => hostname === suffix || hostname.endsWith(`.${suffix}`) + ); + } catch { + return false; + } +} + /** * Extracts the port number from a URL, with sensible defaults. */ diff --git a/apps/scan/src/services/discovery/fetch-discovery.ts b/apps/scan/src/services/discovery/fetch-discovery.ts index 0b97f4090..e4948b759 100644 --- a/apps/scan/src/services/discovery/fetch-discovery.ts +++ b/apps/scan/src/services/discovery/fetch-discovery.ts @@ -1,7 +1,7 @@ import { discoverOriginSchema } from '@agentcash/discovery'; import { getOriginFromUrl } from '@/lib/url'; -import { isLocalUrl } from '@/lib/url-helpers'; +import { isLocalUrl, isTunnelUrl } from '@/lib/url-helpers'; import type { DiscoveredResource, @@ -42,6 +42,15 @@ export async function fetchDiscoveryDocument( }; } + if (isTunnelUrl(origin)) { + return { + success: false, + resources: [], + error: + "Tunnel URLs are ephemeral and can't be reliably discovered by agents. Deploy your API to a permanent URL to register.", + }; + } + const signal = AbortSignal.timeout(FETCH_TIMEOUT_MS); const headers: Record = bustCache ? { 'Cache-Control': 'no-cache, no-store' }