Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -92,8 +92,28 @@ PORT=20128
# Comma-separated extra origins allowed to open a live WebSocket. The
# loopback dashboard origins are already permitted by default; use this
# var when fronting the server with a domain (e.g. https://omni.local).
# ⚠️ When using NEXT_PUBLIC_LIVE_WS_PUBLIC_URL or exposing the WS server
# beyond loopback, this MUST include the public origin(s) — otherwise
# the Origin allow-list check will reject all browser connections.
# Example: LIVE_WS_ALLOWED_ORIGINS=https://omni.local,https://dashboard.example.com,https://ws.my-ai.com
# LIVE_WS_ALLOWED_ORIGINS=https://omni.local,https://dashboard.example.com

# Comma-separated extra hostnames allowed to open a live WebSocket (LAN/Tailscale).
# Unlike LIVE_WS_ALLOWED_ORIGINS (which matches full origin URLs), this matches
# only the host portion — useful for wildcard-ish LAN/Tailscale setups.
# Used by: src/server/ws/liveServerAllowList.ts
# Example: LIVE_WS_ALLOWED_HOSTS=omni.local,tailscale-host,192.168.1.50
# LIVE_WS_ALLOWED_HOSTS=omni.local,tailscale-host,192.168.1.50

# Public URL for the live dashboard WebSocket (client-side, browser only).
# Set this when fronting the WS server with a reverse proxy or Cloudflare Tunnel.
# The browser will connect to this URL instead of ws://hostname:20129.
# The /live-ws path is already proxied from the main app (port 20128) to the
# live WS server (port 20129) by scripts/dev/standalone-server-ws.mjs.
# Used by: src/hooks/useLiveDashboard.ts
# Example: NEXT_PUBLIC_LIVE_WS_PUBLIC_URL=wss://ws.my-ai.com/live-ws
# NEXT_PUBLIC_LIVE_WS_PUBLIC_URL=

# Disable the standalone live WebSocket helper used by scripts/start-ws-server.mjs.
# Used by: scripts/start-ws-server.mjs (CI/embedded harness toggle).
# OMNIROUTE_DISABLE_LIVE_WS=0
Expand Down Expand Up @@ -804,7 +824,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# ── GitLab Duo ──
# Register an OAuth app at: https://gitlab.com/-/profile/applications
# Set redirect URI to: http://localhost:20128/callback (or your NEXT_PUBLIC_BASE_URL + /callback)
# Required scopes: api, read_user, openid, profile, email
# Required scopes: ai_features, read_user (matches GITLAB_DUO_CONFIG.scope in src/lib/oauth/constants/oauth.ts)
# GITLAB_DUO_OAUTH_CLIENT_ID=***
# GITLAB_DUO_OAUTH_CLIENT_SECRET=*** # optional — PKCE flow does not require a secret
#
Expand Down
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@

### ✨ New Features

- **dashboard (live WS behind reverse proxy):** the live dashboard WebSocket can now be fronted by a reverse proxy or Cloudflare Tunnel via `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL` (e.g. `wss://ws.my-ai.com/live-ws`). The URL is honored both at build time (env inlined into the bundle) and at **runtime** for prebuilt Docker/npm images: the `/api/v1/ws?handshake=1` handshake now echoes a lazily-read `live.publicUrl` (only `ws://`/`wss://` values are accepted; anything else is rejected to `null`), and `useLiveDashboard` resolves the URL from that handshake before connecting, falling back to the previous `ws(s)://hostname:20129` default. Also documents `LIVE_WS_ALLOWED_HOSTS` and aligns the GitLab Duo OAuth scopes line in `.env.example` with the live config (`ai_features read_user`). Regression guard: `tests/unit/live-ws-public-url.test.ts` (5). ([#5877](https://github.com/diegosouzapw/OmniRoute/pull/5877) by [@ianriizky](https://github.com/ianriizky))

- **providers (CLI profile auto-sync):** opt-in toggles to auto-regenerate CLI tool profiles after a provider model sync. When enabled, a model-catalog change (re)writes that tool's profile files from the live catalog — Codex (`~/.codex/*.config.toml`) and now **Claude Code** (`~/.claude/profiles/<name>/settings.json`, via an extracted `syncClaudeProfilesFromModels` + a new `claudeProfileAutoSync.ts` mirroring the Codex path). Both are **off by default** and never touch the active/default CLI config; they are backed by the `OMNIROUTE_AUTO_SYNC_CODEX_PROFILES` / `OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES` feature flags (DB/dashboard override > env > default "false") and additionally gated behind the existing `CLI_ALLOW_CONFIG_WRITES` write-guard. A **"CLI profile auto-sync"** card on the CLI Code dashboard toggles each (moved from the providers dashboard in [#5778](https://github.com/diegosouzapw/OmniRoute/pull/5778) — thanks [@rdself](https://github.com/rdself)). Regression guards: `tests/unit/claude-profile-auto-sync-gate.test.ts`, `tests/unit/codex-profile-auto-sync-gate.test.ts`, `tests/unit/cli/setup-claude.test.ts` (follow-up to #5737).

- **cli (startup banner):** the `serve` startup banner now prints the running OmniRoute version (`v3.8.x`) beneath the ASCII logo, so the active version is visible at a glance without a separate `--version` call. Regression guard: `tests/unit/cli-serve-version-banner.test.ts`. Thanks [@chirag127](https://github.com/chirag127) ([#5752](https://github.com/diegosouzapw/OmniRoute/pull/5752)).
Expand Down
51 changes: 33 additions & 18 deletions src/app/api/v1/ws/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,36 @@ const WS_HANDSHAKE_HEADERS = {
"Cache-Control": "no-store",
};

const WS_PROTOCOL = {
request: {
type: "request",
id: "req-1",
payload: { model: "openai/gpt-4.1-mini", messages: [] },
},
cancel: { type: "cancel", id: "req-1" },
live: {
port: parseInt(process.env.LIVE_WS_PORT || "20129", 10),
path: "/live",
protocol: "json",
channels: ["requests", "combo", "credentials"],
auth: "api-key",
heartbeatMs: 15000,
},
};
/**
* Public URL for the live dashboard WebSocket (reverse proxy / Cloudflare
* Tunnel setups). Read lazily at request time (not module load) so runtime
* env changes are honored, and only echoed when it is a ws:// or wss:// URL.
*/
function getLivePublicUrl(): string | null {
const publicUrl = process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL;
if (!publicUrl) return null;
return publicUrl.startsWith("ws://") || publicUrl.startsWith("wss://") ? publicUrl : null;
}

function getWsProtocol() {
return {
request: {
type: "request",
id: "req-1",
payload: { model: "openai/gpt-4.1-mini", messages: [] },
},
cancel: { type: "cancel", id: "req-1" },
live: {
port: parseInt(process.env.LIVE_WS_PORT || "20129", 10),
publicUrl: getLivePublicUrl(),
path: "/live",
protocol: "json",
channels: ["requests", "combo", "credentials"],
auth: "api-key",
heartbeatMs: 15000,
},
};
}

export async function OPTIONS() {
return new Response(null, {
Expand Down Expand Up @@ -66,9 +80,10 @@ export async function GET(request: Request) {
wsAuth: auth.wsAuth,
authenticated: auth.authenticated,
authType: auth.authType,
protocol: WS_PROTOCOL,
protocol: getWsProtocol(),
live: {
port: parseInt(process.env.LIVE_WS_PORT || "20129", 10),
publicUrl: getLivePublicUrl(),
path: "/live",
protocol: "json",
channels: ["requests", "combo", "credentials"],
Expand All @@ -91,7 +106,7 @@ export async function GET(request: Request) {
},
path: auth.wsPath,
wsAuth: auth.wsAuth,
protocol: WS_PROTOCOL,
protocol: getWsProtocol(),
},
{
status: 426,
Expand Down
58 changes: 54 additions & 4 deletions src/hooks/useLiveDashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,19 @@ import type { DashboardChannel, DashboardEventName } from "@/lib/events/types";
// ── Config ────────────────────────────────────────────────────────────────

const WS_RECONNECT_DELAYS = [1000, 2000, 4000, 8000, 16000, 30000];

/** Only accept ws:// or wss:// URLs (mirrors the guard in src/app/api/v1/ws/route.ts). */
function sanitizeWsPublicUrl(url: unknown): string | null {
if (typeof url !== "string" || url.length === 0) return null;
return url.startsWith("ws://") || url.startsWith("wss://") ? url : null;
}

// Build-time inlined value (Docker/npm prebuilt images won't have this — the
// runtime value is discovered via the /api/v1/ws?handshake=1 handshake below).
const BUILD_TIME_PUBLIC_WS_URL = sanitizeWsPublicUrl(process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL);

function getDefaultWsUrl(): string {
if (BUILD_TIME_PUBLIC_WS_URL) return BUILD_TIME_PUBLIC_WS_URL;
if (typeof window === "undefined") return "ws://localhost:20129";
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const { hostname } = window.location;
Expand Down Expand Up @@ -72,7 +84,7 @@ export interface UseLiveDashboardOptions {
* Manages connection lifecycle, reconnection, and event streaming.
*/
export function useLiveDashboard({
wsUrl = DEFAULT_WS_URL,
wsUrl,
enabled = true,
apiKey,
channels = ["requests", "combo", "credentials"],
Expand All @@ -86,6 +98,38 @@ export function useLiveDashboard({
reconnectAttempt: 0,
});

// Runtime discovery of the public WS URL via the handshake endpoint.
// NEXT_PUBLIC_* env vars are inlined at build time, so prebuilt Docker/npm
// images never see a runtime NEXT_PUBLIC_LIVE_WS_PUBLIC_URL — the server
// echoes it in the /api/v1/ws?handshake=1 response instead.
// Skipped when the caller passes an explicit wsUrl or the env was inlined.
const needsHandshake = !wsUrl && !BUILD_TIME_PUBLIC_WS_URL && typeof window !== "undefined";
const [handshakeUrl, setHandshakeUrl] = useState<string | null>(null);
const [wsUrlResolved, setWsUrlResolved] = useState(!needsHandshake);

useEffect(() => {
if (!needsHandshake || wsUrlResolved) return;
let cancelled = false;
fetch("/api/v1/ws?handshake=1")
.then((res) => (res.ok ? res.json() : null))
.then((body) => {
if (cancelled) return;
const publicUrl = sanitizeWsPublicUrl(body?.live?.publicUrl);
if (publicUrl) setHandshakeUrl(publicUrl);
})
.catch(() => {
// Handshake unavailable — fall back to the default URL.
})
.finally(() => {
if (!cancelled) setWsUrlResolved(true);
});
return () => {
cancelled = true;
};
}, [needsHandshake, wsUrlResolved]);

const effectiveWsUrl = wsUrl ?? handshakeUrl ?? DEFAULT_WS_URL;

const [events, setEvents] = useState<WsEventPayload[]>([]);
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
Expand All @@ -108,7 +152,9 @@ export function useLiveDashboard({
}));

try {
const wsUrlWithAuth = apiKey ? `${wsUrl}?token=${encodeURIComponent(apiKey)}` : wsUrl;
const wsUrlWithAuth = apiKey
? `${effectiveWsUrl}?token=${encodeURIComponent(apiKey)}`
: effectiveWsUrl;

const ws = new WebSocket(wsUrlWithAuth);
wsRef.current = ws;
Expand Down Expand Up @@ -206,7 +252,7 @@ export function useLiveDashboard({
error: err instanceof Error ? err.message : "Connection failed",
}));
}
}, [wsUrl, apiKey, channels.join(","), autoReconnect, connection.reconnectAttempt]);
}, [effectiveWsUrl, apiKey, channels.join(","), autoReconnect, connection.reconnectAttempt]);

// Connect on mount and on reconnect trigger
useEffect(() => {
Expand All @@ -227,6 +273,10 @@ export function useLiveDashboard({
return;
}

// Wait for the handshake URL resolution before opening the socket, so we
// never connect to the hardcoded default and then flap to the public URL.
if (!wsUrlResolved) return;

connect();
return () => {
mountedRef.current = false;
Expand All @@ -235,7 +285,7 @@ export function useLiveDashboard({
}
wsRef.current?.close();
};
}, [connect, enabled]);
}, [connect, enabled, wsUrlResolved]);

// Connect (for manual retry)
const reconnect = useCallback(() => {
Expand Down
126 changes: 126 additions & 0 deletions tests/unit/live-ws-public-url.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-live-ws-public-url-"));
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
const ORIGINAL_API_KEY_SECRET = process.env.API_KEY_SECRET;
const ORIGINAL_PUBLIC_URL = process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL;

process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-live-ws-public-url-secret";

const core = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const localDb = await import("../../src/lib/localDb.ts");
const wsRoute = await import("../../src/app/api/v1/ws/route.ts");

function resetStorage() {
apiKeysDb.resetApiKeyState();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}

test.beforeEach(async () => {
resetStorage();
delete process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL;
await localDb.updateSettings({
wsAuth: false,
requireLogin: true,
password: "hashed-password",
});
});

test.after(() => {
apiKeysDb.resetApiKeyState();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });

if (ORIGINAL_DATA_DIR === undefined) {
delete process.env.DATA_DIR;
} else {
process.env.DATA_DIR = ORIGINAL_DATA_DIR;
}

if (ORIGINAL_API_KEY_SECRET === undefined) {
delete process.env.API_KEY_SECRET;
} else {
process.env.API_KEY_SECRET = ORIGINAL_API_KEY_SECRET;
}

if (ORIGINAL_PUBLIC_URL === undefined) {
delete process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL;
} else {
process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = ORIGINAL_PUBLIC_URL;
}
});

test("handshake response includes publicUrl when NEXT_PUBLIC_LIVE_WS_PUBLIC_URL is set", async () => {
process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = "wss://ws.my-ai.com/live-ws";

const response = await wsRoute.GET(
new Request("http://localhost/api/v1/ws?handshake=1", {
headers: { origin: "http://localhost" },
})
);

assert.equal(response.status, 200);
const body = (await response.json()) as any;
assert.equal(body.live.publicUrl, "wss://ws.my-ai.com/live-ws");
});

test("handshake response includes null publicUrl when NEXT_PUBLIC_LIVE_WS_PUBLIC_URL is unset", async () => {
delete process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL;

const response = await wsRoute.GET(
new Request("http://localhost/api/v1/ws?handshake=1", {
headers: { origin: "http://localhost" },
})
);

assert.equal(response.status, 200);
const body = (await response.json()) as any;
assert.equal(body.live.publicUrl, null);
});

test("protocol.live.publicUrl reflects env set after module import (lazy read)", async () => {
process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = "wss://custom.example.com/ws";

const response = await wsRoute.GET(new Request("http://localhost/api/v1/ws"));

assert.equal(response.status, 426);
const body = (await response.json()) as any;
assert.equal(body.protocol.live.publicUrl, "wss://custom.example.com/ws");
});

test("publicUrl with non-WebSocket scheme is rejected (null)", async () => {
process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = "https://ws.my-ai.com/live-ws";

const response = await wsRoute.GET(
new Request("http://localhost/api/v1/ws?handshake=1", {
headers: { origin: "http://localhost" },
})
);

assert.equal(response.status, 200);
const body = (await response.json()) as any;
assert.equal(body.live.publicUrl, null);
assert.equal(body.protocol.live.publicUrl, null);
});

test("publicUrl with ws:// scheme is accepted", async () => {
process.env.NEXT_PUBLIC_LIVE_WS_PUBLIC_URL = "ws://lan-host:20129/live-ws";

const response = await wsRoute.GET(
new Request("http://localhost/api/v1/ws?handshake=1", {
headers: { origin: "http://localhost" },
})
);

assert.equal(response.status, 200);
const body = (await response.json()) as any;
assert.equal(body.live.publicUrl, "ws://lan-host:20129/live-ws");
});
Loading