diff --git a/.env.example b/.env.example index 3335784a6c0..a487fe02008 100644 --- a/.env.example +++ b/.env.example @@ -551,6 +551,16 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500 # heuristic in instrumentation-node.ts. Default: unset (tests skip background). #OMNIROUTE_ENABLE_RUNTIME_BACKGROUND_TASKS=1 +# Proactive connection-cooldown recovery (#8): re-validates connections whose +# transient `rate_limited_until` window has elapsed OUTSIDE the request hot path, +# so the first request after a cooldown does not pay the probe latency. Lazy +# recovery in getProviderCredentials still applies regardless. Used by: +# src/lib/quota/connectionRecovery.ts. +# Tick cadence (ms). Default 60000, floor 5000. +# OMNIROUTE_CONNECTION_RECOVERY_INTERVAL_MS=60000 +# Disable the proactive recovery scheduler entirely (default: false). +# OMNIROUTE_DISABLE_CONNECTION_RECOVERY=false + # Background job interval for budget reset checks (ms). Default: 600000 (10m). # Used by: src/lib/jobs/budgetResetJob.ts. Floor: 10000. #OMNIROUTE_BUDGET_RESET_JOB_INTERVAL_MS=600000 @@ -598,6 +608,11 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500 # Used by: scripts/postinstall.mjs. #OMNIROUTE_SKIP_POSTINSTALL=0 +# Operator-supplied JSON credentials for the offline compression-eval CLI +# (parsed with JSON.parse; leave unset for a dry run). Developer tooling only. +# Used by: scripts/compression-eval/index.ts. Default: {} (empty). +#OMNIROUTE_EVAL_CREDENTIALS={} + # Skip the DB healthcheck entirely on startup (useful for short-lived tasks / tests). # Used by: src/lib/db/core.ts, src/lib/db/healthCheck.ts. Set to 1 to disable. Default: 0. #OMNIROUTE_SKIP_DB_HEALTHCHECK=0 @@ -792,7 +807,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98 # Used by: open-sse/executors/base.ts — buildHeaders() dynamic lookup. # Update these when providers release new CLI versions to avoid blocks. -CLAUDE_USER_AGENT="claude-cli/2.1.158 (external, cli)" +CLAUDE_USER_AGENT="claude-cli/2.1.187 (external, cli)" # Disable the deterministic tool-name cloak applied on both Anthropic-bound paths # (executors/base.ts native OAuth + executors/cliproxyapi.ts CLIProxyAPI) — @@ -801,7 +816,7 @@ CLAUDE_USER_AGENT="claude-cli/2.1.158 (external, cli)" # stream with a misleading 400 out-of-extra-usage placeholder. Set to true to # forward the original names verbatim (debugging only). # CLAUDE_DISABLE_TOOL_NAME_CLOAK=false -CODEX_USER_AGENT="codex-cli/0.132.0 (Windows 10.0.26200; x64)" +CODEX_USER_AGENT="codex-cli/0.142.0 (Windows 10.0.26200; x64)" GITHUB_USER_AGENT="GitHubCopilotChat/0.45.1" ANTIGRAVITY_USER_AGENT="antigravity/2.0.1 linux/arm64 google-api-nodejs-client/10.3.0" KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0" @@ -823,7 +838,13 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0" # Override Codex client version sent in headers independently of the # CODEX_USER_AGENT string. Used by: open-sse/config/codexClient.ts. -# CODEX_CLIENT_VERSION=0.132.0 +# CODEX_CLIENT_VERSION=0.142.0 + +# Kill-switch to strip non-standard `codex.*` SSE events (e.g. codex.rate_limits) +# from the Codex Responses stream. These frames break the OpenAI SDK's +# responses.stream() with a 502 "Controller is already closed". Off by default; +# set to true/1/yes to enable. Used by: open-sse/executors/codex.ts. +# OMNIROUTE_CODEX_DROP_NONSTANDARD_EVENTS=true # ═══════════════════════════════════════════════════════════════════════════════ # 13. CLI FINGERPRINT COMPATIBILITY (Anti-Detection) @@ -950,6 +971,15 @@ GEMINI_CLI_USER_AGENT="google-api-nodejs-client/10.3.0" # OMNIROUTE_CIRCUIT_BREAKER_LOCAL_THRESHOLD=2 # OMNIROUTE_CIRCUIT_BREAKER_LOCAL_RESET_MS=15000 +# ── Context-cache pin health gate ── +# Used by: open-sse/services/combo.ts. When a context-cache pin points at a +# provider that is durably unhealthy, the pin is dropped to allow failover. +# PIN_DROP_BACKOFF_LEVEL gates how deep a connection's backoff must be before the +# pin is considered durably unhealthy; PIN_DROP_GRACE_MS is the anti-flap window +# that tolerates brief transient cooldowns before dropping the pin. +# PIN_DROP_BACKOFF_LEVEL=2 +# PIN_DROP_GRACE_MS=20000 + # ── Stream idle detection ── # STREAM_IDLE_TIMEOUT_MS=600000 # Max silence between SSE chunks (default: 600000) # # Extended-thinking models rarely pause >90s. @@ -1200,6 +1230,34 @@ APP_LOG_TO_FILE=true # Used by: open-sse/executors/cloudflare-ai.ts # CLOUDFLARE_ACCOUNT_ID= +# ── Deno Deploy proxy relay (#4643 / 9router#1437) ── +# Override the Deno Deploy REST API base used by the proxy-pool relay deployer. +# Default: https://api.deno.com/v2 (omit unless mocking). +# Used by: src/app/api/settings/proxy/deno-deploy/route.ts +# DENO_DEPLOY_API_BASE=https://api.deno.com/v2 + +# Default Deno Deploy app name suggested in the "Deploy Relay" modal. +# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/DenoRelayModal.tsx +# NEXT_PUBLIC_DENO_RELAY_DEFAULT_PROJECT=omniroute-deno-relay + +# Set to "false" to hide the Deno Deploy relay option from the Proxy Pool tab. +# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx +# NEXT_PUBLIC_DENO_RELAY_ENABLED=true + +# ── Cloudflare Workers proxy relay (#4640 / 9router#1360) ── +# Override the Cloudflare REST API base used by the proxy-pool relay deployer. +# Default: https://api.cloudflare.com/client/v4 (omit unless mocking). +# Used by: src/app/api/settings/proxy/cloudflare-deploy/route.ts +# CLOUDFLARE_API_BASE=https://api.cloudflare.com/client/v4 + +# Default worker project name suggested in the "Deploy Relay" modal. +# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/CloudflareRelayModal.tsx +# NEXT_PUBLIC_CLOUDFLARE_RELAY_DEFAULT_PROJECT=omniroute-relay + +# Set to "false" to hide the Cloudflare Workers relay option from the Proxy Pool tab. +# Used by: src/app/(dashboard)/dashboard/settings/components/proxy/ProxyPoolTab.tsx +# NEXT_PUBLIC_CLOUDFLARE_RELAY_ENABLED=true + # ── Cloudflare Tunnel (cloudflared) ── # Custom path to cloudflared binary for tunnel management. # Used by: src/lib/cloudflaredTunnel.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0da08470d4c..5181ebc246e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -846,6 +846,7 @@ jobs: path: | coverage/coverage-summary.json coverage/coverage-report.md + coverage/lcov.info if-no-files-found: warn sonarqube: diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index b6cfcb2e8e8..1c3fdc5e36d 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -40,6 +40,8 @@ jobs: - run: npm run check:fetch-targets - run: npm run check:openapi-routes - run: npm run check:docs-symbols + - name: Docs accuracy (fabricated-docs + i18n mirrors, strict) + run: npm run check:docs-all - run: npm run check:deps - run: npm run check:file-size - run: npm run check:error-helper @@ -49,6 +51,7 @@ jobs: - run: npm run check:known-symbols - run: npm run check:route-guard-membership - run: npm run check:test-discovery + - run: npm run check:test-runner-api - run: npm run check:any-budget:t11 - name: Typecheck (core) run: npm run typecheck:core @@ -82,3 +85,48 @@ jobs: echo "Running impacted tests:"; echo "$SEL" mapfile -t FILES <<< "$SEL" node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${FILES[@]}" + + fast-vitest: + name: Vitest (fast-path) + runs-on: ubuntu-latest + env: + JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation + API_KEY_SECRET: ci-lint-api-key-secret-long + DISABLE_SQLITE_AUTO_BACKUP: "true" + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.CI_NODE_VERSION }} + cache: npm + - run: npm ci + - run: npm run test:vitest + + fast-unit: + name: Unit Tests fast-path (${{ matrix.shard }}/2) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shard: [1, 2] + env: + JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation + API_KEY_SECRET: ci-lint-api-key-secret-long + DISABLE_SQLITE_AUTO_BACKUP: "true" + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.CI_NODE_VERSION }} + cache: npm + - run: npm ci + - run: > + node --max-old-space-size=4096 --import tsx + --import ./tests/_setup/isolateDataDir.ts + --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2 + tests/unit/*.test.ts + "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts" diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b4175f3546..6e5fc041c7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ --- +## [3.8.36] — TBD + +_In development — bullets added per PR; finalized at release._ + +- **feat(providers):** update volcengine-ark model list, adding DeepSeek-V4-Flash and DeepSeek-V4-Pro. (thanks @kenlin8827) + +### 🔧 Bug Fixes + +- **fix(dashboard):** show custom provider given-name instead of internal id across dashboard pages — cache, combo health, compression analytics, cost overview, health/autopilot, provider stats, route explainability, provider utilization, runtime. Adds shared `resolveProviderName` resolver and `useProviderNodeMap` hook. (#4603) + +--- + ## [3.8.35] — 2026-06-23 ### ✨ New Features @@ -19,6 +31,7 @@ - **Dashboard**: remove the dead, unconditional `useLiveRequests()` call from `HomePageClient.tsx` — it crashed the `/home` page in production builds with `ReferenceError: useLiveRequests is not defined` (#4759, #4745) and opened the live-dashboard WebSocket even when Provider Topology was hidden (#4596). The live feed remains owned by the settings-gated `HomeProviderTopologySection` ([#4761](https://github.com/diegosouzapw/OmniRoute/pull/4761) — thanks @diegosouzapw). - **Providers dashboard**: dedupe provider nodes by id when adding a compatible provider (`upsertProviderNodeById`) so the same provider can no longer appear twice and no-op adds don't invalidate the compatible-provider memo ([#4768](https://github.com/diegosouzapw/OmniRoute/pull/4768) — closes #4746, thanks @diegosouzapw). - **Storage VACUUM**: the scheduled VACUUM job now follows the Storage page settings (`scheduledVacuum` / `vacuumHour`) as the single source of truth; the legacy env-flag control path was removed ([#4726](https://github.com/diegosouzapw/OmniRoute/pull/4726) — thanks @rdself). +- **Storage SQLite tuning**: `Cache Size` is now a positive KiB setting (for example, `16384`) that applies to SQLite as `PRAGMA cache_size = -16384`; Page Size and Cache Size changes are applied to the live database instead of being persisted only in the settings table. - **Tiers**: no-auth providers are now counted as free, and the free-tier filter returns an empty set instead of falling through to every provider ([#4753](https://github.com/diegosouzapw/OmniRoute/pull/4753) — thanks @megamen32 / @diegosouzapw). - **Combos**: auto-promote `zeroLatencyOptimizationsEnabled` so legacy configs (pre-3.8.33 `fallbackCompressionMode="lite"`) round-trip cleanly on the first GUI edit ([#4774](https://github.com/diegosouzapw/OmniRoute/pull/4774) — thanks @KooshaPari / @diegosouzapw). diff --git a/README.md b/README.md index 150f630fd5b..852666b8540 100644 --- a/README.md +++ b/README.md @@ -574,7 +574,7 @@ Dashboard at `http://localhost:20128` · API at `http://localhost:20128/v1`. **2) Connect a FREE provider (no signup)** -Dashboard → **Providers** → connect **Kiro AI** (free Claude unlimited) or **OpenCode Free** (no auth) → done. +Dashboard → **Providers** → connect **Kiro AI** (free Claude, ~50 credits/month per account) or **OpenCode Free** (no auth) → done. **3) Point your coding tool** @@ -743,7 +743,7 @@ podman compose --profile base up -d **$0 forever:** ``` -1. kr/claude-sonnet-4.5 (Kiro — unlimited) +1. kr/claude-sonnet-4.5 (Kiro — ~50 credits/mo per acct) 2. if/kimi-k2-thinking (Qoder — unlimited) 3. pol/gpt-5 (Pollinations — no key) 4. lc/longcat-flash-lite (50M tok/day backup) @@ -800,7 +800,7 @@ Compression: aggressive (~50%) → double your free quota · Cost: $0/mo | `DATA_DIR` | `~/.omniroute` | Database & config storage | **Will I be charged by OmniRoute?** No — it's free, open-source software on your machine. You only pay paid providers directly. OmniRoute has no billing system. -**Are FREE providers really unlimited?** Yes — Kiro, Qoder, Pollinations, LongCat, Cloudflare. No catch. +**Are FREE providers really unlimited?** Mostly — Qoder, Pollinations, LongCat, and Cloudflare are free with no per-account credit cap. Kiro is free too but capped at ~50 credits/month per account. Stack multiple free providers in a combo and auto-fallback keeps you serving for $0. **Will compression hurt quality?** No — it only compresses the **input**; code, URLs, JSON are always protected. **Does it work where AI is blocked?** Yes — 3-level proxy + 1proxy marketplace reach all 231 providers. diff --git a/bin/_ops-common.sh b/bin/_ops-common.sh new file mode 100644 index 00000000000..820efccc303 --- /dev/null +++ b/bin/_ops-common.sh @@ -0,0 +1,70 @@ +# bin/_ops-common.sh — shared helpers for the OmniRoute ops runbook scripts. +# +# Sourced (not executed) by rollback.sh / snapshot-data.sh / restore-data.sh / +# restore-policies.sh / cold-start-bench.sh. The runbook context lives in +# docs/INCIDENT_RESPONSE.md and docs/PERF_BUDGETS.md. +# +# Path resolution mirrors the app (src/lib/db/core.ts): the SQLite store is +# $DATA_DIR/storage.sqlite and managed backups go to $DATA_DIR/db_backups +# (overridable via DB_BACKUPS_DIR), so snapshots created here are interchangeable +# with the ones the server writes on migrations. + +# Recompute the data-dir-derived paths. Called once on source, and again by +# scripts that accept a --data-dir override. +ops_set_data_dir() { + OMNIROUTE_DATA_DIR="$1" + OMNIROUTE_SQLITE="${OMNIROUTE_DATA_DIR}/storage.sqlite" + OMNIROUTE_BACKUPS_DIR="${DB_BACKUPS_DIR:-${OMNIROUTE_DATA_DIR}/db_backups}" +} +ops_set_data_dir "${DATA_DIR:-$HOME/.omniroute}" + +ops_log() { printf '[%s] %s\n' "${SCRIPT_NAME:-ops}" "$*" >&2; } +ops_die() { + printf '[%s] ERROR: %s\n' "${SCRIPT_NAME:-ops}" "$*" >&2 + exit 1 +} + +ops_require_cmd() { + command -v "$1" >/dev/null 2>&1 || ops_die "required command not found: $1" +} + +# ops_confirm "" — return 0 to proceed. Honors ASSUME_YES=1 (set by the +# --yes flag) and REFUSES a destructive action on a non-interactive stdin unless +# ASSUME_YES is set, so an unattended/CI invocation can never silently destroy data. +ops_confirm() { + local prompt="$1" reply + if [ "${ASSUME_YES:-0}" = "1" ]; then return 0; fi + if [ ! -t 0 ]; then + ops_die "refusing a destructive action without a TTY; pass --yes to proceed non-interactively" + fi + read -r -p "$prompt [y/N] " reply + case "$reply" in + [yY] | [yY][eE][sS]) return 0 ;; + *) return 1 ;; + esac +} + +# ops_find_snapshot — resolve a snapshot identifier (a snapshot dir name, +# a bare timestamp/sha, or an explicit path) to a directory containing +# storage.sqlite. Echoes the resolved dir or dies. +ops_find_snapshot() { + local id="$1" cand + [ -n "$id" ] || ops_die "snapshot id required (a timestamp/sha, dir name, or path)" + for cand in \ + "$id" \ + "$id/" \ + "$OMNIROUTE_BACKUPS_DIR/$id" \ + "$OMNIROUTE_BACKUPS_DIR/snapshot_$id"; do + if [ -f "${cand%/}/storage.sqlite" ]; then + printf '%s\n' "${cand%/}" + return 0 + fi + done + # Fall back to a prefix match against snapshot_* dirs (e.g. a short sha/date). + if [ -d "$OMNIROUTE_BACKUPS_DIR" ]; then + for cand in "$OMNIROUTE_BACKUPS_DIR"/snapshot_*"$id"*; do + [ -f "$cand/storage.sqlite" ] && { printf '%s\n' "$cand"; return 0; } + done + fi + ops_die "no snapshot matching '$id' under $OMNIROUTE_BACKUPS_DIR (run bin/snapshot-data.sh first)" +} diff --git a/bin/cli/runtime/nativeDeps.mjs b/bin/cli/runtime/nativeDeps.mjs index f74ab1eea99..852cff76b7d 100644 --- a/bin/cli/runtime/nativeDeps.mjs +++ b/bin/cli/runtime/nativeDeps.mjs @@ -12,7 +12,7 @@ import { spawnSync } from "node:child_process"; import { platform } from "node:os"; import { resolveDataDir } from "../data-dir.mjs"; -const BETTER_SQLITE3_VERSION = "12.9.0"; +const BETTER_SQLITE3_VERSION = "12.10.1"; function runtimeDir() { return join(resolveDataDir(), "runtime"); diff --git a/bin/cli/runtime/sqliteRuntime.mjs b/bin/cli/runtime/sqliteRuntime.mjs index 19dd7907643..506481a353f 100644 --- a/bin/cli/runtime/sqliteRuntime.mjs +++ b/bin/cli/runtime/sqliteRuntime.mjs @@ -6,7 +6,7 @@ import { pathToFileURL } from "node:url"; import { validateBinaryMagic, platformBinaryLabel } from "./magicBytes.mjs"; const RUNTIME_DIR = join(homedir(), ".omniroute", "runtime"); -const BETTER_SQLITE3_VERSION = "better-sqlite3@^12.6.2"; +const BETTER_SQLITE3_VERSION = "better-sqlite3@^12.10.1"; let resolvedCached = null; diff --git a/bin/cli/runtime/trayRuntime.ts b/bin/cli/runtime/trayRuntime.ts index c1d76a96fde..cc734b6f806 100644 --- a/bin/cli/runtime/trayRuntime.ts +++ b/bin/cli/runtime/trayRuntime.ts @@ -1,4 +1,4 @@ -import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, writeFileSync, chmodSync } from "node:fs"; import { join } from "node:path"; import { homedir } from "node:os"; import { execSync } from "node:child_process"; @@ -6,7 +6,43 @@ import { execSync } from "node:child_process"; const RUNTIME_DIR = join(homedir(), ".omniroute", "runtime"); // systray2 is a maintained fork with prebuilt binaries — installed lazily at runtime, // not in dependencies, to avoid npm install overhead for users who don't use --tray. -const SYSTRAY_VERSION = "systray2@1.4.5"; +// +// Pin: stay on the 2.x line. The original `systray@1.0.5` package bundles a 2017 +// x86_64 Go binary whose Mach-O headers modern dyld (macOS 14+) rejects, so the +// tray silently fails to register on Apple Silicon. systray2 2.x ships newer +// getlantern/systray-portable binaries that work under Rosetta. Inherited from +// upstream decolua/9router#1080. +export const SYSTRAY_PACKAGE = "systray2"; +export const SYSTRAY_VERSION = "2.1.4"; +const SYSTRAY_SPEC = `${SYSTRAY_PACKAGE}@${SYSTRAY_VERSION}`; + +export function resolveSystrayBinName(platform: NodeJS.Platform): string | null { + if (platform === "win32") return null; + if (platform === "darwin") return "tray_darwin_release"; + return "tray_linux_release"; +} + +export interface ChmodResult { + changed: boolean; + reason?: "win32-skip" | "missing" | "chmod-failed"; +} + +// systray2's npm tarball sometimes ships the bundled Go binary without the +// executable bit set on macOS/Linux, causing spawn() to fail with EACCES. +// Set +x best-effort so the tray actually starts. Inherited from +// upstream decolua/9router#1080. +export function chmodSystrayBinAt(runtimeRoot: string, platform: NodeJS.Platform): ChmodResult { + const binName = resolveSystrayBinName(platform); + if (!binName) return { changed: false, reason: "win32-skip" }; + const binPath = join(runtimeRoot, "node_modules", SYSTRAY_PACKAGE, "traybin", binName); + if (!existsSync(binPath)) return { changed: false, reason: "missing" }; + try { + chmodSync(binPath, 0o755); + return { changed: true }; + } catch { + return { changed: false, reason: "chmod-failed" }; + } +} export async function loadSystray(): Promise<(new (...args: unknown[]) => unknown) | null> { if (process.platform === "win32") return null; // Windows uses tray.ps1 instead @@ -15,15 +51,21 @@ export async function loadSystray(): Promise<(new (...args: unknown[]) => unknow try { installSystray(); } catch (err) { + // Surface failures to stderr instead of staying silent — anyone hitting + // a tray problem otherwise has zero diagnostic. (PR #1080) console.warn(`[omniroute] tray runtime install failed: ${(err as Error).message}`); return null; } } + // Best-effort: ensure the bundled Go binary is executable. Some npm tarballs + // drop the +x bit on extraction (observed on macOS). + chmodSystrayBinAt(RUNTIME_DIR, process.platform); try { - const modPath = join(RUNTIME_DIR, "node_modules", "systray2"); + const modPath = join(RUNTIME_DIR, "node_modules", SYSTRAY_PACKAGE); const mod = await import(modPath); return (mod.default ?? mod.SysTray ?? mod) as (new (...args: unknown[]) => unknown) | null; - } catch { + } catch (err) { + console.warn(`[omniroute] tray runtime import failed: ${(err as Error).message}`); return null; } } @@ -37,12 +79,12 @@ function ensureRuntimeDir(): void { } function isInstalled(): boolean { - return existsSync(join(RUNTIME_DIR, "node_modules", "systray2", "package.json")); + return existsSync(join(RUNTIME_DIR, "node_modules", SYSTRAY_PACKAGE, "package.json")); } function installSystray(): void { execSync( - `npm install --prefix "${RUNTIME_DIR}" ${SYSTRAY_VERSION} --no-audit --no-fund --silent`, + `npm install --prefix "${RUNTIME_DIR}" ${SYSTRAY_SPEC} --no-audit --no-fund --silent`, { stdio: ["ignore", "ignore", "pipe"], timeout: 120_000 } ); } diff --git a/bin/cli/tray/tray.ts b/bin/cli/tray/tray.ts index f9e3c642e19..502467bc922 100644 --- a/bin/cli/tray/tray.ts +++ b/bin/cli/tray/tray.ts @@ -124,6 +124,10 @@ async function initUnixTray(options: TrayOptions): Promise const systray = new SysTray({ menu: { icon: getIconBase64(), + // isTemplateIcon: false on darwin — the bundled icon.png is a full-color + // RGBA logo; template mode would render it as a solid white square + // because macOS template icons only use the alpha channel. (PR #1080) + isTemplateIcon: false, title: "OmniRoute", tooltip: `OmniRoute :${options.port}`, items: menuItems.map((it) => ({ @@ -175,6 +179,8 @@ async function initUnixTray(options: TrayOptions): Promise setTooltip: () => { /* systray2 does not support runtime tooltip change */ }, - destroy: () => systray.kill(), + // Pass false so systray2's kill does NOT call process.exit(0) before the + // rest of cleanup (server SIGKILL, MITM/tunnel cleanup) runs. (PR #1080) + destroy: () => systray.kill(false), }; } diff --git a/bin/cli/tray/traySystray.mjs b/bin/cli/tray/traySystray.mjs index bf0b7caee06..4c5bcb3c5e8 100644 --- a/bin/cli/tray/traySystray.mjs +++ b/bin/cli/tray/traySystray.mjs @@ -100,8 +100,32 @@ export function initSystrayUnix({ port, onQuit, onOpenDashboard, onShowLogs }) { return tray; } +/** + * Resolve the Go systray2 child subprocess PID from a tray instance. + * systray2 exposes the spawned binary either as the `_process` field or via a + * `process()` accessor depending on version. Returns the numeric PID or null. + */ +export function getSystrayChildPid(tray) { + if (!tray) return null; + try { + const proc = tray._process || (typeof tray.process === "function" ? tray.process() : null); + if (proc && typeof proc.pid === "number") return proc.pid; + } catch {} + return null; +} + export function killSystrayUnix(tray) { try { + // systray2.kill(false) closes the IPC channel but leaves the Go tray binary + // subprocess running, which keeps an orphan NSStatusItem on macOS and blocks + // a freshly spawned tray (e.g. on respawn / hide-to-tray) from registering. + // SIGKILL the child PID directly first, then close IPC. + const pid = getSystrayChildPid(tray); + if (pid) { + try { + process.kill(pid, "SIGKILL"); + } catch {} + } tray.kill(false); } catch {} } diff --git a/bin/cold-start-bench.sh b/bin/cold-start-bench.sh new file mode 100755 index 00000000000..f879610712a --- /dev/null +++ b/bin/cold-start-bench.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# bin/cold-start-bench.sh — measure OmniRoute cold-start against the budgets in +# docs/PERF_BUDGETS.md §5 (container start → HTTP listening ≤ 800 ms; first warm +# TTFB ≤ 200 ms). Boots the server on a throwaway port, times until +# /api/health/ping answers 200, measures a warm request, and reports PASS/FAIL. +set -euo pipefail +SCRIPT_NAME="cold-start-bench" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_ops-common.sh" + +usage() { + cat <<'EOF' +Usage: bin/cold-start-bench.sh [--port ] [--start-cmd ""] [--url ] + [--listen-budget-ms ] [--ttfb-budget-ms ] [-h|--help] + +Boots OmniRoute, times cold-start to the first /api/health/ping 200, measures +warm TTFB, and compares against the PERF_BUDGETS.md §5 budgets +(listen ≤ 800 ms, TTFB ≤ 200 ms). Exits non-zero if a budget is exceeded. + +--url benches an already-running server instead of booting one (skips the boot +timing; only TTFB is measured). +EOF +} + +PORT="${PORT:-21987}" +START_CMD="" +BASE_URL="" +LISTEN_BUDGET_MS=800 +TTFB_BUDGET_MS=200 + +while [ $# -gt 0 ]; do + case "$1" in + --port) PORT="${2:?--port needs a value}"; shift 2 ;; + --start-cmd) START_CMD="${2:?--start-cmd needs a value}"; shift 2 ;; + --url) BASE_URL="${2:?--url needs a value}"; shift 2 ;; + --listen-budget-ms) LISTEN_BUDGET_MS="${2:?}"; shift 2 ;; + --ttfb-budget-ms) TTFB_BUDGET_MS="${2:?}"; shift 2 ;; + -h | --help) usage; exit 0 ;; + *) ops_die "unknown argument: $1 (see --help)" ;; + esac +done + +ops_require_cmd curl + +now_ms() { date +%s%3N; } # ms since epoch (GNU date / Linux) +ping_ok() { curl -fsS -o /dev/null --max-time 2 "$1/api/health/ping" 2>/dev/null; } + +SERVER_PID="" +cleanup() { [ -n "$SERVER_PID" ] && kill "$SERVER_PID" 2>/dev/null || true; } +trap cleanup EXIT + +if [ -n "$BASE_URL" ]; then + ops_log "benching already-running server at $BASE_URL (boot timing skipped)" + listen_ms="" +else + BASE_URL="http://127.0.0.1:$PORT" + [ -n "$START_CMD" ] || START_CMD="npm start -- --port $PORT" + ops_log "booting: $START_CMD" + start_ms="$(now_ms)" + # shellcheck disable=SC2086 + PORT="$PORT" $START_CMD >/tmp/omniroute-coldstart.log 2>&1 & + SERVER_PID="$!" + deadline=$(($(now_ms) + 30000)) + until ping_ok "$BASE_URL"; do + kill -0 "$SERVER_PID" 2>/dev/null || ops_die "server process exited during boot (see /tmp/omniroute-coldstart.log)" + [ "$(now_ms)" -gt "$deadline" ] && ops_die "server did not answer /api/health/ping within 30s" + sleep 0.05 + done + listen_ms=$(($(now_ms) - start_ms)) +fi + +ping_ok "$BASE_URL" || ops_die "server at $BASE_URL is not answering /api/health/ping" +ttfb_ms="$(curl -fsS -o /dev/null -w '%{time_starttransfer}' "$BASE_URL/api/health/ping" | awk '{printf "%d", $1 * 1000}')" + +fail=0 +if [ -n "$listen_ms" ]; then + echo "cold-start (start → listening): ${listen_ms} ms (budget ${LISTEN_BUDGET_MS} ms)" + [ "$listen_ms" -le "$LISTEN_BUDGET_MS" ] || { echo "FAIL: listen budget exceeded"; fail=1; } +fi +echo "warm TTFB: ${ttfb_ms} ms (budget ${TTFB_BUDGET_MS} ms)" +[ "$ttfb_ms" -le "$TTFB_BUDGET_MS" ] || { echo "FAIL: TTFB budget exceeded"; fail=1; } +[ "$fail" -eq 0 ] && echo "PASS: within cold-start budgets" +exit "$fail" diff --git a/bin/restore-data.sh b/bin/restore-data.sh new file mode 100755 index 00000000000..df907fb7325 --- /dev/null +++ b/bin/restore-data.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# bin/restore-data.sh — restore the OmniRoute SQLite data volume from a snapshot +# created by bin/snapshot-data.sh. Used by the data-layer incident runbook +# (docs/INCIDENT_RESPONSE.md §4.4) after stopping writers. +# +# Safety: takes a pre-restore snapshot of the CURRENT data, refuses to run +# unattended without --yes, and verifies the snapshot before overwriting. +set -euo pipefail +SCRIPT_NAME="restore-data" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_ops-common.sh" + +usage() { + cat <<'EOF' +Usage: bin/restore-data.sh [--data-dir ] [--yes|-y] [-h|--help] + +Restores storage.sqlite (and any sibling *.sqlite) from a snapshot. The current +data is first copied to $DB_BACKUPS_DIR/pre-restore_ as a safety net. + is a timestamp/sha, a snapshot dir name, or a path (see snapshot-data.sh). +Stop OmniRoute before running, and restart it afterwards. +EOF +} + +ID="" +while [ $# -gt 0 ]; do + case "$1" in + --yes | -y) ASSUME_YES=1; shift ;; + --data-dir) ops_set_data_dir "${2:?--data-dir needs a value}"; shift 2 ;; + -h | --help) usage; exit 0 ;; + -*) ops_die "unknown argument: $1 (see --help)" ;; + *) ID="$1"; shift ;; + esac +done + +[ -n "$ID" ] || ops_die "snapshot id required (see --help)" +snap="$(ops_find_snapshot "$ID")" +ops_log "restore source: $snap → $OMNIROUTE_DATA_DIR" +ops_confirm "Overwrite storage.sqlite at $OMNIROUTE_DATA_DIR from $snap?" || ops_die "aborted" + +# Pre-restore safety copy of the live data. +if [ -f "$OMNIROUTE_SQLITE" ]; then + safety="$OMNIROUTE_BACKUPS_DIR/pre-restore_$(date -u +%Y%m%dT%H%M%SZ)" + mkdir -p "$safety" + if command -v sqlite3 >/dev/null 2>&1; then + sqlite3 "$OMNIROUTE_SQLITE" "VACUUM INTO '$safety/storage.sqlite'" \ + || cp -a "$OMNIROUTE_SQLITE" "$safety/storage.sqlite" + else + cp -a "$OMNIROUTE_SQLITE" "$safety/storage.sqlite" + fi + ops_log "current data saved to $safety" +fi + +mkdir -p "$OMNIROUTE_DATA_DIR" +# Drop stale WAL/SHM so the restored DB is authoritative, then copy in. +rm -f "$OMNIROUTE_SQLITE" "${OMNIROUTE_SQLITE}-wal" "${OMNIROUTE_SQLITE}-shm" +cp -a "$snap/storage.sqlite" "$OMNIROUTE_SQLITE" + +# Restore sibling DBs captured in the snapshot. +for f in "$snap"/*.sqlite; do + [ -e "$f" ] || continue + [ "$(basename "$f")" = "storage.sqlite" ] && continue + cp -a "$f" "$OMNIROUTE_DATA_DIR/" +done + +ops_log "restore complete — restart OmniRoute to pick up the restored data" diff --git a/bin/restore-policies.sh b/bin/restore-policies.sh new file mode 100755 index 00000000000..9cf4da03ef6 --- /dev/null +++ b/bin/restore-policies.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# bin/restore-policies.sh — restore ONLY the API-key policy tables from a +# snapshot, leaving request/audit/runtime state intact. Used by the auth-layer +# incident runbook (docs/INCIDENT_RESPONSE.md §4.3) when policies_active is empty +# but the rest of the database is healthy (so a full restore-data is overkill). +# +# "Policy" tables = api_key* definition tables (the key + its limits/allowed +# quotas/context sources), EXCLUDING usage counters and reset logs so a restore +# never rewinds live usage accounting. +set -euo pipefail +SCRIPT_NAME="restore-policies" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_ops-common.sh" + +usage() { + cat <<'EOF' +Usage: bin/restore-policies.sh [--data-dir ] [--yes|-y] [-h|--help] + +Replaces the API-key policy tables (api_key*, excluding *counter* / *_log*) in +the live DB with the copies from a snapshot, inside one transaction. The live DB +is snapshotted to $DB_BACKUPS_DIR/pre-policy-restore_ first. Requires sqlite3. +EOF +} + +ID="" +while [ $# -gt 0 ]; do + case "$1" in + --yes | -y) ASSUME_YES=1; shift ;; + --data-dir) ops_set_data_dir "${2:?--data-dir needs a value}"; shift 2 ;; + -h | --help) usage; exit 0 ;; + -*) ops_die "unknown argument: $1 (see --help)" ;; + *) ID="$1"; shift ;; + esac +done + +[ -n "$ID" ] || ops_die "snapshot id required (see --help)" +ops_require_cmd sqlite3 +snap="$(ops_find_snapshot "$ID")" +[ -f "$OMNIROUTE_SQLITE" ] || ops_die "no live DB at $OMNIROUTE_SQLITE (use restore-data.sh for a full restore)" + +# Policy definition tables present in BOTH the snapshot and the live DB. GLOB +# keeps `_` literal; we drop usage counters / logs so accounting isn't rewound. +readarray -t tables < <( + sqlite3 "$snap/storage.sqlite" \ + "SELECT name FROM sqlite_master WHERE type='table' AND name GLOB 'api_key*' \ + AND name NOT GLOB '*counter*' AND name NOT GLOB '*_log*' ORDER BY name;" +) +[ "${#tables[@]}" -gt 0 ] || ops_die "snapshot has no api_key* policy tables" + +ops_log "policy tables to restore: ${tables[*]}" +ops_confirm "Replace ${#tables[@]} policy table(s) in $OMNIROUTE_SQLITE from $snap?" || ops_die "aborted" + +# Safety snapshot of the live DB before mutating it. +safety="$OMNIROUTE_BACKUPS_DIR/pre-policy-restore_$(date -u +%Y%m%dT%H%M%SZ)" +mkdir -p "$safety" +sqlite3 "$OMNIROUTE_SQLITE" "VACUUM INTO '$safety/storage.sqlite'" +ops_log "live DB saved to $safety" + +# Replace each policy table inside a single transaction, attaching the snapshot +# (only tables that also exist in the live DB are touched). +sql="ATTACH DATABASE '$snap/storage.sqlite' AS snap; +PRAGMA foreign_keys=OFF; +BEGIN;" +for t in "${tables[@]}"; do + if [ -n "$(sqlite3 "$OMNIROUTE_SQLITE" "SELECT 1 FROM sqlite_master WHERE type='table' AND name='$t' LIMIT 1;")" ]; then + sql="$sql +DELETE FROM main.\"$t\"; +INSERT INTO main.\"$t\" SELECT * FROM snap.\"$t\";" + else + ops_log "skipping '$t' — not present in live DB" + fi +done +sql="$sql +COMMIT; +DETACH DATABASE snap;" + +printf '%s\n' "$sql" | sqlite3 "$OMNIROUTE_SQLITE" +ops_log "policies restored from $snap — restart OmniRoute to apply" diff --git a/bin/rollback.sh b/bin/rollback.sh new file mode 100755 index 00000000000..41d7bfcd436 --- /dev/null +++ b/bin/rollback.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# bin/rollback.sh — roll OmniRoute back to a previous release to mitigate a bad +# deploy. Used by the incident runbook (docs/INCIDENT_RESPONSE.md §3 / §4). +# +# Methods (auto-detected; override with --method): +# • npm — `npm install -g omniroute@` and, if PM2 manages it, +# `pm2 restart omniroute`. This is how the VPS deploy runs. +# • docker — re-tag the local image omniroute: to omniroute:prod and +# recreate the prod service from docker-compose.prod.yml. (That +# compose builds the `prod` tag locally rather than pulling a +# registry tag, so the versioned image must already exist locally.) +# With no , targets the highest published release strictly below the +# current package.json version. +set -euo pipefail +SCRIPT_NAME="rollback" +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/_ops-common.sh" +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +usage() { + cat <<'EOF' +Usage: bin/rollback.sh [] [--method npm|docker] [--yes|-y] [-h|--help] + +Rolls OmniRoute back to (e.g. 3.8.35 or v3.8.35). With no version, +picks the highest published release below the current package.json version. +Auto-detects npm vs docker deployment; override with --method. +EOF +} + +VERSION="" +METHOD="" +while [ $# -gt 0 ]; do + case "$1" in + --yes | -y) ASSUME_YES=1; shift ;; + --method) METHOD="${2:?--method needs npm|docker}"; shift 2 ;; + -h | --help) usage; exit 0 ;; + -*) ops_die "unknown argument: $1 (see --help)" ;; + *) VERSION="${1#v}"; shift ;; + esac +done + +if [ -z "$METHOD" ]; then + if command -v docker >/dev/null 2>&1 && [ -f "$REPO_ROOT/docker-compose.prod.yml" ] \ + && docker compose -f "$REPO_ROOT/docker-compose.prod.yml" ps -q 2>/dev/null | grep -q .; then + METHOD="docker" + elif command -v npm >/dev/null 2>&1; then + METHOD="npm" + else + ops_die "no deploy method detected (no running prod compose, no npm) — pass --method npm|docker" + fi +fi + +# Resolve the previous published version when none was given. +if [ -z "$VERSION" ]; then + ops_require_cmd npm + ops_require_cmd node + current="$(node -p "require('$REPO_ROOT/package.json').version" 2>/dev/null || true)" + [ -n "$current" ] || ops_die "cannot read current version from package.json — pass " + VERSION="$(npm view omniroute versions --json 2>/dev/null | node -e ' + let s = ""; + process.stdin.on("data", (d) => (s += d)).on("end", () => { + let vs; + try { vs = JSON.parse(s); } catch { vs = []; } + if (!Array.isArray(vs)) vs = [vs]; + const ok = (v) => /^[0-9]+\.[0-9]+\.[0-9]+$/.test(v); + const cmp = (a, b) => { + const x = a.split(".").map(Number), y = b.split(".").map(Number); + return x[0] - y[0] || x[1] - y[1] || x[2] - y[2]; + }; + const cur = process.argv[1]; + const prev = vs.filter(ok).filter((v) => cmp(v, cur) < 0).sort(cmp).pop() || ""; + process.stdout.write(prev); + }); + ' "$current")" + [ -n "$VERSION" ] || ops_die "could not resolve the previous published version — pass explicitly" +fi + +ops_log "target: omniroute@$VERSION via $METHOD" +ops_confirm "Roll OmniRoute back to $VERSION via $METHOD?" || ops_die "aborted" + +case "$METHOD" in + npm) + ops_require_cmd npm + npm install -g "omniroute@$VERSION" + if command -v pm2 >/dev/null 2>&1 && pm2 jlist 2>/dev/null | grep -q '"name":"omniroute"'; then + pm2 restart omniroute --update-env + ops_log "pm2 restarted omniroute" + else + ops_log "installed omniroute@$VERSION — restart the service to apply (no PM2 'omniroute' process found)" + fi + ;; + docker) + ops_require_cmd docker + if ! docker image inspect "omniroute:$VERSION" >/dev/null 2>&1; then + ops_die "local image omniroute:$VERSION not found — build it from the $VERSION checkout first (this compose builds the 'prod' tag, it does not pull a registry tag)" + fi + docker tag "omniroute:$VERSION" omniroute:prod + docker compose -f "$REPO_ROOT/docker-compose.prod.yml" up -d --no-build + ops_log "recreated prod service from omniroute:$VERSION" + ;; + *) ops_die "unknown method: $METHOD (use npm or docker)" ;; +esac +ops_log "rollback to $VERSION complete" diff --git a/bin/snapshot-data.sh b/bin/snapshot-data.sh new file mode 100755 index 00000000000..f312a46b9aa --- /dev/null +++ b/bin/snapshot-data.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# bin/snapshot-data.sh — consistent point-in-time snapshot of the OmniRoute data +# volume (the SQLite store under $DATA_DIR). Used by the data-layer incident +# runbook (docs/INCIDENT_RESPONSE.md §4.4) before any restore. +# +# Output: a directory $DB_BACKUPS_DIR/snapshot_[_