From 64d1bea4ebe3fd5c94d6c6b678f58f16db325b5a Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 1 Jul 2026 05:23:32 -0300 Subject: [PATCH 1/3] fix(mitm): revert privileged hosts entries on exit when possible installCleanupHandlers() only killed the spawned MITM child on SIGINT/SIGTERM and always left /etc/hosts spoofed for a manual Repair (Gap 7), even though a sudo password is often already cached for the session (getCachedPassword()). The extracted handleExitCleanup() now best-effort reverts every managed host via the same removeDNSEntry()/removeDNSEntries(collectManagedHosts()) pair stopMitm() already uses when a password is cached, and falls back to flagging _orphanedStateDetected exactly as before when none is available. Co-authored-by: manhdzzz Inspired-by: https://github.com/decolua/9router/pull/2216 --- CHANGELOG.md | 2 + src/mitm/manager.ts | 84 +++++++++-- tests/unit/mitm-hosts-cleanup-on-exit.test.ts | 134 ++++++++++++++++++ 3 files changed, 208 insertions(+), 12 deletions(-) create mode 100644 tests/unit/mitm-hosts-cleanup-on-exit.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9433791c6b0d..3a3fb25fd03e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ ### πŸ”§ Bug Fixes +- **fix(mitm):** best-effort revert privileged /etc/hosts entries on exit when a sudo password is cached, instead of always leaving orphaned state. (thanks @manhdzzz) + - **settings (model aliases β€” self-heal after restart):** the Settings β†’ Routing page showed "No exact-match aliases configured" after a server restart even though the aliases were persisted in the DB. Aliases are held in a module-local `_customAliases` map in `modelDeprecation.ts` that the boot path hydrates, but Next.js compiles the app-route module graph separately from the startup graph (the same webpack chunk-splitting class as #5312), so the `GET /api/settings/model-aliases` handler read a different, un-hydrated copy. The handler now self-heals: when its in-memory alias map is empty it reads `settings.modelAliases` from the DB (via the existing `getSettings()` db module β€” no raw SQL in the route) and repopulates the map, so the UI reflects the persisted aliases on the first GET after a restart. Regression guard: `tests/unit/model-aliases-settings-route-selfheal.test.ts`. ([#5777](https://github.com/diegosouzapw/OmniRoute/pull/5777) β€” thanks [@jleonar2](https://github.com/jleonar2)) - **providers (grok-cli token auto-refresh):** grok-cli OAuth tokens were never proactively refreshed before their real expiry. `mapTokens` hardcoded `expiresIn: 21600` (6 h) regardless of the token's actual lifetime, so the persisted `expiresAt` was always "now + 6 h" and the proactive `tokenHealthCheck` sweep (refresh when `expiresAt - now < 5 min`) fired 6 h after import instead of shortly before the token really expired. `mapTokens` now computes `expiresIn` from the authoritative `expires_at` field in `~/.grok/auth.json` (ISO β†’ epoch-seconds) with a fallback to the JWT `exp` claim (payload-only decode, no signature trust); the hardcoded `21600` is kept only when neither is present. Regression guards: 3 new cases in `tests/unit/grok-cli-oauth.test.ts` (JWT `exp`, JSON `expires_at`, and the fallback). ([#5775](https://github.com/diegosouzapw/OmniRoute/pull/5775) β€” thanks [@Chewji9875](https://github.com/Chewji9875)) diff --git a/src/mitm/manager.ts b/src/mitm/manager.ts index fde526a2f77b..ea8cbe48766c 100644 --- a/src/mitm/manager.ts +++ b/src/mitm/manager.ts @@ -309,28 +309,88 @@ export async function repairMitm(sudoPassword: string): Promise<{ repaired: stri /** * Best-effort JS surrogate for ProxyBridge's library destructor + crash signal - * handler. On SIGINT/SIGTERM we terminate the spawned child and warn that - * privileged cleanup (DNS/CA/proxy) still requires a Repair β€” we have no sudo - * password in a signal handler. Idempotent; never blocks process exit. (Gap 7.) + * handler. On SIGINT/SIGTERM we terminate the spawned child and, when a sudo + * password is already cached for this session (getCachedPassword()), also + * best-effort revert the privileged /etc/hosts entries β€” so a clean Ctrl+C / + * tray-quit does not always leave orphaned state for a manual Repair. Without + * a cached password we cannot prompt for one inside a signal handler, so we + * fall back to flagging `_orphanedStateDetected`, exactly as before. + * Idempotent; never blocks process exit. (Gap 7.) */ export function installCleanupHandlers(): void { if (_cleanupHandlersInstalled) return; _cleanupHandlersInstalled = true; const onSignal = (signal: string) => { - try { - if (serverProcess && !serverProcess.killed) serverProcess.kill("SIGTERM"); - } catch { - // ignore - } - log.warn( - { signal }, - "MITM parent received signal β€” child terminated; run Repair if DNS/CA/proxy were applied." - ); + void handleExitCleanup(signal); }; process.once("SIGINT", () => onSignal("SIGINT")); process.once("SIGTERM", () => onSignal("SIGTERM")); } +/** + * Exit-cleanup body extracted from installCleanupHandlers() so it is directly + * unit-testable (no real OS signal needs to be delivered to the test + * process). Terminates the spawned MITM child, then: + * - if a sudo password is cached in this session, best-effort reverts every + * managed /etc/hosts entry via the same `removeDNSEntry` + + * `removeDNSEntries(collectManagedHosts())` pair stopMitm() uses, so the + * hosts file does not stay spoofed after a clean signal-driven exit; + * - otherwise flags `_orphanedStateDetected` (surfaced by getMitmStatus() + * for the dashboard's one-click Repair) β€” we have no way to prompt for a + * password inside a signal handler. (Gap 7.) + * @param _depsOverride - optional dependency override, used in tests for DI. + */ +export async function handleExitCleanup( + signal: string, + _depsOverride?: { + getCachedPassword?: () => string | null; + removeDNSEntry?: (sudoPassword: string) => Promise; + removeDNSEntries?: (hosts: string[], sudoPassword: string) => Promise; + collectManagedHosts?: () => string[]; + } +): Promise { + const deps = { + getCachedPassword: _depsOverride?.getCachedPassword ?? getCachedPassword, + removeDNSEntry: _depsOverride?.removeDNSEntry ?? removeDNSEntry, + removeDNSEntries: _depsOverride?.removeDNSEntries ?? removeDNSEntries, + collectManagedHosts: _depsOverride?.collectManagedHosts ?? collectManagedHosts, + }; + + try { + if (serverProcess && !serverProcess.killed) serverProcess.kill("SIGTERM"); + } catch { + // ignore + } + + const sudoPassword = deps.getCachedPassword(); + if (!sudoPassword) { + _orphanedStateDetected = true; + log.warn( + { signal }, + "MITM parent received signal β€” child terminated; no cached sudo password, run Repair if DNS/CA/proxy were applied." + ); + return; + } + + try { + await deps.removeDNSEntry(sudoPassword); + const managed = deps.collectManagedHosts(); + if (managed.length > 0) { + await deps.removeDNSEntries(managed, sudoPassword); + } + log.info( + { signal }, + "MITM parent received signal β€” child terminated and privileged /etc/hosts entries reverted." + ); + } catch (err) { + _orphanedStateDetected = true; + log.error( + { err, signal }, + "MITM parent received signal β€” hosts cleanup failed; run Repair if DNS/CA/proxy were applied." + ); + } +} + /** * Get MITM status */ diff --git a/tests/unit/mitm-hosts-cleanup-on-exit.test.ts b/tests/unit/mitm-hosts-cleanup-on-exit.test.ts new file mode 100644 index 000000000000..f39ac8857486 --- /dev/null +++ b/tests/unit/mitm-hosts-cleanup-on-exit.test.ts @@ -0,0 +1,134 @@ +/** + * Gap 7 follow-up: installCleanupHandlers() previously only killed the spawned + * MITM child on SIGINT/SIGTERM and always left `/etc/hosts` state for a manual + * Repair, even when a sudo password was already cached in this session + * (getCachedPassword()/setCachedPassword()). handleExitCleanup() β€” extracted + * from the signal handler so it is directly unit-testable without sending a + * real OS signal to the test process β€” now best-effort reverts every managed + * host (collectManagedHosts(), mirroring stopMitm()'s DNS teardown) when a + * password is cached, and falls back to flagging `_orphanedStateDetected` + * (surfaced via getMitmStatus().orphanedStateDetected) exactly as before when + * no password is available. + * + * DATA_DIR-tmp + resetDbInstance pattern prevents the Node test runner from + * hanging on open SQLite handles (CLAUDE.md PII learning #3). + */ +import test from "node:test"; +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-mitm-exit-cleanup-")); +process.env.DATA_DIR = TEST_DATA_DIR; + +const core = await import("../../src/lib/db/core.ts"); +const manager = await import("../../src/mitm/manager.ts"); + +async function resetStorage() { + core.resetDbInstance(); + for (let attempt = 0; attempt < 10; attempt++) { + try { + if (fs.existsSync(TEST_DATA_DIR)) { + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); + } + break; + } catch (error: unknown) { + const code = (error as { code?: string } | null)?.code; + if ((code === "EBUSY" || code === "EPERM") && attempt < 9) { + await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1))); + } else { + throw error; + } + } + } + fs.mkdirSync(TEST_DATA_DIR, { recursive: true }); +} + +test.beforeEach(async () => { + await resetStorage(); + manager.clearCachedPassword(); +}); + +test.after(async () => { + core.resetDbInstance(); + fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); +}); + +test("handleExitCleanup: with a cached sudo password, best-effort reverts managed /etc/hosts entries", async () => { + manager.setCachedPassword("s3cr3t-session-password"); + + const removeDNSEntryCalls: string[] = []; + const removeDNSEntriesCalls: Array<{ hosts: string[]; sudoPassword: string }> = []; + const managedHosts = ["daily-cloudcode-pa.googleapis.com", "api.individual.githubcopilot.com"]; + + await manager.handleExitCleanup("SIGINT", { + removeDNSEntry: async (sudoPassword: string) => { + removeDNSEntryCalls.push(sudoPassword); + }, + removeDNSEntries: async (hosts: string[], sudoPassword: string) => { + removeDNSEntriesCalls.push({ hosts, sudoPassword }); + }, + collectManagedHosts: () => managedHosts, + }); + + assert.deepEqual( + removeDNSEntryCalls, + ["s3cr3t-session-password"], + "must revert the legacy Antigravity DNS entries with the cached password" + ); + assert.equal(removeDNSEntriesCalls.length, 1, "must revert the managed host set exactly once"); + assert.deepEqual( + removeDNSEntriesCalls[0].hosts, + managedHosts, + "must pass collectManagedHosts() output through to removeDNSEntries" + ); + assert.equal( + removeDNSEntriesCalls[0].sudoPassword, + "s3cr3t-session-password", + "must forward the cached sudo password to removeDNSEntries" + ); + + const status = await manager.getMitmStatus(); + assert.equal( + status.orphanedStateDetected, + false, + "a successful hosts revert must not flag orphaned state" + ); +}); + +test("handleExitCleanup: with NO cached password, falls back to orphaned-state flag and skips DNS removal", async () => { + manager.clearCachedPassword(); + assert.equal(manager.getCachedPassword(), null, "precondition: no password cached in this session"); + + let removeDNSEntryCalled = false; + let removeDNSEntriesCalled = false; + + await manager.handleExitCleanup("SIGTERM", { + removeDNSEntry: async () => { + removeDNSEntryCalled = true; + }, + removeDNSEntries: async () => { + removeDNSEntriesCalled = true; + }, + collectManagedHosts: () => ["should-not-be-used.invalid"], + }); + + assert.equal( + removeDNSEntryCalled, + false, + "must NOT attempt privileged DNS removal without a cached password" + ); + assert.equal( + removeDNSEntriesCalled, + false, + "must NOT attempt privileged DNS removal without a cached password" + ); + + const status = await manager.getMitmStatus(); + assert.equal( + status.orphanedStateDetected, + true, + "must fall back to the orphaned-state flag for a manual Repair when no password is cached" + ); +}); From d3620f43019f8840d2119946114e3cdd2ed2a6ea Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 1 Jul 2026 20:49:13 -0300 Subject: [PATCH 2/3] fix(release): resolve CHANGELOG merge with release/v3.8.43, preserve MITM hosts cleanup bullet --- CHANGELOG.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a3fb25fd03e..b7c88b844234 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,9 +34,9 @@ - **fix(mitm):** best-effort revert privileged /etc/hosts entries on exit when a sudo password is cached, instead of always leaving orphaned state. (thanks @manhdzzz) -- **settings (model aliases β€” self-heal after restart):** the Settings β†’ Routing page showed "No exact-match aliases configured" after a server restart even though the aliases were persisted in the DB. Aliases are held in a module-local `_customAliases` map in `modelDeprecation.ts` that the boot path hydrates, but Next.js compiles the app-route module graph separately from the startup graph (the same webpack chunk-splitting class as #5312), so the `GET /api/settings/model-aliases` handler read a different, un-hydrated copy. The handler now self-heals: when its in-memory alias map is empty it reads `settings.modelAliases` from the DB (via the existing `getSettings()` db module β€” no raw SQL in the route) and repopulates the map, so the UI reflects the persisted aliases on the first GET after a restart. Regression guard: `tests/unit/model-aliases-settings-route-selfheal.test.ts`. ([#5777](https://github.com/diegosouzapw/OmniRoute/pull/5777) β€” thanks [@jleonar2](https://github.com/jleonar2)) +- **settings (model aliases β€” self-heal after restart):** the Settings β†’ Routing page showed "No exact-match aliases configured" after a server restart even though the aliases were persisted in the DB. Aliases are held in a module-local `_customAliases` map in `modelDeprecation.ts` that the boot path hydrates, but Next.js compiles the app-route module graph separately from the startup graph (the same webpack chunk-splitting class as #5312), so the `GET /api/settings/model-aliases` handler read a different, un-hydrated copy. The handler now self-heals: when its in-memory alias map is empty it reads `settings.modelAliases` from the DB (via the existing `getSettings()` db module β€” no raw SQL in the route) and repopulates the map, so the UI reflects the persisted aliases on the first GET after a restart. Follow-up: the root cause is now also fixed β€” the `_customAliases` store in `modelDeprecation.ts` is backed by `globalThis` (key `__omniroute_customAliases__`), so the startup and app-route module graphs share **one** store and the route reads the boot-hydrated aliases directly (the DB self-heal remains as a harmless fallback), mirroring the same `globalThis` singleton pattern already applied to `thinkingBudget.ts`/`backgroundTaskDetector.ts` (#5312). Regression guards: `tests/unit/model-aliases-settings-route-selfheal.test.ts` + `tests/unit/model-aliases-globalthis-5777.test.ts`. ([#5777](https://github.com/diegosouzapw/OmniRoute/pull/5777) β€” thanks [@jleonar2](https://github.com/jleonar2)) -- **providers (grok-cli token auto-refresh):** grok-cli OAuth tokens were never proactively refreshed before their real expiry. `mapTokens` hardcoded `expiresIn: 21600` (6 h) regardless of the token's actual lifetime, so the persisted `expiresAt` was always "now + 6 h" and the proactive `tokenHealthCheck` sweep (refresh when `expiresAt - now < 5 min`) fired 6 h after import instead of shortly before the token really expired. `mapTokens` now computes `expiresIn` from the authoritative `expires_at` field in `~/.grok/auth.json` (ISO β†’ epoch-seconds) with a fallback to the JWT `exp` claim (payload-only decode, no signature trust); the hardcoded `21600` is kept only when neither is present. Regression guards: 3 new cases in `tests/unit/grok-cli-oauth.test.ts` (JWT `exp`, JSON `expires_at`, and the fallback). ([#5775](https://github.com/diegosouzapw/OmniRoute/pull/5775) β€” thanks [@Chewji9875](https://github.com/Chewji9875)) +- **providers (grok-cli token auto-refresh):** grok-cli OAuth tokens were never proactively refreshed before their real expiry. `mapTokens` hardcoded `expiresIn: 21600` (6 h) regardless of the token's actual lifetime, so the persisted `expiresAt` was always "now + 6 h" and the proactive `tokenHealthCheck` sweep (refresh when `expiresAt - now < 5 min`) fired 6 h after import instead of shortly before the token really expired. `mapTokens` now computes `expiresIn` from the authoritative `expires_at` field in `~/.grok/auth.json` (ISO β†’ epoch-seconds) with a fallback to the JWT `exp` claim (payload-only decode, no signature trust); the hardcoded `21600` is kept only when neither is present. An already-expired token (real `expires_at`/`exp` in the past) is now clamped to a positive `expiresIn` via `Math.max(1, …)`, so the import route stores a near-future `expiresAt` and AutoCombo refreshes the connection instead of reading a past date and excluding it outright. Regression guards: 5 cases in `tests/unit/grok-cli-oauth.test.ts` (JWT `exp`, JSON `expires_at`, the `21600` fallback, and the two expired-token clamps). ([#5775](https://github.com/diegosouzapw/OmniRoute/pull/5775) β€” thanks [@Chewji9875](https://github.com/Chewji9875)) - **compression (CCR retrieve via MCP HTTP):** the `omniroute_ccr_retrieve` MCP tool returned `"CCR block not found"` for blocks stored earlier in the **same** session when called over the MCP HTTP transports (SSE / Streamable HTTP), e.g. from OpenCode in a Docker deployment. Compression stores each block keyed by the API-key principal (`String(apiKeyInfo.id)`), but the tool resolved the caller via `extra.authInfo.clientId` β€” which the MCP SDK never populates for API-key auth β€” so it fell back to `"anonymous"` and the compound store-key never matched. The retrieve tool now resolves the caller's API-key id from the MCP HTTP auth context (`httpAuthContext`) using the **same** `getApiKeyMetadata` lookup used at storage time, so retrieval matches storage. Cross-tenant IDOR isolation is preserved: a different key resolves to a different id β†’ miss; no key β†’ the anonymous bucket only. Regression guard: `tests/unit/compression/ccr-mcp-principal-5649.test.ts` (extraction, distinct-principal isolation, fail-closed, end-to-end storeβ†’retrieve). ([#5649](https://github.com/diegosouzapw/OmniRoute/issues/5649)) From ff7a7d805e59c6b030b1746243a61a64c9b3e886 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Wed, 1 Jul 2026 21:57:49 -0300 Subject: [PATCH 3/3] chore: resolve CHANGELOG conflict for #5808 (release cascade) --- CHANGELOG.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7c88b844234..7752e4eb0483 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### πŸ”§ Bug Fixes + +- **fix(kiro):** bound the Claude model-id dashβ†’dot normalization to a 1–2 digit minor so date-suffixed ids (e.g. claude-opus-4-20250514) are no longer corrupted. (thanks @voravitl) + --- ## [3.8.43] β€” TBD @@ -32,8 +36,14 @@ ### πŸ”§ Bug Fixes +- **fix(github):** drop a trailing assistant prefill before dispatching to GitHub Copilot chat to avoid 400 errors. (thanks @baslr) + +- **fix(oauth):** prevent cross-IdP account overwrites by disambiguating OAuth connections on `username` when present, not email alone. (thanks @KunN-21) + - **fix(mitm):** best-effort revert privileged /etc/hosts entries on exit when a sudo password is cached, instead of always leaving orphaned state. (thanks @manhdzzz) +- **providers (Kiro β€” Claude Sonnet 5):** the Kiro provider's model catalog was missing `claude-sonnet-5`, so the model could not be selected or routed even on accounts that already had access to it ("claude-sonnet-5 is not supported"). Added the model to the Kiro registry (`open-sse/config/providers/registry/kiro/index.ts`) as a 1M-context / 128K-output Claude model, mirroring the existing Claude entries; the registry `models[]` feeds both the model selector and the live CodeWhisperer `ListAvailableModels` fallback, so the model is now selectable and routable. Regression guard: `tests/unit/kiro-claude-sonnet-5-2267.test.ts`. (thanks [@openbioinfo](https://github.com/openbioinfo)) + - **settings (model aliases β€” self-heal after restart):** the Settings β†’ Routing page showed "No exact-match aliases configured" after a server restart even though the aliases were persisted in the DB. Aliases are held in a module-local `_customAliases` map in `modelDeprecation.ts` that the boot path hydrates, but Next.js compiles the app-route module graph separately from the startup graph (the same webpack chunk-splitting class as #5312), so the `GET /api/settings/model-aliases` handler read a different, un-hydrated copy. The handler now self-heals: when its in-memory alias map is empty it reads `settings.modelAliases` from the DB (via the existing `getSettings()` db module β€” no raw SQL in the route) and repopulates the map, so the UI reflects the persisted aliases on the first GET after a restart. Follow-up: the root cause is now also fixed β€” the `_customAliases` store in `modelDeprecation.ts` is backed by `globalThis` (key `__omniroute_customAliases__`), so the startup and app-route module graphs share **one** store and the route reads the boot-hydrated aliases directly (the DB self-heal remains as a harmless fallback), mirroring the same `globalThis` singleton pattern already applied to `thinkingBudget.ts`/`backgroundTaskDetector.ts` (#5312). Regression guards: `tests/unit/model-aliases-settings-route-selfheal.test.ts` + `tests/unit/model-aliases-globalthis-5777.test.ts`. ([#5777](https://github.com/diegosouzapw/OmniRoute/pull/5777) β€” thanks [@jleonar2](https://github.com/jleonar2)) - **providers (grok-cli token auto-refresh):** grok-cli OAuth tokens were never proactively refreshed before their real expiry. `mapTokens` hardcoded `expiresIn: 21600` (6 h) regardless of the token's actual lifetime, so the persisted `expiresAt` was always "now + 6 h" and the proactive `tokenHealthCheck` sweep (refresh when `expiresAt - now < 5 min`) fired 6 h after import instead of shortly before the token really expired. `mapTokens` now computes `expiresIn` from the authoritative `expires_at` field in `~/.grok/auth.json` (ISO β†’ epoch-seconds) with a fallback to the JWT `exp` claim (payload-only decode, no signature trust); the hardcoded `21600` is kept only when neither is present. An already-expired token (real `expires_at`/`exp` in the past) is now clamped to a positive `expiresIn` via `Math.max(1, …)`, so the import route stores a near-future `expiresAt` and AutoCombo refreshes the connection instead of reading a past date and excluding it outright. Regression guards: 5 cases in `tests/unit/grok-cli-oauth.test.ts` (JWT `exp`, JSON `expires_at`, the `21600` fallback, and the two expired-token clamps). ([#5775](https://github.com/diegosouzapw/OmniRoute/pull/5775) β€” thanks [@Chewji9875](https://github.com/Chewji9875)) @@ -44,6 +54,8 @@ - **proxy (relay test diagnostics):** the Proxy Pool "Test" button showed a bare "failed" with **nothing in the server logs** when a **relay** (Vercel / Deno / Cloudflare) *responded* with a non-200 β€” e.g. a `401` from an auth-token mismatch after a `STORAGE_ENCRYPTION_KEY` rotation. The relay success-path response set `success: false` but carried no `error` field, so the dashboard had no reason to show and the server logged nothing. The test now returns an actionable `error` (the HTTP status, plus an auth/encryption-key hint on `401`/`403`) and logs the failure server-side; the SOCKS5/HTTP proxy path now logs its failures too. Shaping extracted to `buildRelayTestResult` with a regression guard (`tests/unit/proxy-relay-test-error-5716.test.ts`). Note: this surfaces *why* a relay fails β€” it does not repair a genuinely broken/misconfigured relay. ([#5716](https://github.com/diegosouzapw/OmniRoute/issues/5716)) +- **fix(dashboard):** add error boundaries for the Combos and MITM Proxy pages so a render error shows a recoverable fallback instead of a blank page. (thanks @wahyuzero) + - **providers (onboarding wizard β€” unsupported validation):** adding a provider whose credentials have **no live validator** (LMArena, PiAPI, …) failed silently in the Add-Provider wizard. The `/api/providers/validate` endpoint returns `HTTP 400 + { unsupported: true }` for these (#5565/#5567), but the wizard's `validateOnboardingApiKey` ran it through `expectOk`, which threw on the non-200 β€” so the flow jumped to the error step and the connection was **never created**. The wizard now treats `unsupported: true` as a non-blocking "can't verify" and proceeds to save, mirroring `AddApiKeyModal`. Regression guard added to `tests/unit/provider-onboarding-wizard.test.ts`. (related to [#5692](https://github.com/diegosouzapw/OmniRoute/issues/5692)) - **dashboard (Quick Start step 1):** the Quick Start "Create API key" step told users to "Go to **Endpoint** β†’ Registered Keys" and linked to `/dashboard/endpoint`, but API keys are created on the **API Manager** page (`/dashboard/api-manager`, sidebar "API Keys") β€” the Endpoint page has no "Registered Keys" section, so users followed the link and could not find where to create a key. Step 1 now reads "Go to **API Keys**" and links to `/dashboard/api-manager`. Regression guard: `tests/unit/ui/quick-start-api-keys-link-5695.test.ts`. ([#5695](https://github.com/diegosouzapw/OmniRoute/issues/5695)) @@ -90,6 +102,14 @@ - **Security hardening follow-ups (v3.8.15):** the `auth_token` cookie now sets an explicit 30-day `maxAge` so sessions persist as intended (Seg3); the management bootstrap warns at boot when `INITIAL_PASSWORD` is left at the insecure `CHANGEME` default (Seg2); VS Code path-token endpoints (`/api/v1/vscode/raw/[token]`) emit a once-per-process security warning since the API key travels in the URL and can leak via logs/proxies (Seg4); the system version route resolves the real global install path via `npm root -g` instead of a hardcoded `/app` (Bug3); and auto-update mode detection segment-matches `node_modules` instead of substring-matching, eliminating false "global install" positives (Bug1). +- **fix(cli):** rename the Node process title to `omniroute` so it shows correctly in ps/htop. (thanks @waguriagentic) + +- **dashboard (model picker):** guard against null model-alias values so opening Create Combo for a custom provider node no longer crashes. `ModelSelectModal`'s custom-provider branch filtered `modelAliases` entries with a raw `fullModel.startsWith(...)`, which threw a `TypeError` whenever an alias value was `null`/`undefined` (a stale/partial entry persisted to settings). The filter/map logic is extracted into a new `buildNodeAliasModels` helper (mirroring the sibling passthrough-alias guard, #485) that requires `typeof fullModel === "string"` before calling `.startsWith`. Regression guard: `tests/unit/model-select-null-alias-guard-2247.test.ts`. (thanks @wahyuzero) + +- **fix(translator):** strip orphaned tool results (results with no matching tool call) across request formats to avoid upstream 400s. (thanks @warelik) + +- **fix(kiro):** stop injecting a placeholder user turn on trailing tool-result turns so agentic loops aren't disrupted. (thanks @jetmiky) + ### πŸ“ Maintenance ---