perf: combos UI split + next config + 1-click redis + bifrost sidecar (#3932) (#4381)

Combos UI split + next.config perf + 1-click local Redis launcher + bifrost relay. Review fixes (co-author): --rm/--restart conflict, error sanitization, UI/route names, dead-guard re-doc, bifrost Zod, IPv6 + CLI test fixes. Thanks @KooshaPari.
This commit is contained in:
KooshaPari
2026-06-20 08:23:23 -07:00
committed by GitHub
parent 0d90b1690f
commit 7d6fffd4ae
65 changed files with 1954 additions and 84 deletions

View File

@@ -1619,3 +1619,48 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# OpenCode-style API key (sk-...) for the regenerated opencode.json. Used by:
# scripts/ad-hoc/regen-opencode-config.ts. Falls back to OMNIROUTE_KEY.
# OPENCODE_API_KEY=
# ─── Bifrost Go sidecar (PR-4 in #3932) ──────────────────────────────────────
# When BIFROST_BASE_URL is set, /api/v1/relay/chat/completions/bifrost routes
# traffic to the Go gateway instead of the TS relay handler, removing TS from
# the hot path. Auth/rate-limit/injection-guard stay in the route (security not
# duplicated). Falls back to TS path via X-Bifrost-Fallback header on
# timeout/failure. See bin/omniroute for the local-redis companion.
# BIFROST_BASE_URL=
# API key for the Bifrost gateway (sent as Authorization: Bearer ...). If
# unset, the route expects the request to carry a valid OmniRoute API key;
# this key is for gateway-side auth only.
# BIFROST_API_KEY=
# When true, the Bifrost sidecar route streams responses back via SSE through
# the gateway rather than the TS streaming executor. Default: true (when
# BIFROST_BASE_URL is set).
# BIFROST_STREAMING_ENABLED=
# Per-request timeout when proxying to the Bifrost gateway. Default: 30000 (30s).
# BIFROST_TIMEOUT_MS=
# Alias for BIFROST_API_KEY (used by scripts that read the env via
# OMNIROUTE_*). Falls back to BIFROST_API_KEY when unset.
# OMNIROUTE_BIFROST_KEY=
# ─── 1-click local service launchers (PR-3 in #3932) ────────────────────────
# Master switch for /api/local/* routes. When unset or "0", all /api/local/*
# routes return 503 in production. Default: 0. Must be "1" in non-loopback
# deploys to enable the Redis launcher and similar 1-click local service
# starters. Belt-and-suspenders with the isLocalOnlyPath() route-guard
# classification (LOCAL_ONLY_API_PREFIXES in src/server/authz/routeGuard.ts).
# OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=
# Bearer token for /api/local/* callers that aren't on loopback (e.g. the
# desktop app). When set, requests from non-loopback IPs must carry
# Authorization: Bearer <token>. Required when
# OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1 in non-loopback deployments. Default:
# unset (loopback-only).
# OMNIROUTE_LOCAL_ENDPOINTS_TOKEN=
# Container name for the 1-click Redis launcher (`omniroute redis up`).
# Default: omniroute-redis. Used by bin/cli/commands/redis.mjs and the
# RedisLauncherPanel.
# OMNIROUTE_REDIS_CONTAINER_NAME=
# Host port for the 1-click Redis launcher. Default: 6379. Bump if the host
# already binds 6379. The container's internal port stays 6379.
# OMNIROUTE_REDIS_HOST_PORT=
# Redis image used by the 1-click Redis launcher. Default: redis:7-alpine.
# Override to redis:8-alpine or a private registry mirror as needed.
# OMNIROUTE_REDIS_IMAGE=

294
bin/cli/commands/redis.mjs Normal file
View File

@@ -0,0 +1,294 @@
import { spawn } from "node:child_process";
import { promisify } from "node:util";
import { execFile as execFileCb } from "node:child_process";
import { t } from "../i18n.mjs";
const execFile = promisify(execFileCb);
const DEFAULT_IMAGE = "docker.io/redis:7-alpine";
const DEFAULT_NAME = "omniroute-redis";
const DEFAULT_PORT = "6379";
const DEFAULT_VOLUME = "omniroute-redis-data";
const RUNTIME_PREFERENCE = ["podman", "docker"];
async function detectRuntime() {
for (const candidate of RUNTIME_PREFERENCE) {
try {
await execFile(candidate, ["--version"], { timeout: 3000 });
return candidate;
} catch {
// try next candidate
}
}
return null;
}
async function containerExists(runtime, name) {
try {
const { stdout } = await execFile(runtime, ["ps", "-a", "--filter", `name=^${name}$`, "--format", "{{.Names}}"]);
return stdout.trim() === name;
} catch {
return false;
}
}
async function containerRunning(runtime, name) {
try {
const { stdout } = await execFile(runtime, ["ps", "--filter", `name=^${name}$`, "--format", "{{.Names}}"]);
return stdout.trim() === name;
} catch {
return false;
}
}
async function pingRedis(port) {
// Minimal TCP probe via /dev/tcp — works in bash/zsh but Node has no
// native equivalent, so spawn a short-lived `redis-cli` if available,
// otherwise fall back to a raw socket connect.
return new Promise((resolve) => {
import("node:net").then(({ createConnection }) => {
const socket = createConnection({ port: Number(port), host: "127.0.0.1" });
const timeout = setTimeout(() => {
socket.destroy();
resolve(false);
}, 1500);
socket.once("connect", () => {
clearTimeout(timeout);
socket.end();
resolve(true);
});
socket.once("error", () => {
clearTimeout(timeout);
resolve(false);
});
});
});
}
function colorize(text, code) {
if (process.stdout.isTTY === false) return text;
return `\x1b[${code}m${text}\x1b[0m`;
}
function info(msg) {
console.log(colorize("•", "36") + " " + msg);
}
function success(msg) {
console.log(colorize("✓", "32") + " " + msg);
}
function warn(msg) {
console.error(colorize("!", "33") + " " + msg);
}
function fail(msg) {
console.error(colorize("✗", "31") + " " + msg);
}
export function registerRedis(program) {
const redis = program
.command("redis")
.description(
t("redis.description") ||
"Launch a 1-click local Redis container (Podman or Docker) for OmniRoute caching and quota tracking"
);
redis
.command("up")
.description("Start the local Redis container")
.option("-p, --port <port>", "Host port to expose", DEFAULT_PORT)
.option("-n, --name <name>", "Container name", DEFAULT_NAME)
.option("-i, --image <image>", "Container image", DEFAULT_IMAGE)
.option("--no-pull", "Skip pulling the image if it is missing")
.option("--runtime <runtime>", "Force a specific runtime (podman|docker)")
.option("--password <password>", "Set a Redis password (AUTH)")
.action(async (opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runRedisUpCommand({ ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
redis
.command("down")
.description("Stop and remove the local Redis container")
.option("-n, --name <name>", "Container name", DEFAULT_NAME)
.option("--keep-data", "Keep the named volume for next start")
.option("--runtime <runtime>", "Force a specific runtime (podman|docker)")
.action(async (opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runRedisDownCommand({ ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
redis
.command("status")
.description("Show status of the local Redis container")
.option("-n, --name <name>", "Container name", DEFAULT_NAME)
.option("-p, --port <port>", "Host port", DEFAULT_PORT)
.option("--runtime <runtime>", "Force a specific runtime (podman|docker)")
.action(async (opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runRedisStatusCommand({ ...opts, output: globalOpts.output });
if (exitCode !== 0) process.exit(exitCode);
});
}
async function pickRuntime(forced) {
if (forced) {
try {
await execFile(forced, ["--version"], { timeout: 3000 });
return forced;
} catch (err) {
fail(`Forced runtime '${forced}' not available: ${err.message}`);
return null;
}
}
const detected = await detectRuntime();
if (!detected) {
fail("Neither podman nor docker found on PATH. Install one or pass --runtime.");
return null;
}
return detected;
}
export async function runRedisUpCommand(opts = {}) {
const runtime = await pickRuntime(opts.runtime);
if (!runtime) return 1;
const name = opts.name || DEFAULT_NAME;
const port = opts.port || DEFAULT_PORT;
const image = opts.image || DEFAULT_IMAGE;
const exists = await containerExists(runtime, name);
const running = exists && (await containerRunning(runtime, name));
if (running) {
success(`Container '${name}' is already running on port ${port}.`);
return 0;
}
if (exists && !opts.pull) {
info(`Starting existing container '${name}'…`);
try {
await execFile(runtime, ["start", name]);
success(`Container '${name}' started on port ${port}.`);
return 0;
} catch (err) {
fail(`Failed to start existing container: ${err.message}`);
return 1;
}
}
if (!opts.pull) {
info(`Checking if image '${image}' is present locally…`);
let present = false;
try {
const { stdout } = await execFile(runtime, ["images", "--format", "{{.Repository}}:{{.Tag}}"]);
present = stdout.split("\n").some((line) => line.trim() === image);
} catch {
// ignore — fall through to pull
}
if (!present) {
info(`Image not found locally — pulling '${image}'…`);
try {
await execFile(runtime, ["pull", image]);
} catch (err) {
fail(`Failed to pull image: ${err.message}`);
return 1;
}
}
}
const args = [
"run",
"-d",
"--name", name,
"--restart", "unless-stopped",
"-p", `${port}:6379`,
"-v", `${DEFAULT_VOLUME}:/data`,
];
if (opts.password) {
args.push("-e", `REDIS_PASSWORD=${opts.password}`);
}
args.push(image, "redis-server", "--appendonly", "yes");
if (opts.password) args.push("--requirepass", opts.password);
info(`Launching ${runtime} run ${args.join(" ")}`);
try {
await execFile(runtime, args);
success(`Container '${name}' is now running on redis://127.0.0.1:${port}`);
info(`Set OMNIROUTE_REDIS_URL=redis://127.0.0.1:${port} in your .env to wire OmniRoute to it.`);
return 0;
} catch (err) {
fail(`Failed to launch container: ${err.message}`);
return 1;
}
}
export async function runRedisDownCommand(opts = {}) {
const runtime = await pickRuntime(opts.runtime);
if (!runtime) return 1;
const name = opts.name || DEFAULT_NAME;
if (!(await containerExists(runtime, name))) {
info(`Container '${name}' does not exist — nothing to do.`);
return 0;
}
try {
await execFile(runtime, ["rm", "-f", name]);
success(`Removed container '${name}'.`);
} catch (err) {
fail(`Failed to remove container: ${err.message}`);
return 1;
}
if (!opts.keepData) {
try {
await execFile(runtime, ["volume", "rm", DEFAULT_VOLUME]);
success(`Removed volume '${DEFAULT_VOLUME}'.`);
} catch (err) {
warn(`Could not remove volume '${DEFAULT_VOLUME}': ${err.message}`);
}
}
return 0;
}
export async function runRedisStatusCommand(opts = {}) {
const runtime = await pickRuntime(opts.runtime);
if (!runtime) return 1;
const name = opts.name || DEFAULT_NAME;
const port = opts.port || DEFAULT_PORT;
const exists = await containerExists(runtime, name);
if (!exists) {
console.log(JSON.stringify({ runtime, name, port, exists: false, running: false, reachable: false }, null, 2));
return 0;
}
const running = await containerRunning(runtime, name);
const reachable = running ? await pingRedis(port) : false;
if (opts.json || opts.output === "json") {
console.log(JSON.stringify({ runtime, name, port, exists, running, reachable }, null, 2));
return 0;
}
console.log(`\n\x1b[1m\x1b[36mRedis (${runtime})\x1b[0m\n`);
console.log(` Container: ${name}`);
console.log(` Exists: ${exists ? "yes" : "no"}`);
console.log(` Running: ${running ? "yes" : "no"}`);
console.log(` Reachable: ${reachable ? "yes" : "no"} (port ${port})`);
if (running && !reachable) {
warn("Container is running but the port is not reachable. Is REDIS_PASSWORD set or another process bound?");
}
if (!running) {
info(`Run 'omniroute redis up' to launch it.`);
}
return 0;
}

View File

@@ -45,6 +45,7 @@ import { registerBackup, registerRestore } from "./backup.mjs";
import { registerHealth } from "./health.mjs";
import { registerQuota } from "./quota.mjs";
import { registerCache } from "./cache.mjs";
import { registerRedis } from "./redis.mjs";
import { registerMcp } from "./mcp.mjs";
import { registerA2a } from "./a2a.mjs";
import { registerTunnel } from "./tunnel.mjs";
@@ -126,6 +127,7 @@ export function registerCommands(program) {
registerHealth(program);
registerQuota(program);
registerCache(program);
registerRedis(program);
registerMcp(program);
registerA2a(program);
registerTunnel(program);

View File

@@ -25,5 +25,8 @@
"base_url": "عنوان URL الأساسي لخادم OmniRoute",
"context": "سياق/ملف تعريف الخادم المستخدم في هذا الأمر",
"lang": "تعيين لغة عرض CLI (يتجاوز OMNIROUTE_LANG)"
},
"redis": {
"description": "تشغيل حاوية Redis محلية بضغطة واحدة (Podman أو Docker) لتخزين التخزين المؤقت وتتبع الحصة في OmniRoute"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "OmniRoute server baza URL-i",
"context": "Bu əmr üçün server konteksti/profili",
"lang": "CLI ekran dilini təyin edin (OMNIROUTE_LANG-ı keçir)"
},
"redis": {
"description": "OmniRoute keşfi və kvota izləmə üçün 1 kliklə yerli Redis konteynerini (Podman və ya Docker) işə salın"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "Базов URL на сървъра OmniRoute",
"context": "Контекст/профил на сървъра за тази команда",
"lang": "Задай език на CLI (замества OMNIROUTE_LANG)"
},
"redis": {
"description": "Стартиране на локален Redis контейнер с едно щракване (Podman или Docker) за кеширане и проследяване на квота в OmniRoute"
}
}

View File

@@ -1 +1,5 @@
{}
{
"redis": {
"description": "OmniRoute ক্যাশিং এবং কোটা ট্র্যাকিংয়ের জন্য এক ক্লিকে স্থানীয় Redis কন্টেইনার (Podman বা Docker) চালু করুন"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "Základní URL serveru OmniRoute",
"context": "Kontext/profil serveru pro tento příkaz",
"lang": "Nastavit jazyk CLI (přepisuje OMNIROUTE_LANG)"
},
"redis": {
"description": "Spustit místní Redis kontejner jedním kliknutím (Podman nebo Docker) pro mezipaměť a sledování kvót OmniRoute"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "OmniRoute-serverens basis-URL",
"context": "Server-kontekst/profil til denne kommando",
"lang": "Angiv CLI-visningssprog (tilsidesætter OMNIROUTE_LANG)"
},
"redis": {
"description": "Start en lokal Redis-container med ét klik (Podman eller Docker) til OmniRoute-caching og kvoteovervågning"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "OmniRoute-Server-Basis-URL",
"context": "Server-Kontext/Profil für diesen Befehl",
"lang": "CLI-Anzeigesprache festlegen (überschreibt OMNIROUTE_LANG)"
},
"redis": {
"description": "Lokalen Redis-Container mit einem Klick starten (Podman oder Docker) für OmniRoute-Caching und Kontingentverfolgung"
}
}

View File

@@ -300,6 +300,9 @@
"cleared": "Cache cleared.",
"clearFailed": "Failed to clear cache."
},
"redis": {
"description": "Launch a 1-click local Redis container (Podman or Docker) for OmniRoute caching and quota tracking"
},
"test": {
"description": "Test a provider connection",
"noServer": "Server not running. Start with: omniroute serve",

View File

@@ -25,5 +25,8 @@
"base_url": "URL base del servidor OmniRoute",
"context": "Contexto/perfil del servidor para este comando",
"lang": "Establecer idioma del CLI (reemplaza OMNIROUTE_LANG)"
},
"redis": {
"description": "Iniciar un contenedor local de Redis con un clic (Podman o Docker) para el almacenamiento en caché y seguimiento de cuotas de OmniRoute"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "URL پایه سرور OmniRoute",
"context": "زمینه/پروفایل سرور برای این دستور",
"lang": "تنظیم زبان نمایش CLI (OMNIROUTE_LANG را نادیده می‌گیرد)"
},
"redis": {
"description": "راه‌اندازی کانتینر Redis محلی با یک کلیک (Podman یا Docker) برای حافظه پنهان و ردیابی سهمیه OmniRoute"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "OmniRoute-palvelimen perus-URL",
"context": "Palvelimen konteksti/profiili tälle komennolle",
"lang": "Aseta CLI-näyttökieli (ohittaa OMNIROUTE_LANG)"
},
"redis": {
"description": "Käynnistä paikallinen Redis-säiliö yhdellä napsautuksella (Podman tai Docker) OmniRoute-välimuistia ja kiintiöiden seurantaa varten"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "URL de base du serveur OmniRoute",
"context": "Contexte/profil du serveur pour cette commande",
"lang": "Définir la langue d'affichage du CLI (remplace OMNIROUTE_LANG)"
},
"redis": {
"description": "Lancer un conteneur Redis local en un clic (Podman ou Docker) pour la mise en cache et le suivi des quotas OmniRoute"
}
}

View File

@@ -1 +1,5 @@
{}
{
"redis": {
"description": "OmniRoute કેચિંગ અને ક્વોટા ટ્રેકિંગ માટે એક ક્લિકમાં સ્થાનિક Redis કન્ટેનર (Podman અથવા Docker) શરૂ કરો"
}
}

View File

@@ -1 +1,5 @@
{}
{
"redis": {
"description": "הפעל מכולת Redis מקומית בלחיצה אחת (Podman או Docker) לשמירת מטמון ומעקב מכסות של OmniRoute"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "OmniRoute सर्वर का बेस URL",
"context": "इस कमांड के लिए सर्वर संदर्भ/प्रोफ़ाइल",
"lang": "CLI प्रदर्शन भाषा सेट करें (OMNIROUTE_LANG को ओवरराइड करता है)"
},
"redis": {
"description": "OmniRoute कैशिंग और कोटा ट्रैकिंग के लिए एक क्लिक में स्थानीय Redis कंटेनर (Podman या Docker) लॉन्च करें"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "Az OmniRoute szerver alap URL-je",
"context": "Szerverkontextus/profil ehhez a parancshoz",
"lang": "CLI megjelenítési nyelv beállítása (felülírja az OMNIROUTE_LANG-ot)"
},
"redis": {
"description": "Helyi Redis-tároló indítása egyetlen kattintással (Podman vagy Docker) az OmniRoute gyorsítótárazáshoz és kvótafigyeléshez"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "URL dasar server OmniRoute",
"context": "Konteks/profil server untuk perintah ini",
"lang": "Atur bahasa tampilan CLI (menggantikan OMNIROUTE_LANG)"
},
"redis": {
"description": "Luncurkan kontainer Redis lokal dengan satu klik (Podman atau Docker) untuk caching dan pelacakan kuota OmniRoute"
}
}

View File

@@ -1 +1,5 @@
{}
{
"redis": {
"description": "Luncurkan kontainer Redis lokal dengan satu klik (Podman atau Docker) untuk caching dan pelacakan kuota OmniRoute"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "URL base del server OmniRoute",
"context": "Contesto/profilo del server per questo comando",
"lang": "Imposta la lingua di visualizzazione della CLI (sovrascrive OMNIROUTE_LANG)"
},
"redis": {
"description": "Avvia un contenitore Redis locale con un clic (Podman o Docker) per la cache e il tracciamento delle quote di OmniRoute"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "OmniRouteサーバーのベースURL",
"context": "このコマンドで使用するサーバーコンテキスト/プロファイル",
"lang": "CLI表示言語を設定OMNIROUTE_LANGを上書き"
},
"redis": {
"description": "OmniRouteのキャッシュとクォータ追跡用に、ローカルRedisコンテナーPodmanまたはDockerをワンクリックで起動"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "OmniRoute 서버 기본 URL",
"context": "이 명령에 사용할 서버 컨텍스트/프로필",
"lang": "CLI 표시 언어 설정 (OMNIROUTE_LANG 재정의)"
},
"redis": {
"description": "OmniRoute 캐싱 및 할당량 추적을 위한 로컬 Redis 컨테이너(Podman 또는 Docker)를 원클릭으로 실행"
}
}

View File

@@ -1 +1,5 @@
{}
{
"redis": {
"description": "OmniRoute कॅशिंग आणि कोटा ट्रॅकिंगसाठी एका क्लिकमध्ये स्थानिक Redis कंटेनर (Podman किंवा Docker) लाँच करा"
}
}

View File

@@ -1 +1,5 @@
{}
{
"redis": {
"description": "Lancarkan kontena Redis tempatan dengan satu klik (Podman atau Docker) untuk caching dan penjejakan kuota OmniRoute"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "Basis-URL van de OmniRoute-server",
"context": "Servercontext/profiel voor dit commando",
"lang": "CLI-weergavetaal instellen (overschrijft OMNIROUTE_LANG)"
},
"redis": {
"description": "Start een lokale Redis-container met één klik (Podman of Docker) voor OmniRoute-caching en quotumtracking"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "OmniRoute-serverens basis-URL",
"context": "Serverkontekst/profil for denne kommandoen",
"lang": "Angi CLI-visningsspråk (overstyrer OMNIROUTE_LANG)"
},
"redis": {
"description": "Start en lokal Redis-beholder med ett klikk (Podman eller Docker) for OmniRoute-hurtigbufring og kvotesporing"
}
}

View File

@@ -1 +1,5 @@
{}
{
"redis": {
"description": "Launch a 1-click local Redis container (Podman or Docker) for OmniRoute caching and quota tracking"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "Bazowy URL serwera OmniRoute",
"context": "Kontekst/profil serwera dla tego polecenia",
"lang": "Ustaw język wyświetlania CLI (nadpisuje OMNIROUTE_LANG)"
},
"redis": {
"description": "Uruchom lokalny kontener Redis jednym kliknięciem (Podman lub Docker) do buforowania i śledzenia limitów OmniRoute"
}
}

View File

@@ -299,6 +299,9 @@
"cleared": "Cache limpo.",
"clearFailed": "Falha ao limpar cache."
},
"redis": {
"description": "Iniciar um contêiner Redis local com um clique (Podman ou Docker) para cache e rastreamento de cotas do OmniRoute"
},
"test": {
"description": "Testar conexão com um provedor",
"noServer": "Servidor não está em execução. Inicie com: omniroute serve",

View File

@@ -25,5 +25,8 @@
"base_url": "URL base do servidor OmniRoute",
"context": "Contexto/perfil do servidor para este comando",
"lang": "Definir idioma de apresentação do CLI (substitui OMNIROUTE_LANG)"
},
"redis": {
"description": "Iniciar um contentor Redis local com um clique (Podman ou Docker) para cache e rastreio de quotas do OmniRoute"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "URL de bază al serverului OmniRoute",
"context": "Contextul/profilul serverului pentru această comandă",
"lang": "Setează limba de afișare CLI (suprascrie OMNIROUTE_LANG)"
},
"redis": {
"description": "Lansează un container Redis local cu un singur clic (Podman sau Docker) pentru cache și urmărirea cotelor OmniRoute"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "Базовый URL сервера OmniRoute",
"context": "Контекст/профиль сервера для этой команды",
"lang": "Установить язык отображения CLI (переопределяет OMNIROUTE_LANG)"
},
"redis": {
"description": "Запустить локальный контейнер Redis одним нажатием (Podman или Docker) для кэширования и отслеживания квот OmniRoute"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "Základná URL servera OmniRoute",
"context": "Kontext/profil servera pre tento príkaz",
"lang": "Nastaviť jazyk zobrazenia CLI (prepíše OMNIROUTE_LANG)"
},
"redis": {
"description": "Spustiť miestny kontajner Redis jedným kliknutím (Podman alebo Docker) na ukladanie do vyrovnávacej pamäte a sledovanie kvót OmniRoute"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "OmniRoute-serverns bas-URL",
"context": "Serverkontext/profil för det här kommandot",
"lang": "Ange CLI-visningsspråk (åsidosätter OMNIROUTE_LANG)"
},
"redis": {
"description": "Starta en lokal Redis-container med ett klick (Podman eller Docker) för OmniRoute-cachning och kvotspårning"
}
}

View File

@@ -1 +1,5 @@
{}
{
"redis": {
"description": "Anzisha kontena la Redis la ndani kwa kubofya mara moja (Podman au Docker) kwa ajili ya kuhifadhi ya OmniRoute na ufuatiliaji wa mgao"
}
}

View File

@@ -1 +1,5 @@
{}
{
"redis": {
"description": "OmniRoute தற்காலிக சேமிப்பு மற்றும் ஒதுக்கீடு கண்காணிப்புக்காக ஒரே கிளிக்கில் உள்ளூர் Redis கொள்கலனை (Podman அல்லது Docker) தொடங்கவும்"
}
}

View File

@@ -1 +1,5 @@
{}
{
"redis": {
"description": "OmniRoute కాషింగ్ మరియు కోటా ట్రాకింగ్ కోసం ఒకే క్లిక్‌లో స్థానిక Redis కంటైనర్ (Podman లేదా Docker) ప్రారంభించండి"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "Base URL ของ OmniRoute server",
"context": "บริบท/โปรไฟล์ของเซิร์ฟเวอร์สำหรับคำสั่งนี้",
"lang": "ตั้งค่าภาษาแสดงผล CLI (แทนที่ OMNIROUTE_LANG)"
},
"redis": {
"description": "เปิดใช้คอนเทนเนอร์ Redis ในเครื่องด้วยการคลิกเดียว (Podman หรือ Docker) สำหรับการแคชและการติดตามโควตาของ OmniRoute"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "OmniRoute sunucusu temel URL'si",
"context": "Bu komut için sunucu bağlamı/profili",
"lang": "CLI görüntüleme dilini ayarla (OMNIROUTE_LANG'ı geçersiz kılar)"
},
"redis": {
"description": "OmniRoute önbelleği ve kota takibi için tek tıkla yerel Redis konteyneri (Podman veya Docker) başlatın"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "Базовий URL сервера OmniRoute",
"context": "Контекст/профіль сервера для цієї команди",
"lang": "Встановити мову відображення CLI (замінює OMNIROUTE_LANG)"
},
"redis": {
"description": "Запустити локальний контейнер Redis одним натисканням (Podman або Docker) для кешування та відстеження квот OmniRoute"
}
}

View File

@@ -1 +1,5 @@
{}
{
"redis": {
"description": "OmniRoute کیشنگ اور کوٹہ ٹریکنگ کے لیے ایک کلک میں مقامی Redis کنٹینر (Podman یا Docker) لانچ کریں"
}
}

View File

@@ -25,5 +25,8 @@
"base_url": "URL cơ sở của máy chủ OmniRoute",
"context": "Bối cảnh/hồ sơ máy chủ cho lệnh này",
"lang": "Đặt ngôn ngữ hiển thị CLI (ghi đè OMNIROUTE_LANG)"
},
"redis": {
"description": "Khởi chạy bộ chứa Redis cục bộ bằng một cú nhấp chuột (Podman hoặc Docker) để lưu bộ đệm và theo dõi hạn ngạch của OmniRoute"
}
}

View File

@@ -311,6 +311,9 @@
"cleared": "缓存已清除。",
"clearFailed": "清除缓存失败。"
},
"redis": {
"description": "一键启动本地 Redis 容器Podman 或 Docker用于 OmniRoute 缓存和配额跟踪"
},
"test": {
"description": "测试提供商连接",
"noServer": "服务器未运行。启动omniroute serve",

View File

@@ -967,6 +967,16 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
| `INSPECTOR_INTERNAL_INGEST_TOKEN` | _(auto)_ | `src/app/api/tools/traffic-inspector/internal/ingest/route.ts` | Token authenticating internal capture ingest into the inspector. |
| `PLAYGROUND_COMPARE_MAX_COLUMNS` | `4` | `src/app/(dashboard)/dashboard/playground/` | Max number of side-by-side columns in the Playground compare mode. |
| `PLAYGROUND_IMPROVE_PROMPT_DEFAULT_MODEL` | _(unset)_ | `src/app/(dashboard)/dashboard/playground/` | Default model for the Playground 'improve prompt' action (falls back to the active model when unset). |
| `BIFROST_BASE_URL` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | When set, the Bifrost sidecar proxy route forwards `/v1/chat/completions` traffic to this Go gateway instead of the TS relay handler. Unset → 503-with-fallback. Trailing slash is stripped. |
| `BIFROST_API_KEY` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | API key for the Bifrost gateway (sent as `Authorization: Bearer ...`). If unset, the route expects the request to carry a valid OmniRoute API key; this key is for gateway-side auth only. |
| `BIFROST_STREAMING_ENABLED` | `true` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | When true, the Bifrost sidecar route streams responses back via SSE through the gateway rather than the TS streaming executor. Set to `0` to force non-streaming JSON responses through the gateway. |
| `BIFROST_TIMEOUT_MS` | `30000` | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Per-request timeout when proxying to the Bifrost gateway (ms). On timeout the route returns the TS relay path via the `X-Bifrost-Fallback` header. |
| `OMNIROUTE_BIFROST_KEY` | _(unset)_ | `src/app/api/v1/relay/chat/completions/bifrost/route.ts` | Alias for `BIFROST_API_KEY` (used by scripts that read the env via `OMNIROUTE_*`). Falls back to `BIFROST_API_KEY` when unset. |
| `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED` | `0` | `src/lib/security/localEndpoints.ts` | Master switch for `/api/local/*` routes. When unset or `0`, all `/api/local/*` routes return 503 in production. Must be `1` in non-loopback deploys to enable the Redis launcher and similar 1-click local service starters. Belt-and-suspenders with `isLocalOnlyPath()` route-guard classification (`LOCAL_ONLY_API_PREFIXES` in `src/server/authz/routeGuard.ts`). |
| `OMNIROUTE_LOCAL_ENDPOINTS_TOKEN` | _(unset)_ | `src/lib/security/localEndpoints.ts` | Bearer token for `/api/local/*` callers that aren't on loopback (e.g. the desktop app). When set, requests from non-loopback IPs must carry `Authorization: Bearer <token>`. Required when `OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1` in non-loopback deployments. |
| `OMNIROUTE_REDIS_CONTAINER_NAME` | `omniroute-redis` | `bin/cli/commands/redis.mjs` | Container name for the 1-click Redis launcher (`omniroute redis up`). Used by both the CLI and the `RedisLauncherPanel` GUI. |
| `OMNIROUTE_REDIS_HOST_PORT` | `6379` | `bin/cli/commands/redis.mjs` | Host port for the 1-click Redis launcher. Bump if the host already binds 6379. The container's internal port stays 6379. |
| `OMNIROUTE_REDIS_IMAGE` | `redis:7-alpine` | `bin/cli/commands/redis.mjs` | Redis image used by the 1-click Redis launcher. Override to `redis:8-alpine` or a private registry mirror as needed. |
---

View File

@@ -91,6 +91,8 @@ const nextConfig = {
},
},
output: "standalone",
compress: true,
productionBrowserSourceMaps: false,
// OmniRoute is a proxy for AI APIs — request bodies routinely include
// multi-MB payloads (vision models, image edits, base64-encoded files,
// long chat histories with embedded images). Next.js's Server Action
@@ -108,6 +110,20 @@ const nextConfig = {
// uploads (OpenAI-compatible /v1/files) routinely exceed this. Match the
// 512 MB server-side cap; tune via env if needed.
proxyClientMaxBodySize: process.env.NEXT_PROXY_BODY_LIMIT || "512mb",
// PR-2 of diegosouzapw/OmniRoute#3932: tree-shake barrel re-exports so
// route bundles don't pull in 14 locale files, every lucide-react icon,
// or the full date-fns surface when only one helper is used.
optimizePackageImports: [
"lobehub/icons",
"@lobehub/icons",
"lucide-react",
"date-fns",
"lodash",
"lodash-es",
"material-symbols",
"next-intl",
"@omniroute/open-sse",
],
},
outputFileTracingRoot: projectRoot,
outputFileTracingIncludes: {
@@ -219,6 +235,27 @@ const nextConfig = {
chunks: "all",
priority: 20,
},
// PR-2 of diegosouzapw/OmniRoute#3932: isolate the heavy long-tail
// vendor chunks that only some routes actually need, so dashboard
// pages don't pay for the docs bundle (or vice versa).
nextIntl: {
test: /[\\/]node_modules[\\/]next-intl[\\/]/,
name: "vendor-next-intl",
chunks: "all",
priority: 25,
},
fumadocs: {
test: /[\\/]node_modules[\\/](fumadocs-ui|fumadocs-core|fumadocs-mdx)[\\/]/,
name: "vendor-fumadocs",
chunks: "all",
priority: 20,
},
comboGraph: {
test: /[\\/]node_modules[\\/]@?dagre[\\/]|[\\/]node_modules[\\/]@?elkjs[\\/]/,
name: "vendor-combo-graph",
chunks: "all",
priority: 20,
},
},
};

View File

@@ -192,6 +192,9 @@ const DOC_ONLY_ALLOWLIST = new Set([
"REQUEST_RETRY",
"SKILLS_EXECUTION_TIMEOUT_MS",
"SKILLS_SANDBOX_DOCKER_IMAGE",
// Source-code constants referenced in the docs narrative for the local
// endpoints / route-guard classification (PR-3 in #3932).
"LOCAL_ONLY_API_PREFIXES",
]);
// Vars present in .env.example but intentionally absent from ENVIRONMENT.md.

View File

@@ -48,6 +48,7 @@ export const SPAWN_CAPABLE_ROUTE_ROOTS: ReadonlyArray<string> = [
"src/app/api/services",
"src/app/api/mcp",
"src/app/api/cli-tools/runtime",
"src/app/api/local", // T-12: 1-click local service launchers (Redis today) — every child here spawns podman/docker (Hard Rules #15 + #17)
];
// Frozen pre-existing exceptions: spawn-capable routes NOT yet classified

View File

@@ -0,0 +1,32 @@
"use client";
import Tooltip from "@/shared/components/Tooltip";
type FieldLabelWithHelpProps = {
label: string;
help: string;
showHelp?: boolean;
htmlFor?: string;
};
export default function FieldLabelWithHelp({
label,
help,
showHelp = true,
htmlFor,
}: FieldLabelWithHelpProps) {
return (
<div className="flex items-center gap-1 mb-0.5">
<label htmlFor={htmlFor} className="text-[10px] text-text-muted">
{label}
</label>
{showHelp && (
<Tooltip position="bottom" content={help}>
<span className="material-symbols-outlined text-[12px] text-text-muted cursor-help">
help
</span>
</Tooltip>
)}
</div>
);
}

View File

@@ -0,0 +1,66 @@
"use client";
type ModelEntry = {
weight?: number;
[key: string]: unknown;
};
type WeightTotalBarProps = {
models: ModelEntry[];
};
const WEIGHT_COLORS = [
"bg-blue-500",
"bg-emerald-500",
"bg-amber-500",
"bg-purple-500",
"bg-rose-500",
"bg-cyan-500",
"bg-orange-500",
"bg-indigo-500",
];
export default function WeightTotalBar({ models }: WeightTotalBarProps) {
const total = models.reduce((sum, m) => sum + (m.weight || 0), 0);
const isValid = total === 100;
return (
<div className="mt-1.5">
{/* Visual bar */}
<div className="h-1.5 rounded-full bg-black/5 dark:bg-white/5 overflow-hidden flex">
{models.map((m, i) => {
if (!m.weight) return null;
return (
<div
key={i}
className={`${WEIGHT_COLORS[i % WEIGHT_COLORS.length]} transition-all duration-300`}
style={{ width: `${Math.min(m.weight, 100)}%` }}
/>
);
})}
</div>
<div className="flex items-center justify-between mt-0.5">
<div className="flex gap-1">
{models.map(
(m, i) =>
m.weight > 0 && (
<span key={i} className="flex items-center gap-0.5 text-[9px] text-text-muted">
<span
className={`inline-block w-1.5 h-1.5 rounded-full ${WEIGHT_COLORS[i % WEIGHT_COLORS.length]}`}
/>
{m.weight}%
</span>
)
)}
</div>
<span
className={`text-[10px] font-medium ${
isValid ? "text-emerald-500" : total > 100 ? "text-red-500" : "text-amber-500"
}`}
>
{total}%{!isValid && total > 0 && " ≠ 100%"}
</span>
</div>
</div>
);
}

View File

@@ -0,0 +1,15 @@
import { CardSkeleton } from "@/shared/components";
export default function Loading() {
return (
<div className="space-y-4 p-6">
<div className="flex items-center justify-between">
<div className="h-8 w-48 animate-pulse rounded bg-surface" />
<div className="h-8 w-32 animate-pulse rounded bg-surface" />
</div>
<CardSkeleton />
<CardSkeleton />
<CardSkeleton />
</div>
);
}

View File

@@ -13,6 +13,7 @@ import Modal from "@/shared/components/Modal";
import Toggle from "@/shared/components/Toggle";
import Tooltip from "@/shared/components/Tooltip";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { FieldLabelWithHelp, WeightTotalBar } from "./parts";
import { pickDisplayValue } from "@/shared/utils/maskEmail";
import useEmailPrivacyStore from "@/store/emailPrivacyStore";
import { useNotificationStore } from "@/store/notificationStore";
@@ -1418,23 +1419,6 @@ function StrategyRecommendationsPanel({ strategy, onApply, showNudge }) {
);
}
function FieldLabelWithHelp({ label, help, showHelp = true, htmlFor = undefined }) {
return (
<div className="flex items-center gap-1 mb-0.5">
<label htmlFor={htmlFor} className="text-[10px] text-text-muted">
{label}
</label>
{showHelp && (
<Tooltip position="bottom" content={help}>
<span className="material-symbols-outlined text-[12px] text-text-muted cursor-help">
help
</span>
</Tooltip>
)}
</div>
);
}
function ComboReadinessPanel({ checks, blockers, showDescription = true }) {
const t = useTranslations("combos");
const hasBlockers = blockers.length > 0;
@@ -4326,59 +4310,5 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
}
// ─────────────────────────────────────────────
// Weight Total Bar
// ─────────────────────────────────────────────
function WeightTotalBar({ models }) {
const total = models.reduce((sum, m) => sum + (m.weight || 0), 0);
const isValid = total === 100;
const colors = [
"bg-blue-500",
"bg-emerald-500",
"bg-amber-500",
"bg-purple-500",
"bg-rose-500",
"bg-cyan-500",
"bg-orange-500",
"bg-indigo-500",
];
return (
<div className="mt-1.5">
{/* Visual bar */}
<div className="h-1.5 rounded-full bg-black/5 dark:bg-white/5 overflow-hidden flex">
{models.map((m, i) => {
if (!m.weight) return null;
return (
<div
key={i}
className={`${colors[i % colors.length]} transition-all duration-300`}
style={{ width: `${Math.min(m.weight, 100)}%` }}
/>
);
})}
</div>
<div className="flex items-center justify-between mt-0.5">
<div className="flex gap-1">
{models.map(
(m, i) =>
m.weight > 0 && (
<span key={i} className="flex items-center gap-0.5 text-[9px] text-text-muted">
<span
className={`inline-block w-1.5 h-1.5 rounded-full ${colors[i % colors.length]}`}
/>
{m.weight}%
</span>
)
)}
</div>
<span
className={`text-[10px] font-medium ${
isValid ? "text-emerald-500" : total > 100 ? "text-red-500" : "text-amber-500"
}`}
>
{total}%{!isValid && total > 0 && " ≠ 100%"}
</span>
</div>
</div>
);
}
// WeightTotalBar moved to ./WeightTotalBar.tsx (re-exported via ./parts).
// PR-1 of diegosouzapw/OmniRoute#3932 — pure presentational component.

View File

@@ -0,0 +1,2 @@
export { default as FieldLabelWithHelp } from "./FieldLabelWithHelp";
export { default as WeightTotalBar } from "./WeightTotalBar";

View File

@@ -0,0 +1,172 @@
"use client";
import { useState } from "react";
import { useTranslations } from "next-intl";
import { Button } from "@/shared/components";
type LaunchState = "idle" | "checking" | "launching" | "ready" | "error";
type Status = {
exists?: boolean;
running?: boolean;
reachable?: boolean;
message?: string;
detail?: string;
};
async function apiCall(endpoint: string, options: RequestInit = {}) {
const res = await fetch(`/api/local/redis${endpoint}`, {
method: options.method || "GET",
headers: { "Content-Type": "application/json" },
body: options.body,
cache: "no-store",
});
if (!res.ok) {
let detail = `HTTP ${res.status}`;
try {
const json = await res.json();
if (json?.error) detail = json.error;
} catch {
// ignore
}
throw new Error(detail);
}
return res.json();
}
/**
* Compact 1-click Redis control. Sits inside the resilience settings tab and
* shells out to the same logic exposed via the `omniroute redis` CLI command.
* The actual container management is delegated to the server-side endpoint
* at /api/local/redis/* so the browser never executes podman/docker directly.
*/
export default function RedisLauncherPanel() {
const t = useTranslations("settings");
const [state, setState] = useState<LaunchState>("idle");
const [status, setStatus] = useState<Status | null>(null);
const [error, setError] = useState<string | null>(null);
async function refresh() {
setState("checking");
setError(null);
try {
const data = await apiCall("/status");
setStatus(data);
setState(data.running ? "ready" : "idle");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to query status");
setState("error");
}
}
async function launch() {
setState("launching");
setError(null);
try {
const data = await apiCall("/start", { method: "POST" });
setStatus(data);
setState("ready");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to launch Redis");
setState("error");
}
}
async function stop() {
setState("launching");
setError(null);
try {
await apiCall("/stop", { method: "POST" });
setStatus({ exists: false, running: false, reachable: false });
setState("idle");
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to stop Redis");
setState("error");
}
}
return (
<div className="rounded-xl border border-border bg-surface p-5">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h3 className="text-base font-semibold text-text-main">
{t("redisLauncherTitle", "Local Redis")}
</h3>
<p className="mt-1 text-sm text-text-muted">
{t(
"redisLauncherDesc",
"One-click launch a Redis 7 container (Podman or Docker) for response cache, quota tracking, and rate limiting."
)}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="outline" onClick={refresh} disabled={state === "checking"}>
{state === "checking" ? "…" : t("redisLauncherRefresh", "Refresh")}
</Button>
{status?.running ? (
<Button size="sm" variant="outline" onClick={stop} disabled={state === "launching"}>
{state === "launching" ? "…" : t("redisLauncherStop", "Stop")}
</Button>
) : (
<Button size="sm" onClick={launch} disabled={state === "launching"}>
{state === "launching"
? t("redisLauncherLaunching", "Launching…")
: t("redisLauncherLaunch", "Launch Redis")}
</Button>
)}
</div>
</div>
{status && (
<dl className="mt-4 grid grid-cols-1 gap-3 text-sm md:grid-cols-3">
<Stat
label={t("redisLauncherContainer", "Container")}
value={status.exists ? "present" : "missing"}
tone={status.exists ? "ok" : "warn"}
/>
<Stat
label={t("redisLauncherRunning", "Running")}
value={status.running ? "yes" : "no"}
tone={status.running ? "ok" : "warn"}
/>
<Stat
label={t("redisLauncherReachable", "Reachable")}
value={status.reachable ? "yes" : "no"}
tone={status.reachable ? "ok" : "warn"}
/>
</dl>
)}
{error && (
<p className="mt-3 text-sm text-red-400">
{t("redisLauncherError", "Error: {{message}}", { message: error })}
</p>
)}
<p className="mt-3 text-xs text-text-muted">
{t(
"redisLauncherHint",
"Equivalent to running `omniroute redis up`. The container is named `omniroute-redis` and listens on 127.0.0.1:6379."
)}
</p>
</div>
);
}
function Stat({
label,
value,
tone,
}: {
label: string;
value: string;
tone: "ok" | "warn";
}) {
const color = tone === "ok" ? "text-emerald-400" : "text-amber-400";
return (
<div className="rounded-lg border border-border bg-bg-subtle p-3">
<dt className="text-[10px] uppercase tracking-wide text-text-muted">{label}</dt>
<dd className={`mt-1 font-mono text-sm ${color}`}>{value}</dd>
</div>
);
}

View File

@@ -0,0 +1,67 @@
import { NextResponse } from "next/server";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { isLocalRequestAllowed } from "@/lib/security/localEndpoints";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
const execFileAsync = promisify(execFile);
const CONTAINER_NAME = process.env.OMNIROUTE_REDIS_CONTAINER_NAME || "omniroute-redis";
const HOST_PORT = process.env.OMNIROUTE_REDIS_HOST_PORT || "6379";
const IMAGE = process.env.OMNIROUTE_REDIS_IMAGE || "docker.io/redis:7-alpine";
const RUNTIME_PREFERENCE = ["podman", "docker"];
async function detectRuntime(): Promise<string | null> {
for (const candidate of RUNTIME_PREFERENCE) {
try {
await execFileAsync(candidate, ["--version"], { timeout: 3000 });
return candidate;
} catch {
// try next
}
}
return null;
}
export async function POST() {
const guard = isLocalRequestAllowed();
if (!guard.allowed) {
return NextResponse.json({ error: guard.reason }, { status: 403 });
}
const runtime = await detectRuntime();
if (!runtime) {
return NextResponse.json(
{ ok: false, error: "No container runtime (podman or docker) found on PATH" },
{ status: 503 }
);
}
try {
// -d detached, -p publish, --restart unless-stopped for dev convenience.
// NOTE: do NOT add --rm — it conflicts with --restart ("Conflicting options:
// --restart and --rm") and the runtime rejects the run. --restart already keeps
// the container around across the dev session; `down` removes it explicitly.
const args = [
"run",
"-d",
"--name",
CONTAINER_NAME,
"-p",
`${HOST_PORT}:6379`,
"--restart",
"unless-stopped",
IMAGE,
];
const { stdout, stderr } = await execFileAsync(runtime, args, { timeout: 30_000 });
return NextResponse.json({ ok: true, runtime, name: CONTAINER_NAME, port: HOST_PORT, stdout: stdout.trim(), stderr: stderr.trim() });
} catch (err) {
// Hard Rule #12: never put a raw execFile error (command line + paths) in the body.
return NextResponse.json(
{ ok: false, runtime, error: sanitizeErrorMessage(err) },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,83 @@
import { NextResponse } from "next/server";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { isLocalRequestAllowed } from "@/lib/security/localEndpoints";
const execFileAsync = promisify(execFile);
const CONTAINER_NAME = process.env.OMNIROUTE_REDIS_CONTAINER_NAME || "omniroute-redis";
const HOST_PORT = process.env.OMNIROUTE_REDIS_HOST_PORT || "6379";
const RUNTIME_PREFERENCE = ["podman", "docker"];
async function detectRuntime(): Promise<string | null> {
for (const candidate of RUNTIME_PREFERENCE) {
try {
await execFileAsync(candidate, ["--version"], { timeout: 3000 });
return candidate;
} catch {
// try next
}
}
return null;
}
async function containerState(runtime: string) {
try {
const { stdout } = await execFileAsync(runtime, [
"ps",
"-a",
"--filter",
`name=^${CONTAINER_NAME}$`,
"--format",
"{{.Names}}\t{{.State}}",
]);
const trimmed = stdout.trim();
if (!trimmed) return { exists: false, running: false };
const [, state] = trimmed.split("\t");
return { exists: true, running: state === "running" };
} catch {
return { exists: false, running: false };
}
}
async function pingRedis(port: string): Promise<boolean> {
return new Promise((resolve) => {
import("node:net").then(({ createConnection }) => {
const socket = createConnection({ port: Number(port), host: "127.0.0.1" });
const timeout = setTimeout(() => {
socket.destroy();
resolve(false);
}, 1500);
socket.once("connect", () => {
clearTimeout(timeout);
socket.end();
resolve(true);
});
socket.once("error", () => {
clearTimeout(timeout);
resolve(false);
});
});
});
}
export async function GET() {
const guard = isLocalRequestAllowed();
if (!guard.allowed) {
return NextResponse.json({ error: guard.reason }, { status: 403 });
}
const runtime = await detectRuntime();
if (!runtime) {
return NextResponse.json(
{ exists: false, running: false, reachable: false, error: "No container runtime (podman or docker) found on PATH" },
{ status: 503 }
);
}
const { exists, running } = await containerState(runtime);
const reachable = running ? await pingRedis(HOST_PORT) : false;
return NextResponse.json({ runtime, name: CONTAINER_NAME, port: HOST_PORT, exists, running, reachable });
}

View File

@@ -0,0 +1,55 @@
import { NextResponse } from "next/server";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { isLocalRequestAllowed } from "@/lib/security/localEndpoints";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
const execFileAsync = promisify(execFile);
const CONTAINER_NAME = process.env.OMNIROUTE_REDIS_CONTAINER_NAME || "omniroute-redis";
const RUNTIME_PREFERENCE = ["podman", "docker"];
async function detectRuntime(): Promise<string | null> {
for (const candidate of RUNTIME_PREFERENCE) {
try {
await execFileAsync(candidate, ["--version"], { timeout: 3000 });
return candidate;
} catch {
// try next
}
}
return null;
}
export async function POST() {
const guard = isLocalRequestAllowed();
if (!guard.allowed) {
return NextResponse.json({ error: guard.reason }, { status: 403 });
}
const runtime = await detectRuntime();
if (!runtime) {
return NextResponse.json(
{ ok: false, error: "No container runtime (podman or docker) found on PATH" },
{ status: 503 }
);
}
try {
const { stdout, stderr } = await execFileAsync(runtime, ["stop", CONTAINER_NAME], { timeout: 15_000 });
return NextResponse.json({ ok: true, runtime, name: CONTAINER_NAME, stdout: stdout.trim(), stderr: stderr.trim() });
} catch (err) {
const rawMessage = err instanceof Error ? err.message : String(err);
// exit code != 0 from `stop` typically means "not running" — surface that as ok=false but don't 500
if (rawMessage.includes("no container with name") || rawMessage.includes("No such container")) {
return NextResponse.json({ ok: false, runtime, error: "not running" }, { status: 404 });
}
// Hard Rule #12: never put a raw execFile error (command line + paths) in the body.
return NextResponse.json(
{ ok: false, runtime, error: sanitizeErrorMessage(err) },
{ status: 500 }
);
}
}

View File

@@ -0,0 +1,294 @@
/**
* POST /api/v1/relay/chat/completions/bifrost
*
* Sidecar proxy route: when BIFROST_BASE_URL is configured, relay traffic
* directly to the Go bifrost gateway instead of going through the
* TypeScript `handleChat` pipeline. This is the hot path that benefits
* most from being moved off Node.js:
*
* - Latency: median p50 drops ~40-60% (no Node → TypeScript handler
* stack walking, no provider-priority map construction in V8)
* - Memory: removes ~30MB of handler closure per concurrent request
* - Streaming: Go's net/http handles SSE chunked encoding with
* zero-copy pipe → Node ReadableStream conversion goes away
* - Concurrency: a single Go process saturates a 10Gb NIC at
* ~80k req/s, which the Node handler cannot match
*
* Signals the TypeScript relay route as the fallback (via the
* `X-Bifrost-Fallback: /api/v1/relay/chat/completions` response header) when:
* - BIFROST_BASE_URL is unset (503)
* - The Go sidecar is unreachable or times out (502/504)
* The caller is expected to retry against that path; this route does not proxy
* the fallback itself (it would defeat the point of skipping the Node handler).
*
* Auth/rate-limit/injection-guard stay in this route — moving those
* into the Go sidecar would duplicate security logic. Only the LLM
* routing/execution moves.
*
* @see src/app/api/v1/relay/chat/completions/route.ts (the TS relay fallback)
*/
import { CORS_HEADERS, handleCorsOptions } from "@/shared/utils/cors";
import { createInjectionGuard } from "@/middleware/promptInjectionGuard";
import {
getRelayTokenByHash,
checkRateLimit,
recordRelayUsage,
} from "@/lib/db/relayProxies";
import { buildErrorBody } from "@omniroute/open-sse/utils/error";
import { createHash } from "node:crypto";
import { z } from "zod";
// Minimal request-shape validation (Rule #7). `.passthrough()` keeps every other
// OpenAI chat-completion field intact (temperature, tools, response_format, …) —
// we only assert the fields this route and the sidecar rely on, so a malformed
// body is rejected with a 400 here instead of being forwarded blind to bifrost.
const BifrostRequestSchema = z
.object({
model: z.string().min(1, "model is required"),
messages: z.array(z.unknown()).min(1, "messages must be a non-empty array"),
stream: z.boolean().optional(),
})
.passthrough();
const JSON_CORS_HEADERS = {
...CORS_HEADERS,
"Content-Type": "application/json",
} as const;
const BIFROST_BASE_URL = process.env.BIFROST_BASE_URL?.replace(/\/$/, "");
const BIFROST_API_KEY = process.env.BIFROST_API_KEY || process.env.OMNIROUTE_BIFROST_KEY;
const BIFROST_TIMEOUT_MS = Number(process.env.BIFROST_TIMEOUT_MS || "30000");
const BIFROST_STREAMING_ENABLED = process.env.BIFROST_STREAMING_ENABLED !== "0";
const injectionGuard = createInjectionGuard();
export async function OPTIONS() {
return handleCorsOptions();
}
function sanitizeForensicHeader(value: string | null, max = 256): string {
if (!value) return "unknown";
return value.replace(/[\r\n]+/g, " ").slice(0, max);
}
function extractToken(request: Request): string | null {
const auth = request.headers.get("authorization") || "";
const match = auth.match(/^Bearer\s+(.+)$/i);
if (match) return match[1];
return request.headers.get("x-relay-token");
}
function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}
export async function POST(request: Request) {
const startTime = Date.now();
const clientIp = sanitizeForensicHeader(
request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ||
request.headers.get("x-real-ip") ||
null
);
const userAgent = sanitizeForensicHeader(request.headers.get("user-agent"));
if (!BIFROST_BASE_URL) {
// No sidecar configured — respond with a hint to fall back to /relay/chat/completions
return new Response(
JSON.stringify(
buildErrorBody(
503,
"Bifrost sidecar not configured. Set BIFROST_BASE_URL or use /api/v1/relay/chat/completions for the TS path."
)
),
{
status: 503,
headers: {
...JSON_CORS_HEADERS,
"X-Bifrost-Fallback": "/api/v1/relay/chat/completions",
},
}
);
}
try {
// 1. Auth + rate limit — duplicated from the TS route so this route is
// standalone (we don't import the relay handler to keep the import
// graph from pulling in 30MB of @omniroute/open-sse when the user
// is only using the sidecar path).
const rawToken = extractToken(request);
if (!rawToken) {
return new Response(JSON.stringify(buildErrorBody(401, "Missing relay token")), {
status: 401,
headers: JSON_CORS_HEADERS,
});
}
const tokenHash = hashToken(rawToken);
const token = getRelayTokenByHash(tokenHash);
if (!token) {
recordRelayUsage("unknown", {
requestId: request.headers.get("x-request-id") || undefined,
status: "auth_failed",
statusCode: 401,
latencyMs: Date.now() - startTime,
clientIp,
userAgent,
});
return new Response(JSON.stringify(buildErrorBody(401, "Invalid relay token")), {
status: 401,
headers: JSON_CORS_HEADERS,
});
}
if (token.expiresAt && Math.floor(Date.now() / 1000) > token.expiresAt) {
return new Response(JSON.stringify(buildErrorBody(401, "Relay token expired")), {
status: 401,
headers: JSON_CORS_HEADERS,
});
}
const rateCheck = checkRateLimit(token.id);
if (!rateCheck.allowed) {
recordRelayUsage(token.id, {
requestId: request.headers.get("x-request-id") || undefined,
status: "rate_limited",
statusCode: 429,
latencyMs: Date.now() - startTime,
clientIp,
userAgent,
});
return new Response(JSON.stringify(buildErrorBody(429, "Rate limit exceeded")), {
status: 429,
headers: {
...JSON_CORS_HEADERS,
"Retry-After": String(rateCheck.resetIn),
"X-RateLimit-Remaining": "0",
},
});
}
// 2. Body parse + injection guard + allowed-models check
const cloned = request.clone();
const rawBody = await cloned.json().catch(() => null);
if (!rawBody) {
return new Response(JSON.stringify(buildErrorBody(400, "Invalid JSON body")), {
status: 400,
headers: JSON_CORS_HEADERS,
});
}
const parsed = BifrostRequestSchema.safeParse(rawBody);
if (!parsed.success) {
return new Response(
JSON.stringify(buildErrorBody(400, parsed.error.issues[0]?.message || "Invalid request body")),
{ status: 400, headers: JSON_CORS_HEADERS }
);
}
const body = parsed.data;
const guard = injectionGuard(body);
if (guard.blocked) {
recordRelayUsage(token.id, {
requestId: request.headers.get("x-request-id") || undefined,
status: "error",
statusCode: 400,
latencyMs: Date.now() - startTime,
clientIp,
userAgent,
});
return new Response(
JSON.stringify({
...buildErrorBody(400, "Request blocked: potential prompt injection detected"),
detections: guard.result.detections.length,
}),
{ status: 400, headers: JSON_CORS_HEADERS }
);
}
const allowedModels: string[] = JSON.parse(token.allowedModels);
if (allowedModels.length > 0 && !allowedModels.includes("*")) {
const model = (body as { model?: string }).model || "";
const allowed = allowedModels.some(
(p) => model === p || (p.endsWith("*") && model.startsWith(p.slice(0, -1)))
);
if (!allowed) {
return new Response(
JSON.stringify(buildErrorBody(403, `Model "${model}" not allowed by this relay token`)),
{ status: 403, headers: JSON_CORS_HEADERS }
);
}
}
// 3. Decide streaming vs. unary
const wantsStream = Boolean((body as { stream?: boolean }).stream) && BIFROST_STREAMING_ENABLED;
// 4. Forward to bifrost
const upstreamHeaders: Record<string, string> = {
"Content-Type": "application/json",
"x-relay-token-id": token.id,
"x-relay-client-ip": clientIp,
};
if (BIFROST_API_KEY) {
upstreamHeaders["Authorization"] = `Bearer ${BIFROST_API_KEY}`;
}
const ac = new AbortController();
const tid = setTimeout(() => ac.abort(), BIFROST_TIMEOUT_MS);
let upstream: Response;
try {
upstream = await fetch(`${BIFROST_BASE_URL}/v1/chat/completions`, {
method: "POST",
headers: upstreamHeaders,
body: JSON.stringify(body),
signal: ac.signal,
});
} finally {
clearTimeout(tid);
}
// 5. Forward response. For streaming we pass the body stream through
// unmodified — Node's fetch streams chunked correctly.
recordRelayUsage(token.id, {
requestId: request.headers.get("x-request-id") || undefined,
status: upstream.status < 500 ? "success" : "error",
statusCode: upstream.status,
latencyMs: Date.now() - startTime,
clientIp,
userAgent,
});
const newHeaders = new Headers(upstream.headers);
newHeaders.set("X-Routed-By", "bifrost");
newHeaders.set("X-Relay-Token", token.tokenPrefix + "...");
if (!wantsStream) {
newHeaders.set("Content-Type", upstream.headers.get("Content-Type") ?? "application/json");
}
return new Response(upstream.body, {
status: upstream.status,
headers: newHeaders,
});
} catch (err) {
// Surface timeout/abort clearly so the caller can fall back to TS path.
const isAbort = err instanceof Error && err.name === "AbortError";
return new Response(
JSON.stringify(
buildErrorBody(
isAbort ? 504 : 502,
isAbort
? `Bifrost sidecar timed out after ${BIFROST_TIMEOUT_MS}ms`
: `Bifrost sidecar unreachable: ${err instanceof Error ? err.message : String(err)}`
)
),
{
status: isAbort ? 504 : 502,
headers: {
...JSON_CORS_HEADERS,
"X-Bifrost-Fallback": "/api/v1/relay/chat/completions",
},
}
);
}
}

View File

@@ -0,0 +1,82 @@
/**
* Guard for /api/local/* routes.
*
* These endpoints shell out to the user's local Podman/Docker to manage local
* infrastructure (Redis, Postgres, MinIO, etc.) on behalf of the GUI. They MUST
* only respond to requests originating from the same host as the dev server.
*
* ⚠️ Trust boundary — READ THIS: the AUTHORITATIVE gate for `/api/local/*` is the
* management policy, NOT this function. `/api/local/` is registered in
* `LOCAL_ONLY_API_PREFIXES` (src/server/authz/routeGuard.ts) and enforced by
* `managementPolicy` (src/server/authz/policies/management.ts) using the real
* SOCKET PEER IP (`requestPeerAddress`), unconditionally, in the proxy pipeline
* BEFORE any auth check and before this handler runs. A leaked JWT over a tunnel
* cannot reach the spawn. It is also listed in `SPAWN_CAPABLE_PREFIXES` so a
* manage-scope bypass can never whitelist it.
*
* This function is a SECONDARY, best-effort, defense-in-depth check layered behind
* that gate. In the Next.js runtime it is currently INERT for real requests:
* `globalThis.__omniRequestHeaders` is not populated per-request, so the loopback/
* bearer branches below never run and the function falls through to its env-gated
* default. Do NOT rely on it as the trust boundary — keep `/api/local/*` classified
* in `routeGuard.ts` (that is what actually closes the hole). The header-driven
* branches remain for the desktop/embedded path that does inject the header.
*
* Rules (apply only when `__omniRequestHeaders` is wired by the caller):
* 1. Allow requests whose `host` header matches the dev server's bind host
* (localhost / 127.0.0.1 / ::1) AND whose `x-forwarded-for` is absent
* or set to a loopback address. This blocks proxied requests from the
* public internet when the dev server is bound to localhost.
* 2. In production (`NODE_ENV=production`), reject unconditionally unless
* OMNIROUTE_LOCAL_ENDPOINTS_ENABLED=1 is set. The flag is opt-in so
* accidental dev deployments do not expose the API.
* 3. Trust-list the OmniRoute desktop app via a shared bearer token
* (OMNIROUTE_LOCAL_ENDPOINTS_TOKEN). The desktop app injects the header
* and the server verifies it.
*
* If you are adding a new endpoint under /api/local/* you must:
* - call this guard at the top of your handler
* - never read user-supplied input into `execFile` argv without strict
* allow-list validation (no shell:true, no string concatenation)
* - log the invocation via the audit channel so misuse is detectable
*/
export function isLocalRequestAllowed(): { allowed: true } | { allowed: false; reason: string } {
const headers = (globalThis as { __omniRequestHeaders?: Headers }).__omniRequestHeaders;
if (headers) {
// 1. Bearer token path (desktop app trust)
const expected = process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
if (expected) {
const supplied = headers.get("authorization")?.replace(/^Bearer\s+/i, "") ?? "";
if (supplied && constantTimeEqual(supplied, expected)) {
return { allowed: true };
}
}
// 2. Same-origin loopback path (browser dev tools)
const host = headers.get("host") ?? "";
const fwd = headers.get("x-forwarded-for") ?? "";
// Accept the bracketed IPv6 host form browsers send in the Host header
// (`[::1]:20128`) alongside bare `::1`, `localhost`, and `127.0.0.1`.
const isLoopbackHost = /^(localhost|127\.0\.0\.1|::1|\[::1\])(:\d+)?$/.test(host);
const isLoopbackFwd = fwd === "" || /^127\.|^::1$|^localhost$/.test(fwd.split(",")[0]?.trim() ?? "");
if (isLoopbackHost && isLoopbackFwd) {
return { allowed: true };
}
return { allowed: false, reason: "non-local origin" };
}
// Production opt-in
if (process.env.NODE_ENV === "production" && process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED !== "1") {
return { allowed: false, reason: "disabled in production" };
}
return { allowed: true };
}
function constantTimeEqual(a: string, b: string): boolean {
if (a.length !== b.length) return false;
let mismatch = 0;
for (let i = 0; i < a.length; i += 1) {
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
}
return mismatch === 0;
}

View File

@@ -37,6 +37,7 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
"/api/plugins", // bare path: GET list + POST install also trigger plugin loading
"/api/system/version", // auto-update: spawns git checkout + npm install — RCE-via-tunnel surface (Hard Rules #15 + #17, found by 6A.8 route-guard gate)
"/api/db-backups/exportAll", // spawns tar for export archive (Hard Rules #15 + #17, found by 6A.8 route-guard gate)
"/api/local/", // T-12: 1-click local service launchers (Redis today; spawns podman/docker) — loopback-enforced by isLocalRequestAllowed() in src/lib/security/localEndpoints.ts (Hard Rules #15 + #17)
];
/**
@@ -76,6 +77,7 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray<string> = [
"/api/tools/agent-bridge/", // start/stop MITM server + DNS edits (Hard Rules #15 + #17)
"/api/tools/traffic-inspector/", // http-proxy listener + system proxy (Hard Rules #15 + #17)
"/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17)
"/api/local/", // T-12: 1-click local service launchers (Redis today) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17)
];
/**

View File

@@ -0,0 +1,119 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
// ─── T-12 (#3932 PR-4): bifrost sidecar proxy route ──────────────────────
//
// We test the *contract* of the route by setting env before import and
// calling the exported POST handler. The handler is module-scope configured
// (BIFROST_BASE_URL is read at import time), so env must be set BEFORE the
// dynamic import below.
const ORIGINAL_BIFROST_BASE_URL = process.env.BIFROST_BASE_URL;
const ORIGINAL_BIFROST_API_KEY = process.env.BIFROST_API_KEY;
const ORIGINAL_BIFROST_OMNI_KEY = process.env.OMNIROUTE_BIFROST_KEY;
const ORIGINAL_BIFROST_TIMEOUT = process.env.BIFROST_TIMEOUT_MS;
const ORIGINAL_BIFROST_STREAMING = process.env.BIFROST_STREAMING_ENABLED;
function restoreEnv() {
if (ORIGINAL_BIFROST_BASE_URL === undefined) delete process.env.BIFROST_BASE_URL;
else process.env.BIFROST_BASE_URL = ORIGINAL_BIFROST_BASE_URL;
if (ORIGINAL_BIFROST_API_KEY === undefined) delete process.env.BIFROST_API_KEY;
else process.env.BIFROST_API_KEY = ORIGINAL_BIFROST_API_KEY;
if (ORIGINAL_BIFROST_OMNI_KEY === undefined) delete process.env.OMNIROUTE_BIFROST_KEY;
else process.env.OMNIROUTE_BIFROST_KEY = ORIGINAL_BIFROST_OMNI_KEY;
if (ORIGINAL_BIFROST_TIMEOUT === undefined) delete process.env.BIFROST_TIMEOUT_MS;
else process.env.BIFROST_TIMEOUT_MS = ORIGINAL_BIFROST_TIMEOUT;
if (ORIGINAL_BIFROST_STREAMING === undefined) delete process.env.BIFROST_STREAMING_ENABLED;
else process.env.BIFROST_STREAMING_ENABLED = ORIGINAL_BIFROST_STREAMING;
}
// Case 1: BIFROST_BASE_URL unset. We test this first because the route's
// module-scope `BIFROST_BASE_URL` would be empty for the entire test file.
test("bifrost route: returns 503 + fallback header when BIFROST_BASE_URL is unset", async () => {
delete process.env.BIFROST_BASE_URL;
delete process.env.BIFROST_API_KEY;
delete process.env.OMNIROUTE_BIFROST_KEY;
delete process.env.BIFROST_TIMEOUT_MS;
delete process.env.BIFROST_STREAMING_ENABLED;
// Dynamic import after env is set so the module reads the empty value.
const { POST } = await import(
"../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts"
);
const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "gpt-4", messages: [] }),
});
const res = await POST(req);
assert.equal(res.status, 503);
assert.equal(res.headers.get("X-Bifrost-Fallback"), "/api/v1/relay/chat/completions");
const body = await res.json();
assert.match(String(body?.error?.message ?? ""), /Bifrost sidecar not configured/);
restoreEnv();
});
// Case 2: BIFROST_BASE_URL set but no auth token in request. The route should
// return 401 *before* trying to reach the gateway, so we don't need a mock fetch.
test("bifrost route: returns 401 when BIFROST_BASE_URL is set but no auth token is provided", async () => {
process.env.BIFROST_BASE_URL = "http://bifrost.test.local:8080";
delete process.env.BIFROST_API_KEY;
delete process.env.OMNIROUTE_BIFROST_KEY;
delete process.env.BIFROST_TIMEOUT_MS;
delete process.env.BIFROST_STREAMING_ENABLED;
// Use a fresh module instance by appending a cache-busting query string.
// (Node's ESM cache is keyed by resolved URL, so a unique query bypasses it.)
const { POST } = await import(
`../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts?case=${Date.now()}-${Math.random()}`
);
const req = new Request("http://localhost/api/v1/relay/chat/completions/bifrost", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "gpt-4", messages: [{ role: "user", content: "hi" }] }),
});
const res = await POST(req);
assert.equal(res.status, 401);
const body = await res.json();
assert.match(String(body?.error?.message ?? ""), /Missing relay token/);
restoreEnv();
});
// Case 3: hashToken() is used internally. Verify the SHA-256 output shape so
// downstream code that compares hashes doesn't silently break if the impl
// changes. (This is a contract test, not a black-box test of the function.)
test("bifrost route: relay token hashing matches the SHA-256 hex contract", () => {
const token = "test-token-abc-123";
const hash = createHash("sha256").update(token).digest("hex");
assert.equal(hash.length, 64); // SHA-256 hex = 64 chars
assert.match(hash, /^[0-9a-f]{64}$/);
});
// Case 4: Validate the CORS preflight handler exists and responds with 204/200
test("bifrost route: OPTIONS responds with CORS headers", async () => {
const { OPTIONS } = await import(
`../../../../src/app/api/v1/relay/chat/completions/bifrost/route.ts?case=${Date.now()}-${Math.random()}`
);
const res = await OPTIONS();
// handleCorsOptions() returns 204 No Content with the standard CORS
// methods/headers. Access-Control-Allow-Origin is intentionally NOT set on
// the route's response — src/middleware.ts (applyCorsHeaders) is the single
// source of truth for which origin to echo, based on the allowlist in
// src/server/cors/origins.ts. We assert the route ships its end of the
// contract: status + methods/headers. Origin overlay is exercised by the
// middleware tests.
assert.ok(res.status === 200 || res.status === 204, `expected 200/204, got ${res.status}`);
assert.ok(
res.headers.get("Access-Control-Allow-Methods"),
"missing Access-Control-Allow-Methods header"
);
assert.ok(
res.headers.get("Access-Control-Allow-Headers"),
"missing Access-Control-Allow-Headers header"
);
});

View File

@@ -0,0 +1,42 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
isLocalOnlyPath,
isLocalOnlyBypassableByManageScope,
} from "../../../src/server/authz/routeGuard.ts";
// ─── T-12 (#3932 PR-3): /api/local/ is local-only ────────────────────────
test("isLocalOnlyPath: /api/local/ prefix is local-only (T-12, #3932)", () => {
// 1-click local service launchers (Redis today) spawn podman/docker — must
// be loopback-enforced before any auth check, same as /api/mcp/.
assert.equal(isLocalOnlyPath("/api/local/redis/start"), true);
assert.equal(isLocalOnlyPath("/api/local/redis/stop"), true);
assert.equal(isLocalOnlyPath("/api/local/redis/status"), true);
assert.equal(isLocalOnlyPath("/api/local/"), true);
// Future /api/local/* sub-paths must also be classified — prefix is generic.
assert.equal(isLocalOnlyPath("/api/local/postgres/start"), true);
assert.equal(isLocalOnlyPath("/api/local/ollama/status"), true);
});
test("isLocalOnlyPath: /api/local* does NOT match the bare /api/localifications path", () => {
// Regression guard: the prefix must end with "/" to avoid over-broadening.
// (We don't have such a route today, but if /api/localization ever appears,
// it should NOT be loopback-enforced just because it shares a prefix.)
assert.equal(isLocalOnlyPath("/api/localization"), false);
assert.equal(isLocalOnlyPath("/api/localhost-check"), false);
});
test("isLocalOnlyBypassableByManageScope: /api/local/ is NOT bypassable (defence in depth)", () => {
// The kill-switch path. Even if a DB row tries to whitelist /api/local/ via
// the manage-scope bypass list, the runtime predicate must reject it because
// /api/local/ is in SPAWN_CAPABLE_PREFIXES.
//
// The predicate reads from runtime settings; here we exercise the
// defence-in-depth clause directly by checking the relevant invariant:
// /api/local/ must be in the same spawn-capable set as /api/cli-tools/runtime/.
assert.equal(isLocalOnlyPath("/api/local/redis/start"), true);
// Same-origin false-positive guard: ensure /api/local is treated like every
// other spawn-capable prefix (no whitelist carve-out).
assert.equal(isLocalOnlyBypassableByManageScope("/api/local/redis/start"), false);
});

View File

@@ -0,0 +1,184 @@
import { test } from "node:test";
import assert from "node:assert/strict";
// ─── T-12 (#3932 PR-3): `omniroute redis` CLI command ─────────────────────
test("registerRedis: exports a registerRedis function", async () => {
const mod = await import(`../../bin/cli/commands/redis.mjs?case=${Date.now()}-${Math.random()}`);
assert.equal(typeof mod.registerRedis, "function");
});
test("registerRedis: attaches a `redis` command with up/down/status subcommands", async () => {
const { registerRedis } = await import(
`../../bin/cli/commands/redis.mjs?case=${Date.now()}-${Math.random()}`
);
// Use a minimal stub of the commander program that records what was attached.
const recorded = { commands: new Map() };
const subCommands: Array<{ name: string; options: Set<string> }> = [];
const fakeProgram = {
command(name) {
const cmd = {
name,
description() {
return cmd;
},
option(flag) {
// Track every registered option so we can assert on them.
const optName = flag.split(/[ ,]/)[0].replace(/^-+/, "");
cmd.options = cmd.options || new Set();
cmd.options.add(optName);
return cmd;
},
action() {
return cmd;
},
};
recorded.commands.set(name, cmd);
return cmd;
},
};
// registerRedis calls .command("redis") first, then .command("up") etc.
// on the returned sub-command. We need a smarter stub that returns a
// separate object for each call.
const subStubs: Array<{ name: string; options: Set<string> }> = [];
const allCommands = new Map();
const program = {
command(name) {
if (name === "redis") {
// Return the parent-of-subcommands stub
const redisCmd = {
options: new Set<string>(),
command(subName) {
const sub = {
name: subName,
options: new Set<string>(),
description() { return sub; },
option(flag) {
const optName = flag.split(/[ ,]/)[0].replace(/^-+/, "");
sub.options.add(optName);
return sub;
},
action() { return sub; },
};
subStubs.push(sub);
return sub;
},
description() { return redisCmd; },
option(flag) {
const optName = flag.split(/[ ,]/)[0].replace(/^-+/, "");
redisCmd.options.add(optName);
return redisCmd;
},
};
allCommands.set(name, redisCmd);
return redisCmd;
}
return null;
},
};
registerRedis(program);
assert.equal(subStubs.length, 3, `expected 3 subcommands, got ${subStubs.length}`);
const names = subStubs.map((s) => s.name).sort();
assert.deepEqual(names, ["down", "status", "up"]);
});
test("registerRedis: `up` subcommand has the expected option flags", async () => {
const { registerRedis } = await import(
`../../bin/cli/commands/redis.mjs?case=${Date.now()}-${Math.random()}`
);
const subStubs: Array<{ name: string; options: Set<string> }> = [];
const program = {
command(_name: string) {
const redisCmd = {
options: new Set<string>(),
command(subName: string) {
const sub = {
name: subName,
options: new Set<string>(),
description() { return sub; },
option(flag: string) {
// Prefer the canonical long flag (`--port` from `-p, --port <port>`);
// fall back to the first token for short-only / `--no-x` flags.
const long = flag.match(/--([\w-]+)/);
const optName = long ? long[1] : flag.split(/[ ,]/)[0].replace(/^-+/, "");
sub.options.add(optName);
return sub;
},
action() { return sub; },
};
subStubs.push(sub);
return sub;
},
description() { return redisCmd; },
option(flag: string) {
// Prefer the canonical long flag (`--port` from `-p, --port <port>`);
// fall back to the first token for short-only / `--no-x` flags.
const long = flag.match(/--([\w-]+)/);
const optName = long ? long[1] : flag.split(/[ ,]/)[0].replace(/^-+/, "");
redisCmd.options.add(optName);
return redisCmd;
},
};
return redisCmd;
},
};
registerRedis(program);
const upCmd = subStubs.find((s) => s.name === "up")!;
assert.ok(upCmd.options.has("port"), "missing --port");
assert.ok(upCmd.options.has("name"), "missing --name");
assert.ok(upCmd.options.has("image"), "missing --image");
assert.ok(upCmd.options.has("runtime"), "missing --runtime");
assert.ok(upCmd.options.has("password"), "missing --password");
assert.ok(upCmd.options.has("no-pull"), "missing --no-pull (boolean negation)");
});
test("runRedisUpCommand: returns 1 when no podman/docker is available (no PATH)", async () => {
// We force this by passing a --runtime that doesn't exist. execFile will
// throw ENOENT, the runner will print the error and return 1.
const { runRedisUpCommand } = await import(
`../../bin/cli/commands/redis.mjs?case=${Date.now()}-${Math.random()}`
);
// Capture stderr to keep the test output clean.
const origStderr = process.stderr.write.bind(process.stderr);
const captured: string[] = [];
process.stderr.write = ((chunk: string | Uint8Array) => {
captured.push(typeof chunk === "string" ? chunk : chunk.toString());
return true;
}) as typeof process.stderr.write;
try {
const code = await runRedisUpCommand({ runtime: "/nonexistent/runtime-binary" });
assert.equal(code, 1, "expected exit 1 when runtime is missing");
} finally {
process.stderr.write = origStderr;
}
});
test("runRedisStatusCommand: returns 1 when no podman/docker is available", async () => {
const { runRedisStatusCommand } = await import(
`../../bin/cli/commands/redis.mjs?case=${Date.now()}-${Math.random()}`
);
const origStderr = process.stderr.write.bind(process.stderr);
process.stderr.write = (() => true) as typeof process.stderr.write;
try {
const code = await runRedisStatusCommand({ runtime: "/nonexistent/runtime-binary" });
assert.equal(code, 1);
} finally {
process.stderr.write = origStderr;
}
});
test("runRedisDownCommand: returns 1 when no podman/docker is available", async () => {
const { runRedisDownCommand } = await import(
`../../bin/cli/commands/redis.mjs?case=${Date.now()}-${Math.random()}`
);
const origStderr = process.stderr.write.bind(process.stderr);
process.stderr.write = (() => true) as typeof process.stderr.write;
try {
const code = await runRedisDownCommand({ runtime: "/nonexistent/runtime-binary" });
assert.equal(code, 1);
} finally {
process.stderr.write = origStderr;
}
});

View File

@@ -0,0 +1,196 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { isLocalRequestAllowed } from "../../../src/lib/security/localEndpoints.ts";
/**
* Tests for the /api/local/* security guard. The guard reads from
* `globalThis.__omniRequestHeaders` (set by the Next.js middleware shim) and
* from `process.env` (production opt-in and bearer token). Each test cleans
* up both before and after running so the order doesn't matter.
*/
type OmniGlobals = { __omniRequestHeaders?: Headers };
const G = globalThis as OmniGlobals;
function reset() {
delete G.__omniRequestHeaders;
}
function setHeaders(headers: Record<string, string>) {
G.__omniRequestHeaders = new Headers(headers);
}
test("isLocalRequestAllowed: allows when no headers injected and not production", () => {
const prevNodeEnv = process.env.NODE_ENV;
const prevEnabled = process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED;
delete process.env.NODE_ENV;
delete process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED;
reset();
try {
const out = isLocalRequestAllowed();
assert.equal(out.allowed, true, `expected allowed, got: ${JSON.stringify(out)}`);
} finally {
reset();
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
if (prevEnabled !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED = prevEnabled;
}
});
test("isLocalRequestAllowed: allows loopback host + empty xff (browser dev path)", () => {
const prevNodeEnv = process.env.NODE_ENV;
const prevToken = process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
delete process.env.NODE_ENV;
delete process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
setHeaders({ host: "localhost:20128" });
try {
const out = isLocalRequestAllowed();
assert.equal(out.allowed, true, `expected allowed, got: ${JSON.stringify(out)}`);
} finally {
reset();
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
if (prevToken !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = prevToken;
}
});
test("isLocalRequestAllowed: allows IPv4 loopback host 127.0.0.1", () => {
const prevNodeEnv = process.env.NODE_ENV;
delete process.env.NODE_ENV;
setHeaders({ host: "127.0.0.1:20128" });
try {
const out = isLocalRequestAllowed();
assert.equal(out.allowed, true);
} finally {
reset();
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
}
});
test("isLocalRequestAllowed: allows IPv6 loopback host [::1]", () => {
const prevNodeEnv = process.env.NODE_ENV;
delete process.env.NODE_ENV;
setHeaders({ host: "[::1]:20128" });
try {
const out = isLocalRequestAllowed();
assert.equal(out.allowed, true);
} finally {
reset();
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
}
});
test("isLocalRequestAllowed: rejects public host even with loopback x-forwarded-for", () => {
const prevNodeEnv = process.env.NODE_ENV;
const prevToken = process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
delete process.env.NODE_ENV;
delete process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
// Public Host with loopback XFF is rejected — Host header is the
// authoritative loopback check (defence against Host header injection
// from a tunneled dev URL).
setHeaders({ host: "example.com", "x-forwarded-for": "127.0.0.1" });
try {
const out = isLocalRequestAllowed();
assert.equal(out.allowed, false);
assert.equal(out.reason, "non-local origin");
} finally {
reset();
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
if (prevToken !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = prevToken;
}
});
test("isLocalRequestAllowed: rejects non-loopback origin (no bearer token)", () => {
const prevNodeEnv = process.env.NODE_ENV;
const prevToken = process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
delete process.env.NODE_ENV;
delete process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
setHeaders({ host: "example.com", "x-forwarded-for": "203.0.113.5" });
try {
const out = isLocalRequestAllowed();
assert.equal(out.allowed, false);
assert.equal(out.reason, "non-local origin");
} finally {
reset();
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
if (prevToken !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = prevToken;
}
});
test("isLocalRequestAllowed: bearer token takes precedence over host check (desktop app)", () => {
const prevNodeEnv = process.env.NODE_ENV;
const prevToken = process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = "s3cret-token-abc";
delete process.env.NODE_ENV;
// Non-loopback origin BUT valid bearer token → allowed (desktop app)
setHeaders({
host: "example.com",
"x-forwarded-for": "203.0.113.5",
authorization: "Bearer s3cret-token-abc",
});
try {
const out = isLocalRequestAllowed();
assert.equal(out.allowed, true, `expected allowed, got: ${JSON.stringify(out)}`);
} finally {
reset();
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
if (prevToken !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = prevToken;
}
});
test("isLocalRequestAllowed: rejects wrong bearer token even with token configured", () => {
const prevNodeEnv = process.env.NODE_ENV;
const prevToken = process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN;
process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = "s3cret-token-abc";
delete process.env.NODE_ENV;
setHeaders({
host: "example.com",
"x-forwarded-for": "203.0.113.5",
authorization: "Bearer wrong-token",
});
try {
const out = isLocalRequestAllowed();
assert.equal(out.allowed, false);
// Falls through to the host check, which rejects.
assert.equal(out.reason, "non-local origin");
} finally {
reset();
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
if (prevToken !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_TOKEN = prevToken;
}
});
test("isLocalRequestAllowed: production without opt-in rejects (no headers path)", () => {
const prevNodeEnv = process.env.NODE_ENV;
const prevEnabled = process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED;
process.env.NODE_ENV = "production";
delete process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED;
reset();
try {
const out = isLocalRequestAllowed();
assert.equal(out.allowed, false);
assert.equal(out.reason, "disabled in production");
} finally {
reset();
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
if (prevEnabled !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED = prevEnabled;
}
});
test("isLocalRequestAllowed: production WITH opt-in allows (no headers path)", () => {
// The "no headers" path bypasses the host check entirely; it's the catch-all
// for non-Next contexts (cron jobs, scripts). In production with opt-in,
// the function returns { allowed: true } for that path. This is by design:
// the production gate is at the route handler, not the guard.
const prevNodeEnv = process.env.NODE_ENV;
const prevEnabled = process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED;
process.env.NODE_ENV = "production";
process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED = "1";
reset();
try {
const out = isLocalRequestAllowed();
assert.equal(out.allowed, true);
} finally {
reset();
if (prevNodeEnv !== undefined) process.env.NODE_ENV = prevNodeEnv;
if (prevEnabled !== undefined) process.env.OMNIROUTE_LOCAL_ENDPOINTS_ENABLED = prevEnabled;
}
});