diff --git a/.env.example b/.env.example
index 15fceaea12..379c72b5f9 100644
--- a/.env.example
+++ b/.env.example
@@ -23,7 +23,11 @@ SQLITE_MAX_SIZE_MB=2048
SQLITE_CLEAN_LEGACY_FILES=true
# Recommended runtime variables
+# Canonical/base port (keeps backward compatibility)
PORT=20128
+# Optional split ports:
+# API_PORT=20128
+# DASHBOARD_PORT=20128
NODE_ENV=production
INSTANCE_NAME=omniroute
diff --git a/Dockerfile b/Dockerfile
index 308389163a..ffb9c07364 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -27,13 +27,14 @@ RUN mkdir -p /app/data
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/.next/standalone ./
+COPY --from=builder /app/scripts/run-standalone.mjs ./run-standalone.mjs
EXPOSE 20128
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
- CMD node -e "fetch('http://127.0.0.1:20128/api/settings').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))"
+ CMD node -e "const p=process.env.DASHBOARD_PORT||process.env.PORT||'20128';fetch('http://127.0.0.1:'+p+'/api/settings').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))"
-CMD ["node", "server.js"]
+CMD ["node", "run-standalone.mjs"]
FROM runner-base AS runner-cli
@@ -45,4 +46,3 @@ RUN apt-get update \
# Install CLI tools globally. Separate layer from apt for better cache reuse.
RUN npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest
-
diff --git a/README.md b/README.md
index 8d4c466b82..51619853fd 100644
--- a/README.md
+++ b/README.md
@@ -168,12 +168,22 @@ omniroute
π Dashboard opens at `http://localhost:20128`
-| Command | Description |
-| ----------------------- | --------------------------------- |
-| `omniroute` | Start server (default port 20128) |
-| `omniroute --port 3000` | Use custom port |
-| `omniroute --no-open` | Don't auto-open browser |
-| `omniroute --help` | Show help |
+| Command | Description |
+| ----------------------- | ----------------------------------------------------------- |
+| `omniroute` | Start server (`PORT=20128`, API and dashboard on same port) |
+| `omniroute --port 3000` | Set canonical/API port to 3000 |
+| `omniroute --no-open` | Don't auto-open browser |
+| `omniroute --help` | Show help |
+
+Optional split-port mode:
+
+```bash
+PORT=20128 DASHBOARD_PORT=20129 omniroute
+# API: http://localhost:20128/v1
+# Dashboard: http://localhost:20129
+```
+
+When ports are split, the API port serves only OpenAI-compatible routes (`/v1`, `/chat/completions`, `/responses`, `/models`, `/codex/*`).
**2. Connect a FREE provider:**
@@ -195,7 +205,7 @@ Claude Code/Codex/Gemini CLI/OpenClaw/Cursor/Cline Settings:
```bash
cp .env.example .env
npm install
-PORT=20128 NEXT_PUBLIC_BASE_URL=http://localhost:20128 npm run dev
+PORT=20128 DASHBOARD_PORT=20129 NEXT_PUBLIC_BASE_URL=http://localhost:20129 npm run dev
```
---
@@ -978,9 +988,12 @@ Se nΓ£o quiser criar credenciais prΓ³prias agora, ainda Γ© possΓvel usar o flux
- Switch primary model to GLM/MiniMax
- Use free tier (Gemini CLI, iFlow) for non-critical tasks
-**Dashboard opens on wrong port**
+**Dashboard/API ports are wrong**
-- Set `PORT=20128` and `NEXT_PUBLIC_BASE_URL=http://localhost:20128`
+- `PORT` is the canonical base port (and API port by default)
+- `API_PORT` overrides only OpenAI-compatible API listener
+- `DASHBOARD_PORT` overrides only dashboard/Next.js listener
+- Set `NEXT_PUBLIC_BASE_URL` to your dashboard/public URL (for OAuth callbacks)
**Cloud sync errors**
diff --git a/bin/omniroute.mjs b/bin/omniroute.mjs
index a655e248d1..e9f1bc94cf 100755
--- a/bin/omniroute.mjs
+++ b/bin/omniroute.mjs
@@ -30,18 +30,18 @@ if (args.includes("--help") || args.includes("-h")) {
\x1b[1mUsage:\x1b[0m
omniroute Start the server
- omniroute --port Use custom port (default: 20128)
+ omniroute --port Use custom API port (default: 20128)
omniroute --no-open Don't open browser automatically
omniroute --help Show this help
omniroute --version Show version
\x1b[1mAfter starting:\x1b[0m
- Dashboard: http://localhost:
- API: http://localhost:/v1
+ Dashboard: http://localhost:
+ API: http://localhost:/v1
\x1b[1mConnect your tools:\x1b[0m
Set your CLI tool (Cursor, Cline, Codex, etc.) to use:
- \x1b[33mhttp://localhost:20128/v1\x1b[0m
+ \x1b[33mhttp://localhost:/v1\x1b[0m
`);
process.exit(0);
}
@@ -58,17 +58,26 @@ if (args.includes("--version") || args.includes("-v")) {
process.exit(0);
}
-// Parse --port
-let port = 20128;
+function parsePort(value, fallback) {
+ const parsed = parseInt(String(value), 10);
+ return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
+}
+
+// Parse --port (canonical/base port)
+let port = parsePort(process.env.PORT || "20128", 20128);
const portIdx = args.indexOf("--port");
if (portIdx !== -1 && args[portIdx + 1]) {
- port = parseInt(args[portIdx + 1], 10);
- if (isNaN(port)) {
+ const parsed = parseInt(args[portIdx + 1], 10);
+ if (isNaN(parsed)) {
console.error("\x1b[31mβ Invalid port number\x1b[0m");
process.exit(1);
}
+ port = parsed;
}
+const apiPort = parsePort(process.env.API_PORT || String(port), port);
+const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(port), port);
+
const noOpen = args.includes("--no-open");
// ββ Banner βββββββββββββββββββββββββββββββββββββββββββββββββ
@@ -85,13 +94,8 @@ console.log(`
const serverJs = join(APP_DIR, "server.js");
if (!existsSync(serverJs)) {
- console.error(
- "\x1b[31mβ Server not found at:\x1b[0m",
- serverJs,
- );
- console.error(
- " This usually means the package was not built correctly.",
- );
+ console.error("\x1b[31mβ Server not found at:\x1b[0m", serverJs);
+ console.error(" This usually means the package was not built correctly.");
console.error(" Try reinstalling: npm install -g omniroute");
process.exit(1);
}
@@ -101,7 +105,10 @@ console.log(` \x1b[2mβ³ Starting server...\x1b[0m\n`);
const env = {
...process.env,
- PORT: String(port),
+ OMNIROUTE_PORT: String(port),
+ PORT: String(dashboardPort),
+ DASHBOARD_PORT: String(dashboardPort),
+ API_PORT: String(apiPort),
HOSTNAME: "0.0.0.0",
NODE_ENV: "production",
};
@@ -119,7 +126,10 @@ server.stdout.on("data", (data) => {
process.stdout.write(text);
// Detect server ready
- if (!started && (text.includes("Ready") || text.includes("started") || text.includes("listening"))) {
+ if (
+ !started &&
+ (text.includes("Ready") || text.includes("started") || text.includes("listening"))
+ ) {
started = true;
onReady();
}
@@ -156,16 +166,17 @@ process.on("SIGTERM", shutdown);
// ββ On ready βββββββββββββββββββββββββββββββββββββββββββββββ
async function onReady() {
- const url = `http://localhost:${port}`;
+ const dashboardUrl = `http://localhost:${dashboardPort}`;
+ const apiUrl = `http://localhost:${apiPort}`;
console.log(`
\x1b[32mβ OmniRoute is running!\x1b[0m
- \x1b[1m Dashboard:\x1b[0m ${url}
- \x1b[1m API Base:\x1b[0m ${url}/v1
+ \x1b[1m Dashboard:\x1b[0m ${dashboardUrl}
+ \x1b[1m API Base:\x1b[0m ${apiUrl}/v1
\x1b[2m Point your CLI tool (Cursor, Cline, Codex) to:\x1b[0m
- \x1b[33m ${url}/v1\x1b[0m
+ \x1b[33m ${apiUrl}/v1\x1b[0m
\x1b[2m Press Ctrl+C to stop\x1b[0m
`);
@@ -173,7 +184,7 @@ async function onReady() {
if (!noOpen) {
try {
const open = await import("open");
- await open.default(url);
+ await open.default(dashboardUrl);
} catch {
// open is optional β if not available, just skip
}
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 486afaafeb..f129888275 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -35,7 +35,7 @@ services:
"CMD",
"node",
"-e",
- "fetch('http://127.0.0.1:20128/api/settings').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))",
+ "const p=process.env.DASHBOARD_PORT||process.env.PORT||'20128';fetch('http://127.0.0.1:'+p+'/api/settings').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
diff --git a/docker-compose.yml b/docker-compose.yml
index 0b936ffb08..40441ace65 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -28,7 +28,7 @@ x-common: &common
"CMD",
"node",
"-e",
- "fetch('http://127.0.0.1:20128/api/settings').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))",
+ "const p=process.env.DASHBOARD_PORT||process.env.PORT||'20128';fetch('http://127.0.0.1:'+p+'/api/settings').then(r=>{if(!r.ok)throw r.status}).catch(()=>process.exit(1))",
]
interval: 30s
timeout: 5s
diff --git a/package.json b/package.json
index 1116ed6770..b57b582870 100644
--- a/package.json
+++ b/package.json
@@ -42,10 +42,10 @@
},
"homepage": "https://omniroute.online",
"scripts": {
- "dev": "next dev --webpack --port 20128",
+ "dev": "OMNIROUTE_PORT=${PORT:-20128} next dev --webpack --port ${DASHBOARD_PORT:-${PORT:-20128}}",
"build": "next build --webpack",
"build:cli": "node scripts/prepublish.mjs",
- "start": "next start --port 20128",
+ "start": "OMNIROUTE_PORT=${PORT:-20128} next start --port ${DASHBOARD_PORT:-${PORT:-20128}}",
"lint": "eslint .",
"test": "node --test tests/unit/*.test.mjs",
"test:unit": "node --import tsx/esm --test tests/unit/*.test.mjs",
diff --git a/playwright.config.ts b/playwright.config.ts
index 491b3eb6f1..15e3706b82 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -1,5 +1,8 @@
import { defineConfig, devices } from "@playwright/test";
+const dashboardPort = process.env.DASHBOARD_PORT || process.env.PORT || "20128";
+const dashboardBaseUrl = `http://localhost:${dashboardPort}`;
+
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
@@ -8,7 +11,7 @@ export default defineConfig({
workers: process.env.CI ? 1 : undefined,
reporter: process.env.CI ? "github" : "html",
use: {
- baseURL: "http://localhost:20128",
+ baseURL: dashboardBaseUrl,
trace: "on-first-retry",
screenshot: "only-on-failure",
},
@@ -20,7 +23,7 @@ export default defineConfig({
],
webServer: {
command: process.env.CI ? "npm start" : "npm run dev",
- url: "http://localhost:20128",
+ url: dashboardBaseUrl,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
diff --git a/scripts/run-standalone.mjs b/scripts/run-standalone.mjs
new file mode 100644
index 0000000000..b48ccf3eb2
--- /dev/null
+++ b/scripts/run-standalone.mjs
@@ -0,0 +1,34 @@
+#!/usr/bin/env node
+
+import { spawn } from "node:child_process";
+
+function parsePort(value, fallback) {
+ const parsed = Number.parseInt(String(value), 10);
+ return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
+}
+
+const basePort = parsePort(process.env.PORT || "20128", 20128);
+const apiPort = parsePort(process.env.API_PORT || String(basePort), basePort);
+const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(basePort), basePort);
+
+const child = spawn("node", ["server.js"], {
+ stdio: "inherit",
+ env: {
+ ...process.env,
+ OMNIROUTE_PORT: String(basePort),
+ PORT: String(dashboardPort),
+ DASHBOARD_PORT: String(dashboardPort),
+ API_PORT: String(apiPort),
+ },
+});
+
+child.on("exit", (code, signal) => {
+ if (signal) {
+ process.kill(process.pid, signal);
+ return;
+ }
+ process.exit(code ?? 0);
+});
+
+process.on("SIGINT", () => child.kill("SIGINT"));
+process.on("SIGTERM", () => child.kill("SIGTERM"));
diff --git a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx
index 89ba789269..8daa857bec 100644
--- a/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx
+++ b/src/app/(dashboard)/dashboard/cli-tools/CLIToolsPageClient.tsx
@@ -30,6 +30,7 @@ export default function CLIToolsPageClient({ machineId }) {
const [apiKeys, setApiKeys] = useState([]);
const [toolStatuses, setToolStatuses] = useState({});
const [statusesLoaded, setStatusesLoaded] = useState(false);
+ const [apiBaseUrl, setApiBaseUrl] = useState("");
useEffect(() => {
fetchConnections();
@@ -44,6 +45,12 @@ export default function CLIToolsPageClient({ machineId }) {
if (res.ok) {
const data = await res.json();
setCloudEnabled(data.cloudEnabled || false);
+ if (typeof window !== "undefined") {
+ const protocol = window.location.protocol;
+ const hostname = window.location.hostname;
+ const apiPort = data?.apiPort || 20128;
+ setApiBaseUrl(`${protocol}//${hostname}:${apiPort}`);
+ }
}
} catch (error) {
console.log("Error loading cloud settings:", error);
@@ -141,6 +148,9 @@ export default function CLIToolsPageClient({ machineId }) {
if (cloudEnabled && CLOUD_URL) {
return CLOUD_URL;
}
+ if (apiBaseUrl) {
+ return apiBaseUrl;
+ }
if (typeof window !== "undefined") {
return window.location.origin;
}
diff --git a/src/app/(dashboard)/dashboard/onboarding/page.tsx b/src/app/(dashboard)/dashboard/onboarding/page.tsx
index a782f63964..7d18120183 100644
--- a/src/app/(dashboard)/dashboard/onboarding/page.tsx
+++ b/src/app/(dashboard)/dashboard/onboarding/page.tsx
@@ -24,6 +24,7 @@ export default function OnboardingWizard() {
const router = useRouter();
const [step, setStep] = useState(0);
const [loading, setLoading] = useState(true);
+ const [apiEndpoint, setApiEndpoint] = useState("http://localhost:20128/api/v1");
// Security step state
const [password, setPassword] = useState("");
@@ -42,11 +43,20 @@ export default function OnboardingWizard() {
// Check if setup is already complete
useEffect(() => {
+ const resolveApiEndpoint = (apiPort) => {
+ if (typeof window === "undefined") return;
+ const protocol = window.location.protocol;
+ const hostname = window.location.hostname;
+ const effectiveApiPort = apiPort || 20128;
+ setApiEndpoint(`${protocol}//${hostname}:${effectiveApiPort}/api/v1`);
+ };
+
const checkSetup = async () => {
try {
const res = await fetch("/api/settings");
if (res.ok) {
const settings = await res.json();
+ resolveApiEndpoint(settings?.apiPort);
if (settings.setupComplete) {
router.replace("/dashboard");
return;
@@ -396,7 +406,7 @@ export default function OnboardingWizard() {
Your endpoint:
-
http://localhost:20128/api/v1
+
{apiEndpoint}
)}
diff --git a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts
index 5984681b78..38fca04b98 100644
--- a/src/app/api/cli-tools/guide-settings/[toolId]/route.ts
+++ b/src/app/api/cli-tools/guide-settings/[toolId]/route.ts
@@ -68,9 +68,7 @@ async function saveContinueConfig({ baseUrl, apiKey, model }) {
const existingIdx = models.findIndex(
(m) =>
m.apiBase &&
- (m.apiBase.includes("localhost:20128") ||
- m.apiBase.includes("omniroute") ||
- m.title === model)
+ (m.apiBase.includes("localhost") || m.apiBase.includes("omniroute") || m.title === model)
);
if (existingIdx >= 0) {
diff --git a/src/app/api/cli-tools/status/route.ts b/src/app/api/cli-tools/status/route.ts
index 610ba837f5..1465d7f66d 100644
--- a/src/app/api/cli-tools/status/route.ts
+++ b/src/app/api/cli-tools/status/route.ts
@@ -33,7 +33,7 @@ async function checkToolConfigStatus(toolId: string): Promise {
case "kilo":
// Generic check: look for any OmniRoute-related URL in the config
const configStr = JSON.stringify(config).toLowerCase();
- return configStr.includes("omniroute") || configStr.includes("20128")
+ return configStr.includes("omniroute") || configStr.includes("localhost")
? "configured"
: "not_configured";
default:
diff --git a/src/app/api/settings/route.ts b/src/app/api/settings/route.ts
index 9e427f1639..d5dbe3da2d 100644
--- a/src/app/api/settings/route.ts
+++ b/src/app/api/settings/route.ts
@@ -2,6 +2,7 @@ import { NextResponse } from "next/server";
import { getSettings, updateSettings } from "@/lib/localDb";
import bcrypt from "bcryptjs";
import { updateSettingsSchema, validateBody } from "@/shared/validation/schemas";
+import { getRuntimePorts } from "@/lib/runtime/ports";
export async function GET() {
try {
@@ -9,11 +10,15 @@ export async function GET() {
const { password, ...safeSettings } = settings;
const enableRequestLogs = process.env.ENABLE_REQUEST_LOGS === "true";
+ const runtimePorts = getRuntimePorts();
return NextResponse.json({
...safeSettings,
enableRequestLogs,
hasPassword: !!password || !!process.env.INITIAL_PASSWORD,
+ runtimePorts,
+ apiPort: runtimePorts.apiPort,
+ dashboardPort: runtimePorts.dashboardPort,
});
} catch (error) {
console.log("Error getting settings:", error);
diff --git a/src/instrumentation.ts b/src/instrumentation.ts
index d537bd4680..4ebbaef73c 100644
--- a/src/instrumentation.ts
+++ b/src/instrumentation.ts
@@ -29,6 +29,9 @@ export async function register() {
const { initGracefulShutdown } = await import("@/lib/gracefulShutdown");
initGracefulShutdown();
+ const { initApiBridgeServer } = await import("@/lib/apiBridgeServer");
+ initApiBridgeServer();
+
// Compliance: Initialize audit_log table + cleanup expired logs
try {
const { initAuditLog, cleanupExpiredLogs } = await import("@/lib/compliance/index");
diff --git a/src/lib/apiBridgeServer.ts b/src/lib/apiBridgeServer.ts
new file mode 100644
index 0000000000..1b57568d1d
--- /dev/null
+++ b/src/lib/apiBridgeServer.ts
@@ -0,0 +1,91 @@
+import http from "node:http";
+import type { IncomingMessage, ServerResponse } from "node:http";
+import { getRuntimePorts } from "@/lib/runtime/ports";
+
+const OPENAI_COMPAT_PATHS = [
+ /^\/v1(?:\/|$)/,
+ /^\/chat\/completions(?:\?|$)/,
+ /^\/responses(?:\?|$)/,
+ /^\/models(?:\?|$)/,
+ /^\/codex(?:\/|\?|$)/,
+];
+
+function isOpenAiCompatiblePath(pathname: string): boolean {
+ return OPENAI_COMPAT_PATHS.some((pattern) => pattern.test(pathname));
+}
+
+function proxyRequest(req: IncomingMessage, res: ServerResponse, dashboardPort: number): void {
+ const targetReq = http.request(
+ {
+ hostname: "127.0.0.1",
+ port: dashboardPort,
+ method: req.method,
+ path: req.url,
+ headers: {
+ ...req.headers,
+ host: `127.0.0.1:${dashboardPort}`,
+ },
+ },
+ (targetRes) => {
+ res.writeHead(targetRes.statusCode || 502, targetRes.headers);
+ targetRes.pipe(res);
+ }
+ );
+
+ targetReq.on("error", (error) => {
+ if (res.headersSent) return;
+ res.writeHead(502, { "content-type": "application/json" });
+ res.end(
+ JSON.stringify({ error: "api_bridge_unavailable", detail: String(error.message || error) })
+ );
+ });
+
+ req.pipe(targetReq);
+}
+
+declare global {
+ // eslint-disable-next-line no-var
+ var __omnirouteApiBridgeStarted: boolean | undefined;
+}
+
+export function initApiBridgeServer(): void {
+ if (globalThis.__omnirouteApiBridgeStarted) return;
+
+ const { apiPort, dashboardPort } = getRuntimePorts();
+ if (apiPort === dashboardPort) return;
+
+ const host = process.env.HOSTNAME || "0.0.0.0";
+
+ const server = http.createServer((req, res) => {
+ const rawUrl = req.url || "/";
+ const pathname = rawUrl.split("?")[0] || "/";
+
+ if (!isOpenAiCompatiblePath(pathname)) {
+ res.writeHead(404, { "content-type": "application/json" });
+ res.end(
+ JSON.stringify({
+ error: "not_found",
+ message: "API port only serves OpenAI-compatible routes.",
+ })
+ );
+ return;
+ }
+
+ proxyRequest(req, res, dashboardPort);
+ });
+
+ server.on("error", (error: NodeJS.ErrnoException) => {
+ if (error?.code === "EADDRINUSE") {
+ console.warn(
+ `[API Bridge] Port ${apiPort} is already in use. API bridge disabled. (dashboard: ${dashboardPort})`
+ );
+ return;
+ }
+ console.warn("[API Bridge] Failed to start:", error?.message || error);
+ });
+
+ server.listen(apiPort, host, () => {
+ globalThis.__omnirouteApiBridgeStarted = true;
+ console.log(`[API Bridge] Listening on ${host}:${apiPort} -> dashboard:${dashboardPort}`);
+ });
+}
diff --git a/src/lib/oauth/config/index.ts b/src/lib/oauth/config/index.ts
index 03291185ab..d6ae85cc4b 100644
--- a/src/lib/oauth/config/index.ts
+++ b/src/lib/oauth/config/index.ts
@@ -11,13 +11,21 @@ interface ServerCredentials {
userId: string;
}
+function getDefaultApiServer() {
+ const basePort = Number.parseInt(process.env.OMNIROUTE_PORT || process.env.PORT || "20128", 10);
+ const fallbackPort = Number.isFinite(basePort) ? basePort : 20128;
+ const apiPort = Number.parseInt(process.env.API_PORT || String(fallbackPort), 10);
+ const effectivePort = Number.isFinite(apiPort) ? apiPort : fallbackPort;
+ return `http://localhost:${effectivePort}`;
+}
+
/**
* Get server credentials from environment variables.
* Used by OAuth CLI services to save tokens to the running server.
*/
export function getServerCredentials(): ServerCredentials {
return {
- server: process.env.OMNIROUTE_SERVER || process.env.SERVER_URL || "http://localhost:20128",
+ server: process.env.OMNIROUTE_SERVER || process.env.SERVER_URL || getDefaultApiServer(),
token: process.env.OMNIROUTE_TOKEN || process.env.CLI_TOKEN || "",
userId: process.env.OMNIROUTE_USER_ID || process.env.CLI_USER_ID || "cli",
};
diff --git a/src/lib/runtime/ports.ts b/src/lib/runtime/ports.ts
new file mode 100644
index 0000000000..af880aa8dc
--- /dev/null
+++ b/src/lib/runtime/ports.ts
@@ -0,0 +1,32 @@
+const DEFAULT_PORT = 20128;
+
+function parsePort(value: string | undefined, fallback: number): number {
+ if (!value) return fallback;
+ const parsed = Number.parseInt(String(value), 10);
+ if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) return fallback;
+ return parsed;
+}
+
+export type RuntimePorts = {
+ port: number;
+ apiPort: number;
+ dashboardPort: number;
+ apiPortExplicit: boolean;
+ dashboardPortExplicit: boolean;
+};
+
+export function getRuntimePorts(): RuntimePorts {
+ // OMNIROUTE_PORT preserves the user's canonical PORT in wrapped runtimes
+ // where Next.js requires process.env.PORT to be the dashboard listener port.
+ const basePort = parsePort(process.env.OMNIROUTE_PORT || process.env.PORT, DEFAULT_PORT);
+ const apiPortExplicit = !!process.env.API_PORT;
+ const dashboardPortExplicit = !!process.env.DASHBOARD_PORT;
+
+ return {
+ port: basePort,
+ apiPort: parsePort(process.env.API_PORT, basePort),
+ dashboardPort: parsePort(process.env.DASHBOARD_PORT, basePort),
+ apiPortExplicit,
+ dashboardPortExplicit,
+ };
+}
diff --git a/src/shared/services/cloudSyncScheduler.ts b/src/shared/services/cloudSyncScheduler.ts
index 30e3a21eae..84becf0ae1 100644
--- a/src/shared/services/cloudSyncScheduler.ts
+++ b/src/shared/services/cloudSyncScheduler.ts
@@ -1,11 +1,14 @@
import { getConsistentMachineId } from "@/shared/utils/machineId";
import { isCloudEnabled } from "@/lib/localDb";
+import { getRuntimePorts } from "@/lib/runtime/ports";
+
+const { dashboardPort } = getRuntimePorts();
const INTERNAL_BASE_URL =
process.env.BASE_URL ||
process.env.NEXT_PUBLIC_BASE_URL ||
process.env.NEXT_PUBLIC_APP_URL ||
- "http://localhost:20128";
+ `http://localhost:${dashboardPort}`;
/**
* Cloud sync scheduler
diff --git a/src/types/global.d.ts b/src/types/global.d.ts
index 53de65b5b7..7a89d27969 100644
--- a/src/types/global.d.ts
+++ b/src/types/global.d.ts
@@ -13,6 +13,9 @@ declare namespace NodeJS {
PROMPT_CACHE_MAX_SIZE?: string;
PROMPT_CACHE_TTL_MS?: string;
NEXT_PUBLIC_CLOUD_URL?: string;
+ API_PORT?: string;
+ DASHBOARD_PORT?: string;
+ OMNIROUTE_PORT?: string;
NODE_ENV?: "development" | "production" | "test";
}
}