From c4fc33480ca9a3fb5c75c81d779c3357fab06779 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Sat, 27 Jun 2026 00:14:58 -0300 Subject: [PATCH] fix(services): embed WS proxy honours LIVE_WS_HOST; reject empty messages early (#5110) (#5133) --- CHANGELOG.md | 1 + src/lib/services/embedWsProxy.ts | 17 ++- src/sse/handlers/chat.ts | 15 +++ .../embed-ws-host-empty-messages-5110.test.ts | 118 ++++++++++++++++++ 4 files changed, 150 insertions(+), 1 deletion(-) create mode 100644 tests/unit/embed-ws-host-empty-messages-5110.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8664523ca7..ddb76f03ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ _In development — bullets added per PR; finalized at release._ ### 🔧 Bug Fixes +- **fix(services): embed WS proxy honours `LIVE_WS_HOST`; reject empty `messages` early** — two headless/Docker deployment fixes (#5110). The embed WebSocket proxy (`:20131`) only read `EMBED_WS_PROXY_HOST`, so behind a reverse proxy/tunnel it stayed bound to `127.0.0.1` even with `LIVE_WS_HOST=0.0.0.0` set and the Live dashboard showed "WebSocket disconnected"; it now falls back to `LIVE_WS_HOST` (default still loopback). Separately, a request with an explicitly empty `messages: []` array was forwarded upstream and bounced back as a confusing raw `400/502`; `handleChat` now rejects it up front with a clear `messages: at least one message is required` (Responses-API `input` requests are unaffected). ([#5110](https://github.com/diegosouzapw/OmniRoute/issues/5110)) - **fix(proxy): repair one-click Deno & Cloudflare relay deployments** — the `/api/settings/proxy/test` endpoint only recognized the `vercel` relay type, so testing a deployed Deno or Cloudflare relay returned `proxy.type must be http, https, or socks5` and never reached the relay; it now routes all relay types through `isRelayType()`. On installs with `STORAGE_ENCRYPTION_KEY` the relay-auth token is read via `extractRelayAuth` (encrypted `relayAuthEnc` form), fixing the silent `401` that left `publicIp` null. The Cloudflare Worker upload now sends the script part as `application/javascript` (the API rejects `application/javascript+module`; ES-module semantics come from `main_module`), and the proxy-registry schema accepts the `deno`/`cloudflare` types + `deno-relay`/`cloudflare-relay` sources so editing a deployed relay no longer 400s. ([#5128](https://github.com/diegosouzapw/OmniRoute/issues/5128)) - **fix(quota): hydrate the in-memory quota cache from snapshots + scope auto-combo candidates** — after a restart the quota cache was empty, so a known-exhausted connection looked healthy until re-queried; `isAccountQuotaExhausted` now lazily hydrates from persisted `quota_snapshots`. Auto-combo candidate expansion is also scoped to the connections each combo target actually allows, instead of pulling in every connection for the provider. ([#5015](https://github.com/diegosouzapw/OmniRoute/pull/5015) — thanks @JxnLexn) - **fix(resilience): harden quota cutoff, Gemini audio MIME, and model-lockout cooldown** — stored quota hard-cutoff values are no longer coerced to `enabled=true` from arbitrary strings; Gemini audio input parts have their MIME type validated/normalized before forwarding; and model lockout now honours the configured `maxCooldownMs` ceiling. ([#5093](https://github.com/diegosouzapw/OmniRoute/pull/5093) — thanks @KooshaPari) diff --git a/src/lib/services/embedWsProxy.ts b/src/lib/services/embedWsProxy.ts index b5beb36ba3..8ab9052545 100644 --- a/src/lib/services/embedWsProxy.ts +++ b/src/lib/services/embedWsProxy.ts @@ -222,6 +222,21 @@ async function proxyUpgrade(req: IncomingMessage, socket: net.Socket, head: Buff }); } +/** + * Resolve the bind host for the embed WS proxy. + * + * `EMBED_WS_PROXY_HOST` takes precedence, but we fall back to `LIVE_WS_HOST` + * so a single env var exposes BOTH WebSocket sockets (the Live dashboard server + * on :20129 and this embed proxy on :20131) in Docker / behind a reverse proxy + * or tunnel. Without this fallback the embed proxy stayed bound to 127.0.0.1 + * even when the operator set `LIVE_WS_HOST=0.0.0.0`, so the Live view was + * permanently "disconnected" in headless deployments (#5110). Defaults to + * loopback for safety when neither is set. + */ +export function resolveEmbedWsHost(): string { + return process.env.EMBED_WS_PROXY_HOST ?? process.env.LIVE_WS_HOST ?? DEFAULT_HOST; +} + /** * Start the embed WebSocket proxy server. * Idempotent — safe to call multiple times. @@ -229,7 +244,7 @@ async function proxyUpgrade(req: IncomingMessage, socket: net.Socket, head: Buff export function initEmbedWsProxy(): void { if (globalThis.__omnirouteEmbedWsStarted) return; - const host = process.env.EMBED_WS_PROXY_HOST ?? DEFAULT_HOST; + const host = resolveEmbedWsHost(); const port = parseInt(process.env.EMBED_WS_PROXY_PORT ?? String(DEFAULT_PORT), 10); const server = http.createServer((_req, res) => { diff --git a/src/sse/handlers/chat.ts b/src/sse/handlers/chat.ts index 6fb4c47568..87cbce0824 100644 --- a/src/sse/handlers/chat.ts +++ b/src/sse/handlers/chat.ts @@ -223,6 +223,21 @@ export async function handleChat( const rawClientBody = cloneLogPayload(body); + // Early guard: an explicitly empty `messages` array is invalid for every + // upstream (Anthropic/OpenAI both reject "at least one message is required"). + // Forwarding it produced a confusing raw upstream 400/502; reject it here with + // a clear OmniRoute-level error before any routing or upstream call (#5110). + // Responses-API requests use `input` (not `messages`) so they are unaffected, + // and an absent `messages` field is left to downstream validation. + if (Array.isArray((body as { messages?: unknown }).messages) && + (body as { messages: unknown[] }).messages.length === 0) { + log.warn("CHAT", "Rejecting request with empty messages array"); + return errorResponse( + HTTP_STATUS.BAD_REQUEST, + "messages: at least one message is required" + ); + } + // Build clientRawRequest for logging (if not provided) if (!clientRawRequest) { clientRawRequest = buildClientRawRequest(request, rawClientBody); diff --git a/tests/unit/embed-ws-host-empty-messages-5110.test.ts b/tests/unit/embed-ws-host-empty-messages-5110.test.ts new file mode 100644 index 0000000000..f4123dcb53 --- /dev/null +++ b/tests/unit/embed-ws-host-empty-messages-5110.test.ts @@ -0,0 +1,118 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { createChatPipelineHarness } from "../integration/_chatPipelineHarness.ts"; + +// Regression tests for #5110 — two independent Docker/headless deployment bugs: +// +// Issue 1) The Live/embed WebSocket proxy binds 127.0.0.1 only and ignores +// LIVE_WS_HOST. Behind a reverse proxy/tunnel it can never be reached, +// so the dashboard shows "Live disabled — WebSocket disconnected" +// even with LIVE_WS_HOST=0.0.0.0 set. The embed proxy read only +// EMBED_WS_PROXY_HOST; it now falls back to LIVE_WS_HOST. +// +// Issue 4) A request with `messages: []` (empty array) was forwarded upstream, +// which Anthropic rejects with a raw "[400]: messages: at least one +// message is required". OmniRoute now rejects it early with a clear +// OmniRoute-level 400 before any upstream call. + +// ── Issue 1: embed WS bind host honours LIVE_WS_HOST ────────────────────────── +test("#5110-1: resolveEmbedWsHost prefers EMBED_WS_PROXY_HOST, then LIVE_WS_HOST, then loopback", async () => { + const { resolveEmbedWsHost } = await import("../../src/lib/services/embedWsProxy.ts"); + const prevEmbed = process.env.EMBED_WS_PROXY_HOST; + const prevLive = process.env.LIVE_WS_HOST; + try { + delete process.env.EMBED_WS_PROXY_HOST; + delete process.env.LIVE_WS_HOST; + assert.equal(resolveEmbedWsHost(), "127.0.0.1", "default stays loopback for safety"); + + process.env.LIVE_WS_HOST = "0.0.0.0"; + assert.equal( + resolveEmbedWsHost(), + "0.0.0.0", + "LIVE_WS_HOST should control the embed WS bind when EMBED_WS_PROXY_HOST is unset" + ); + + process.env.EMBED_WS_PROXY_HOST = "10.0.0.5"; + assert.equal( + resolveEmbedWsHost(), + "10.0.0.5", + "EMBED_WS_PROXY_HOST still wins when both are set" + ); + } finally { + if (prevEmbed === undefined) delete process.env.EMBED_WS_PROXY_HOST; + else process.env.EMBED_WS_PROXY_HOST = prevEmbed; + if (prevLive === undefined) delete process.env.LIVE_WS_HOST; + else process.env.LIVE_WS_HOST = prevLive; + } +}); + +// ── Issue 4: empty messages array rejected early with a clear 400 ───────────── +const harness = await createChatPipelineHarness("chat-empty-messages-5110"); +const { handleChat, buildRequest, resetStorage, seedConnection } = harness; + +test.beforeEach(async () => { + await resetStorage(); +}); + +test.after(async () => { + await harness.cleanup(); +}); + +test("#5110-4: an empty messages array is rejected with a clear 400 before hitting upstream", async () => { + await seedConnection("anthropic", { apiKey: "sk-ant" }); + + let upstreamCalled = false; + globalThis.fetch = async () => { + upstreamCalled = true; + return new Response("{}", { status: 200, headers: { "content-type": "application/json" } }); + }; + + const response = await handleChat( + buildRequest({ + url: "http://localhost/v1/messages", + body: { + model: "anthropic/claude-haiku-4-5", + max_tokens: 100, + system: "You are helpful.", + messages: [], + }, + }) + ); + + assert.equal(response.status, 400, "empty messages must be a 400, not a forwarded upstream error"); + const body = (await response.json()) as { error?: { message?: string } }; + assert.match( + body.error?.message ?? "", + /at least one message is required/i, + "error should clearly state messages must be non-empty" + ); + assert.equal(upstreamCalled, false, "must not forward an empty-messages request upstream"); +}); + +test("#5110-4: a non-empty messages array still routes normally (guard is not over-broad)", async () => { + await seedConnection("openai", { apiKey: "sk-openai" }); + + let upstreamCalled = false; + globalThis.fetch = async () => { + upstreamCalled = true; + return Response.json({ + id: "x", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "hi" }, finish_reason: "stop" }], + }); + }; + + const response = await handleChat( + buildRequest({ + body: { + model: "openai/gpt-4.1", + stream: false, + messages: [{ role: "user", content: "Hello" }], + }, + }) + ); + + assert.notEqual(response.status, 400, "a valid request must not be caught by the empty guard"); + assert.equal(upstreamCalled, true, "a valid request must still reach upstream"); +});