diff --git a/apps/desktop/electron/main/live-voice/openai-realtime-adapter.ts b/apps/desktop/electron/main/live-voice/openai-realtime-adapter.ts index d19a44fa2e..c4f5673e2d 100644 --- a/apps/desktop/electron/main/live-voice/openai-realtime-adapter.ts +++ b/apps/desktop/electron/main/live-voice/openai-realtime-adapter.ts @@ -3,28 +3,13 @@ import type { LiveBinding } from "@pi-desktop/shared"; import { LIVE_WORK_TOOL_NAME, MAX_LIVE_AUDIO_BYTES, MAX_LIVE_JSON_BYTES, parseLiveWorkArguments, parseRealtimeMessage, RealtimeResponseTracker, realtimeAudioMessage, realtimeSessionMatches, realtimeSessionUpdateMessage, realtimeToolReceiptMessage, realtimeTruncateMessages, realtimeWorkFeedbackMessages } from "@pi-desktop/voice-runtime/live"; import type { LiveAdapter, LiveAdapterContext, LivePlaybackCursor, LiveReceiptDelivery } from "./types"; import type { LiveWorkFeedback } from "@pi-desktop/shared"; +import { realtimeSocketUrl } from "./websocket-endpoint"; import { openLiveWebSocket } from "./websocket-transport"; import { sendJsonBounded, sendJsonConfirmed, waitForReady, waitForSocketReady, websocketJson } from "./websocket-wire"; import { WebSocket } from "ws"; const MAX_FRAME_BYTES = 24000 * 2 / 10; -function realtimeUrl(baseUrl: string, modelId: string): string { - let base: URL; - try { base = new URL(baseUrl); } catch { throw Object.assign(new Error("Realtime Provider URL is invalid"), { errorCode: "LIVE_PROTOCOL_UNSUPPORTED" }); } - if (base.protocol !== "https:" || base.username || base.password || base.search || base.hash) { - throw Object.assign(new Error("Realtime Provider must use an HTTPS base URL without credentials or query parameters"), { errorCode: "LIVE_PROTOCOL_UNSUPPORTED" }); - } - let path = base.pathname.replace(/\/+$/, ""); - if (!path.endsWith("/realtime")) path = `${path}/realtime`; - base.pathname = path; - base.protocol = "wss:"; - base.search = ""; - base.searchParams.set("model", modelId); - base.hash = ""; - return base.toString(); -} - export function createOpenAIRealtimeAdapter(context: LiveAdapterContext): LiveAdapter { const binding = context.binding as Extract; if (context.auth.kind !== "api-key") { @@ -172,7 +157,7 @@ export function createOpenAIRealtimeAdapter(context: LiveAdapterContext): LiveAd mediaKind: "pcm", async connect() { if (closed || context.signal.aborted) throw Object.assign(new Error("Live call was cancelled"), { errorCode: "LIVE_STALE_CALL" }); - const url = realtimeUrl(baseUrl, binding.modelId); + const url = realtimeSocketUrl(baseUrl, binding.modelId); const liveSocket = await openLiveWebSocket({ url, headers: { Authorization: `Bearer ${apiKey}`, ...(binding.wireProfile === "realtime-compat-v1" ? { "OpenAI-Beta": "realtime=v1" } : {}) }, signal: context.signal, endpointOrigin: "user" }); socket = liveSocket; liveSocket.on("message", onSocketMessage); diff --git a/apps/desktop/electron/main/live-voice/websocket-endpoint.ts b/apps/desktop/electron/main/live-voice/websocket-endpoint.ts new file mode 100644 index 0000000000..cf17c4f8fc --- /dev/null +++ b/apps/desktop/electron/main/live-voice/websocket-endpoint.ts @@ -0,0 +1,66 @@ +/** + * Scheme rules for Live WebSocket endpoints, kept free of Electron so they can + * be tested directly. + * + * A user-supplied endpoint (an OpenAI Realtime Provider base URL) follows the + * desktop network policy (ADR 0304): plain `ws` is accepted here and the public + * network guard decides, from `networkPolicy.mode`, whether the plaintext hop + * and its address are allowed. Every third-party endpoint keeps `wss` only. + */ + +export type LiveEndpointOrigin = "user" | "third-party"; + +function liveEndpointError(message: string, errorCode: string): Error { + return Object.assign(new Error(message), { errorCode }); +} + +/** + * Build the Realtime WebSocket URL from a Provider base URL: `https` maps to + * `wss`, `http` maps to `ws`. The scheme is only syntax here; whether `ws` may + * be dialed is decided by the transport's network guard. + */ +export function realtimeSocketUrl(baseUrl: string, modelId: string): string { + let base: URL; + try { + base = new URL(baseUrl); + } catch { + throw liveEndpointError("Realtime Provider URL is invalid", "LIVE_PROTOCOL_UNSUPPORTED"); + } + if ( + (base.protocol !== "https:" && base.protocol !== "http:") || + base.username || + base.password || + base.search || + base.hash + ) { + throw liveEndpointError( + "Realtime Provider must use an HTTP(S) base URL without credentials or query parameters", + "LIVE_PROTOCOL_UNSUPPORTED", + ); + } + let path = base.pathname.replace(/\/+$/, ""); + if (!path.endsWith("/realtime")) path = `${path}/realtime`; + base.pathname = path; + base.protocol = base.protocol === "http:" ? "ws:" : "wss:"; + base.search = ""; + base.searchParams.set("model", modelId); + base.hash = ""; + return base.toString(); +} + +/** + * The HTTP(S) URL the network guard judges for a Live WebSocket URL. Plain + * `ws` is only ever accepted for a user-supplied endpoint. + */ +export function liveSocketGuardUrl(url: string, origin: LiveEndpointOrigin): string { + const parsed = new URL(url); + if (parsed.protocol === "wss:") { + parsed.protocol = "https:"; + return parsed.toString(); + } + if (parsed.protocol === "ws:" && origin === "user") { + parsed.protocol = "http:"; + return parsed.toString(); + } + throw liveEndpointError("Live WebSocket endpoints must use TLS", "LIVE_NETWORK_POLICY_UNSUPPORTED"); +} diff --git a/apps/desktop/electron/main/live-voice/websocket-transport.ts b/apps/desktop/electron/main/live-voice/websocket-transport.ts index 9f030e34ef..73255784c3 100644 --- a/apps/desktop/electron/main/live-voice/websocket-transport.ts +++ b/apps/desktop/electron/main/live-voice/websocket-transport.ts @@ -7,6 +7,7 @@ import { net, session } from "electron"; import { WebSocket } from "ws"; import { allowInsecureUserEndpointsEnabled, noteInsecureUserEndpoint, relaxedNetworkPolicyEnabled } from "../endpoint-policy"; import { createPublicHttpsClient } from "../public-https-fetch"; +import { liveSocketGuardUrl } from "./websocket-endpoint"; import { responseCodeError } from "./websocket-errors"; const HANDSHAKE_TIMEOUT_MS = 15_000; @@ -79,19 +80,22 @@ export async function openLiveWebSocket(input: { signal: AbortSignal; endpointOrigin: "user" | "third-party"; }): Promise { - const parsedUrl = new URL(input.url); - if (parsedUrl.protocol !== "wss:") { - throw Object.assign(new Error("Live WebSocket endpoints must use TLS"), { - errorCode: "LIVE_NETWORK_POLICY_UNSUPPORTED", - }); - } - await endpointGuard.assertPublicUrl(input.url.replace(/^wss:/, "https:"), input.endpointOrigin); + const guardUrl = liveSocketGuardUrl(input.url, input.endpointOrigin); + await endpointGuard.assertPublicUrl(guardUrl, input.endpointOrigin); if (input.signal.aborted) throw input.signal.reason; const route = await session.defaultSession.resolveProxy(input.url); if (input.signal.aborted) { throw Object.assign(new Error("Live provider connection was cancelled"), { errorCode: "LIVE_STALE_CALL" }); } const proxy = parseResolvedProxy(route); + // The proxy tunnel speaks TLS to the destination. A plaintext user endpoint + // is expected to sit outside the proxy (ADR 0304 bypass list), so a proxied + // `ws` route fails closed instead of being tunneled in the clear. + if (proxy && new URL(guardUrl).protocol === "http:") { + throw Object.assign(new Error("plain ws endpoints cannot be reached through the network proxy"), { + errorCode: "LIVE_NETWORK_POLICY_UNSUPPORTED", + }); + } const agent = proxy ? new ProxyTunnelAgent(proxy) : undefined; try { diff --git a/apps/desktop/test/live-voice-websocket-endpoint.test.mjs b/apps/desktop/test/live-voice-websocket-endpoint.test.mjs new file mode 100644 index 0000000000..bf1f5096fb --- /dev/null +++ b/apps/desktop/test/live-voice-websocket-endpoint.test.mjs @@ -0,0 +1,156 @@ +import assert from "node:assert/strict"; +import { once } from "node:events"; +import { createServer as createHttpServer } from "node:http"; +import { createRequire } from "node:module"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { createServer } from "vite"; + +async function loadEndpoint(t) { + const server = await createServer({ + root: fileURLToPath(new URL("..", import.meta.url)), + configFile: false, + server: { middlewareMode: true, hmr: false, ws: false }, + appType: "custom", + optimizeDeps: { noDiscovery: true, include: [] }, + }); + t.after(() => server.close()); + return server.ssrLoadModule("/electron/main/live-voice/websocket-endpoint.ts"); +} + +async function loadTransport(t, proxyRoute = "DIRECT") { + const electronStub = { + name: "live-voice-electron-test-stub", + enforce: "pre", + resolveId(id) { + return id === "electron" ? "\0live-voice-electron-test-stub" : null; + }, + load(id) { + if (id !== "\0live-voice-electron-test-stub") return null; + return `export const net = { fetch: async () => { throw new Error("unexpected fetch"); } }; + export const session = { defaultSession: { resolveProxy: async () => ${JSON.stringify(proxyRoute)} } };`; + }, + }; + const server = await createServer({ + root: fileURLToPath(new URL("..", import.meta.url)), + configFile: false, + plugins: [electronStub], + ssr: { noExternal: ["electron"] }, + server: { middlewareMode: true, hmr: false, ws: false }, + appType: "custom", + optimizeDeps: { noDiscovery: true, include: [] }, + }); + t.after(() => server.close()); + return server.ssrLoadModule("/electron/main/live-voice/websocket-transport.ts"); +} + +test("Live transport connects to a user-supplied loopback ws endpoint", async (t) => { + const { openLiveWebSocket } = await loadTransport(t); + const { WebSocketServer } = createRequire(new URL("../package.json", import.meta.url))("ws"); + const server = createHttpServer(); + const fixture = new WebSocketServer({ server }); + let connections = 0; + let request; + fixture.on("connection", (_socket, incoming) => { + connections++; + request = incoming; + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + t.after(async () => { + for (const socket of fixture.clients) socket.terminate(); + await new Promise((resolve) => fixture.close(resolve)); + server.closeAllConnections(); + await new Promise((resolve) => server.close(resolve)); + }); + + const client = await openLiveWebSocket({ + url: `ws://127.0.0.1:${server.address().port}/v1/realtime?model=fixture`, + headers: { Authorization: "Bearer local-fixture-key" }, + signal: new AbortController().signal, + endpointOrigin: "user", + }); + assert.equal(connections, 1); + assert.ok(request); + assert.equal(request.url, "/v1/realtime?model=fixture"); + assert.equal(request.headers.authorization, "Bearer local-fixture-key"); + const closed = once(client, "close"); + client.close(); + await closed; +}); + +test("Live transport refuses plain ws when Electron routes it through a proxy", async (t) => { + const { openLiveWebSocket } = await loadTransport(t, "PROXY 127.0.0.1:8080"); + + await assert.rejects( + openLiveWebSocket({ + url: "ws://127.0.0.1:8010/v1/realtime?model=fixture", + signal: new AbortController().signal, + endpointOrigin: "user", + }), + { errorCode: "LIVE_NETWORK_POLICY_UNSUPPORTED" }, + ); +}); + +test("Realtime socket URL keeps TLS for HTTPS providers and maps HTTP providers to ws", async (t) => { + const { realtimeSocketUrl } = await loadEndpoint(t); + + assert.equal( + realtimeSocketUrl("https://api.openai.com/v1", "gpt-realtime"), + "wss://api.openai.com/v1/realtime?model=gpt-realtime", + ); + assert.equal( + realtimeSocketUrl("https://example.test/v1/realtime/", "m"), + "wss://example.test/v1/realtime?model=m", + ); + assert.equal( + realtimeSocketUrl("http://127.0.0.1:8010/v1", "local-model"), + "ws://127.0.0.1:8010/v1/realtime?model=local-model", + ); + assert.equal( + realtimeSocketUrl("http://[::1]:8010/v1/", "m"), + "ws://[::1]:8010/v1/realtime?model=m", + ); +}); + +test("Realtime socket URL rejects non-HTTP schemes, credentials, query and fragment", async (t) => { + const { realtimeSocketUrl } = await loadEndpoint(t); + + for (const baseUrl of [ + "not a url", + "ws://127.0.0.1:8010/v1", + "file:///tmp/realtime", + "http://user:secret@127.0.0.1:8010/v1", + "https://api.example.test/v1?key=1", + "https://api.example.test/v1#frag", + ]) { + assert.throws(() => realtimeSocketUrl(baseUrl, "m"), { errorCode: "LIVE_PROTOCOL_UNSUPPORTED" }, baseUrl); + } +}); + +test("Live socket guard URL allows plain ws only for user-supplied endpoints", async (t) => { + const { liveSocketGuardUrl } = await loadEndpoint(t); + + assert.equal( + liveSocketGuardUrl("wss://generativelanguage.googleapis.com/ws?x=1", "third-party"), + "https://generativelanguage.googleapis.com/ws?x=1", + ); + assert.equal( + liveSocketGuardUrl("wss://api.openai.com/v1/realtime?model=m", "user"), + "https://api.openai.com/v1/realtime?model=m", + ); + assert.equal( + liveSocketGuardUrl("ws://127.0.0.1:8010/v1/realtime?model=m", "user"), + "http://127.0.0.1:8010/v1/realtime?model=m", + ); + assert.throws( + () => liveSocketGuardUrl("ws://127.0.0.1:8010/v1/realtime", "third-party"), + { errorCode: "LIVE_NETWORK_POLICY_UNSUPPORTED" }, + ); + assert.throws( + () => liveSocketGuardUrl("https://api.openai.com/v1/realtime", "user"), + { errorCode: "LIVE_NETWORK_POLICY_UNSUPPORTED" }, + ); +}); diff --git a/docs/spec/03-runtime/live-voice.md b/docs/spec/03-runtime/live-voice.md index 28c5e412e6..fd18f94d5a 100644 --- a/docs/spec/03-runtime/live-voice.md +++ b/docs/spec/03-runtime/live-voice.md @@ -44,6 +44,14 @@ sent only to the frame that owns the call. OAuth tokens and API keys remain in Main. SDP, audio and transcript content are not logged. Main WebSocket endpoints are validated before connection and use the configured desktop network proxy. Unsupported proxy routes fail closed. +Codex SDP negotiation stays HTTPS-only and the Gemini endpoint is third-party +and requires `wss`. The OpenAI Realtime Provider base URL is user-supplied +(ADR 0304): an `https` base URL connects over `wss`, and an `http` base URL +connects over plain `ws` only when the network guard accepts it under +`networkPolicy.mode` (`relaxed`, the default; `strict` refuses it). A plain `ws` endpoint is never sent through a +proxy tunnel; a proxied route for it fails closed with +`LIVE_NETWORK_POLICY_UNSUPPORTED`. Base URLs with credentials, a query or a +fragment are refused. The PCM port exists for one prepared call, has bounded frame sizes and credits, and carries no credentials. diff --git a/docs/spec/06-delivery/04-e2e-test-plan.md b/docs/spec/06-delivery/04-e2e-test-plan.md index 738b8523c2..aed1ac09b7 100644 --- a/docs/spec/06-delivery/04-e2e-test-plan.md +++ b/docs/spec/06-delivery/04-e2e-test-plan.md @@ -40,9 +40,10 @@ ### E2E-LIVE-VOICE-public-settings-and-reconnect - **Preconditions:** A built production Renderer and real Electron/Main/Host, - isolated data/profile/project, developer mode off, a local TLS Realtime - fixture and synthetic microphone. Trust only the fixture CA in the child - process; do not disable TLS, sender, sandbox or microphone checks. + isolated data/profile/project, developer mode off, a local TLS or loopback + HTTP Realtime fixture and synthetic microphone. For TLS, trust only the + fixture CA in the child process; do not disable TLS, sender, sandbox or + microphone checks. - **Steps:** Open Live from Composer while disabled and follow Open settings. Find Voice through settings search, bind the fixture account, enable Live, connect, unmute, receive audio/captions, mute and hang up. Cancel a delayed @@ -55,10 +56,14 @@ call. Settings survive restart without reconnecting. Legacy Dictation settings are unchanged; a voice-only call creates no Agent session. - **Coverage:** `pnpm test:e2e:live-voice` drives the built app and its concrete - Realtime GA adapter against local WSS; `live-voice-owner.test.mjs` bundles - the production owner module and rejects other files/frames. Fixture audio - is not physical-device or real-provider acceptance. Commands and results - are recorded in `docs/implementation/live-voice-public-readiness.md`. + Realtime GA adapter against local WSS; `pnpm test:e2e:live-voice -- + --plain-http` repeats the call flow against a loopback `ws://` endpoint. + `live-voice-websocket-endpoint.test.mjs` connects the production transport to + a local loopback WebSocket and verifies proxy refusal; `live-voice-owner.test.mjs` + bundles the production owner module and rejects other files/frames. Fixture + audio is not physical-device or real-provider acceptance. Commands and + results are recorded in + `docs/implementation/live-voice-public-readiness.md`. - **Specs:** [Live Voice](../03-runtime/live-voice.md). ### E2E-LIVE-VOICE-provider-call-lifecycle @@ -98,6 +103,30 @@ The full Electron flow and real-provider/device compatibility remain unverified until their respective isolated acceptance environments are run. +### E2E-LIVE-VOICE-realtime-plaintext-user-endpoint + +- **Preconditions:** Isolated desktop profile with Live Voice enabled and an + OpenAI-compatible API-key Provider whose base URL is a local plain-HTTP + Realtime fixture such as `http://127.0.0.1:/v1`. Do not use a real + provider account. +- **Steps:** Bind the Realtime adapter to that Provider and start a call with + `networkPolicy.mode` at its default (`relaxed`). End the call, switch the + mode to `strict` and start again. Repeat in `relaxed` with a system proxy + that does not bypass the fixture host. +- **Expected:** In `relaxed` mode the call connects over + `ws://127.0.0.1:/v1/realtime?model=…` and the one-time plaintext + notice is raised. In `strict` mode, and on a proxied route, the call fails + before any socket opens with a network-policy error. An `https` base URL + still connects only over `wss`; Gemini never uses `ws` and Codex SDP stays + HTTPS-only. +- **Specs:** [Live Voice](../03-runtime/live-voice.md), + [ADR 0304](../../adr/0304-user-supplied-endpoint-trust.md). +- **Acceptance:** `apps/desktop/test/live-voice-websocket-endpoint.test.mjs` + covers the scheme mapping, the user/third-party split and the refused base + URL shapes; the network guard's `relaxed`/`strict` verdict is covered by the + existing public-network tests. The full Electron flow remains unverified + until its isolated acceptance environment is run. + ### E2E-LIVE-VOICE-four-stage-ui - **Title:** Disabled, preparation, compact call, and deliberate details. diff --git a/scripts/e2e-live-voice.mjs b/scripts/e2e-live-voice.mjs index 7a5f03259a..46c94980db 100644 --- a/scripts/e2e-live-voice.mjs +++ b/scripts/e2e-live-voice.mjs @@ -12,9 +12,11 @@ import { launchLiveVoiceDesktop } from "./e2e/live-voice-desktop.mjs"; import { startLiveVoiceFixture } from "./e2e/live-voice-fixture.mjs"; import { waitFor } from "./e2e/wait.mjs"; -if (process.argv.slice(2).some((arg) => arg !== "--fixture")) { - throw new Error("Usage: node scripts/e2e-live-voice.mjs [--fixture]. Real accounts are never used by this suite."); +const args = process.argv.slice(2); +if (args.some((arg) => !["--fixture", "--plain-http"].includes(arg))) { + throw new Error("Usage: node scripts/e2e-live-voice.mjs [--fixture] [--plain-http]. Real accounts are never used by this suite."); } +const plainHttp = args.includes("--plain-http"); const root = repositoryRoot(); assertDesktopBuild(root); const temp = await mkdtemp(join(tmpdir(), "pi-live-voice-e2e-")); @@ -24,7 +26,7 @@ const home = join(temp, "home"); const project = join(temp, "project"); const evidence = process.env.PI_LIVE_VOICE_EVIDENCE_DIR || join(temp, "evidence"); for (const path of [dataDir, profile, home, project, evidence]) await mkdir(path, { recursive: true }); -const fixture = await startLiveVoiceFixture(root); +const fixture = await startLiveVoiceFixture(root, { plainHttp }); const host = new Host(resolveHostBinary(), dataDir); let desktop; const results = []; @@ -110,7 +112,9 @@ try { const providerId = created.provider.id; await host.call("providers.update", { id: providerId, enabled: true }); await host.stop(); - const launch = () => launchLiveVoiceDesktop({ root, dataDir, profile, home, certificate: fixture.certificate, evidence }); + const launch = () => launchLiveVoiceDesktop({ + root, dataDir, profile, home, certificate: fixture.certificate, evidence, + }); desktop = await launch(); const initial = await settings(); assert.equal(initial.developerMode, false); @@ -156,7 +160,8 @@ try { assert.equal((await status()).call.muted, true); assert.equal((await status()).call.workBinding, undefined); await clickBar("Unmute microphone"); - await waitFor(() => fixture.stats.inputFrames > 0, 10_000, "synthetic microphone reaches the real WSS transport"); + const socketTransport = plainHttp ? "plain WS" : "WSS"; + await waitFor(() => fixture.stats.inputFrames > 0, 10_000, `synthetic microphone reaches the real ${socketTransport} transport`); await waitFor(async () => (await status()).call?.phase === "connected" && fixture.active() === 1, 2_000, "uplink credit keeps the call and provider socket connected"); fixture.reply(); @@ -174,7 +179,7 @@ try { await waitIdle(); await waitFor(() => fixture.active() === 0, 5_000, "provider socket closes after hangup"); assert.deepEqual((await desktop.invoke("sessionList")).sessions, [], "voice-only call creates no Agent session"); - pass("real Realtime socket, synthetic capture, playback/transcript, mute and hangup"); + pass(`real Realtime ${socketTransport} socket, synthetic capture, playback/transcript, mute and hangup`); fixture.holdNextSession(); await desktop.clickSelector(".live-voice-control button"); @@ -217,7 +222,7 @@ try { candidate: execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(), baseMain: execFileSync("git", ["rev-parse", "origin/main"], { cwd: root, encoding: "utf8" }).trim(), date: new Date().toISOString(), platform: process.platform, node: process.version, - boundary: "built Electron, real preload/Main/Host, concrete Realtime GA adapter, local trusted TLS peer and synthetic audio", + boundary: `built Electron, real preload/Main/Host, concrete Realtime GA adapter, local ${plainHttp ? "loopback HTTP" : "trusted TLS"} peer and synthetic audio`, notRun: ["real provider accounts", "physical microphone and audible playback", "Live Work tool execution"], results, fixture: fixture.stats, }, null, 2)); diff --git a/scripts/e2e/live-voice-fixture.mjs b/scripts/e2e/live-voice-fixture.mjs index a213a44d7e..23bed87937 100644 --- a/scripts/e2e/live-voice-fixture.mjs +++ b/scripts/e2e/live-voice-fixture.mjs @@ -1,18 +1,22 @@ import assert from "node:assert/strict"; -import { createServer } from "node:https"; +import { createServer as createHttpServer } from "node:http"; +import { createServer as createHttpsServer } from "node:https"; import { readFile } from "node:fs/promises"; import { createRequire } from "node:module"; import { join } from "node:path"; -/** Local TLS peer only: the app still owns its real socket, media and IPC path. */ -export async function startLiveVoiceFixture(root) { +/** Local peer only: the app still owns its real socket, media and IPC path. */ +export async function startLiveVoiceFixture(root, { plainHttp = false } = {}) { const { WebSocketServer, WebSocket } = createRequire(join(root, "apps/desktop/package.json"))("ws"); const certificates = join(root, "scripts/e2e/fixtures/certificates"); const certificate = join(certificates, "localhost-cert.pem"); - const server = createServer({ - key: await readFile(join(certificates, "localhost-key.pem")), - cert: await readFile(certificate), - }, (_request, response) => { response.writeHead(404); response.end(); }); + const requestHandler = (_request, response) => { response.writeHead(404); response.end(); }; + const server = plainHttp + ? createHttpServer(requestHandler) + : createHttpsServer({ + key: await readFile(join(certificates, "localhost-key.pem")), + cert: await readFile(certificate), + }, requestHandler); const sockets = new Set(); const timers = new Set(); const errors = []; @@ -34,7 +38,8 @@ export async function startLiveVoiceFixture(root) { const held = holdNext; holdNext = false; try { - assert.equal(new URL(request.url, "https://localhost").pathname, "/v1/realtime"); + const base = plainHttp ? "http://127.0.0.1" : "https://localhost"; + assert.equal(new URL(request.url, base).pathname, "/v1/realtime"); assert.equal(request.headers.authorization, "Bearer live-voice-e2e-key"); } catch { errors.push("unexpected Realtime connection metadata"); socket.close(); return; } socket.once("close", () => { sockets.delete(socket); stats.closed++; }); @@ -74,8 +79,8 @@ export async function startLiveVoiceFixture(root) { server.listen(0, "127.0.0.1", resolve); }); return { - certificate, - baseUrl: `https://localhost:${server.address().port}/v1`, + certificate: plainHttp ? undefined : certificate, + baseUrl: `${plainHttp ? "http://127.0.0.1" : "https://localhost"}:${server.address().port}/v1`, stats, errors, active: () => sockets.size,