From 3ff3e3dd15b0d285db3888e246b54bed1b4469d2 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 20 May 2026 00:43:15 -0300 Subject: [PATCH] fix(playground): guard ChatPlayground filteredModels for non-string ids Same root cause as commit 49fe356b9: ChatPlayground filtered models with m.id.startsWith(...) which crashed on null/undefined ids returned by /v1/models (synthetic combo entries). Apply the same defensive guard and dedupe used in the parent page. --- .semgrep/rules/cli-no-sqlite.yaml | 8 +- bin/cli/commands/backup.mjs | 16 +- bin/cli/commands/doctor.mjs | 67 +------- bin/cli/sqlite.mjs | 161 +++++++++++++++--- bin/reset-password.mjs | 56 +----- docker-compose.prod.yml | 2 +- docker-compose.yml | 27 --- docs/guides/DOCKER_GUIDE.md | 19 +-- eslint.config.mjs | 2 + scripts/build/validate-pack-artifact.ts | 31 ++-- .../dashboard/playground/ChatPlayground.tsx | 15 +- .../api/cli-tools/antigravity-mitm/route.ts | 11 +- src/app/api/openapi/try/route.ts | 3 +- src/app/api/settings/favicon/route.ts | 81 +++------ src/app/api/v1beta/models/[...path]/route.ts | 6 +- src/app/api/v1beta/models/route.ts | 3 +- src/app/api/webhooks/[id]/route.ts | 7 +- src/app/api/webhooks/[id]/test/route.ts | 5 +- src/app/api/webhooks/route.ts | 5 +- src/sse/handlers/chatHelpers.ts | 18 ++ src/sse/services/auth.ts | 24 ++- tests/unit/chat-helpers.test.ts | 35 ++++ 22 files changed, 328 insertions(+), 274 deletions(-) diff --git a/.semgrep/rules/cli-no-sqlite.yaml b/.semgrep/rules/cli-no-sqlite.yaml index 7c9e9fe508..5057d888c3 100644 --- a/.semgrep/rules/cli-no-sqlite.yaml +++ b/.semgrep/rules/cli-no-sqlite.yaml @@ -4,9 +4,9 @@ rules: - pattern: new Database(...) paths: include: - - "bin/**" + - "/bin/**" exclude: - - "bin/cli/sqlite.mjs" + - "/bin/cli/sqlite.mjs" message: > Direct SQLite access in bin/ is banned. Use src/lib/db/* helpers or withRuntime() from bin/cli/runtime.mjs. See CLAUDE.md hard rule #5 and @@ -21,9 +21,9 @@ rules: - pattern: $DB.prepare("UPDATE $TABLE SET ...") paths: include: - - "bin/**" + - "/bin/**" exclude: - - "bin/cli/sqlite.mjs" + - "/bin/cli/sqlite.mjs" message: > Raw SQL in bin/ is banned. Use src/lib/db/* helpers. See CLAUDE.md hard rule #5 and bin/cli/CONVENTIONS.md. diff --git a/bin/cli/commands/backup.mjs b/bin/cli/commands/backup.mjs index 0786ba5f5e..8e42877098 100644 --- a/bin/cli/commands/backup.mjs +++ b/bin/cli/commands/backup.mjs @@ -4,6 +4,7 @@ import { mkdirSync, readdirSync, readFileSync, + unlinkSync, writeFileSync, } from "node:fs"; import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; @@ -11,6 +12,7 @@ import { dirname, join, extname, basename } from "node:path"; import { resolveDataDir } from "../data-dir.mjs"; import { apiFetch, isServerUp } from "../api.mjs"; import { t } from "../i18n.mjs"; +import { backupSqliteFile } from "../sqlite.mjs"; function getBackupDir() { return join(resolveDataDir(), "backups"); @@ -187,13 +189,6 @@ export async function runBackupCommand(opts = {}) { try { if (!existsSync(backupDir)) mkdirSync(backupDir, { recursive: true }); - let Database; - try { - Database = (await import("better-sqlite3")).default; - } catch { - Database = null; - } - let backedUp = 0; let skipped = 0; @@ -207,14 +202,11 @@ export async function runBackupCommand(opts = {}) { const destName = opts.encrypt ? `${file.name}.enc` : file.name; const destPath = join(backupPath, destName); mkdirSync(dirname(destPath), { recursive: true }); - if (file.name.endsWith(".sqlite") && Database) { - const db = new Database(sourcePath, { readonly: true }); + if (file.name.endsWith(".sqlite")) { const tmpPath = destPath.replace(/\.enc$/, ""); - await db.backup(tmpPath); - db.close(); + await backupSqliteFile(sourcePath, tmpPath); if (opts.encrypt) { encryptFile(tmpPath, destPath, passphrase); - const { unlinkSync } = await import("node:fs"); unlinkSync(tmpPath); } } else if (opts.encrypt) { diff --git a/bin/cli/commands/doctor.mjs b/bin/cli/commands/doctor.mjs index c17ae0864d..7a864d6e2d 100644 --- a/bin/cli/commands/doctor.mjs +++ b/bin/cli/commands/doctor.mjs @@ -7,6 +7,7 @@ import { pathToFileURL } from "node:url"; import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs"; import { printHeading } from "../io.mjs"; import { t } from "../i18n.mjs"; +import { readDatabaseHealth, readEncryptedCredentialSamples } from "../sqlite.mjs"; const STATIC_SALT = "omniroute-field-encryption-v1"; const KEY_LENGTH = 32; @@ -78,14 +79,6 @@ function checkConfig(dataDir) { return ok("Config", `.env found at ${envFile}`, { envFile }); } -async function loadBetterSqlite() { - try { - return (await import("better-sqlite3")).default; - } catch (error) { - return { error }; - } -} - function resolveMigrationsDir(rootDir) { const configured = process.env.OMNIROUTE_MIGRATIONS_DIR; const candidates = [ @@ -115,18 +108,9 @@ async function checkDatabase(dbPath, rootDir) { return warn("Database", `SQLite database not found at ${dbPath}`, { dbPath }); } - const Database = await loadBetterSqlite(); - if (Database.error) { - return fail("Database", "better-sqlite3 could not be loaded", { - error: Database.error instanceof Error ? Database.error.message : String(Database.error), - }); - } - - let db; try { - db = new Database(dbPath, { readonly: true, fileMustExist: true }); - const quickCheck = db.prepare("PRAGMA quick_check").get(); - const quickCheckValue = Object.values(quickCheck || {})[0]; + const { quickCheckValue, hasMigrationTable, appliedMigrationVersions } = + await readDatabaseHealth(dbPath); if (quickCheckValue !== "ok") { return fail("Database", `SQLite quick_check failed: ${quickCheckValue}`, { dbPath }); } @@ -137,18 +121,11 @@ async function checkDatabase(dbPath, rootDir) { return ok("Database", "SQLite quick_check passed", { dbPath, migrations: "not_checked" }); } - const table = db - .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") - .get("_omniroute_migrations"); - if (!table) { + if (!hasMigrationTable) { return warn("Database", "SQLite is readable, but migration table is missing", { dbPath }); } - const appliedRows = db - .prepare("SELECT version FROM _omniroute_migrations") - .all() - .map((row) => row.version); - const applied = new Set(appliedRows); + const applied = new Set(appliedMigrationVersions); const pending = migrationFiles.filter((migration) => !applied.has(migration.version)); if (pending.length > 0) { @@ -164,8 +141,6 @@ async function checkDatabase(dbPath, rootDir) { dbPath, error: error instanceof Error ? error.message : String(error), }); - } finally { - if (db) db.close(); } } @@ -203,42 +178,14 @@ async function checkStorageEncryption(dbPath) { : warn("Storage/encryption", "No STORAGE_ENCRYPTION_KEY configured; passthrough mode"); } - const Database = await loadBetterSqlite(); - if (Database.error) { - return fail("Storage/encryption", "Could not inspect encrypted credentials", { - error: Database.error instanceof Error ? Database.error.message : String(Database.error), - }); - } - - let db; try { - db = new Database(dbPath, { readonly: true, fileMustExist: true }); - const hasProviderTable = db - .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") - .get("provider_connections"); + const { hasProviderTable, encryptedValues } = await readEncryptedCredentialSamples(dbPath); if (!hasProviderTable) { return secret ? ok("Storage/encryption", "Encryption key is configured; provider table not initialized") : warn("Storage/encryption", "No STORAGE_ENCRYPTION_KEY configured; passthrough mode"); } - const rows = db - .prepare( - `SELECT api_key, access_token, refresh_token, id_token - FROM provider_connections - WHERE api_key LIKE 'enc:v1:%' - OR access_token LIKE 'enc:v1:%' - OR refresh_token LIKE 'enc:v1:%' - OR id_token LIKE 'enc:v1:%' - LIMIT 20` - ) - .all(); - const encryptedValues = rows.flatMap((row) => - ["api_key", "access_token", "refresh_token", "id_token"] - .filter((key) => typeof row[key] === "string" && row[key].startsWith("enc:v1:")) - .map((key) => row[key]) - ); - if (encryptedValues.length === 0) { return secret ? ok("Storage/encryption", "Encryption key is configured; no encrypted samples found") @@ -268,8 +215,6 @@ async function checkStorageEncryption(dbPath) { return fail("Storage/encryption", "Encrypted credential check failed", { error: error instanceof Error ? error.message : String(error), }); - } finally { - if (db) db.close(); } } diff --git a/bin/cli/sqlite.mjs b/bin/cli/sqlite.mjs index cb7eaed351..e861ae15e4 100644 --- a/bin/cli/sqlite.mjs +++ b/bin/cli/sqlite.mjs @@ -1,33 +1,42 @@ import fs from "node:fs"; import { resolveDataDir, resolveStoragePath } from "./data-dir.mjs"; import { ensureProviderSchema } from "./provider-store.mjs"; -import { ensureSettingsSchema } from "./settings-store.mjs"; +import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./settings-store.mjs"; + +async function loadBetterSqlite() { + try { + return (await import("better-sqlite3")).default; + } catch { + throw new Error("better-sqlite3 is not installed. Run npm install before using setup."); + } +} + +function createSqliteNativeError(error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) { + return new Error( + "better-sqlite3 native binding is incompatible with this Node.js runtime. " + + "Run `npm rebuild better-sqlite3` in the OmniRoute project and try again." + ); + } + return error; +} + +async function openSqliteDatabase(dbPath, options = {}) { + const Database = await loadBetterSqlite(); + try { + return new Database(dbPath, options); + } catch (error) { + throw createSqliteNativeError(error); + } +} export async function openOmniRouteDb() { const dataDir = resolveDataDir(); const dbPath = resolveStoragePath(dataDir); fs.mkdirSync(dataDir, { recursive: true }); - let Database; - try { - Database = (await import("better-sqlite3")).default; - } catch { - throw new Error("better-sqlite3 is not installed. Run npm install before using setup."); - } - - let db; - try { - db = new Database(dbPath); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) { - throw new Error( - "better-sqlite3 native binding is incompatible with this Node.js runtime. " + - "Run `npm rebuild better-sqlite3` in the OmniRoute project and try again." - ); - } - throw error; - } + const db = await openSqliteDatabase(dbPath); db.pragma("journal_mode = WAL"); ensureSettingsSchema(db); @@ -35,3 +44,113 @@ export async function openOmniRouteDb() { return { db, dataDir, dbPath }; } + +export async function withReadonlySqlite(dbPath, callback) { + const db = await openSqliteDatabase(dbPath, { readonly: true, fileMustExist: true }); + try { + return await callback(db); + } finally { + db.close(); + } +} + +export async function backupSqliteFile(sourcePath, destPath) { + const db = await openSqliteDatabase(sourcePath, { readonly: true }); + try { + await db.backup(destPath); + } finally { + db.close(); + } +} + +export async function readDatabaseHealth(dbPath) { + return withReadonlySqlite(dbPath, (db) => { + const quickCheck = db.prepare("PRAGMA quick_check").get(); + const quickCheckValue = Object.values(quickCheck || {})[0]; + const hasMigrationTable = !!db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") + .get("_omniroute_migrations"); + const appliedMigrationVersions = hasMigrationTable + ? db + .prepare("SELECT version FROM _omniroute_migrations") + .all() + .map((row) => row.version) + : []; + + return { quickCheckValue, hasMigrationTable, appliedMigrationVersions }; + }); +} + +export async function readEncryptedCredentialSamples(dbPath) { + return withReadonlySqlite(dbPath, (db) => { + const hasProviderTable = !!db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") + .get("provider_connections"); + if (!hasProviderTable) { + return { hasProviderTable: false, encryptedValues: [] }; + } + + const rows = db + .prepare( + `SELECT api_key, access_token, refresh_token, id_token + FROM provider_connections + WHERE api_key LIKE 'enc:v1:%' + OR access_token LIKE 'enc:v1:%' + OR refresh_token LIKE 'enc:v1:%' + OR id_token LIKE 'enc:v1:%' + LIMIT 20` + ) + .all(); + + const encryptedValues = rows.flatMap((row) => + ["api_key", "access_token", "refresh_token", "id_token"] + .filter((key) => typeof row[key] === "string" && row[key].startsWith("enc:v1:")) + .map((key) => row[key]) + ); + + return { hasProviderTable: true, encryptedValues }; + }); +} + +export async function readManagementPasswordState(dbPath = resolveStoragePath(resolveDataDir())) { + if (!fs.existsSync(dbPath)) { + return { exists: false, hasPassword: false }; + } + + return withReadonlySqlite(dbPath, (db) => { + const hasSettingsTable = !!db + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?") + .get("key_value"); + if (!hasSettingsTable) { + return { exists: true, hasPassword: false }; + } + const row = db + .prepare("SELECT value FROM key_value WHERE namespace = 'settings' AND key = ?") + .get("password"); + let password = row?.value; + if (typeof password === "string") { + try { + password = JSON.parse(password); + } catch {} + } + return { + exists: true, + hasPassword: typeof password === "string" && password.length > 0, + }; + }); +} + +export async function resetManagementPassword( + password, + dbPath = resolveStoragePath(resolveDataDir()) +) { + const db = await openSqliteDatabase(dbPath); + try { + db.pragma("journal_mode = WAL"); + ensureSettingsSchema(db); + const hashedPassword = await hashManagementPassword(password); + updateSettings(db, { password: hashedPassword, requireLogin: true }); + } finally { + db.close(); + } +} diff --git a/bin/reset-password.mjs b/bin/reset-password.mjs index e1d85eced8..2691dcb966 100644 --- a/bin/reset-password.mjs +++ b/bin/reset-password.mjs @@ -14,16 +14,12 @@ */ import { createInterface } from "node:readline"; -import { resolve, dirname } from "node:path"; -import { fileURLToPath } from "node:url"; -import { existsSync } from "node:fs"; -import bcrypt from "bcryptjs"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); +import { resolveDataDir, resolveStoragePath } from "./cli/data-dir.mjs"; +import { readManagementPasswordState, resetManagementPassword } from "./cli/sqlite.mjs"; // Resolve data directory — same logic as the server -const DATA_DIR = process.env.DATA_DIR || resolve(__dirname, "..", "data"); -const DB_PATH = resolve(DATA_DIR, "settings.db"); +const DATA_DIR = resolveDataDir(); +const DB_PATH = resolveStoragePath(DATA_DIR); const rl = createInterface({ input: process.stdin, @@ -34,37 +30,19 @@ function ask(question) { return new Promise((resolve) => rl.question(question, resolve)); } -function generateSecretDigest(input) { - // Use bcrypt with a salt round of 10 to match login/route.ts expectations - // and resolve CodeQL js/insufficient-password-hash warning. - return bcrypt.hashSync(input, 10); -} - console.log("\nšŸ”‘ OmniRoute — Password Reset\n"); async function main() { // Check if database exists - if (!existsSync(DB_PATH)) { + const passwordState = await readManagementPasswordState(DB_PATH); + if (!passwordState.exists) { console.error(`āŒ Database not found at: ${DB_PATH}`); console.error(` Make sure OmniRoute has been started at least once.`); console.error(` Or set DATA_DIR env var to your data directory.\n`); process.exit(1); } - let Database; - try { - Database = (await import("better-sqlite3")).default; - } catch { - console.error("āŒ better-sqlite3 not installed. Run: npm install"); - process.exit(1); - } - - const db = new Database(DB_PATH); - - // Check current settings - const row = db.prepare("SELECT value FROM settings WHERE key = 'password'").get(); - - if (row) { + if (passwordState.hasPassword) { console.log("ā„¹ļø A password is currently set."); } else { console.log("ā„¹ļø No password is currently set."); @@ -74,7 +52,6 @@ async function main() { if (!password || password.length < 8) { console.error("\nāŒ Password must be at least 8 characters.\n"); - db.close(); rl.close(); process.exit(1); } @@ -83,28 +60,11 @@ async function main() { if (password !== confirm) { console.error("\nāŒ Passwords do not match.\n"); - db.close(); rl.close(); process.exit(1); } - const hashed = generateSecretDigest(password); - - // Upsert the password - const stmt = db.prepare(` - INSERT INTO settings (key, value) VALUES ('password', ?) - ON CONFLICT(key) DO UPDATE SET value = excluded.value - `); - stmt.run(hashed); - - // Also ensure requireLogin is true - const loginStmt = db.prepare(` - INSERT INTO settings (key, value) VALUES ('requireLogin', 'true') - ON CONFLICT(key) DO UPDATE SET value = excluded.value - `); - loginStmt.run(); - - db.close(); + await resetManagementPassword(password, DB_PATH); rl.close(); console.log("\nāœ… Password reset successfully!"); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 7f58e614b0..420c8bb7c7 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -14,7 +14,7 @@ services: # ── Redis (Rate Limiter Backend) ────────────────────────────────── redis: - image: redis:8.6.2 + image: redis:8.6.2-alpine container_name: omniroute-redis-prod restart: unless-stopped volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 0b5a762837..c0b28a307e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,14 +6,11 @@ # base → minimal image, no CLI tools # cli → CLIs installed inside the container (portable) # host → runner-base + host-mounted CLI binaries (Linux-first) -# cliproxyapi → CLIProxyAPI sidecar on port 8317 # # Usage: # docker compose --profile base up -d # docker compose --profile cli up -d # docker compose --profile host up -d -# docker compose --profile cliproxyapi up -d -# docker compose --profile cli --profile cliproxyapi up -d # # Before first run, copy .env.example → .env and edit your secrets. # ────────────────────────────────────────────────────────────────────── @@ -130,30 +127,6 @@ services: profiles: - host - # ── Profile: cliproxyapi (CLIProxyAPI as sidecar) ───────────────── - cliproxyapi: - container_name: cliproxyapi - image: ghcr.io/router-for-me/cliproxyapi:v6.9.7 - restart: unless-stopped - ports: - - "${CLIPROXYAPI_PORT:-8317}:${CLIPROXYAPI_PORT:-8317}" - volumes: - - cliproxyapi-data:/root/.cli-proxy-api - environment: - - PORT=${CLIPROXYAPI_PORT:-8317} - - HOST=0.0.0.0 - healthcheck: - test: - ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:${CLIPROXYAPI_PORT:-8317}/v1/models"] - interval: 30s - timeout: 5s - retries: 3 - start_period: 10s - profiles: - - cliproxyapi - volumes: - cliproxyapi-data: - name: cliproxyapi-data redis-data: name: omniroute-redis-data diff --git a/docs/guides/DOCKER_GUIDE.md b/docs/guides/DOCKER_GUIDE.md index 913b583725..e66d0867b1 100644 --- a/docs/guides/DOCKER_GUIDE.md +++ b/docs/guides/DOCKER_GUIDE.md @@ -64,23 +64,17 @@ docker compose --profile cli up -d # Host profile (Linux-first; mounts host CLI binaries read-only) docker compose --profile host up -d - -# Combine CLI + CLIProxyAPI sidecar -docker compose --profile cli --profile cliproxyapi up -d ``` ## Available Profiles -OmniRoute ships four Compose profiles. Pick the one that matches your environment. +OmniRoute ships three Compose profiles. Pick the one that matches your environment. -| Profile | Service | When to use | Command | -| ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------- | -| `base` (default) | `omniroute-base` | Headless server / minimal runtime, no provider CLIs bundled | `docker compose --profile base up -d` | -| `cli` | `omniroute-cli` | Agentic workflows that call `omniroute providers/setup/doctor` and bundled CLIs (Codex, Claude Code, Droid, OpenClaw) | `docker compose --profile cli up -d` | -| `host` | `omniroute-host` | Linux hosts that want `network_mode`-like access to host CLIs by mounting `~/.local/bin`, `~/.codex`, `~/.claude`, etc. read-only | `docker compose --profile host up -d` | -| `cliproxyapi` | `cliproxyapi` | Run the [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) sidecar on port `8317` for upstream CLI proxying | `docker compose --profile cliproxyapi up -d` | - -> Multiple profiles can be combined: `docker compose --profile cli --profile cliproxyapi up -d`. +| Profile | Service | When to use | Command | +| ---------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- | +| `base` (default) | `omniroute-base` | Headless server / minimal runtime, no provider CLIs bundled | `docker compose --profile base up -d` | +| `cli` | `omniroute-cli` | Agentic workflows that call `omniroute providers/setup/doctor` and bundled CLIs (Codex, Claude Code, Droid, OpenClaw) | `docker compose --profile cli up -d` | +| `host` | `omniroute-host` | Linux hosts that want `network_mode`-like access to host CLIs by mounting `~/.local/bin`, `~/.codex`, `~/.claude`, etc. read-only | `docker compose --profile host up -d` | ## Redis Sidecar @@ -167,7 +161,6 @@ Beyond the defaults documented in [ENVIRONMENT.md](../reference/ENVIRONMENT.md), | `OMNIROUTE_MEMORY_MB` | Node heap ceiling (`NODE_OPTIONS=--max-old-space-size`) baked into the image | `256` (set in Dockerfile) | | `DASHBOARD_PORT` / `API_PORT` | Override exposed ports for dashboard (20128) and API (20129) | `20128` / `20129` | | `PROD_DASHBOARD_PORT` | Host-side dashboard port for `docker-compose.prod.yml` | `20130` | -| `CLIPROXYAPI_PORT` | Host-side port for the `cliproxyapi` sidecar | `8317` | ## Docker Compose with Caddy (HTTPS Auto-TLS) diff --git a/eslint.config.mjs b/eslint.config.mjs index 9266b9e696..f19c6e72ef 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -50,6 +50,8 @@ const eslintConfig = [ "bin/**", // Dependencies "node_modules/**", + ".worktrees/**", + ".omnivscodeagent/**", // VS Code extension and its large test fixtures "vscode-extension/**", "_references/**", diff --git a/scripts/build/validate-pack-artifact.ts b/scripts/build/validate-pack-artifact.ts index 2053fd69b1..e208c4b6eb 100644 --- a/scripts/build/validate-pack-artifact.ts +++ b/scripts/build/validate-pack-artifact.ts @@ -1,6 +1,7 @@ #!/usr/bin/env node import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -17,22 +18,29 @@ const __dirname: string = dirname(__filename); const ROOT: string = join(__dirname, "..", ".."); const npmCommand: string = process.platform === "win32" ? "npm.cmd" : "npm"; -function runPackDryRun(): any { +function runNpm(args: string[], stdio: "inherit" | "pipe" = "pipe"): string { const npmExecPath = process.env.npm_execpath; const command = npmExecPath ? process.execPath : npmCommand; - const args = [ - ...(npmExecPath ? [npmExecPath] : []), - "pack", - "--dry-run", - "--json", - "--ignore-scripts", - ]; - - const output = execFileSync(command, args, { + return execFileSync(command, [...(npmExecPath ? [npmExecPath] : []), ...args], { cwd: ROOT, encoding: "utf8", - stdio: ["ignore", "pipe", "pipe"], + stdio: stdio === "inherit" ? "inherit" : ["ignore", "pipe", "pipe"], }); +} + +function ensureAppStagingReady(): void { + const missingAppRequiredPaths = PACK_ARTIFACT_REQUIRED_PATHS.filter((requiredPath) => + requiredPath.startsWith("app/") + ).filter((requiredPath) => !existsSync(join(ROOT, requiredPath))); + + if (missingAppRequiredPaths.length === 0) return; + + console.log("šŸ“¦ app/ staging is missing required runtime files; running npm run build:cli..."); + runNpm(["run", "build:cli"], "inherit"); +} + +function runPackDryRun(): any { + const output = runNpm(["pack", "--dry-run", "--json", "--ignore-scripts"]); const jsonStart = output.indexOf("["); const jsonEnd = output.lastIndexOf("]"); @@ -66,6 +74,7 @@ function formatBytes(bytes: number): string { } try { + ensureAppStagingReady(); const packReport = runPackDryRun(); const artifactPaths: string[] = packReport.files.map((file: any) => file.path); const unexpectedPaths: string[] = findUnexpectedArtifactPaths(artifactPaths, { diff --git a/src/app/(dashboard)/dashboard/playground/ChatPlayground.tsx b/src/app/(dashboard)/dashboard/playground/ChatPlayground.tsx index 508326890f..ff0875e436 100644 --- a/src/app/(dashboard)/dashboard/playground/ChatPlayground.tsx +++ b/src/app/(dashboard)/dashboard/playground/ChatPlayground.tsx @@ -48,9 +48,18 @@ export default function ChatPlayground({ const messagesEndRef = useRef(null); const abortRef = useRef(null); - const filteredModels = models - .filter((m) => !selectedProvider || m.id.startsWith(selectedProvider + "/")) - .map((m) => ({ value: m.id, label: m.id })); + const filteredModels = (() => { + const seen = new Set(); + const out: Array<{ value: string; label: string }> = []; + for (const m of models) { + if (typeof m?.id !== "string") continue; + if (selectedProvider && !m.id.startsWith(selectedProvider + "/")) continue; + if (seen.has(m.id)) continue; + seen.add(m.id); + out.push({ value: m.id, label: m.id }); + } + return out; + })(); const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: "smooth" }); diff --git a/src/app/api/cli-tools/antigravity-mitm/route.ts b/src/app/api/cli-tools/antigravity-mitm/route.ts index 901c7ea919..f296d31417 100644 --- a/src/app/api/cli-tools/antigravity-mitm/route.ts +++ b/src/app/api/cli-tools/antigravity-mitm/route.ts @@ -3,6 +3,7 @@ export const runtime = "nodejs"; import { NextResponse } from "next/server"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { requireCliToolsAuth } from "@/lib/api/requireCliToolsAuth"; import { cliMitmStartSchema, cliMitmStopSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; @@ -25,7 +26,7 @@ export async function GET(request) { hasCachedPassword: !!getCachedPassword(), }); } catch (error) { - console.log("Error getting MITM status:", error.message); + console.log("Error getting MITM status:", sanitizeErrorMessage(error)); return NextResponse.json({ error: "Failed to get MITM status" }, { status: 500 }); } } @@ -81,9 +82,9 @@ export async function POST(request) { pid: result.pid, }); } catch (error) { - console.log("Error starting MITM:", error.message); + console.log("Error starting MITM:", sanitizeErrorMessage(error)); return NextResponse.json( - { error: error.message || "Failed to start MITM proxy" }, + { error: sanitizeErrorMessage(error) || "Failed to start MITM proxy" }, { status: 500 } ); } @@ -130,9 +131,9 @@ export async function DELETE(request) { return NextResponse.json({ success: true, running: false }); } catch (error) { - console.log("Error stopping MITM:", error.message); + console.log("Error stopping MITM:", sanitizeErrorMessage(error)); return NextResponse.json( - { error: error.message || "Failed to stop MITM proxy" }, + { error: sanitizeErrorMessage(error) || "Failed to stop MITM proxy" }, { status: 500 } ); } diff --git a/src/app/api/openapi/try/route.ts b/src/app/api/openapi/try/route.ts index cd64dad52c..4469c9a4a0 100644 --- a/src/app/api/openapi/try/route.ts +++ b/src/app/api/openapi/try/route.ts @@ -5,6 +5,7 @@ import { z } from "zod"; import { NextRequest, NextResponse } from "next/server"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; @@ -138,7 +139,7 @@ export async function POST(request: NextRequest) { status: 0, statusText: "Network Error", headers: {}, - body: { error: error.message || "Request failed" }, + body: { error: sanitizeErrorMessage(error) || "Request failed" }, latencyMs: 0, contentType: "application/json", }, diff --git a/src/app/api/settings/favicon/route.ts b/src/app/api/settings/favicon/route.ts index caf5be2b66..fd6e6459e9 100644 --- a/src/app/api/settings/favicon/route.ts +++ b/src/app/api/settings/favicon/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { getSettings } from "@/lib/db/settings"; +import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch"; export const dynamic = "force-dynamic"; @@ -15,33 +16,6 @@ const MAX_FAVICON_SIZE = 50 * 1024; // 50KB const FETCH_TIMEOUT = 5000; // 5 seconds const CACHE_DURATION = 300; // 5 minutes -function isAllowedUrl(url: string): boolean { - try { - const parsedUrl = new URL(url); - // Only allow https (or http for local development) - if (parsedUrl.protocol !== "https:" && parsedUrl.protocol !== "http:") { - return false; - } - // Block private/internal IPs - const hostname = parsedUrl.hostname; - if ( - hostname === "localhost" || - hostname === "127.0.0.1" || - hostname === "0.0.0.0" || - hostname.startsWith("192.168.") || - hostname.startsWith("10.") || - hostname.startsWith("172.") || - hostname.endsWith(".local") || - hostname === "localhost" - ) { - return false; - } - return true; - } catch { - return false; - } -} - function validateImageData(base64Data: string, contentType: string): boolean { if (!ALLOWED_IMAGE_TYPES.includes(contentType)) { console.error("Invalid content type:", contentType); @@ -76,42 +50,35 @@ export async function GET() { faviconData = customFaviconBase64; } } else if (customFaviconUrl) { - // Validate URL before fetching (SSRF protection) - if (!isAllowedUrl(customFaviconUrl)) { - console.error("Blocked invalid favicon URL:", customFaviconUrl); - } else { - try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT); + try { + const response = await safeOutboundFetch(customFaviconUrl, { + ...SAFE_OUTBOUND_FETCH_PRESETS.validationRead, + guard: "public-only", + timeoutMs: FETCH_TIMEOUT, + headers: { + "User-Agent": "OmniRoute/2.0", + }, + }); - const response = await fetch(customFaviconUrl, { - signal: controller.signal, - headers: { - "User-Agent": "OmniRoute/2.0", - }, - }); - clearTimeout(timeoutId); + if (response.ok) { + const contentType = response.headers.get("content-type") || ""; + const arrayBuffer = await response.arrayBuffer(); + const uint8Array = new Uint8Array(arrayBuffer); - if (response.ok) { - const contentType = response.headers.get("content-type") || ""; - const arrayBuffer = await response.arrayBuffer(); - const uint8Array = new Uint8Array(arrayBuffer); + // Validate size before processing + if (uint8Array.length > MAX_FAVICON_SIZE) { + console.error("Favicon exceeds max size:", uint8Array.length); + } else { + const base64 = Buffer.from(uint8Array).toString("base64"); + const fullData = `data:${contentType};base64,${base64}`; - // Validate size before processing - if (uint8Array.length > MAX_FAVICON_SIZE) { - console.error("Favicon exceeds max size:", uint8Array.length); - } else { - const base64 = Buffer.from(uint8Array).toString("base64"); - const fullData = `data:${contentType};base64,${base64}`; - - if (validateImageData(fullData, contentType)) { - faviconData = fullData; - } + if (validateImageData(fullData, contentType)) { + faviconData = fullData; } } - } catch (error) { - console.error("Failed to fetch custom favicon:", error); } + } catch (error) { + console.error("Failed to fetch custom favicon:", error); } } diff --git a/src/app/api/v1beta/models/[...path]/route.ts b/src/app/api/v1beta/models/[...path]/route.ts index a8bf6d0230..7ed236d71c 100644 --- a/src/app/api/v1beta/models/[...path]/route.ts +++ b/src/app/api/v1beta/models/[...path]/route.ts @@ -1,5 +1,6 @@ import { buildClientRawRequest, handleChat } from "@/sse/handlers/chat"; import { initTranslators } from "@omniroute/open-sse/translator/index.ts"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { v1betaGeminiGenerateSchema } from "@/shared/validation/schemas"; import { isValidationFailure, validateBody } from "@/shared/validation/helpers"; @@ -88,7 +89,10 @@ export async function POST(request, { params }) { return await handleChat(newRequest, buildClientRawRequest(request, rawBody)); } catch (error) { console.log("Error handling Gemini request:", error); - return Response.json({ error: { message: error.message, code: 500 } }, { status: 500 }); + return Response.json( + { error: { message: sanitizeErrorMessage(error), code: 500 } }, + { status: 500 } + ); } } diff --git a/src/app/api/v1beta/models/route.ts b/src/app/api/v1beta/models/route.ts index 5d5aec0a8f..8d6a50834b 100644 --- a/src/app/api/v1beta/models/route.ts +++ b/src/app/api/v1beta/models/route.ts @@ -1,4 +1,5 @@ import { PROVIDER_MODELS } from "@/shared/constants/models"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { getAllCustomModels, getAllSyncedAvailableModels, @@ -156,6 +157,6 @@ export async function GET() { return Response.json({ models }); } catch (error: any) { console.log("Error fetching models:", error); - return Response.json({ error: { message: error.message } }, { status: 500 }); + return Response.json({ error: { message: sanitizeErrorMessage(error) } }, { status: 500 }); } } diff --git a/src/app/api/webhooks/[id]/route.ts b/src/app/api/webhooks/[id]/route.ts index f15cc98d04..220bd00cbf 100644 --- a/src/app/api/webhooks/[id]/route.ts +++ b/src/app/api/webhooks/[id]/route.ts @@ -7,6 +7,7 @@ import { z } from "zod"; import { NextResponse } from "next/server"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { getWebhook, updateWebhookRecord, deleteWebhook } from "@/lib/localDb"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; @@ -33,7 +34,7 @@ export async function GET(_: Request, { params }: { params: Promise<{ id: string } return NextResponse.json({ webhook }); } catch (error: any) { - return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ error: sanitizeErrorMessage(error) }, { status: 500 }); } } @@ -55,7 +56,7 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: } return NextResponse.json({ webhook }); } catch (error: any) { - return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ error: sanitizeErrorMessage(error) }, { status: 500 }); } } @@ -71,6 +72,6 @@ export async function DELETE(_: Request, { params }: { params: Promise<{ id: str } return NextResponse.json({ success: true }); } catch (error: any) { - return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ error: sanitizeErrorMessage(error) }, { status: 500 }); } } diff --git a/src/app/api/webhooks/[id]/test/route.ts b/src/app/api/webhooks/[id]/test/route.ts index 11686cdb94..0d5065853e 100644 --- a/src/app/api/webhooks/[id]/test/route.ts +++ b/src/app/api/webhooks/[id]/test/route.ts @@ -4,6 +4,7 @@ */ import { NextResponse } from "next/server"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { getWebhook, recordWebhookDelivery } from "@/lib/localDb"; import { deliverWebhook } from "@/lib/webhookDispatcher"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; @@ -38,9 +39,9 @@ export async function POST(_: Request, { params }: { params: Promise<{ id: strin return NextResponse.json({ delivered: result.success, status: result.status, - error: result.error || null, + error: result.error ? sanitizeErrorMessage(result.error) : null, }); } catch (error: any) { - return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ error: sanitizeErrorMessage(error) }, { status: 500 }); } } diff --git a/src/app/api/webhooks/route.ts b/src/app/api/webhooks/route.ts index 1089ca0ebc..c69c4960b8 100644 --- a/src/app/api/webhooks/route.ts +++ b/src/app/api/webhooks/route.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { NextResponse } from "next/server"; +import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error"; import { getWebhooks, createWebhook } from "@/lib/localDb"; import { validateBody, isValidationFailure } from "@/shared/validation/helpers"; import { requireManagementAuth } from "@/lib/api/requireManagementAuth"; @@ -31,7 +32,7 @@ export async function GET(request: Request) { return NextResponse.json({ webhooks: masked }); } catch (error: any) { return NextResponse.json( - { error: error.message || "Failed to list webhooks" }, + { error: sanitizeErrorMessage(error) || "Failed to list webhooks" }, { status: 500 } ); } @@ -59,7 +60,7 @@ export async function POST(request: Request) { return NextResponse.json({ webhook }, { status: 201 }); } catch (error: any) { return NextResponse.json( - { error: error.message || "Failed to create webhook" }, + { error: sanitizeErrorMessage(error) || "Failed to create webhook" }, { status: 500 } ); } diff --git a/src/sse/handlers/chatHelpers.ts b/src/sse/handlers/chatHelpers.ts index 48ca9e4366..c508b6a85c 100644 --- a/src/sse/handlers/chatHelpers.ts +++ b/src/sse/handlers/chatHelpers.ts @@ -457,6 +457,24 @@ export function handleNoCredentials( credentials.retryAfterHuman ); } + + if (credentials?.allExpired) { + // Every connection for this provider is in a terminal state (expired, + // banned, or credits_exhausted). Surface as 401 with a re-auth hint + // instead of the generic 400 "No credentials", so dashboards/CLIs can + // distinguish "never configured" from "needs to reconnect". + const status = credentials.expiredStatus || "expired"; + const count = credentials.expiredCount || 1; + const reason = + status === "credits_exhausted" + ? "credits exhausted" + : status === "banned" + ? "banned by upstream" + : "authentication expired"; + const message = `[${provider}] All ${count} connection(s) ${reason} — please reconnect in the dashboard`; + log.warn("CHAT", message); + return errorResponse(HTTP_STATUS.UNAUTHORIZED, message); + } if (lastError && lastStatus) { log.warn("CHAT", "Preserving last upstream error after credential exhaustion", { provider, diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 358acd45ba..43ce3b62c5 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -885,6 +885,28 @@ export async function getProviderCredentials( ` → ${c.id?.slice(0, 8)} | isActive=${c.isActive} | rateLimitedUntil=${c.rateLimitedUntil || "none"} | testStatus=${c.testStatus}` ); }); + + // If every existing connection is in a terminal state (expired/banned/ + // credits_exhausted), surface that as a re-auth signal instead of the + // generic "No credentials" 400. The classic case is AWS SSO/Kiro + // refresh tokens hitting their 90-day TTL: all connections flip to + // is_active=0 with testStatus=banned|expired, and without this branch + // the dashboard sees a misleading "bad_request" code. + const terminalConnections = allConnections.filter(isTerminalConnectionStatus); + if (terminalConnections.length === allConnections.length) { + const statusCounts = new Map(); + for (const c of terminalConnections) { + const key = normalizeStatus(c.testStatus) || "expired"; + statusCounts.set(key, (statusCounts.get(key) || 0) + 1); + } + const dominantStatus = + [...statusCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] || "expired"; + return { + allExpired: true, + expiredCount: terminalConnections.length, + expiredStatus: dominantStatus, + }; + } } log.warn("AUTH", `No credentials for ${provider}`); return null; @@ -1390,7 +1412,7 @@ export async function getProviderCredentialsWithQuotaPreflight( return null; } - if (credentials.allRateLimited) { + if (credentials.allRateLimited || credentials.allExpired) { return credentials; } diff --git a/tests/unit/chat-helpers.test.ts b/tests/unit/chat-helpers.test.ts index 9ac870459e..1c1603bf61 100644 --- a/tests/unit/chat-helpers.test.ts +++ b/tests/unit/chat-helpers.test.ts @@ -257,6 +257,41 @@ test("handleNoCredentials returns structured model_cooldown when every credentia assert.match(json.error.message, /cooling down/i); }); +test("handleNoCredentials returns 401 with re-auth hint when every connection is in a terminal state", async () => { + // Classic scenario: AWS SSO refresh tokens hit their 90-day TTL, every Kiro + // connection flips to is_active=0 + testStatus=banned/expired. Surface as + // 401 with a reconnect hint instead of the misleading 400 "No credentials". + const response = handleNoCredentials( + { allExpired: true, expiredCount: 1, expiredStatus: "banned" }, + null, + "kiro", + "claude-sonnet-4.6", + null, + null + ); + const json = (await response.json()) as any; + + assert.equal(response.status, 401); + assert.match(json.error.message, /\[kiro\]/); + assert.match(json.error.message, /banned by upstream/); + assert.match(json.error.message, /please reconnect/i); +}); + +test("handleNoCredentials maps allExpired status='expired' to the 'authentication expired' reason", async () => { + const response = handleNoCredentials( + { allExpired: true, expiredCount: 3, expiredStatus: "expired" }, + null, + "cline", + "claude-sonnet-4.6", + null, + null + ); + const json = (await response.json()) as any; + + assert.equal(response.status, 401); + assert.match(json.error.message, /3 connection\(s\) authentication expired/); +}); + test("safeResolveProxy returns the direct route when no proxy config is present", async () => { const connection = await seedConnection("openai", { apiKey: "sk-openai-direct" });