From 2c4d153399f48723d5eb4680379426029356e44c Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 3 Jun 2026 10:13:00 -0400 Subject: [PATCH 1/4] feat: route browser telemetry directly to the VM by default Add "telemetry" to the default KERNEL_BROWSER_ROUTING_SUBRESOURCES list so telemetry SSE streams are routed straight to the browser VM, and change the telemetry stream method path from /browsers/{id}/telemetry to /browsers/{id}/telemetry/stream so the direct-routing rewrite yields {base_url}/telemetry/stream on the VM (the VM's /telemetry is a different, non-streaming endpoint). DEPENDS ON the control-plane PR renaming the public endpoint /browsers/{id}/telemetry -> /browsers/{id}/telemetry/stream. Until that deploys, telemetry.stream() only works via direct routing. Verified with a live smoke test against prod: the telemetry stream request is rewritten to the VM proxy host (.../telemetry/stream?jwt=...), the Authorization header is stripped, and an api_call telemetry event arrives within ~1s of generating activity. Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/smoke-browser-telemetry.ts | 141 ++++++++++++++++++++++++++++ scripts/smoke-browser-telemetry | 7 ++ src/lib/browser-routing.ts | 2 +- tests/lib/browser-routing.test.ts | 36 ++++++- 4 files changed, 183 insertions(+), 3 deletions(-) create mode 100644 examples/smoke-browser-telemetry.ts create mode 100755 scripts/smoke-browser-telemetry diff --git a/examples/smoke-browser-telemetry.ts b/examples/smoke-browser-telemetry.ts new file mode 100644 index 0000000..27f4c50 --- /dev/null +++ b/examples/smoke-browser-telemetry.ts @@ -0,0 +1,141 @@ +import Kernel from '@onkernel/sdk'; + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) { + throw new Error(message); + } +} + +function normalizeURL(input: unknown): string { + if (typeof input === 'string') { + return input; + } + if (input instanceof URL) { + return input.toString(); + } + return (input as Request).url; +} + +function authHeaderPresent(input: unknown, init?: RequestInit): boolean { + const headers = input instanceof Request ? new Headers(input.headers) : new Headers(init?.headers); + return headers.has('authorization'); +} + +async function main() { + // Telemetry is now a default routing subresource; set the env var explicitly to be safe. + process.env['KERNEL_BROWSER_ROUTING_SUBRESOURCES'] = 'curl,telemetry'; + + const records: Array<{ url: string; auth: boolean }> = []; + const realFetch: typeof fetch = fetch; + + const kernel = new Kernel({ + baseURL: process.env['KERNEL_BASE_URL'] || 'https://api.onkernel.com', + fetch: async (input, init) => { + records.push({ url: normalizeURL(input), auth: authHeaderPresent(input, init as RequestInit) }); + return realFetch(input as any, init as any); + }, + }); + + let sessionID: string | undefined; + + try { + console.log(`Using Kernel API ${kernel.baseURL}`); + const browser = await kernel.browsers.create({ + headless: true, + timeout_seconds: 120, + telemetry: { enabled: true }, + }); + sessionID = browser.session_id; + console.log(`Created browser ${sessionID}`); + + const route = kernel.browserRouteCache.get(sessionID); + assert(route, `expected a cached route for session ${sessionID}`); + const baseHost = new URL(route.baseURL).host; + console.log(`Cached VM base_url host: ${baseHost}`); + + const recordsBeforeStream = records.length; + const stream = await kernel.browsers.telemetry.stream(sessionID); + console.log(`Opened telemetry stream`); + + // The telemetry stream request should be the most recent recorded request. + const streamReq = records[records.length - 1]; + assert(streamReq, 'no recorded request for telemetry stream'); + assert(records.length > recordsBeforeStream, 'telemetry stream did not produce an outbound request'); + + const streamURL = new URL(streamReq.url); + console.log(`Telemetry stream outbound URL: ${streamReq.url} (auth=${streamReq.auth})`); + + assert( + streamURL.host === baseHost, + `telemetry stream host ${streamURL.host} did not match VM base_url host ${baseHost}`, + ); + assert(streamURL.host !== 'api.onkernel.com', 'telemetry stream was NOT routed (still api.onkernel.com)'); + assert( + streamURL.pathname.endsWith('/telemetry/stream'), + `telemetry stream path ${streamURL.pathname} did not end with /telemetry/stream`, + ); + assert(!!streamURL.searchParams.get('jwt'), 'telemetry stream URL missing jwt query param'); + assert(!streamReq.auth, 'Authorization header was NOT stripped on the routed telemetry stream request'); + console.log( + `Routing confirmed: stream -> ${streamURL.host}${streamURL.pathname} (jwt present, auth stripped)`, + ); + + // Generate activity so the "api" telemetry category emits an event. + const activity = (async () => { + try { + await kernel.browsers.curl(sessionID!, { + url: 'https://example.com/', + method: 'GET', + response_encoding: 'utf8', + timeout_ms: 10_000, + }); + console.log('Generated activity via browsers.curl'); + } catch (error) { + console.error('activity curl failed', error); + } + })(); + + let eventCount = 0; + const deadline = Date.now() + 25_000; + const reader = (async () => { + for await (const event of stream) { + eventCount += 1; + console.log( + `telemetry event #${eventCount}: seq=${(event as any)?.seq} type=${(event as any)?.event?.type}`, + ); + break; + } + })(); + + await activity; + await Promise.race([ + reader, + new Promise((resolve) => { + const timer = setInterval(() => { + if (eventCount > 0 || Date.now() > deadline) { + clearInterval(timer); + resolve(); + } + }, 250); + }), + ]); + + assert(eventCount >= 1, `expected at least one telemetry event within 25s, got ${eventCount}`); + console.log(`PASS telemetry stream received ${eventCount} event(s) over direct-routed VM connection`); + console.log(`SMOKE_RESULT eventsObserved=${eventCount} routedURL=${streamReq.url}`); + } finally { + if (sessionID) { + console.log(`Deleting browser ${sessionID}`); + try { + await kernel.browsers.deleteByID(sessionID); + } catch (error) { + console.error(`Failed to delete browser ${sessionID}`, error); + } + } + } +} + +void main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/scripts/smoke-browser-telemetry b/scripts/smoke-browser-telemetry new file mode 100755 index 0000000..3f374f8 --- /dev/null +++ b/scripts/smoke-browser-telemetry @@ -0,0 +1,7 @@ +#!/usr/bin/env bash + +set -euo pipefail + +cd "$(dirname "$0")/.." + +./node_modules/.bin/ts-node -r tsconfig-paths/register examples/smoke-browser-telemetry.ts "$@" diff --git a/src/lib/browser-routing.ts b/src/lib/browser-routing.ts index 6580da6..bbedec2 100644 --- a/src/lib/browser-routing.ts +++ b/src/lib/browser-routing.ts @@ -28,7 +28,7 @@ export class BrowserRouteCache { } const BROWSER_ROUTING_SUBRESOURCES_ENV = 'KERNEL_BROWSER_ROUTING_SUBRESOURCES'; -const DEFAULT_BROWSER_ROUTING_SUBRESOURCES = ['curl']; +const DEFAULT_BROWSER_ROUTING_SUBRESOURCES = ['curl', 'telemetry']; const BROWSER_ROUTE_CACHEABLE_PATH = /^\/(?:v\d+\/)?browsers(?:\/[^/]+)?\/?$/; const BROWSER_POOL_ACQUIRE_PATH = /^\/(?:v\d+\/)?browser_pools\/[^/]+\/acquire\/?$/; const BROWSER_DELETE_BY_ID_PATH = /^\/(?:v\d+\/)?browsers\/([^/]+)\/?$/; diff --git a/tests/lib/browser-routing.test.ts b/tests/lib/browser-routing.test.ts index 1ca285c..da87ad6 100644 --- a/tests/lib/browser-routing.test.ts +++ b/tests/lib/browser-routing.test.ts @@ -381,9 +381,41 @@ describe('browser routing', () => { ).rejects.toThrow(/unsupported HTTP method/i); }); - test('defaults browser routing subresources to curl when env is unset', async () => { + test('defaults browser routing subresources to curl and telemetry when env is unset', async () => { await withBrowserRoutingEnv(undefined, async () => { - expect(browserRoutingSubresourcesFromEnv()).toEqual(['curl']); + expect(browserRoutingSubresourcesFromEnv()).toEqual(['curl', 'telemetry']); + }); + }); + + test('routes telemetry stream calls to the VM /telemetry/stream path by default', async () => { + await withBrowserRoutingEnv(undefined, async () => { + const calls: Array<{ url: string; headers: Headers }> = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async (input, init?: RequestInit) => { + const url = normalizeURL(input); + const headers = input instanceof Request ? new Headers(input.headers) : new Headers(init?.headers); + calls.push({ url, headers }); + if (url === 'https://api.example/browsers') { + return Response.json({ + session_id: 'sess-1', + base_url: 'http://browser-session.test/browser/kernel', + cdp_ws_url: 'wss://browser-session.test/browser/cdp?jwt=token-abc', + }); + } + return new Response('id: 1\ndata: {"seq":1}\n\n', { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }); + }, + }); + + await kernel.browsers.create(); + await kernel.browsers.telemetry.stream('sess-1'); + + expect(calls[1]?.url).toBe('http://browser-session.test/browser/kernel/telemetry/stream?jwt=token-abc'); + expect(calls[1]?.headers.get('authorization')).toBeNull(); }); }); From 203054736d39a77e3e55895668130655a680b0c5 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 3 Jun 2026 10:40:42 -0400 Subject: [PATCH 2/4] refactor(examples): rename telemetry example to browser-telemetry Co-Authored-By: Claude Opus 4.8 (1M context) --- examples/browser-telemetry.ts | 30 ++++++ examples/smoke-browser-telemetry.ts | 141 ---------------------------- scripts/smoke-browser-telemetry | 7 -- 3 files changed, 30 insertions(+), 148 deletions(-) create mode 100644 examples/browser-telemetry.ts delete mode 100644 examples/smoke-browser-telemetry.ts delete mode 100755 scripts/smoke-browser-telemetry diff --git a/examples/browser-telemetry.ts b/examples/browser-telemetry.ts new file mode 100644 index 0000000..e2dbe43 --- /dev/null +++ b/examples/browser-telemetry.ts @@ -0,0 +1,30 @@ +import Kernel from '@onkernel/sdk'; + +async function main() { + const kernel = new Kernel(); + + // Create a browser with telemetry enabled so it emits events while it runs. + const browser = await kernel.browsers.create({ telemetry: { enabled: true } }); + + try { + // Telemetry is a default routing subresource, so the stream goes directly to the VM automatically. + const stream = await kernel.browsers.telemetry.stream(browser.session_id); + + // Make browser activity to generate telemetry. The "api" category emits an event per VM API call, + // so events arrive within ~1s. + for (let i = 0; i < 3; i++) { + await kernel.browsers.curl(browser.session_id, { url: 'https://example.com', method: 'GET' }); + } + + // Print a few events, then stop so the program terminates promptly. + let count = 0; + for await (const event of stream) { + console.log('telemetry event', event); + if (++count >= 3) break; + } + } finally { + await kernel.browsers.deleteByID(browser.session_id); + } +} + +void main(); diff --git a/examples/smoke-browser-telemetry.ts b/examples/smoke-browser-telemetry.ts deleted file mode 100644 index 27f4c50..0000000 --- a/examples/smoke-browser-telemetry.ts +++ /dev/null @@ -1,141 +0,0 @@ -import Kernel from '@onkernel/sdk'; - -function assert(condition: unknown, message: string): asserts condition { - if (!condition) { - throw new Error(message); - } -} - -function normalizeURL(input: unknown): string { - if (typeof input === 'string') { - return input; - } - if (input instanceof URL) { - return input.toString(); - } - return (input as Request).url; -} - -function authHeaderPresent(input: unknown, init?: RequestInit): boolean { - const headers = input instanceof Request ? new Headers(input.headers) : new Headers(init?.headers); - return headers.has('authorization'); -} - -async function main() { - // Telemetry is now a default routing subresource; set the env var explicitly to be safe. - process.env['KERNEL_BROWSER_ROUTING_SUBRESOURCES'] = 'curl,telemetry'; - - const records: Array<{ url: string; auth: boolean }> = []; - const realFetch: typeof fetch = fetch; - - const kernel = new Kernel({ - baseURL: process.env['KERNEL_BASE_URL'] || 'https://api.onkernel.com', - fetch: async (input, init) => { - records.push({ url: normalizeURL(input), auth: authHeaderPresent(input, init as RequestInit) }); - return realFetch(input as any, init as any); - }, - }); - - let sessionID: string | undefined; - - try { - console.log(`Using Kernel API ${kernel.baseURL}`); - const browser = await kernel.browsers.create({ - headless: true, - timeout_seconds: 120, - telemetry: { enabled: true }, - }); - sessionID = browser.session_id; - console.log(`Created browser ${sessionID}`); - - const route = kernel.browserRouteCache.get(sessionID); - assert(route, `expected a cached route for session ${sessionID}`); - const baseHost = new URL(route.baseURL).host; - console.log(`Cached VM base_url host: ${baseHost}`); - - const recordsBeforeStream = records.length; - const stream = await kernel.browsers.telemetry.stream(sessionID); - console.log(`Opened telemetry stream`); - - // The telemetry stream request should be the most recent recorded request. - const streamReq = records[records.length - 1]; - assert(streamReq, 'no recorded request for telemetry stream'); - assert(records.length > recordsBeforeStream, 'telemetry stream did not produce an outbound request'); - - const streamURL = new URL(streamReq.url); - console.log(`Telemetry stream outbound URL: ${streamReq.url} (auth=${streamReq.auth})`); - - assert( - streamURL.host === baseHost, - `telemetry stream host ${streamURL.host} did not match VM base_url host ${baseHost}`, - ); - assert(streamURL.host !== 'api.onkernel.com', 'telemetry stream was NOT routed (still api.onkernel.com)'); - assert( - streamURL.pathname.endsWith('/telemetry/stream'), - `telemetry stream path ${streamURL.pathname} did not end with /telemetry/stream`, - ); - assert(!!streamURL.searchParams.get('jwt'), 'telemetry stream URL missing jwt query param'); - assert(!streamReq.auth, 'Authorization header was NOT stripped on the routed telemetry stream request'); - console.log( - `Routing confirmed: stream -> ${streamURL.host}${streamURL.pathname} (jwt present, auth stripped)`, - ); - - // Generate activity so the "api" telemetry category emits an event. - const activity = (async () => { - try { - await kernel.browsers.curl(sessionID!, { - url: 'https://example.com/', - method: 'GET', - response_encoding: 'utf8', - timeout_ms: 10_000, - }); - console.log('Generated activity via browsers.curl'); - } catch (error) { - console.error('activity curl failed', error); - } - })(); - - let eventCount = 0; - const deadline = Date.now() + 25_000; - const reader = (async () => { - for await (const event of stream) { - eventCount += 1; - console.log( - `telemetry event #${eventCount}: seq=${(event as any)?.seq} type=${(event as any)?.event?.type}`, - ); - break; - } - })(); - - await activity; - await Promise.race([ - reader, - new Promise((resolve) => { - const timer = setInterval(() => { - if (eventCount > 0 || Date.now() > deadline) { - clearInterval(timer); - resolve(); - } - }, 250); - }), - ]); - - assert(eventCount >= 1, `expected at least one telemetry event within 25s, got ${eventCount}`); - console.log(`PASS telemetry stream received ${eventCount} event(s) over direct-routed VM connection`); - console.log(`SMOKE_RESULT eventsObserved=${eventCount} routedURL=${streamReq.url}`); - } finally { - if (sessionID) { - console.log(`Deleting browser ${sessionID}`); - try { - await kernel.browsers.deleteByID(sessionID); - } catch (error) { - console.error(`Failed to delete browser ${sessionID}`, error); - } - } - } -} - -void main().catch((error) => { - console.error(error); - process.exitCode = 1; -}); diff --git a/scripts/smoke-browser-telemetry b/scripts/smoke-browser-telemetry deleted file mode 100755 index 3f374f8..0000000 --- a/scripts/smoke-browser-telemetry +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -cd "$(dirname "$0")/.." - -./node_modules/.bin/ts-node -r tsconfig-paths/register examples/smoke-browser-telemetry.ts "$@" From a0c377d9a05c8f3f81c763be868b60d53663f772 Mon Sep 17 00:00:00 2001 From: "stainless-app[bot]" <142633134+stainless-app[bot]@users.noreply.github.com> Date: Wed, 3 Jun 2026 17:45:30 +0000 Subject: [PATCH 3/4] feat: Fix browser pool update schema --- .stats.yml | 4 +-- src/resources/browser-pools.ts | 15 +++++------ tests/api-resources/browser-pools.test.ts | 32 ++--------------------- 3 files changed, 11 insertions(+), 40 deletions(-) diff --git a/.stats.yml b/.stats.yml index 5a5e563..57627cd 100644 --- a/.stats.yml +++ b/.stats.yml @@ -1,4 +1,4 @@ configured_endpoints: 117 -openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel/kernel-1acd8f0b76ab00e36b53cc3ca90b72b2199f3388b3e307890adb464b87f9a2d8.yml -openapi_spec_hash: 82003125c1c2c5d82d19270bafb4a6ca +openapi_spec_url: https://storage.googleapis.com/stainless-sdk-openapi-specs/kernel/kernel-2d1337eec44e036b9c896b7db4691f0a12edfa79d3f28b611818bcedf62d44ee.yml +openapi_spec_hash: 30110dbbe733b16e40a6d0aa41d0c8c4 config_hash: ede72e4ae65cc5a6d6927938b3455c46 diff --git a/src/resources/browser-pools.ts b/src/resources/browser-pools.ts index dc1af44..c6e7849 100644 --- a/src/resources/browser-pools.ts +++ b/src/resources/browser-pools.ts @@ -48,7 +48,6 @@ export class BrowserPools extends APIResource { * ```ts * const browserPool = await client.browserPools.update( * 'id_or_name', - * { size: 10 }, * ); * ``` */ @@ -494,13 +493,6 @@ export interface BrowserPoolCreateParams { } export interface BrowserPoolUpdateParams { - /** - * Number of browsers to maintain in the pool. The maximum size is determined by - * your organization's pooled sessions limit (the sum of all pool sizes cannot - * exceed your limit). - */ - size: number; - /** * Custom Chrome enterprise policy overrides applied to all browsers in this pool. * Keys are Chrome enterprise policy names; values must match their expected types. @@ -554,6 +546,13 @@ export interface BrowserPoolUpdateParams { */ proxy_id?: string; + /** + * Number of browsers to maintain in the pool. The maximum size is determined by + * your organization's pooled sessions limit (the sum of all pool sizes cannot + * exceed your limit). + */ + size?: number; + /** * Optional URL to navigate to when a new browser is warmed into the pool. * Best-effort: failures to navigate do not fail pool fill. Only applied to diff --git a/tests/api-resources/browser-pools.test.ts b/tests/api-resources/browser-pools.test.ts index 7a473ba..dff2eed 100644 --- a/tests/api-resources/browser-pools.test.ts +++ b/tests/api-resources/browser-pools.test.ts @@ -60,8 +60,8 @@ describe('resource browserPools', () => { }); // Mock server tests are disabled - test.skip('update: only required params', async () => { - const responsePromise = client.browserPools.update('id_or_name', { size: 10 }); + test.skip('update', async () => { + const responsePromise = client.browserPools.update('id_or_name', {}); const rawResponse = await responsePromise.asResponse(); expect(rawResponse).toBeInstanceOf(Response); const response = await responsePromise; @@ -71,34 +71,6 @@ describe('resource browserPools', () => { expect(dataAndResponse.response).toBe(rawResponse); }); - // Mock server tests are disabled - test.skip('update: required and optional params', async () => { - const response = await client.browserPools.update('id_or_name', { - size: 10, - chrome_policy: { foo: 'bar' }, - discard_all_idle: false, - extensions: [{ id: 'id', name: 'name' }], - fill_rate_per_minute: 0, - headless: false, - kiosk_mode: true, - name: 'my-pool', - profile: { - id: 'id', - name: 'name', - save_changes: true, - }, - proxy_id: 'proxy_id', - start_url: 'https://example.com', - stealth: true, - timeout_seconds: 60, - viewport: { - height: 800, - width: 1280, - refresh_rate: 60, - }, - }); - }); - // Mock server tests are disabled test.skip('list', async () => { const responsePromise = client.browserPools.list(); From 94b71219ad325453141cfc2cccc99c4f66afdf05 Mon Sep 17 00:00:00 2001 From: Rafael Garcia Date: Wed, 3 Jun 2026 14:35:40 -0400 Subject: [PATCH 4/4] feat: opt-in control-plane fallback on browser_gone 404 Rework the routing-layer control-plane fallback to a tight, opt-in design that pairs with metro-api kernel#2317 (routed requests to a deleted/gone browser return HTTP 404 with body {"code":"browser_gone"}). Replaces the previous broad trigger (fall back on any 5xx for any idempotent GET), which retried the dead VM then fell back, adding latency on transient errors. New semantics in routeRequest, applied only when the request was actually routed to the VM (allowlisted subresource + cached route): fall back IFF method is GET, the routed (subresource + suffix) path is in the fallback-eligible registry, and the VM returns HTTP 404 whose JSON body has code == "browser_gone". On fallback, evict the cached route and re-issue the ORIGINAL request to the control plane exactly once (original URL, restore Authorization, drop the jwt query param); return that response, never loop. Success, transient 5xx, network errors, other 4xx, and non-browser_gone 404s propagate unchanged. The 404 body is read via response.clone() so a non-fallback response is returned intact. Per kernel#2317 there is no special response header, so the gone check keys off the body code only (no content-type gate), matching the python SDK. Adds an isFallbackEligible(subresource, suffix) predicate backed by a registry that is default-OFF. Pre-registers only the prospective pull endpoint GET /browsers/{id}/telemetry/events; adding future eligible endpoints is a one-line registry edit. Scoped to the fallback only: does not modify the default routing subresource list (owned by the telemetry-default-routing PR). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/browser-routing.ts | 80 +++++++++- tests/lib/browser-routing.test.ts | 254 ++++++++++++++++++++++++++++++ 2 files changed, 332 insertions(+), 2 deletions(-) diff --git a/src/lib/browser-routing.ts b/src/lib/browser-routing.ts index bbedec2..f82e5fb 100644 --- a/src/lib/browser-routing.ts +++ b/src/lib/browser-routing.ts @@ -34,6 +34,56 @@ const BROWSER_POOL_ACQUIRE_PATH = /^\/(?:v\d+\/)?browser_pools\/[^/]+\/acquire\/ const BROWSER_DELETE_BY_ID_PATH = /^\/(?:v\d+\/)?browsers\/([^/]+)\/?$/; const BROWSER_POOL_RELEASE_PATH = /^\/(?:v\d+\/)?browser_pools\/[^/]+\/release\/?$/; +/** + * Registry of routed (subresource + suffix) paths that are eligible for + * control-plane fallback when the VM reports the browser is authoritatively + * gone (HTTP 404 with body code "browser_gone"). Everything not listed here is + * fallback-OFF by default. + * + * Adding a future eligible endpoint is intentionally a one-line edit: append + * another `${subresource} ${suffix}` entry below. + */ +const FALLBACK_ELIGIBLE_ROUTES = new Set([ + // PROSPECTIVE: GET /browsers/{id}/telemetry/events. This pull endpoint / + // method does not exist on the SDK yet; this entry pre-wires the opt-in so + // control-plane fallback works the moment the method ships. Remove this + // comment once the method lands. + fallbackRouteKey('telemetry', '/events'), +]); + +const BROWSER_GONE_CODE = 'browser_gone'; + +function fallbackRouteKey(subresource: string, suffix: string): string { + return `${subresource} ${suffix}`; +} + +/** + * Whether a routed path (parsed subresource + suffix) is opted in to + * control-plane fallback. Suffix is the portion after the subresource (e.g. + * "/events"), or "" when the request targets the bare subresource. + */ +export function isFallbackEligible(subresource: string, suffix: string): boolean { + return FALLBACK_ELIGIBLE_ROUTES.has(fallbackRouteKey(subresource, suffix)); +} + +async function isBrowserGone404(response: Response): Promise { + if (response.status !== 404) { + return false; + } + // Key off the body code only (per kernel#2317: there is NO special response + // header). We do not gate on content-type so behavior matches the spec and + // the python SDK, which simply attempts response.json(). A non-JSON body just + // fails to parse and returns false. + try { + const body = await response.clone().json(); + return ( + !!body && typeof body === 'object' && (body as Record)['code'] === BROWSER_GONE_CODE + ); + } catch { + return false; + } +} + export function browserRoutingSubresourcesFromEnv(): string[] { const raw = readBrowserRoutingSubresourcesEnv(); if (raw === undefined) { @@ -229,6 +279,7 @@ async function routeRequest( const sessionId = decodeURIComponent(match[1] ?? ''); const subresource = match[2] ?? ''; + const suffix = match[3] ?? ''; if (!sessionId || !allowed.has(subresource)) { return innerFetch(input, init); } @@ -237,7 +288,7 @@ async function routeRequest( return innerFetch(input, init); } - const target = new URL(joinURL(route.baseURL, `/${subresource}${match[3] ?? ''}`)); + const target = new URL(joinURL(route.baseURL, `/${subresource}${suffix}`)); url.searchParams.forEach((value, key) => { if (key !== 'jwt') { target.searchParams.append(key, value); @@ -249,7 +300,32 @@ async function routeRequest( const headers = new Headers(request.headers); headers.delete('authorization'); - return innerFetch(target.toString(), buildRoutedInit(request, init, headers)); + const response = await innerFetch(target.toString(), buildRoutedInit(request, init, headers)); + + // Control-plane fallback: the request was actually routed to the VM, so this + // is the only place we attempt it. Fall back IFF the method is GET, the routed + // path is opted in, and the VM authoritatively reports the browser is gone + // (HTTP 404 with body code "browser_gone"). Everything else — success, + // transient 5xx, network errors, other 4xx, or a 404 without that code — + // propagates unchanged. + const method = request.method.toUpperCase(); + if (method !== 'GET' || !isFallbackEligible(subresource, suffix)) { + return response; + } + if (!(await isBrowserGone404(response))) { + return response; + } + + // The browser is authoritatively gone: evict the now-stale route and re-issue + // the ORIGINAL request to the control plane exactly once. Restore the original + // Authorization (still present on `request.headers`) and drop the jwt query + // param so we hit the CP, not the VM. Never loops back through routing. + cache.delete(sessionId); + + const cpURL = new URL(request.url); + cpURL.searchParams.delete('jwt'); + const cpHeaders = new Headers(request.headers); + return innerFetch(cpURL.toString(), buildRoutedInit(request, init, cpHeaders)); } function buildRoutedInit( diff --git a/tests/lib/browser-routing.test.ts b/tests/lib/browser-routing.test.ts index da87ad6..6503881 100644 --- a/tests/lib/browser-routing.test.ts +++ b/tests/lib/browser-routing.test.ts @@ -4,6 +4,7 @@ import { BrowserRouteCache, browserRoutingSubresourcesFromEnv, createRoutingFetch, + isFallbackEligible, } from '../../src/lib/browser-routing'; describe('browser routing', () => { @@ -425,3 +426,256 @@ describe('browser routing', () => { }); }); }); + +describe('browser routing control-plane fallback', () => { + const ELIGIBLE_PATH = 'https://api.example/browsers/sess-1/telemetry/events'; + const VM_BASE = 'http://browser-session.test/browser/kernel'; + + const browserGone404 = () => + Response.json({ code: 'browser_gone', message: 'browser not found' }, { status: 404 }); + + const warmedCache = () => { + const cache = new BrowserRouteCache(); + cache.set({ sessionId: 'sess-1', baseURL: VM_BASE, jwt: 'token-abc' }); + return cache; + }; + + // The "telemetry" subresource must be routed; the SUT's default-subresources + // constant is out of scope for this PR, so tests enable it explicitly here. + const makeFetch = ( + cache: BrowserRouteCache, + inner: (input: any, init?: RequestInit) => Promise, + ) => + createRoutingFetch(inner as any, { + apiBaseURL: 'https://api.example/', + subresources: ['telemetry'], + cache, + }); + + const urlOf = (input: any) => + typeof input === 'string' ? input + : input instanceof URL ? input.toString() + : input.url; + + test('isFallbackEligible only matches the pre-registered telemetry/events route', () => { + expect(isFallbackEligible('telemetry', '/events')).toBe(true); + expect(isFallbackEligible('telemetry', '/stream')).toBe(false); + expect(isFallbackEligible('telemetry', '')).toBe(false); + expect(isFallbackEligible('curl', '/events')).toBe(false); + }); + + test('eligible GET + 404 browser_gone falls back to the control plane', async () => { + const cache = warmedCache(); + const calls: Array<{ url: string; method: string; auth: string | null }> = []; + const wrapped = makeFetch(cache, async (input, init) => { + const url = urlOf(input); + const headers = new Headers(init?.headers); + calls.push({ url, method: (init?.method ?? 'GET').toUpperCase(), auth: headers.get('authorization') }); + if (url.startsWith(VM_BASE)) { + return browserGone404(); + } + return Response.json({ events: [] }, { status: 200 }); + }); + + const res = await wrapped(ELIGIBLE_PATH, { + method: 'GET', + headers: { authorization: 'Bearer original-key' }, + }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ events: [] }); + // Exactly one VM attempt + exactly one CP re-issue. No loop. + expect(calls).toHaveLength(2); + // VM attempt: routed, Authorization stripped, jwt added. + expect(calls[0]?.url).toBe(`${VM_BASE}/telemetry/events?jwt=token-abc`); + expect(calls[0]?.auth).toBeNull(); + // CP re-issue: original URL, Authorization restored, jwt dropped. + expect(calls[1]?.url).toBe(ELIGIBLE_PATH); + expect(calls[1]?.method).toBe('GET'); + expect(calls[1]?.auth).toBe('Bearer original-key'); + // Route evicted as authoritatively gone. + expect(cache.get('sess-1')).toBeUndefined(); + }); + + test('eligible GET + 404 browser_gone where CP also errors returns CP response, no loop', async () => { + const cache = warmedCache(); + const calls: string[] = []; + const wrapped = makeFetch(cache, async (input) => { + const url = urlOf(input); + calls.push(url); + if (url.startsWith(VM_BASE)) { + return browserGone404(); + } + return new Response('boom', { status: 500, headers: { 'content-type': 'text/plain' } }); + }); + + const res = await wrapped(ELIGIBLE_PATH, { method: 'GET', headers: { authorization: 'Bearer k' } }); + + expect(res.status).toBe(500); + expect(await res.text()).toBe('boom'); + // One VM attempt + one CP re-issue only; the CP 500 is returned as-is. + expect(calls).toHaveLength(2); + expect(calls[0]).toBe(`${VM_BASE}/telemetry/events?jwt=token-abc`); + expect(calls[1]).toBe(ELIGIBLE_PATH); + }); + + test('not-eligible routed path + 404 browser_gone does NOT fall back', async () => { + const cache = warmedCache(); + const calls: string[] = []; + const wrapped = makeFetch(cache, async (input) => { + calls.push(urlOf(input)); + return browserGone404(); + }); + + const res = await wrapped('https://api.example/browsers/sess-1/telemetry/stream', { + method: 'GET', + headers: { authorization: 'Bearer k' }, + }); + + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ code: 'browser_gone', message: 'browser not found' }); + expect(calls).toEqual([`${VM_BASE}/telemetry/stream?jwt=token-abc`]); + expect(cache.get('sess-1')).toMatchObject({ sessionId: 'sess-1' }); + }); + + test('eligible GET + transient 502 does NOT fall back (propagated)', async () => { + const cache = warmedCache(); + const calls: string[] = []; + const wrapped = makeFetch(cache, async (input) => { + calls.push(urlOf(input)); + return new Response('bad gateway', { status: 502, headers: { 'content-type': 'text/plain' } }); + }); + + const res = await wrapped(ELIGIBLE_PATH, { method: 'GET', headers: { authorization: 'Bearer k' } }); + + expect(res.status).toBe(502); + expect(calls).toEqual([`${VM_BASE}/telemetry/events?jwt=token-abc`]); + expect(cache.get('sess-1')).toMatchObject({ sessionId: 'sess-1' }); + }); + + test('eligible GET + connection error does NOT fall back (propagated)', async () => { + const cache = warmedCache(); + let attempts = 0; + const wrapped = makeFetch(cache, async () => { + attempts++; + throw new TypeError('network down'); + }); + + await expect( + wrapped(ELIGIBLE_PATH, { method: 'GET', headers: { authorization: 'Bearer k' } }), + ).rejects.toThrow(/network down/); + expect(attempts).toBe(1); + expect(cache.get('sess-1')).toMatchObject({ sessionId: 'sess-1' }); + }); + + test('eligible GET + 200 does NOT fall back', async () => { + const cache = warmedCache(); + const calls: string[] = []; + const wrapped = makeFetch(cache, async (input) => { + calls.push(urlOf(input)); + return Response.json({ events: [{ seq: 1 }] }, { status: 200 }); + }); + + const res = await wrapped(ELIGIBLE_PATH, { method: 'GET', headers: { authorization: 'Bearer k' } }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ events: [{ seq: 1 }] }); + expect(calls).toEqual([`${VM_BASE}/telemetry/events?jwt=token-abc`]); + expect(cache.get('sess-1')).toMatchObject({ sessionId: 'sess-1' }); + }); + + test('eligible path but POST does NOT fall back even on 404 browser_gone', async () => { + const cache = warmedCache(); + const calls: Array<{ url: string; method: string }> = []; + const wrapped = makeFetch(cache, async (input, init) => { + calls.push({ url: urlOf(input), method: (init?.method ?? 'GET').toUpperCase() }); + return browserGone404(); + }); + + const res = await wrapped(ELIGIBLE_PATH, { + method: 'POST', + headers: { authorization: 'Bearer k' }, + body: '{}', + }); + + expect(res.status).toBe(404); + expect(calls).toHaveLength(1); + expect(calls[0]?.method).toBe('POST'); + expect(cache.get('sess-1')).toMatchObject({ sessionId: 'sess-1' }); + }); + + test('a 404 whose body code is not browser_gone does NOT fall back', async () => { + const cache = warmedCache(); + const calls: string[] = []; + const wrapped = makeFetch(cache, async (input) => { + calls.push(urlOf(input)); + return Response.json({ code: 'not_found', message: 'no such event' }, { status: 404 }); + }); + + const res = await wrapped(ELIGIBLE_PATH, { method: 'GET', headers: { authorization: 'Bearer k' } }); + + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ code: 'not_found', message: 'no such event' }); + expect(calls).toEqual([`${VM_BASE}/telemetry/events?jwt=token-abc`]); + expect(cache.get('sess-1')).toMatchObject({ sessionId: 'sess-1' }); + }); + + test('keys off the body code only: browser_gone 404 without a JSON content-type still falls back', async () => { + const cache = warmedCache(); + const calls: string[] = []; + const wrapped = makeFetch(cache, async (input) => { + const url = urlOf(input); + calls.push(url); + if (url.startsWith(VM_BASE)) { + // Body is JSON but the proxy omitted the application/json content-type. + // Per kernel#2317 we key off the body code only, so this must fall back. + return new Response(JSON.stringify({ code: 'browser_gone', message: 'gone' }), { + status: 404, + headers: { 'content-type': 'text/plain' }, + }); + } + return Response.json({ events: [] }, { status: 200 }); + }); + + const res = await wrapped(ELIGIBLE_PATH, { method: 'GET', headers: { authorization: 'Bearer k' } }); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ events: [] }); + expect(calls).toEqual([`${VM_BASE}/telemetry/events?jwt=token-abc`, ELIGIBLE_PATH]); + expect(cache.get('sess-1')).toBeUndefined(); + }); + + test('a non-JSON 404 body does NOT fall back', async () => { + const cache = warmedCache(); + const calls: string[] = []; + const wrapped = makeFetch(cache, async (input) => { + calls.push(urlOf(input)); + return new Response('plain not found', { + status: 404, + headers: { 'content-type': 'text/plain' }, + }); + }); + + const res = await wrapped(ELIGIBLE_PATH, { method: 'GET', headers: { authorization: 'Bearer k' } }); + + expect(res.status).toBe(404); + expect(await res.text()).toBe('plain not found'); + expect(calls).toEqual([`${VM_BASE}/telemetry/events?jwt=token-abc`]); + expect(cache.get('sess-1')).toMatchObject({ sessionId: 'sess-1' }); + }); + + test('non-routed request (cache miss) is untouched and never falls back', async () => { + const cache = new BrowserRouteCache(); // no route warmed -> not routed to VM + const calls: string[] = []; + const wrapped = makeFetch(cache, async (input) => { + calls.push(urlOf(input)); + return browserGone404(); + }); + + const res = await wrapped(ELIGIBLE_PATH, { method: 'GET', headers: { authorization: 'Bearer k' } }); + + expect(res.status).toBe(404); + // Hit the original CP URL directly (not routed to a VM), and no re-issue. + expect(calls).toEqual([ELIGIBLE_PATH]); + }); +});