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 { isCapacitorApp } from '@/lib/platform';
|
||||||
import { adoptRelayTunnel, isRelayModeActive } from '@/lib/relay/runtime-tunnel';
|
import { adoptRelayTunnel, isRelayModeActive } from '@/lib/relay/runtime-tunnel';
|
||||||
import { createRelayTunnelClient } from '@/lib/relay/tunnel-client';
|
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 { getRuntimeApiBaseUrl, getRuntimeKey, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||||
|
|
||||||
import { recordMobileConnectDebug } from './mobileConnectionDebug';
|
import { recordMobileConnectDebug } from './mobileConnectionDebug';
|
||||||
@@ -346,11 +346,11 @@ const nativeHttpRequest = async (url: string, init?: RequestInit): Promise<Mobil
|
|||||||
if (!isCapacitorApp()) return null;
|
if (!isCapacitorApp()) return null;
|
||||||
try {
|
try {
|
||||||
const { CapacitorHttp } = await import('@capacitor/core');
|
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({
|
const response = await CapacitorHttp.request({
|
||||||
url,
|
url,
|
||||||
method: init?.method || 'GET',
|
method: init?.method || 'GET',
|
||||||
headers,
|
headers: Object.fromEntries(requestHeaders.entries()),
|
||||||
data: getJsonRequestData(init?.body),
|
data: getJsonRequestData(init?.body),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
import { createOpencodeClient } from '@opencode-ai/sdk/v2';
|
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 { clearRuntimeAuthCredentialProvider, setRuntimeBearerToken } from './runtime-auth';
|
||||||
import { configureRuntimeUrlResolver, getRuntimeUrlResolver, setRuntimeUrlResolver } from './runtime-url';
|
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', () => {
|
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 () => {
|
test('preserves bodies from actual SDK mutation requests on same-origin runtimes', async () => {
|
||||||
const previous = getRuntimeUrlResolver();
|
const previous = getRuntimeUrlResolver();
|
||||||
const originalWindow = globalThis.window;
|
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 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 => {
|
const appendRuntimeQuery = (url: URL, query?: RuntimeUrlQuery): void => {
|
||||||
if (!query) return;
|
if (!query) return;
|
||||||
const entries = query instanceof URLSearchParams ? Array.from(query.entries()) : Object.entries(query);
|
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 resolvedInput = resolveRuntimeFetchInput(input, query);
|
||||||
const inputHeaders = resolvedInput instanceof Request ? resolvedInput.headers : undefined;
|
const inputHeaders = resolvedInput instanceof Request ? resolvedInput.headers : undefined;
|
||||||
const headers = await mergeHeaders(inputHeaders, requestInit.headers, shouldAttachRuntimeAuth(resolvedInput));
|
const headers = await mergeHeaders(inputHeaders, requestInit.headers, shouldAttachRuntimeAuth(resolvedInput));
|
||||||
doFetch = resolvedInput instanceof Request
|
const resolvedUrl =
|
||||||
? () => fetch(new Request(resolvedInput, { ...requestInit, headers }))
|
|
||||||
: () => fetch(resolvedInput, { ...requestInit, headers });
|
|
||||||
url =
|
|
||||||
resolvedInput instanceof Request ? resolvedInput.url
|
resolvedInput instanceof Request ? resolvedInput.url
|
||||||
: resolvedInput instanceof URL ? resolvedInput.toString()
|
: resolvedInput instanceof URL ? resolvedInput.toString()
|
||||||
: String(resolvedInput);
|
: String(resolvedInput);
|
||||||
|
addRuntimeProxyHeaders(resolvedUrl, headers);
|
||||||
|
doFetch = resolvedInput instanceof Request
|
||||||
|
? () => fetch(new Request(resolvedInput, { ...requestInit, headers }))
|
||||||
|
: () => fetch(resolvedInput, { ...requestInit, headers });
|
||||||
|
url = resolvedUrl;
|
||||||
method = String(
|
method = String(
|
||||||
requestInit.method ?? (resolvedInput instanceof Request ? resolvedInput.method : 'GET'),
|
requestInit.method ?? (resolvedInput instanceof Request ? resolvedInput.method : 'GET'),
|
||||||
).toUpperCase();
|
).toUpperCase();
|
||||||
@@ -313,6 +329,7 @@ export const installRuntimeFetchBridge = (): void => {
|
|||||||
const url = new URL(input);
|
const url = new URL(input);
|
||||||
if (isActiveRuntimeServiceUrl(url)) {
|
if (isActiveRuntimeServiceUrl(url)) {
|
||||||
const headers = await mergeHeaders(undefined, init?.headers);
|
const headers = await mergeHeaders(undefined, init?.headers);
|
||||||
|
addRuntimeProxyHeaders(url.toString(), headers);
|
||||||
return nativeFetch(input, { ...init, headers });
|
return nativeFetch(input, { ...init, headers });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -321,7 +338,9 @@ export const installRuntimeFetchBridge = (): void => {
|
|||||||
return nativeFetch(input, init);
|
return nativeFetch(input, init);
|
||||||
}
|
}
|
||||||
const headers = await mergeHeaders(undefined, init?.headers);
|
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) {
|
if (input instanceof URL) {
|
||||||
@@ -329,12 +348,15 @@ export const installRuntimeFetchBridge = (): void => {
|
|||||||
if (!shouldResolveFetchInput(raw)) {
|
if (!shouldResolveFetchInput(raw)) {
|
||||||
if (isActiveRuntimeServiceUrl(input)) {
|
if (isActiveRuntimeServiceUrl(input)) {
|
||||||
const headers = await mergeHeaders(undefined, init?.headers);
|
const headers = await mergeHeaders(undefined, init?.headers);
|
||||||
|
addRuntimeProxyHeaders(input.toString(), headers);
|
||||||
return nativeFetch(input, { ...init, headers });
|
return nativeFetch(input, { ...init, headers });
|
||||||
}
|
}
|
||||||
return nativeFetch(input, init);
|
return nativeFetch(input, init);
|
||||||
}
|
}
|
||||||
const headers = await mergeHeaders(undefined, init?.headers);
|
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) {
|
if (input instanceof Request) {
|
||||||
@@ -343,6 +365,7 @@ export const installRuntimeFetchBridge = (): void => {
|
|||||||
const url = new URL(input.url);
|
const url = new URL(input.url);
|
||||||
if (isActiveRuntimeServiceUrl(url)) {
|
if (isActiveRuntimeServiceUrl(url)) {
|
||||||
const headers = await mergeHeaders(input.headers, init?.headers);
|
const headers = await mergeHeaders(input.headers, init?.headers);
|
||||||
|
addRuntimeProxyHeaders(url.toString(), headers);
|
||||||
return nativeFetch(new Request(input, { ...init, headers }));
|
return nativeFetch(new Request(input, { ...init, headers }));
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -352,6 +375,7 @@ export const installRuntimeFetchBridge = (): void => {
|
|||||||
}
|
}
|
||||||
const headers = await mergeHeaders(input.headers, init?.headers);
|
const headers = await mergeHeaders(input.headers, init?.headers);
|
||||||
const target = buildRuntimeFetchUrl(input.url);
|
const target = buildRuntimeFetchUrl(input.url);
|
||||||
|
addRuntimeProxyHeaders(target, headers);
|
||||||
const request = target === input.url ? input : new Request(target, input);
|
const request = target === input.url ? input : new Request(target, input);
|
||||||
return nativeFetch(new Request(request, { ...init, headers }));
|
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-Origin', origin);
|
||||||
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
res.setHeader('Access-Control-Allow-Credentials', 'true');
|
||||||
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS');
|
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('Access-Control-Expose-Headers', 'x-next-cursor');
|
||||||
res.setHeader('Vary', 'Origin');
|
res.setHeader('Vary', 'Origin');
|
||||||
if (req.method === 'OPTIONS') {
|
if (req.method === 'OPTIONS') {
|
||||||
|
|||||||
Reference in New Issue
Block a user