Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
13 changes: 13 additions & 0 deletions apps/scan/src/lib/discovery/register-endpoint.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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);

Expand Down
42 changes: 42 additions & 0 deletions apps/scan/src/lib/url-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
27 changes: 27 additions & 0 deletions apps/scan/src/lib/url-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
11 changes: 10 additions & 1 deletion apps/scan/src/services/discovery/fetch-discovery.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<string, string> = bustCache
? { 'Cache-Control': 'no-cache, no-store' }
Expand Down
Loading