From f07ea1086993772d0060b183ec7df7bc4cc77254 Mon Sep 17 00:00:00 2001 From: dwc1997 <66739829+dwc1997@users.noreply.github.com> Date: Tue, 14 Jul 2026 02:33:53 +0800 Subject: [PATCH] fix(deepgram): validate realtime base URL overrides, preserving ws(s):// endpoints (#105334) * fix(deepgram): reject malformed and non-http(s) realtime base URL overrides * fix(deepgram): accept direct ws(s):// realtime base URL overrides Preserve released behavior: v2026.6.11 passes wss:// (and ws://) base URL overrides straight through to the WebSocket URL builder. The prior validator rejected every non-http(s) scheme, breaking custom Deepgram realtime proxies that use a direct wss:// endpoint. Accept ws:/wss: alongside http(s):, keep rejecting malformed and unrelated schemes, and preserve a direct ws(s): override's protocol/path/port through toDeepgramRealtimeWsUrl. * fix(deepgram): keep secure ws:// -> wss:// upgrade for realtime base URL A prior change preserved direct ws:/wss: overrides unchanged, which also stopped upgrading a plaintext ws:// override to wss://. That regressed the released behavior where every accepted non-http: scheme maps to wss:, so an existing ws:// endpoint would begin sending Deepgram realtime audio and provider auth over an unencrypted socket. Restore the release mapping (http: -> ws:, all others -> wss:), which still preserves a direct wss:// override and keeps the ws:// -> wss:// secure upgrade. * fix(deepgram): preserve custom websocket transport Co-authored-by: dwc1997 * test(deepgram): use placeholder credential fixture * test(deepgram): clarify loopback callbacks --------- Co-authored-by: Peter Steinberger --- CHANGELOG.md | 1 + docs/providers/deepgram.md | 25 ++-- .../realtime-transcription-provider.test.ts | 126 +++++++++++++++++- .../realtime-transcription-provider.ts | 32 ++++- 4 files changed, 169 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e33491773ffe..e0b544adfbf8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ Docs: https://docs.openclaw.ai ### Fixes - **Gateway in-process restarts:** clear stale SIGUSR1 restart state and resume prepared host suspensions before rebuilding runtime admission, preventing restart cooldowns or paused scheduling from leaking into the next lifecycle. +- **Deepgram realtime custom endpoints:** validate Voice Call streaming base URLs with secret-safe errors, preserve explicit `ws://` and `wss://` endpoints, and map HTTP schemes to their matching WebSocket transport for dedicated and self-hosted deployments. (#105334) Thanks @dwc1997. - **macOS remote node readiness:** take the main-session key from the node hello snapshot instead of opening an operator connection during node admission, preventing remote tunnel recovery from leaving Computer Use and node exec stuck in lifecycle transition. - **Claude CLI context budgets:** honor Anthropic model and per-agent `contextTokens` limits by passing the effective limit to Claude Code's native auto-compactor and persisting the same prepared budget in OpenClaw session state. Fixes #80933. (#93198) Thanks @mushuiyu886. - **Native app connection and relay reliability:** keep Android disconnects stopped across Activity recreation, fail remote camera commands without opening permission prompts, refresh mobile node registration after capability changes, surface iOS onboarding connection failures, cancel stale Talk owners on session switches, reject invalid Watch acknowledgments, preserve Watch events received during startup, and prevent older agent overview requests from replacing newer gateway state. diff --git a/docs/providers/deepgram.md b/docs/providers/deepgram.md index 5a043e34ebc7..8cf35279e919 100644 --- a/docs/providers/deepgram.md +++ b/docs/providers/deepgram.md @@ -106,15 +106,16 @@ Deepgram `/listen` request, so any Deepgram-supported param name works The bundled `deepgram` plugin also registers a realtime transcription provider for the Voice Call plugin. -| Setting | Config path | Default | -| --------------- | ----------------------------------------------------------------------- | -------------------------------- | -| API key | `plugins.entries.voice-call.config.streaming.providers.deepgram.apiKey` | Falls back to `DEEPGRAM_API_KEY` | -| Model | `...deepgram.model` | `nova-3` | -| Language | `...deepgram.language` | (unset) | -| Encoding | `...deepgram.encoding` | `mulaw` | -| Sample rate | `...deepgram.sampleRate` | `8000` | -| Endpointing | `...deepgram.endpointingMs` | `800` | -| Interim results | `...deepgram.interimResults` | `true` | +| Setting | Config path | Default | +| --------------- | ----------------------------------------------------------------------- | -------------------------------------------- | +| API key | `plugins.entries.voice-call.config.streaming.providers.deepgram.apiKey` | Falls back to `DEEPGRAM_API_KEY` | +| Base URL | `...deepgram.baseUrl` | `DEEPGRAM_BASE_URL` or Deepgram's public API | +| Model | `...deepgram.model` | `nova-3` | +| Language | `...deepgram.language` | (unset) | +| Encoding | `...deepgram.encoding` | `mulaw` | +| Sample rate | `...deepgram.sampleRate` | `8000` | +| Endpointing | `...deepgram.endpointingMs` | `800` | +| Interim results | `...deepgram.interimResults` | `true` | ```json5 { @@ -141,6 +142,12 @@ for the Voice Call plugin. } ``` +For a [Deepgram custom endpoint](https://developers.deepgram.com/reference/custom-endpoints), +set `baseUrl` to the endpoint root, including any base path but not `/listen`. +Realtime endpoints accept `http://`, `https://`, `ws://`, and `wss://`. HTTP +maps to WS, HTTPS maps to WSS, and explicit WebSocket schemes stay unchanged. +Malformed URLs and other schemes fail during session setup. + Voice Call receives telephony audio as 8 kHz G.711 u-law. The Deepgram streaming provider defaults to `encoding: "mulaw"` and `sampleRate: 8000`, so diff --git a/extensions/deepgram/realtime-transcription-provider.test.ts b/extensions/deepgram/realtime-transcription-provider.test.ts index 5ef93b28cbc5..c50f7a552638 100644 --- a/extensions/deepgram/realtime-transcription-provider.test.ts +++ b/extensions/deepgram/realtime-transcription-provider.test.ts @@ -1,13 +1,69 @@ // Deepgram tests cover realtime transcription provider plugin behavior. +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { afterEach, describe, expect, it, vi } from "vitest"; +import type WebSocket from "ws"; +import { WebSocketServer } from "ws"; import { testing, buildDeepgramRealtimeTranscriptionProvider, } from "./realtime-transcription-provider.js"; +let cleanup: (() => Promise) | undefined; + +async function createDeepgramRealtimeServer(params: { + onRequest: (url: URL, headers: Record) => void; +}) { + const server = createServer(); + const wss = new WebSocketServer({ noServer: true }); + const clients = new Set(); + + server.on("upgrade", (request, socket, head) => { + params.onRequest(new URL(request.url ?? "/", "http://127.0.0.1"), request.headers); + wss.handleUpgrade(request, socket, head, (ws) => { + clients.add(ws); + ws.on("close", () => clients.delete(ws)); + }); + }); + + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => resolve()); + }); + const port = (server.address() as AddressInfo).port; + cleanup = async () => { + for (const ws of clients) { + ws.terminate(); + } + await new Promise((resolve) => { + wss.close(() => resolve()); + }); + await new Promise((resolve) => { + server.close(() => resolve()); + }); + }; + return { baseUrl: `http://127.0.0.1:${port}/deepgram/v1` }; +} + +function buildTestRealtimeUrl(baseUrl: string): URL { + return new URL( + testing.toDeepgramRealtimeWsUrl({ + apiKey: "dg-key", + baseUrl, + model: "nova-3", + providerConfig: {}, + sampleRate: 8000, + encoding: "mulaw", + interimResults: true, + endpointingMs: 800, + }), + ); +} + describe("buildDeepgramRealtimeTranscriptionProvider", () => { - afterEach(() => { + afterEach(async () => { + await cleanup?.(); + cleanup = undefined; vi.unstubAllEnvs(); }); @@ -67,4 +123,72 @@ describe("buildDeepgramRealtimeTranscriptionProvider", () => { "Deepgram API key missing", ); }); + + it("returns the default when no value or env is set", () => { + vi.stubEnv("DEEPGRAM_BASE_URL", ""); + expect(testing.normalizeDeepgramRealtimeBaseUrl(undefined)).toBe("https://api.deepgram.com/v1"); + expect(testing.normalizeDeepgramRealtimeBaseUrl(" ")).toBe("https://api.deepgram.com/v1"); + }); + + it.each([ + ["http://localhost:8080/deepgram/v1", "ws:"], + ["https://custom.example.com/deepgram/v1", "wss:"], + ["ws://localhost:8080/deepgram/v1", "ws:"], + ["wss://custom.example.com:8443/deepgram/v1", "wss:"], + ])("maps or preserves %s as %s", (baseUrl, expectedProtocol) => { + const url = buildTestRealtimeUrl(baseUrl); + expect(url.protocol).toBe(expectedProtocol); + expect(url.pathname).toBe("/deepgram/v1/listen"); + }); + + it.each(["not a url", "ftp://files.example.com"])("rejects invalid endpoint %s", (baseUrl) => { + expect(() => testing.normalizeDeepgramRealtimeBaseUrl(baseUrl)).toThrow( + /^Invalid Deepgram baseUrl:/, + ); + }); + + it("validates the environment override", () => { + vi.stubEnv("DEEPGRAM_BASE_URL", "not a url"); + expect(() => testing.normalizeDeepgramRealtimeBaseUrl()).toThrow( + "Invalid Deepgram baseUrl: value is not a valid URL", + ); + }); + + it("does not echo the configured URL in validation errors", () => { + const rawMarker = "configured-value-marker"; + const nonHttp = `ftp://files.example.com/${rawMarker}`; + try { + testing.normalizeDeepgramRealtimeBaseUrl(nonHttp); + throw new Error("expected rejection"); + } catch (error) { + const message = (error as Error).message; + expect(message).toMatch(/unsupported scheme/); + expect(message).not.toContain(rawMarker); + } + }); + + it("connects through an explicit HTTP base URL over loopback WebSocket", async () => { + const requests: Array<{ + url: URL; + headers: Record; + }> = []; + const server = await createDeepgramRealtimeServer({ + onRequest: (url, headers) => requests.push({ url, headers }), + }); + const provider = buildDeepgramRealtimeTranscriptionProvider(); + const session = provider.createSession({ + providerConfig: { + apiKey: "dummy", + baseUrl: server.baseUrl, + }, + }); + + await session.connect(); + session.close(); + + expect(requests).toHaveLength(1); + expect(requests[0]?.url.pathname).toBe("/deepgram/v1/listen"); + expect(requests[0]?.url.searchParams.get("model")).toBe("nova-3"); + expect(requests[0]?.headers.authorization).toBe("Token dummy"); + }); }); diff --git a/extensions/deepgram/realtime-transcription-provider.ts b/extensions/deepgram/realtime-transcription-provider.ts index 037b5d4b509d..65d01dc72ab0 100644 --- a/extensions/deepgram/realtime-transcription-provider.ts +++ b/extensions/deepgram/realtime-transcription-provider.ts @@ -90,15 +90,36 @@ function normalizeDeepgramEncoding( } function normalizeDeepgramRealtimeBaseUrl(value?: string): string { - return ( - normalizeOptionalString(value ?? process.env.DEEPGRAM_BASE_URL) ?? - DEFAULT_DEEPGRAM_AUDIO_BASE_URL - ); + const resolved = normalizeOptionalString(value ?? process.env.DEEPGRAM_BASE_URL); + if (!resolved) { + return DEFAULT_DEEPGRAM_AUDIO_BASE_URL; + } + let parsed: URL; + try { + parsed = new URL(resolved); + } catch { + throw new Error("Invalid Deepgram baseUrl: value is not a valid URL"); + } + const { protocol } = parsed; + if (protocol !== "http:" && protocol !== "https:" && protocol !== "ws:" && protocol !== "wss:") { + // Endpoint URLs can contain userinfo or sensitive query values. Keep the + // error actionable without echoing the configured value. + throw new Error( + `Invalid Deepgram baseUrl: unsupported scheme "${protocol}" (expected http, https, ws, or wss)`, + ); + } + return resolved; } function toDeepgramRealtimeWsUrl(config: DeepgramRealtimeTranscriptionSessionConfig): string { const url = new URL(normalizeDeepgramRealtimeBaseUrl(config.baseUrl)); - url.protocol = url.protocol === "http:" ? "ws:" : "wss:"; + // Self-hosted Deepgram may explicitly use ws:// without TLS. Translate only + // matching HTTP schemes so direct WebSocket endpoints keep their contract. + if (url.protocol === "http:") { + url.protocol = "ws:"; + } else if (url.protocol === "https:") { + url.protocol = "wss:"; + } url.pathname = `${url.pathname.replace(/\/+$/, "")}/listen`; url.searchParams.set("model", config.model); url.searchParams.set("encoding", config.encoding); @@ -250,6 +271,7 @@ export function buildDeepgramRealtimeTranscriptionProvider(): RealtimeTranscript export const testing = { normalizeProviderConfig, + normalizeDeepgramRealtimeBaseUrl, toDeepgramRealtimeWsUrl, }; export { testing as __testing };