diff --git a/clients/chrome-extension/background/__tests__/worker-host-browser-result.test.ts b/clients/chrome-extension/background/__tests__/worker-host-browser-result.test.ts new file mode 100644 index 00000000000..aa8e34dd60c --- /dev/null +++ b/clients/chrome-extension/background/__tests__/worker-host-browser-result.test.ts @@ -0,0 +1,266 @@ +/** + * Tests for the relay-aware host_browser result poster. + * + * Drives `postHostBrowserResult` against a fake `fetch` and a fake + * `RelayConnection` so we can exercise both transport branches without + * standing up a real socket or local daemon. Covers: + * + * - self-hosted mode: POSTs to `${baseUrl}/v1/host-browser-result` + * with `Authorization: Bearer ` and the JSON-serialised + * result envelope as the body. + * - cloud mode with an OPEN connection: sends a JSON-stringified + * `host_browser_result` frame via `connection.send` and never + * touches `fetch`. + * - cloud mode with a closed or null connection: logs a warning, + * never touches `fetch`, and never throws. + * + * The function lives in `relay-connection.ts` (rather than `worker.ts`) + * so the test can import it directly without dragging in the chrome + * service worker module surface. + */ + +import { describe, test, expect, beforeEach, afterEach } from 'bun:test'; + +import { + postHostBrowserResult, + type RelayConnectionLike, + type RelayMode, +} from '../relay-connection.js'; +import type { HostBrowserResultEnvelope } from '../host-browser-dispatcher.js'; + +// ── Fake transports ───────────────────────────────────────────────── + +interface FakeFetchCall { + input: string; + init?: RequestInit; +} + +interface FakeFetchHandle { + calls: FakeFetchCall[]; + /** Sets the response returned by the next fetch call. */ + setNextResponse(resp: Response): void; + restore(): void; +} + +function installFakeFetch(): FakeFetchHandle { + const calls: FakeFetchCall[] = []; + let nextResponse: Response = new Response(null, { status: 200 }); + const original = (globalThis as { fetch?: typeof fetch }).fetch; + (globalThis as { fetch: typeof fetch }).fetch = (async ( + input: RequestInfo | URL, + init?: RequestInit, + ) => { + calls.push({ input: String(input), init }); + return nextResponse; + }) as typeof fetch; + return { + calls, + setNextResponse(resp) { + nextResponse = resp; + }, + restore() { + if (original) { + (globalThis as { fetch: typeof fetch }).fetch = original; + } else { + delete (globalThis as { fetch?: typeof fetch }).fetch; + } + }, + }; +} + +interface FakeConnection extends RelayConnectionLike { + sent: string[]; + /** Toggle whether `isOpen()` returns true or false. */ + open: boolean; +} + +function makeFakeConnection(open: boolean): FakeConnection { + const sent: string[] = []; + return { + sent, + open, + isOpen() { + return this.open; + }, + send(data) { + sent.push(data); + }, + }; +} + +interface ConsoleSpy { + warnings: unknown[][]; + restore(): void; +} + +function spyConsoleWarn(): ConsoleSpy { + const warnings: unknown[][] = []; + const original = console.warn; + console.warn = (...args: unknown[]) => { + warnings.push(args); + }; + return { + warnings, + restore() { + console.warn = original; + }, + }; +} + +// ── Fixtures ──────────────────────────────────────────────────────── + +const exampleResult: HostBrowserResultEnvelope = { + requestId: 'req-abc', + content: '{"frameId":"42"}', + isError: false, +}; + +let fetchHandle: FakeFetchHandle; +let consoleSpy: ConsoleSpy; + +beforeEach(() => { + fetchHandle = installFakeFetch(); + consoleSpy = spyConsoleWarn(); +}); + +afterEach(() => { + fetchHandle.restore(); + consoleSpy.restore(); +}); + +// ── Self-hosted mode ──────────────────────────────────────────────── + +describe('postHostBrowserResult — self-hosted mode', () => { + test('POSTs to ${baseUrl}/v1/host-browser-result with bearer auth', async () => { + const mode: RelayMode = { + kind: 'self-hosted', + baseUrl: 'http://127.0.0.1:9999', + token: 'tok-1', + }; + + await postHostBrowserResult(mode, null, exampleResult); + + expect(fetchHandle.calls.length).toBe(1); + const call = fetchHandle.calls[0]; + expect(call.input).toBe('http://127.0.0.1:9999/v1/host-browser-result'); + expect(call.init?.method).toBe('POST'); + const headers = call.init?.headers as Record | undefined; + expect(headers?.authorization).toBe('Bearer tok-1'); + expect(headers?.['content-type']).toBe('application/json'); + expect(call.init?.body).toBe(JSON.stringify(exampleResult)); + }); + + test('omits the authorization header when no token is configured', async () => { + const mode: RelayMode = { + kind: 'self-hosted', + baseUrl: 'http://127.0.0.1:9999', + token: null, + }; + + await postHostBrowserResult(mode, null, exampleResult); + + expect(fetchHandle.calls.length).toBe(1); + const headers = fetchHandle.calls[0].init?.headers as + | Record + | undefined; + expect(headers?.authorization).toBeUndefined(); + }); + + test('strips a trailing slash from the base URL', async () => { + const mode: RelayMode = { + kind: 'self-hosted', + baseUrl: 'http://127.0.0.1:9999/', + token: 'tok-1', + }; + + await postHostBrowserResult(mode, null, exampleResult); + + expect(fetchHandle.calls[0].input).toBe( + 'http://127.0.0.1:9999/v1/host-browser-result', + ); + }); + + test('logs a warning when the daemon returns a non-2xx status', async () => { + fetchHandle.setNextResponse(new Response(null, { status: 503 })); + const mode: RelayMode = { + kind: 'self-hosted', + baseUrl: 'http://127.0.0.1:9999', + token: 'tok-1', + }; + + await postHostBrowserResult(mode, null, exampleResult); + + expect(consoleSpy.warnings.length).toBeGreaterThanOrEqual(1); + const flat = consoleSpy.warnings.flat().join(' '); + expect(flat).toContain('503'); + }); + + test('ignores the supplied connection in self-hosted mode', async () => { + const conn = makeFakeConnection(true); + const mode: RelayMode = { + kind: 'self-hosted', + baseUrl: 'http://127.0.0.1:9999', + token: 'tok-1', + }; + + await postHostBrowserResult(mode, conn, exampleResult); + + expect(fetchHandle.calls.length).toBe(1); + expect(conn.sent).toEqual([]); + }); +}); + +// ── Cloud mode ────────────────────────────────────────────────────── + +describe('postHostBrowserResult — cloud mode', () => { + test('sends a host_browser_result frame over an open connection and skips fetch', async () => { + const conn = makeFakeConnection(true); + const mode: RelayMode = { + kind: 'cloud', + baseUrl: 'https://api.vellum.ai', + token: 'cloud-token', + }; + + await postHostBrowserResult(mode, conn, exampleResult); + + expect(fetchHandle.calls).toEqual([]); + expect(conn.sent.length).toBe(1); + const parsed = JSON.parse(conn.sent[0]) as Record; + expect(parsed.type).toBe('host_browser_result'); + expect(parsed.requestId).toBe(exampleResult.requestId); + expect(parsed.content).toBe(exampleResult.content); + expect(parsed.isError).toBe(exampleResult.isError); + }); + + test('warns and no-ops when the connection is not open', async () => { + const conn = makeFakeConnection(false); + const mode: RelayMode = { + kind: 'cloud', + baseUrl: 'https://api.vellum.ai', + token: 'cloud-token', + }; + + const returned = await postHostBrowserResult(mode, conn, exampleResult); + expect(returned).toBeUndefined(); + + expect(fetchHandle.calls).toEqual([]); + expect(conn.sent).toEqual([]); + const flat = consoleSpy.warnings.flat().join(' '); + expect(flat).toContain('cloud relay not connected'); + }); + + test('warns and no-ops when the connection is null', async () => { + const mode: RelayMode = { + kind: 'cloud', + baseUrl: 'https://api.vellum.ai', + token: 'cloud-token', + }; + + const returned = await postHostBrowserResult(mode, null, exampleResult); + expect(returned).toBeUndefined(); + + expect(fetchHandle.calls).toEqual([]); + const flat = consoleSpy.warnings.flat().join(' '); + expect(flat).toContain('cloud relay not connected'); + }); +}); diff --git a/clients/chrome-extension/background/relay-connection.ts b/clients/chrome-extension/background/relay-connection.ts index 3b5b7c45546..6ada322711d 100644 --- a/clients/chrome-extension/background/relay-connection.ts +++ b/clients/chrome-extension/background/relay-connection.ts @@ -14,8 +14,17 @@ * relay messages — worker.ts owns the envelope dispatch (ExtensionCommand * + host_browser_request via the PR 9 dispatcher) via the `onMessage` * callback. + * + * This module also exports {@link postHostBrowserResult}, the relay-aware + * helper used by the host-browser dispatcher to ship CDP result envelopes + * back to the daemon. In self-hosted mode the result is POSTed to the + * local `/v1/host-browser-result` HTTP endpoint; in cloud mode it would + * round-trip back through the gateway WebSocket — see the function + * docstring for the current Phase 2 behaviour. */ +import type { HostBrowserResultEnvelope } from './host-browser-dispatcher.js'; + /** Reconnect backoff bounds mirror the legacy inline worker.ts values. */ const RECONNECT_BASE_MS = 1_000; const RECONNECT_MAX_MS = 30_000; @@ -246,3 +255,77 @@ export class RelayConnection { this.reconnectDelay = Math.min(this.reconnectDelay * 2, RECONNECT_MAX_MS); } } + +// ── host_browser result poster ───────────────────────────────────── +// +// The host-browser dispatcher needs a way to ship CDP result envelopes +// back to the daemon. The transport depends on the relay mode: +// +// - self-hosted: POST to the local daemon's +// `/v1/host-browser-result` endpoint, authenticated with the +// stored capability token. +// - cloud: send the envelope as a `host_browser_result` frame over +// the existing browser-relay WebSocket. The gateway proxies the +// frame straight through to the runtime — see +// `gateway/src/http/routes/browser-relay-websocket.ts`. (Phase 3 +// will land the runtime-side handler for inbound result frames; +// today the runtime drops them, but the cloud CDP path is +// feature-flagged off in Phase 2 so this is harmless.) + +/** + * Minimal subset of {@link RelayConnection} that {@link postHostBrowserResult} + * actually consumes. Used by tests to inject a fake without having to + * stand up a real WebSocket. + */ +export interface RelayConnectionLike { + isOpen(): boolean; + send(data: string): void; +} + +/** + * Ship a host_browser result envelope back to the daemon. + * + * In self-hosted mode this POSTs to `${mode.baseUrl}/v1/host-browser-result` + * with `Authorization: Bearer `. In cloud mode it sends a + * `{ type: 'host_browser_result', ...result }` frame over the supplied + * relay connection. + * + * The cloud branch is a no-op (with a console.warn) when the connection + * is missing or not currently open. We deliberately do NOT throw — the + * dispatcher's error path catches and logs synchronously, but a thrown + * rejection here would bubble up to the service worker as an unhandled + * promise rejection. + */ +export async function postHostBrowserResult( + mode: RelayMode, + connection: RelayConnectionLike | null, + result: HostBrowserResultEnvelope, +): Promise { + if (mode.kind === 'cloud') { + if (!connection || !connection.isOpen()) { + console.warn( + '[vellum-relay] host-browser-result dropped: cloud relay not connected', + ); + return; + } + connection.send(JSON.stringify({ type: 'host_browser_result', ...result })); + return; + } + + // self-hosted: POST to the local daemon. The base URL is whatever + // `buildRelayModeConfig` resolved at connect time (usually + // `http://127.0.0.1:`). + const headers: Record = { 'content-type': 'application/json' }; + if (mode.token) headers.authorization = `Bearer ${mode.token}`; + const url = `${mode.baseUrl.replace(/\/$/, '')}/v1/host-browser-result`; + const resp = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(result), + }); + if (!resp.ok) { + console.warn( + `[vellum-relay] host-browser-result POST returned ${resp.status}`, + ); + } +} diff --git a/clients/chrome-extension/background/worker.ts b/clients/chrome-extension/background/worker.ts index 07d660ff2a5..d48469791b7 100644 --- a/clients/chrome-extension/background/worker.ts +++ b/clients/chrome-extension/background/worker.ts @@ -38,7 +38,11 @@ import { type HostBrowserCancelEnvelope, type HostBrowserResultEnvelope, } from './host-browser-dispatcher.js'; -import { RelayConnection, type RelayMode } from './relay-connection.js'; +import { + RelayConnection, + postHostBrowserResult, + type RelayMode, +} from './relay-connection.js'; // Cloud OAuth defaults — kept here so the popup can stay a thin client and the // service worker is the single owner of the launchWebAuthFlow lifecycle. This @@ -66,6 +70,12 @@ function isRelayModeKind(v: unknown): v is RelayModeKind { let relayMode: RelayModeKind = 'self-hosted'; let relayConnection: RelayConnection | null = null; +// Active RelayMode (mode kind + base URL + token) captured at connect +// time. Tracked alongside `relayConnection` so the host-browser +// dispatcher's `postResult` callback can route results back through the +// correct transport (cloud WebSocket vs self-hosted HTTP) using the +// same credentials the live socket was opened with. +let activeRelayMode: RelayMode | null = null; let heartbeatTimer: ReturnType | null = null; let shouldConnect = false; @@ -98,25 +108,36 @@ async function resolveHostBrowserTarget( return { tabId: activeTab.id }; } -async function postHostBrowserResult(result: HostBrowserResultEnvelope): Promise { - const [token, port] = await Promise.all([getBearerToken(), getRelayPort()]); - const headers: Record = { 'content-type': 'application/json' }; - if (token) headers.authorization = `Bearer ${token}`; - const resp = await fetch(`http://127.0.0.1:${port}/v1/host-browser-result`, { - method: 'POST', - headers, - body: JSON.stringify(result), - }); - if (!resp.ok) { - console.warn( - `[vellum-relay] host-browser-result POST returned ${resp.status}`, - ); +/** + * Bridge the host-browser dispatcher to the relay-aware + * {@link postHostBrowserResult} helper. Reads the live `activeRelayMode` + * and `relayConnection` so a result envelope generated mid-session is + * always shipped through the transport that's currently connected — not + * a stale snapshot captured at module init. + * + * Falls back to a self-hosted POST against the bare bearer token + relay + * port when no relay session has been established yet (e.g. the host + * browser dispatcher fired before `connect()` ran). This preserves the + * pre-Phase 2 behaviour for the legacy ExtensionCommand → daemon path. + */ +async function dispatchHostBrowserResult( + result: HostBrowserResultEnvelope, +): Promise { + if (activeRelayMode) { + return postHostBrowserResult(activeRelayMode, relayConnection, result); } + const [token, port] = await Promise.all([getBearerToken(), getRelayPort()]); + const fallback: RelayMode = { + kind: 'self-hosted', + baseUrl: `http://127.0.0.1:${port}`, + token, + }; + return postHostBrowserResult(fallback, null, result); } const hostBrowserDispatcher: HostBrowserDispatcher = createHostBrowserDispatcher({ resolveTarget: resolveHostBrowserTarget, - postResult: postHostBrowserResult, + postResult: dispatchHostBrowserResult, }); // ── Storage helpers ───────────────────────────────────────────────── @@ -253,6 +274,10 @@ async function connect(): Promise { relayConnection.close(1000, 'reconfigured'); } relayConnection = createRelayConnection(mode); + // Stash the resolved mode so the host-browser dispatcher can route + // results back through the same transport (cloud WebSocket vs + // self-hosted HTTP) without re-resolving the token / base URL. + activeRelayMode = mode; relayConnection.start(); } @@ -262,6 +287,7 @@ function disconnect(): void { relayConnection.close(1000, 'User disconnected'); relayConnection = null; } + activeRelayMode = null; } /** @@ -579,9 +605,14 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponseFn) => { // can't tear down the awaited promise before the token is persisted. // chrome.runtime.connectNative also requires the "nativeMessaging" // permission, which is declared in manifest.json. + // + // IMPORTANT: use `sendResponseFn` (the chrome.runtime.onMessage + // callback) — NOT the module-level `sendResponse` helper, which + // forwards to the WebSocket relay and would leave the popup's + // requestLocalPair() promise hanging forever. bootstrapLocalToken() - .then((stored: StoredLocalToken) => sendResponse({ ok: true, token: stored })) - .catch((err) => sendResponse({ ok: false, error: err instanceof Error ? err.message : String(err) })); + .then((stored: StoredLocalToken) => sendResponseFn({ ok: true, token: stored })) + .catch((err) => sendResponseFn({ ok: false, error: err instanceof Error ? err.message : String(err) })); return true; // async } });