diff --git a/src/lib/browser-routing.ts b/src/lib/browser-routing.ts index 7c5f0480..d0272a75 100644 --- a/src/lib/browser-routing.ts +++ b/src/lib/browser-routing.ts @@ -31,7 +31,7 @@ const BROWSER_ROUTING_SUBRESOURCES_ENV = 'KERNEL_BROWSER_ROUTING_SUBRESOURCES'; // Path prefixes eligible for direct-to-VM routing. "telemetry/stream" is the live // SSE endpoint (served by the VM); "telemetry/events" is a historical read served // by the control plane (S2) and must NOT be here. -const DEFAULT_BROWSER_ROUTING_SUBRESOURCES = ['curl', 'telemetry/stream']; +const DEFAULT_BROWSER_ROUTING_SUBRESOURCES = ['curl', 'telemetry/stream', 'computer', 'playwright']; 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 79ce4c87..93320230 100644 --- a/tests/lib/browser-routing.test.ts +++ b/tests/lib/browser-routing.test.ts @@ -444,20 +444,28 @@ describe('browser routing', () => { ).rejects.toThrow(/unsupported HTTP method/i); }); - test('defaults browser routing subresources to curl and telemetry/stream when env is unset', async () => { + test('defaults browser routing subresources when env is unset', async () => { await withBrowserRoutingEnv(undefined, async () => { - expect(browserRoutingSubresourcesFromEnv()).toEqual(['curl', 'telemetry/stream']); + expect(browserRoutingSubresourcesFromEnv()).toEqual([ + 'curl', + 'telemetry/stream', + 'computer', + 'playwright', + ]); }); }); test('allowlist matching is segment-boundary aware (telemetry/events stays on the control plane)', () => { - const prefixes = ['curl', 'telemetry/stream']; + const prefixes = ['curl', 'telemetry/stream', 'computer', 'playwright']; expect(matchesDirectVMPrefix('telemetry/stream', prefixes)).toBe(true); expect(matchesDirectVMPrefix('telemetry/stream/x', prefixes)).toBe(true); expect(matchesDirectVMPrefix('telemetry/events', prefixes)).toBe(false); expect(matchesDirectVMPrefix('telemetry/streaming-config', prefixes)).toBe(false); expect(matchesDirectVMPrefix('telemetry', prefixes)).toBe(false); expect(matchesDirectVMPrefix('curl/raw', prefixes)).toBe(true); + expect(matchesDirectVMPrefix('computer/screenshot', prefixes)).toBe(true); + expect(matchesDirectVMPrefix('playwright/execute', prefixes)).toBe(true); + expect(matchesDirectVMPrefix('process/exec', prefixes)).toBe(false); expect(matchesDirectVMPrefix('fs/read', prefixes)).toBe(false); }); @@ -498,4 +506,88 @@ describe('browser routing', () => { expect(browserRoutingSubresourcesFromEnv()).toEqual([]); }); }); + + test('routes computer screenshot and playwright execute to the VM 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', + }); + } + if (url.includes('/computer/screenshot')) { + return new Response(new Uint8Array([1, 2, 3]), { + status: 200, + headers: { 'content-type': 'image/png' }, + }); + } + return Response.json({ success: true }); + }, + }); + + await kernel.browsers.create(); + await kernel.browsers.computer.captureScreenshot('sess-1'); + await kernel.browsers.playwright.execute('sess-1', { code: 'return 1' }); + + expect(calls[1]?.url).toBe( + 'http://browser-session.test/browser/kernel/computer/screenshot?jwt=token-abc', + ); + expect(calls[1]?.headers.get('authorization')).toBeNull(); + expect(calls[2]?.url).toBe( + 'http://browser-session.test/browser/kernel/playwright/execute?jwt=token-abc', + ); + expect(calls[2]?.headers.get('authorization')).toBeNull(); + }); + }); + + test('keeps process, fs, and telemetry/events on the API origin by default', async () => { + await withBrowserRoutingEnv(undefined, async () => { + const calls: string[] = []; + const kernel = new Kernel({ + apiKey: 'k', + baseURL: 'https://api.example/', + fetch: async (input) => { + const url = normalizeURL(input); + calls.push(url); + 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', + }); + } + if (url.includes('/telemetry/events')) { + return Response.json([]); + } + if (url.includes('/fs/read_file')) { + return new Response(new Uint8Array([1]), { + status: 200, + headers: { 'content-type': 'application/octet-stream' }, + }); + } + return Response.json({ exit_code: 0, stdout_b64: '', stderr_b64: '' }); + }, + }); + + await kernel.browsers.create(); + await kernel.browsers.process.exec('sess-1', { command: 'echo' }); + await kernel.browsers.fs.readFile('sess-1', { path: '/tmp/x' }); + await kernel.browsers.telemetry.events('sess-1'); + + expect(calls.slice(1)).toEqual([ + 'https://api.example/browsers/sess-1/process/exec', + 'https://api.example/browsers/sess-1/fs/read_file?path=%2Ftmp%2Fx', + 'https://api.example/browsers/sess-1/telemetry/events', + ]); + }); + }); });