Skip to content

Commit 92715c8

Browse files
authored
Release v3.8.46
Release v3.8.46. Full changelog: CHANGELOG.md → [3.8.46]. Contributor attribution in the CHANGELOG entries.
1 parent 3ddcee6 commit 92715c8

370 files changed

Lines changed: 24527 additions & 1196 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 317 additions & 110 deletions
Large diffs are not rendered by default.

CLAUDE.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ Client → /v1/chat/completions (Next.js route)
7272

7373
API routes follow a consistent pattern: `Route → CORS preflight → Zod body validation → Optional auth (extractApiKey/isValidApiKey) → API key policy enforcement → Handler delegation (open-sse)`. No global Next.js middleware — interception is route-specific.
7474

75-
**Combo routing** (`open-sse/services/combo.ts`): 17 strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. The `fusion` strategy is the exception: it fans out to a panel of models in parallel, then a judge model synthesizes one final answer (`open-sse/services/fusion.ts`). See `docs/routing/AUTO-COMBO.md` for the 12-factor Auto-Combo scoring + the full strategy table and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers.
75+
**Combo routing** (`open-sse/services/combo.ts`): 18 strategies (priority, weighted, fill-first, round-robin, P2C, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. The `fusion` strategy is the exception: it fans out to a panel of models in parallel, then a judge model synthesizes one final answer (`open-sse/services/fusion.ts`). See `docs/routing/AUTO-COMBO.md` for the 12-factor Auto-Combo scoring + the full strategy table and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers.
7676

7777
---
7878

@@ -221,7 +221,7 @@ connection continue serving other models.
221221
- **2 spaces**, semicolons, double quotes, 100 char width, es5 trailing commas (enforced by lint-staged via Prettier)
222222
- **Imports**: external → internal (`@/`, `@omniroute/open-sse`) → relative
223223
- **Naming**: files=camelCase/kebab, components=PascalCase, constants=UPPER_SNAKE
224-
- **ESLint**: `no-eval`, `no-implied-eval`, `no-new-func` = error everywhere; `no-explicit-any` = warn in `open-sse/` and `tests/`
224+
- **ESLint**: `no-eval`, `no-implied-eval`, `no-new-func` = error everywhere; `no-explicit-any` = **error** in `open-sse/` and `tests/` (since #6218 — pre-existing violations are frozen in `config/quality/eslint-suppressions.json`, new ones must be fixed; `npm run lint` applies the suppressions and is what CI runs)
225225
- **TypeScript**: `strict: false`, target ES2022, module esnext, resolution bundler. Prefer explicit types.
226226

227227
### Database
@@ -332,7 +332,7 @@ For any non-trivial change, read the matching deep-dive first:
332332
| Repo navigation | `docs/architecture/REPOSITORY_MAP.md` |
333333
| Architecture | `docs/architecture/ARCHITECTURE.md` |
334334
| Engineering reference | `docs/architecture/CODEBASE_DOCUMENTATION.md` |
335-
| Auto-Combo (12-factor scoring, 17 strategies) | `docs/routing/AUTO-COMBO.md` |
335+
| Auto-Combo (12-factor scoring, 18 strategies) | `docs/routing/AUTO-COMBO.md` |
336336
| Resilience (3 mechanisms) | `docs/architecture/RESILIENCE_GUIDE.md` |
337337
| Reasoning replay | `docs/routing/REASONING_REPLAY.md` |
338338
| Skills framework | `docs/frameworks/SKILLS.md` |
@@ -543,7 +543,7 @@ the stale-enforcement added in Fase 6A.3.
543543
21. **Release-freeze — the FROZEN release branch belongs to the release captain; development does NOT stop (parallel-cycle model, 2026-07-04).** `/generate-release` opens a marker issue labeled `release-freeze` at the start of reconciliation (Phase 0a), **immediately cuts the next cycle's branch `release/vX+1` from the frozen tip (Phase 0a.0b — bump + living release PR + re-home of open PRs)**, and closes the freeze once the release PR squash-merges to `main`. Before merging **any** PR, every campaign workflow (`/review-issues`, `/review-prs`, `/implement-features`, `/green-prs`, `/port-upstream-*`) **MUST** check `gh issue list --repo diegosouzapw/OmniRoute --label release-freeze --state open` — if a freeze is active: **NEVER merge into the frozen `release/vX.Y.Z` named in the freeze title**; instead resolve the ACTIVE development branch (the **highest** `release/v*` by semver — normally `release/vX+1`, announced in a freeze-issue comment) and **retarget the PR there** (`gh pr edit <N> --base release/vX+1`, then VERIFY with `gh pr view <N> --json baseRefName` — the edit fails silently) and merge normally. **HOLD only when the highest release/v\* branch IS the frozen one** (the short window before 0a.0b completes, or a pre-parallel-cycle release) — in that case leave the PR ready and open, tell the operator, and resume when the next branch appears or the freeze lifts. The just-shipped fixes reach `release/vX+1` via the Phase 5 sync-back (`scripts/release/sync-next-cycle.mjs`); do not try to sync mid-release. This is a **coordination signal, not a permission lock**: the release captain and the campaign sessions share the `diegosouzapw` identity, so a GitHub branch-protection lock cannot distinguish them — only this honored marker prevents the mid-release commit races that forced full CHANGELOG re-reconciliation in v3.8.40/v3.8.41 (a parallel campaign advanced `release/vX.Y.Z` by 34 commits mid-run). The release captain's own reconciliation/cycle-open pushes are exempt — they _are_ the release. Fixes that must land during a freeze (a homologation finding) follow the post-merge read-only rule: land on `main` first via `fix/release-vX.Y.Z-*`. **⛔ ONLY `/generate-release` may raise a release-freeze, and ONLY at its Phase 0a (start of generating a new version) — lifted at Phase 12c after the squash-merge to `main`.** No campaign, session, or agent may open a `release-freeze` marker at any other time — a freeze is **never** a mid-development coordination tool. If a session ever believes a freeze is genuinely, unavoidably necessary outside the `/generate-release` flow, it **MUST first ask the operator (`diegosouzapw`) in chat, explicitly alert "estou criando um freeze" and get an explicit yes** — never open, extend, or re-open a `release-freeze` autonomously. Conversely, do **not** close/lift an active `/generate-release` freeze to unblock campaign merges: it protects the captain's single clean CI run and auto-lifts at Phase 12c — closing it early re-triggers the exact commit race it prevents. Verify a freeze is legitimate before acting on it: an open `release-freeze` whose title/body references an **OPEN** release PR (`gh pr view <N> --json state`) is the authorized captain freeze — hold, don't touch.
544544
22. **Cross-session safety — this repo is worked by MANY parallel sessions/agents at once; never step on another's in-flight work.** Two absolute bans, both recurring incidents (this rule exists because they keep happening):
545545
- **(a) Never `git stash` / `git stash pop` — ANYWHERE in this repo, including inside an isolated worktree, and including inside any subagent you dispatch.** `git stash` operates on the **shared repository object store**, not the per-worktree working tree — so a stash pushed or popped in one session can silently clobber or resurrect another parallel session's uncommitted changes. This is not hypothetical: 2026-07-02 a `#5923` quotaCache change leaked into the unrelated `#2296` worktree via a global `stash pop`, and the same class reincided through a **subagent**. To compare working changes against a base ref **without** stashing, use `git show <ref>:<path>` or `git diff <ref> -- <path>`; to confirm a typecheck/lint error is pre-existing on the base, inspect the base ref directly (`git show origin/release/vX.Y.Z:<path>`) — never stash your tree away to "get it clean". **Put this ban verbatim in the prompt of every subagent that touches git** (agents don't inherit this file's context — the recurrence was a subagent).
546-
- **(b) Never merge, push, rebase, or force-push a PR / branch / worktree that another session is actively working.** An open PR whose head is a live fix worktree in `.claude/worktrees/` you did **not** create (e.g. `fix-5852`/`fix-5923` carrying fresh commits, even when they share your `diegosouzapw` identity), or any branch another session owns, is **off-limits — HOLD**, and let the owning session merge it. **Before** merging or pushing to any PR you did not create *this* session, run `git worktree list` to check for a matching in-flight worktree and re-check `gh pr view <N> --json state,headRefOid`. Only the owning session merges its own in-flight PR; mid-flight merges race the owner and re-trigger the exact commit/CHANGELOG races Rule #19 and Rule #21 guard against. (Reinforces Rule #19.)
546+
- **(b) Never merge, push, rebase, or force-push a PR / branch / worktree that another session is actively working.** An open PR whose head is a live fix worktree in `.claude/worktrees/` you did **not** create (e.g. `fix-5852`/`fix-5923` carrying fresh commits, even when they share your `diegosouzapw` identity), or any branch another session owns, is **off-limits — HOLD**, and let the owning session merge it. **Before** merging or pushing to any PR you did not create _this_ session, run `git worktree list` to check for a matching in-flight worktree and re-check `gh pr view <N> --json state,headRefOid`. Only the owning session merges its own in-flight PR; mid-flight merges race the owner and re-trigger the exact commit/CHANGELOG races Rule #19 and Rule #21 guard against. (Reinforces Rule #19.)
547547

548548
---
549549

Dockerfile

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,11 @@ RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
7070
# See docs/ops/QUALITY_GATE_PLAYBOOK.md Parte 6.
7171
ENV OMNIROUTE_USE_TURBOPACK=1
7272

73+
# Docker containers cannot run the MITM/Agent-Bridge stack (no host DNS/cert
74+
# access), so keep @/mitm/manager on the graceful stub (#3390). This flag is
75+
# Docker-only: npm/Electron/VPS builds must bundle the REAL manager (#6344).
76+
ENV OMNIROUTE_MITM_STUB=1
77+
7378
# Raise the V8 heap ceiling for the build. The webpack production optimization
7479
# pass needs more than V8's default ceiling (~2 GB) for a codebase this size; a
7580
# memory-constrained Docker build otherwise dies with "FATAL ERROR: ... JavaScript

bin/cli/commands/launch-codex.mjs

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,16 @@ const STRIPPED_CODEX_ENV_KEYS = [
1919
/** Placeholder so codex's `env_key` is always satisfied when the backend is open. */
2020
const NO_AUTH_SENTINEL = "omniroute-no-auth";
2121

22+
// On Windows the `codex` binary is an npm `.cmd` shim that `spawn` cannot resolve
23+
// without a shell (bare "codex" → ENOENT). Mirror the qodercli Windows fix (#6263):
24+
// spawn `codex.cmd` through a shell on win32, and the bare binary elsewhere.
25+
export function resolveCodexSpawn(platform) {
26+
if (platform === "win32") {
27+
return { command: "codex.cmd", shell: true };
28+
}
29+
return { command: "codex", shell: undefined };
30+
}
31+
2232
function stripTrailingSlash(value) {
2333
let s = String(value);
2434
let end = s.length;
@@ -126,10 +136,10 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
126136

127137
if (!(await healthCheck(baseUrl))) {
128138
console.error(
129-
(t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace(
130-
"{port}",
131-
baseUrl
132-
)
139+
(
140+
t("launch.notRunning") ||
141+
"OmniRoute is not reachable at {port}. Start it with 'omniroute serve'."
142+
).replace("{port}", baseUrl)
133143
);
134144
return 1;
135145
}
@@ -142,7 +152,12 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
142152
const env = buildCodexEnv(process.env, authToken);
143153

144154
return await new Promise((resolve) => {
145-
const child = spawn("codex", extraArgs, { env, stdio: "inherit" });
155+
const { command: codexLaunch, shell: shellValue } = resolveCodexSpawn(process.platform);
156+
const child = spawn(codexLaunch, extraArgs, {
157+
env,
158+
stdio: "inherit",
159+
shell: shellValue,
160+
});
146161
child.on("error", (err) => {
147162
if (err?.code === "ENOENT") {
148163
console.error(
@@ -165,10 +180,16 @@ export function registerLaunchCodex(program) {
165180
t("launchCodex.description") || "Launch Codex CLI pointed at OmniRoute (local or remote VPS)"
166181
)
167182
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
168-
.option("--remote <url>", "Remote OmniRoute base URL, e.g. http://192.168.0.15:20128 (overrides --port + context)")
183+
.option(
184+
"--remote <url>",
185+
"Remote OmniRoute base URL, e.g. http://192.168.0.15:20128 (overrides --port + context)"
186+
)
169187
.option("--profile <name>", "Codex profile to activate (passed as --profile <name>)")
170188
.option("-p, --p <name>", "Alias for --profile")
171-
.option("--api-key <key>", "OmniRoute API key (overrides OMNIROUTE_API_KEY env var for this invocation)")
189+
.option(
190+
"--api-key <key>",
191+
"OmniRoute API key (overrides OMNIROUTE_API_KEY env var for this invocation)"
192+
)
172193
.allowUnknownOption(true)
173194
.allowExcessArguments(true)
174195
.argument("[codexArgs...]", "arguments passed through to the codex binary")

bin/omniroute.mjs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* Special bypasses (handled before Commander):
77
* --mcp Start MCP server over stdio
88
* reset-encrypted-columns Recovery tool for broken encrypted credentials
9+
* reset-password Reset the admin/management password
910
*
1011
* All other commands are routed through Commander (bin/cli/program.mjs).
1112
*/
@@ -210,6 +211,15 @@ if (process.argv.includes("reset-encrypted-columns")) {
210211
process.exit(exitCode ?? 0);
211212
}
212213

214+
if (process.argv.includes("reset-password")) {
215+
// bin/reset-password.mjs self-executes its `main()` on import and calls
216+
// process.exit() on completion/error. Await a never-resolving promise so
217+
// control never falls through to Commander (which would then reject
218+
// `reset-password` as an unknown command). See #6261.
219+
await import(pathToFileURL(join(ROOT, "bin", "reset-password.mjs")).href);
220+
await new Promise(() => {});
221+
}
222+
213223
try {
214224
const { createProgram } = await import(
215225
pathToFileURL(join(ROOT, "bin", "cli", "program.mjs")).href

bin/reset-password.mjs

Lines changed: 79 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,15 @@
55
*
66
* Usage:
77
* node bin/reset-password.mjs
8-
* npx omniroute reset-password
8+
* omniroute reset-password
9+
*
10+
* Non-interactive / scripted usage (piped stdin, e.g. CI or Docker):
11+
* printf 'NewPass123\nNewPass123\n' | omniroute reset-password
12+
* printf 'NewPass123' | omniroute reset-password --password-stdin
913
*
1014
* Resets the admin password for OmniRoute.
11-
* Prompts for a new password and updates the database directly.
15+
* Prompts for a new password (interactive TTY) or reads it from stdin
16+
* (non-TTY) and updates the database directly.
1217
*
1318
* @module bin/reset-password
1419
*/
@@ -21,19 +26,61 @@ import { readManagementPasswordState, resetManagementPassword } from "./cli/sqli
2126
const DATA_DIR = resolveDataDir();
2227
const DB_PATH = resolveStoragePath(DATA_DIR);
2328

24-
const rl = createInterface({
25-
input: process.stdin,
26-
output: process.stdout,
27-
});
28-
29-
function ask(question) {
30-
return new Promise((resolve) => rl.question(question, resolve));
29+
const MIN_PASSWORD_LENGTH = 8;
30+
31+
/** Read the entire stdin stream as a UTF-8 string (used for non-TTY input). */
32+
function readAllStdin() {
33+
return new Promise((resolve) => {
34+
let data = "";
35+
process.stdin.setEncoding("utf8");
36+
process.stdin.on("data", (chunk) => {
37+
data += chunk;
38+
});
39+
process.stdin.on("end", () => resolve(data));
40+
process.stdin.on("error", () => resolve(data));
41+
// Resuming is implied by attaching a 'data' listener, but be explicit so a
42+
// paused stream (some spawn setups) still flows to EOF.
43+
process.stdin.resume();
44+
});
3145
}
3246

33-
function exitWithError(message) {
34-
console.error(message);
35-
rl.close();
36-
process.exit(1);
47+
/**
48+
* Obtain the new password (and its confirmation).
49+
*
50+
* - `--password-stdin`: the ENTIRE stdin is the password, no confirmation.
51+
* - non-TTY stdin (piped): read all of stdin once; first line is the password,
52+
* second line — when present — is the confirmation, else the first line is
53+
* reused (a single-line pipe means "no separate confirmation").
54+
* - interactive TTY: two sequential prompts (unchanged behavior).
55+
*
56+
* The non-TTY path exists because two sequential `rl.question` promises never
57+
* settle under a piped EOF — the second read blocks forever, so the reset was
58+
* silently never applied (#6258).
59+
*/
60+
async function collectPassword() {
61+
if (process.argv.includes("--password-stdin")) {
62+
const raw = await readAllStdin();
63+
const password = raw.replace(/[\r\n]+$/, "");
64+
return { password, confirm: password };
65+
}
66+
67+
if (!process.stdin.isTTY) {
68+
const raw = await readAllStdin();
69+
const lines = raw.split(/\r?\n/);
70+
const password = lines[0] ?? "";
71+
const confirm = lines[1] ? lines[1] : password;
72+
return { password, confirm };
73+
}
74+
75+
const rl = createInterface({ input: process.stdin, output: process.stdout });
76+
try {
77+
const ask = (question) => new Promise((resolve) => rl.question(question, resolve));
78+
const password = await ask("Enter new password (min 8 chars): ");
79+
const confirm = await ask("Confirm new password: ");
80+
return { password, confirm };
81+
} finally {
82+
rl.close();
83+
}
3784
}
3885

3986
console.log("\n🔑 OmniRoute — Password Reset\n");
@@ -54,27 +101,34 @@ async function main() {
54101
console.log("ℹ️ No password is currently set.");
55102
}
56103

57-
const password = await ask("Enter new password (min 8 chars): ");
104+
const { password, confirm } = await collectPassword();
58105

59-
if (!password || password.length < 8) {
60-
exitWithError("\n❌ Password must be at least 8 characters.\n");
106+
if (!password || password.length < MIN_PASSWORD_LENGTH) {
107+
console.error(`\n❌ Password must be at least ${MIN_PASSWORD_LENGTH} characters.\n`);
108+
process.exit(1);
61109
}
62110

63-
const confirm = await ask("Confirm new password: ");
64-
65111
if (password !== confirm) {
66-
exitWithError("\n❌ Passwords do not match.\n");
112+
console.error("\n❌ Passwords do not match.\n");
113+
process.exit(1);
67114
}
68115

69116
await resetManagementPassword(password, DB_PATH);
70-
rl.close();
71117

72118
console.log("\n✅ Password reset successfully!");
73119
console.log(" Restart OmniRoute for changes to take effect.\n");
74120
}
75121

76-
main().catch((err) => {
77-
console.error(`\n❌ Error: ${err.message}\n`);
78-
rl.close();
79-
process.exit(1);
80-
});
122+
main()
123+
.then(() => {
124+
// Explicit exit(0) so a caller that imports this module (bin/omniroute.mjs
125+
// routes `omniroute reset-password` here) terminates cleanly instead of
126+
// hanging / exiting with code 13 on an unsettled wrapper await. On POSIX,
127+
// console.log to a pipe is synchronous, so the success line is already
128+
// flushed by the time we exit.
129+
process.exit(0);
130+
})
131+
.catch((err) => {
132+
console.error(`\n❌ Error: ${err.message}\n`);
133+
process.exit(1);
134+
});

0 commit comments

Comments
 (0)