diff --git a/.env.example b/.env.example index be34e27634..fe0f342498 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,11 @@ DISABLE_SQLITE_AUTO_BACKUP=false # Default: 20128 PORT=20128 +# Base path (URL subpath) when serving OmniRoute behind a reverse proxy under a subpath. +# Used by: next.config.mjs — sets Next.js `basePath`; auth redirects are basePath-aware. +# Default: "" (served at the domain root). Example: /omniroute to serve under https://host/omniroute +# OMNIROUTE_BASE_PATH= + # Split-port mode: serve Dashboard and API on separate ports for network isolation. # Used by: src/lib/runtime/ports.ts — overrides PORT for each service. # API_PORT=20129 @@ -568,6 +573,8 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true # CLI_CONTINUE_BIN=cn # CLI_QODER_BIN=qoder # CLI_QWEN_BIN=qwen +# CLI_AUGGIE_BIN=auggie +# AUGGIE_BIN=auggie # Override the Hermes Agent home directory (where OmniRoute reads/writes the # Hermes CLI config). Matches the env var the Hermes PowerShell installer sets diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f826b19c4..ad6f41d231 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ - **feat(providers):** add Nube.sh as an OpenAI-compatible (API-key) provider. (thanks @whale9820) - **feat(providers):** add Charm Hyper as an OpenAI-compatible (API-key) provider. (thanks @whale9820) - **feat(providers):** add SumoPod and X5Lab as OpenAI-compatible (API-key) providers. (thanks @rigelra15) +- **feat(server):** support reverse-proxy subpath deployment via OMNIROUTE_BASE_PATH (basePath-aware auth redirects). (thanks @SillyHippy) ### 🔧 Bug Fixes diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index e0006c6180..713a04d125 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -117,6 +117,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari | Variable | Default | Source File | Description | | ------------------------------------------- | ------------------------------- | ------------------------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PORT` | `20128` | `src/lib/runtime/ports.ts` | Primary port for both Dashboard UI and API endpoints (single-port mode). | +| `OMNIROUTE_BASE_PATH` | _(empty = root)_ | `next.config.mjs` | URL subpath for serving OmniRoute behind a reverse proxy under a subpath (sets Next.js `basePath`; auth redirects are basePath-aware). E.g. `/omniroute`. | | `API_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the `/v1/*` proxy API on this separate port. | | `API_HOST` | `0.0.0.0` | `src/lib/runtime/ports.ts` | Bind address for the API port. | | `DASHBOARD_PORT` | _(unset)_ | `src/lib/runtime/ports.ts` | When set, serves the Dashboard UI on this separate port. | @@ -353,6 +354,8 @@ Controls how OmniRoute discovers and launches CLI sidecars (Claude Code, Codex, | `CLI_QODER_BIN` | `qoder` | `src/shared/services/cliRuntime.ts` | Custom path to Qoder CLI binary. | | `CLI_QWEN_BIN` | `qwen` | `src/shared/services/cliRuntime.ts` | Custom path to the Qwen Code CLI binary. | | `CLI_DEVIN_BIN` | `devin` | `open-sse/executors/devin-cli.ts` | Custom path to the Devin CLI binary (v3.8.0). Used by the Windsurf/Devin executor. | +| `AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Absolute-path override for the Augment (Auggie) CLI binary used by the local `auggie` provider. Falls back to `CLI_AUGGIE_BIN`, then a PATH lookup. | +| `CLI_AUGGIE_BIN` | `auggie` | `open-sse/executors/auggie.ts` | Alias override for the Augment (Auggie) CLI binary path (checked after `AUGGIE_BIN`). | | `HERMES_HOME` | `~/.hermes` | `src/lib/cli-helper/config-generator/hermesHome.ts` | Hermes Agent home directory where OmniRoute reads/writes the Hermes CLI config. Matches the env var the Hermes PowerShell installer sets on Windows (`%LOCALAPPDATA%\hermes`). | ### CLI Profile Auto-Sync diff --git a/next.config.mjs b/next.config.mjs index 24bbc30de5..2f5c948bc8 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -94,6 +94,13 @@ function readTimeoutMs(...values) { /** @type {import('next').NextConfig} */ const nextConfig = { + // Opt-in subpath deployment behind a reverse proxy (e.g. nginx/Caddy serving + // OmniRoute under https://host/omniroute/). Empty by default so root-path + // deployments are unaffected. Next.js strips this prefix from `pathname` + // before route matching, so authz classification (classifyRoute/isLocalOnlyPath) + // keeps operating on un-prefixed paths — see src/server/authz/pipeline.ts for + // the two redirect call sites that re-add it via `request.nextUrl.basePath`. + basePath: process.env.OMNIROUTE_BASE_PATH || "", distDir, // Turbopack config: redirect native modules to stubs at build time turbopack: { diff --git a/src/server/authz/pipeline.ts b/src/server/authz/pipeline.ts index 247391c179..5312974006 100644 --- a/src/server/authz/pipeline.ts +++ b/src/server/authz/pipeline.ts @@ -148,7 +148,7 @@ async function refreshDashboardSessionIfNeeded( } function dashboardLoginRedirect(request: NextRequest, requestId: string): NextResponse { - const response = NextResponse.redirect(new URL("/login", request.url)); + const response = NextResponse.redirect(new URL(`${request.nextUrl.basePath}/login`, request.url)); response.cookies.delete("auth_token"); stampRouteResponse(response, requestId, "MANAGEMENT"); applyCorsHeaders(response, request); @@ -223,7 +223,9 @@ export async function runAuthzPipeline( const requestId = generateRequestId(); if (pathname === "/") { - const response = NextResponse.redirect(new URL("/dashboard", request.url)); + const response = NextResponse.redirect( + new URL(`${request.nextUrl.basePath}/dashboard`, request.url) + ); return stampRouteResponse(response, requestId, "MANAGEMENT"); } diff --git a/tests/unit/authz/pipeline.test.ts b/tests/unit/authz/pipeline.test.ts index 18d90b12d8..2b43287911 100644 --- a/tests/unit/authz/pipeline.test.ts +++ b/tests/unit/authz/pipeline.test.ts @@ -138,6 +138,50 @@ test("runAuthzPipeline redirects unauthenticated /home/* nested paths to login ( assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT"); }); +// PR #1810 (upstream 9router): reverse-proxy subpath deployment via +// OMNIROUTE_BASE_PATH. Next.js strips the basePath from nextUrl.pathname +// before route classification, so the redirect targets must re-add it via +// request.nextUrl.basePath to stay inside the deployed subpath. +test("runAuthzPipeline prefixes the root-to-dashboard redirect with basePath when set", async () => { + await forceAuthRequired(); + + const req = new NextRequest("http://localhost/omniroute/", { + nextConfig: { basePath: "/omniroute" }, + }); + + const response = await pipeline.runAuthzPipeline(req, { enforce: true }); + + assert.equal(response.status, 307); + assert.equal(response.headers.get("location"), "http://localhost/omniroute/dashboard"); +}); + +test("runAuthzPipeline prefixes the dashboard login redirect with basePath when set", async () => { + await forceAuthRequired(); + + const req = new NextRequest("http://localhost/omniroute/dashboard", { + nextConfig: { basePath: "/omniroute" }, + }); + + const response = await pipeline.runAuthzPipeline(req, { enforce: true }); + + assert.equal(response.status, 307); + assert.equal(response.headers.get("location"), "http://localhost/omniroute/login"); + assert.equal(response.headers.get("x-omniroute-route-class"), "MANAGEMENT"); +}); + +test("runAuthzPipeline leaves redirect targets unprefixed when basePath is empty", async () => { + await forceAuthRequired(); + + const req = new NextRequest("http://localhost/dashboard", { + nextConfig: { basePath: "" }, + }); + + const response = await pipeline.runAuthzPipeline(req, { enforce: true }); + + assert.equal(response.status, 307); + assert.equal(response.headers.get("location"), "http://localhost/login"); +}); + test("runAuthzPipeline allows onboarding when login is required but no password exists", async () => { delete process.env.INITIAL_PASSWORD; await settingsDb.updateSettings({