mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-21 10:16:44 +00:00
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 <du.wenchi@xydigit.com> * test(deepgram): use placeholder credential fixture * test(deepgram): clarify loopback callbacks --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
co-authored by
Peter Steinberger
parent
cdf4f2208b
commit
f07ea10869
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Note>
|
||||
Voice Call receives telephony audio as 8 kHz G.711 u-law. The Deepgram
|
||||
streaming provider defaults to `encoding: "mulaw"` and `sampleRate: 8000`, so
|
||||
|
||||
@@ -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<void>) | undefined;
|
||||
|
||||
async function createDeepgramRealtimeServer(params: {
|
||||
onRequest: (url: URL, headers: Record<string, string | string[] | undefined>) => void;
|
||||
}) {
|
||||
const server = createServer();
|
||||
const wss = new WebSocketServer({ noServer: true });
|
||||
const clients = new Set<WebSocket>();
|
||||
|
||||
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<void>((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<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
await new Promise<void>((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<string, string | string[] | undefined>;
|
||||
}> = [];
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user