fix(mobile): bypass ngrok browser interstitial
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 +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 }));
|
||||
}
|
||||
|
||||
@@ -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') {
|
||||
|
||||
Reference in New Issue
Block a user