fix(mobile): bypass ngrok browser interstitial

This commit is contained in:
Bohdan Triapitsyn
2026-08-18 19:12:02 +03:00
parent f6a4492527
commit 84e940a9e4
4 changed files with 67 additions and 11 deletions
+3 -3
View File
@@ -23,7 +23,7 @@ import type { PairingConnectionPayload, PairingEndpointCandidate } from '@/lib/c
import { isCapacitorApp } from '@/lib/platform';
import { adoptRelayTunnel, isRelayModeActive } from '@/lib/relay/runtime-tunnel';
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { addRuntimeProxyHeaders, runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeApiBaseUrl, getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch';
import { recordMobileConnectDebug } from './mobileConnectionDebug';
@@ -346,11 +346,11 @@ const nativeHttpRequest = async (url: string, init?: RequestInit): Promise<Mobil
if (!isCapacitorApp()) return null;
try {
const { CapacitorHttp } = await import('@capacitor/core');
const headers = Object.fromEntries(new Headers(init?.headers).entries());
const requestHeaders = addRuntimeProxyHeaders(url, new Headers(init?.headers));
const response = await CapacitorHttp.request({
url,
method: init?.method || 'GET',
headers,
headers: Object.fromEntries(requestHeaders.entries()),
data: getJsonRequestData(init?.body),
});
return {
+33 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from 'bun:test';
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
import { buildRuntimeFetchUrl, isLatin1Safe, runtimeFetch, sanitizeHeadersForBrowser } from './runtime-fetch';
import { addRuntimeProxyHeaders, buildRuntimeFetchUrl, isLatin1Safe, runtimeFetch, sanitizeHeadersForBrowser } from './runtime-fetch';
import { clearRuntimeAuthCredentialProvider, setRuntimeBearerToken } from './runtime-auth';
import { configureRuntimeUrlResolver, getRuntimeUrlResolver, setRuntimeUrlResolver } from './runtime-url';
@@ -48,7 +48,39 @@ describe('buildRuntimeFetchUrl', () => {
});
});
describe('addRuntimeProxyHeaders', () => {
test('bypasses the ngrok browser interstitial for official ngrok hosts', () => {
const headers = addRuntimeProxyHeaders('https://demo.ngrok-free.app/health', new Headers());
expect(headers.get('ngrok-skip-browser-warning')).toBe('openchamber');
});
test('does not add proxy headers to non-ngrok or lookalike hosts', () => {
expect(addRuntimeProxyHeaders('https://runtime.example/health', new Headers()).has('ngrok-skip-browser-warning')).toBe(false);
expect(addRuntimeProxyHeaders('https://ngrok-free.app.evil.example/health', new Headers()).has('ngrok-skip-browser-warning')).toBe(false);
});
});
describe('runtimeFetch transport contract', () => {
test('adds the ngrok bypass header to runtime requests', async () => {
const previous = getRuntimeUrlResolver();
let capturedHeaders = new Headers();
try {
configureRuntimeUrlResolver({ apiBaseUrl: 'https://demo.ngrok-free.app' });
globalThis.fetch = async (_input, init) => {
capturedHeaders = new Headers(init?.headers);
return new Response(null, { status: 204 });
};
await runtimeFetch('/health');
expect(capturedHeaders.get('ngrok-skip-browser-warning')).toBe('openchamber');
} finally {
setRuntimeUrlResolver(previous);
globalThis.fetch = originalFetch;
}
});
test('preserves bodies from actual SDK mutation requests on same-origin runtimes', async () => {
const previous = getRuntimeUrlResolver();
const originalWindow = globalThis.window;
+30 -6
View File
@@ -30,6 +30,20 @@ const isCurrentWindowUrl = (url: URL): boolean => {
const isAbsoluteUrl = (value: string): boolean => /^[a-z][a-z\d+.-]*:\/\//i.test(value);
const isNgrokHost = (hostname: string): boolean =>
/(^|\.)ngrok(?:-free)?\.(?:app|dev|io)$/i.test(hostname);
export const addRuntimeProxyHeaders = (url: string, headers: Headers): Headers => {
try {
if (isNgrokHost(new URL(url).hostname) && !headers.has('ngrok-skip-browser-warning')) {
headers.set('ngrok-skip-browser-warning', 'openchamber');
}
} catch {
// Relative and non-HTTP runtime paths do not need proxy-specific headers.
}
return headers;
};
const appendRuntimeQuery = (url: URL, query?: RuntimeUrlQuery): void => {
if (!query) return;
const entries = query instanceof URLSearchParams ? Array.from(query.entries()) : Object.entries(query);
@@ -266,13 +280,15 @@ export const runtimeFetch = async (input: string | URL | Request, init: RuntimeF
const resolvedInput = resolveRuntimeFetchInput(input, query);
const inputHeaders = resolvedInput instanceof Request ? resolvedInput.headers : undefined;
const headers = await mergeHeaders(inputHeaders, requestInit.headers, shouldAttachRuntimeAuth(resolvedInput));
doFetch = resolvedInput instanceof Request
? () => fetch(new Request(resolvedInput, { ...requestInit, headers }))
: () => fetch(resolvedInput, { ...requestInit, headers });
url =
const resolvedUrl =
resolvedInput instanceof Request ? resolvedInput.url
: resolvedInput instanceof URL ? resolvedInput.toString()
: String(resolvedInput);
addRuntimeProxyHeaders(resolvedUrl, headers);
doFetch = resolvedInput instanceof Request
? () => fetch(new Request(resolvedInput, { ...requestInit, headers }))
: () => fetch(resolvedInput, { ...requestInit, headers });
url = resolvedUrl;
method = String(
requestInit.method ?? (resolvedInput instanceof Request ? resolvedInput.method : 'GET'),
).toUpperCase();
@@ -313,6 +329,7 @@ export const installRuntimeFetchBridge = (): void => {
const url = new URL(input);
if (isActiveRuntimeServiceUrl(url)) {
const headers = await mergeHeaders(undefined, init?.headers);
addRuntimeProxyHeaders(url.toString(), headers);
return nativeFetch(input, { ...init, headers });
}
} catch {
@@ -321,7 +338,9 @@ export const installRuntimeFetchBridge = (): void => {
return nativeFetch(input, init);
}
const headers = await mergeHeaders(undefined, init?.headers);
return nativeFetch(buildRuntimeFetchUrl(input), { ...init, headers });
const target = buildRuntimeFetchUrl(input);
addRuntimeProxyHeaders(target, headers);
return nativeFetch(target, { ...init, headers });
}
if (input instanceof URL) {
@@ -329,12 +348,15 @@ export const installRuntimeFetchBridge = (): void => {
if (!shouldResolveFetchInput(raw)) {
if (isActiveRuntimeServiceUrl(input)) {
const headers = await mergeHeaders(undefined, init?.headers);
addRuntimeProxyHeaders(input.toString(), headers);
return nativeFetch(input, { ...init, headers });
}
return nativeFetch(input, init);
}
const headers = await mergeHeaders(undefined, init?.headers);
return nativeFetch(buildRuntimeFetchUrl(raw), { ...init, headers });
const target = buildRuntimeFetchUrl(raw);
addRuntimeProxyHeaders(target, headers);
return nativeFetch(target, { ...init, headers });
}
if (input instanceof Request) {
@@ -343,6 +365,7 @@ export const installRuntimeFetchBridge = (): void => {
const url = new URL(input.url);
if (isActiveRuntimeServiceUrl(url)) {
const headers = await mergeHeaders(input.headers, init?.headers);
addRuntimeProxyHeaders(url.toString(), headers);
return nativeFetch(new Request(input, { ...init, headers }));
}
} catch {
@@ -352,6 +375,7 @@ export const installRuntimeFetchBridge = (): void => {
}
const headers = await mergeHeaders(input.headers, init?.headers);
const target = buildRuntimeFetchUrl(input.url);
addRuntimeProxyHeaders(target, headers);
const request = target === input.url ? input : new Request(target, input);
return nativeFetch(new Request(request, { ...init, headers }));
}
+1 -1
View File
@@ -1608,7 +1608,7 @@ async function main(options = {}) {
res.setHeader('Access-Control-Allow-Origin', origin);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,Accept,X-Requested-With,Cache-Control,X-OpenCode-Directory,X-OpenCode-Directory-Encoding');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type,Authorization,Accept,X-Requested-With,Cache-Control,X-OpenCode-Directory,X-OpenCode-Directory-Encoding,Ngrok-Skip-Browser-Warning');
res.setHeader('Access-Control-Expose-Headers', 'x-next-cursor');
res.setHeader('Vary', 'Origin');
if (req.method === 'OPTIONS') {