Compare commits

..

86 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
ae6e2342d1 Merge origin/main into release/v3.8.38
Reconcile the security hotfix #5129 (exact-host Anthropic baseUrl check,
CodeQL js/incomplete-url-substring-sanitization #674) landed on main into
the active release branch.

Only conflict: open-sse/executors/default.ts import block. Resolved to the
release superset — release already independently carries the #5129 logic
(isOfficialAnthropicBaseUrl import + exact-host check, auto-merged) and adds
normalizeAnthropicHeaderVariants on top, so both imports are kept. No
security change is dropped; anthropicHost.ts and its test are byte-identical
on both branches.
2026-06-27 08:22:13 -03:00
KooshaPari
16449d4874 test: refresh release expectations to match current code (#5150)
Integrated into release/v3.8.38 (test-only base-red alignment extracted from #5150)
2026-06-27 08:02:59 -03:00
Diego Rodrigues de Sa e Souza
591486e1ea test(combo): gated live smoke for combo strategies (in-process + VPS HTTP) (#5151)
Integrated into release/v3.8.38
2026-06-27 07:55:39 -03:00
Diego Rodrigues de Sa e Souza
0c113e19d5 feat(compression): ionizer engine — lossy JSON-array sampling reversible via CCR (#5148)
Integrated into release/v3.8.38
2026-06-27 07:52:20 -03:00
Randi
905616b24d Preserve non-stream reasoning fields for compatible clients (#5155)
Integrated into release/v3.8.38
2026-06-27 07:52:03 -03:00
Randi
107e42a21c fix(i18n): add missing English UI labels (#5153)
Integrated into release/v3.8.38
2026-06-27 07:51:57 -03:00
Diego Rodrigues de Sa e Souza
ff57be32f4 chore(release): finalize v3.8.38 CHANGELOG + cycle reconciliation
- Reconcile [3.8.38]: +18 bullets (compression fidelity-gate/fuzzy-dedup #5143,
  quota keepalive #5102, web-session robustness #5121, MiniMax/Nemotron #5136,
  model-visibility #5091, failover logs #5016, disconnect races #5007, sidebar
  orphan #5142, SRE playbooks salvage #5138, new Security #5130 + Maintenance roll-up)
- Credit salvaged-PR authors (@JxnLexn / @KooshaPari / @herjarsa / @Witroch4)
- Remove phantom bullet for CLOSED-not-merged #5092 (setup aggregator never landed)
- Fix isHidden bullet PR citation #4389 -> #5086 (@herjarsa)
- Back-fill forgotten v3.8.36 bullet: #5026 crypto.randomUUID ID-gen (@hamsa0x7)
- Sync 41 i18n CHANGELOG mirrors; README What's New -> v3.8.38
- Rebaseline cycle drift: eslint 3987->4002, cognitive 833->841, dead-exports
  345->346, cyclomatic 1978->1980 (file-size handled by #5147)
2026-06-27 04:21:32 -03:00
Diego Rodrigues de Sa e Souza
7caa7f23f1 chore(quality): rebaseline file-size for sidebarVisibility.ts + chat.ts drift (#5147)
Mid-cycle drift on release/v3.8.38 from already-merged PRs that the fast-path
(PR->release skips check:file-size) let accumulate without a bump:

- src/shared/constants/sidebarVisibility.ts 1100->1198 (#3812 colored menu
  icons, per-item accent map; #5142 dropped one orphan, net still above frozen)
- src/sse/handlers/chat.ts 1560->1575 (#5064 self-inflicted-timeout cooldown
  skip + #5124 long OpenAI-compatible SSE hardening + #5110 embed-WS
  LIVE_WS_HOST honour / early empty-message reject)

Each covered by its own PR tests; structural shrink of chat.ts tracked in #3501.
Unblocks the Fast Quality Gates for PRs targeting release/v3.8.38.
2026-06-27 03:43:11 -03:00
Diego Rodrigues de Sa e Souza
54313b4e95 feat(compression): fuzzy near-duplicate dedup (session-dedup 2nd pass + playground toggle) (#5143)
Integrated into release/v3.8.38.
2026-06-27 03:08:54 -03:00
Diego Rodrigues de Sa e Souza
b6742f038c test(combo): deterministic routing-decision matrix for all 17 strategies (#5146)
Integrated into release/v3.8.38.
2026-06-27 03:03:59 -03:00
Diego Rodrigues de Sa e Souza
cbe8290226 feat: salvage batch from closed stale PRs (#5038, #5057, #5076) (#5138)
Integrated into release/v3.8.38.
2026-06-27 03:03:43 -03:00
Diego Rodrigues de Sa e Souza
513184f281 test(usage): wire usageHistoryDedup under unit runner brace-list (#5145)
Integrated into release/v3.8.38.
2026-06-27 03:03:31 -03:00
Diego Rodrigues de Sa e Souza
2b00e4e4fd refactor(compression): extract fidelity-gate step helpers to shrink strategySelector (file-size gate)
bodyToText and gateAdvance moved to fidelityGateStep.ts; StackAccumulator exported.
strategySelector: 889->854 (-35). Residual +6 vs pre-Milestone-B frozen 848 is the
irreducible StackOptions.fidelityGate field + two stacked-loop dispatch reads + import.
Baseline updated to 854 with justification. No cycle introduced (import type only).
940 compression tests pass; typecheck clean.
2026-06-27 02:56:06 -03:00
Diego Rodrigues de Sa e Souza
fc41228a8f docs(compression): note fidelityGate advanced thresholds are intentionally API-omitted 2026-06-27 02:56:06 -03:00
Diego Rodrigues de Sa e Souza
ce802fa296 feat(compression): playground fidelity-gate toggle + lane rejection display 2026-06-27 02:56:06 -03:00
Diego Rodrigues de Sa e Souza
8df1aff301 feat(compression): preview route accepts fidelityGate flag (playground) 2026-06-27 02:56:06 -03:00
Diego Rodrigues de Sa e Souza
d9d8cfa777 feat(compression): wire per-step fidelity gate into stacked pipeline (opt-in) 2026-06-27 02:56:06 -03:00
Diego Rodrigues de Sa e Souza
bccdfac449 feat(compression): fidelityGate config + rejected breakdown fields 2026-06-27 02:56:06 -03:00
Diego Rodrigues de Sa e Souza
5dacaa7f4d feat(compression): pure per-step fidelity checker (4 invariants, fail-open) 2026-06-27 02:56:06 -03:00
Diego Rodrigues de Sa e Souza
1f83bde19e docs(i18n): sync 3.8.38 CHANGELOG section to 41 mirrors (unblock docs-accuracy) (#5144)
The root CHANGELOG [3.8.38] section grew with this cycle's merged PRs, but the
docs/i18n/<lang>/CHANGELOG.md mirrors were not re-synced — drifting >25% in body
size and failing check:docs-sync (the "Docs accuracy" fast-gate step) for every
open PR against the release.

Ran scripts/release/sync-changelog-i18n.mjs 3.8.38 3.8.37 to copy the root
[3.8.38] section into all 41 mirrors. check:docs-all now passes (exit 0).

Sections are copied verbatim; the per-language translation pass runs at release
time via i18n:run — this only restores the size-sync the gate enforces.
2026-06-27 02:47:12 -03:00
Diego Rodrigues de Sa e Souza
5b8bd727f0 feat: salvage batch 2 — diagnostics null-guard (#5096) + observed quota reset windows (#5025) (#5141)
* fix(diagnostics): null-guard content blocks in detectMalformedNonStream

A null (or non-object) entry in a Claude-native `content` array made the
non-stream classifier throw `TypeError: Cannot read properties of null
(reading 'type')`, crashing the malformed-response detection path. Guard
before type-asserting each block: a null/non-object block is simply skipped.

Two regression tests added (null block among valid blocks → null; only-null
blocks → empty_choices).

Salvaged from closed PR #5096 (base-stale; only the defensive guard — the
Claude-shape recognition it also carried already landed via #5108).

Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>

* feat(quota): persist observed provider quota reset windows

Adds `provider_quota_reset_events` (migration 108) + `db/quotaResetEvents.ts`
to record real upstream weekly-quota window transitions whenever a quota
refresh shows the reset rolling to a new cycle (different day, later resetAt).
`apiKeyUsageLimits` now prefers the observed window start over the inferred
`resetAt − 7d`, falling back to snapshot inference when no event is recorded
yet. `quotaCache.setQuotaCache` records the transition opportunistically.

`recordProviderQuotaResetEventIfChanged` only fires for the primary weekly
window (not daily/sonnet), is idempotent (INSERT OR IGNORE on the unique
window key), and no-ops when the reset didn't actually roll. 4 unit tests
(tests/unit/lib/quota-reset-events.test.ts).

Salvaged from closed PR #5025 (which bundled this with two unrelated
features + a colliding migration 104). Renumbered to 108; module re-exported
from localDb (Rule #2).

Co-authored-by: Witroch4 <175152067+Witroch4@users.noreply.github.com>

---------

Co-authored-by: herjarsa <herjarsa@users.noreply.github.com>
Co-authored-by: Witroch4 <175152067+Witroch4@users.noreply.github.com>
2026-06-27 02:39:28 -03:00
Diego Rodrigues de Sa e Souza
1b446cc92d fix(sidebar): drop orphan settings accent color (typecheck:core red) (#5142)
SIDEBAR_ICON_ACCENTS is typed Partial<Record<HideableSidebarItemId, string>>,
but `settings` is not a hideable item id (only `settings-general`,
`settings-appearance`, … and `context-settings` exist; there is no item with
`id: "settings"`), so the accent was unreachable. It broke `typecheck:core`
on the release tip ("'settings' does not exist in type …", introduced by
#3812 colored menu icons). Removing the orphan key restores a clean
typecheck:core (rc=0).
2026-06-27 02:38:29 -03:00
diegosouzapw
9fc84f4ea1 fix(kiro): retire claude-sonnet-4.5 from catalog + pin 400 model-unavailable test (#5140)
Extracted the real change from #5140 (the bot PR regenerated the entire
freeModelCatalog.data.ts + touched package-lock.json; only the targeted
edits are kept here):
- remove claude-sonnet-4.5 from the Kiro registry entry
- remove the matching kiro free-model catalog row
- pin Kiro's verbatim 400 "Invalid model..." to isModelUnavailableError

Closes #4484
2026-06-27 02:33:36 -03:00
Randi
50e6621101 Fix resilience settings page response mapping (#5139)
Integrated into release/v3.8.38. Thanks @rdself for the fix and the regression test.
2026-06-27 02:22:45 -03:00
Diego Rodrigues de Sa e Souza
3654ae50b3 fix(providers): complete grok-cli OAuth wiring + zenmux-free web-session metadata
Base-red repair for #5020 (grok-cli) and #5105 (zenmux-free), surfaced by the
full CI on the release PR (#5078) — the PR->release fast-path does not run the
oauth-providers-config / web-session-credentials / provider-consistency gates.

- grok-cli: register in OAUTH_PROVIDERS (providers.ts canonical list, fixes
  check:provider-consistency), add OAUTH_PROVIDER_IDS.GROK_CLI + GROK_CLI_CONFIG
  in oauth constants (provider config now sourced there, not a local literal),
  align oauth-providers-config.test.ts (EXPECTED_PROVIDER_KEYS + config map).
- zenmux-free: declare its web-session credential requirement (full Cookie header)
  in WEB_SESSION_CREDENTIAL_REQUIREMENTS.

Local: oauth-providers-config 27/27, web-session-credentials 4/4, grok-cli-oauth
7/7, check:provider-consistency OK, +115 OAUTH_PROVIDERS tests green.
2026-06-27 01:29:15 -03:00
Ramel Tecnologia - Rafa Martins
58d1311454 feat(sidebar): add support for colored menu icons (#3812)
Integrated into release/v3.8.38 (recreated on tip — fork had unrelated history; added getSidebarIconAccent regression test, Rule #18). Clean 2-file UI feature.
2026-06-27 01:22:22 -03:00
Diego Rodrigues de Sa e Souza
1677ef55e1 fix(proxy): make SOCKS5 handshake timeout tunable via SOCKS_HANDSHAKE_TIMEOUT_MS (#5109) (#5137) 2026-06-27 01:14:38 -03:00
Diego Rodrigues de Sa e Souza
7b718134e0 fix(providers): add MiniMax M3 & Nemotron 3 Ultra to Cline catalog (#3321) (#5136) 2026-06-27 01:13:11 -03:00
Diego Rodrigues de Sa e Souza
454eb75b31 fix(api): resolve /v1/models/{id} case-insensitively (#5082) (#5135) 2026-06-27 01:12:17 -03:00
Diego Rodrigues de Sa e Souza
c4fc33480c fix(services): embed WS proxy honours LIVE_WS_HOST; reject empty messages early (#5110) (#5133) 2026-06-27 00:14:58 -03:00
Diego Rodrigues de Sa e Souza
b8cc4feef1 fix(proxy): repair one-click Deno & Cloudflare relay deployments (#5128) (#5132) 2026-06-27 00:13:55 -03:00
Diego Rodrigues de Sa e Souza
86b8938e2c fix(security): exact-host Anthropic baseUrl check (CodeQL js/incomplete-url-substring-sanitization #674) (#5130)
The anthropic-compatible Bearer-fallback gate decided whether a configured baseUrl
targeted the official api.anthropic.com host via a substring `.includes("api.anthropic.com")`.
A look-alike upstream such as `https://api.anthropic.com.evil.test` or
`https://evil.test/?x=api.anthropic.com` matched the substring and was wrongly treated as
official, suppressing the Bearer fallback meant for third-party gateways
(CodeQL #674, js/incomplete-url-substring-sanitization, high).

Replace the substring test with an exported `isOfficialAnthropicBaseUrl()` helper that
parses the URL and compares the hostname for exact equality. Empty baseUrl stays official;
scheme-less hosts are parsed with an assumed https://; an unparseable baseUrl falls back to
third-party (Bearer emitted) as the safer default. Behavior for legitimate official/third-party
baseUrls is unchanged.

Adds tests/unit/anthropic-official-baseurl-host.test.ts covering official, look-alike,
scheme-less, and unparseable inputs plus a static guard that the substring pattern is gone.
2026-06-26 23:14:17 -03:00
Diego Rodrigues de Sa e Souza
5eaf230da9 chore(quality): reconcile complexity + file-size baselines for v3.8.38 owner-PR batch
complexity 1972->1978 (+6) and file-size providers.ts 1093->1107 / usageHistory.ts
934->983 — drift from the /review-prs merge batch (#4845/#5105/#5020/#4940/#5093/
#5015 + #5121 cookie-dedup helper extraction). check:complexity/check:file-size do
not run on the PR->release fast-path, so the branch accrued unmeasured; all legit
feature/fix growth, not regression. See per-key justifications in each baseline.
2026-06-26 22:55:40 -03:00
Jan Leon
c0830c95ab Hydrate quota cache and scope auto combo candidates (#5015)
Integrated into release/v3.8.38 (rebased on tip). Kept core quota-cache hydration + auto-combo candidate scoping + combos UI; dropped out-of-scope toolCloaking refactor (conflicted with #4813 stripEnumDescriptions — took tip) and the unrelated sse-auth test split. Added quota-cache-hydrate-5015 regression test (Rule #18); combo-account-allowlist 8/8 + hydration 2/2 green.
2026-06-26 22:51:25 -03:00
KooshaPari
09ac615894 fix(resilience): harden quota and model lockout edge cases (#5093)
Integrated into release/v3.8.38 (rebased on tip). TRUST-BUT-VERIFY: dropped the PR's 0dd7df641 'fix unit gates' commit which reverted #5122 reasoning-replay (preserveReasoningContent) + re-introduced #4849 O(n^2) growth, and restored 5 tests it had realigned. Kept only the 3 declared resilience fixes (quota cutoff guard, gemini MIME, model-lockout maxCooldownMs); 23/23 green.
2026-06-26 22:46:19 -03:00
Diego Rodrigues de Sa e Souza
9fd630c34b chore(test): reconcile golden snapshot + apikey count for new providers
#5020 (grok-cli), #5065 (factory), #5105 (zenmux-free) added providers but did
not regenerate tests/snapshots/provider/translate-path.json (now +3 entries) nor
bump the APIKEY_PROVIDERS count (159->160 for the factory gateway). Test-only
reconciliation; no production change.
2026-06-26 22:44:43 -03:00
KooshaPari
004c0c4fd1 feat(providers): add Factory (factory.ai) as a subscription gateway provider (#5065)
Integrated into release/v3.8.38 (rebased on tip; added factory registry test for PR Test Policy + fixed check:env-doc-sync phantom FACTORY_API_KEY; factory loads in PROVIDERS, no Zod issue — that flag was a false positive)
2026-06-26 22:36:03 -03:00
fulorgnas
2eefe2f76d feat: Add Grok Build (xAI) provider with OAuth import-token flow (#5020)
Integrated into release/v3.8.38 (rebased on tip; Hard Rule #11 fix — Grok public client_id now via resolvePublicCred(grok_id), 3 literals removed; grok-oauth 7/7 + check:public-creds green)
2026-06-26 22:31:40 -03:00
Randi
9e2c2817da fix(streaming): harden long OpenAI-compatible SSE streams (#5124)
Integrated into release/v3.8.38 (rebased on tip; streamHandler conflict with #5007 disconnect-guard resolved — both coexist, stream-handler 22/22 green)
2026-06-26 22:27:11 -03:00
dependabot[bot]
843680f6ca chore(deps): bump actions/cache from 5.0.5 to 6.0.0 (#5112)
Integrated into release/v3.8.38 (retargeted main→release; workflow-only actions/cache bump — unit failures were stale main base-reds)
2026-06-26 22:24:23 -03:00
R. Beltran
5f9bf378e7 fix(dashboard): key model visibility toggle on canonical providerId (#5091)
Integrated into release/v3.8.38 (retargeted main→release; .tsx visibility-key test green 2/2)
2026-06-26 22:24:16 -03:00
Diego Rodrigues de Sa e Souza
4bdbaa0062 fix(usage): dedupe request-usage logging and debounce stats (#4940)
Integrated into release/v3.8.38 (rebased on tip; DB-handle hang was stale-base artifact — resetDbInstance already closes the handle, test green 5/5; file-size drift consolidated at release; CHANGELOG re-injected)
2026-06-26 22:22:00 -03:00
Diego Rodrigues de Sa e Souza
57bed419c4 feat(mcp): web-session robustness — cookie dedup (PR6) + browser-pool observability (PR7) (#3368) (#5121)
Integrated into release/v3.8.38 (rebased on tip; cookie-dedup branch extracted to findExistingCookieConnection helper → complexity-neutral; CHANGELOG added)
2026-06-26 22:20:09 -03:00
Diego Rodrigues de Sa e Souza
594aa94945 feat(dashboard): click-to-edit model alias in provider page (#5119)
Integrated into release/v3.8.38 (rebased on tip, i18n scope verified, CHANGELOG re-injected)
2026-06-26 22:09:29 -03:00
Diego Rodrigues de Sa e Souza
c9fe0fb7a1 feat(providers): add ZenMux Free session-cookie provider (#5105)
Integrated into release/v3.8.38 (rebased on tip, CHANGELOG re-injected)
2026-06-26 22:08:28 -03:00
Diego Rodrigues de Sa e Souza
2b0e1dfd94 fix(sse): convert Gemini body to OpenAI format in antigravity MITM handler (#4845)
Integrated into release/v3.8.38 (rebased on tip, CHANGELOG re-injected)
2026-06-26 22:07:04 -03:00
Jan Leon
25b65ac34a Track final connection IDs in failover logs (#5016)
Integrated into release/v3.8.38
2026-06-26 22:04:39 -03:00
Jan Leon
b8070ed3a8 Ignore disconnect races during in-band stream error handling (#5007)
Integrated into release/v3.8.38
2026-06-26 22:04:33 -03:00
Diego Rodrigues de Sa e Souza
d6f402e0ad test(golden): redact OmniRoute app version from translate-path snapshot (#5126)
Coverage shard golden unblock for #5078.
2026-06-26 20:31:41 -03:00
Diego Rodrigues de Sa e Souza
fe8f1dac0f test(golden): redact live Node version from provider translate-path snapshot (#5125)
Final golden unblock for #5078.
2026-06-26 20:02:33 -03:00
Diego Rodrigues de Sa e Souza
58d942829f fix(translator): preserve reasoning-replay reasoning_content + repair 3 release-green test reds (#5122)
Repairs 3 release-green test reds + test-masking; unblocks #5078.
2026-06-26 19:12:46 -03:00
Diego Rodrigues de Sa e Souza
05071c011c test(release): relocate 2 orphan test files into the collected flat tests/unit dir (#5120)
Unblocks Lint (test-discovery) on #5078. Integrated into release/v3.8.38.
2026-06-26 17:26:40 -03:00
Diego Rodrigues de Sa e Souza
d1a3aa8909 feat(quota): add opt-in Codex/Claude auto-ping keepalive (#5102)
Base-reds fixed (#5117); auto-ping keepalive + file-size rebaseline. Integrated into release/v3.8.38.
2026-06-26 17:20:26 -03:00
Diego Rodrigues de Sa e Souza
68aeb89327 feat(dashboard): expose Fusion judgeModel + fusionTuning in the combo editor (#5074)
Base-reds fixed (#5117); Fusion editor + file-size rebaseline. Integrated into release/v3.8.38.
2026-06-26 17:15:38 -03:00
Diego Rodrigues de Sa e Souza
cfaba4410f fix(responses): default text.format for openai-compatible responses providers (#5101)
Base-reds fixed (#5117); default text.format + file-size rebaseline. Integrated into release/v3.8.38.
2026-06-26 17:13:19 -03:00
Diego Rodrigues de Sa e Souza
135b5daaa5 fix(translator): forward image tool_result blocks as image_url (#5100)
Base-reds fixed (#5117); image tool_result→image_url. Integrated into release/v3.8.38.
2026-06-26 17:11:15 -03:00
Diego Rodrigues de Sa e Souza
2b55659481 chore(quality): rebaseline file-size for responseSanitizer (#5117) + AddApiKeyModal drift 2026-06-26 16:47:07 -03:00
Diego Rodrigues de Sa e Souza
800b04ad6d fix(sse): repair release base-reds — malformed-response false positives + header casing + stale tests (#5117)
Repairs the release/v3.8.38 base-reds; unblocks #5078.
2026-06-26 16:30:51 -03:00
Diego Rodrigues de Sa e Souza
d486ed1643 fix(db): translate pt-BR SQLite driver-fallback log lines to English (#5103) (#5115) 2026-06-26 16:15:13 -03:00
Diego Rodrigues de Sa e Souza
db68c9b790 fix(diagnostics): treat non-streaming Claude messages shape as valid output (#5108) (#5116) 2026-06-26 16:14:14 -03:00
Diego Rodrigues de Sa e Souza
11cb8f0976 feat(providers): allow local/private provider URLs by default with scoped metadata-safe guard (#5066) (#5107) 2026-06-26 14:10:09 -03:00
Diego Rodrigues de Sa e Souza
cc6741dd8c fix(dashboard): surface detailed credential-validation error in add-connection modal (#5088) (#5106) 2026-06-26 14:09:41 -03:00
Diego Rodrigues de Sa e Souza
5315e73be5 fix(combo): fail over on empty-content 502 instead of exhausting the provider (#5085) (#5104) 2026-06-26 14:08:17 -03:00
Diego Rodrigues de Sa e Souza
721598fe02 feat(compression): compression playground (Play + Compare tabs) in the studio (#5080)
Integrated into release/v3.8.38
2026-06-26 13:10:06 -03:00
Diego Rodrigues de Sa e Souza
964ed04d3b chore(quality): rebaseline file-size for leva 5 PR batch drift
6 frozen files grew from merged leva-5 PRs (cursor #4912, kiro #4911,
videoGeneration #5051, default #4727, base #4846, chat #5064); all covered
by per-PR tests. See _rebaseline_2026_06_26_leva5 in the baseline.
2026-06-26 11:22:45 -03:00
Jan Leon
7b6a9ed33d Harden selected API error responses (#5032)
Integrated into release/v3.8.38 (leva 5)
2026-06-26 11:20:18 -03:00
Randi
f7e5d2c268 feat(cc): add summarized thinking display toggle (#5055)
Integrated into release/v3.8.38 (leva 5)
2026-06-26 11:18:22 -03:00
fulorgnas
ef99a3c5ba fix(compression): replace fileURLToPath(import.meta.url) with runtime anchors for standalone bundle (#5089)
Integrated into release/v3.8.38 (leva 5)
2026-06-26 11:16:28 -03:00
Hernan Javier Ardila Sanchez
57d10ea9f7 fix(models): derive model discovery config from registry modelsUrl (#5087)
Integrated into release/v3.8.38 (leva 5)
2026-06-26 11:14:53 -03:00
Hernan Javier Ardila Sanchez
7256d34a23 fix: preserve model hidden flags (isHidden) across model sync (#5086)
Integrated into release/v3.8.38 (leva 5)
2026-06-26 11:13:24 -03:00
José Victor Ferreira
14eab57ed3 feat(video): add Alibaba DashScope (wan2.7-t2v) provider (#5051)
Integrated into release/v3.8.38 (leva 5)
2026-06-26 11:10:33 -03:00
Éder Costa
98c5fac3ad fix(sse): robust Anthropic /v1/messages streaming — real ping keepalive + client-disconnect guard (#5063)
Integrated into release/v3.8.38 (leva 5)
2026-06-26 11:09:12 -03:00
Éder Costa
1b041d8acc fix(sse): don't cool down the connection on a self-inflicted upstream timeout (504) (#5064)
Integrated into release/v3.8.38 (leva 5)
2026-06-26 11:06:45 -03:00
Diego Rodrigues de Sa e Souza
a835192a9c fix(tts): resolve Gemini TTS models from catalog (#4934)
Integrated into release/v3.8.38 (leva 5)
2026-06-26 11:04:16 -03:00
Diego Rodrigues de Sa e Souza
c5c8db77da fix(translator): preserve cache_control for DashScope OpenAI-compat providers (port from 9router#2069) (#5013)
Integrated into release/v3.8.38 (leva 5)
2026-06-26 11:00:39 -03:00
Diego Rodrigues de Sa e Souza
ac8bd72e91 fix(oauth): support Kiro IDC (organization) token import (#4944)
Integrated into release/v3.8.38 (leva 5)
2026-06-26 10:58:50 -03:00
Diego Rodrigues de Sa e Souza
abcf593213 feat(proxy): auth-less host:port batch import (#4938)
Integrated into release/v3.8.38 (leva 5)
2026-06-26 10:57:52 -03:00
Diego Rodrigues de Sa e Souza
2ed7d532f9 feat(cursor): parse Composer DeepSeek-style inline tool calls (#4912)
Integrated into release/v3.8.38 (leva 5)
2026-06-26 10:57:19 -03:00
Diego Rodrigues de Sa e Souza
2dbd3e933a feat(sse): Kiro inline <thinking> stream splitter (#4911)
Integrated into release/v3.8.38 (leva 5)
2026-06-26 10:56:54 -03:00
Diego Rodrigues de Sa e Souza
f52863c80a fix(sse): dedupe case-variant Anthropic version/beta headers (#4846)
Integrated into release/v3.8.38 (leva 5)
2026-06-26 10:55:21 -03:00
Diego Rodrigues de Sa e Souza
f8a17c4126 feat(blackbox): refresh provider model catalog (#4935)
Integrated into release/v3.8.38 (leva 5)
2026-06-26 10:53:27 -03:00
Diego Rodrigues de Sa e Souza
63a5bc9413 fix(sse): guard non-JSON SSE lines and duplicate [DONE] (#4937)
Integrated into release/v3.8.38 (leva 5)
2026-06-26 10:52:54 -03:00
Diego Rodrigues de Sa e Souza
eb712d2966 fix(sse): keep streaming for forceStream providers when client requests JSON (#5021)
Integrated into release/v3.8.38 (leva 5)
2026-06-26 10:52:11 -03:00
Diego Rodrigues de Sa e Souza
36c62c7a38 fix(codebuddy): only send reasoning params when client requests reasoning (#5019)
Integrated into release/v3.8.38 (leva 5)
2026-06-26 10:51:28 -03:00
Diego Rodrigues de Sa e Souza
7bda901ce7 fix(executors): strip client_metadata for cerebras and mistral (#4727)
Integrated into release/v3.8.38 (leva 5)
2026-06-26 10:49:58 -03:00
Diego Rodrigues de Sa e Souza
66d78772e6 chore(release): open v3.8.38 development cycle 2026-06-26 04:08:40 -03:00
5568 changed files with 157422 additions and 755447 deletions

View File

@@ -125,4 +125,3 @@ app.__qa_backup/
.worktrees
.next-playwright/
cloud/
electron/dist-electron

View File

@@ -1,12 +0,0 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.sh]
indent_size = 4

View File

@@ -40,11 +40,6 @@ INITIAL_PASSWORD=CHANGEME
# also if you want to share the same database as "npm run dev" use "./data"
# DATA_DIR=/var/lib/omniroute
# Fallback alias for DATA_DIR, checked only when DATA_DIR is unset.
# Used by: open-sse/executors/promptql/threadSticky.ts — locates the PromptQL
# executor's on-disk thread-sticky session cache. Leave unset to rely on DATA_DIR.
# OMNIROUTE_DATA_DIR=/var/lib/omniroute
# Encryption key for SQLite database encryption at rest.
# Used by: src/lib/db/encryption.ts — encrypts the entire SQLite database.
# Generate: openssl rand -hex 32 | Leave empty to disable DB encryption.
@@ -78,33 +73,16 @@ DISABLE_SQLITE_AUTO_BACKUP=false
# Default: 20128
PORT=20128
# Base path (URL subpath) when serving OmniRoute behind a reverse proxy under a subpath.
# Used by: next.config.mjs — sets Next.js `basePath`; auth redirects are basePath-aware.
# Default: "" (served at the domain root). Example: /omniroute to serve under https://host/omniroute
# OMNIROUTE_BASE_PATH=
# Split-port mode: serve Dashboard and API on separate ports for network isolation.
# Used by: src/lib/runtime/ports.ts — overrides PORT for each service.
# API_PORT=20129
# API_HOST=0.0.0.0
# DASHBOARD_PORT=20128
# Connection backpressure: cap concurrent in-flight chat connections (503 + Retry-After when full).
# Used by: src/sse/utils/backpressure.ts — disabled when unset/0.
# OMNI_MAX_CONCURRENT_CONNECTIONS=0
# Optional OmniRoute-to-OmniRoute peer chaining guard. Give every instance a
# unique ID and allowlist only the other OmniRoute base URLs it may call.
# Requests to allowlisted peers carry X-OmniRoute-Peer-Trace; repeated instances
# and exhausted hop budgets are rejected with HTTP 508 before provider routing.
# OMNIROUTE_INSTANCE_ID=gateway-a
# OMNIROUTE_PEER_URLS=http://gateway-b:20128/v1
# OMNIROUTE_PEER_MAX_HOPS=4
# Port for the real-time WebSocket live monitoring server.
# Used by: src/server/ws/liveServer.ts, src/app/api/v1/ws/route.ts
# Default: 20132
# LIVE_WS_PORT=20132
# Default: 20129
# LIVE_WS_PORT=20129
# Bind address for the live WebSocket server.
# Default: 127.0.0.1 (loopback only). Set to 0.0.0.0 to expose on LAN —
@@ -114,29 +92,11 @@ PORT=20128
# Comma-separated extra origins allowed to open a live WebSocket. The
# loopback dashboard origins are already permitted by default; use this
# var when fronting the server with a domain (e.g. https://omni.local).
# ⚠️ When using NEXT_PUBLIC_LIVE_WS_PUBLIC_URL or exposing the WS server
# beyond loopback, this MUST include the public origin(s) — otherwise
# the Origin allow-list check will reject all browser connections.
# Example: LIVE_WS_ALLOWED_ORIGINS=https://omni.local,https://dashboard.example.com,https://ws.my-ai.com
# LIVE_WS_ALLOWED_ORIGINS=https://omni.local,https://dashboard.example.com
# Comma-separated extra hostnames allowed to open a live WebSocket (LAN/Tailscale).
# Unlike LIVE_WS_ALLOWED_ORIGINS (which matches full origin URLs), this matches
# only the host portion — useful for wildcard-ish LAN/Tailscale setups.
# Used by: src/server/ws/liveServerAllowList.ts
# Example: LIVE_WS_ALLOWED_HOSTS=omni.local,tailscale-host,192.168.1.50
# LIVE_WS_ALLOWED_HOSTS=omni.local,tailscale-host,192.168.1.50
# Public URL for the live dashboard WebSocket (client-side, browser only).
# Set this when fronting the WS server with a reverse proxy or Cloudflare Tunnel.
# The browser will connect to this URL instead of ws://hostname:20132.
# The path portion of this URL (e.g. ws://localhost:20132/live-ws -> /live-ws) is also used by the dev proxy
# (scripts/dev/standalone-server-ws.mjs) and the handshake response to route
# WebSocket upgrades. Default path: /live-ws.
# Used by: src/hooks/useLiveDashboard.ts, src/app/api/v1/ws/route.ts,
# scripts/dev/standalone-server-ws.mjs, and scripts/start-ws-server.mjs.
# Example: NEXT_PUBLIC_LIVE_WS_PUBLIC_URL=wss://ws.my-ai.com/live-ws
# NEXT_PUBLIC_LIVE_WS_PUBLIC_URL=ws://localhost:20132/live-ws
# Disable the standalone live WebSocket helper used by scripts/start-ws-server.mjs.
# Used by: scripts/start-ws-server.mjs (CI/embedded harness toggle).
# OMNIROUTE_DISABLE_LIVE_WS=0
# Enable the real-time dashboard WebSocket server.
# Used by: src/server/ws/liveServer.ts, scripts/start-ws-server.mjs
@@ -151,19 +111,13 @@ PORT=20128
# Used by: src/app/api/v1/relay/chat/completions/route.ts
# RELAY_IP_PER_MINUTE=30
# Bundler selection for `npm run dev`. Set to 0 to fall back to webpack.
# Default is 1 (Turbopack). PR #4092 had forced webpack because earlier
# Turbopack 16.2.x panicked on the OmniRoute module graph with "internal error:
# entered unreachable code: there must be a path to a root"
# (turbopack-core/module_graph/mod.rs:662). That panic no longer reproduces on
# the pinned Next 16.2.9 — verified across a broad cold-compile sweep (36
# dashboard routes + open-sse-heavy API routes incl. /api/v1/chat/completions,
# /api/v1/models, /api/mcp) and repeated HMR rebuilds: zero panics. Turbopack
# also keeps dev memory far lower on the edit→rebuild loop (HMR rebuild RSS stays
# ~flat vs webpack's monotonic growth), which mitigates the dev-server OOM on
# this 60+ route app. The production build still uses webpack (build pipeline is
# unaffected by this dev-only flag).
OMNIROUTE_USE_TURBOPACK=1
# Bundler selection for `npm run dev`. Set to 1 to opt into Turbopack.
# Default is 0 (webpack) because Turbopack 16.2.x panics on the OmniRoute
# module graph with "internal error: entered unreachable code: there must be
# a path to a root" (turbopack-core/module_graph/mod.rs:662). Same bug class
# the production Docker build worked around in PR #4052. Webpack starts
# slower but compiles cleanly. Re-enable once upstream Turbopack ships a fix.
OMNIROUTE_USE_TURBOPACK=0
# Skip the SQLite integrity health check on startup (faster boot on large DBs).
# Used by: src/lib/db/core.ts, src/lib/db/healthCheck.ts. Set to 1 to skip.
@@ -189,12 +143,6 @@ OMNIROUTE_USE_TURBOPACK=1
# hints in production logs.
# OMNIROUTE_PROXY_FETCH_DEBUG=true
# Set to any non-empty value to emit `[omniroute completion]` diagnostics from
# the CLI shell-completion cache paths (read/refresh/write) in
# bin/cli/commands/completion.mjs. Off by default — these caches fail silently
# so a missing/corrupt cache never breaks tab-completion.
# OMNIROUTE_DEBUG_COMPLETION=1
# Docker production port mappings (docker-compose.prod.yml only).
# These set the HOST-side published ports. Container ports use PORT/API_PORT.
# PROD_DASHBOARD_PORT=20130
@@ -208,13 +156,8 @@ OMNIROUTE_USE_TURBOPACK=1
# Hostname/bind address for the Next.js server.
# Used by: scripts/dev/run-next.mjs (HOST), Playwright runner (HOSTNAME).
# Default: 0.0.0.0 (HOST) / 127.0.0.1 (HOSTNAME inside tests).
# NOTE: Do NOT use `HOSTNAME` — it is a POSIX shell variable automatically set to
# the machine name by bash/zsh. The .env loader cannot override it (first-wins
# semantics). Use OMNIROUTE_SERVER_HOST instead for `omniroute serve`.
# See: https://github.com/diegosouzapw/OmniRoute/issues/6194
# HOST=0.0.0.0
# HOSTNAME=127.0.0.1
# OMNIROUTE_SERVER_HOST=0.0.0.0
#HOST=0.0.0.0
#HOSTNAME=127.0.0.1
# Environment mode — affects Next.js behavior, logging verbosity, and caching.
# Values: production | development | Default: production
@@ -226,17 +169,6 @@ NODE_ENV=production
# gives the correct fix instructions (podman unshare chown vs sudo chown).
CONTAINER_HOST=docker
# Container runtime override for skill sandboxing.
# Used by: src/lib/skills/sandbox.ts + src/lib/skills/containerProvider.ts
# Values: auto | docker | apple | wsl | orbstack | podman
# - auto: OS-aware auto-detect (apple/orbstack on macOS, wsl on Windows, podman on Linux)
# - apple: Apple Container (native OCI on macOS 26+)
# - wsl: WSL Container CLI (wslc.exe on Windows)
# - orbstack: OrbStack (high-perf Linux VM + docker shim on macOS)
# - podman: Podman (rootless, daemonless)
# - docker: Docker (default fallback)
# (defined under SKILLS & SANDBOXING section below)
# ═══════════════════════════════════════════════════════════════════════════════
# 4. SECURITY & AUTHENTICATION
# ═══════════════════════════════════════════════════════════════════════════════
@@ -301,40 +233,10 @@ ALLOW_API_KEY_REVEAL=false
# Default: 10485760 (10 MB)
# MAX_BODY_SIZE_BYTES=10485760
# Atomic admission for POST /v1/chat/completions (#7846). Large request bodies
# amplify into multiple transient representations during parsing, compression, and
# provider dispatch. Heavyweight capacity is reserved before parsing; excess work
# receives 503 + Retry-After instead of overlapping until the process OOMs.
# Used by: src/shared/middleware/chatBodyAdmission.ts
# Actual bodies at or above this size require a heavyweight lease. Default 262144 (256 KB).
# OMNIROUTE_CHAT_LARGE_BODY_BYTES=262144
# Actual-byte hard cap enforced during bounded ingestion. Default 52428800 (50 MB).
# OMNIROUTE_CHAT_HARD_MAX_BODY_BYTES=52428800
# Maximum heavyweight requests simultaneously admitted in one process. Default 1.
# OMNIROUTE_CHAT_MAX_HEAVY_IN_FLIGHT=1
# Message count that classifies an otherwise small body as heavyweight. Default 200.
# OMNIROUTE_CHAT_HEAVY_MESSAGE_COUNT=200
# Tool count that classifies an otherwise small body as heavyweight. Default 64.
# OMNIROUTE_CHAT_HEAVY_TOOL_COUNT=64
# Conservative string-size token estimate that classifies a request as heavyweight. Default 32000.
# OMNIROUTE_CHAT_HEAVY_ESTIMATED_TOKENS=32000
# Hard message-count cap; excess receives compact-required 413. Default 800.
# OMNIROUTE_CHAT_HARD_MAX_MESSAGES=800
# Hard cap (bytes) for a non-streaming upstream response buffered fully into memory
# (#5152). Past this the upstream reader is cancelled and the request fails fast
# instead of growing an unbounded string until the V8 heap is exhausted.
# Used by: open-sse/handlers/chatCore/nonStreamingResponseBody.ts
# Default: 67108864 (64 MB)
# OMNIROUTE_MAX_NONSTREAMING_RESPONSE_BYTES=67108864
# CORS configuration — controls which cross-origin browser clients can call the API.
# Used by: src/server/cors/origins.ts — sets Access-Control-Allow-Origin.
# Same-origin dashboard requests behind a reverse proxy do not need CORS; they
# use session-bound CSRF protection. No wildcard is sent unless CORS_ALLOW_ALL=true.
# CORS_ALLOWED_ORIGINS=https://your-frontend.example.com
# CORS_ORIGIN=https://your-frontend.example.com # legacy single-origin alias
# CORS_ALLOW_ALL=false
# CORS configuration — controls which origins can call the API.
# Used by: Next.js middleware — sets Access-Control-Allow-Origin header.
# Default: * (all origins) | Restrict for production security.
# CORS_ORIGIN=https://your-domain.com
# Allow provider URLs pointing to private/local networks (localhost, 192.168.x.x, etc.).
# REQUIRED for self-hosted providers: LM Studio, Ollama, vLLM, Llamafile, Triton, etc.
@@ -360,24 +262,16 @@ ALLOW_API_KEY_REVEAL=false
# ── Request-Side: Prompt Injection Guard ──
# Scans incoming messages for prompt injection patterns before routing.
# Used by: src/middleware/promptInjectionGuard.ts
# Default ON when unset. Set to false/0/no/off to disable. Truthy: true/1/yes/on.
# INPUT_SANITIZER_ENABLED=false
# INPUT_SANITIZER_MODE=warn # warn = log only | block = reject request | redact = legacy (does NOT strip injection; use PII_REDACTION_ENABLED for request PII)
# INPUT_SANITIZER_BLOCK_THRESHOLD=high # high (default) | medium | low — severities at/above this are blocked in block mode
# INPUT_SANITIZER_ENABLED=true
# INPUT_SANITIZER_MODE=warn # warn = log only | block = reject request | redact = strip patterns
# Legacy aliases for INPUT_SANITIZER_MODE / INPUT_SANITIZER_BLOCK_THRESHOLD (same effect).
# Legacy alias for INPUT_SANITIZER_MODE (same effect).
# INJECTION_GUARD_MODE=warn
# INJECTION_GUARD_BLOCK_THRESHOLD=high
# PII detection in incoming requests (emails, phone numbers, SSNs, etc.).
# Used by: src/middleware/promptInjectionGuard.ts — extends injection guard.
# PII_REDACTION_ENABLED=false
# Redacts well-known API-key / secret-token patterns (OpenAI, Anthropic, GitHub,
# Slack, etc.) from request/response payloads before they reach providers/clients.
# Opt-in; mirrors PII_REDACTION_ENABLED. Used by: src/lib/guardrails/credentialMasker.ts.
# CREDENTIAL_REDACTION_ENABLED=false
# Minimum streaming window size for PII detection (bytes). Default: 200.
# Used by: src/lib/streamingPiiTransform.ts.
# PII_WINDOW_SIZE=200
@@ -432,7 +326,6 @@ ALLOW_API_KEY_REVEAL=false
# URLs used for internal sync jobs, OAuth callbacks, and cloud relay.
# Internal base URL — used by server-side sync jobs to call /api/sync/cloud.
# Keep this as a loopback/container URL even when the app is publicly proxied.
# Used by: src/lib/cloudSync.ts, src/lib/initCloudSync.ts
# Default: http://localhost:20128
BASE_URL=http://localhost:20128
@@ -446,39 +339,27 @@ CLOUD_URL=
# Default: 12000 (12 seconds)
# CLOUD_SYNC_TIMEOUT_MS=12000
# Public-facing base URL — required for stable reverse proxy / OAuth callback setups.
# Used by: OAuth redirect_uri computation, Dashboard UI links, and generated public URLs.
# Set to your stable public URL when OAuth callbacks or generated browser links need a
# canonical host behind nginx/Caddy (e.g., https://omniroute.example.com).
# Public-facing base URL — CRITICAL for reverse proxy / OAuth callback setups.
# Used by: OAuth redirect_uri computation, Dashboard UI links, cloud/model sync.
# Set to your public URL when behind nginx/Caddy (e.g., https://omniroute.example.com).
#
# Dashboard display behavior: when this variable is unset, the dashboard
# auto-detects the base URL shown in curl examples and CLI tool snippets
# from window.location.origin (the host the user is browsing). Setting it
# explicitly is only required when running behind a reverse proxy with a
# different public hostname, or when OAuth callbacks / generated browser links must point
# to a canonical URL. Authenticated dashboard writes use same-origin requests plus
# session-bound CSRF protection and do not require a static public base URL.
# different public hostname, or when OAuth callbacks must point to a
# canonical URL.
#
# Default: http://localhost:20128
NEXT_PUBLIC_BASE_URL=http://localhost:20128
# Browser-facing OmniRoute origin for generated assets in API responses.
# Highest-priority public origin override; also used by non-dashboard public-origin validation.
# Used by: chatgpt-web image generation cache URLs (/v1/chatgpt-web/image/<id>).
# Set this when OpenWebUI or another relay reaches OmniRoute by an internal URL
# but the user's browser must fetch images from a LAN, tunnel, or public origin.
# Do not include /v1; if included accidentally it will be normalized away.
# OMNIROUTE_PUBLIC_BASE_URL=http://192.168.0.15:20128
# Absolute provider plugin manifest URL advertised to sidecar clients.
# Used by: open-sse/config/providerPluginManifestUrl.ts. When unset, OmniRoute
# derives the URL from request origin or HOST/PORT using OMNIROUTE_PUBLIC_PROTOCOL.
# OMNIROUTE_PROVIDER_MANIFEST_URL=https://omniroute.example.com/api/v1/provider-plugin-manifest
# Protocol used when deriving provider plugin manifest URLs without a request origin.
# Used by: open-sse/config/providerPluginManifestUrl.ts. Defaults to http.
# OMNIROUTE_PUBLIC_PROTOCOL=http
# Max wait time for an async chatgpt-web image to land via the celsius
# WebSocket, in milliseconds. Default 180000 (3 minutes). Increase during
# upstream queue-deep windows ("Lots of people are creating images right now").
@@ -490,30 +371,12 @@ NEXT_PUBLIC_BASE_URL=http://localhost:20128
# is heavy and clients are racing the 30-minute TTL.
# OMNIROUTE_CGPT_WEB_IMAGE_CACHE_MAX_MB=256
# Overall wait budget for a chatgpt-web GPT-5.5 Pro background-poll handoff,
# in milliseconds. Default 1200000 (20 minutes). Pro reasoning runs are slow
# and complete out-of-band, so OmniRoute polls until the answer lands or this
# budget elapses. Raise it if Pro requests time out before finishing.
# OMNIROUTE_CGPT_WEB_PRO_TIMEOUT_MS=1200000
# Interval between chatgpt-web GPT-5.5 Pro background-poll attempts, in
# milliseconds. Default 4000 (4 seconds). Lower for snappier completion at the
# cost of more upstream polling; raise to reduce request volume.
# OMNIROUTE_CGPT_WEB_PRO_POLL_INTERVAL_MS=4000
# Public cloud URL — client-side mirror of CLOUD_URL.
NEXT_PUBLIC_CLOUD_URL=
# Legacy alias — fallback for NEXT_PUBLIC_BASE_URL in sync schedulers.
# NEXT_PUBLIC_APP_URL=http://localhost:20128
# Advanced reverse-proxy trust mode for deriving public origin from Forwarded /
# X-Forwarded-* headers when no explicit public base URL is set. Prefer setting
# NEXT_PUBLIC_BASE_URL. Only enable if direct client access to OmniRoute is blocked
# and your proxy strips/rebuilds incoming forwarded headers.
# Values: true/loopback (trust loopback proxy peers), private/lan (also trust LAN peers).
# OMNIROUTE_TRUST_PROXY=
# Public callback URL for asynchronous image/audio jobs (kie.ai, etc.).
# Used by: open-sse/utils/kieTask.ts — overrides callbackUrlFromBaseUrl().
# Honor order: KIE_CALLBACK_URL → OMNIROUTE_KIE_CALLBACK_URL → OMNIROUTE_PUBLIC_URL.
@@ -531,11 +394,10 @@ NEXT_PUBLIC_CLOUD_URL=
# debugging or when routing through a corporate mirror. Used by:
# open-sse/services/usage.ts.
#OMNIROUTE_CROF_USAGE_URL=https://crof.ai/usage_api/
#OMNIROUTE_GEMINI_CLI_USAGE_URL=https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist
#OMNIROUTE_CODEWHISPERER_BASE_URL=https://codewhisperer.us-east-1.amazonaws.com
#OMNIROUTE_OPENCODE_QUOTA_URL=https://opencode.ai/zen/go/v1/quota
# OpenCode Go has no public quota API — this has no default and stays
# unset unless you explicitly opt in to a self-hosted/mirrored endpoint:
#OMNIROUTE_OPENCODE_GO_QUOTA_URL=
#OMNIROUTE_OPENCODE_GO_QUOTA_URL=https://api.z.ai/api/monitor/usage/quota/limit
#OMNIROUTE_OPENCODE_GO_DASHBOARD_URL=https://opencode.ai/workspace
#OMNIROUTE_OLLAMA_CLOUD_USAGE_URL=https://ollama.com/settings
@@ -547,18 +409,6 @@ NEXT_PUBLIC_CLOUD_URL=
#OPENCODE_GO_AUTH_COOKIE=auth=...
#OMNIROUTE_OPENCODE_GO_AUTH_COOKIE=auth=...
# OpenCode Go/Zen VPS egress (#5997): on a datacenter VPS, Cloudflare in front of
# opencode.ai/zen/go 403s chat requests that lack OpenCode CLI identity headers.
# When your clients don't already send them, set this to synthesize the CLI headers
# (User-Agent, x-opencode-client, x-opencode-project, fresh request/session UUIDs) on
# absent keys. OFF by default — forward-only is safer when clients already send them.
# Values are overridable via OPENCODE_GO_USER_AGENT / OPENCODE_USER_AGENT / OPENCODE_CLIENT /
# OPENCODE_PROJECT (defaults: opencode-cli/1.0.0 / cli / default).
#OPENCODE_SYNTHESIZE_CLI_HEADERS=true
#OPENCODE_USER_AGENT=opencode-cli/1.0.0
#OPENCODE_CLIENT=cli
#OPENCODE_PROJECT=default
# Ollama Cloud quota scraping. Prefer configuring this per connection in
# Dashboard → Providers → Ollama Cloud. The cookie is sensitive.
#OLLAMA_USAGE_COOKIE=__Secure-session=...
@@ -630,14 +480,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Allow OmniRoute to write CLI config files (token refresh, etc.).
# CLI_ALLOW_CONFIG_WRITES=true
# Auto-sync CLI profile files after provider model discovery changes. OPT-IN, default OFF for
# both. When enabled, writes only the tool's profile files (~/.codex/*.config.toml or
# ~/.claude/profiles/<name>/settings.json); never changes the active/default config. Both also
# require CLI_ALLOW_CONFIG_WRITES (default on). Toggle from the CLI Code dashboard, or set here.
# Leave unset to disable. (Feature flags — a DB/dashboard override takes precedence over env.)
# OMNIROUTE_AUTO_SYNC_CODEX_PROFILES=true
# OMNIROUTE_AUTO_SYNC_CLAUDE_PROFILES=true
# Override binary paths for individual CLI tools.
# CLI_CLAUDE_BIN=claude
# CLI_CODEX_BIN=codex
@@ -648,8 +490,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# CLI_CONTINUE_BIN=cn
# CLI_QODER_BIN=qoder
# CLI_QWEN_BIN=qwen
# CLI_AUGGIE_BIN=auggie
# AUGGIE_BIN=auggie
# Override the Hermes Agent home directory (where OmniRoute reads/writes the
# Hermes CLI config). Matches the env var the Hermes PowerShell installer sets
@@ -677,15 +517,6 @@ NEXT_PUBLIC_ENABLE_SOCKS5_PROXY=true
# Legacy alias for OMNIROUTE_API_KEY.
# ROUTER_API_KEY=
# Enable the offline/local Issue Agent recorded-triage endpoint.
# Used by: src/app/api/issue-agent/runs/route.ts. Default: disabled.
# OMNIROUTE_ISSUE_AGENT_ENABLED=false
# Timeout (ms) for a single Issue Agent recorded-triage run. Clamped to an internal
# maximum; falls back to the built-in default when unset or invalid.
# Used by: src/lib/issueAgent/execution.ts.
# OMNIROUTE_ISSUE_AGENT_TIMEOUT_MS=
# CLI remote-mode context/profile for `omniroute` commands (overrides the active
# context in the local contexts store). Equivalent to the `--context <name>` flag.
# Used by: bin/cli/program.mjs, bin/cli/api.mjs (remote mode).
@@ -726,14 +557,6 @@ PROVIDER_LIMITS_SYNC_INTERVAL_MINUTES=70
# to opt out (restores fully concurrent fetches). Default: 1500
PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# Min interval (ms) between consecutive UPSTREAM quota fetches on the per-request
# preflight/monitor path (e.g. Codex /wham/usage), complementing the bulk-sync
# spacing above. Many accounts on one IP fetching quota in the same second can look
# like automation to the upstream and get an OAuth token revoked (#6009). This gate
# serializes genuine network calls (cache hits are unaffected). Set to 0 to disable.
# Default: 250 (clamped 0..5000).
# OMNIROUTE_QUOTA_FETCH_MIN_INTERVAL_MS=250
# Delay (ms) before refreshing provider limits after a real usage event (e.g. a
# completed request). Gives the upstream quota API time to register the consumption
# before the dashboard polls. Default: 5000
@@ -802,25 +625,6 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# Used by: open-sse/services/compression/engines/rtk/filterLoader.ts. Default: 0.
#OMNIROUTE_RTK_TRUST_PROJECT_FILTERS=0
# T02 stacked-pipeline engine circuit-breaker (OPT-IN, default off). When enabled, a compression
# engine that throws repeatedly across requests is skipped (fail-open) for a cooldown.
# Used by: open-sse/services/compression/pipelineEngineBreaker.ts.
#COMPRESSION_PIPELINE_BREAKER_ENABLED=false # master switch (default false)
#COMPRESSION_PIPELINE_BREAKER_THRESHOLD=3 # consecutive failures before the engine opens
#COMPRESSION_PIPELINE_BREAKER_COOLDOWN_MS=30000 # ms the engine stays skipped before a probe
# T08/H8 — CCR retrieval-feedback ramp factor. Each prior retrieval of a stored block raises its
# effective minChars linearly, so frequently-retrieved content is compressed progressively less
# (>= 3 retrievals = never compressed). 1 disables the ramp (binary skip at the threshold only).
# Used by: open-sse/services/compression/engines/ccr/index.ts. Default: 2.
#COMPRESSION_CCR_RETRIEVAL_RAMP_FACTOR=2
# T08/H5 — usage-observed prefix freeze (OPT-IN, default off). When enabled, a system prompt seen
# >= THRESHOLD times is treated as a stable cacheable prefix and preserved from compression even
# for providers the static cache-aware heuristic does not recognize (freeze = preserve, never
# mutates). Used by: open-sse/services/compression/prefixFreeze.ts.
#COMPRESSION_PREFIX_FREEZE_ENABLED=false # master switch (default false)
#COMPRESSION_PREFIX_FREEZE_THRESHOLD=3 # observations before a prefix is frozen
# Skip the postinstall native-runtime warm-up (useful in CI / headless installs). Default: 0.
# Used by: scripts/postinstall.mjs.
#OMNIROUTE_SKIP_POSTINSTALL=0
@@ -854,11 +658,10 @@ PROVIDER_LIMITS_SYNC_SPACING_MS=1500
# Used by: open-sse/executors/antigravity.ts — escape hatch for multi-project setups.
# OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE=0
# Control Antigravity Google One AI credit usage. Used by:
# open-sse/services/antigravityCredits.ts — accepts off, retry, or always.
# off (default): never use credits; retry: use credits once after eligible quota 429;
# always: use credits on the first request (higher account and spend risk).
#ANTIGRAVITY_CREDITS=off
# Adjust how Antigravity advertises remaining credits. Used by:
# open-sse/services/antigravityCredits.ts — accepts forced override strings.
# Default: empty (use upstream-reported credits).
#ANTIGRAVITY_CREDITS=
# Override the path to the Antigravity CLI (agy) token file read by the
# "auto-detect local login" import. Used by:
@@ -899,7 +702,7 @@ CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
# Used by: open-sse/executors/theoldllm.ts. Default: 30000 (30s).
# THEOLDLLM_NAV_TIMEOUT_MS=30000
# ── Gemini / Antigravity / Windsurf (all Google-based) ──
# ── Gemini / Gemini CLI / Antigravity / Windsurf (all Google-based) ──
# These providers ship public OAuth client_id/secret values (or Firebase Web
# keys) embedded in their public CLIs/binaries. Defaults are baked into the
# code via open-sse/utils/publicCreds.ts — leave the env vars unset to use
@@ -908,25 +711,25 @@ CODEX_OAUTH_CLIENT_ID=app_EMoamEEZ73f0CkXaXp7hrann
#
# GEMINI_OAUTH_CLIENT_ID=
# GEMINI_OAUTH_CLIENT_SECRET=
# GEMINI_CLI_OAUTH_CLIENT_ID=
# GEMINI_CLI_OAUTH_CLIENT_SECRET=
# ANTIGRAVITY_OAUTH_CLIENT_ID=
# ANTIGRAVITY_OAUTH_CLIENT_SECRET=
# WINDSURF_FIREBASE_API_KEY=
# ── Qwen (Alibaba) ──
QWEN_OAUTH_CLIENT_ID=f0304373b74a44d2b584a3fb70ca9e56
# ── Kimi Coding (Moonshot) ──
KIMI_CODING_OAUTH_CLIENT_ID=17e5f671-d194-4dfb-9706-5516cb48c098
# ── GitHub Copilot ──
GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# ── GitHub Enterprise (GHE) Copilot ──
# Optional override for GHE Copilot's OAuth client id. Falls back to the public
# GITHUB_OAUTH_CLIENT_ID default when unset. Used by: src/lib/oauth/constants/oauth.ts.
# GHE_COPILOT_OAUTH_CLIENT_ID=
# ── GitLab Duo ──
# Register an OAuth app at: https://gitlab.com/-/profile/applications
# Set redirect URI to: http://localhost:20128/callback (or your NEXT_PUBLIC_BASE_URL + /callback)
# Required scopes: ai_features, read_user (matches GITLAB_DUO_CONFIG.scope in src/lib/oauth/constants/oauth.ts)
# Required scopes: api, read_user, openid, profile, email
# GITLAB_DUO_OAUTH_CLIENT_ID=***
# GITLAB_DUO_OAUTH_CLIENT_SECRET=*** # optional — PKCE flow does not require a secret
#
@@ -971,8 +774,6 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# QODER_PERSONAL_ACCESS_TOKEN=
# QODER_CLI_WORKSPACE=
# OMNIROUTE_QODER_WORKSPACE=
# Override the Qoder CLI config dir (isolated PAT session, avoids clobbering a browser login).
# QODER_CLI_CONFIG_DIR=
# ── Blackbox Web validated-token override (issue #2252) ──
# Used by: open-sse/executors/blackbox-web.ts. Blackbox `/api/chat` rejects
@@ -999,7 +800,7 @@ GITHUB_OAUTH_CLIENT_ID=Iv1.b507a08c87ecfe98
# VISION_BRIDGE_API_KEY=
# ─────────────────────────────────────────────────────────────────────────────
# ⚠️ GOOGLE OAUTH (Antigravity) & OTHER PROVIDERS — REMOTE SERVERS
# ⚠️ GOOGLE OAUTH (Antigravity, Gemini CLI) & OTHER PROVIDERS — REMOTE SERVERS
# ─────────────────────────────────────────────────────────────────────────────
# The default Client IDs above ONLY work when OmniRoute runs on localhost.
# For remote/VPS hosting (including Docker containers on remote servers):
@@ -1027,7 +828,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.207 (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) —
@@ -1036,8 +837,8 @@ CLAUDE_USER_AGENT="claude-cli/2.1.207 (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.144.1 (Windows 10.0.26200; x64)"
GITHUB_USER_AGENT="GitHubCopilotChat/0.54.0"
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"
# KIRO_VERIFY_FULL_CRC=false # opt-in: full per-frame message CRC validation on the Kiro event stream (debug corrupted streams; prelude CRC + TLS already protect framing)
@@ -1052,11 +853,13 @@ KIRO_USER_AGENT="AWS-SDK-JS/3.0.0 kiro-ide/1.0.0"
# Used by: open-sse/executors/kiro.ts
# KIRO_VERIFY_FULL_CRC=false
QODER_USER_AGENT="Qoder-Cli"
QWEN_USER_AGENT="QwenCode/0.15.11 (linux; x64)"
CURSOR_USER_AGENT="Cursor/3.4"
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.144.1
# 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
@@ -1081,6 +884,8 @@ CURSOR_USER_AGENT="Cursor/3.4"
# CLI_COMPAT_KIMI_CODING=1
# CLI_COMPAT_KILOCODE=1
# CLI_COMPAT_CLINE=1
# CLI_COMPAT_QWEN=1
# Or enable for all providers at once:
# CLI_COMPAT_ALL=1
@@ -1138,22 +943,12 @@ CURSOR_USER_AGENT="Cursor/3.4"
# fallback when FETCH_TIMEOUT_MS is unset. Default: 120000 (2 min).
# OMNIROUTE_DEFAULT_FETCH_TIMEOUT_MS=120000
# ── Firecrawl web-fetch executor ──
# Point at a self-hosted Firecrawl instance (defaults to the public cloud API).
# When set to a non-cloud base URL, the API key becomes optional.
# FIRECRAWL_BASE_URL=https://api.firecrawl.dev
# FIRECRAWL_TIMEOUT_MS=30000 # Per-request timeout (default: 30000 = 30s)
# ── ChatGPT TLS sidecar (Firefox-fingerprinted client) ──
# Used by: open-sse/services/chatgptTlsClient.ts — wire-level timeout for
# the bogdanfinn/tls-client koffi binding and the JS-side grace window
# layered on top of it when the native library is wedged.
# OMNIROUTE_CHATGPT_TLS_TIMEOUT_MS=60000
# OMNIROUTE_CHATGPT_TLS_GRACE_MS=10000
# Max wait for the FIRST streamed byte from the ChatGPT TLS sidecar before the
# request is aborted as a dead stream, in milliseconds. Default 30000 (30s).
# Raise it if upstream cold-starts routinely exceed the window.
# OMNIROUTE_CHATGPT_STREAM_FIRST_BYTE_TIMEOUT_MS=30000
# ── Claude TLS sidecar (Chromium-fingerprinted client) ──
# Used by: open-sse/services/claudeTlsClient.ts — wire-level timeout for
@@ -1206,28 +1001,10 @@ CURSOR_USER_AGENT="Cursor/3.4"
# PIN_DROP_BACKOFF_LEVEL=2
# PIN_DROP_GRACE_MS=20000
# Whether OmniRoute may emit SSE `:` comment lines (e.g. the `: keepalive` heartbeat).
# Some strict OpenAI-compatible clients parse every SSE line as JSON and crash on `:`
# comments. Set to `off` to suppress comment-shaped heartbeats (they become a no-op);
# `data:` heartbeats are unaffected. Default: enabled.
# Used by: open-sse/utils/sseHeartbeat.ts.
# OMNIROUTE_SSE_COMMENTS=off
# ── Stream idle detection ──
# STREAM_IDLE_TIMEOUT_MS=600000 # Max silence between SSE chunks (default: 600000)
# # Extended-thinking models rarely pause >90s.
# STREAM_READINESS_TIMEOUT_MS=80000 # Time to receive the first non-ping SSE event
# STREAM_READINESS_MAX_TIMEOUT_MS=180000 # Cap for adaptive first-event extensions
# # (large/tool-heavy/high-reasoning requests).
# OMNIROUTE_AGENT_GOAL_POLICY_ENABLED=true # Kill-switch for the /goal heuristic below.
# # Set to false to fully disable detection —
# # readiness timeouts and stream recovery are
# # never elevated by request body/headers when off.
# OMNIROUTE_AGENT_GOAL_READINESS_MAX_TIMEOUT_MS=600000 # Auto cap for detected /goal agent runs
# OMNIROUTE_AGENT_GOAL_STREAM_RECOVERY=true # Auto early stream recovery for /goal runs.
# # NOTE: this can only ADD recovery on top of the
# # operator default — it never overrides an explicit
# # STREAM_RECOVERY_ENABLED / DB settings opt-out.
# ── TLS client (wreq-js fingerprint proxy) ──
# TLS_CLIENT_TIMEOUT_MS=600000 # Inherits from FETCH_TIMEOUT_MS by default
@@ -1263,7 +1040,7 @@ CURSOR_USER_AGENT="Cursor/3.4"
APP_LOG_TO_FILE=true
# Path to the application log file.
# Default: <DATA_DIR>/logs/application/app.log (DATA_DIR defaults to ~/.omniroute)
# Default: logs/application/app.log (relative to project root / DATA_DIR)
# APP_LOG_FILE_PATH=logs/application/app.log
# Maximum single log file size before rotation.
@@ -1438,13 +1215,6 @@ APP_LOG_TO_FILE=true
# Default: 86400 (24 hours)
# MODELS_DEV_SYNC_INTERVAL=86400
# Self-correcting context-window reconciler interval in seconds (feature 5004).
# Pins provider-declared windows from /models discovery as auto:discovery overrides
# when they diverge from the catalog. Set to 0 to disable. Never overwrites manual overrides.
# Used by: src/lib/contextWindowResolver.ts
# Default: 86400 (24 hours)
# CONTEXT_WINDOW_RECONCILE_INTERVAL=86400
# ═══════════════════════════════════════════════════════════════════════════════
# 20. PROVIDER-SPECIFIC SETTINGS
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1469,12 +1239,6 @@ APP_LOG_TO_FILE=true
# NANOBANANA_POLL_TIMEOUT_MS=120000 # Max wait for job completion (default: 120s)
# NANOBANANA_POLL_INTERVAL_MS=2500 # Poll frequency (default: 2.5s)
# ── Microsoft Designer Web (Image Generation) ──
# Polling config for the microsoft-designer-web submit-then-poll image job.
# Used by: open-sse/handlers/imageGeneration/providers/designerWeb.ts
# DESIGNER_WEB_POLL_TIMEOUT_MS=60000 # Max wait for job completion (default: 60s)
# DESIGNER_WEB_POLL_INTERVAL_MS=2000 # Poll frequency (default: 2s)
# ── AWS Bedrock (Kiro / Audio) ──
# Region used to construct AWS Bedrock endpoints. Used by:
# src/lib/providers/validation.ts and open-sse/handlers/audioSpeech.ts.
@@ -1562,13 +1326,6 @@ APP_LOG_TO_FILE=true
# CLIPROXYAPI_PORT=5544
# CLIPROXYAPI_CONFIG_DIR=~/.cli-proxy-api
# ── Mux embedded service ──
# Override the port where the embedded Mux (coder/mux) agent-orchestration
# daemon listens. Always bound to 127.0.0.1 — never configurable to 0.0.0.0.
# Rarely needed — defaults to 8322.
# Used by: src/lib/services/bootstrap.ts, src/app/api/services/mux/_lib.ts
# MUX_SERVICE_PORT=8322
# ── Local hostnames (Docker networking) ──
# Comma-separated additional hostnames treated as "local" for provider routing.
# Used by: open-sse/config/providerRegistry.ts — allows Docker service names.
@@ -1583,50 +1340,17 @@ APP_LOG_TO_FILE=true
# Timeout for fast-fail health checks (ms). Default: 2000
# PROXY_FAST_FAIL_TIMEOUT_MS=2000
# Time window (hours) for calculating the average latency of candidate proxies
# in the latency-optimized pool strategy. Default: 3
# Used by: src/lib/db/proxies.ts
# PROXY_LATENCY_WINDOW_HOURS=3
# Health check result cache TTL (ms). Default: 30000 (30s)
# PROXY_HEALTH_CACHE_TTL_MS=30000
# Unhealthy health check result cache TTL (ms). Default: 2000 (2s)
# Keeps transient fast-fail timeouts from poisoning a proxy for the full
# healthy-result cache window under high concurrency.
# PROXY_HEALTH_UNHEALTHY_CACHE_TTL_MS=2000
# Background proxy health scheduler (src/lib/proxyHealth/scheduler.ts).
# Periodically probes every registered proxy and (optionally) removes dead ones.
# Set "false" to disable the scheduler entirely. Default: enabled.
# PROXY_HEALTH_ENABLED=true
# Sweep interval in ms (minimum 60000). Default: 600000 (10min).
# PROXY_HEALTH_INTERVAL_MS=600000
# Reachability probe target for the scheduler and the auto-test endpoint.
# Point it at an internal/self-hosted URL to avoid the public default.
# PROXY_HEALTH_TEST_URL=https://httpbin.org/ip
# Set "true" to let the scheduler auto-remove proxies after repeated failures.
# PROXY_AUTO_REMOVE=false
# Consecutive failures before an auto-remove fires. Default: 3.
# PROXY_AUTO_REMOVE_AFTER=3
# Let automated reachability probes (the scheduler + the "Test All" button) WRITE
# a proxy's status. Default "false": probes are read-only and never deactivate a
# proxy — only the operator sets active/inactive (a flaky probe must not strand an
# assigned proxy; #6246). Set "true" to restore the legacy test-and-set behaviour.
# PROXY_HEALTH_AUTO_DEACTIVATE=false
# Allow OAuth and provider validation flows to bypass a pinned proxy and connect
# directly when proxy reachability pre-checks fail. Default: false.
# Also configurable from Dashboard > Settings > Feature Flags.
# OMNIROUTE_CONTROL_PLANE_PROXY_DIRECT_FALLBACK=false
# Rate limit maximum wait time before failing a request (ms). Default: 15000 (15s)
# Rate limit maximum wait time before failing a request (ms). Default: 120000 (2 min)
# Used by: open-sse/services/rateLimitManager.ts
# RATE_LIMIT_MAX_WAIT_MS=15000
# Rate limit queue admission cap: reject with 429 queue_full once this many requests
# are already queued (0 = disabled/unbounded, the default). Used by: open-sse/services/rateLimitManager.ts
# RATE_LIMIT_MAX_QUEUE_DEPTH=0
# RATE_LIMIT_MAX_WAIT_MS=120000
# Force the auto-enable rate limit safety net on/off regardless of the persisted
# Dashboard setting. Used by: open-sse/services/rateLimitManager.ts.
@@ -1675,17 +1399,6 @@ APP_LOG_TO_FILE=true
# Used by: src/lib/tokenHealthCheck.ts. Default: 3000.
# HEALTHCHECK_STAGGER_MS=3000
# Randomized jitter range (ms) added on top of HEALTHCHECK_STAGGER_MS between
# provider token healthchecks, to prevent bursting (Issue #1220).
# Used by: src/lib/tokenHealthCheck.ts. Defaults: min=500, max=5000.
# HEALTHCHECK_JITTER_MIN_MS=500
# HEALTHCHECK_JITTER_MAX_MS=5000
# Concurrent-check batch size for the startup token-healthcheck sweep. Larger
# values check more connections in parallel; smaller values reduce burst load.
# Used by: src/lib/tokenHealthCheck.ts. Default: 20 (Issue #7875, regression of #7719).
# HEALTHCHECK_BATCH_SIZE=20
# ═══════════════════════════════════════════════════════════════════════════════
# 22. DEBUGGING
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1721,19 +1434,10 @@ APP_LOG_TO_FILE=true
# Used by: open-sse/utils/cursorImages.ts.
# CURSOR_IMAGE_FETCH_TIMEOUT_MS=15000
# Cursor state DB path override (for IDE cursor version detection).
# Cursor state DB path override (for cursor version detection).
# Used by: open-sse/utils/cursorVersionDetector.ts. Default: probed automatically.
# CURSOR_STATE_DB_PATH=
# Cursor Agent CLI build id for AgentService/Run impersonation (YYYY.MM.DD-<hash>).
# Used by: open-sse/utils/cursorAgentCliVersion.ts. Default: detect local install, else pin.
# CURSOR_AGENT_CLI_VERSION=2026.07.08-0c04a8a
# Cursor Agent CLI data directory override (versions live under <dir>/versions/).
# Used by: open-sse/utils/cursorAgentCliVersion.ts. Default: ~/.local/share/cursor-agent (unix)
# or %LOCALAPPDATA%\cursor-agent (win32). Official agent CLI also honors this var.
# CURSOR_DATA_DIR=
# Direct Cursor bearer token used by scripts/ad-hoc/cursor-tap.cjs (developer tooling).
# CURSOR_TOKEN=
@@ -1820,36 +1524,6 @@ APP_LOG_TO_FILE=true
# Routing-decision log verbosity: 0 silences, higher values log more bypass/route
# decisions (src/mitm/server.cjs, _internal/bypass.cjs).
# MITM_VERBOSE=1
# Strip the leading `sudo` from MITM cert-trust commands (src/mitm/systemCommands.ts) —
# for root-less / user-namespaced deployments (e.g. rootless Docker/Podman)
# where the operator trusts the CA manually (e.g. via Node's extra-CA-certs mechanism).
# OMNIROUTE_NO_SUDO=0
# Explicit opt-out: skip provisioning /etc/hosts DNS entries for the Antigravity
# proxy hostnames entirely (containers with no sudo/root available).
# Used by: src/mitm/dns/provision.ts.
# SKIP_ANTIGRAVITY_DNS=true
# Skip writing to the hosts file when adding/removing DNS entries (e.g. sandboxed
# or read-only test environments). Used by: src/mitm/dns/dnsConfig.ts.
# OMNIROUTE_SKIP_DNS_WRITE=1
# Opt in to the root-CA + per-host-leaf cert model for the MITM proxy (#6684).
# Fresh installs and installs with this set to "true" get a persisted root CA that
# signs per-host leaves; installs with a pre-existing trusted legacy leaf keep the
# legacy fixed-SAN cert unless opted in. Used by: src/mitm/manager.ts.
# MITM_ROOT_CA_ENABLED=true
# Set BY the MITM manager for the spawned proxy process ("root-ca" | "legacy") —
# reflects the migration decision above; not meant to be set manually.
# Read by: src/mitm/server.cjs.
# MITM_CERT_MODE=legacy
# ── Test/CI-only guards (never needed in production) ──
# Set automatically by tests/_setup/isolateDataDir.ts and the CI workflows: the
# test suite must NEVER mutate the OS trust store (a fake test PEM installed via
# update-ca-certificates broke all system TLS on a persistent runner, 2026-07-05).
# OMNIROUTE_SKIP_SYSTEM_TRUST=1
# check-changelog-integrity.mjs (anti CHANGELOG-eat gate): explicit base ref
# override, and the justified-removal escape hatch for intentional bullet removals.
# CHANGELOG_BASE_REF=origin/release/v0.0.0
# ALLOW_CHANGELOG_REMOVALS=1
# ── 1Proxy egress pool ──
# Used by: src/lib/oneproxySync.ts — fetches proxy nodes from the OmniRoute
@@ -1859,14 +1533,6 @@ APP_LOG_TO_FILE=true
# ONEPROXY_MAX_PROXIES=500
# ONEPROXY_MIN_QUALITY_THRESHOLD=50
# ── Free Proxy Pool (auto-sync scheduler) ──
# Background refresh of the free-proxy pool. Opt-in, OFF by default (parallels
# Hard Rule #20's default-off posture for data-mutating background features).
# Used by: src/lib/freeProxyProviders/scheduler.ts
# FREE_PROXY_AUTO_SYNC_ENABLED=true
# Sync interval in ms (default: 1800000 = 30 min).
# FREE_PROXY_AUTO_SYNC_INTERVAL_MS=1800000
# ── Free Proxy Pool (1proxy source) ──
# Used by: src/lib/freeProxyProviders/oneproxy.ts
# Set FREE_PROXY_1PROXY_ENABLED=false to disable this source.
@@ -1888,15 +1554,6 @@ APP_LOG_TO_FILE=true
# FREE_PROXY_IPLOCATE_ENABLED=false
# FREE_PROXY_IPLOCATE_BASE_URL=https://raw.githubusercontent.com/iplocate/free-proxy-list/main/protocols
# ── Free Proxy Pool (Webshare source) ──
# Used by: src/lib/freeProxyProviders/webshare.ts
# Paid, per-account proxy list — requires FREE_PROXY_WEBSHARE_API_KEY to activate,
# regardless of FREE_PROXY_WEBSHARE_ENABLED.
# FREE_PROXY_WEBSHARE_ENABLED=true
# FREE_PROXY_WEBSHARE_API_KEY=
# FREE_PROXY_WEBSHARE_API_URL=https://proxy.webshare.io/api/v2/proxy/list/
# FREE_PROXY_WEBSHARE_MAX=500
# ── Vercel Relay ──
# Used by: src/app/api/settings/proxy/vercel-deploy/route.ts
# Hides the "Deploy Relay" button when set to false.
@@ -1939,15 +1596,6 @@ APP_LOG_TO_FILE=true
# SKILLS_SANDBOX_NETWORK_ENABLED=0
# SKILLS_ALLOWED_SANDBOX_IMAGES=
# Container runtime used by the skill sandbox. Accepted values:
# auto — pick the best installed runtime per host OS (default)
# docker — Docker Engine / Docker Desktop
# apple — Apple Container (macOS native, micro-VM)
# wsl — WSL Container (Windows native via wslc.exe)
# orbstack — OrbStack (high-perf Linux VM + docker shim on macOS)
# podman — Podman (rootless, daemonless)
# SKILLS_SANDBOX_RUNTIME=auto
# ═══════════════════════════════════════════════════════════════════════════════
# 25. TEST & E2E
# ═══════════════════════════════════════════════════════════════════════════════
@@ -1993,7 +1641,7 @@ APP_LOG_TO_FILE=true
# OMNIROUTE_TRANSLATION_API_URL=
# Bearer token for the translation backend (NEVER commit a real key here).
# OMNIROUTE_TRANSLATION_API_KEY=
# Model id, e.g. gpt-4o-mini or cx/gpt-5.6-sol.
# Model id, e.g. gpt-4o-mini or cx/gpt-5.4-mini.
# OMNIROUTE_TRANSLATION_MODEL=gpt-4o-mini
# Per-request timeout in milliseconds (default 60000).
# OMNIROUTE_TRANSLATION_TIMEOUT_MS=60000
@@ -2048,11 +1696,6 @@ PLAYGROUND_COMPARE_MAX_COLUMNS=4
# MEMORY_VEC_TOP_K=20 # default top-K for vector search
# MEMORY_RRF_K=60 # RRF k constant (sqlite-vec hybrid recipe)
# HF_HUB_ENDPOINT=https://huggingface.co # override Hugging Face Hub base URL for static potion downloads
# TV6 typed memory decay (OPT-IN, default off — the sweep DELETES decayed memories)
# MEMORY_TYPED_DECAY_ENABLED=false # master switch for the destructive sweep (default off)
# MEMORY_TYPED_DECAY_EPISODIC_DAYS=30 # episodic TTL in days; 0 = episodic immune too
# MEMORY_TYPED_DECAY_ACCESS_IMMUNITY=3 # access_count >= N → immune; 0 disables access immunity
# MEMORY_TYPED_DECAY_SWEEP_INTERVAL=0 # periodic sweep interval (seconds); 0 = no periodic sweep
# AgentBridge + Traffic Inspector (Group A)
# AgentBridge
@@ -2110,10 +1753,6 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# duplicated). Falls back to TS path via X-Bifrost-Fallback header on
# timeout/failure. See bin/omniroute for the local-redis companion.
# BIFROST_BASE_URL=
# Port the supervised Bifrost embedded service binds to (127.0.0.1:<port>), read by
# src/lib/services/bootstrap.ts when OmniRoute manages the Bifrost sidecar lifecycle.
# Default: 8080.
# BIFROST_PORT=8080
# API key for the Bifrost gateway (sent as Authorization: Bearer ...). If
# unset, the route expects the request to carry a valid OmniRoute API key;
# this key is for gateway-side auth only.
@@ -2125,29 +1764,8 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# Per-request timeout when proxying to the Bifrost gateway. Default: 30000 (30s).
# BIFROST_TIMEOUT_MS=
# Alias for BIFROST_API_KEY (used by scripts that read the env via
# OMNIROUTE_*). BIFROST_API_KEY takes precedence when both are set.
# OMNIROUTE_*). Falls back to BIFROST_API_KEY when unset.
# OMNIROUTE_BIFROST_KEY=
# Relay backend selection for the OpenAI-compatible relay endpoint:
# ts | bifrost | auto. "ts" (default when Bifrost is not configured) uses the
# TypeScript relay; "auto" selects Bifrost when BIFROST_BASE_URL is set (and
# BIFROST_ENABLED != 0) and falls back to TS if the sidecar is unreachable;
# "bifrost" forces Bifrost (strict — no TS fallback). Auth, rate limits,
# injection guard and model allowlists always run in the Next route first.
# RELAY_ROUTING_BACKEND is an accepted alias. Responses carry X-Routing-Backend
# and X-Routing-Fallback.
# OMNIROUTE_RELAY_BACKEND=
# RELAY_ROUTING_BACKEND=
# Cooldown (ms) after a Bifrost sidecar hop fails in "auto" mode before the relay
# re-attempts the sidecar; it goes straight to the TS path while the cooldown lasts.
# 0 disables. Default 5000. Only applies when OMNIROUTE_RELAY_BACKEND=auto.
# OMNIROUTE_BIFROST_FAILURE_COOLDOWN_MS=
# Opt-in native HTTPS/TLS for `omniroute serve` (equivalent to --tls-cert /
# --tls-key). Provide BOTH a PEM certificate and its private key and the
# standalone server terminates TLS on the same listener (wss:// works
# unchanged). With neither set the server stays plain HTTP; providing only one
# (or an unreadable path) logs a warning and stays HTTP (never half-enables).
# OMNIROUTE_TLS_CERT=
# OMNIROUTE_TLS_KEY=
# ─── 1-click local service launchers (PR-3 in #3932) ────────────────────────
# Master switch for /api/local/* routes. When unset or "0", all /api/local/*
@@ -2195,76 +1813,7 @@ QUOTA_STORE_DRIVER=sqlite # sqlite | redis
# gateway as a separate sidecar (helps in 3+ replica deployments where you want
# provider rotation centralised). See docs/architecture/cluster-decisions.md §
# "Bifrost (bifrost profile)".
# Set OMNIROUTE_RELAY_BACKEND=auto to use this sidecar when healthy, or
# OMNIROUTE_RELAY_BACKEND=bifrost to require it without TS fallback.
# BIFROST_BASE_URL=http://bifrost:8080
# BIFROST_API_KEY=
# BIFROST_STREAMING_ENABLED=true
# BIFROST_TIMEOUT_MS=30000
# ─────────────────────────────────────────────────────────────────────────────
# Account rotation config (operator-managed; consumed by open-sse/services/rotationConfig.ts)
# Lets a supervising front-end mirror its rotation rules onto the backend's account-fallback
# engine. All optional; defaults preserve the historical behavior.
# ─────────────────────────────────────────────────────────────────────────────
# OMNIROUTE_ROTATION_ENABLED=true
# OMNIROUTE_ROTATION_RATE_LIMIT_RESET_SECONDS=0
# OMNIROUTE_ROTATION_DISABLE_TAG_WITHOUT_RESET=true
# OMNIROUTE_ROTATE_ON_429=true
# OMNIROUTE_ROTATE_429_THRESHOLD=1
# OMNIROUTE_ROTATE_429_WINDOW_SECONDS=120
# OMNIROUTE_ROTATE_ON_500=true
# OMNIROUTE_ROTATE_500_THRESHOLD=1
# OMNIROUTE_ROTATE_500_WINDOW_SECONDS=120
# OMNIROUTE_ROTATE_ON_502=true
# OMNIROUTE_ROTATE_502_THRESHOLD=1
# OMNIROUTE_ROTATE_502_WINDOW_SECONDS=120
# OMNIROUTE_ROTATE_ON_400=false
# OMNIROUTE_ROTATE_400_THRESHOLD=1
# OMNIROUTE_ROTATE_400_WINDOW_SECONDS=120
# ─────────────────────────────────────────────────────────────────────────────
# PromptQL playground provider (Unofficial/Experimental — src/shared/constants/providers/web-cookie.ts)
# Reverse-engineered GraphQL session bridge for prompt.ql.app. All optional —
# defaults point at the public playground endpoints; override only for a
# self-hosted/alternate PromptQL deployment.
# Used by: open-sse/executors/promptql.ts, open-sse/services/usage/promptql.ts
# ─────────────────────────────────────────────────────────────────────────────
# PROMPTQL_GRAPHQL_ENDPOINT=https://data.prompt.ql.app/promptql/playground-v2-hge/v1/graphql
# PROMPTQL_CREDITS_ENDPOINT=https://data.pro.ql.app/v1/graphql
# PROMPTQL_TOKEN_REFRESH_URL=https://auth.pro.ql.app/ddn/project/token
# PROMPTQL_POLL_TIMEOUT_MS=180000
# ─────────────────────────────────────────────────────────────────────────────
# HyperAgent web provider (Unofficial/Experimental — src/shared/constants/providers/web-cookie.ts)
# Reverse-engineered session bridge for hyperagent.com. Optional — defaults
# point at the public billing/usage endpoint; override only for a
# self-hosted/alternate HyperAgent deployment.
# Used by: open-sse/services/usage/hyperagent.ts
# ─────────────────────────────────────────────────────────────────────────────
# HYPERAGENT_USAGE_URL=https://hyperagent.com/api/settings/billing/usage
# ─────────────────────────────────────────────────────────────────────────────
# Browser-login VNC sessions (optional — src/lib/vncSession/manifest.ts)
# Containerized Chromium+VNC used for interactive browser-login credential
# capture via /api/vnc-session. All optional — defaults target the bundled
# `omniroute-vnc-chromium:local` image; override only for a custom image, ports,
# or lifecycle tuning.
# ─────────────────────────────────────────────────────────────────────────────
# OMNIROUTE_VNC_IMAGE=omniroute-vnc-chromium:local
# OMNIROUTE_DOCKER_BIN=docker# OMNIROUTE_VNC_CONTAINER_VNC_PORT=3000
# OMNIROUTE_VNC_CONTAINER_CDP_PORT=9223
# OMNIROUTE_VNC_CONTAINER_PROFILE_DIR=/config
# OMNIROUTE_VNC_PROFILE_DIR=
# OMNIROUTE_VNC_IDLE_MS=600000
# OMNIROUTE_VNC_MAX_MS=1800000
# OMNIROUTE_VNC_MAX_SESSIONS=4
# OMNIROUTE_VNC_READY_MS=45000
# OMNIROUTE_VNC_HARVEST_MS=20000
# OMNIROUTE_VNC_CHROMIUM_ARGS=--remote-debugging-port=9222 --no-first-run --no-default-browser-check
# ─────────────────────────────────────────────────────────────────────────────
# Data-dir alias (optional — open-sse/services/notionThreadSessions.ts)
# Legacy fallback for DATA_DIR, checked only after DATA_DIR and
# OMNIROUTE_DATA_DIR are both unset. Locates the Notion web-thread session cache.# ─────────────────────────────────────────────────────────────────────────────
# VIBEPROXY_DATA_DIR=

View File

@@ -1,9 +0,0 @@
# Homologação E2E real — copie para .env.homolog (NUNCA commitar o real)
HOMOLOG_BASE_URL=http://192.168.0.15:20128
# Senha de management do dashboard da VPS (a mesma do /login)
HOMOLOG_ADMIN_PASSWORD=
# Deixe vazio: a suíte cria uma API key efêmera via admin e revoga no fim.
# Só preencha para depurar uma camada isolada com uma key fixa.
HOMOLOG_API_KEY=
# Tier crítico (chat real, max_tokens=5). Demais providers: só validação de catálogo.
HOMOLOG_CRITICAL_PROVIDERS=openai,anthropic,gemini,codex,grok,glm,deepseek,openrouter

View File

@@ -24,15 +24,6 @@ updates:
update-types: ["version-update:semver-major"]
- dependency-name: "eslint-config-next"
update-types: ["version-update:semver-major"]
# typescript majors are peer-blocked by typescript-eslint, which pins a hard
# upper bound (8.64.0 → peerDependencies.typescript ">=4.8.4 <6.1.0"). A TS 7
# bump therefore violates the peer and takes down the whole toolchain at once —
# #7068 grouped it with 6 harmless bumps and turned Build + Lint + Quality Ratchet
# + Unit (6/8, 8/8) + Integration (1/2, 2/2) + dast-smoke red in one shot, blocking
# the innocuous updates riding along with it. Un-ignore once typescript-eslint
# widens the peer, and migrate TS majors intentionally (own PR, own CI run).
- dependency-name: "typescript"
update-types: ["version-update:semver-major"]
# jscpd v5 is a Rust rewrite (native binary, no Node.js programmatic API).
# scripts/check/check-duplication.mjs is deliberately pinned to jscpd@4 (it
# parses jscpd-report.json against a frozen baseline). A v5 major would break

File diff suppressed because it is too large Load Diff

View File

@@ -22,10 +22,10 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
- uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: javascript-typescript
queries: security-extended
- uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
- uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
category: "/language:javascript-typescript"

View File

@@ -10,10 +10,7 @@ jobs:
# ADVISORY while this new gate matures (repo convention: advisory -> blocking).
# Flip to blocking (remove continue-on-error) once it's proven stable across a few PRs.
continue-on-error: true
# Build CLI bundle alone varies 6-11min on GitHub-hosted runners (3 consecutive
# timeouts observed on 2026-07-14 with the old 12min cap killing schemathesis
# mid-run) — 25min leaves real headroom for the actual DAST steps.
timeout-minutes: 25
timeout-minutes: 12
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-api-key-secret-with-sufficient-length-aaaa
@@ -21,14 +18,12 @@ jobs:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "24"
cache: npm
- run: npm ci
- name: Build CLI bundle
env:
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
run: npm run build:cli
- name: Start OmniRoute
env:

View File

@@ -98,13 +98,11 @@ jobs:
PROMOTE="${PROMOTE_INPUT:-false}"
else
git fetch --tags --quiet || true
# Decide via the extracted helper, which folds VERSION into the
# candidate set so the result is independent of git-tag sync timing
# on `release` events (#5301). Without that, the freshly-created tag
# is often not yet visible here and :latest stays a release behind.
PROMOTE=$(git tag -l 'v[0-9]*' | bash scripts/ci/should-promote-latest.sh "$VERSION")
if [ "$PROMOTE" != "true" ]; then
echo "Version $VERSION is not the highest stable semver. Not promoting :latest."
HIGHEST=$(git tag -l 'v[0-9]*' | sed 's/^v//' | grep -vE -- '-(rc|alpha|beta|pre|next)' | sort -V | tail -1 || echo "")
if [ -n "$HIGHEST" ] && [ "$VERSION" = "$HIGHEST" ]; then
PROMOTE="true"
else
echo "Version $VERSION is not the highest semver tag (highest=${HIGHEST:-<none>}). Not promoting :latest."
fi
fi
echo "promote_latest=$PROMOTE" >> "$GITHUB_OUTPUT"
@@ -346,15 +344,6 @@ jobs:
# Visibility scan: reports HIGH + CRITICAL into the SARIF (Security tab) but
# never blocks (exit-code 0). The blocking gate below narrows to CRITICAL.
#
# ignore-unfixed mirrors the blocking gate: the Security tab must surface only
# ACTIONABLE vulnerabilities — ones with a published fix we can pull by rebuilding
# on a patched base or bumping the dep. Without it the advisory upload floods the
# tab with unfixable base-image OS CVEs (Debian trixie packages with no upstream
# patch yet, overwhelmingly local-only and not reachable from the proxy request
# surface), which is noise an operator cannot act on. trivyignores points at the
# repo-root .trivyignore so accepted-risk fixable CVEs have one auditable home.
# See docs/security/SUPPLY_CHAIN.md.
- name: Trivy image scan (SARIF, advisory)
if: needs.prepare.outputs.version != 'main'
continue-on-error: true
@@ -364,8 +353,6 @@ jobs:
format: sarif
output: trivy-results.sarif
severity: HIGH,CRITICAL
ignore-unfixed: true
trivyignores: .trivyignore
exit-code: "0"
# BLOCKING gate (v3.8.27 cycle-end): fail the release on a CRITICAL CVE in the

View File

@@ -88,13 +88,13 @@ jobs:
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
- name: Cache node_modules
uses: actions/cache@v6.1.0
uses: actions/cache@v6.0.0
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
@@ -201,12 +201,6 @@ jobs:
[ -f "$file" ] && cp "$file" "../../release-assets/OmniRoute.exe" && break
done
fi
# electron-updater manifests (latest.yml / latest-mac.yml / latest-linux.yml)
# must be published alongside the installers, or autoUpdater fails with
# "Cannot find latest.yml in the latest release artifacts" (#6766).
for file in latest*.yml; do
[ -f "$file" ] && cp "$file" ../../release-assets/
done
- name: Upload artifacts
uses: actions/upload-artifact@v7
@@ -269,7 +263,6 @@ jobs:
release-assets/*.AppImage
release-assets/*.deb
release-assets/*.blockmap
release-assets/*.yml
release-assets/*.source.tar.gz
release-assets/*.source.zip
env:

View File

@@ -44,7 +44,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm

View File

@@ -1,126 +0,0 @@
name: Nightly Node Compat
# Plano mestre testes+CI (Eixo D2, aprovado 2026-07-04): as matrizes de compatibilidade
# Node 24/26 custavam ~28% de CADA run do CI pesado (2 execuções completas da suíte por
# sync da release-PR) para pegar uma classe de quebra que raramente nasce num PR típico.
# Elas rodam aqui 1×/dia contra o tip da release ativa (mesmo alvo do nightly-release-green)
# e continuam obrigatórias no gate de release via workflow_dispatch do ci.yml se preciso.
# fail-fast desligado: numa quebra queremos saber TODAS as versões afetadas de uma vez.
on:
schedule:
- cron: "47 6 * * *" # 06:47 UTC diário — slot distinto dos demais nightlies
workflow_dispatch:
inputs:
branch:
description: "Branch to validate (default: highest release/vX.Y.Z)"
required: false
type: string
permissions:
contents: read
issues: write
concurrency:
group: nightly-compat
cancel-in-progress: true
jobs:
resolve-branch:
name: Resolve active release branch
runs-on: ubuntu-latest
outputs:
target: ${{ steps.branch.outputs.target }}
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
persist-credentials: false
- name: Resolve active release branch
id: branch
env:
INPUT_BRANCH: ${{ github.event.inputs.branch }}
run: |
set -euo pipefail
if [ -n "${INPUT_BRANCH:-}" ]; then
TARGET="$INPUT_BRANCH"
else
TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \
| sed 's#origin/##' \
| sort -t/ -k2 -V \
| tail -1)
fi
case "$TARGET" in
release/v[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "Refusing non-canonical branch name: $TARGET"; exit 1 ;;
esac
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
compat-build-26:
name: Node 26 Compatibility Build
runs-on: ubuntu-latest
timeout-minutes: 25
needs: resolve-branch
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve-branch.outputs.target }}
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "26"
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm run build
compat-tests:
name: Node ${{ matrix.node }} Compat Tests (${{ matrix.shard }}/4)
runs-on: ubuntu-latest
timeout-minutes: 25
needs: resolve-branch
strategy:
fail-fast: false
matrix:
node: [24, 26]
shard: [1, 2, 3, 4]
env:
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-nightly-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
TEST_SHARD: ${{ matrix.shard }}/4
steps:
- uses: actions/checkout@v7
with:
ref: ${{ needs.resolve-branch.outputs.target }}
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ matrix.node }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm run check:node-runtime
- run: npm run test:unit:ci:shard
report:
name: Open / update tracking issue on failure
runs-on: ubuntu-latest
if: ${{ !cancelled() && (needs.compat-tests.result == 'failure' || needs.compat-build-26.result == 'failure') }}
needs: [resolve-branch, compat-build-26, compat-tests]
permissions:
issues: write
steps:
- name: Open or update issue
env:
GH_TOKEN: ${{ github.token }}
TARGET: ${{ needs.resolve-branch.outputs.target }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
TITLE="🌙 nightly-compat: Node 24/26 failures on $TARGET"
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "$TITLE in:title" --json number --jq '.[0].number')
BODY="Nightly Node-compat run failed on \`$TARGET\`: $RUN_URL — triage which Node version/shard broke (fail-fast off, all versions reported)."
if [ -n "$EXISTING" ]; then
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body "$BODY"
else
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body "$BODY"
fi

View File

@@ -15,13 +15,11 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with: { node-version: "24", cache: npm }
- run: npm ci
- name: Build CLI bundle
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
run: npm run build:cli
- name: Start OmniRoute (block mode)
env:
@@ -67,16 +65,14 @@ jobs:
with:
persist-credentials: false
if: steps.gate.outputs.run == 'true'
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
if: steps.gate.outputs.run == 'true'
with: { node-version: "24", cache: npm }
- run: npm ci
if: steps.gate.outputs.run == 'true'
- name: Build CLI bundle
if: steps.gate.outputs.run == 'true'
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
run: npm run build:cli
- name: Start OmniRoute
if: steps.gate.outputs.run == 'true'

View File

@@ -107,13 +107,13 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm
- run: npm ci
- name: Restore Stryker incremental cache
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: reports/mutation/stryker-incremental.json
key: stryker-incremental-${{ matrix.batch.name }}-${{ github.run_id }}
@@ -151,7 +151,7 @@ jobs:
- uses: actions/checkout@v6
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
- name: Download all mutation reports

View File

@@ -13,7 +13,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm

View File

@@ -1,18 +1,11 @@
name: Release-Green (continuous)
name: Nightly Release-Green
# Solution D — continuous, NON-BLOCKING drift signal for the active release branch.
#
# WHY: the full gate (ci.yml) only runs on the release PR (PR → main), so reds
# accrue silently on release/** and explode — in layers — at release time. This
# workflow reproduces the release-equivalent validation on the release branch and,
# when there are HARD failures, opens/updates a single tracking issue.
#
# WS5.1 (v3.8.49 quality plan) — two modes:
# push to release/v* (code paths) → --quick (fast HARD gates, ~5-8min). Catches the
# captain's direct pushes (sync-back — the one ungated write path) AND the merged
# COMBINATION right after every PR merge, attributing the offending push range in
# the issue. Base-red MTTD drops from ≤24h to ≤~15min after the offending push.
# schedule (3×/day) → full --with-build --full-ci (the deep sweep incl. build+suites).
# nightly reproduces the release-equivalent validation on the active release branch
# HEAD and, when there are HARD failures, opens/updates a single tracking issue.
#
# It is NOT a required status check and never touches a contributor PR — it only
# reports. Ratchet drift (eslint warnings / cognitive-complexity / file-size) is
@@ -21,23 +14,8 @@ name: Release-Green (continuous)
# package-artifact) flip the issue open.
on:
push:
branches: ["release/v*", "main"]
paths:
- "src/**"
- "open-sse/**"
- "bin/**"
- "electron/**"
- "scripts/**"
- "tests/**"
- "config/**"
- "package.json"
- "package-lock.json"
- "tsconfig*.json"
schedule:
- cron: "23 5 * * *" # full sweep — off-peak, distinct from other nightlies
- cron: "23 12 * * *" # full sweep — midday (WS5.1: 3×/day instead of 1×)
- cron: "23 18 * * *" # full sweep — evening
- cron: "23 5 * * *" # 05:23 UTC daily — off-peak, distinct from other nightlies
workflow_dispatch:
inputs:
branch:
@@ -50,25 +28,13 @@ permissions:
issues: write
concurrency:
# push storms during merge campaigns collapse to the newest commit per branch;
# scheduled full sweeps keep their own single lane.
group: release-green-${{ github.event_name }}-${{ github.ref }}
group: nightly-release-green
cancel-in-progress: true
env:
OMNIROUTE_SKIP_SYSTEM_TRUST: "1"
jobs:
release-green:
name: Validate active release branch
# On a push, only run for release/* pushes — a push to main is handled by the
# main-green job below. Schedule/dispatch always run (they validate the highest release).
if: ${{ github.event_name != 'push' || startsWith(github.ref_name, 'release/') }}
# Dynamic runner: with USE_VPS_RUNNER=true (release window / on-demand pre-flight)
# this runs on the dedicated VPS runner — clean env (no operator OMNIROUTE_API_KEY,
# no local noauth CLIs => zero machine-specific false positives) and no contention.
# Nightly cron normally finds the var false (VM off) and falls back to hosted.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-latest' }}
runs-on: ubuntu-latest
env:
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-nightly-api-key-secret-long
@@ -83,15 +49,10 @@ jobs:
id: branch
env:
INPUT_BRANCH: ${{ github.event.inputs.branch }}
EVENT_NAME: ${{ github.event_name }}
PUSHED_REF: ${{ github.ref_name }}
run: |
set -euo pipefail
if [ -n "${INPUT_BRANCH:-}" ]; then
TARGET="$INPUT_BRANCH"
elif [ "$EVENT_NAME" = "push" ]; then
# validate exactly what was pushed, not the highest branch
TARGET="$PUSHED_REF"
else
# highest release/vX.Y.Z by semver among remote branches
TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \
@@ -116,7 +77,7 @@ jobs:
git checkout "$TARGET"
git log -1 --oneline
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm
@@ -125,26 +86,9 @@ jobs:
- name: Release-green validation (full)
id: validate
env:
EVENT_NAME: ${{ github.event_name }}
run: |
set +e
# --hermetic: scrub live-test trigger vars (self-hosted runner may carry
# operator env; hosted ignores the unknown flag before #6300 lands).
# push → --quick: fast HARD gates only (~5-8min), per-merge signal.
# schedule/dispatch → --with-build --full-ci: ALSO run every static gate from
# ci.yml's gate jobs (lint, quality-gate, quality-extended, docs-sync-strict,
# pr-test-policy) + build + full suites. PRs into release/** only get the
# fast-gates, so these accrue silently and explode in layers on the release PR
# (v3.8.46: 11 static base-reds leaked).
if [ "$EVENT_NAME" = "push" ]; then
MODE="--quick"
else
MODE="--with-build --full-ci"
fi
echo "[release-green] mode: $MODE (event: $EVENT_NAME)"
# shellcheck disable=SC2086 — MODE is an intentional flag list
node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \
node scripts/quality/validate-release-green.mjs --json --with-build \
1> release-green.json 2> release-green.log
echo "exit=$?" >> "$GITHUB_OUTPUT"
echo "------- report -------"
@@ -156,28 +100,15 @@ jobs:
GH_TOKEN: ${{ github.token }}
TARGET: ${{ steps.branch.outputs.target }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
AFTER_SHA: ${{ github.event.after }}
run: |
set -euo pipefail
TITLE="🔴 Release branch not green: ${TARGET}"
{
echo "The **release-green** validation found HARD failures on \`${TARGET}\`."
echo "The nightly **release-green** validation found HARD failures on \`${TARGET}\`."
echo "These are real defects that would block the release PR — fix them in the"
echo "originating PR branch (via co-authorship), not by demanding it from contributors."
echo ""
echo "**Run:** ${RUN_URL} (mode: ${EVENT_NAME})"
# WS5.1 attribution: on push events the offending change IS this push's range
# (one merge per push in the normal queue), so name it — no bisect needed.
if [ "$EVENT_NAME" = "push" ] && [ -n "${BEFORE_SHA:-}" ] && \
git cat-file -e "$BEFORE_SHA" 2>/dev/null; then
echo ""
echo "**Offending push range** (\`${BEFORE_SHA:0:9}..${AFTER_SHA:0:9}\`):"
echo '```'
git log --no-decorate --oneline "${BEFORE_SHA}..${AFTER_SHA}" | head -20
echo '```'
fi
echo "**Run:** ${RUN_URL}"
echo ""
echo '```'
sed -n '/──────── verdict ────────/,$p' release-green.log || tail -40 release-green.log
@@ -197,107 +128,10 @@ jobs:
- name: Upload report artifact
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v4
with:
name: release-green-report
path: |
release-green.json
release-green.log
if-no-files-found: ignore
# Companion arm for `main`. Under the parallel-cycle model, main only receives merged
# work at the release squash — so a gate/infra fix that lands only on release leaves
# main red the whole cycle, and repo-wide gates (CodeQL alert count, ratchet baselines)
# turn EVERY PR into main red on a check unrelated to its diff. This detects that and
# opens a "🔴 main not green" tracking issue. The PREVENTION is the companion-PR reflex
# (Hard Rule #21 area / _shared/merge-gates.md §8); this is the automated backstop.
main-green:
name: Validate main branch
# On a push, only run for a push to main — a push to release/* is handled by
# release-green above. Schedule/dispatch always run (they also sweep main).
if: ${{ github.event_name != 'push' || github.ref_name == 'main' }}
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-latest' }}
env:
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-nightly-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v7
with:
ref: main # literal — no injection surface; scheduled runs default to the repo default branch (a release/v*), so pin main explicitly
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- uses: ./.github/actions/npm-ci-retry
- name: Main-green validation
id: validate
env:
EVENT_NAME: ${{ github.event_name }}
run: |
set +e
# push (a merge into main) → --quick fast HARD gates; schedule/dispatch → full sweep.
if [ "$EVENT_NAME" = "push" ]; then
MODE="--quick"
else
MODE="--with-build --full-ci"
fi
echo "[main-green] mode: $MODE (event: $EVENT_NAME)"
# shellcheck disable=SC2086 — MODE is an intentional flag list
node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \
1> main-green.json 2> main-green.log
echo "exit=$?" >> "$GITHUB_OUTPUT"
echo "------- report -------"
cat main-green.log
- name: Open / update tracking issue on HARD failure
if: steps.validate.outputs.exit != '0'
env:
GH_TOKEN: ${{ github.token }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
TITLE="🔴 main branch not green"
{
echo "The **main-green** validation found HARD failures on \`main\`."
echo ""
echo "Because \`main\` only receives merged work at the release squash, a gate/infra"
echo "fix that landed only on the release branch leaves \`main\` broken for the whole"
echo "cycle — and repo-wide gates (CodeQL alert count, ratchet baselines) then turn"
echo "**every open PR into main** red on a check unrelated to its diff. The fix is a"
echo "companion PR \`--base main\` carrying the release-side fix (see"
echo "\`_shared/merge-gates.md\` §8), NOT chasing each contributor PR."
echo ""
echo "**Run:** ${RUN_URL} (mode: ${EVENT_NAME})"
echo ""
echo '```'
sed -n '/──────── verdict ────────/,$p' main-green.log || tail -40 main-green.log
echo '```'
echo ""
echo "_Ratchet drift (eslint warnings / cognitive-complexity / file-size) is expected mid-cycle and did NOT, on its own, open this issue._"
} > issue-body.md
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING" ]; then
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file issue-body.md
echo "Updated existing issue #$EXISTING"
else
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file issue-body.md
fi
- name: Upload report artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: main-green-report
path: |
main-green.json
main-green.log
if-no-files-found: ignore

View File

@@ -15,7 +15,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm
@@ -29,7 +29,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm
@@ -43,7 +43,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm
@@ -51,7 +51,6 @@ jobs:
- name: Build CLI bundle
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
run: npm run build:cli
- name: Start OmniRoute (background)
env:
@@ -95,13 +94,13 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "24"
cache: npm
- run: npm ci
- name: Cache Playwright browsers
uses: actions/cache@v6.1.0
uses: actions/cache@v6.0.0
with:
path: ~/.cache/ms-playwright
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}

View File

@@ -16,13 +16,11 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with: { node-version: "24", cache: npm }
- run: npm ci
- name: Build CLI bundle
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
run: npm run build:cli
- name: Start OmniRoute (background)
env:

View File

@@ -22,14 +22,6 @@ on:
- latest
- next
- historic
publish_mode:
description: "staged = npm stage publish (owner approves with 2FA after the staged boot-verify); direct = legacy immediate publish (emergency fallback only)"
required: false
default: "staged"
type: choice
options:
- staged
- direct
workflow_call:
inputs:
version:
@@ -71,7 +63,7 @@ jobs:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
registry-url: https://registry.npmjs.org
@@ -174,34 +166,8 @@ jobs:
TAG: ${{ github.ref_name }}
run: gh release upload "$TAG" sbom-npm.cdx.json --clobber
# WS1.2/WS1.3 (#7065 class): the artifact that is about to be published must
# BOOT. build:cli already assembled dist/ above; this packs+installs+boots the
# real tarball and fails the publish before anything reaches the registry.
- name: Boot-smoke the tarball before ANY publish
- name: Publish to npm
if: steps.resolve.outputs.skip != 'true'
run: npm run check:pack-boot
# WS1.3 (D2, v3.8.49 plan): STAGED publishing by default — `npm stage publish`
# parks the exact bytes on the registry WITHOUT making them installable; the
# owner then verifies and approves with 2FA (`npm stage approve`), moving the
# human gate to AFTER the proof instead of before it. Requires npm >= 11.15
# (staged publishing GA 2026-05-22). publish_mode=direct is the emergency
# fallback (legacy immediate publish) via workflow_dispatch.
- name: Ensure npm supports staged publishing
if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct')
run: |
set -euo pipefail
CUR=$(npm --version)
if ! node -e "const [a,b]='$(npm --version)'.split('.').map(Number); process.exit(a>11||(a===11&&b>=15)?0:1)"; then
# Pinned exact version (supply-chain: never float @latest in the publish
# job); bump deliberately when a newer npm is required.
echo "npm $CUR < 11.15 — installing pinned npm 11.15.0 for staged publishing"
npm install -g --ignore-scripts npm@11.15.0
fi
npm --version
- name: Publish to npm (staged — owner approves with 2FA)
if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct')
env:
VERSION: ${{ steps.resolve.outputs.version }}
TAG: ${{ steps.resolve.outputs.tag }}
@@ -209,32 +175,10 @@ jobs:
run: |
set -euo pipefail
# Always pass --tag explicitly. Defense in depth: even if VERSION is
# accidentally an older release, the historic tag will NOT claim `@latest`.
npm stage publish --provenance --access public --tag "$TAG"
{
echo "## 📦 omniroute@$VERSION STAGED (not yet installable)"
echo ""
echo "The exact bytes are parked on the registry. To release them:"
echo '```'
echo "npm stage list omniroute # find the stage id"
echo "npm stage approve <id> # owner 2FA — THE publish"
echo '```'
echo "To verify the staged bytes first: npm stage download <id> → run"
echo "scripts/check/check-pack-boot.mjs against them (see RELEASE_CHECKLIST)."
echo "To discard: npm stage reject <id>."
} >> "$GITHUB_STEP_SUMMARY"
echo "✅ Staged omniroute@$VERSION (dist-tag=$TAG) — awaiting owner 'npm stage approve'"
- name: Publish to npm (DIRECT — emergency fallback)
if: steps.resolve.outputs.skip != 'true' && github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct'
env:
VERSION: ${{ steps.resolve.outputs.version }}
TAG: ${{ steps.resolve.outputs.tag }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
# accidentally an older release, `npm publish --tag historic` will
# NOT promote it to `@latest`.
npm publish --provenance --access public --tag "$TAG"
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) [DIRECT mode]"
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG)"
- name: Publish to GitHub Packages
if: steps.resolve.outputs.skip != 'true'
@@ -261,56 +205,13 @@ jobs:
uses: actions/checkout@v7
with:
persist-credentials: false
fetch-depth: 0
# Full history needed for auto-bump: git diff against previous release tag
- name: Setup Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
registry-url: https://registry.npmjs.org
- name: Auto-bump plugin version if plugin changed since last release
id: bump
working-directory: "@omniroute/opencode-plugin"
env:
CURRENT_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
PKG_VERSION=$(node -p "require('./package.json').version")
PKG_NAME=$(node -p "require('./package.json').name")
# 1) Skip if current version is not yet published (no bump needed)
PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)"
if [ "$PUBLISHED" != "$PKG_VERSION" ]; then
echo "✅ ${PKG_NAME}@${PKG_VERSION} is new — no bump needed."
echo "bumped=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# 2) Find the previous release tag (exclude the current one)
PREV_TAG=$(git tag -l 'v*' --sort=-version:refname \
| grep -v "^${CURRENT_TAG}$" | head -1 || echo "")
if [ -z "$PREV_TAG" ]; then
echo "No previous tag to compare — skipping bump."
echo "bumped=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# 3) Check if plugin dir actually changed since that tag
if git diff --quiet "$PREV_TAG" -- "@omniroute/opencode-plugin/"; then
echo "⏭️ No plugin changes since $PREV_TAG — nothing to publish."
echo "bumped=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# 4) Auto-bump patch version
npm version patch --no-git-tag-version --allow-same-version
NEW_VERSION=$(node -p "require('./package.json').version")
echo "bumped=true" >> "$GITHUB_OUTPUT"
echo "📦 Auto-bumped ${PKG_NAME} from ${PKG_VERSION} to ${NEW_VERSION}"
- name: Install plugin dependencies
working-directory: "@omniroute/opencode-plugin"
run: npm install --no-audit --no-fund

View File

@@ -35,7 +35,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
@@ -52,7 +52,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "22"
cache: npm

View File

@@ -35,7 +35,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node }}
cache: npm
@@ -51,7 +51,7 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: "20"
cache: npm

View File

@@ -14,80 +14,12 @@ permissions:
contents: read
env:
# CI must never mutate the runner's OS trust store (2026-07-05: a cert-flow
# test installed a fake PEM on a persistent self-hosted runner and broke all
# system TLS). Belt-and-suspenders with tests/_setup/isolateDataDir.ts.
OMNIROUTE_SKIP_SYSTEM_TRUST: "1"
CI_NODE_VERSION: "24"
jobs:
# Same classifier as ci.yml (scripts/quality/classify-pr-changes.mjs) so PR→release
# path filters share existence reasons: code / docs / i18n / workflow.
changes:
name: Change Classification
runs-on: ubuntu-latest
outputs:
code: ${{ steps.classify.outputs.code }}
docs: ${{ steps.classify.outputs.docs }}
i18n: ${{ steps.classify.outputs.i18n }}
workflow: ${{ steps.classify.outputs.workflow }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
fetch-depth: 0
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
- id: classify
env:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if [ "$EVENT_NAME" != "pull_request" ]; then
{
echo "code=true"
echo "docs=true"
echo "i18n=true"
echo "workflow=true"
} >> "$GITHUB_OUTPUT"
exit 0
fi
git diff --name-only "$BASE_SHA" "$HEAD_SHA" > changed-files.txt
node scripts/quality/classify-pr-changes.mjs changed-files.txt >> "$GITHUB_OUTPUT"
# Docs/OpenAPI contract gates only — existence reason is doc accuracy + route refs.
# Split out of fast-gates so pure-docs PRs skip typecheck/unit while still validating docs.
docs-gates:
name: Docs Gates (fast-path)
needs: changes
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
# One walk of src/app/api for openapi-routes + docs-symbols (both still fail independently).
- run: npm run check:api-docs-refs
- name: Docs accuracy (fabricated-docs + i18n mirrors, strict)
run: npm run check:docs-all
fast-gates:
name: Fast Quality Gates
needs: changes
# Code surface only — pure docs/i18n PRs skip this bag (docs-gates covers docs).
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
# Dynamic runner (same rule as ci.yml): use the self-hosted VPS pool only when the
# release captain has USE_VPS_RUNNER=true AND this is not a fork PR (own-origin
# branches only — a fork PR must never execute on the LAN runner). Var unset/false
# or a fork PR falls back to ubuntu-latest, so this is inert until the flag flips.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
runs-on: ubuntu-latest
# tsx gates (known-symbols, route-guard-membership) import modules that open
# SQLite on load; provide DB env so a fresh CI DB initializes cleanly.
env:
@@ -99,23 +31,17 @@ jobs:
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- name: Restore ESLint file cache
uses: actions/cache@v6
with:
path: |
.eslintcache
.eslintcache-complexity
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
restore-keys: |
eslint-${{ runner.os }}-
- run: npm run check:provider-consistency
- run: npm run check:fetch-targets
# docs-all / openapi-routes / docs-symbols live in docs-gates (path-filtered).
- 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
@@ -126,10 +52,6 @@ jobs:
- run: npm run check:route-guard-membership
- run: npm run check:test-discovery
- run: npm run check:test-runner-api
# Guards tap.testFiles drift: a covering unit test absent from stryker.conf.json
# tap.testFiles makes its module's mutants survive on a cold nightly-mutation run,
# false-failing the blocking mutationScore ratchet. See check-mutation-test-coverage.mjs.
- run: npm run check:mutation-test-coverage
- run: npm run check:any-budget:t11
# Build-scope guard: fails if worktrees/cruft leak into the tsconfig include
# scope (would OOM `next build`). Instant. See incident 2026-06-25 / #5031.
@@ -138,39 +60,25 @@ jobs:
# leaking into the npm tarball (v3.8.36: 6 ops bin/*.sh) per-PR instead of only on
# the release PR's heavy Package Artifact job.
- run: npm run check:pack-policy
# Complexity + cognitive-complexity: ONE ESLint walk (both baselines still
# enforced separately by ruleId). Avoids two cold tree walks on fast-path.
- run: npm run check:complexity-ratchets
# Complexity + cognitive-complexity ratchets on the fast-path (PR→release) so
# cycle drift is rebaselined PER-PR instead of cascading onto the release PR's
# Quality Ratchet (v3.8.36: +30 complexity / +15 cognitive surfaced only post-merge).
- run: npm run check:complexity
- run: npm run check:cognitive-complexity
- name: Typecheck (core)
run: npm run typecheck:core
# #7033: dashboard-scoped typecheck gate — src/app/(dashboard) TSX is not
# covered by typecheck:core's curated allowlist. See check-dashboard-typecheck.mjs.
- name: Typecheck (dashboard)
run: npm run check:dashboard-typecheck
# WS4.2 (v3.8.49 plan): TypeScript 7 native-compiler SHADOW — advisory only.
# TS7 went GA 2026-07-08 with 8-12x type-check speedups; its Compiler API only
# arrives in 7.1, so typescript-eslint / type-coverage / Stryker stay on 6.x
# (the hybrid is the officially documented pattern). Isolated npx on purpose:
# installing an alias package could collide node_modules/.bin/tsc with 6.x.
# Promote to the blocking gate after ~1 week of parity with the step above.
- name: Typecheck (core) — TS7 native shadow (advisory)
continue-on-error: true
run: |
RC=0
START=$(date +%s)
npx -y -p typescript@7 tsc --pretty false -p tsconfig.typecheck-core.json || RC=$?
echo "[ts7-shadow] exit=$RC elapsed=$(( $(date +%s) - START ))s — the 6.x step above stays authoritative"
exit $RC
# TIA: build the impact map at runtime (gitignored, ~21MB) and run only the
# unit tests impacted by this PR's changed files. On hub/unmapped changes the
# selector returns __RUN_ALL__ — full-suite authority is the parallel
# `fast-unit` 4-shard job (test:unit:ci:shard; was 2-shard, #6781), NOT an
# unsharded re-run here. Stacking unsharded test:unit:ci on top of fast-unit
# doubled wall time (~16 min extra on ubuntu-latest) without extra coverage.
# unit tests impacted by this PR's changed files. Fail-safe runs the FULL
# unit suite on hub/unmapped changes — TIA accelerates, never replaces, the net.
#
# BLOCKING for the *impacted subset* (flipped 2026-06-17). Fail-safe full
# coverage remains required via `Unit Tests fast-path` (fast-unit).
- name: Impacted unit tests (TIA subset; blocking)
# BLOCKING (flipped 2026-06-17). The pre-existing release unit test-debt that kept
# this advisory was cleared: #4030 (16 Zod/registry reds, lossless restore) and
# #4063 (the last red — the LiveWS boot test — root-caused as a real event-loop
# stall in the WS sidecar, fixed + relocated to the integration suite). A full
# ci.yml run on release/v3.8.28 then showed all 8 unit shards green, so PR->release
# now blocks on unit-test regressions in the impacted set (typecheck:core already
# blocked above). Fail-safe still runs the FULL unit suite on hub/unmapped changes.
- name: Impacted unit tests (TIA, fail-safe full; blocking)
env:
GITHUB_BASE_REF: ${{ github.base_ref }}
run: |
@@ -184,39 +92,15 @@ jobs:
# which must not happen on a blocking gate. DATA_DIR isolation keeps the parallel
# run race-free regardless of concurrency.
if echo "$SEL" | grep -q "__RUN_ALL__"; then
echo "Fail-safe: __RUN_ALL__ — deferring FULL unit suite to fast-unit (4-shard)."
echo "Not re-running unsharded test:unit:ci here (duplicate of fast-unit coverage)."
exit 0
echo "Fail-safe: running FULL unit suite (CI concurrency)"; npm run test:unit:ci; exit $?
fi
echo "Running impacted tests:"; echo "$SEL"
mapfile -t FILES <<< "$SEL"
# Loader parity with test:unit:ci:shard (#6787): tests/unit/dashboard/** runs
# under `--import tsx` (CJS transform — required for ESM-only deep imports like
# @lobehub/icons/es/* reached via lobeProviderIcons.ts); everything else under
# `--import tsx/esm`. A single tsx/esm invocation false-reds every dashboard
# module-shape test the impact map selects ("Unexpected token 'export'").
DASH=(); REST=()
for f in "${FILES[@]}"; do
case "$f" in
tests/unit/dashboard/*) DASH+=("$f") ;;
*) REST+=("$f") ;;
esac
done
RC=0
if [ ${#REST[@]} -gt 0 ]; then
node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${REST[@]}" || RC=$?
fi
if [ ${#DASH[@]} -gt 0 ]; then
node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${DASH[@]}" || RC=$?
fi
exit $RC
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)
needs: changes
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
runs-on: ubuntu-latest
env:
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-lint-api-key-secret-long
@@ -225,38 +109,20 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
# WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast-path runs on EVERY PR,
# which is where flaky-detection volume actually comes from (ci.yml's heavy
# jobs only run on the release PR). Advisory upload, own-origin only.
- run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-fastpath.xml
- name: Upload test results to Trunk (advisory)
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
continue-on-error: true
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
with:
junit-paths: trunk-junit/**/*.xml
org-slug: omniroute
token: ${{ secrets.TRUNK_TOKEN }}
- run: npm run test:vitest
fast-unit:
name: Unit Tests fast-path (${{ matrix.shard }}/4)
needs: changes
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
# This is the heaviest fast-path job; 4-way sharding (was 2, #6781) halves the
# critical path again (~8.5min → ~4.5min on ubuntu-latest; ~2min on the 8-slot
# runner box). Node's native --test-shard=N/total takes any denominator — only
# this matrix and the TEST_SHARD env below encode the shard count.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
name: Unit Tests fast-path (${{ matrix.shard }}/2)
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
shard: [1, 2, 3, 4]
shard: [1, 2]
env:
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-lint-api-key-secret-long
@@ -265,90 +131,14 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
- uses: actions/setup-node@v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
# QW-d: fonte única — o mesmo npm script do CI pesado/local. Fecha dois drifts do
# comando inline antigo: os dirs `memory` e `usage` estavam FORA do glob (testes
# silenciosamente não rodavam no fast path) e o setupPolyfill não era importado.
- run: npm run test:unit:ci:shard
env:
TEST_SHARD: ${{ matrix.shard }}/4
# ── Pacote 4 (plano mestre testes+CI, aprovado 2026-07-04) ─────────────────────────
# No-new-warnings por PR via ESLint bulk suppressions nativo (>=9.24). O baseline
# config/quality/eslint-suppressions.json congela as violações EXISTENTES por
# arquivo+regra; qualquer warning NOVO aparece e o --max-warnings 0 falha o job — o
# drift de +41/+88 warnings por ciclo passa a morrer no PR que o introduz, em vez de
# ser rebaselinado às cegas na release. Aperto do baseline (na reconciliação da
# release): npx eslint . --prune-suppressions --suppressions-location config/quality/eslint-suppressions.json
#
# Princípio Zero: bloqueante SÓ para branches internas (as campanhas/sessões são a
# origem do drift). PR de FORK roda em modo report (continue-on-error → o job fica
# verde com anotação; a campanha /green-prs aplica o fix via co-autoria — o
# contribuidor NUNCA é bloqueado nem cobrado).
lint-guard:
name: No new ESLint warnings
needs: changes
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }}
runs-on: ubuntu-latest
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- name: Restore ESLint file cache
uses: actions/cache@v6
with:
path: |
.eslintcache
.eslintcache-complexity
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
restore-keys: |
eslint-${{ runner.os }}-
- name: ESLint (baseline congelado — warning novo = vermelho)
# lint:json writes the report; --max-warnings 0 keeps no-new-warnings policy.
run: npm run lint:json -- --max-warnings 0
# Merge-integrity: pega no PR os dois vazamentos crônicos de merge que hoje só
# explodem na release-PR. (1) CHANGELOG-eat — o auto-resolve do merge come
# bullets vizinhos/seções inteiras (incidente #6193, 2026-07-05: 212 linhas /
# 130 bullets); o checkout de PR é refs/pull/N/merge, então comparar contra a
# base detecta o eat ANTES do merge. (2) SKILL.md gerado stale vs o catálogo de
# agent-skills (#6186 mergeou um id de catálogo sem rodar o gerador → 8 reds de
# integration invisíveis até a release).
#
# Princípio Zero: bloqueante SÓ para branches internas; PR de FORK roda em modo
# report (continue-on-error) — a campanha corrige via co-autoria, o contribuidor
# nunca é bloqueado.
merge-integrity:
name: Merge integrity (changelog + generated skills)
# Always on non-draft PRs — CHANGELOG/skills can break on docs-only merges too.
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) }}
runs-on: ubuntu-latest
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with:
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- run: npm ci
- name: CHANGELOG integrity (nenhum bullet da base pode sumir no merge-result)
run: npm run check:changelog-integrity
- name: Agent-skills generator sync (SKILL.md gerado ≡ catálogo)
run: npm run check:agent-skills-sync
- 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"

View File

@@ -9,7 +9,7 @@ name: Wiki Sync
# It does NOT overwrite existing wiki pages by default: several docs sources still carry
# stale counts (e.g. ARCHITECTURE.md says "177 providers" while the wiki cover is 226),
# so blind overwrite would regress the wiki. Full content parity (--update-existing) is
# gated on regenerating those sources first.
# gated on regenerating those sources — see docs/ops/DOCUMENTATION_AUDIT_REPORT.md.
on:
push:
@@ -40,7 +40,7 @@ jobs:
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
uses: actions/setup-node@v6
with:
node-version: "24"

21
.gitignore vendored
View File

@@ -72,7 +72,6 @@ yarn-error.log*
# env files (can opt-in for committing if needed)
.env*
!.env.example
!.env.homolog.example
# Provider API keys (never commit)
*.api-key
.nvidia-api-key
@@ -230,23 +229,3 @@ docs/prompts/AGENT-OWNERSHIP-PROTOCOL.md
docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute-mim.md
docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute-mid.md
omniroute.md
# mise configuration
mise.toml
_artifacts/ # release-green artifacts
.claude-flow/
# ESLint file cache (npm run lint --cache / complexity ratchets)
.eslintcache
.eslintcache-complexity
# CI/local quality artifacts (eslint-results.json, quality-ratchet.md, etc.)
.artifacts/
# Homologation E2E suite (npm run homolog) — real-environment credentials + report output
.env.homolog
tests/homolog/.auth/
tests/homolog/ui/.auth/
homolog-report/
docker-compose.yml.bak

View File

@@ -74,16 +74,3 @@
# '''tests/unit/''',
# ]
#
[[rules]]
# Falsos-positivos comprovados do generic-api-key — zerados em 2026-07-13 (WS6/D3,
# plano v3.8.49). Revisar em v3.9.0. Nenhum é credencial: dois são NOMES DE CAMPO
# de métricas de latência; o terceiro é o valor PÚBLICO de um beta header da API
# da Anthropic (documentado publicamente, não é segredo).
id = "generic-api-key"
[rules.allowlist]
description = "Field names + public Anthropic beta-header value (não são segredos)"
regexes = [
'''latencyP\d{2}Ms''',
'''interleaved-thinking-2025-05-14''',
]

View File

@@ -1,9 +1,7 @@
#!/usr/bin/env sh
# .husky/pre-push — intentionally light.
# any-budget + tracked-artifacts already run on pre-commit; re-running them on
# every push only doubles local wall time for the same existence reason (CI still
# enforces both). Keep this hook as a PATH/npm sanity check + reminder.
# Intentionally excludes test:unit / typecheck (slow; covered by CI).
# .husky/pre-push — fast deterministic gates (<10s total)
# Intentionally excludes test:unit (slow; covered by CI pre-push remote run).
# Activated: 2026-06-13 (6A.12 — replaced commented-out test:unit stub)
if ! command -v npm >/dev/null 2>&1; then
echo "⚠️ npm not found in PATH — skipping pre-push hooks"
@@ -11,5 +9,4 @@ if ! command -v npm >/dev/null 2>&1; then
exit 0
fi
# No-op success: real local gates live in pre-commit; CI owns the rest.
exit 0
npm run check:any-budget:t11 && npm run check:tracked-artifacts

17
.mcp.json.example Normal file
View File

@@ -0,0 +1,17 @@
{
"$comment_purpose": "OPT-IN agent-lsp / LSP-in-the-loop (Quality Gates Fase 7 Task 15). Copy this file to `.mcp.json` to enable. It exposes a TypeScript language server to coding agents (Claude Code, etc.) so they get diagnostics / hover / go-to-definition / blast-radius BEFORE writing code — turning 'invented symbol' review-catches into impossible-at-edit-time. Pairs with `npm run typecheck:core` as a compile-before-claim check.",
"$comment_safety": "Shipped as `.example` (NOT `.mcp.json`) on purpose so it never auto-loads an unvetted server into everyone's session. Pick an MCP<->LSP bridge you trust and have verified locally, then drop in its package + args below. A broken MCP entry only logs a connection error; it does not break agent sessions. The underlying language server is `typescript-language-server` (npm, mature) — install via `npm i -g typescript-language-server typescript` or rely on npx.",
"mcpServers": {
"typescript-lsp": {
"command": "npx",
"args": [
"-y",
"<your-mcp-lsp-bridge>",
"--lsp",
"typescript-language-server",
"--stdio"
],
"$note": "Replace <your-mcp-lsp-bridge> with the concrete MCP<->LSP adapter you chose. It must speak MCP on stdio and proxy to `typescript-language-server --stdio`. Scope it to this repo's tsconfig (open-sse/tsconfig.json / tsconfig.json) for accurate diagnostics."
}
}
}

View File

@@ -1,55 +0,0 @@
# Mergify merge queue — WS3.4/D5 of the v3.8.49 quality/velocity master plan.
#
# WHY: ~85-100 active PR authors/month and 300+ PRs/week peaks, all merged by ONE
# identity. The manual merge-train validated batches by hand; this queue automates
# it with batching + automatic batch bisection (a red batch of N costs ~log2(N)
# revalidations instead of N). Mergify Open Source plan: free, unlimited, public repo.
#
# GOVERNANCE (non-negotiable, mirrors CLAUDE.md Hard Rules #21/#22 + the owner's
# pre-merge ⭐ gate):
# • A PR enters the queue ONLY via the `queue` label — applied by the owner (or a
# session acting for the owner) AFTER the pre-merge ⭐ report/decision. The label
# IS the merge approval; Mergify only executes it.
# • During a release-freeze (open issue labeled `release-freeze`), do NOT label PRs
# targeting the frozen branch — the freeze is a human-honored coordination signal
# the queue cannot see. Retarget to the active release/vX+1 first (Hard Rule #21).
# • Never label a PR another session is actively working (Hard Rule #22b).
# • Fallback path if Mergify misbehaves or the OSS plan changes: the manual
# merge-train runbook (docs/ops/MERGE_TRAIN.md) — remove labels, proceed by hand.
queue_rules:
- name: release
# Any current or future release branch — the reason GitHub's native queue was
# rejected (no wildcard support on personal-account repos).
queue_conditions:
- base~=^release/v\d+\.\d+\.\d+$
- label=queue
- -draft
- -conflict
# "Everything that ran is green, nothing still running, AND the always-on
# anchor check succeeded" — robust to the path-filtered fast-gates (docs-only
# PRs skip code jobs; matrix shard names vary) while never fail-open: a PR with
# zero checks cannot vacuously merge, because `Merge integrity` runs on EVERY
# non-draft PR (quality.yml) and must be an affirmative success. Review approval
# is intentionally NOT a condition here: the owner-applied `queue` label IS the
# approval in this repo's single-maintainer model (see governance header).
merge_conditions:
- "#check-failure=0"
- "#check-pending=0"
- "#check-success>=1"
- check-success=Merge integrity (changelog + generated skills)
# Batching: validate up to 10 queued PRs together (the manual train's sweet spot);
# don't hold a lone PR hostage waiting for siblings.
batch_size: 10
batch_max_wait_time: 5 min
# Squash keeps the one-commit-per-PR history the CHANGELOG reconciliation expects.
merge_method: squash
pull_request_rules:
- name: clean up the queue label after merge
conditions:
- merged
actions:
label:
remove:
- queue

10
.npmrc
View File

@@ -2,13 +2,3 @@
# Keeping peer auto-install disabled prevents npm from pulling @lobehub/ui/mermaid
# back into the tree and reopening npm audit findings for unused packages.
legacy-peer-deps=true
# Network resilience: enlarge npm's fetch retry budget so a transient registry
# socket reset (ECONNRESET) mid-download retries instead of failing the job.
# npm defaults to only 2 retries with short timeouts; `npm ci` in
# electron-release.yml hit ECONNRESET during v3.8.41 publish. Applies to every
# CI workflow (electron / docker / unit) and local installs.
fetch-retries=5
fetch-retry-factor=4
fetch-retry-mintimeout=20000
fetch-retry-maxtimeout=120000

View File

@@ -1,5 +0,0 @@
# Long reference tables are manually aligned; formatting the whole file causes noisy diffs.
docs/reference/ENVIRONMENT.md
# Dense auto-generated free-tier budget rows (one object per line) — prettier multi-line expand blows past file-size cap 800.
open-sse/config/freeModelCatalog.data.ts

View File

@@ -1,22 +0,0 @@
# .trivyignore — accepted-risk suppressions for the container image scan
#
# Policy (see docs/security/SUPPLY_CHAIN.md):
# - The Trivy steps in .github/workflows/docker-publish.yml run with
# `ignore-unfixed: true`, so vulnerabilities WITHOUT a published fix are
# already excluded from both the blocking CRITICAL gate and the advisory
# Security-tab upload. You do NOT need an entry here for an unfixable
# base-image OS CVE — it will not be reported.
# - This file is the single auditable home for the rare case where a *fixable*
# CVE must be temporarily accepted (e.g. the upstream fix is not yet in the
# pinned base tag, or the affected package/binary is provably unreachable
# from the proxy request surface and rebuilding now is not justified).
#
# Format — one CVE id per line, each with a justification comment and, where
# possible, an expiry, e.g.:
# # CVE-XXXX-YYYY — <why accepted>; revisit on next base-image bump (YYYY-MM-DD)
# CVE-XXXX-YYYY
#
# Keep this list SHORT and reviewed every release. Prefer fixing (rebuild on a
# patched base / bump the dep) over suppressing. Stale entries are debt.
#
# (No accepted-risk suppressions at present — ignore-unfixed covers the noise.)

10
.vscode/settings.json vendored
View File

@@ -48,19 +48,11 @@
"**/.build",
"**/dist",
"**/coverage",
"**/.worktrees",
"**/.claude/worktrees",
"**/electron",
"**/_references",
"**/_mono_repo",
"**/_tasks"
"**/.worktrees"
]
},
// Para esconder os diretórios gerados da árvore do Explorer, descomente:
// (MANTIDO comentado — o dono precisa ver _references/_mono_repo/_tasks na árvore.
// A performance é resolvida por watcherExclude + search.exclude + tsserver, sem
// precisar escondê-los do Explorer.)
// "files.exclude": {
// "**/.worktrees": true,
// "**/coverage": true,

View File

@@ -165,7 +165,7 @@ npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prepro
| Dynamic `/v1/models` | Pulls live catalog (455+ entries on prod) on each refresh, TTL-cached | `provider.models` |
| Variants pass-through | `-low`/`-medium`/`-high`/`-thinking` ship as first-class IDs from OmniRoute (no client synthesis) | `provider.models` |
| Combo LCD aggregation | Combos appear with intersected capabilities + min context/output across members | `provider.models` + `config` |
| `combo/<slug>` namespace + `Combo:` prefix | Combos surface under `combo/claude-primary` (not the upstream UUID) and the picker shows `Combo: claude-primary` so they stand apart from raw provider/model pairs | both hooks |
| `combo/<slug>` namespace + `Combo: ` prefix | Combos surface under `combo/claude-primary` (not the upstream UUID) and the picker shows `Combo: claude-primary` so they stand apart from raw provider/model pairs | both hooks |
| Nice names + cost | `/api/pricing/models` display names AND `/api/pricing` per-million-token cost overlaid onto the live catalog | both hooks |
| Canonical-twin dedup + alias-fallback | `/v1/models` exposes the same upstream model under both short alias (`cc/claude-opus-4-7`) and canonical name (`claude/claude-opus-4-7`); the plugin drops the canonical twin when an alias twin exists (no duplicate rows in the picker) and reverse-maps canonical → alias to pick up enrichment for short aliases (`dg/nova-3 → Deepgram - Nova 3`) that `/api/pricing/models` only indexes by canonical | both hooks |
| Compression pipeline tags | Combo names get tagged with their compression pipeline (e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`) when `features.compressionMetadata: true`. Intensity tokens render as a traffic-light emoji: 🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra | both hooks |
@@ -179,33 +179,30 @@ npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prepro
## Plugin options
| Option | Type | Default | Description |
| --------------------- | -------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
| `providerId` | `string` | `"omniroute"` | OpenCode provider id; must be unique across plugin entries |
| `displayName` | `string` | `"OmniRoute"` or `OmniRoute (<id>)` | Label in the OC UI |
| `modelCacheTtl` | `number` | `300000` (5 min) | `/v1/models` TTL in ms |
| `baseURL` | `string` | resolved from `auth.json` after `/connect` | Override OmniRoute base URL |
| `managementReadToken` | `string` | falls back to `apiKey` | Optional read-only token for management catalog GETs; `/v1` inference stays on the connected `apiKey` |
| `features` | `object` | see below | Feature toggles (all opt-in/out, defaults preserve v0.1.0) |
For least-privilege deployments, set top-level `managementReadToken` to a read-only management token. It is sent only to catalog reads (`/api/combos`, `/api/combos/auto`, `/api/pricing/models`, `/api/pricing`, `/api/context/combos`, and `/api/providers`). Inference requests under `/v1`, including chat, continue to use the `apiKey` stored by OpenCode. `features.mcpToken` remains independent. If `managementReadToken` is omitted, catalog reads retain the previous `apiKey` behavior.
| Option | Type | Default | Description |
| --------------- | -------- | ------------------------------------------ | ---------------------------------------------------------- |
| `providerId` | `string` | `"omniroute"` | OpenCode provider id; must be unique across plugin entries |
| `displayName` | `string` | `"OmniRoute"` or `OmniRoute (<id>)` | Label in the OC UI |
| `modelCacheTtl` | `number` | `300000` (5 min) | `/v1/models` TTL in ms |
| `baseURL` | `string` | resolved from `auth.json` after `/connect` | Override OmniRoute base URL |
| `features` | `object` | see below | Feature toggles (all opt-in/out, defaults preserve v0.1.0) |
### `features` block
Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.json` files do not need to change.
| Feature | Type | Default | What it does |
| --------------------- | --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `combos` | `boolean` | `true` | Discover `/api/combos` and surface them as pseudo-models with LCD capabilities. Combos are keyed under the `combo/<slug>` namespace and labelled `Combo: <name>` in the model picker so they're distinguishable from raw provider/model pairs. |
| `enrichment` | `boolean` | `true` | Pull display names from `/api/pricing/models` AND per-million-token pricing (`input`, `output`, `cached``cacheRead`, `cache_creation``cacheWrite`) from `/api/pricing`, then overlay both onto the live catalog (so the UI shows `Claude 4.7 Opus` with `cost.input: 5`, `cost.output: 25` instead of raw IDs and zeroed cost). |
| `compressionMetadata` | `boolean` | `false` | Pull `/api/context/combos` so combo names get tagged with their compression pipeline, e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`. Intensity tokens render as traffic-light emoji (🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra) so the picker advertises "how compressed" each combo is at a glance. |
| `providerTag` | `boolean` | `true` | Prepend a short upstream-provider label to the enriched display name with `" - "` separator, so `cc/claude-opus-4-7 → Claude - Claude Opus 4.7` differs visibly from `kr/claude-opus-4-7 → Kiro - Claude Opus 4.7` in the OC TUI model picker. Label resolution: use `/api/pricing/models[<alias>].name` verbatim when ≤8 chars (e.g. `Claude`, `Kiro`, `Codex`, `Qwen`), otherwise fall back to `UPPER(alias)` (e.g. `GitHub Models``GHM`, `Gemini``GEMINI`). Idempotent. Combos intentionally skipped (the `Combo:` prefix already conveys multi-upstream). |
| `usableOnly` | `boolean` | `false` | Read `/api/providers` and filter the catalog to providers that have at least one connection with `isActive: true` AND `testStatus: 'active'`. Subtract-filter semantics: providers unknown to BOTH the pricing-models catalog AND the connection table pass through (so synthetic prefixes like `agentrouter/*` survive). On fetch failure the filter is disabled for the refresh — never hides the whole catalog. |
| `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-<providerId>.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write — never blocks publishing. |
| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` |
| `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.<providerId>` remote entry into the OC config pointing at `<baseURL>/api/mcp/stream` with the resolved Bearer token |
| `mcpToken` | `string` | _unset_ | Optional separate Bearer for the auto-emitted MCP entry. Falls back to the provider's `apiKey` (from `auth.json`) when unset |
| `fetchInterceptor` | `boolean` | `true` | Inject `Authorization: Bearer` + default `Content-Type` on every outbound request targeting `baseURL` (suffix-spoof guarded) |
| Feature | Type | Default | What it does |
| --------------------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `combos` | `boolean` | `true` | Discover `/api/combos` and surface them as pseudo-models with LCD capabilities. Combos are keyed under the `combo/<slug>` namespace and labelled `Combo: <name>` in the model picker so they're distinguishable from raw provider/model pairs. |
| `enrichment` | `boolean` | `true` | Pull display names from `/api/pricing/models` AND per-million-token pricing (`input`, `output`, `cached``cacheRead`, `cache_creation``cacheWrite`) from `/api/pricing`, then overlay both onto the live catalog (so the UI shows `Claude 4.7 Opus` with `cost.input: 5`, `cost.output: 25` instead of raw IDs and zeroed cost). |
| `compressionMetadata` | `boolean` | `false` | Pull `/api/context/combos` so combo names get tagged with their compression pipeline, e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`. Intensity tokens render as traffic-light emoji (🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra) so the picker advertises "how compressed" each combo is at a glance. |
| `providerTag` | `boolean` | `true` | Prepend a short upstream-provider label to the enriched display name with `" - "` separator, so `cc/claude-opus-4-7 → Claude - Claude Opus 4.7` differs visibly from `kr/claude-opus-4-7 → Kiro - Claude Opus 4.7` in the OC TUI model picker. Label resolution: use `/api/pricing/models[<alias>].name` verbatim when ≤8 chars (e.g. `Claude`, `Kiro`, `Codex`, `Qwen`), otherwise fall back to `UPPER(alias)` (e.g. `GitHub Models``GHM`, `Gemini-cli``GEMINI-CLI`). Idempotent. Combos intentionally skipped (the `Combo: ` prefix already conveys multi-upstream). |
| `usableOnly` | `boolean` | `false` | Read `/api/providers` and filter the catalog to providers that have at least one connection with `isActive: true` AND `testStatus: 'active'`. Subtract-filter semantics: providers unknown to BOTH the pricing-models catalog AND the connection table pass through (so synthetic prefixes like `agentrouter/*` survive). On fetch failure the filter is disabled for the refresh — never hides the whole catalog. |
| `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-<providerId>.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write — never blocks publishing. |
| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` |
| `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.<providerId>` remote entry into the OC config pointing at `<baseURL>/api/mcp/stream` with the resolved Bearer token |
| `mcpToken` | `string` | _unset_ | Optional separate Bearer for the auto-emitted MCP entry. Falls back to the provider's `apiKey` (from `auth.json`) when unset |
| `fetchInterceptor` | `boolean` | `true` | Inject `Authorization: Bearer` + default `Content-Type` on every outbound request targeting `baseURL` (suffix-spoof guarded) |
#### Example — enrichment + compression tags + MCP auto-emit
@@ -217,7 +214,6 @@ Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.
{
"providerId": "omniroute",
"baseURL": "https://or.example.com",
"managementReadToken": "<read-only-management-token>",
"features": {
"combos": true,
"enrichment": true,

View File

@@ -1,12 +1,12 @@
{
"name": "@omniroute/opencode-plugin",
"version": "0.2.0",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@omniroute/opencode-plugin",
"version": "0.2.0",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"zod": "^4.4.3"

View File

@@ -1,6 +1,6 @@
{
"name": "@omniroute/opencode-plugin",
"version": "0.2.0",
"version": "0.1.0",
"description": "OpenCode plugin for the OmniRoute AI Gateway. Drives dynamic model discovery, /connect auth flow, and multi-instance OmniRoute providers via the official @opencode-ai/plugin contract.",
"type": "module",
"main": "./dist/index.js",
@@ -23,7 +23,7 @@
"scripts": {
"build": "tsup",
"clean": "rm -rf dist",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts",
"prepublishOnly": "npm run clean && npm run build && npm test"
},
"keywords": [

View File

@@ -91,10 +91,6 @@ import {
* - `baseURL` Override base URL for this OmniRoute instance. When
* absent, the loader falls back to a credential-attached
* baseURL set by `/connect`.
* - `managementReadToken` Optional read-only management-plane bearer token.
* Used only for catalog GETs under `/api/*`; `/v1/*`
* inference continues to use the connected `apiKey`.
* Falls back to `apiKey` when unset.
*/
/**
* Optional feature toggles. Every field is opt-in/out per call; defaults
@@ -126,9 +122,8 @@ import {
* provider's API key (from auth.json) when unset.
* Useful when a narrower-scoped MCP-only key is
* preferred over the chat/inference key.
* - `fetchInterceptor` Inject Authorization: Bearer + Content-Type only
* on same-origin `/v1/chat/completions` and
* `/v1/models` requests. Default true.
* - `fetchInterceptor` Inject Authorization: Bearer + Content-Type on
* every outbound request to baseURL. Default true.
* - `debugLog` Capture every outbound request + response to a
* JSONL file at
* `~/.local/share/opencode/plugins/omniroute-debug-{providerId}.jsonl`.
@@ -191,7 +186,6 @@ const optionsSchema = z
displayName: z.string().min(1).optional(),
modelCacheTtl: z.number().positive().optional(),
baseURL: z.string().url().optional(),
managementReadToken: z.string().min(1).optional(),
features: featuresSchema.optional(),
})
.strict();
@@ -242,26 +236,11 @@ function trimLeadingDashes(value: string): string {
* applying defaults. Centralises the providerId fallback so every hook
* sees a consistent identifier.
*/
export function resolveOmniRoutePluginOptions(opts?: OmniRoutePluginOptions): Required<
Pick<OmniRoutePluginOptions, "providerId" | "displayName" | "modelCacheTtl">
> & {
/**
* #6859: the UNPREFIXED provider id ("omniroute", "omniroute-preprod", …).
* `providerId` above is auto-prefixed with "opencode-" ONLY to satisfy OC
* 1.17.8+'s native-adapter gate ({openai, anthropic, opencode*}) — that
* prefixed value is OC-internal and must be used ONLY for AuthHook.provider
* and provider-registration keys (the OC config-hook top-level
* `provider.<id>` block). `omnirouteProviderId` MUST be used everywhere an
* identifier reaches or represents something OmniRoute's own server parses
* (model `id` prefix, `ModelV2.providerID`, combo catalog keys in the
* dynamic provider hook) — OmniRoute's `parseModel()` has no alias for
* "opencode-<x>", so a prefixed id there is unrecoverable and credential
* lookup fails with "No credentials for opencode-<x>".
*/
omnirouteProviderId: string;
} & Pick<OmniRoutePluginOptions, "baseURL" | "managementReadToken" | "features"> {
export function resolveOmniRoutePluginOptions(
opts?: OmniRoutePluginOptions
): Required<Pick<OmniRoutePluginOptions, "providerId" | "displayName" | "modelCacheTtl">> &
Pick<OmniRoutePluginOptions, "baseURL" | "features"> {
const rawProviderId = opts?.providerId ?? OMNIROUTE_PROVIDER_KEY;
const omnirouteProviderId = trimLeadingOpencodePrefix(rawProviderId);
// OC 1.17.8+ native-adapter gate rejects providerID not in
// {openai, anthropic, opencode*}. Silently prefix so existing
// configs (providerId: "omniroute") keep working.
@@ -279,29 +258,13 @@ export function resolveOmniRoutePluginOptions(opts?: OmniRoutePluginOptions): Re
: DEFAULT_MODEL_CACHE_TTL_MS;
return {
providerId,
omnirouteProviderId,
displayName,
modelCacheTtl,
baseURL: opts?.baseURL,
managementReadToken: opts?.managementReadToken,
features: opts?.features,
};
}
/**
* Strip a leading "opencode-" prefix (added only for the OC native-adapter
* gate — see `resolveOmniRoutePluginOptions`) so the returned id is safe to
* embed in anything OmniRoute's own server parses. A user-supplied
* `providerId: "opencode-omniroute"` (already prefixed) resolves to the same
* unprefixed "omniroute" as the default, matching `providerId`'s own
* idempotent-prefix handling above.
*/
function trimLeadingOpencodePrefix(rawProviderId: string): string {
return rawProviderId.startsWith("opencode-")
? rawProviderId.slice("opencode-".length)
: rawProviderId;
}
/**
* Strict parse of raw plugin options (as received from opencode.json or a
* direct factory call) into the validated `OmniRoutePluginOptions` shape.
@@ -862,14 +825,6 @@ export interface OmniRouteRawCombo {
isHidden?: boolean;
/** When OmniRoute attaches a lifecycle hint we forward it; today it doesn't. */
release_date?: string;
/**
* Server-computed context window for this combo (aggregated from member
* models using the same logic as /v1/models). When present, the client
* uses this value directly instead of re-aggregating from member models.
*
* Added in 3.9.x — old servers do not send it.
*/
computed_context_length?: number;
}
/**
@@ -1074,12 +1029,7 @@ export function mapComboToModelV2(
cache: { read: 0, write: 0 },
},
limit: {
context:
typeof combo.computed_context_length === "number" && combo.computed_context_length > 0
? combo.computed_context_length
: contextValues.length > 0
? Math.min(...contextValues)
: 0,
context: contextValues.length > 0 ? Math.min(...contextValues) : 0,
...(everyDeclaresInput ? { input: Math.min(...inputValues) } : {}),
output: outputValues.length > 0 ? Math.min(...outputValues) : 0,
},
@@ -1275,7 +1225,7 @@ export interface OmniRouteEnrichmentEntry {
cacheWrite?: number;
};
/**
* Provider alias prefix seen in `/v1/models` ids (e.g. `cc`, `gemini`).
* Provider alias prefix seen in `/v1/models` ids (e.g. `cc`, `gemini-cli`).
* Populated by `defaultOmniRouteEnrichmentFetcher` from
* `/api/pricing/models` keys. Drives the `usableOnly` alias↔canonical
* resolution.
@@ -1283,7 +1233,7 @@ export interface OmniRouteEnrichmentEntry {
providerAlias?: string;
/**
* Canonical provider id used by `/api/providers` connections (e.g.
* `claude`, `gemini`, `kiro`). Populated from the per-provider
* `claude`, `gemini-cli`, `kiro`). Populated from the per-provider
* `entry.id` field inside `/api/pricing/models`.
*/
providerCanonical?: string;
@@ -2096,7 +2046,7 @@ export function formatCompressionPipeline(pipeline: OmniRouteCompressionStep[]):
export interface OmniRouteProviderConnection {
/** Connection UUID. */
id: string;
/** Canonical provider id, e.g. `claude`, `gemini`, `kiro`. Matches `entry.id` in `/api/pricing/models`. */
/** Canonical provider id, e.g. `claude`, `gemini-cli`, `kiro`. Matches `entry.id` in `/api/pricing/models`. */
provider: string;
/** Connection auth flavor, e.g. `apikey`, `oauth`, `cookie`. */
authType?: string;
@@ -2175,7 +2125,7 @@ export const defaultOmniRouteProvidersFetcher: OmniRouteProvidersFetcher = async
* walk only the namespaced keys to derive the alias↔canonical mapping).
*
* Returns:
* - `aliases`: set of alias prefixes safe to keep (e.g. `cc`, `gemini`).
* - `aliases`: set of alias prefixes safe to keep (e.g. `cc`, `gemini-cli`).
* - `canonicals`: set of canonical provider ids (e.g. `claude`, `kiro`).
*
* Callers should treat membership in EITHER set as "usable" — raw model
@@ -2224,7 +2174,7 @@ export function usableProviderAliasSet(
}
// Always include every usable canonical as an alias too — handles the
// common case where `/v1/models` ids use the canonical id directly
// (e.g. `gemini/gemini-1.5-pro`).
// (e.g. `gemini-cli/gemini-1.5-pro`).
for (const canonical of usableCanonicals) aliases.add(canonical);
return { aliases, canonicals: usableCanonicals, knownAliases };
}
@@ -2355,14 +2305,12 @@ export function buildComboKey(
}
/**
* Internal cache key: `${baseURL}::sha256(credentialId)`. The credential id
* combines the inference key and effective management-read token so catalog
* results fetched under different permissions never share an entry. We hash
* it so the cache key is safe to inspect without leaking either secret.
* Different credential tuples MUST keep independent cache entries: a single
* OC user may register prod + preprod OmniRoute side-by-side with distinct
* keys, and serving one's catalog from the other's cache would be a
* correctness bug, not just a privacy one.
* Internal cache key: `${baseURL}::sha256(apiKey)`. We hash the apiKey so
* the key is safe to log / inspect via debugger without leaking the secret.
* Different (baseURL, apiKey) tuples MUST keep independent cache entries:
* a single OC user may register prod + preprod OmniRoute side-by-side with
* distinct keys, and serving one's catalog from the other's cache would be
* a correctness bug, not just a privacy one.
*/
// codeql[js/insufficient-password-hash]: the input here is an API-key
// identifier we use solely to derive an in-memory cache lookup key — it is
@@ -2513,9 +2461,6 @@ export function createOmniRouteProviderHook(
return {};
}
const apiKey = (auth as { key: string }).key;
// Management-plane catalog reads may use a narrower read-only token.
// Backward compatibility: when unset, retain the historical apiKey use.
const managementReadToken = resolved.managementReadToken ?? apiKey;
// baseURL resolution: plugin opts first, then credential-attached
// baseURL (auth backends sometimes stash it next to the key), then the
@@ -2545,7 +2490,7 @@ export function createOmniRouteProviderHook(
return {};
}
const cacheKey = modelsCacheKey(baseURL, `${apiKey}\0${managementReadToken}`);
const cacheKey = modelsCacheKey(baseURL, apiKey);
const t = now();
const cached = cache.get(cacheKey);
@@ -2577,7 +2522,7 @@ export function createOmniRouteProviderHook(
rawCombos = [];
if (wantCombos) {
try {
rawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
rawCombos = await combosFetcher(baseURL, apiKey, 10_000);
} catch (err) {
console.warn(
"[omniroute-plugin] combos fetch failed, falling back to models-only catalog",
@@ -2592,7 +2537,7 @@ export function createOmniRouteProviderHook(
rawAutoCombos = [];
if (wantAutoCombos) {
try {
rawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000);
rawAutoCombos = await autoCombosFetcher(baseURL, apiKey, 5_000);
} catch {
// Already handled inside the default fetcher — this catch
// is belt-and-suspenders for injected stubs.
@@ -2604,7 +2549,7 @@ export function createOmniRouteProviderHook(
rawEnrichment = new Map();
if (wantEnrichment) {
try {
rawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000);
rawEnrichment = await enrichmentFetcher(baseURL, apiKey, 10_000);
} catch (err) {
console.warn(
"[omniroute-plugin] enrichment fetch failed, falling back to raw ids",
@@ -2618,11 +2563,7 @@ export function createOmniRouteProviderHook(
rawCompressionCombos = [];
if (wantCompressionMeta) {
try {
rawCompressionCombos = await compressionMetaFetcher(
baseURL,
managementReadToken,
10_000
);
rawCompressionCombos = await compressionMetaFetcher(baseURL, apiKey, 10_000);
} catch (err) {
console.warn("[omniroute-plugin] compression-metadata fetch failed", err);
}
@@ -2636,7 +2577,7 @@ export function createOmniRouteProviderHook(
rawConnections = [];
if (wantUsableOnly) {
try {
rawConnections = await providersFetcher(baseURL, managementReadToken, 10_000);
rawConnections = await providersFetcher(baseURL, apiKey, 10_000);
} catch (err) {
console.warn(
"[omniroute-plugin] /api/providers fetch failed; usableOnly filter disabled for this refresh",
@@ -2720,8 +2661,7 @@ export function createOmniRouteProviderHook(
if (canonicalDedup.has(entry.id)) continue;
if (usable && !isUsableRawModelId(entry.id, usable, rawEnrichment)) continue;
const model = mapRawModelToModelV2(entry, {
// #6859: server-facing id — NOT the OC-gate-prefixed `resolved.providerId`.
providerId: resolved.omnirouteProviderId,
providerId: resolved.providerId,
baseURL,
apiFormat: resolved.features?.apiFormat,
});
@@ -2886,8 +2826,7 @@ export function createOmniRouteProviderHook(
const mapped = mapComboToModelV2(
combo,
memberEntries,
// #6859: server-facing id — NOT the OC-gate-prefixed `resolved.providerId`.
resolved.omnirouteProviderId,
resolved.providerId,
baseURL,
features.apiFormat
);
@@ -2906,8 +2845,7 @@ export function createOmniRouteProviderHook(
}
}
// #6859: server-facing key — NOT the OC-gate-prefixed `resolved.providerId`.
const comboKey = buildComboKey(combo, usedComboKeys, resolved.omnirouteProviderId);
const comboKey = buildComboKey(combo, usedComboKeys, resolved.providerId);
// Collision policy: combos win. Warn ONCE per (cacheKey, comboKey)
// when overwriting a same-key raw model so the operator can spot
@@ -3009,8 +2947,7 @@ export function createOmniRouteProviderHook(
},
status: "active",
release_date: "",
// #6859: server-facing id — NOT the OC-gate-prefixed `resolved.providerId`.
providerID: resolved.omnirouteProviderId,
providerID: resolved.providerId,
options: {},
headers: {},
};
@@ -3024,18 +2961,17 @@ export function createOmniRouteProviderHook(
}
// ────────────────────────────────────────────────────────────────────────────
// Fetch interceptor (T-04) — Bearer + Content-Type injection on intended
// same-origin OmniRoute inference requests only
// Fetch interceptor (T-04) — Bearer + Content-Type injection on outbound
// provider requests targeting the configured OmniRoute baseURL
// ────────────────────────────────────────────────────────────────────────────
/**
* Build a `fetch`-compatible interceptor that injects `Authorization: Bearer`
* (and a default `Content-Type`) only onto same-origin requests targeting
* `<base>/chat/completions` or `<base>/models`, where `<base>` is the
* configured baseURL path normalized to end in `/v1`. Management, MCP, and
* unrelated inference paths pass through untouched. The apiKey is treated as
* a secret bound to both the configured OmniRoute origin and these intended
* inference endpoints, and MUST NOT leak elsewhere.
* (and a default `Content-Type`) onto outbound requests targeting the given
* `baseURL`. Requests to any other host pass through untouched — the apiKey
* is treated as a secret bound to the configured OmniRoute instance and
* MUST NOT leak to third-party endpoints (a vector AI-SDKs occasionally
* exercise when a tool call rewrites the URL mid-flight).
*
* Ported from Alph4d0g's `opencode-omniroute-auth@1.2.1` `createFetchInterceptor`
* (their `dist/src/plugin.js:477-516`) with these intentional deviations:
@@ -3062,35 +2998,17 @@ export function createOmniRouteFetchInterceptor(config: {
apiKey: string;
baseURL: string;
}): typeof fetch {
let baseOrigin: string | undefined;
const inferencePaths = new Set<string>();
try {
const baseUrl = new URL(config.baseURL);
baseOrigin = baseUrl.origin;
const basePath = ensureV1Suffix(baseUrl.pathname);
inferencePaths.add(`${basePath}/chat/completions`);
inferencePaths.add(`${basePath}/models`);
} catch {
// Credential-attached base URLs are not schema-validated. A malformed
// value must disable injection rather than broaden the credential scope.
}
const trimmed = trimTrailingSlashes(config.baseURL);
// Use `<base>/` for prefix matching to prevent suffix-spoof attacks
// (e.g. baseURL `https://or.example.com/v1` should NOT match
// `https://or.example.com/v1-attacker.evil/...`).
const prefix = `${trimmed}/`;
return async (input, init = {}) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
let requestUrl: URL | undefined;
try {
requestUrl = new URL(url);
} catch {
// Native fetch will report malformed/relative URLs as usual. We only
// decline to attach credentials before forwarding the original input.
}
const normalizedPath = requestUrl ? trimTrailingSlashes(requestUrl.pathname) || "/" : undefined;
const targetsInference =
requestUrl?.origin === baseOrigin &&
normalizedPath !== undefined &&
inferencePaths.has(normalizedPath);
if (!targetsInference) {
const targetsOmniRoute = url === trimmed || url.startsWith(prefix);
if (!targetsOmniRoute) {
return fetch(input, init);
}
@@ -3256,6 +3174,7 @@ export function sanitizeGeminiToolSchemas(payload: unknown): unknown {
* `gemini-2.5-flash`, etc.)
* - `models/gemini-…` (Google Generative AI canonical id form)
* - `google-vertex/gemini-…` (OpenCode + AI-SDK Vertex routing prefix)
* - `gemini-cli/…` (real OmniRoute alias surfaced on b35 prod `/v1/models`)
*
* Liberal by design: a false positive (cleaning a payload that didn't
* need cleaning) costs only a structuredClone + one walk; a false negative
@@ -3939,16 +3858,7 @@ export function buildStaticProviderEntry(
// `combo/MASTER` as provider=`combo`. Slug collisions across
// combos are disambiguated with a short UUID-prefix suffix; see
// `buildComboKey` for the policy.
// #6859: server-facing key — NOT the OC-gate-prefixed `opts.providerId`.
// OC dispatches the static-catalog `models` map key VERBATIM as the
// `model` field of the outbound `@ai-sdk/openai-compatible` request
// (only the top-level `provider["<id>"]` segment is stripped for
// routing) — so a bare-slug combo key prefixed with the OC-gated
// `opts.providerId` reaches OmniRoute's server doubled
// (`opencode-omniroute/opencode-omniroute/<slug>`), and `parseModel()`
// resolves credentials for the nonexistent provider `opencode-omniroute`
// instead of `omniroute`. See #7976.
models[buildComboKey(combo, usedComboKeys, opts.omnirouteProviderId)] = entry;
models[buildComboKey(combo, usedComboKeys, opts.providerId)] = entry;
// Make this combo's resolved entry available to parent combos
// that reference it via combo-ref. Use the friendly name since
@@ -4041,9 +3951,7 @@ type AuthJsonShape = Record<string, AuthJsonApiEntry | { type?: string; [k: stri
/** Disk snapshot envelope. Versioned for forward-compat. */
interface OmniRouteDiskSnapshot {
v: 2;
/** Opaque identity for normalized baseURL + both effective credentials. */
identityFingerprint: string;
v: 1;
rawModels: OmniRouteRawModelEntry[];
rawCombos: OmniRouteRawCombo[];
rawAutoCombos?: OmniRouteRawAutoCombo[];
@@ -4063,53 +3971,22 @@ export function diskSnapshotPath(providerId: string): string {
export type OmniRouteDiskSnapshotWriter = (
providerId: string,
entry: Omit<OmniRouteFetchCacheEntry, "expiresAt">,
identityFingerprint: string
entry: Omit<OmniRouteFetchCacheEntry, "expiresAt">
) => Promise<void>;
export type OmniRouteDiskSnapshotReader = (
providerId: string,
identityFingerprint: string
providerId: string
) => Promise<Omit<OmniRouteFetchCacheEntry, "expiresAt"> | undefined>;
/**
* Bind a snapshot to the endpoint and effective credential tuple without
* persisting any raw token. This opaque value is only stored and compared;
* it is never logged or included in generated provider configuration.
*/
function diskSnapshotIdentityFingerprint(
baseURL: string,
apiKey: string,
managementReadToken: string
): string {
let normalizedBaseURL: string;
try {
const parsed = new URL(baseURL);
parsed.hash = "";
parsed.pathname = trimTrailingSlashes(parsed.pathname) || "/";
normalizedBaseURL = parsed.toString();
} catch {
normalizedBaseURL = trimTrailingSlashes(baseURL);
}
return createHash("sha256")
.update(JSON.stringify([normalizedBaseURL, apiKey, managementReadToken]))
.digest("hex");
}
/** Best-effort disk write. Soft-fails on any I/O error (no exception thrown). */
export const defaultDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async (
providerId,
entry,
identityFingerprint
) => {
export const defaultDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async (providerId, entry) => {
try {
const file = diskSnapshotPath(providerId);
// Restrict perms to the owner: the snapshot lives alongside auth.json
// (0o600) and embeds provider topology + masked connection records.
await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
const snapshot: OmniRouteDiskSnapshot = {
v: 2,
identityFingerprint,
v: 1,
rawModels: entry.rawModels,
rawCombos: entry.rawCombos,
rawAutoCombos: entry.rawAutoCombos,
@@ -4128,22 +4005,12 @@ export const defaultDiskSnapshotWriter: OmniRouteDiskSnapshotWriter = async (
};
/** Best-effort disk read. Returns `undefined` when missing/corrupt/unreadable. */
export const defaultDiskSnapshotReader: OmniRouteDiskSnapshotReader = async (
providerId,
identityFingerprint
) => {
export const defaultDiskSnapshotReader: OmniRouteDiskSnapshotReader = async (providerId) => {
try {
const file = diskSnapshotPath(providerId);
const body = await readFile(file, "utf8");
const parsed = JSON.parse(body) as Partial<OmniRouteDiskSnapshot>;
if (
!parsed ||
parsed.v !== 2 ||
typeof parsed.identityFingerprint !== "string" ||
parsed.identityFingerprint !== identityFingerprint
) {
return undefined;
}
if (!parsed || parsed.v !== 1) return undefined;
return {
rawModels: Array.isArray(parsed.rawModels) ? parsed.rawModels : [],
rawCombos: Array.isArray(parsed.rawCombos) ? parsed.rawCombos : [],
@@ -4567,9 +4434,6 @@ export function createOmniRouteConfigHook(
);
return;
}
// Management-plane catalog reads may use a narrower read-only token.
// Backward compatibility: when unset, retain the historical apiKey use.
const managementReadToken = resolved.managementReadToken ?? apiKey;
// baseURL resolution: opts.baseURL wins, then auth.json's stored baseURL.
// No silent localhost default — a misconfigured plugin should surface a
@@ -4586,12 +4450,7 @@ export function createOmniRouteConfigHook(
// Try the shared cache first. On OC ≥1.14.49 the provider hook may have
// populated it moments earlier; on OC ≤1.14.48 only this hook runs but
// the cache still works (single producer + consumer through one Map).
const cacheKey = modelsCacheKey(baseURL, `${apiKey}\0${managementReadToken}`);
const snapshotFingerprint = diskSnapshotIdentityFingerprint(
baseURL,
apiKey,
managementReadToken
);
const cacheKey = modelsCacheKey(baseURL, apiKey);
const t = now();
const cached = cache.get(cacheKey);
@@ -4632,7 +4491,7 @@ export function createOmniRouteConfigHook(
rawCombos = [];
try {
rawCombos = await combosFetcher(baseURL, managementReadToken, 10_000);
rawCombos = await combosFetcher(baseURL, apiKey, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/combos fetch failed; publishing models-only static catalog",
@@ -4643,7 +4502,7 @@ export function createOmniRouteConfigHook(
rawAutoCombos = [];
if (wantAutoCombos) {
try {
rawAutoCombos = await autoCombosFetcher(baseURL, managementReadToken, 5_000);
rawAutoCombos = await autoCombosFetcher(baseURL, apiKey, 5_000);
} catch {
// Already handled inside the default fetcher
}
@@ -4659,7 +4518,7 @@ export function createOmniRouteConfigHook(
rawEnrichment = new Map();
if (wantEnrichment) {
try {
rawEnrichment = await enrichmentFetcher(baseURL, managementReadToken, 10_000);
rawEnrichment = await enrichmentFetcher(baseURL, apiKey, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/pricing/models fetch failed; publishing raw-id static catalog",
@@ -4674,7 +4533,7 @@ export function createOmniRouteConfigHook(
rawCompressionCombos = [];
if (wantCompressionMeta) {
try {
rawCompressionCombos = await compressionMetaFetcher(baseURL, managementReadToken, 10_000);
rawCompressionCombos = await compressionMetaFetcher(baseURL, apiKey, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/context/combos fetch failed; publishing combos without compression suffix",
@@ -4690,7 +4549,7 @@ export function createOmniRouteConfigHook(
rawConnections = [];
if (wantUsableOnly) {
try {
rawConnections = await providersFetcher(baseURL, managementReadToken, 10_000);
rawConnections = await providersFetcher(baseURL, apiKey, 10_000);
} catch (err) {
logger.warn(
"[omniroute-plugin] config shim: /api/providers fetch failed; usableOnly filter disabled for this refresh",
@@ -4706,7 +4565,7 @@ export function createOmniRouteConfigHook(
// a healthy refresh; staleness is bounded only by how recently the
// user was online.
if (modelsFetchThrew && wantDiskCache) {
const snapshot = await diskSnapshotReader(resolved.providerId, snapshotFingerprint);
const snapshot = await diskSnapshotReader(resolved.providerId);
if (snapshot && snapshot.rawModels.length > 0) {
logger.warn(
`[omniroute-plugin] config shim: /v1/models unreachable; using stale disk cache (${snapshot.rawModels.length} models)`
@@ -4751,18 +4610,14 @@ export function createOmniRouteConfigHook(
// Best-effort; soft-fail keeps us moving when the data dir isn't
// writable (e.g. read-only container).
if (modelsFetchOk && wantDiskCache) {
await diskSnapshotWriter(
resolved.providerId,
{
rawModels,
rawCombos,
rawAutoCombos,
rawEnrichment,
rawCompressionCombos,
rawConnections,
},
snapshotFingerprint
);
await diskSnapshotWriter(resolved.providerId, {
rawModels,
rawCombos,
rawAutoCombos,
rawEnrichment,
rawCompressionCombos,
rawConnections,
});
}
}

View File

@@ -447,14 +447,14 @@ test("models() returns combo entries merged into the map", async () => {
// 3 raw models + 1 combo = 4 entries
assert.equal(Object.keys(out).length, 4);
assert.ok(out["omniroute/claude-primary"]);
assert.ok(out["omniroute/claude-secondary"]);
assert.ok(out["omniroute/gemini-3-flash"]);
assert.ok(out["omniroute/claude-tier"]);
assert.ok(out["opencode-omniroute/claude-primary"]);
assert.ok(out["opencode-omniroute/claude-secondary"]);
assert.ok(out["opencode-omniroute/gemini-3-flash"]);
assert.ok(out["opencode-omniroute/claude-tier"]);
const combo = out["omniroute/claude-tier"];
const combo = out["opencode-omniroute/claude-tier"];
assert.equal(combo.name, "Claude Tier");
assert.equal(combo.providerID, "omniroute");
assert.equal(combo.providerID, "opencode-omniroute");
// LCD over claude-primary (200k, reasoning) + claude-secondary (100k, no reasoning)
assert.equal(combo.limit.context, 100_000);
assert.equal(combo.capabilities.reasoning, false);
@@ -478,11 +478,11 @@ test("models(): combo with unknown member ids degrades to all-false LCD posture"
{ fetcher: modelsFetcher, combosFetcher }
);
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
assert.ok(out["omniroute/phantom-combo"]);
assert.ok(out["opencode-omniroute/phantom-combo"]);
// With zero resolvable members, LCD = all-false (defensive posture).
assert.equal(out["omniroute/phantom-combo"].capabilities.toolcall, false);
assert.equal(out["omniroute/phantom-combo"].capabilities.reasoning, false);
assert.equal(out["omniroute/phantom-combo"].limit.context, 0);
assert.equal(out["opencode-omniroute/phantom-combo"].capabilities.toolcall, false);
assert.equal(out["opencode-omniroute/phantom-combo"].capabilities.reasoning, false);
assert.equal(out["opencode-omniroute/phantom-combo"].limit.context, 0);
});
test("models(): hidden combos are excluded from the map", async () => {
@@ -505,8 +505,8 @@ test("models(): hidden combos are excluded from the map", async () => {
{ fetcher: modelsFetcher, combosFetcher }
);
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
assert.ok(out["omniroute/visible"]);
assert.ok(!out["omniroute/hidden"], "hidden combo must be omitted");
assert.ok(out["opencode-omniroute/visible"]);
assert.ok(!out["opencode-omniroute/hidden"], "hidden combo must be omitted");
});
test("models(): combo name exactly matches raw model id → raw deleted, raw deleted, no warn", async () => {
@@ -530,8 +530,8 @@ test("models(): combo name exactly matches raw model id → raw deleted, raw del
});
// Raw model replaced by combo of the same key; combo now lives at the bare slug.
assert.ok(out["omniroute/claude-primary"], "combo surfaces under prefixed key");
assert.equal(out["omniroute/claude-primary"].name, "claude-primary");
assert.ok(out["opencode-omniroute/claude-primary"], "combo surfaces under prefixed key");
assert.equal(out["opencode-omniroute/claude-primary"].name, "claude-primary");
// No collision warning fires — dedup makes keys disjoint.
const collisionWarns = warnings.filter((w) => {
@@ -565,8 +565,8 @@ test("models(): two combos with same slug → second gets disambiguator suffix",
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
// First combo gets the bare slug; second gets disambiguated.
assert.ok(out["omniroute/claude"], "first combo at prefixed slug");
assert.ok(out["omniroute/claude-uuid"], "second combo disambiguated by id prefix");
assert.ok(out["opencode-omniroute/claude"], "first combo at prefixed slug");
assert.ok(out["opencode-omniroute/claude-uuid"], "second combo disambiguated by id prefix");
});
test("models(): combos fetch fails → falls back to models-only, warn emitted, no throw", async () => {
@@ -583,8 +583,8 @@ test("models(): combos fetch fails → falls back to models-only, warn emitted,
// Catalog includes the models but NOT any combo entries.
assert.equal(Object.keys(out).length, 2);
assert.ok(out["omniroute/claude-primary"]);
assert.ok(out["omniroute/claude-secondary"]);
assert.ok(out["opencode-omniroute/claude-primary"]);
assert.ok(out["opencode-omniroute/claude-secondary"]);
// Soft-fail warning surfaced.
const softFail = warnings.find((w) => {
@@ -609,7 +609,7 @@ test("models(): combos cached + reused within TTL (one combo fetch per TTL windo
const second = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
assert.equal(combosFetcher.callCount(), 1, "combos fetched only once within TTL");
assert.equal(modelsFetcher.callCount(), 1, "models fetched only once within TTL");
assert.ok(second["omniroute/claude-tier"]);
assert.ok(second["opencode-omniroute/claude-tier"]);
});
test("models(): combos refetched after TTL expiry (same key as models)", async () => {
@@ -701,7 +701,7 @@ test("models(): nested combo-ref context is the min of nested + raw members", as
{ fetcher: modelsFetcher, combosFetcher }
);
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
const masterLight = out["omniroute/master-light"];
const masterLight = out["opencode-omniroute/master-light"];
assert.ok(masterLight, "MASTER-LIGHT entry must exist");
assert.equal(
masterLight.limit.context,

View File

@@ -248,7 +248,7 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
// Combo surfaces under bare key + LCD'd
// (gemini's reasoning=false → combo reasoning=false).
const combo = entry.models["omniroute/claude-tier"];
const combo = entry.models["opencode-omniroute/claude-tier"];
assert.ok(combo, "combo surfaced under bare key");
assert.equal(combo.name, "Claude Tier");
assert.equal(combo.reasoning, false, "LCD: any member reasoning=false → combo reasoning=false");
@@ -474,7 +474,7 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m
"opencode-omniroute/claude-sonnet-4-6",
"opencode-omniroute/gemini-3-flash",
]);
assert.equal(entry.models["omniroute/claude-tier"], undefined, "no combo entry");
assert.equal(entry.models["opencode-omniroute/claude-tier"], undefined, "no combo entry");
assert.ok(
logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")),
"combos-fetch breadcrumb emitted"
@@ -748,7 +748,7 @@ test("buildStaticProviderEntry: hidden combos are excluded", () => {
"https://or.example/v1",
"sk-test"
);
assert.equal(block.models["omniroute/claude-tier"], undefined);
assert.equal(block.models["opencode-omniroute/claude-tier"], undefined);
assert.ok(block.models["opencode-omniroute/claude-sonnet-4-6"]);
});
@@ -858,7 +858,7 @@ test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)
"https://or.example/v1",
"sk-test"
);
const combo = block.models["omniroute/mixed-tier"];
const combo = block.models["opencode-omniroute/mixed-tier"];
assert.ok(combo, "combo emitted under slug key");
// claude has text+image, text-only has text → intersection drops image.
assert.deepEqual(combo.modalities?.input, ["text"]);
@@ -970,7 +970,7 @@ test("config: enrichment fetched + name overlaid on raw-model entries", async ()
assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
assert.equal(entry.models["opencode-omniroute/gemini-3-flash"].name, "Gemini 3 Flash");
// Combo names still come from /api/combos — enrichment overlay does NOT touch combos.
assert.equal(entry.models["omniroute/claude-tier"].name, "Claude Tier");
assert.equal(entry.models["opencode-omniroute/claude-tier"].name, "Claude Tier");
assert.equal(enrichmentFetcher.callCount(), 1);
});
@@ -1311,9 +1311,9 @@ test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-
"gemini-3-flash",
{
name: "Gemini 3 Flash",
providerAlias: "gemini",
providerCanonical: "gemini",
providerDisplayName: "Gemini",
providerAlias: "gemini-cli",
providerCanonical: "gemini-cli",
providerDisplayName: "Gemini-cli",
},
],
])
@@ -1335,9 +1335,12 @@ test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
"Claude - Claude Sonnet 4.6"
);
assert.equal(entry.models["opencode-omniroute/gemini-3-flash"].name, "Gemini - Gemini 3 Flash");
assert.equal(
entry.models["opencode-omniroute/gemini-3-flash"].name,
"Gemini-cli - Gemini 3 Flash"
);
// Combos stay untouched — `Combo: ` prefix already conveys multi-upstream.
assert.equal(entry.models["omniroute/claude-tier"].name, "Claude Tier");
assert.equal(entry.models["opencode-omniroute/claude-tier"].name, "Claude Tier");
});
test("config: providerTag=false suppresses the suffix", async () => {
@@ -1516,7 +1519,7 @@ test("buildStaticProviderEntry: nested combo-ref context is the bottleneck acros
);
// Pre-fix: Parent would advertise 200_000 (only raw-big counted).
// Post-fix: Parent should advertise 8_000 (TinyCombo bottleneck).
const parent = block.models["omniroute/parent"];
const parent = block.models["opencode-omniroute/parent"];
assert.ok(parent, "Parent combo must be in the static catalog");
assert.equal(parent.limit?.context, 8_000);
});

View File

@@ -40,7 +40,7 @@ test("defaultDiskSnapshotWriter writes an owner-only (no group/other) snapshot",
process.env.OPENCODE_DATA_DIR = tmp;
try {
await defaultDiskSnapshotWriter("perm-test", makeEntry(), "test-snapshot-identity");
await defaultDiskSnapshotWriter("perm-test", makeEntry());
const file = diskSnapshotPath("perm-test");
assert.ok(fs.existsSync(file), "snapshot file should be written");

View File

@@ -376,8 +376,7 @@ test("provider hook: enrichment fetcher called when features.enrichment !== fals
);
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
assert.equal(called, 1, "enrichment fetcher called once");
// #6859: dynamic-hook catalog keys use the unprefixed omnirouteProviderId.
const m = out["omniroute/claude-sonnet-4-6"];
const m = out["opencode-omniroute/claude-sonnet-4-6"];
assert.equal(m.name, "Claude Sonnet 4.6", "enrichment name overlay applied");
assert.equal(m.cost.input, 3, "enrichment pricing applied");
assert.equal(m.cost.output, 15);
@@ -403,7 +402,7 @@ test("provider hook: enrichment fetcher NOT called when features.enrichment:fals
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
assert.equal(called, 0, "enrichment fetcher NOT called when gated off");
assert.equal(
out["omniroute/claude-sonnet-4-6"].name,
out["opencode-omniroute/claude-sonnet-4-6"].name,
"claude-sonnet-4-6",
"raw id preserved"
);
@@ -464,7 +463,7 @@ test("provider hook: compression metadata fetcher called when opted in", async (
);
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
assert.equal(called, 1, "compression metadata fetcher called");
const combo = out["omniroute/claude-primary"];
const combo = out["opencode-omniroute/claude-primary"];
assert.ok(combo, "combo entry present");
assert.match(
combo.name,

View File

@@ -50,52 +50,6 @@ test("createOmniRouteFetchInterceptor: targets baseURL → Authorization header
}
});
test("createOmniRouteFetchInterceptor: path-prefixed baseURL scopes auth to its normalized inference paths", async () => {
const { calls, restore } = installFetchRecorder();
try {
const prefixedBase = "https://or.example.com/tenant-a/v1";
const f = createOmniRouteFetchInterceptor({
apiKey: KEY,
baseURL: `${prefixedBase}///`,
});
const streamingBody = '{"stream":true}';
await f(`${prefixedBase}/chat/completions?trace=1`, {
method: "POST",
body: streamingBody,
headers: { Accept: "text/event-stream" },
});
await f(`${prefixedBase}/models/?refresh=1`);
await f("https://or.example.com/v1/chat/completions", { method: "POST", body: "{}" });
await f("https://or.example.com/v1/models");
await f("https://or.example.com/tenant-b/v1/chat/completions", {
method: "POST",
body: "{}",
});
await f(`${prefixedBase}/chat/completions/batch`, { method: "POST", body: "{}" });
const headers = calls.map(({ init }) => new Headers(init?.headers));
assert.equal(headers[0]?.get("Authorization"), `Bearer ${KEY}`);
assert.equal(headers[1]?.get("Authorization"), `Bearer ${KEY}`);
for (const index of [2, 3, 4, 5]) {
assert.equal(headers[index]?.get("Authorization"), null);
}
assert.equal(calls[0]?.input, `${prefixedBase}/chat/completions?trace=1`);
assert.equal(calls[0]?.init?.body, streamingBody);
assert.equal(headers[0]?.get("Accept"), "text/event-stream");
const suffixingInterceptor = createOmniRouteFetchInterceptor({
apiKey: KEY,
baseURL: "https://or.example.com/tenant-a/",
});
await suffixingInterceptor(`${prefixedBase}/models`);
const suffixedHeaders = new Headers(calls[6]?.init?.headers);
assert.equal(suffixedHeaders.get("Authorization"), `Bearer ${KEY}`);
} finally {
restore();
}
});
test("createOmniRouteFetchInterceptor: targets baseURL → Authorization OVERRIDES caller-supplied Bearer", async () => {
const { calls, restore } = installFetchRecorder();
try {
@@ -305,7 +259,7 @@ test("loader integration: wired interceptor actually injects Bearer when invoked
{} as never
);
const wiredFetch = (result as { fetch: typeof fetch }).fetch;
await wiredFetch(`${BASE}/models`, {});
await wiredFetch(`${BASE}/v1/models`, {});
assert.equal(calls.length, 1);
const sentHeaders = new Headers((calls[0]!.init as RequestInit).headers);
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);

View File

@@ -212,8 +212,8 @@ test("shouldSanitizeForGemini: google-vertex/gemini-1.5-flash → true", () => {
assert.equal(shouldSanitizeForGemini({ model: "google-vertex/gemini-1.5-flash" }), true);
});
test("shouldSanitizeForGemini: gemini/gemini-2.5-pro → true", () => {
assert.equal(shouldSanitizeForGemini({ model: "gemini/gemini-2.5-pro" }), true);
test("shouldSanitizeForGemini: gemini-cli/gemini-2.5-pro → true (real OmniRoute alias)", () => {
assert.equal(shouldSanitizeForGemini({ model: "gemini-cli/gemini-2.5-pro" }), true);
});
test("shouldSanitizeForGemini: claude-sonnet-4 → false", () => {

View File

@@ -1,271 +0,0 @@
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";
import {
createOmniRouteAuthHook,
createOmniRouteConfigHook,
createOmniRouteProviderHook,
parseOmniRoutePluginOptions,
type OmniRouteCompressionMetaFetcher,
type OmniRouteEnrichmentFetcher,
type OmniRouteProvidersFetcher,
type OmniRouteRawModelEntry,
} from "../src/index.js";
const BASE_URL = "https://or.example.com/v1";
const API_KEY = "sk-inference-only";
const MANAGEMENT_READ_TOKEN = "sk-management-read-only";
const RAW_MODELS: OmniRouteRawModelEntry[] = [
{
id: "openai/gpt-test",
context_length: 16_000,
max_output_tokens: 4_000,
capabilities: {
tool_calling: true,
reasoning: false,
vision: false,
thinking: false,
temperature: true,
},
input_modalities: ["text"],
output_modalities: ["text"],
},
];
function apiAuth(key: string) {
return { type: "api" as const, key };
}
test("options: managementReadToken is accepted and preserved", () => {
const parsed = parseOmniRoutePluginOptions({ managementReadToken: MANAGEMENT_READ_TOKEN });
assert.equal(parsed.managementReadToken, MANAGEMENT_READ_TOKEN);
});
test("provider hook: management GET fetchers use managementReadToken while /v1 uses apiKey", async () => {
const calls: Array<[string, string]> = [];
const enrichmentFetcher: OmniRouteEnrichmentFetcher = async (_baseURL, token) => {
calls.push(["pricing", token]);
return new Map();
};
const compressionMetaFetcher: OmniRouteCompressionMetaFetcher = async (_baseURL, token) => {
calls.push(["context", token]);
return [];
};
const providersFetcher: OmniRouteProvidersFetcher = async (_baseURL, token) => {
calls.push(["providers", token]);
return [];
};
const hook = createOmniRouteProviderHook(
{
baseURL: BASE_URL,
managementReadToken: MANAGEMENT_READ_TOKEN,
features: { compressionMetadata: true, usableOnly: true },
},
{
fetcher: async (_baseURL, token) => {
calls.push(["models", token]);
return RAW_MODELS;
},
combosFetcher: async (_baseURL, token) => {
calls.push(["combos", token]);
return [];
},
autoCombosFetcher: async (_baseURL, token) => {
calls.push(["auto-combos", token]);
return [];
},
enrichmentFetcher,
compressionMetaFetcher,
providersFetcher,
}
);
await hook.models!({} as never, { auth: apiAuth(API_KEY) as never });
assert.deepEqual(calls, [
["models", API_KEY],
["combos", MANAGEMENT_READ_TOKEN],
["auto-combos", MANAGEMENT_READ_TOKEN],
["pricing", MANAGEMENT_READ_TOKEN],
["context", MANAGEMENT_READ_TOKEN],
["providers", MANAGEMENT_READ_TOKEN],
]);
});
test("provider hook: absent managementReadToken preserves apiKey fallback", async () => {
const calls: Array<[string, string]> = [];
const hook = createOmniRouteProviderHook(
{ baseURL: BASE_URL, features: { enrichment: false, autoCombos: false } },
{
fetcher: async (_baseURL, token) => {
calls.push(["models", token]);
return RAW_MODELS;
},
combosFetcher: async (_baseURL, token) => {
calls.push(["combos", token]);
return [];
},
}
);
await hook.models!({} as never, { auth: apiAuth(API_KEY) as never });
assert.deepEqual(calls, [
["models", API_KEY],
["combos", API_KEY],
]);
});
test("config hook: managementReadToken stays out of provider inference and MCP config", async () => {
const calls: Array<[string, string]> = [];
const hook = createOmniRouteConfigHook(
{
baseURL: BASE_URL,
managementReadToken: MANAGEMENT_READ_TOKEN,
features: { enrichment: false, autoCombos: false, diskCache: false, mcpAutoEmit: true },
},
{
readAuthJson: async () => ({
"opencode-omniroute": { type: "api" as const, key: API_KEY },
}),
fetcher: async (_baseURL, token) => {
calls.push(["models", token]);
return RAW_MODELS;
},
combosFetcher: async (_baseURL, token) => {
calls.push(["combos", token]);
return [];
},
logger: { warn: () => {} },
}
);
const input: { provider?: Record<string, any>; mcp?: Record<string, any> } = {};
await hook(input as never);
assert.deepEqual(calls, [
["models", API_KEY],
["combos", MANAGEMENT_READ_TOKEN],
]);
assert.equal(input.provider?.["opencode-omniroute"]?.options?.apiKey, API_KEY);
assert.equal(
input.mcp?.["opencode-omniroute"]?.headers?.Authorization,
`Bearer ${API_KEY}`,
"mcpAutoEmit remains independent of managementReadToken"
);
});
test("auth fetch: only intended same-origin inference paths receive apiKey", async () => {
const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ input, init });
return new Response("ok");
}) as typeof fetch;
try {
const hook = createOmniRouteAuthHook({
baseURL: `${BASE_URL}/`,
managementReadToken: MANAGEMENT_READ_TOKEN,
});
const loaded = await hook.loader!(async () => apiAuth(API_KEY) as never, {} as never);
const interceptedFetch = (loaded as { fetch: typeof fetch }).fetch;
const streamingBody = '{"stream":true}';
await interceptedFetch(`${BASE_URL}/chat/completions?trace=1`, {
method: "POST",
body: streamingBody,
headers: { Accept: "text/event-stream" },
});
await interceptedFetch(`${BASE_URL}/models/?refresh=1`);
await interceptedFetch("https://or.example.com/api/combos");
await interceptedFetch("https://or.example.com/api/mcp/stream");
await interceptedFetch("https://or.example.com/v1/embeddings");
await interceptedFetch("https://third-party.example/v1/chat/completions", {
method: "POST",
body: "{}",
});
const headers = calls.map(({ init }) => new Headers(init?.headers));
assert.equal(headers[0]?.get("Authorization"), `Bearer ${API_KEY}`);
assert.equal(headers[1]?.get("Authorization"), `Bearer ${API_KEY}`);
for (const index of [2, 3, 4, 5]) {
assert.equal(headers[index]?.get("Authorization"), null);
}
assert.equal(calls[0]?.input, `${BASE_URL}/chat/completions?trace=1`);
assert.equal(calls[0]?.init?.body, streamingBody);
assert.equal(headers[0]?.get("Accept"), "text/event-stream");
assert.equal(
calls.some(({ init }) =>
[...new Headers(init?.headers).values()].some((value) =>
value.includes(MANAGEMENT_READ_TOKEN)
)
),
false,
"managementReadToken must never enter inference fetch headers"
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("disk cache: snapshot written under management token A is rejected under token B", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-token-snapshot-"));
const previousDataDir = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = tmp;
try {
const commonDeps = {
readAuthJson: async () => ({
"opencode-omniroute": {
type: "api" as const,
key: API_KEY,
baseURL: BASE_URL,
},
}),
combosFetcher: async () => [],
logger: { warn: () => {} },
};
const features = {
enrichment: false,
autoCombos: false,
diskCache: true,
} as const;
const tokenAHook = createOmniRouteConfigHook(
{ managementReadToken: "token-A", features },
{
...commonDeps,
fetcher: async () => RAW_MODELS,
}
);
await tokenAHook({} as never);
const tokenBHook = createOmniRouteConfigHook(
{ managementReadToken: "token-B", features },
{
...commonDeps,
fetcher: async () => {
throw new Error("offline");
},
}
);
const input: { provider?: Record<string, { models: Record<string, unknown> }> } = {};
await tokenBHook(input as never);
assert.deepEqual(
input.provider?.["opencode-omniroute"]?.models,
{},
"catalog from token A must not hydrate after switching to token B"
);
} finally {
if (previousDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = previousDataDir;
fs.rmSync(tmp, { recursive: true, force: true });
}
});

View File

@@ -1,141 +0,0 @@
/**
* Regression test for #6859.
*
* `resolveOmniRoutePluginOptions()` auto-prefixes `providerId` with
* `"opencode-"` (commit 75b52e286) so OpenCode 1.17.8+'s native-adapter gate
* accepts it as an OC-registered provider id. That prefixed value must stay
* OC-internal (AuthHook.provider / provider registration keys) — it must
* NEVER leak into the identifiers OmniRoute's own server parses to resolve
* credentials (`mapRawModelToModelV2`'s `id`/`providerID`,
* `mapComboToModelV2`'s `providerID`, and the dynamic-hook catalog keys).
*
* OmniRoute's server-side `parseModel()` (open-sse/services/model.ts) splits
* a dispatched model string on `/` to recover the provider name and look up
* credentials. If the plugin embeds the OC-gate-prefixed id in that string,
* the server looks up credentials for a provider named "opencode-omniroute"
* (which never exists in `src/shared/constants/providers.ts`) instead of
* "omniroute" — producing the exact "No credentials for opencode-omniroute" /
* "No active credentials for provider: opencode-omniroute" errors reported
* in #6859.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
buildStaticProviderEntry,
createOmniRouteProviderHook,
mapRawModelToModelV2,
resolveOmniRoutePluginOptions,
type OmniRouteRawCombo,
} from "../src/index.js";
/**
* Minimal stand-in for OmniRoute's own `parseModel()` (open-sse/services/
* model.ts), which splits a dispatched `<providerID>/<modelID>` string on the
* FIRST "/" to recover the provider name used for credential lookup. Kept
* local (rather than cross-importing the real module) so this package's
* self-contained test suite (`cd @omniroute/opencode-plugin && npm test`)
* doesn't depend on the root repo's `@/*` path-alias resolution.
*/
function splitProviderFromDispatchedModel(modelStr: string): string {
const idx = modelStr.indexOf("/");
return idx === -1 ? modelStr : modelStr.slice(0, idx);
}
const apiAuth = (key: string) => ({ type: "api" as const, key });
test("#6859: server-facing model id/providerID must resolve to the unprefixed provider name", () => {
const resolved = resolveOmniRoutePluginOptions();
// The OC-gate-compatible id stays prefixed — it is legitimate for
// AuthHook.provider / provider registration.
assert.equal(resolved.providerId, "opencode-omniroute");
// A second, unprefixed id must be exposed for anything that reaches
// OmniRoute's own server (model id prefix, ModelV2.providerID, combo keys).
assert.equal(
resolved.omnirouteProviderId,
"omniroute",
"resolveOmniRoutePluginOptions() must expose an unprefixed omnirouteProviderId"
);
// A bare raw /v1/models entry (no existing "/" in its id — the common
// case for OmniRoute's catalog) mapped with the server-facing id.
const model = mapRawModelToModelV2(
{ id: "claude-opus-4-7" },
{ providerId: resolved.omnirouteProviderId, baseURL: "http://localhost:20128" }
);
assert.equal(model.providerID, "omniroute");
assert.equal(model.id, "omniroute/claude-opus-4-7");
// OpenCode dispatches back to OmniRoute using `providerID/modelKey`
// (matches the issue's own repro: `-m opencode-omniroute/oc/big-pickle`).
const dispatchedModelString = `${model.providerID}/claude-opus-4-7`;
const parsedProvider = splitProviderFromDispatchedModel(dispatchedModelString);
assert.equal(
parsedProvider,
"omniroute",
`server-side provider split resolved '${parsedProvider}', expected 'omniroute' — ` +
`credentials lookup would fail for an OC-gate-prefixed provider id`
);
});
test("#6859: createOmniRouteProviderHook end-to-end — catalog keys/providerID never carry the OC-gate prefix", async () => {
const hook = createOmniRouteProviderHook(
{ baseURL: "https://or.example.com/v1" },
{
fetcher: async () => [{ id: "claude-opus-4-7" }],
combosFetcher: async () => [],
}
);
const out = await hook.models!({} as never, { auth: apiAuth("sk-test") as never });
const model = out["omniroute/claude-opus-4-7"];
assert.ok(model, "catalog keyed under the unprefixed provider name");
assert.equal(model.providerID, "omniroute");
assert.ok(
!model.providerID.startsWith("opencode-"),
"the OC-gate prefix must never leak into ModelV2.providerID"
);
});
// #7976: buildStaticProviderEntry (the STATIC provider() config-hook path,
// exercised when the plugin writes `opencode.json` up front rather than
// registering the dynamic `provider.models()` hook) never received the
// #6859 fix. OC dispatches a static-catalog `models` map key verbatim as
// the `model` field of the outbound request — only the top-level
// `provider["<id>"]` segment is stripped for routing — so a bare-slug combo
// key built with the OC-gated `providerId` reaches OmniRoute's server
// doubled and fails credential lookup for the nonexistent provider
// `opencode-omniroute`. Confirmed against the issue's own curl repro
// (`model: "opencode-omniroute/hermes-smart-stack"` → "No active
// credentials for provider: opencode-omniroute").
test("#7976: buildStaticProviderEntry keys bare-slug combo ids with the unprefixed omnirouteProviderId (no double OC-gate prefix)", () => {
const resolved = resolveOmniRoutePluginOptions({ providerId: "omniroute" });
assert.equal(resolved.providerId, "opencode-omniroute");
assert.equal(resolved.omnirouteProviderId, "omniroute");
const combo = {
id: "combo-abc123",
name: "Hermes Smart Stack",
isHidden: false,
models: [],
} as unknown as OmniRouteRawCombo;
const block = buildStaticProviderEntry(
[],
[combo],
resolved,
"https://or.example/v1",
"sk-test"
);
assert.deepEqual(Object.keys(block.models), ["omniroute/hermes-smart-stack"]);
assert.equal(
block.models["opencode-omniroute/hermes-smart-stack"],
undefined,
"combo key must not carry the OC-gate-prefixed providerId — it doubles up once " +
"OC dispatches it verbatim as the `model` field"
);
});

View File

@@ -101,10 +101,7 @@ test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it
assert.equal(fetcher.callCount(), 1);
assert.deepEqual(fetcher.callsBy()[0], ["https://or.example.com/v1", "sk-abc"]);
assert.equal(Object.keys(out).length, 3);
// #6859: dynamic-hook catalog keys use the unprefixed omnirouteProviderId
// ("omniroute"), not the OC-gate-prefixed hook.id ("opencode-omniroute") —
// that prefix must never leak into anything OmniRoute's server parses.
assert.ok(out["omniroute/claude-primary"]);
assert.ok(out["opencode-omniroute/claude-primary"]);
});
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
@@ -155,17 +152,13 @@ test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => {
{ fetcher, combosFetcher: async () => [] }
);
const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never });
// #6859: dynamic-hook catalog keys/ids/providerID use the unprefixed
// omnirouteProviderId ("omniroute") — the OC-gate prefix ("opencode-")
// must stay OC-internal (hook.id / AuthHook.provider) and never leak into
// anything OmniRoute's own server parses for credential lookup.
const claude = out["omniroute/claude-primary"];
const claude = out["opencode-omniroute/claude-primary"];
assert.ok(claude, "claude-primary present");
// `mapRawModelToModelV2` stamps the provider prefix on the id so OC's
// static-catalog reader resolves `(providerID, modelID)` from the key.
assert.equal(claude.id, "omniroute/claude-primary");
assert.equal(claude.id, "opencode-omniroute/claude-primary");
assert.equal(claude.name, "claude-primary");
assert.equal(claude.providerID, "omniroute");
assert.equal(claude.providerID, "opencode-omniroute");
assert.equal(claude.api.id, "openai-compatible");
assert.equal(claude.api.url, "https://or.example.com/v1");
assert.equal(claude.api.npm, "@ai-sdk/openai-compatible");

View File

@@ -3,14 +3,14 @@
## Project
Unified AI proxy/router — route any LLM through one endpoint. Multi-provider support
with **290 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
with **231 provider entries** (OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Fireworks,
Cohere, NVIDIA, Cerebras, Pollinations, Puter, Cloudflare AI, HuggingFace, DeepInfra,
SambaNova, Meta Llama API, Moonshot AI, AI21 Labs, Databricks, Snowflake, and many more)
with **MCP Server** (104 tools), **A2A v0.3 Protocol**, and **Electron desktop app**.
with **MCP Server** (87 tools), **A2A v0.3 Protocol**, and **Electron desktop app**.
> **Live counts (v3.8.49)**: providers 290 · MCP tools 104 · MCP scopes 30 · A2A skills 6 ·
> open-sse services 134 · routing strategies 17 · auto-combo scoring factors 12 ·
> DB modules 95 · DB migrations 110 · base tables 17 · search providers 11 ·
> **Live counts (v3.8.31)**: providers 231 · MCP tools 87 · MCP scopes 30 · A2A skills 6 ·
> open-sse services 115 · routing strategies 15 · auto-combo scoring factors 9 ·
> DB modules 83 · DB migrations 97 · base tables 17 · search providers 11 ·
> i18n locales 42. **Refresh with `npm run check:docs-all`.**
## Doc Accuracy Discipline (read before writing any doc)
@@ -178,7 +178,7 @@ Always run `prettier --write` on changed files.
### Data Layer (`src/lib/db/`)
All persistence uses SQLite through **95 domain-specific modules** in `src/lib/db/`. Top modules:
All persistence uses SQLite through **83 domain-specific modules** in `src/lib/db/`. Top modules:
- Core: `core.ts`, `migrationRunner.ts`, `encryption.ts`, `stateReset.ts`
- Providers / catalog: `providers.ts`, `models.ts`, `providerLimits.ts`, `compressionAnalytics.ts`
@@ -188,8 +188,8 @@ All persistence uses SQLite through **95 domain-specific modules** in `src/lib/d
- Storage: `backup.ts`, `cleanup.ts`, `jsonMigration.ts`, `healthCheck.ts`, `databaseSettings.ts`
- Extension modules: `evals.ts`, `webhooks.ts`, `reasoningCache.ts`, `readCache.ts`, `tierConfig.ts`, `compressionCombos.ts`, `compressionScheduler.ts`, `batches.ts`, `files.ts`, `syncTokens.ts`, `proxies.ts`, `oneproxy.ts`, `upstreamProxy.ts`, `versionManager.ts`, `cliToolState.ts`, `prompts.ts`, `detailedLogs.ts`, `contextHandoffs.ts`, `compression.ts`, `stats.ts`
Live count: `ls src/lib/db/*.ts | wc -l` (currently 95). Drift detection: `npm run check:docs-counts`.
Schema migrations live in `db/migrations/` (**110 files** as of v3.8.43) and run via `migrationRunner.ts`.
Live count: `ls src/lib/db/*.ts | wc -l` (currently 83). Drift detection: `npm run check:docs-counts`.
Schema migrations live in `db/migrations/` (**97 files** as of v3.8.24) and run via `migrationRunner.ts`.
`src/lib/localDb.ts` is a **re-export layer only** — never add logic there.
#### DB Internals
@@ -198,7 +198,7 @@ Schema migrations live in `db/migrations/` (**110 files** as of v3.8.43) and run
journaling. `SCHEMA_SQL` defines **17 base tables** (verify with `grep -c "CREATE TABLE" src/lib/db/core.ts` minus 1 for the bookkeeping `_omniroute_migrations` table). Helpers: `rowToCamel`, `encryptConnectionFields`.
- **`migrationRunner.ts`**: Applies versioned SQL files from `db/migrations/` inside transactions.
Tracks applied migrations in `_omniroute_migrations` table.
- **Migrations**: 110 files (`001_initial_schema.sql` → `110_*.sql`).
- **Migrations**: 97 files (`001_initial_schema.sql` → `099_*.sql`).
Each migration is idempotent and runs in a transaction. Live count: `ls src/lib/db/migrations/*.sql | wc -l`.
- **Domain modules** import `getDbInstance()` from `core.ts` for all CRUD operations.
Each module owns a specific table/set of tables (e.g., `providers.ts` → `provider_connections`,
@@ -267,8 +267,8 @@ Zod schemas, and unit tests aligned when editing.
### Provider Categories
- **Free** (2): Qoder AI, Kiro AI
- **OAuth** (13): Claude Code, Antigravity, Codex, GitHub Copilot, Cursor, Kimi Coding, Kilo Code, Cline, Kiro, Qoder, Gemini, Windsurf (v3.8), GitLab Duo (v3.8)
- **Free** (4): Qoder AI, Qwen Code, Gemini CLI (deprecated), Kiro AI
- **OAuth** (14): Claude Code, Antigravity, Codex, GitHub Copilot, Cursor, Kimi Coding, Kilo Code, Cline, Qwen (⚠️ free tier discontinued 2026-04-15), Kiro, Qoder, Gemini, Windsurf (v3.8), GitLab Duo (v3.8)
- **API Key** (120+): OpenAI, Anthropic, Gemini, DeepSeek, Groq, xAI, Mistral, Perplexity,
Together, Fireworks, Cerebras, Cohere, NVIDIA, Nebius, SiliconFlow, Hyperbolic,
HuggingFace, OpenRouter, Vertex AI, Cloudflare AI, Scaleway, AI/ML API, Pollinations,
@@ -291,7 +291,7 @@ Providers are registered in `src/shared/constants/providers.ts` with Zod validat
### Executors (`open-sse/executors/`)
Provider-specific request executors: `base.ts`, `default.ts`, `cursor.ts`, `codex.ts`,
`antigravity.ts`, `github.ts`, `kiro.ts`, `qoder.ts`, `vertex.ts`,
`antigravity.ts`, `github.ts`, `gemini-cli.ts`, `kiro.ts`, `qoder.ts`, `vertex.ts`,
`cloudflare-ai.ts`, `opencode.ts`, `pollinations.ts`, `puter.ts`.
#### Executor Internals
@@ -336,7 +336,7 @@ Includes request/response translators with helpers for image handling.
### Services (`open-sse/services/`)
134 service modules in `open-sse/services/` (top-level only; more including sub-dirs like `autoCombo/` and `compression/`). Refresh: `ls open-sse/services/*.ts | wc -l`. Key modules:
115 service modules in `open-sse/services/` (top-level only; 184 including sub-dirs like `autoCombo/` and `compression/`). Refresh: `ls open-sse/services/*.ts | wc -l`. Key modules:
`combo.ts` (routing engine), `usage.ts`, `tokenRefresh.ts`,
`rateLimitManager.ts`, `accountFallback.ts`, `sessionManager.ts`, `wildcardRouter.ts`,
`autoCombo/`, `intentClassifier.ts`, `taskAwareRouter.ts`, `thinkingBudget.ts`,
@@ -378,8 +378,8 @@ Modular prompt compression that runs proactively before the existing reactive co
and iterates through targets in order until one succeeds or all fail.
- **`resolveComboTargets()`**: Expands a combo configuration into an ordered array of
`ResolvedComboTarget[]`, each specifying provider + model + account + credentials.
- **Strategies** (17): priority, weighted, fill-first, round-robin, P2C, random, least-used, reset-aware (v3.8),
reset-window, cost-optimized, strict-random, auto, lkgp, context-optimized, context-relay, headroom, fusion. Source: `ROUTING_STRATEGY_VALUES` in `src/shared/constants/routingStrategies.ts`.
- **Strategies** (15): priority, weighted, fill-first, round-robin, P2C, random, least-used, reset-aware (v3.8),
reset-window, cost-optimized, strict-random, auto, lkgp, context-optimized, context-relay. Source: `ROUTING_STRATEGY_VALUES` in `src/shared/constants/routingStrategies.ts`.
- Each target calls **`handleSingleModel()`** which wraps `handleChatCore()` with
per-target error handling and circuit breaker checks.
@@ -391,7 +391,7 @@ Policy engine modules: `policyEngine.ts`, `comboResolver.ts`, `costRules.ts`,
### MCP Server (`open-sse/mcp-server/`)
**104 tools** total (`TOTAL_MCP_TOOL_COUNT`, `open-sse/mcp-server/server.ts`): a 42-entry base registry (`MCP_TOOLS` in `schemas/tools.ts`, bundling the core / cache / compression / 1proxy / advanced tools) **plus** standalone module sets — memory (3), skill (4), agentSkill (3), pool (6), gamification (8), plugin (8), notion (6), obsidian (22). 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (31 scopes — see `OMNIROUTE_MCP_SCOPES`), Zod schemas. See [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md).
**87 tools** total (`TOTAL_MCP_TOOL_COUNT`, `open-sse/mcp-server/server.ts`): a 33-entry base registry (`MCP_TOOLS` in `schemas/tools.ts`, bundling the core / cache / compression / 1proxy / advanced tools) **plus** standalone module sets — memory (3), skill (4), agentSkill (3), gamification (8), plugin (8), notion (6), obsidian (22). 3 transports (stdio / SSE / Streamable HTTP). Scoped auth (30 scopes — see `OMNIROUTE_MCP_SCOPES`), Zod schemas. See [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md).
**Core tools** (20): get_health, list_combos, get_combo_metrics, switch_combo, check_quota,
route_request, cost_report, list_models_catalog, web_search, simulate_route, set_budget_guard,
@@ -489,7 +489,7 @@ Request middleware including `promptInjectionGuard.ts`.
### Guardrails (`src/lib/guardrails/`)
Hot-reloadable guardrails framework (3 built-in: pii-masker, prompt-injection, vision-bridge). Fail-open. The `pii-masker` guardrail is registered and runs on every request, but its data-mutating logic is **opt-in** and OFF by default — it only redacts when `PII_REDACTION_ENABLED` (request) / `PII_RESPONSE_SANITIZATION` (response + streaming) are enabled (both `defaultValue: "false"`); with them off, payloads pass through untouched. A request can additionally opt OUT of any guardrail via header (`x-omniroute-disabled-guardrails`). Never make PII default-on (Hard Rule #20). See [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md).
Hot-reloadable guardrails framework (3 built-in: pii-masker, prompt-injection, vision-bridge). Fail-open; per-request opt-out via header. See [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md).
### Cloud Agents (`src/lib/cloudAgent/`)
@@ -534,33 +534,33 @@ Cloudflare Quick/Named, ngrok, Tailscale Funnel. See [`docs/ops/TUNNELS_GUIDE.md
For any non-trivial change, read the matching deep-dive first:
| Area | Doc |
| ------------------------------------------ | --------------------------------------------------------------------------------------------------------------- |
| Repo navigation | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md) |
| Architecture | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
| Engineering reference | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) |
| Auto-Combo (12-factor, 18 strategies) | [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) |
| Resilience (3 layers) | [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md) |
| Skills | [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) |
| Memory | [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md) |
| Cloud agents | [`docs/frameworks/CLOUD_AGENT.md`](docs/frameworks/CLOUD_AGENT.md) |
| Guardrails | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md) |
| Evals | [`docs/frameworks/EVALS.md`](docs/frameworks/EVALS.md) |
| Compliance | [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md) |
| Webhooks | [`docs/frameworks/WEBHOOKS.md`](docs/frameworks/WEBHOOKS.md) |
| Authz | [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) |
| Stealth | [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) |
| Reasoning replay | [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md) |
| Agent protocols (A2A / ACP / Cloud) | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md) |
| MCP server | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md) |
| A2A server | [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) |
| Area | Doc |
| ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------- |
| Repo navigation | [`docs/architecture/REPOSITORY_MAP.md`](docs/architecture/REPOSITORY_MAP.md) |
| Architecture | [`docs/architecture/ARCHITECTURE.md`](docs/architecture/ARCHITECTURE.md) |
| Engineering reference | [`docs/architecture/CODEBASE_DOCUMENTATION.md`](docs/architecture/CODEBASE_DOCUMENTATION.md) |
| Auto-Combo (12-factor, 15 strategies) | [`docs/routing/AUTO-COMBO.md`](docs/routing/AUTO-COMBO.md) |
| Resilience (3 layers) | [`docs/architecture/RESILIENCE_GUIDE.md`](docs/architecture/RESILIENCE_GUIDE.md) |
| Skills | [`docs/frameworks/SKILLS.md`](docs/frameworks/SKILLS.md) |
| Memory | [`docs/frameworks/MEMORY.md`](docs/frameworks/MEMORY.md) |
| Cloud agents | [`docs/frameworks/CLOUD_AGENT.md`](docs/frameworks/CLOUD_AGENT.md) |
| Guardrails | [`docs/security/GUARDRAILS.md`](docs/security/GUARDRAILS.md) |
| Evals | [`docs/frameworks/EVALS.md`](docs/frameworks/EVALS.md) |
| Compliance | [`docs/security/COMPLIANCE.md`](docs/security/COMPLIANCE.md) |
| Webhooks | [`docs/frameworks/WEBHOOKS.md`](docs/frameworks/WEBHOOKS.md) |
| Authz | [`docs/architecture/AUTHZ_GUIDE.md`](docs/architecture/AUTHZ_GUIDE.md) |
| Stealth | [`docs/security/STEALTH_GUIDE.md`](docs/security/STEALTH_GUIDE.md) |
| Reasoning replay | [`docs/routing/REASONING_REPLAY.md`](docs/routing/REASONING_REPLAY.md) |
| Agent protocols (A2A / ACP / Cloud) | [`docs/frameworks/AGENT_PROTOCOLS_GUIDE.md`](docs/frameworks/AGENT_PROTOCOLS_GUIDE.md) |
| MCP server | [`docs/frameworks/MCP-SERVER.md`](docs/frameworks/MCP-SERVER.md) |
| A2A server | [`docs/frameworks/A2A-SERVER.md`](docs/frameworks/A2A-SERVER.md) |
| API reference | [`docs/reference/API_REFERENCE.md`](docs/reference/API_REFERENCE.md) + [`docs/openapi.yaml`](docs/openapi.yaml) |
| Provider catalog (auto-generated) | [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md) |
| Tunnels | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md) |
| Electron desktop | [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) |
| Release flow | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md) |
| Quality gates (35 gates, allowlist policy) | [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md) |
| Cluster opt-in profiles (memory, bifrost) | [`docs/architecture/cluster-decisions.md`](docs/architecture/cluster-decisions.md) |
| Provider catalog (auto-generated) | [`docs/reference/PROVIDER_REFERENCE.md`](docs/reference/PROVIDER_REFERENCE.md) |
| Tunnels | [`docs/ops/TUNNELS_GUIDE.md`](docs/ops/TUNNELS_GUIDE.md) |
| Electron desktop | [`docs/guides/ELECTRON_GUIDE.md`](docs/guides/ELECTRON_GUIDE.md) |
| Release flow | [`docs/ops/RELEASE_CHECKLIST.md`](docs/ops/RELEASE_CHECKLIST.md) |
| Quality gates (35 gates, allowlist policy) | [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md) |
| Cluster opt-in profiles (memory, bifrost) | [`docs/architecture/cluster-decisions.md`](docs/architecture/cluster-decisions.md) |
---

File diff suppressed because it is too large Load Diff

125
CLAUDE.md
View File

@@ -35,22 +35,22 @@ For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep archit
## Project at a Glance
**OmniRoute** — unified AI proxy/router. One endpoint, 290 LLM providers, auto-fallback.
**OmniRoute** — unified AI proxy/router. One endpoint, 231 LLM providers, auto-fallback.
| Layer | Location | Purpose |
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
| API Routes | `src/app/api/v1/` | Next.js App Router — entry points |
| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) |
| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch |
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
| Database | `src/lib/db/` | SQLite domain modules (95 files, 110 migrations) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 104 tools (42 base + memory/skill/agentSkill/pool/notion/obsidian/gamification/plugin modules), 3 transports (stdio / SSE / Streamable HTTP), 31 scopes |
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
| Skills | `src/lib/skills/` | Extensible skill framework |
| Memory | `src/lib/memory/` | Persistent conversational memory |
| Layer | Location | Purpose |
| ------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| API Routes | `src/app/api/v1/` | Next.js App Router — entry points |
| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) |
| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch |
| Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) |
| Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions |
| Services | `open-sse/services/` | Combo routing, rate limits, caching, etc |
| Database | `src/lib/db/` | SQLite domain modules (83 files, 97 migrations) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 87 tools (33 base + memory/skill/notion/obsidian/gamification/plugin modules), 3 transports (stdio / SSE / Streamable HTTP), 30 scopes |
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
| Skills | `src/lib/skills/` | Extensible skill framework |
| Memory | `src/lib/memory/` | Persistent conversational memory |
Monorepo: `src/` (Next.js 16 app), `open-sse/` (streaming engine workspace), `electron/` (desktop app), `tests/`, `bin/` (CLI entry point).
@@ -72,7 +72,7 @@ Client → /v1/chat/completions (Next.js route)
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.
**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.
**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 9-factor Auto-Combo scoring + the full strategy table and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers.
---
@@ -221,7 +221,7 @@ connection continue serving other models.
- **2 spaces**, semicolons, double quotes, 100 char width, es5 trailing commas (enforced by lint-staged via Prettier)
- **Imports**: external → internal (`@/`, `@omniroute/open-sse`) → relative
- **Naming**: files=camelCase/kebab, components=PascalCase, constants=UPPER_SNAKE
- **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)
- **ESLint**: `no-eval`, `no-implied-eval`, `no-new-func` = error everywhere; `no-explicit-any` = warn in `open-sse/` and `tests/`
- **TypeScript**: `strict: false`, target ES2022, module esnext, resolution bundler. Prefer explicit types.
### Database
@@ -327,33 +327,33 @@ connection continue serving other models.
For any non-trivial change, read the matching deep-dive first:
| Area | Doc |
| --------------------------------------------- | ------------------------------------------------------- |
| Repo navigation | `docs/architecture/REPOSITORY_MAP.md` |
| Architecture | `docs/architecture/ARCHITECTURE.md` |
| Engineering reference | `docs/architecture/CODEBASE_DOCUMENTATION.md` |
| Auto-Combo (12-factor scoring, 18 strategies) | `docs/routing/AUTO-COMBO.md` |
| Resilience (3 mechanisms) | `docs/architecture/RESILIENCE_GUIDE.md` |
| Reasoning replay | `docs/routing/REASONING_REPLAY.md` |
| Skills framework | `docs/frameworks/SKILLS.md` |
| Memory system (FTS5 + Qdrant) | `docs/frameworks/MEMORY.md` |
| Cloud agents | `docs/frameworks/CLOUD_AGENT.md` |
| Guardrails (PII / injection / vision) | `docs/security/GUARDRAILS.md` |
| Public upstream credentials (Gemini/etc.) | `docs/security/PUBLIC_CREDS.md` |
| Error message sanitization | `docs/security/ERROR_SANITIZATION.md` |
| Evals | `docs/frameworks/EVALS.md` |
| Compliance / audit | `docs/security/COMPLIANCE.md` |
| Webhooks | `docs/frameworks/WEBHOOKS.md` |
| Authorization pipeline | `docs/architecture/AUTHZ_GUIDE.md` |
| Stealth (TLS / fingerprint) | `docs/security/STEALTH_GUIDE.md` |
| Agent protocols (A2A / ACP / Cloud) | `docs/frameworks/AGENT_PROTOCOLS_GUIDE.md` |
| MCP server | `docs/frameworks/MCP-SERVER.md` |
| A2A server | `docs/frameworks/A2A-SERVER.md` |
| Area | Doc |
| --------------------------------------------- | ----------------------------------------------------------------- |
| Repo navigation | `docs/architecture/REPOSITORY_MAP.md` |
| Architecture | `docs/architecture/ARCHITECTURE.md` |
| Engineering reference | `docs/architecture/CODEBASE_DOCUMENTATION.md` |
| Auto-Combo (9-factor scoring, 17 strategies) | `docs/routing/AUTO-COMBO.md` |
| Resilience (3 mechanisms) | `docs/architecture/RESILIENCE_GUIDE.md` |
| Reasoning replay | `docs/routing/REASONING_REPLAY.md` |
| Skills framework | `docs/frameworks/SKILLS.md` |
| Memory system (FTS5 + Qdrant) | `docs/frameworks/MEMORY.md` |
| Cloud agents | `docs/frameworks/CLOUD_AGENT.md` |
| Guardrails (PII / injection / vision) | `docs/security/GUARDRAILS.md` |
| Public upstream credentials (Gemini/etc.) | `docs/security/PUBLIC_CREDS.md` |
| Error message sanitization | `docs/security/ERROR_SANITIZATION.md` |
| Evals | `docs/frameworks/EVALS.md` |
| Compliance / audit | `docs/security/COMPLIANCE.md` |
| Webhooks | `docs/frameworks/WEBHOOKS.md` |
| Authorization pipeline | `docs/architecture/AUTHZ_GUIDE.md` |
| Stealth (TLS / fingerprint) | `docs/security/STEALTH_GUIDE.md` |
| Agent protocols (A2A / ACP / Cloud) | `docs/frameworks/AGENT_PROTOCOLS_GUIDE.md` |
| MCP server | `docs/frameworks/MCP-SERVER.md` |
| A2A server | `docs/frameworks/A2A-SERVER.md` |
| API reference + OpenAPI | `docs/reference/API_REFERENCE.md` + `docs/openapi.yaml` |
| Provider catalog (auto-generated) | `docs/reference/PROVIDER_REFERENCE.md` |
| Release flow | `docs/ops/RELEASE_CHECKLIST.md` |
| Embedded services | `docs/frameworks/EMBEDDED-SERVICES.md` |
| Quality gates (~48 scripts, allowlist policy) | `docs/architecture/QUALITY_GATES.md` |
| Provider catalog (auto-generated) | `docs/reference/PROVIDER_REFERENCE.md` |
| Release flow | `docs/ops/RELEASE_CHECKLIST.md` |
| Embedded services | `docs/frameworks/EMBEDDED-SERVICES.md` |
| Quality gates (~48 scripts, allowlist policy) | `docs/architecture/QUALITY_GATES.md` |
---
@@ -388,31 +388,6 @@ Why this matters: fixing bug A while opening bug B is worse than not fixing at a
---
## Planning & Research Artifacts (superpowers, deep-research)
`_tasks/` is a **separate, isolated git repository** that is gitignored by the main
repo (`.gitignore``_tasks/`). It is the canonical home for working artifacts —
plans, specs/designs, research, hand-offs — so they stay **versioned in their own
repo** instead of polluting the main OmniRoute tree.
**Hard rule — never write superpowers / planning / research output under `docs/` or
the repo root.** The superpowers skills ship with defaults that point at `docs/…`
(`writing-plans``docs/superpowers/plans/`, `brainstorming``docs/superpowers/specs/`).
Those defaults are **overridden here**. Whenever you invoke superpowers (or any
plan/spec/research generator) in this project, save to `_tasks/` instead, using the
same filename convention:
| Artifact (skill) | Default (do NOT use) | Save here instead |
| ---------------------------------- | ------------------------- | ------------------------------------------------------------- |
| Plans (`writing-plans`) | `docs/superpowers/plans/` | `_tasks/superpowers/plans/YYYY-MM-DD-<feature>.md` |
| Specs / design (`brainstorming`) | `docs/superpowers/specs/` | `_tasks/superpowers/specs/YYYY-MM-DD-<topic>-design.md` |
| Research (`deep-research`, ad-hoc) | `docs/research/` | `_tasks/research/…` |
| Hand-offs (`/handoff`) | — | `_tasks/hands-off/<YYYY-MM-DD>_<branch>_v<versão>_sess-<id>/` |
When a superpowers skill announces a path like "saved to `docs/superpowers/plans/…`",
rewrite it to the `_tasks/…` equivalent before writing. Commit those artifacts inside
the `_tasks/` repo (`git -C _tasks …`), never in the main repo.
## Git Workflow
```bash
@@ -428,10 +403,8 @@ git push -u origin feat/your-feature
**Husky hooks**:
- **pre-commit**: lint-staged + `check-docs-sync` + `check:any-budget:t11` + `check:tracked-artifacts`
- **pre-push**: intentionally light (PATH/npm sanity only). `any-budget` + `tracked-artifacts`
already run on pre-commit; re-running them on every push was pure double-pay. CI still
enforces both. (Was Fase 6A.12 full pre-push gate; folded into pre-commit in #6716.)
- **pre-commit**: lint-staged + `check-docs-sync` + `check:any-budget:t11`
- **pre-push**: fast deterministic gates (`check:any-budget:t11` + `check:tracked-artifacts`); intentionally excludes `test:unit` (slow — covered by the CI `test-unit` job). Activated 2026-06-13 (Quality Gates Fase 6A.12).
### Worktree isolation (MANDATORY for every development task)
@@ -482,8 +455,7 @@ list` shows worktrees you didn't create, leave them alone. End every session wit
## Environment
- **Runtime**: Node.js ≥22.0.0 <23 || ≥24.0.0 <27, ES Modules. This is the **only supported** runtime for the published `omniroute` CLI, the server, and the test suites (`node:test` + vitest) — `engines.node` is authoritative and end users never need Bun. A **best-effort `bun:sqlite` compatibility path** exists so a global Bun install (`bun install -g omniroute`) can start without `better-sqlite3` (driver adapter + Bun-aware process spawning); it is **not** a supported runtime — no support guarantees — and every Bun-specific runtime change MUST preserve the Node driver/fallback chain and ship a Bun test (`test:bun:db`) or an explicit reason why the path is Node-only.
- **Bun (build/dev script runner + compatibility smoke only)**: Bun `1.3.14` is pinned as an **exact devDependency** (provisioned through the existing `npm ci` via the lockfile's `@oven/bun-*` platform binaries — no `setup-bun`/ad-hoc install). It is used **only** to execute a small, allow-listed set of TypeScript **gate/generator scripts** (replacing `node --import tsx` for startup speed): the CI checks `check:provider-consistency`, `check:compression-budget`, `check:known-symbols`, and the non-CI `gen:provider-reference`, `bench:compression` — plus the focused `test:bun:db` compatibility smoke suite for the best-effort `bun:sqlite` path. **Do NOT** widen Bun to `npm install`, the build (`build:cli*`), `check:pack-artifact`, the supported published runtime, or the main test runners — those stay on Node. Any new Bun-invoking gate/generator script must be validated byte-identical against its `node --import tsx` output first. After pulling the lockfile change, run `npm install` so `bun` resolves locally (a stale `node_modules` will fail those scripts with `bun: not found`).
- **Runtime**: Node.js ≥22.0.0 <23 || ≥24.0.0 <27, ES Modules
- **TypeScript**: 6.0+, target ES2022, module esnext, resolution bundler
- **Path aliases**: `@/*` → `src/`, `@omniroute/open-sse` → `open-sse/`, `@omniroute/open-sse/*` → `open-sse/*`
- **Default port**: 20128 (API + dashboard on same port)
@@ -541,11 +513,6 @@ the stale-enforcement added in Fase 6A.3.
17. Never expose routes under `/api/services/` or `/dashboard/providers/services/*/embed/` without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. These routes can spawn child processes (`npm install`, `node`). Loopback enforcement happens unconditionally before any auth check — a leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`.
18. Every bug fix must be validated before shipping: a failing-then-passing unit/integration test (TDD) OR a documented live test on the production VPS (192.168.0.15). A fix without either is not merged. See Testing → "Bug fix / issue triage protocol" for the full decision tree.
19. Never develop on the shared main checkout. Every development task runs in its own git worktree on its own dedicated branch, and you MUST confirm the base branch with the operator (e.g. via `AskUserQuestion`) before creating the worktree/branch — never assume `main` or the currently checked-out branch. A `git checkout` in the shared checkout silently destroys other sessions' uncommitted work. Tear down only the worktrees/branches you created (by name, never `fix/*`/`feat/*` wildcards), leave other sessions' worktrees untouched, and end on the branch you started on (the active `release/vX.Y.Z`, never `main`). See Git Workflow → "Worktree isolation".
20. PII redaction/sanitization is **opt-in — never on by default**. OmniRoute proxies for self-hosted/local LLMs where the operator owns the data, so mutating request/response payloads by default would silently corrupt legitimate traffic. The two data-mutating PII feature flags **MUST** keep `defaultValue: "false"` in `src/shared/constants/featureFlagDefinitions.ts`: `PII_REDACTION_ENABLED` (request-side) and `PII_RESPONSE_SANITIZATION` (response + streaming). All three application points — `src/lib/guardrails/piiMasker.ts` (request guardrail), `src/lib/piiSanitizer.ts` (response), `src/lib/streamingPiiTransform.ts` (SSE) — are gated on these flags; with both off the `pii-masker` guardrail still runs but never mutates payloads (data passes through untouched). Flipping either default to `"true"` requires explicit operator approval. The regression guard is `tests/unit/pii-opt-in-default.test.ts` (asserts both definition defaults + behavioral pass-through). Opt-in is per-operator via env or the settings/DB override (`src/lib/db/featureFlags.ts`), never a silent default. See `docs/security/GUARDRAILS.md`.
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-prs`, `/review-group-prs`, `/merge-prs`, `/triage-fix-bugs`, `/implement-fix-bugs`, `/triage-features`, `/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.
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):
- **(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).
- **(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.)
---

View File

@@ -103,19 +103,13 @@ Default URLs:
## Git Workflow
> ⚠️ **NEVER commit directly to `main`.** Always use feature branches.
>
> **PR base:** target the active `release/vX.Y.Z` branch (not `main`). See
> [`docs/ops/BRANCHING_MODEL.md`](docs/ops/BRANCHING_MODEL.md) for the
> release-per-branch + tag-at-ship model.
```bash
# Branch from the active release tip (example: release/v3.8.49)
git fetch origin
git checkout -b feat/your-feature-name origin/release/v3.8.49
git checkout -b feat/your-feature-name
# ... make changes ...
git commit -m "feat: describe your change"
git push -u origin feat/your-feature-name
# Open a Pull Request with base = release/v3.8.49
# Open a Pull Request on GitHub
```
### Branch Naming
@@ -166,7 +160,7 @@ npm run test:protocols:e2e
# Ecosystem compatibility tests
npm run test:ecosystem
# Coverage gate: 60% statements/lines/functions/branches
# Coverage gate: 75% statements/lines/functions, 70% branches
npm run test:coverage
npm run coverage:report
@@ -190,7 +184,7 @@ npm run test:combo:live:vps:failover # adds a real cross-provider failover s
Coverage notes:
- `npm run test:coverage` measures source coverage for the main unit test suite, excludes `tests/**`, and includes `open-sse/**`
- Pull requests must keep the coverage gate at **60%+** statements/lines/functions/branches
- Pull requests must keep the coverage gate at **75%+** statements/lines/functions and **70%+** branches
- If a PR changes production code in `src/`, `open-sse/`, `electron/`, or `bin/`, it must add or update automated tests in the same PR
- `npm run coverage:report` prints the detailed file-by-file report from the latest coverage run
- `npm run test:coverage:legacy` preserves the older metric for historical comparison
@@ -202,7 +196,7 @@ Before opening or merging a PR:
- Run `npm run test:unit`
- Run `npm run test:coverage`
- Ensure the coverage gate stays at **60%+** statements/lines/functions/branches
- Ensure the coverage gate stays at **75%+** statements/lines/functions, **70%+** branches
- Include the changed or added test files in the PR description when production code changed
- Check the SonarQube result on the PR when the project secrets are configured in CI
@@ -347,7 +341,7 @@ Write unit tests in `tests/unit/` covering at minimum:
- [ ] Error responses route through `buildErrorBody()` / `sanitizeErrorMessage()` — no raw stack traces in response bodies (see [`docs/security/ERROR_SANITIZATION.md`](./docs/security/ERROR_SANITIZATION.md))
- [ ] Shell commands (`exec` / `spawn`) pass runtime values via `env`, not via string interpolation
- [ ] All inputs validated with Zod schemas
- [ ] Changelog **fragment** added under `changelog.d/{features|fixes|maintenance}/<PR>-<slug>.md` for user-facing changes (see [`changelog.d/README.md`](./changelog.d/README.md)) — do **not** edit `CHANGELOG.md` directly; fragments are aggregated at release time and never conflict between PRs
- [ ] CHANGELOG updated (if user-facing change)
- [ ] Documentation updated (if applicable)
- [ ] No new CodeQL / Secret-Scanning alerts opened, or each one dismissed with technical justification referencing the relevant `docs/security/` doc
- [ ] Routes that spawn child processes (`/api/mcp/`, `/api/cli-tools/runtime/`) classified as `isLocalOnlyPath()` in `src/server/authz/routeGuard.ts` — see [Hard Rule #15](docs/security/ROUTE_GUARD_TIERS.md)

254
DESING.md Normal file
View File

@@ -0,0 +1,254 @@
# OmniRoute — Design System & Visual Identity
> **Status:** analysis + standardization plan (no code applied yet — this doc is the spec to approve before implementation).
> **Date:** 2026-06-16 · **Scope:** unify the OmniRoute dashboard (`src/`) with the marketing site (`_mono_repo/omnirouteSite/`) into **one visual identity** — same graph-paper grid background, same color tokens, standardized components.
---
## 1. Purpose
The marketing site (`viral.omniroute.online`, `why.omniroute.online`, `omniroute.online`) and the product dashboard should look like **one product**. The site already borrowed its palette from the dashboard — its `css/tokens.css` even says _"Palette mirrors the OmniRoute dashboard (src/app/globals.css)"_. So the two are already ~80% aligned at the color level. What's missing on the dashboard:
1. The **graph-paper grid wallpaper** the site uses on every page.
2. A handful of **shared design tokens** the site has but the dashboard lacks (radius scale, brand gradient, `surface-2`, mono font).
3. **Component-level consistency** — a number of dashboard components bypass the theme tokens with hardcoded hex/rgba.
This document is the analysis and the plan. **Nothing is changed until approved.**
---
## 2. Principles
- **Single source of truth = `src/app/globals.css`.** The site mirrors the dashboard, never the other way around. New tokens land in `globals.css` first.
- **Tokens, never literals.** Components consume semantic tokens (`bg-surface`, `text-primary`, `border-border`), never raw `#hex`.
- **Subtle, not loud.** The grid is a faint wallpaper that sits behind content — it must never reduce text contrast or fight the UI.
- **Theme-aware.** Everything works in both `.dark` (default-ish, the product's signature look) and light.
- **Surgical rollout.** Ship the grid + tokens first (low risk, high visibility), then component cleanups in waves.
---
## 3. Current state — what's already aligned vs. what's not
### 3.1 Colors — already unified ✅
Every brand color and surface already matches the site **by value** (only the names differ — dashboard prefixes with `--color-`). Verified in `src/app/globals.css:30-128`:
| Concept | Site token (`tokens.css`) | Dashboard token (`globals.css`) | Match |
| -------------------------- | ------------------------------------------- | ------------------------------- | ------------ |
| primary | `--primary #e54d5e` | `--color-primary #e54d5e` | ✅ |
| primary-hover | `--primary-hover #c93d4e` | `--color-primary-hover #c93d4e` | ✅ |
| accent | `--accent #6366f1` | `--color-accent #6366f1` | ✅ |
| accent-2 | `--accent-2 #8b5cf6` | `--color-accent-hover #8b5cf6` | ✅ (renamed) |
| accent-3 | `--accent-3 #a855f7` | `--color-accent-light #a855f7` | ✅ (renamed) |
| success / warning / error | `#22c55e / #f59e0b / #ef4444` | identical | ✅ |
| traffic lights | `#ff5f56 / #ffbd2e / #27c93f` | identical | ✅ |
| dark bg / surface / border | `#0b0e14 / #161b22 / rgba(255,255,255,.08)` | identical | ✅ |
| light bg / surface / text | `#f9f9fb / #fff / #1a1a2e` | identical | ✅ |
**Conclusion:** there is no color migration to do. The identity is already shared; we are _finishing_ it, not rebuilding it.
### 3.2 Gaps — what the dashboard is missing
| Gap | Site has | Dashboard | Action |
| ----------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------- | ---------------------- |
| **Grid wallpaper** | `body::before` graph-paper, `--grid-line`, `--grid-size 46px`, `--section-alt` | none (flat `--color-bg`) | **Part A** |
| **Radius scale** | `--radius 14px`, `--radius-sm 9px` | none — primitives use ad-hoc `rounded-md/lg/xl` (6/8/12px) | **Part B** |
| **Brand gradient** | `--grad-brand 135deg primary→accent-3` | none — only a one-off `.bg-hero-gradient` | **Part B** |
| **Nested surface** | `--surface-2 #1c2230` | none | **Part B** |
| **Mono font** | `--font-mono` (ui-monospace stack) | none (code/terminal areas have no token) | **Part B** |
| **`text-muted` (dark)** | `#8b8b9e` | `#a1a1aa` (zinc-400) | reconcile — **Part B** |
### 3.3 Theming mechanics (so we don't break anything)
- **Tailwind v4, CSS-first** (no `tailwind.config.*`). Tokens are defined in `:root`/`.dark` and exposed to utilities via `@theme inline` (`globals.css:130-179`).
- **Dark via `.dark` class** on `<html>` (`@custom-variant dark` at `globals.css:22`), toggled by a custom Zustand store (`src/store/themeStore.ts`), default theme = `system` (`src/shared/constants/appConfig.ts:11`). The site uses `html[data-theme="light"]` instead — **the mechanisms differ but never meet** (separate origins), so no conflict. We keep the dashboard's `.dark` mechanism.
- **Runtime primary override** exists (`themeStore.ts:85-97`, presets in `COLOR_THEMES`) — users can swap `--color-primary`. Any new token (gradient, etc.) that references `--color-primary` will inherit those overrides for free. ✅
---
## 4. Part A — The graph-paper grid background (headline ask)
### 4.1 What it is
The exact recipe from the site (`_mono_repo/omnirouteSite/css/base.css`): a **fixed, full-viewport pseudo-element** painting two 1px line gradients, sitting at `z-index:-1` behind all content.
```css
body::before {
content: "";
position: fixed;
inset: 0;
z-index: -1;
pointer-events: none;
background-image:
linear-gradient(to right, var(--grid-line) 1px, transparent 1px),
linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px);
background-size: var(--grid-size) var(--grid-size);
}
```
**Why this works even though `body` has an opaque `background-color`:** a `::before` with `z-index:-1` paints _above_ the element's own background but _below_ its in-flow content. So `--color-bg` is the base fill, the grid is layered on top of it, and the app renders above the grid.
### 4.2 Precedent already in the codebase
`src/app/landing/page.tsx:16-26` **already implements this same grid per-page** — but with **red** lines (`#E54D5E`, opacity `0.06`) at **50px**, plus animated orbs. So the pattern is proven in the product; we are promoting it to a **global, theme-aware** wallpaper and (optionally) retiring the duplicate.
### 4.3 Tokens to add (in `globals.css`)
```css
:root {
/* light */
--grid-line: rgba(0, 0, 0, 0.045);
--grid-size: 46px;
--section-alt: rgba(0, 0, 0, 0.022);
}
.dark {
/* dark */
--grid-line: rgba(255, 255, 255, 0.035);
--section-alt: rgba(255, 255, 255, 0.018);
}
```
### 4.4 The single blocker
The grid is global by construction (it covers the panel, `auth`/`login`, error pages — every route — at once). Exactly **one** element hides it inside the panel:
- `src/shared/components/layouts/DashboardLayout.tsx:62` — the outer wrapper paints an opaque `bg-bg`:
```jsx
<div className="flex h-dvh min-h-0 w-full overflow-hidden bg-bg">
```
Everything below it is already transparent — `<main>` (`:93`), the scroll container (`:102`), the `max-w-7xl` inner (`:103`). So **removing `bg-bg` from this one line** lets the body grid show through the entire content area (the body's `--color-bg` remains the base fill underneath the grid).
```diff
- <div className="flex h-dvh min-h-0 w-full overflow-hidden bg-bg">
+ <div className="flex h-dvh min-h-0 w-full overflow-hidden">
```
### 4.5 Chrome interaction (sidebar / header)
- `Header` (`src/shared/components/Header.tsx:207`, `bg-bg`) and `Sidebar` (`src/shared/components/Sidebar.tsx:430`, `bg-sidebar`) stay **opaque** → the grid shows in the **content area only**, with solid chrome framing it. This is the recommended, calm default and matches how the site separates chrome from canvas.
- _Optional vibrancy variant:_ make the header translucent (`bg-bg/80 backdrop-blur`) so the grid runs behind it. A `.bg-vibrancy` helper already exists (`globals.css:370`). **Decision D3 below.**
### 4.6 Login / auth / error pages
These render directly under `<body>` (no panel chrome) and their page wrappers are mostly transparent — the global grid appears behind them automatically. One exception: `src/app/login/page.tsx:124,139` uses opaque `bg-bg` wrappers; soften the same way if we want the grid there too (minor, **D4**).
### 4.7 Landing page
`landing/page.tsx` keeps its richer animated background (orbs + vignette). Options: (a) leave it as-is (its own splash identity), or (b) align its grid to the global tokens (46px, neutral lines) for consistency. **Recommend (a)** — it's a marketing splash, not a panel screen. **Decision D5.**
---
## 5. Part B — Token unification
Add to `globals.css` (`:root` + `@theme inline`) so the dashboard gains the site's missing tokens. None of these change existing colors; they add the _missing_ primitives.
```css
:root {
--surface-2: #f5f5fa; /* light: nested panels */
--radius: 14px;
--radius-sm: 9px;
--grad-brand: linear-gradient(135deg, var(--color-primary), var(--color-accent-light));
--font-mono: ui-monospace, "JetBrains Mono", "Fira Code", "SF Mono", monospace;
}
.dark {
--surface-2: #1c2230;
}
@theme inline {
--color-surface-2: var(--surface-2); /* enables bg-surface-2 */
--radius-lg: var(--radius); /* enables rounded-lg = 14px */
--radius-md: var(--radius-sm); /* enables rounded-md = 9px */
--font-mono: var(--font-mono); /* enables font-mono */
}
```
| Token | Why | Consumers |
| -------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------- |
| `--radius` / `--radius-sm` | One radius scale (14/9) instead of 6/8/12 ad-hoc | Button, Card, Modal, Input, Select |
| `--grad-brand` | Brand gradient for primary CTAs (red→violet), matching the site | Button `primary`, hero/CTA surfaces |
| `--surface-2` | Nested panels / table headers / inset rows | Card.Section, DataTable header, inputs |
| `--font-mono` | Code blocks, terminal, IDs, endpoints | ConsoleLogViewer, code snippets, `localhost:20128/v1` chips |
| `--text-muted` reconcile | Pick one value site↔panel | global |
**Decision D2 (text-muted):** site `#8b8b9e` vs dashboard `#a1a1aa`. Recommend keeping the **dashboard's `#a1a1aa`** (it's the live product, slightly higher contrast) and updating the _site_ to match. Low priority, cosmetic.
---
## 6. Part C — Component standardization
The component layer is **custom** (no shadcn/Radix), Tailwind v4, semantic tokens **mostly** adopted (`bg-surface`, `border-white/10`, `ring-primary`) — good adoption (195 files import the shared barrel). The work is removing the **bypasses**. Home: `src/shared/components/`.
Ranked by impact × reach:
| # | Item | File(s) | Problem → Target |
| --- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| C1 | **Radius alignment** | `Button.tsx:14-18`, `Card.tsx:39`, `Modal.tsx`, `Input.tsx`, `Select.tsx` | mixed 6/8/12px → repoint to `--radius`/`--radius-sm` (14/9) |
| C2 | **Button gradient + `accent` variant** | `Button.tsx:5-12` | primary is flat red→red (`from-primary to-primary-hover`); align to `--grad-brand` (red→violet) and add the missing `accent` variant (indigo `#6366f1` is unused by buttons) — **highest visibility, ~195 importers**. **Decision D1.** |
| C3 | **Tables** | `DataTable.tsx:122-176`, `logTableStyles.ts`, `globals.css:405-414` (Ant remnants) | `DataTable` is 100% inline hardcoded rgba + references non-existent vars (`--text-secondary`, `--bg-table-header`); migrate to tokens, retire the 2 divergent table styles. Tables are everywhere (providers/connections/logs) — worst offender. |
| C4 | **Centralize status colors** | `flow/edgeStyles.ts:7-12`, `TokenHealthBadge.tsx:14-19`, `DegradationBadge.tsx`, `ProviderCascadeNode.tsx`, `Badge.tsx`, +5 `statusColor` helpers | 6+ copies of the same `#22c55e/#f59e0b/#ef4444` hex; create one `statusColors` module driven off `--color-success/warning/error`. Critical for circuit-breaker / cooldown / lockout badges to read consistently. |
| C5 | **Card border** | `Card.tsx:39` | uses `border-white/5`; brand border is `/8` → align |
| C6 | **Focus ring reconcile** | `globals.css:183` vs component `ring-primary/30` | global `:focus-visible` is indigo (`--color-accent`), components are red (`ring-primary`) — pick one (recommend **accent/indigo** globally, it reads as the "interactive" color) |
| C7 | **Add `Checkbox` + `Textarea` primitives** | currently raw `<input>`/`<textarea>` with inline `accentColor:#6366f1` (e.g. `ColumnToggle.tsx:91`) | create token-driven primitives |
| C8 | **Hardcoded-hex sweep** | `ConsoleLogViewer.tsx:240` (`#161b22`/`#30363d`), `ComboLiveStudio.tsx:306` (`#6366f120`), Modal traffic dots `Modal.tsx:149-159`, ~14 chart/component files with literal `#6366f1`/`#a855f7` | replace literals with `bg-surface`/`border-border`/`text-accent` etc. |
| C9 | **`cn()` → clsx + tailwind-merge** | `src/shared/utils/cn.ts` | current `cn` just joins; conflicting classes stack (a `className="rounded-2xl"` override won't replace a primitive's `rounded-lg`). Needed for C1 overrides to behave. |
**Already on-brand (token-driven, only need radius):** `Badge`, `Toggle`, `SegmentedControl`, `Input`, `Select`.
---
## 7. Rollout plan (phased, each phase shippable + testable)
- **Phase 1 — Grid + tokens (low risk, high visibility).**
1. Add grid + identity tokens to `globals.css` (Part A §4.3, Part B §5).
2. Add `body::before` grid.
3. Remove `bg-bg` from `DashboardLayout.tsx:62`.
4. Verify across themes + key screens (dashboard, providers, logs, login, an error page). Confirm contrast unchanged.
→ _Delivers the headline ask. Reversible in one commit._
- **Phase 2 — Primitives radius + Button (C1, C2, C5, C9).** The visible "feel" pass. `cn()` upgrade first so overrides behave.
- **Phase 3 — Tables + status colors (C3, C4).** The largest consistency win; touch the data-heavy screens.
- **Phase 4 — Cleanup (C6, C7, C8).** Focus ring, new primitives, hardcoded-hex sweep.
Each phase: `npm run lint` + `npm run typecheck:core` + a visual pass. Per repo rule, production-code changes ship with tests where applicable (token/CSS changes are visual — validated by screenshots; component API changes get unit coverage).
---
## 8. Open decisions (need your call before/while implementing)
- **D1 — Button primary look.** Keep the current **red→red** gradient, or switch the product's primary buttons to the **red→violet `--grad-brand`** (matches the site CTAs)? _(Affects every primary button.)_ Recommend: **red→violet**, with `--grad-brand`.
- **D2 — Grid line color.** **Neutral** lines (site style: faint white/black, `rgba(255,255,255,0.035)`) — calm, content-first — **or** the landing's **brand-red** lines? Recommend: **neutral** (matches the site's interior pages; red is louder and can tint readability). Size **46px** (site) to retire the landing's 50px drift.
- **D3 — Chrome vibrancy.** Sidebar/header stay **solid** (grid in content area only), or go **translucent** so the grid runs behind them? Recommend: **solid** (calmer; less risk).
- **D4 — Auth/login grid.** Soften `login/page.tsx` wrappers so the grid shows there too? Recommend: **yes** (cheap, more cohesive).
- **D5 — Landing page.** Leave its animated splash bg as-is, or align it to the global grid? Recommend: **leave as-is**.
- **D6 — Radius value.** Adopt **14/9** everywhere (bigger, softer, site-matching) — confirm you want this product-wide shift. Recommend: **yes**, it's the single biggest "one identity" signal.
- **D7 — Scope of first PR.** Ship **Phase 1 only** first (grid + tokens), then iterate? Recommend: **yes** — validate the wallpaper live before the component waves.
---
## 9. Out of scope / risks
- **No palette change** — colors already match; we only add missing tokens. Zero risk of recoloring the product.
- **No theme-engine change** — keep `.dark` + Zustand store; don't migrate to `next-themes` or to the site's `data-theme`.
- **Radius shift is broad** (D6) — it touches every card/button/input; that's the point, but it's the one change worth eyeballing on busy screens (tables, modals) before merge.
- **Tables (C3)** carry the most hardcoded styling and the highest regression surface — isolate in its own PR with before/after screenshots.
- **Worktree isolation (repo hard-rule #19):** implementation runs in a dedicated worktree on a branch cut from the confirmed base (likely `release/v3.8.28`), never on the shared checkout. This doc is the only artifact written to the working tree so far.
---
## 10. Reference index
| Area | Path |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| Dashboard tokens | `src/app/globals.css:30-179` (`:root`, `.dark`, `@theme inline`), `body` `:206` |
| Theme store | `src/store/themeStore.ts`, `src/shared/components/ThemeProvider.tsx`, `src/shared/constants/appConfig.ts:9-11` |
| Panel shell (grid blocker) | `src/shared/components/layouts/DashboardLayout.tsx:62` |
| Chrome | `src/shared/components/Header.tsx:207`, `src/shared/components/Sidebar.tsx:430` |
| Grid precedent | `src/app/landing/page.tsx:16-26` |
| Primitives | `src/shared/components/{Button,Card,Input,Select,Badge,Modal,Toggle,SegmentedControl,Loading,Tooltip,DataTable}.tsx`, barrel `index.tsx` |
| Status-color sources | `flow/edgeStyles.ts`, `TokenHealthBadge.tsx`, `DegradationBadge.tsx`, `logTableStyles.ts` |
| `cn` util | `src/shared/utils/cn.ts` |
| Site reference | `_mono_repo/omnirouteSite/css/tokens.css`, `css/base.css` (grid `body::before`) |

View File

@@ -2,46 +2,24 @@
FROM node:24-trixie-slim AS base
WORKDIR /app
# `apt-get upgrade` pulls the security-patched versions of the Debian (trixie)
# base-image packages at build time — clears the subset of container-scan CVEs
# (perl / util-linux / systemd / ncurses / zlib / tar / sqlite / shadow / pam …)
# that already have a fix published in trixie. CVEs without an upstream fix yet
# (local-only TOCTOU, etc.) remain until the distro patches them and the image
# is rebuilt; none are reachable from the proxy's request surface at runtime.
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=shared \
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=shared \
RUN --mount=type=cache,target=/var/cache/apt,sharing=shared \
--mount=type=cache,target=/var/lib/apt/lists,sharing=shared \
apt-get update \
&& apt-get upgrade -y \
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Refresh the globally-installed npm so its *bundled* node_modules (undici, tar)
# ship the patched versions. These are npm's own internals — not application
# dependencies (our app already resolves undici@8.5.0 / tar@7.5.16, both fixed) —
# but the container scanner flags the stale copies under
# /usr/local/lib/node_modules/npm/node_modules. npm is not invoked at runtime in
# the runner stages, so this is hygiene, not an exploitable runtime path.
RUN npm install -g npm@latest \
&& npm cache clean --force
# ── Builder ────────────────────────────────────────────────────────────────
FROM base AS builder
# Build tools for native module compilation
# apt-get update needed here because base's rm -rf clears the shared cache
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=shared \
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=shared \
RUN --mount=type=cache,target=/var/cache/apt,sharing=shared \
--mount=type=cache,target=/var/lib/apt/lists,sharing=shared \
apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
# Workspace package manifests MUST be present before `npm ci` so npm materializes
# the workspace and installs its *workspace-only* deps (e.g. safe-regex,
# @toon-format/toon — declared in open-sse/package.json, not hoisted to root).
# Without this, `npm ci` skips them and the application build fails with "Module not
# found" (root cause of the v3.8.39 Docker build break). workspaces = ["open-sse"].
COPY open-sse/package.json ./open-sse/package.json
COPY scripts/build/postinstall.mjs ./scripts/build/postinstall.mjs
COPY scripts/build/postinstallSupport.mjs ./scripts/build/postinstallSupport.mjs
COPY scripts/build/native-binary-compat.mjs ./scripts/build/native-binary-compat.mjs
@@ -55,65 +33,32 @@ ENV NPM_CONFIG_LEGACY_PEER_DEPS=true
# are reproducible.
RUN test -f package-lock.json \
|| (echo "package-lock.json is required for reproducible Docker builds" >&2 && exit 1)
# `npm rebuild <pkg>` re-runs the package's own install script, so under npm 11 +
# `--ignore-scripts` on the parent `npm ci` it depends on npm's script-allowlist
# machinery correctly re-enabling that one package's script. Some self-hosted build
# environments (e.g. Dokploy) hit a broken/incomplete better-sqlite3 native binding
# from that indirection. Invoking `node-gyp rebuild` directly inside the package
# directory bypasses npm's script-running layer entirely and is deterministic
# regardless of npm version or ignore-scripts allowlist behavior.
# node-gyp comes from npm's own bundled copy (deterministic, already in the image)
# instead of `npx --yes`, which would install an arbitrary registry version
# on-demand and run its lifecycle scripts (Sonar docker:S6505).
#
# tls-client-node (chatgpt-web/claude-web/grok-web/lmarena/perplexity-web TLS
# impersonation) hits the same --ignore-scripts wall: its own postinstall.js
# fetches a platform .so/.dylib/.dll from the bogdanfinn/tls-client GitHub
# Releases API and is never invoked when npm ci skips lifecycle scripts. Unlike
# better-sqlite3 above, that script never throws on failure — it only
# `console.warn`s and exits 0 — so a rate-limited or offline build would
# otherwise succeed silently with an empty bin/ and only fail at first request
# in production (TlsClientUnavailableError, #7802). Run it explicitly here so
# a broken/rate-limited fetch fails the BUILD loudly instead of shipping a
# broken image.
RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
RUN --mount=type=cache,target=/root/.npm \
npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
&& (cd node_modules/better-sqlite3 \
&& node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild) \
&& node -e "require('better-sqlite3')(':memory:').close()" \
&& node node_modules/tls-client-node/scripts/postinstall.js \
&& (test -n "$(find node_modules/tls-client-node/bin -mindepth 1 -print -quit 2>/dev/null)" \
|| (echo "tls-client-node native binary missing after postinstall — GitHub API fetch likely rate-limited or failed (#7802)" >&2 && exit 1))
&& npm rebuild better-sqlite3 \
&& node -e "require('better-sqlite3')(':memory:').close()"
# Build with Turbopack (stable in Next 16, the repo default). The v3.8.27-era
# TurbopackInternalError panic ("entered unreachable code: there must be a path to a
# root" in ImportTracer::get_traces) no longer reproduces on Next 16.2.9 — validated
# 2026-07-05 with clean amd64 (12min14s, image smoke-tested: /api/monitoring/health
# 200) and arm64 (qemu, exit 0, zero panic strings) builds. Turbopack cut the bare
# build from 17min to 9min on the same 32-core box. Webpack stays available as the
# escape hatch: `--build-arg`/-e OMNIROUTE_USE_TURBOPACK=0.
# Build with webpack (stable). Turbopack hit a non-recoverable internal panic on this
# Next.js version during the v3.8.27 release build — TurbopackInternalError "entered
# unreachable code: there must be a path to a root" in ImportTracer::get_traces, on both
# linux/amd64 and linux/arm64. Webpack is the proven engine (build:release / VPS / CI Build
# all green). Re-enable Turbopack (=1) once the upstream tracer bug is fixed.
# See docs/ops/QUALITY_GATE_PLAYBOOK.md Parte 6.
ENV OMNIROUTE_USE_TURBOPACK=1
# Docker containers cannot run the MITM/Agent-Bridge stack (no host DNS/cert
# access), so keep @/mitm/manager on the graceful stub (#3390). This flag is
# Docker-only: npm/Electron/VPS builds must bundle the REAL manager (#6344).
ENV OMNIROUTE_MITM_STUB=1
ENV OMNIROUTE_USE_TURBOPACK=0
# Raise the V8 heap ceiling for the build. The webpack production optimization
# pass needs more than V8's default ceiling (~2 GB) for a codebase this size; a
# memory-constrained Docker build otherwise dies with "FATAL ERROR: ... JavaScript
# heap out of memory" during the builder stage (#4076). Turbopack's compile is
# native (Rust) and less V8-heap-bound, but the prerender/export phase still runs
# on V8, so keep the ceiling. NODE_OPTIONS propagates to the spawned `next build`
# child (build-next-isolated.mjs → resolveNextBuildEnv spreads process.env).
# Build-only; the runtime heap is set separately on the runner stage
# (OMNIROUTE_MEMORY_MB). Override: `--build-arg OMNIROUTE_BUILD_MEMORY_MB=6144`.
# pass (forced above since Turbopack panics) needs more than V8's default ceiling
# (~2 GB) for a codebase this size; a memory-constrained Docker build otherwise
# dies with "FATAL ERROR: ... JavaScript heap out of memory" at `[builder] npm run
# build` (#4076). NODE_OPTIONS propagates to the spawned `next build` child
# (build-next-isolated.mjs → resolveNextBuildEnv spreads process.env). Build-only;
# the runtime heap is set separately on the runner stage (OMNIROUTE_MEMORY_MB).
# Override for hosts with more/less RAM: `--build-arg OMNIROUTE_BUILD_MEMORY_MB=6144`.
ARG OMNIROUTE_BUILD_MEMORY_MB=4096
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
COPY . ./
RUN --mount=type=cache,id=next-cache,target=/app/.build/next/cache \
RUN --mount=type=cache,target=/app/.build/next/cache \
mkdir -p /app/data && npm run build
# ── Runner base ────────────────────────────────────────────────────────────
@@ -128,12 +73,6 @@ LABEL org.opencontainers.image.title="omniroute" \
ENV NODE_ENV=production
ENV PORT=20128
ENV HOSTNAME=0.0.0.0
# Runtime heap ceiling. 1024MB is enough for normal traffic but can be tight
# for large fusion-combo panels (many models fanned out in parallel, each
# response buffered in full — see open-sse/services/fusion.ts::FUSION_DEFAULTS
# .maxPanel, issue #1905). Override at `docker run` time with
# `-e OMNIROUTE_MEMORY_MB=2048` (or higher) if you raise fusionTuning.maxPanel
# above the default cap.
ENV OMNIROUTE_MEMORY_MB=1024
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_MEMORY_MB}"
@@ -215,8 +154,8 @@ COPY --from=builder /app/node_modules/playwright ./node_modules/playwright
# browsers land under /home/node which persists across image layers and is
# accessible to the non-root runtime user.
ENV PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
apt-get update \
&& node node_modules/playwright/cli.js install chromium --with-deps \
&& chown -R node:node /home/node/.cache \
@@ -232,15 +171,15 @@ FROM runner-base AS runner-cli
USER root
# Install system dependencies required by openclaw (git+ssh references).
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates docker.io docker-compose \
&& rm -rf /var/lib/apt/lists/* \
&& git config --system url."https://github.com/".insteadOf "ssh://git@github.com/"
# Install CLI tools globally. Separate layer from apt for better cache reuse.
RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
RUN --mount=type=cache,target=/root/.npm \
npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest
USER node

1059
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -42,7 +42,7 @@ Request → CORS → Authz pipeline (classify → policies → enforce)
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Dashboard Login** | Password-based auth with JWT tokens (HttpOnly cookies) |
| **API Key Auth** | HMAC-signed keys with CRC validation |
| **OAuth 2.0 + PKCE** | 13 providers (Claude, Codex, GitHub, Cursor, Antigravity, Gemini, Kimi Coding, Kilo Code, Cline, Kiro, Qoder, Windsurf, GitLab Duo) |
| **OAuth 2.0 + PKCE** | 14 providers (Claude, Codex, GitHub, Cursor, Antigravity, Gemini, Kimi Coding, Kilo Code, Cline, Qwen, Kiro, Qoder, Windsurf, GitLab Duo) |
| **Token Refresh** | Automatic OAuth token refresh before expiry |
| **Secure Cookies** | `AUTH_COOKIE_SECURE=true` for HTTPS environments |
| **Authz Pipeline** | Route classification (PUBLIC / CLIENT_API / MANAGEMENT) — see `docs/architecture/AUTHZ_GUIDE.md` |
@@ -77,28 +77,21 @@ Custom guardrails register via `registerGuardrail(new MyGuardrail())`. The model
### 🧠 Prompt Injection Guard
Best-effort heuristic middleware that detects prompt injection patterns in LLM requests.
**Not a complete prompt-injection firewall** — can produce false positives (benign
persona/RPG prompts) and false negatives (leetspeak, spacing, non-English patterns).
Middleware that detects and blocks prompt injection attacks in LLM requests:
| Pattern Type | Severity | Example |
| ------------------- | -------- | ---------------------------------------------- |
| System Override | High | "ignore all previous instructions" |
| Role Hijack | Medium | "you are now DAN, you can do anything" |
| Delimiter Injection | High | Encoded separators to break context boundaries |
| DAN/Jailbreak | Medium | Known jailbreak prompt patterns |
| Instruction Leak | High | "show me your system prompt" |
| Encoding Evasion | Medium | base64/rot13/hex decode + instruction keywords |
Only **High** severity detections are blocked in `block` mode. Medium-severity
families are logged but never blocked by `sanitizeRequest`.
| Role Hijack | High | "you are now DAN, you can do anything" |
| Delimiter Injection | Medium | Encoded separators to break context boundaries |
| DAN/Jailbreak | High | Known jailbreak prompt patterns |
| Instruction Leak | Medium | "show me your system prompt" |
Configure via dashboard (Settings → Security) or `.env`:
```env
INPUT_SANITIZER_ENABLED=true
INPUT_SANITIZER_MODE=block # warn | block (injection policy; legacy "redact" does not strip injection text)
INPUT_SANITIZER_BLOCK_THRESHOLD=high # high (default) | medium | low — severities at/above this are blocked in block mode
INPUT_SANITIZER_MODE=block # warn | block | redact
```
### 🔒 PII Redaction
@@ -115,20 +108,19 @@ Automatic detection and optional redaction of personally identifiable informatio
| SSN (US) | `123-45-6789` | `[SSN_REDACTED]` |
```env
PII_REDACTION_ENABLED=true # request PII rewrite; independent of INPUT_SANITIZER_MODE
PII_RESPONSE_SANITIZATION=true # optional: redact PII in provider responses returned to clients
PII_REDACTION_ENABLED=true
```
### 🌐 Network Security
| Feature | Description |
| ------------------------ | ------------------------------------------------------------------------------ |
| **CORS** | Explicit cross-origin allowlist (`CORS_ALLOWED_ORIGINS`; legacy `CORS_ORIGIN`) |
| **IP Filtering** | Allowlist/blocklist IP ranges in dashboard |
| **Rate Limiting** | Per-provider rate limits with automatic backoff |
| **Anti-Thundering Herd** | Mutex + per-connection locking prevents cascading 502s |
| **TLS Fingerprint** | Browser-like TLS fingerprint spoofing to reduce bot detection |
| **CLI Fingerprint** | Per-provider header/body ordering to match native CLI signatures |
| Feature | Description |
| ------------------------ | ---------------------------------------------------------------- |
| **CORS** | Configurable origin control (`CORS_ORIGIN` env var, default `*`) |
| **IP Filtering** | Allowlist/blocklist IP ranges in dashboard |
| **Rate Limiting** | Per-provider rate limits with automatic backoff |
| **Anti-Thundering Herd** | Mutex + per-connection locking prevents cascading 502s |
| **TLS Fingerprint** | Browser-like TLS fingerprint spoofing to reduce bot detection |
| **CLI Fingerprint** | Per-provider header/body ordering to match native CLI signatures |
### 🔌 Resilience & Availability

View File

@@ -1,8 +1,8 @@
# 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 self-hoster incident-recovery
# and cold-start ops tooling. Each script documents its own contract via --help.
# 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

View File

@@ -1,230 +0,0 @@
/**
* ESM path-alias resolver for global installs.
*
* Problem (#7791): when OmniRoute is installed via `npm i -g omniroute`, the
* package files live under `node_modules/omniroute/`. tsx's tsconfig-path
* resolution does not apply there, so specifiers like `@/shared/utils/featureFlags`
* (declared in tsconfig.json `paths` as `@/* → ./src/*`) or
* `@omniroute/open-sse/services/usage` fail with `ERR_MODULE_NOT_FOUND`.
* The CLI crashes before any command can run.
*
* Fix: register a Node ESM `resolve` hook that rewrites alias specifiers to
* absolute file URLs. Covers all tsconfig.json `paths` entries:
* - `@/*` → `./src/*`
* - `@omniroute/open-sse` → `./open-sse/index.ts`
* - `@omniroute/open-sse/*` → `./open-sse/*`
* The hook runs after tsx so `.ts` extensions are already handled, and only
* intercepts matched prefixes — everything else falls through to Node's
* default resolver.
*
* Exposed as pure functions so the mapping logic is unit-testable without a
* running module loader.
*/
import { existsSync, statSync } from "node:fs";
import { dirname, join, relative, isAbsolute } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
/**
* Alias mapping table — mirrors tsconfig.json `paths`.
* Processed top-to-bottom; first matching prefix wins.
*
* Each entry:
* prefix — specifier prefix to match (e.g. `"@/"`, `"@omniroute/open-sse/"`)
* target — directory name under the package root (e.g. `"src"`, `"open-sse"`)
* exact — if true, the prefix also matches when the specifier equals the
* prefix *without* a trailing slash (e.g. `@omniroute/open-sse` →
* `<root>/open-sse/index.ts`).
*
* Exported for tests/consumers.
*/
export const ALIAS_MAP = [
{ prefix: "@/", target: "src", exact: false },
{ prefix: "@omniroute/open-sse/", target: "open-sse", exact: false },
{ prefix: "@omniroute/open-sse", target: "open-sse", exact: true },
];
/** @deprecated Use ALIAS_MAP instead. Kept for backward compat. */
export const ALIAS_PREFIX = "@/";
// This file is ESM (no CJS __dirname global) — derive it from import.meta.url
// so the pathToFileURL(join(__dirname, ...)) call below resolves correctly
// regardless of the caller's cwd.
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* Resolve an alias specifier to an absolute file URL.
*
* Rules mirror tsconfig.json `paths` via `ALIAS_MAP`:
* "@/..." → <root>/src/...
* "@omniroute/open-sse/..." → <root>/open-sse/...
* "@omniroute/open-sse" → <root>/open-sse/index.*
*
* - Strips the matched alias prefix and joins the remainder against the
* corresponding target directory.
* - Probes the underlying filesystem for the actual source file: the specifier
* itself, then with common source extensions (`.ts`, `.tsx`, `.js`, `.mjs`,
* `.cjs`, `.json`), then `<dir>/index.*`. Returns the first existing match
* as a `file://` URL.
* - Returns `null` for specifiers that do not match any alias, for malformed
* escapes, for path-traversal attempts, or when no corresponding source
* file exists on disk. The caller treats `null` as "defer to the default
* resolver".
*
* @param {string} specifier Module specifier from an `import` statement.
* @param {string} root Absolute path to the package root.
* @returns {string|null} Absolute `file://` URL, or `null` when unresolved.
*/
const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".mjs", ".cjs", ".json"];
export function resolveAlias(specifier, root) {
if (typeof specifier !== "string" || !root || typeof root !== "string") {
return null;
}
// Find the first matching alias entry (top-to-bottom order).
let matchedEntry = null;
let rest = null;
for (const entry of ALIAS_MAP) {
if (specifier.startsWith(entry.prefix)) {
// For non-exact entries, require at least one char after the prefix
// to avoid matching bare "@/" as "nothing".
const after = specifier.slice(entry.prefix.length);
if (after.length === 0 && !entry.exact) continue;
matchedEntry = entry;
rest = after;
break;
}
}
if (!matchedEntry) return null;
const targetDir = join(root, matchedEntry.target);
// Exact match (e.g. `@omniroute/open-sse` with no trailing path) →
// resolve to `<target>/index.*`.
if (rest === "" || rest === undefined) {
return probeIndex(targetDir);
}
// Guard against absolute-ish escapes (`@//etc/passwd`, `@/\x00`).
if (rest.startsWith("/") || rest.startsWith("\\")) {
return null;
}
// Guard against path-traversal escapes (`@/../../../etc/hostname`).
const segments = rest.split(/[\\\/]+/);
if (segments.includes("..")) {
return null;
}
const base = join(targetDir, rest);
if (!isWithinRoot(targetDir, base)) {
return null;
}
return probeFile(base) ?? probeIndex(base) ?? null;
}
/**
* Probe a bare path and its extension variants. Returns the first existing
* match as a `file://` URL, or `null`.
*/
function probeFile(base) {
// Try extension variants first — a bare `base` that happens to be a directory
// would match existsSync() but should NOT be returned as a file URL (the
// caller expects a file, not a directory). Extension-probing avoids this
// false positive (e.g. `usage` vs `usage.ts` vs `usage/`).
for (const ext of SOURCE_EXTENSIONS) {
const candidate = base + ext;
if (existsSync(candidate)) return pathToFileURL(candidate).href;
}
// Only accept the bare path if it is NOT a directory.
if (existsSync(base)) {
try {
const st = statSync(base);
if (!st.isDirectory()) return pathToFileURL(base).href;
} catch {}
}
return null;
}
/**
* Probe a directory for an `index.*` entry. Returns the first existing
* match as a `file://` URL, or `null`.
*/
function probeIndex(dir) {
const indexBase = join(dir, "index");
for (const ext of SOURCE_EXTENSIONS) {
const candidate = indexBase + ext;
if (existsSync(candidate)) return pathToFileURL(candidate).href;
}
return null;
}
/**
* True when `candidate` resolves to a location inside `ancestor` (or is
* `ancestor` itself). Used as a second, path-normalization-aware layer of
* defense against traversal beyond the literal `..` segment check above.
*
* @param {string} ancestor
* @param {string} candidate
* @returns {boolean}
*/
function isWithinRoot(ancestor, candidate) {
const rel = relative(ancestor, candidate);
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
}
/**
* Register the ESM resolve hook for the current process. Safe to call multiple
* times — subsequent calls are no-ops once the hook is installed.
*
* Uses Node's stable `module.register()` API (available since Node 20.6,
* required Node 22+ here). The hook runs in a worker thread but only reads the
* captured `root`, so no shared-state hazards.
*
* @param {string} root Absolute path to the package root.
* @returns {Promise<boolean>} Resolves `true` once registered (or if already
* registered), `false` on environments where `module.register` is unavailable.
*/
let _registered = false;
export async function registerAliasResolver(root) {
// Validate input FIRST, before the _registered short-circuit. Otherwise the
// second call in the same process (e.g. a test suite that already registered
// once) would silently return `true` for invalid input instead of rejecting,
// masking programmer errors. Input validation must be unconditional.
if (!root || typeof root !== "string") {
throw new TypeError("registerAliasResolver: root must be a non-empty string");
}
if (_registered) return true;
// if the directory does not exist we would only mask a real misconfiguration
// by installing a hook that rewrites to nowhere.
if (!existsSync(join(root, "src"))) {
return false;
}
try {
const { register } = await import("node:module");
// #7808: load the hook from a real file on disk via pathToFileURL() instead
// of building a `data:text/javascript,...` URL dynamically. CodeQL's
// `js/incomplete-url-substring-sanitization` flagged the interpolated
// `new URL(...)` call; a file URL produced by pathToFileURL() is a trusted,
// fully-parsed URL — no sanitization ambiguity. The hook source lives in
// `bin/aliasResolverHook.mjs` (sibling of this file), shipped via
// package.json "files": ["bin/"].
const hookPath = join(__dirname, "aliasResolverHook.mjs");
const hookUrl = pathToFileURL(hookPath);
register(hookUrl, { data: { root } });
_registered = true;
return true;
} catch {
// Older Node or sandboxed env without module.register — fall back to the
// default resolver. The bug will resurface only in the exact global-install
// scenario, which is what we explicitly patched; other entry points still
// work because they import via relative paths.
return false;
}
}
// #7808: the ESM loader hook source now lives in `bin/aliasResolverHook.mjs`,
// loaded via `pathToFileURL()` above. The previous inline `HOOK_SOURCE` template
// literal was removed because its `new URL(\`data:text/javascript,...\`)` wrapper
// triggered CodeQL `js/incomplete-url-substring-sanitization`. The hook logic
// itself is unchanged — see aliasResolverHook.mjs for the resolver behaviour.

View File

@@ -1,126 +0,0 @@
/**
* ESM loader hook for path-alias resolution (#7791 + #7808).
*
* This file runs in Node's loader worker thread after being registered via
* `module.register(url, data)` from `bin/aliasResolver.mjs`. It MUST NOT import
* anything from the parent module — all inputs arrive through `initialize(data)`.
*
* Behaviour:
* - Rewrites alias specifiers to absolute filesystem paths, mirroring
* tsconfig.json `paths`:
* - `@/*` → <root>/src/*
* - `@omniroute/open-sse` → <root>/open-sse/index.*
* - `@omniroute/open-sse/*` → <root>/open-sse/*
* - Probes the usual source extensions (`.ts`, `.tsx`, `.js`, `.mjs`, `.cjs`,
* `.json`) plus `index.*` for directory imports.
* - Returns `shortCircuit: true` only when a candidate file exists on disk;
* otherwise delegates to the next resolver (tsx/Node) so unrelated imports
* and legitimate "module not found" errors pass through unchanged.
*
* Why a separate file instead of an inline `data:` URL?
* CodeQL's `js/incomplete-url-substring-sanitization` flags dynamic `new URL(...)`
* construction with interpolated strings. A real file URL produced by
* `pathToFileURL()` is a trusted, fully-parsed URL — no sanitization ambiguity.
*/
import { pathToFileURL } from "node:url";
import { join, relative, isAbsolute } from "node:path";
import { existsSync, statSync } from "node:fs";
let ROOT = "";
export function initialize(data) {
ROOT = (data && data.root) || "";
}
const EXTENSIONS = [".ts", ".tsx", ".js", ".mjs", ".cjs", ".json"];
/**
* Alias prefix table — mirrors ALIAS_MAP in aliasResolver.mjs and
* tsconfig.json `paths`. Processed top-to-bottom; first match wins.
*
* @type {Array<{prefix: string, target: string, exact: boolean}>}
*/
const ALIAS_TABLE = [
{ prefix: "@/", target: "src", exact: false },
{ prefix: "@omniroute/open-sse/", target: "open-sse", exact: false },
{ prefix: "@omniroute/open-sse", target: "open-sse", exact: true },
];
function tryResolveAliasFsPath(specifier) {
if (!ROOT || typeof specifier !== "string") return null;
// Find the first matching alias entry.
let matchedEntry = null;
let rest = null;
for (const entry of ALIAS_TABLE) {
if (specifier.startsWith(entry.prefix)) {
const after = specifier.slice(entry.prefix.length);
if (after.length === 0 && !entry.exact) continue;
matchedEntry = entry;
rest = after;
break;
}
}
if (!matchedEntry) return null;
const targetDir = join(ROOT, matchedEntry.target);
// Exact match (e.g. `@omniroute/open-sse`) → resolve to `<target>/index.*`.
if (rest === "" || rest === undefined) {
return probeIndex(targetDir);
}
// Guard against absolute-ish escapes.
if (rest.startsWith("/") || rest.startsWith("\\")) return null;
// Guard against path-traversal escapes.
const segments = rest.split(/[\\\/]+/);
if (segments.includes("..")) return null;
const base = join(targetDir, rest);
if (!isWithinRoot(targetDir, base)) return null;
return probeFile(base) ?? probeIndex(base) ?? null;
}
function probeFile(base) {
// Extension variants first — avoids matching a bare directory name.
for (const ext of EXTENSIONS) {
const candidate = base + ext;
if (existsSync(candidate)) return candidate;
}
if (existsSync(base)) {
try {
const st = statSync(base);
if (!st.isDirectory()) return base;
} catch {}
}
return null;
}
function probeIndex(dir) {
const indexBase = join(dir, "index");
for (const ext of EXTENSIONS) {
const candidate = indexBase + ext;
if (existsSync(candidate)) return candidate;
}
return null;
}
/**
* True when `candidate` resolves to a location inside `ancestor` (or is
* `ancestor` itself). Path-normalization-aware defense against traversal.
*/
function isWithinRoot(ancestor, candidate) {
const rel = relative(ancestor, candidate);
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
}
export function resolve(specifier, context, nextResolve) {
const fsPath = tryResolveAliasFsPath(specifier);
if (fsPath) {
return {
url: pathToFileURL(fsPath).href,
shortCircuit: true,
};
}
return nextResolve(specifier, context);
}

View File

@@ -1,163 +0,0 @@
import { chmodSync, existsSync, writeFileSync } from "node:fs";
import { decryptCredential } from "../encryption.mjs";
import { findProviderConnection, listProviderConnections } from "../provider-store.mjs";
import { openOmniRouteDb } from "../sqlite.mjs";
import { t } from "../i18n.mjs";
/**
* Local-only, operator-invoked command that dumps DECRYPTED provider credentials
* (apiKey/accessToken/refreshToken/idToken). This never runs inside the HTTP server
* process and must never be reachable over the network — no src/app/api/ route wraps
* this. See docs/security/ for the threat-model writeup referenced in issue #6683.
*/
const CREDENTIAL_FIELDS = [
{ key: "apiKey", envSuffix: "API_KEY" },
{ key: "accessToken", envSuffix: "ACCESS_TOKEN" },
{ key: "refreshToken", envSuffix: "REFRESH_TOKEN" },
{ key: "idToken", envSuffix: "ID_TOKEN" },
];
const VALID_FORMATS = new Set(["json", "env"]);
const SECURE_FILE_MODE = 0o600;
export function registerAuthExport(program) {
program
.command("auth export")
.description(t("authExport.description"))
.option("--id <id>", t("authExport.idOpt"))
.option("--format <format>", t("authExport.formatOpt"), "json")
.option("--out <file>", t("authExport.outOpt"))
.option("--force", t("authExport.forceOpt"))
.action(async (opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
const exitCode = await runAuthExportCommand({ ...opts, ...globalOpts });
if (exitCode !== 0) process.exit(exitCode);
});
}
export async function runAuthExportCommand(opts = {}) {
// Security control (a): confirmation gate BEFORE any DB access — a dry invocation
// never opens the database and never decrypts anything.
if (!opts.force) {
printConfirmationGate();
return 0;
}
const format = opts.format || "json";
if (!VALID_FORMATS.has(format)) {
console.error(t("authExport.invalidFormat", { format }));
return 1;
}
if (!process.env.STORAGE_ENCRYPTION_KEY) {
console.error(t("authExport.missingKey"));
return 1;
}
// Security control (b): stderr warning banner BEFORE any plaintext is emitted.
process.stderr.write(t("authExport.warning") + "\n");
const rows = await loadTargetConnections(opts.id);
if (rows === null) {
console.error(t("authExport.notFound", { id: opts.id }));
return 1;
}
const exported = rows.map(exportConnection);
const content = format === "env" ? formatAsEnv(exported) : formatAsJson(exported);
if (opts.out) {
writeSecureFile(opts.out, content);
} else {
console.log(content);
}
return 0;
}
function printConfirmationGate() {
console.log(
`\n${t("authExport.confirmHeading")}\n\n${t("authExport.confirmBody")}\n\n${t("authExport.confirmFooter")}\n`
);
}
async function loadTargetConnections(id) {
const { db } = await openOmniRouteDb();
try {
if (!id) return listProviderConnections(db);
const connection = findProviderConnection(db, id);
return connection ? [connection] : null;
} finally {
db.close();
}
}
function decryptField(rawValue) {
// Security control (d): a per-field decrypt failure surfaces as a boolean flag,
// never the caught error text. Security control (e): the caught error is never
// interpolated into any message.
try {
return { value: decryptCredential(rawValue), failed: false };
} catch {
return { value: null, failed: true };
}
}
function exportConnection(connection) {
const result = {
id: connection.id,
provider: connection.provider,
name: connection.name,
authType: connection.authType,
};
for (const { key } of CREDENTIAL_FIELDS) {
const rawValue = connection[key];
if (!rawValue) {
result[key] = null;
result[`${key}DecryptFailed`] = false;
continue;
}
const { value, failed } = decryptField(rawValue);
result[key] = value;
result[`${key}DecryptFailed`] = failed;
}
return result;
}
function formatAsJson(rows) {
return JSON.stringify(rows, null, 2);
}
function envSafeSegment(value) {
return String(value || "")
.toUpperCase()
.replace(/[^A-Z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}
function formatAsEnv(rows) {
const lines = [];
for (const row of rows) {
lines.push(`# ${row.provider} (${row.id})`);
const providerSegment = envSafeSegment(row.provider);
for (const { key, envSuffix } of CREDENTIAL_FIELDS) {
const value = row[key];
if (!value) continue;
lines.push(`OMNIROUTE_${providerSegment}_${envSuffix}=${value}`);
}
}
return lines.join("\n");
}
function writeSecureFile(filePath, content) {
// Security control (c): file output written with mode 0o600 (plus chmodSync if the
// file pre-existed, belt-and-suspenders against an already world-readable file).
const preExisted = existsSync(filePath);
writeFileSync(filePath, content, { mode: SECURE_FILE_MODE });
if (preExisted) {
chmodSync(filePath, SECURE_FILE_MODE);
}
}

View File

@@ -15,11 +15,7 @@ function readCache() {
try {
const raw = JSON.parse(readFileSync(cachePath(), "utf8"));
if (raw && typeof raw.ts === "number" && Date.now() - raw.ts < CACHE_TTL_MS) return raw;
} catch (err) {
if (process.env.OMNIROUTE_DEBUG_COMPLETION) {
console.error("[omniroute completion] readCache failed:", err?.message ?? err);
}
}
} catch {}
return null;
}
@@ -45,20 +41,12 @@ async function refreshCache(opts = {}) {
const j = await mr.value.json();
models = (Array.isArray(j) ? j : j.data || []).map((m) => m.id).filter(Boolean);
}
} catch (err) {
if (process.env.OMNIROUTE_DEBUG_COMPLETION) {
console.error("[omniroute completion] refreshCache failed:", err?.message ?? err);
}
}
} catch {}
const data = { combos, providers, models, ts: Date.now() };
try {
mkdirSync(dirname(cachePath()), { recursive: true });
writeFileSync(cachePath(), JSON.stringify(data));
} catch (err) {
if (process.env.OMNIROUTE_DEBUG_COMPLETION) {
console.error("[omniroute completion] writeCache failed:", err?.message ?? err);
}
}
} catch {}
return data;
}

View File

@@ -24,7 +24,7 @@ async function restCompressionStatus() {
const combosBody = combosRes.ok ? await combosRes.json() : { combos: [] };
const analytics = analyticsRes && analyticsRes.ok ? await analyticsRes.json() : null;
return {
strategy: settings.defaultMode || "standard",
engine: settings.engine ?? null,
settings,
combos: combosBody.combos ?? combosBody,
analytics,
@@ -33,10 +33,7 @@ async function restCompressionStatus() {
async function restCompressionConfigure(config) {
const body = { ...config };
if (body.strategy) {
body.defaultMode = body.strategy === "caveman" ? "standard" : normalizeEngine(body.strategy);
delete body.strategy;
}
if (body.engine) body.engine = normalizeEngine(body.engine);
const res = await apiFetch("/api/settings/compression", { method: "PUT", body });
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
@@ -46,10 +43,9 @@ async function restCompressionConfigure(config) {
}
async function restSetEngine(name) {
const normalized = normalizeEngine(name);
const res = await apiFetch("/api/settings/compression", {
method: "PUT",
body: { defaultMode: normalized === "caveman" ? "standard" : normalized },
body: { engine: normalizeEngine(name) },
});
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
@@ -107,11 +103,7 @@ export async function runCompressionStatus(opts, cmd) {
export async function runCompressionConfigure(opts, cmd) {
const config = {};
// #6571 — both the MCP tool schema (compressionConfigureInput) and
// handleCompressionConfigure expect `strategy`, not `engine`; a non-strict
// MCP schema silently strips an unrecognized `engine` key on the primary
// (MCP-mounted) path, so this must be `strategy` on both paths.
if (opts.engine) config.strategy = normalizeEngine(opts.engine);
if (opts.engine) config.engine = opts.engine;
if (opts.cavemanAggressiveness !== undefined)
config.caveman = { aggressiveness: opts.cavemanAggressiveness };
if (opts.rtkBudget !== undefined) config.rtk = { tokenBudget: opts.rtkBudget };
@@ -171,7 +163,7 @@ export function registerCompression(program) {
engine.command("set <name>").action(runCompressionEngineSet);
engine.command("get").action(async (opts, cmd) => {
const data = await mcpCall("omniroute_compression_status", {}, restCompressionStatus);
process.stdout.write(`${data.strategy ?? "(default)"}\n`);
process.stdout.write(`${data.engine ?? "(default)"}\n`);
});
const combos = cmp.command("combos").description(t("compression.combos.description"));

View File

@@ -1,22 +1,17 @@
import { execFile } from "node:child_process";
import { t } from "../i18n.mjs";
function parsePort(value, fallback) {
const parsed = parseInt(String(value), 10);
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
}
export function registerDashboard(program) {
program
.command("dashboard")
.description(t("dashboard.description"))
.option("--url", t("dashboard.urlOnly"))
.option("--port <port>", "Port the server is running on")
.option("--port <port>", "Port the server is running on", "20128")
.option("--tui", t("dashboard.tui") || "Open interactive TUI dashboard (terminal UI)")
.action(async (opts, cmd) => {
if (opts.tui) {
const globalOpts = cmd.optsWithGlobals();
const port = parsePort(opts.port ?? process.env.PORT ?? "20128", 20128);
const port = opts.port ? parseInt(String(opts.port), 10) : 20128;
const baseUrl = globalOpts.baseUrl ?? `http://localhost:${port}`;
const apiKey = globalOpts.apiKey ?? null;
const { startInteractiveTui } = await import("../tui/Dashboard.jsx");
@@ -29,7 +24,7 @@ export function registerDashboard(program) {
}
export async function runDashboardCommand(opts = {}) {
const port = parsePort(opts.port ?? process.env.PORT ?? "20128", 20128);
const port = opts.port ? parseInt(String(opts.port), 10) : 20128;
const dashboardUrl = `http://localhost:${port}`;
if (opts.url) {
@@ -49,22 +44,22 @@ export async function runDashboardCommand(opts = {}) {
return 0;
}
/**
* Resolve the command and args to open a URL in the default browser
* for a given platform. Exported for testing — callers should use openFallback().
* @param {"darwin"|"win32"|string} platform
* @param {string} url
* @returns {{ cmd: string, args: string[] }}
*/
export function resolveOpenCommand(platform, url) {
if (platform === "darwin") return { cmd: "open", args: [url] };
if (platform === "win32") return { cmd: "rundll32", args: ["url.dll,FileProtocolHandler", url] };
return { cmd: "xdg-open", args: [url] };
}
function openFallback(url) {
return new Promise((resolve) => {
const { cmd, args } = resolveOpenCommand(process.platform, url);
const { platform } = process;
let cmd, args;
if (platform === "darwin") {
cmd = "open";
args = [url];
} else if (platform === "win32") {
cmd = "cmd";
args = ["/c", "start", "", url];
} else {
cmd = "xdg-open";
args = [url];
}
execFile(cmd, args, { stdio: "ignore" }, () => resolve());
});
}

View File

@@ -3,7 +3,7 @@ import net from "node:net";
import os from "node:os";
import path from "node:path";
import { createDecipheriv, scryptSync } from "node:crypto";
import { fileURLToPath, pathToFileURL } from "node:url";
import { pathToFileURL } from "node:url";
import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs";
import { printHeading } from "../io.mjs";
import { t } from "../i18n.mjs";
@@ -380,67 +380,24 @@ function resolveLivenessUrl(options = {}) {
return `http://${formatHostForUrl(host || "127.0.0.1")}:${dashboardPort}/api/health/degradation`;
}
async function probeUrl(url) {
try {
const response = await fetchWithTimeout(url);
return { ok: response.ok, status: response.status };
} catch {
return { ok: false, status: 0 };
}
}
async function checkServerLiveness(options = {}) {
const url = resolveLivenessUrl(options);
// First attempt: configured health endpoint (may require auth token).
const primary = await probeUrl(url);
if (primary.ok) {
return ok("Server liveness", "Server health endpoint is reachable", { url, status: primary.status });
}
// #6162: /api/health and /api/health/degradation require a management token.
// When unauthenticated, fall back to probing a publicly served static asset
// (favicon.ico) to confirm the Next.js server is alive and reachable.
// Derive the fallback URL from the primary URL (preserving protocol/host/port)
// so custom liveness URL configurations are honored. Fall back to defaults
// only if the primary URL can't be parsed.
let fallbackUrl;
try {
const parsed = new URL(url);
parsed.pathname = "/favicon.ico";
parsed.search = "";
parsed.hash = "";
fallbackUrl = parsed.toString();
const response = await fetchWithTimeout(url);
if (!response.ok) {
return warn("Server liveness", `Server responded with HTTP ${response.status}`, { url });
}
return ok("Server liveness", "Server health endpoint is reachable", { url });
} catch {
const port = parsePort(process.env.PORT || "20128", 20128);
const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(port), port);
const host = String(options.livenessHost || process.env.OMNIROUTE_DOCTOR_HOST || "127.0.0.1")
.trim()
.replace(/^https?:\/\//, "")
.replace(/\/.*$/, "");
fallbackUrl = `http://${formatHostForUrl(host || "127.0.0.1")}:${dashboardPort}/favicon.ico`;
return warn("Server liveness", "Server health endpoint is not reachable", { url });
}
const fallback = await probeUrl(fallbackUrl);
if (fallback.ok) {
return ok(
"Server liveness",
`Server reachable (health endpoint returned ${primary.status}, likely requires MANAGEMENT_TOKEN)`,
{ primaryUrl: url, primaryStatus: primary.status, fallbackUrl, fallbackStatus: fallback.status }
);
}
return warn(
"Server liveness",
`Server health endpoint returned HTTP ${primary.status || "no-response"} and fallback probe failed`,
{ primaryUrl: url, primaryStatus: primary.status, fallbackUrl, fallbackStatus: fallback.status }
);
}
export async function collectDoctorChecks(context = {}, options = {}) {
const rootDir =
context.rootDir ||
path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", "..", "..");
const dataDir = resolveDataDir();
const dbPath = resolveStoragePath(dataDir);

View File

@@ -48,11 +48,7 @@ export async function runHealthCommand(opts = {}) {
}
try {
const res = await apiFetch("/api/monitoring/health", {
retry: false,
timeout: 5000,
acceptNotOk: true,
});
const res = await apiFetch("/api/health", { retry: false, timeout: 5000, acceptNotOk: true });
if (!res.ok) {
console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1;
@@ -70,22 +66,29 @@ export async function runHealthCommand(opts = {}) {
if (health.uptime) console.log(t("health.uptime", { uptime: health.uptime }));
if (health.version) console.log(` Version: ${health.version}`);
if (health.activeConnections !== undefined) {
console.log(t("health.requests", { count: health.activeConnections }));
if (health.requests !== undefined) {
console.log(t("health.requests", { count: health.requests }));
}
if (health.circuitBreakers && opts.verbose) {
if (health.breakers && opts.verbose) {
console.log("\n \x1b[1mCircuit Breakers\x1b[0m");
const { open = 0, halfOpen = 0, closed = 0 } = health.circuitBreakers;
console.log(` \x1b[32m● closed\x1b[0m ${closed}`);
console.log(` \x1b[33m○ half-open\x1b[0m ${halfOpen}`);
console.log(` \x1b[31m○ open\x1b[0m ${open}`);
for (const [name, status] of Object.entries(health.breakers)) {
const state =
status.state === "closed" ? "\x1b[32m● closed\x1b[0m" : "\x1b[33m○ open\x1b[0m";
console.log(` ${name.padEnd(20)} ${state}`);
}
}
if (opts.verbose && health.memoryUsage) {
if (health.cache && opts.verbose) {
console.log("\n \x1b[1mCache\x1b[0m");
console.log(` Semantic hits: ${health.cache.semanticHits || 0}`);
console.log(` Signature hits: ${health.cache.signatureHits || 0}`);
}
if (opts.verbose && health.memory) {
console.log("\n \x1b[1mMemory\x1b[0m");
console.log(` RSS: ${health.memoryUsage.rss || "N/A"}`);
console.log(` Heap used: ${health.memoryUsage.heapUsed || "N/A"}`);
console.log(` RSS: ${health.memory.rss || "N/A"}`);
console.log(` Heap used: ${health.memory.heapUsed || "N/A"}`);
}
return 0;
@@ -97,17 +100,13 @@ export async function runHealthCommand(opts = {}) {
export async function runHealthComponentsCommand(opts = {}) {
try {
const res = await apiFetch("/api/monitoring/health", {
retry: false,
timeout: 5000,
acceptNotOk: true,
});
const res = await apiFetch("/api/health", { retry: false, timeout: 5000, acceptNotOk: true });
if (!res.ok) {
console.error(`HTTP ${res.status}`);
return 1;
}
const health = await res.json();
const components = health.components || health.circuitBreakers || {};
const components = health.components || health.breakers || {};
for (const [name, info] of Object.entries(components)) {
const status =
typeof info === "object" ? info.state || info.status || "unknown" : String(info);

View File

@@ -19,16 +19,6 @@ const STRIPPED_CODEX_ENV_KEYS = [
/** Placeholder so codex's `env_key` is always satisfied when the backend is open. */
const NO_AUTH_SENTINEL = "omniroute-no-auth";
// On Windows the `codex` binary is an npm `.cmd` shim that `spawn` cannot resolve
// without a shell (bare "codex" → ENOENT). Mirror the qodercli Windows fix (#6263):
// spawn `codex.cmd` through a shell on win32, and the bare binary elsewhere.
export function resolveCodexSpawn(platform) {
if (platform === "win32") {
return { command: "codex.cmd", shell: true };
}
return { command: "codex", shell: undefined };
}
function stripTrailingSlash(value) {
let s = String(value);
let end = s.length;
@@ -136,10 +126,10 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
if (!(await healthCheck(baseUrl))) {
console.error(
(
t("launch.notRunning") ||
"OmniRoute is not reachable at {port}. Start it with 'omniroute serve'."
).replace("{port}", baseUrl)
(t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace(
"{port}",
baseUrl
)
);
return 1;
}
@@ -152,12 +142,7 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
const env = buildCodexEnv(process.env, authToken);
return await new Promise((resolve) => {
const { command: codexLaunch, shell: shellValue } = resolveCodexSpawn(process.platform);
const child = spawn(codexLaunch, extraArgs, {
env,
stdio: "inherit",
shell: shellValue,
});
const child = spawn("codex", extraArgs, { env, stdio: "inherit" });
child.on("error", (err) => {
if (err?.code === "ENOENT") {
console.error(
@@ -180,16 +165,10 @@ export function registerLaunchCodex(program) {
t("launchCodex.description") || "Launch Codex CLI pointed at OmniRoute (local or remote VPS)"
)
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
.option(
"--remote <url>",
"Remote OmniRoute base URL, e.g. http://192.168.0.15:20128 (overrides --port + context)"
)
.option("--remote <url>", "Remote OmniRoute base URL, e.g. http://192.168.0.15:20128 (overrides --port + context)")
.option("--profile <name>", "Codex profile to activate (passed as --profile <name>)")
.option("-p, --p <name>", "Alias for --profile")
.option(
"--api-key <key>",
"OmniRoute API key (overrides OMNIROUTE_API_KEY env var for this invocation)"
)
.option("--api-key <key>", "OmniRoute API key (overrides OMNIROUTE_API_KEY env var for this invocation)")
.allowUnknownOption(true)
.allowExcessArguments(true)
.argument("[codexArgs...]", "arguments passed through to the codex binary")

View File

@@ -106,10 +106,10 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
if (!res.ok) throw new Error(`status ${res.status}`);
} catch {
console.error(
(
t("launch.notRunning") ||
"OmniRoute is not reachable at {port}. Start it with 'omniroute serve'."
).replace("{port}", baseUrl)
(t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace(
"{port}",
baseUrl
)
);
return 1;
}
@@ -120,14 +120,7 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
const env = buildClaudeEnv(process.env, baseUrl, authToken, { configDir });
return await new Promise((resolve) => {
// #8246: on Windows, npm installs claude as a .cmd shim — spawn() without
// shell:true cannot resolve PATHEXT shims and fails with ENOENT.
const claudeCommand = process.platform === "win32" ? "claude.cmd" : "claude";
const child = spawn(claudeCommand, claudeArgs, {
env,
stdio: "inherit",
...(process.platform === "win32" ? { shell: true, windowsHide: true } : {}),
});
const child = spawn("claude", claudeArgs, { env, stdio: "inherit" });
child.on("error", (err) => {
if (err && err.code === "ENOENT") {
console.error(t("launch.notFound") || "The 'claude' CLI was not found in PATH.");
@@ -149,10 +142,7 @@ export function registerLaunch(program) {
)
.option("--port <port>", t("serve.port") || "Proxy port", "20128")
.option("--remote <url>", "Remote OmniRoute base URL (overrides --port and the active context)")
.option(
"--profile <name>",
"Claude Code profile to use (CLAUDE_CONFIG_DIR ~/.claude/profiles/<name>)"
)
.option("--profile <name>", "Claude Code profile to use (CLAUDE_CONFIG_DIR ~/.claude/profiles/<name>)")
.option("--token <token>", t("launch.token") || "Token Claude sends (ANTHROPIC_AUTH_TOKEN)")
.option("--api-key <key>", "Alias for --token (OmniRoute access token / API key)")
.allowUnknownOption(true)

View File

@@ -1,192 +0,0 @@
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
/**
* `omniroute login antigravity` — local OAuth helper for remote installs.
*
* Why this exists: Google's `firstparty/nativeapp` consent for the embedded
* Antigravity desktop client only releases the authorization code when the
* loopback redirect (127.0.0.1:<port>) is REACHABLE. On a remote VPS install the
* loopback is unreachable, so the consent hangs forever and never emits a code —
* the dashboard's "paste the callback URL" fallback has nothing to paste. (The
* same flow works locally and over an SSH tunnel, where the loopback IS reachable.)
*
* This command runs the OAuth on the user's OWN machine — where 127.0.0.1 works —
* captures the code on a local loopback server, exchanges it for tokens, and
* prints a single-line credential blob. The user pastes that blob into the remote
* dashboard (Antigravity → "Paste credentials"), which decodes it, finalizes the
* onboarding server-side, and persists the connection.
*
* It talks ONLY to Google (no OmniRoute server needed locally), so it works even
* if the remote VPS is firewalled from the user's machine.
*/
const PROVIDER = "antigravity";
/** Open the system browser; no-op if the optional `open` dependency is missing. */
async function defaultOpenBrowser(url) {
try {
const { default: open } = await import("open");
await open(url);
} catch {
// `open` not available — the caller already printed the URL to paste manually.
}
}
/**
* Start a loopback HTTP server bound to 127.0.0.1 (NOT 0.0.0.0 — we never want to
* expose the callback to the LAN). Resolves to { port, waitForCallback, close }.
*/
function defaultStartServer(preferredPort) {
return new Promise((resolve, reject) => {
let resolveCallback;
const callbackPromise = new Promise((r) => {
resolveCallback = r;
});
const server = createServer((req, res) => {
const url = new URL(req.url, "http://127.0.0.1");
if (url.pathname !== "/callback" && url.pathname !== "/auth/callback") {
res.writeHead(404).end();
return;
}
const params = Object.fromEntries(url.searchParams.entries());
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end(
"<!doctype html><meta charset=utf-8><title>OmniRoute</title>" +
"<body style=\"font-family:system-ui;padding:2rem\">" +
"<h2>✅ Authorization received</h2>" +
"<p>Return to your terminal — you can close this tab.</p></body>"
);
resolveCallback(params);
});
server.on("error", reject);
server.listen(preferredPort || 0, "127.0.0.1", () => {
const { port } = server.address();
resolve({
port,
waitForCallback: () => callbackPromise,
close: () => new Promise((r) => server.close(() => r())),
});
});
});
}
/** Lazy-load the antigravity provider + blob codec (TS source via tsx). */
async function loadDeps() {
const { antigravity } = await import("../../../src/lib/oauth/providers/antigravity.ts");
const { encodeCredentialBlob } = await import("../../../src/lib/oauth/credentialBlob.ts");
return { antigravity, encodeCredentialBlob };
}
/**
* Build the Google authorization request for a given loopback port. Uses a plain
* authorization_code grant (NO PKCE code_challenge) — matching the working flow:
* a code_challenge here would force the exchange to require a code_verifier.
*/
export async function buildAntigravityAuthRequest(port, makeState = randomUUID) {
const { antigravity } = await loadDeps();
const redirectUri = `http://127.0.0.1:${port}/callback`;
const state = makeState();
const authUrl = antigravity.buildAuthUrl(antigravity.config, redirectUri, state);
return { redirectUri, state, authUrl };
}
/** Exchange the captured code for raw Google tokens (no code_verifier — no PKCE). */
export async function exchangeAntigravityCode(code, redirectUri) {
const { antigravity } = await loadDeps();
return antigravity.exchangeToken(antigravity.config, code, redirectUri);
}
/**
* Orchestrate the local login. Dependencies are injectable for testing; the real
* path uses a 127.0.0.1 loopback server, the system browser, and a live token
* exchange against Google. Returns the credential blob string.
*/
export async function runAntigravityLogin(opts = {}, deps = {}) {
const startServer = deps.startServer ?? defaultStartServer;
const openBrowser = deps.openBrowser ?? defaultOpenBrowser;
const exchange = deps.exchange ?? exchangeAntigravityCode;
const makeState = deps.makeState ?? randomUUID;
const print = deps.print ?? ((s) => process.stdout.write(s));
const log = deps.log ?? ((s) => process.stderr.write(s));
const { encodeCredentialBlob } = await loadDeps();
const server = await startServer(opts.port);
const { redirectUri, state, authUrl } = await buildAntigravityAuthRequest(server.port, makeState);
log(`\nOpen this URL to authorize Antigravity (it will open automatically):\n\n ${authUrl}\n\n`);
if (opts.browser !== false) await openBrowser(authUrl);
log("Waiting for Google to redirect back to the local loopback...\n");
const timeoutMs = opts.timeout ?? 300000;
let timer;
let params;
try {
params = await Promise.race([
server.waitForCallback(),
new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error("Timed out waiting for the OAuth callback")),
timeoutMs
);
// Don't keep the event loop alive solely for this timer.
if (typeof timer.unref === "function") timer.unref();
}),
]);
} finally {
clearTimeout(timer);
await server.close();
}
if (params.error) {
throw new Error(`Authorization failed: ${params.error_description || params.error}`);
}
if (params.state !== state) {
throw new Error("State mismatch — aborting (possible CSRF). Please retry the login.");
}
if (!params.code) {
throw new Error("No authorization code returned by Google.");
}
const tokens = await exchange(params.code, redirectUri);
const blob = encodeCredentialBlob({ provider: PROVIDER, tokens });
print(
"\n" +
"Antigravity authorized. Copy the line below and paste it into your remote\n" +
"OmniRoute dashboard: Providers → Antigravity → Connect → \"Paste credentials\".\n" +
"(This contains a refresh token — treat it like a password.)\n\n" +
blob +
"\n\n"
);
return blob;
}
async function runLoginAntigravity(opts) {
try {
await runAntigravityLogin({
browser: opts.browser,
timeout: opts.timeout,
port: opts.port,
});
} catch (err) {
process.stderr.write(`\nLogin failed: ${err?.message || err}\n`);
process.exit(1);
}
}
export function registerLogin(program) {
const login = program
.command("login")
.description("Local OAuth helpers for remote OmniRoute installs (run on your own machine)");
login
.command("antigravity")
.description("Authorize Antigravity locally and print a credential blob to paste remotely")
.option("--no-browser", "Do not auto-open the browser; print the URL instead")
.option("--port <n>", "Fixed loopback port (default: OS-assigned)", (v) => parseInt(v, 10))
.option("--timeout <ms>", "How long to wait for the callback", (v) => parseInt(v, 10), 300000)
.action(runLoginAntigravity);
}

View File

@@ -7,6 +7,7 @@ const PROVIDERS_WITH_OAUTH = [
{ id: "gemini", name: "Google Gemini", flow: "browser" },
{ id: "antigravity", name: "Antigravity", flow: "browser" },
{ id: "windsurf", name: "Windsurf", flow: "browser" },
{ id: "qwen", name: "Qwen Code", flow: "browser" },
{ id: "cursor", name: "Cursor", flow: "import" },
{ id: "zed", name: "Zed", flow: "import" },
{ id: "kiro", name: "Amazon Kiro", flow: "social" },

View File

@@ -2,7 +2,6 @@ import { registerMemory } from "./memory.mjs";
import { registerSkills } from "./skills.mjs";
import { registerAudit } from "./audit.mjs";
import { registerOAuth } from "./oauth.mjs";
import { registerLogin } from "./login.mjs";
import { registerCloud } from "./cloud.mjs";
import { registerEval } from "./eval.mjs";
import { registerWebhooks } from "./webhooks.mjs";
@@ -37,7 +36,6 @@ import { registerProviders } from "./providers.mjs";
import { registerProvider } from "./provider-cmd.mjs";
import { registerConfig } from "./config.mjs";
import { registerKeys } from "./keys.mjs";
import { registerAuthExport } from "./auth-export.mjs";
import { registerModels } from "./models.mjs";
import { registerCombo } from "./combo.mjs";
import { registerStatus } from "./status.mjs";
@@ -70,8 +68,9 @@ import { registerSetupCursor } from "./setup-cursor.mjs";
import { registerSetupRoo } from "./setup-roo.mjs";
import { registerSetupCrush } from "./setup-crush.mjs";
import { registerSetupGoose } from "./setup-goose.mjs";
import { registerSetupAider } from "./setup-aider.mjs";
import { registerSetupQwen } from "./setup-qwen.mjs";
import { registerSetupAider } from "./setup-aider.mjs";
import { registerSetupGemini } from "./setup-gemini.mjs";
import { registerConnect } from "./connect.mjs";
import { registerContexts } from "./contexts.mjs";
import { registerTokens } from "./tokens.mjs";
@@ -84,7 +83,6 @@ export function registerCommands(program) {
registerSkills(program);
registerAudit(program);
registerOAuth(program);
registerLogin(program);
registerCloud(program);
registerEval(program);
registerWebhooks(program);
@@ -119,7 +117,6 @@ export function registerCommands(program) {
registerProvider(program);
registerConfig(program);
registerKeys(program);
registerAuthExport(program);
registerModels(program);
registerCombo(program);
registerStatus(program);
@@ -153,8 +150,9 @@ export function registerCommands(program) {
registerSetupRoo(program);
registerSetupCrush(program);
registerSetupGoose(program);
registerSetupAider(program);
registerSetupQwen(program);
registerSetupAider(program);
registerSetupGemini(program);
registerConnect(program);
registerContexts(program);
registerTokens(program);

View File

@@ -1,26 +1,14 @@
import { spawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import { existsSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { platform, totalmem, hostname as osHostname } from "node:os";
import { platform } from "node:os";
import { t } from "../i18n.mjs";
import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs";
import { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs";
import { isTermux } from "../../../scripts/build/postinstallSupport.mjs";
import {
resolveMaxOldSpaceMb,
calibrateHeapFallbackMb,
buildServerNodeOptions,
buildNodeHeapArgs,
} from "../../../scripts/build/runtime-env.mjs";
import { resolveTlsOptions } from "../../../scripts/dev/tls-options.mjs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const _pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "..", "package.json"), "utf8"));
// URL scheme for the "OmniRoute is running" banner — flipped to https when
// opt-in TLS (#5242) is active. Process-scoped: one `serve` run = one scheme.
let urlScheme = "http";
const ROOT = join(__dirname, "..", "..", "..");
// The standalone bundle ships in `dist/` (since the build-output-isolation
// refactor). Fall back to the legacy `app/` location so an upgrade over a
@@ -47,24 +35,12 @@ export function registerServe(program) {
.option("--max-restarts <n>", t("serve.max_restarts"), parseInt, 2)
.option("--tray", t("serve.tray") || "Show system tray icon (desktop only)")
.option("--no-tray", t("serve.no_tray") || "Disable system tray icon")
.option(
"--tls-cert <path>",
t("serve.tls_cert") ||
"Path to a TLS certificate (PEM) to serve HTTPS (also OMNIROUTE_TLS_CERT)"
)
.option(
"--tls-key <path>",
t("serve.tls_key") ||
"Path to the TLS private key (PEM) to serve HTTPS (also OMNIROUTE_TLS_KEY)"
)
.action(async (opts) => {
await runServe(opts);
});
}
export async function runServe(opts = {}) {
const startedAt = performance.now();
const { isNativeBinaryCompatible } =
await import("../../../scripts/build/native-binary-compat.mjs");
const { getNodeRuntimeSupport, getNodeRuntimeWarning } =
@@ -83,7 +59,6 @@ export async function runServe(opts = {}) {
| |__| | | | | | | | | | | | \\ \\ (_) | |_| | || __/
\\____/|_| |_| |_|_| |_|_|_| \\_\\___/ \\__,_|\\__\\___|
\x1b[0m`);
console.log(`\x1b[2m v${_pkg.version}\x1b[0m\n`);
const nodeSupport = getNodeRuntimeSupport();
if (!nodeSupport.nodeCompatible) {
@@ -134,7 +109,7 @@ export async function runServe(opts = {}) {
"Release",
"better_sqlite3.node"
);
if (!process.versions.bun && existsSync(sqliteBinary) && !isNativeBinaryCompatible(sqliteBinary)) {
if (existsSync(sqliteBinary) && !isNativeBinaryCompatible(sqliteBinary)) {
console.error(
"\x1b[31m✖ better-sqlite3 native module is incompatible with this platform.\x1b[0m"
);
@@ -151,18 +126,9 @@ export async function runServe(opts = {}) {
console.log(` \x1b[2m⏳ Starting server...\x1b[0m\n`);
// #5172/#5160/#5152: default the V8 heap to ~35% of physical RAM (clamped
// [512, 4096]) instead of a fixed 512MB, which OOM-crashed boxes with plenty
// of RAM under load. An explicit OMNIROUTE_MEMORY_MB still wins.
const memoryLimit = resolveMaxOldSpaceMb(
process.env.OMNIROUTE_MEMORY_MB,
calibrateHeapFallbackMb(totalmem())
);
// #5242: opt-in native HTTPS. CLI flags take precedence over env; the child
// server (server-ws.mjs) reads these and terminates TLS on the same listener.
const tlsCert = opts.tlsCert ?? process.env.OMNIROUTE_TLS_CERT;
const tlsKey = opts.tlsKey ?? process.env.OMNIROUTE_TLS_KEY;
const rawMemory = parseInt(process.env.OMNIROUTE_MEMORY_MB || "512", 10);
const memoryLimit =
Number.isFinite(rawMemory) && rawMemory >= 64 && rawMemory <= 16384 ? rawMemory : 512;
const env = {
...process.env,
@@ -170,30 +136,11 @@ export async function runServe(opts = {}) {
PORT: String(dashboardPort),
DASHBOARD_PORT: String(dashboardPort),
API_PORT: String(apiPort),
// #6194: POSIX shells (bash/zsh) auto-set HOSTNAME to the machine name — the
// .env loader (first-wins) can never override it. Ignore HOSTNAME when it
// matches the OS-reported hostname (the auto-set signature). OMNIROUTE_SERVER_HOST
// takes precedence; legacy HOSTNAME values that don't match os.hostname() are
// still honoured for backward compatibility (e.g. Windows CMD/PowerShell users
// who set HOSTNAME in .env where it is NOT auto-set).
HOSTNAME:
process.env.OMNIROUTE_SERVER_HOST ||
(process.env.HOSTNAME !== osHostname() ? process.env.HOSTNAME : undefined) ||
"0.0.0.0",
HOSTNAME: "0.0.0.0",
NODE_ENV: "production",
// #5238: preserve a user-set NODE_OPTIONS (incl. their own
// `--max-old-space-size=…`) instead of clobbering it with the calibrated
// default — mirror the Electron/standalone launchers.
NODE_OPTIONS: buildServerNodeOptions(process.env, memoryLimit),
...(tlsCert ? { OMNIROUTE_TLS_CERT: tlsCert } : {}),
...(tlsKey ? { OMNIROUTE_TLS_KEY: tlsKey } : {}),
NODE_OPTIONS: `--max-old-space-size=${memoryLimit}`,
};
// Validate the TLS pair up front so the operator sees a clear warning in the
// CLI (the child re-validates authoritatively). Drives the banner scheme;
// when null we fall through to identical plain-HTTP behavior as before.
urlScheme = resolveTlsOptions(env) ? "https" : "http";
const isDaemon = opts.daemon === true;
const useTray = opts.tray === true;
@@ -202,15 +149,7 @@ export async function runServe(opts = {}) {
}
if (opts.noRecovery) {
return runWithoutRecovery(
serverJs,
env,
memoryLimit,
dashboardPort,
apiPort,
noOpen,
startedAt
);
return runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, noOpen);
}
return runWithSupervisor(
@@ -222,18 +161,12 @@ export async function runServe(opts = {}) {
noOpen,
opts.log === true,
opts.maxRestarts ?? 2,
startedAt,
useTray
);
}
function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) {
// #5238: skip the explicit CLI --max-old-space-size when the user pinned the
// heap via NODE_OPTIONS (a CLI arg would shadow/override their value).
const server = spawn(process.versions.bun ? process.execPath : "node", [
...(process.versions.bun ? [] : buildNodeHeapArgs(process.env, memoryLimit)),
serverJs,
], {
const server = spawn("node", [`--max-old-space-size=${memoryLimit}`, serverJs], {
cwd: APP_DIR,
env,
stdio: "ignore",
@@ -242,17 +175,12 @@ function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) {
writePidFile("server", server.pid);
server.unref();
console.log(`\x1b[32m✔ OmniRoute started in background (PID: ${server.pid})\x1b[0m`);
console.log(` \x1b[1mDashboard:\x1b[0m ${urlScheme}://localhost:${dashboardPort}`);
console.log(` \x1b[1mAPI Base:\x1b[0m ${urlScheme}://localhost:${apiPort}/v1`);
console.log(` \x1b[1mDashboard:\x1b[0m http://localhost:${dashboardPort}`);
console.log(` \x1b[1mAPI Base:\x1b[0m http://localhost:${apiPort}/v1`);
}
function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, noOpen, startedAt) {
// #5238: skip the explicit CLI --max-old-space-size when the user pinned the
// heap via NODE_OPTIONS (a CLI arg would shadow/override their value).
const server = spawn(process.versions.bun ? process.execPath : "node", [
...(process.versions.bun ? [] : buildNodeHeapArgs(process.env, memoryLimit)),
serverJs,
], {
function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, noOpen) {
const server = spawn("node", [`--max-old-space-size=${memoryLimit}`, serverJs], {
cwd: APP_DIR,
env,
stdio: "pipe",
@@ -270,7 +198,7 @@ function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort,
(text.includes("Ready") || text.includes("started") || text.includes("listening"))
) {
started = true;
onReady(dashboardPort, apiPort, noOpen, startedAt);
onReady(dashboardPort, apiPort, noOpen);
}
});
@@ -304,7 +232,7 @@ function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort,
setTimeout(() => {
if (!started) {
started = true;
onReady(dashboardPort, apiPort, noOpen, startedAt);
onReady(dashboardPort, apiPort, noOpen);
}
}, 15000);
}
@@ -318,7 +246,6 @@ async function runWithSupervisor(
noOpen,
showLog,
maxRestarts,
startedAt,
useTray = false
) {
if (showLog) process.env.OMNIROUTE_SHOW_LOG = "1";
@@ -356,36 +283,12 @@ async function runWithSupervisor(
waitForServer(dashboardPort, 60000).then(async (up) => {
if (up) {
if (useTray) await maybeStartTray(dashboardPort, apiPort, supervisor);
onReady(dashboardPort, apiPort, noOpen, startedAt);
} else {
reportReadinessTimeout(dashboardPort, supervisor);
onReady(dashboardPort, apiPort, noOpen);
}
});
}
}
// #6321: waitForServer resolving `false` used to fall through silently — the CLI
// printed the banner + "⏳ Starting server..." and then produced ZERO further
// output forever, even though the child process may well have crashed or be
// stuck (issue reports show the server sometimes actually comes up later, or is
// reachable directly while the CLI still looks hung). Surface a clear diagnostic
// plus whatever stdout/stderr the child buffered instead of going silent.
export function reportReadinessTimeout(dashboardPort, supervisor) {
console.error(
`\n\x1b[33m⚠ Server did not respond within 60s.\x1b[0m It may still be starting, or may` +
` have failed silently.`
);
console.error(` Try: curl -I http://localhost:${dashboardPort}/api/monitoring/health`);
console.error(` Or: rerun with \x1b[36m--log\x1b[0m to see live server output.\n`);
const recentLog = supervisor?.getRecentLog?.() ?? [];
if (recentLog.length) {
console.error("--- Recent server output ---");
recentLog.forEach((l) => console.error(l));
console.error("--- End recent output ---\n");
}
}
let _killTray = null;
function killTrayIfActive() {
if (_killTray) {
@@ -401,8 +304,8 @@ async function maybeStartTray(port, apiPort, supervisor) {
const { initTray, isTraySupported } = await import("../tray/index.mjs");
if (!isTraySupported()) return;
const { default: open } = await import("open").catch(() => ({ default: null }));
const dashboardUrl = `${urlScheme}://localhost:${port}`;
const tray = await initTray({
const dashboardUrl = `http://localhost:${port}`;
const tray = initTray({
port,
onQuit: () => {
killTrayIfActive();
@@ -418,23 +321,17 @@ async function maybeStartTray(port, apiPort, supervisor) {
const { killTray } = await import("../tray/index.mjs");
_killTray = killTray;
}
} catch (err) {
// tray is optional — do not fail the server, but surface why it failed so
// "--tray shows nothing" is diagnosable instead of silent (#4605).
process.stderr.write(`[omniroute][tray] failed to start: ${err?.message ?? String(err)}\n`);
} catch {
// tray is optional — do not fail the server
}
}
async function onReady(dashboardPort, apiPort, noOpen, startedAt) {
const dashboardUrl = `${urlScheme}://localhost:${dashboardPort}`;
const apiUrl = `${urlScheme}://localhost:${apiPort}`;
const elapsed =
typeof startedAt === "number" && Number.isFinite(startedAt)
? ((performance.now() - startedAt) / 1000).toFixed(1)
: "0.0";
async function onReady(dashboardPort, apiPort, noOpen) {
const dashboardUrl = `http://localhost:${dashboardPort}`;
const apiUrl = `http://localhost:${apiPort}`;
console.log(`
\x1b[32m✔ OmniRoute is running!\x1b[0m \x1b[2m(started in ${elapsed}s)\x1b[0m
\x1b[32m✔ OmniRoute is running!\x1b[0m
\x1b[1m Dashboard:\x1b[0m ${dashboardUrl}
\x1b[1m API Base:\x1b[0m ${apiUrl}/v1

View File

@@ -20,11 +20,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import {
categoriseModel,
isCodexCompatibleTextModel,
profileNameFromModelId,
} from "./setup-codex.mjs";
import { categoriseModel } from "./setup-codex.mjs";
/** Map a Codex-style effort to a Claude Code settings.json effortLevel. */
function effortLevelFor(cfg) {
@@ -33,17 +29,6 @@ function effortLevelFor(cfg) {
return cfg.effort || undefined;
}
/**
* Generic profile for a live-catalog model that `categoriseModel()` doesn't
* recognize (e.g. any provider added after the hardcoded glm/kimi/mimo/…
* pattern list was written). Mirrors setup-codex.mjs's fallbackCodexProfile()
* so setup-claude never silently produces zero profiles for a fresh catalog.
*/
export function fallbackClaudeProfile(modelId, model) {
if (!isCodexCompatibleTextModel(model)) return null;
return { name: profileNameFromModelId(modelId) };
}
/** Build the settings.json content for one Claude Code profile. */
export function buildProfileSettings(modelId, baseUrl, cfg) {
const env = {
@@ -65,84 +50,18 @@ export function buildProfileSettings(modelId, baseUrl, cfg) {
return JSON.stringify(settings, null, 2) + "\n";
}
/**
* Generate Claude Code profile files for a live model catalog. Shared by the
* `setup-claude` CLI command and the post-model-sync auto-sync so both stay
* behaviorally identical. Writes `<claudeHome>/profiles/<name>/settings.json`
* (directory-per-profile); never touches the active/default Claude config.
* @param {Array} models
* @param {{claudeHome?:string, baseUrl:string, dryRun?:boolean, only?:string, log?:(line:string)=>void}} opts
* @returns {Promise<{written:number, skipped:number, profiles:Array<{name:string, model:string, filePath:string}>}>}
*/
export async function syncClaudeProfilesFromModels(models, opts = {}) {
const claudeHome = opts.claudeHome || join(os.homedir(), ".claude");
const profilesRoot = join(claudeHome, "profiles");
const baseUrl = opts.baseUrl;
const dryRun = Boolean(opts.dryRun);
// Injectable dry-run printer (#5959): under the node:test runner, a child
// process writing multi-byte UTF-8 (the "──" box-drawing heading) to stdout
// corrupts the runner's V8-serialized event stream ~50% of the time
// ("Unable to deserialize cloned data due to invalid or unsupported
// version"). Tests inject a collector; the CLI default stays console.log.
const log = opts.log ?? console.log;
const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null;
if (!dryRun && !existsSync(profilesRoot)) {
mkdirSync(profilesRoot, { recursive: true });
}
let written = 0;
let skipped = 0;
const profiles = [];
for (const m of models) {
const id = typeof m === "string" ? m : (m.id ?? "");
if (!id) {
skipped++;
continue;
}
if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) {
skipped++;
continue;
}
const cfg = categoriseModel(id) ?? fallbackClaudeProfile(id, m);
if (!cfg) {
skipped++;
continue;
}
const dir = join(profilesRoot, cfg.name);
const filePath = join(dir, "settings.json");
const content = buildProfileSettings(id, baseUrl, cfg);
if (dryRun) {
log(`\n── [dry-run] ${filePath} ──`);
log(content);
} else {
mkdirSync(dir, { recursive: true });
writeFileSync(filePath, content, "utf8");
}
profiles.push({ name: cfg.name, model: id, filePath });
written++;
}
return { written, skipped, profiles };
}
/**
* @param {{remote?:string, port?:string, apiKey?:string, claudeHome?:string, dryRun?:boolean, only?:string}} opts
* @returns {Promise<number>}
*/
export async function runSetupClaudeCommand(opts = {}) {
const port = Number(opts.port ?? process.env.PORT ?? 20128) || 20128;
const baseUrl = (opts.remote ?? `http://localhost:${port}`)
.replace(/\/+$/, "")
.replace(/\/v1$/, "");
const baseUrl = (opts.remote ?? `http://localhost:${port}`).replace(/\/+$/, "").replace(/\/v1$/, "");
const apiKey = opts.apiKey ?? opts["api-key"] ?? process.env.OMNIROUTE_API_KEY ?? "";
const claudeHome = opts.claudeHome ?? opts["claude-home"] ?? join(os.homedir(), ".claude");
const profilesRoot = join(claudeHome, "profiles");
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null;
printHeading("OmniRoute → Claude Code profile generator");
printInfo(`Connecting to ${baseUrl}`);
@@ -170,25 +89,42 @@ export async function runSetupClaudeCommand(opts = {}) {
printInfo(`Received ${models.length} models from ${baseUrl}`);
const { written, skipped, profiles } = await syncClaudeProfilesFromModels(models, {
claudeHome,
baseUrl,
dryRun,
only: opts.only,
});
if (!dryRun && !existsSync(profilesRoot)) {
mkdirSync(profilesRoot, { recursive: true });
}
if (!dryRun) {
for (const profile of profiles) {
printSuccess(` ✓ profiles/${profile.name}/settings.json (${profile.model})`);
let written = 0;
for (const m of models) {
const id = typeof m === "string" ? m : m.id ?? "";
if (!id) continue;
if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) continue;
const cfg = categoriseModel(id);
if (!cfg) continue;
const dir = join(profilesRoot, cfg.name);
const filePath = join(dir, "settings.json");
const content = buildProfileSettings(id, baseUrl, cfg);
if (dryRun) {
console.log(`\n── [dry-run] ${filePath} ──`);
console.log(content);
} else {
mkdirSync(dir, { recursive: true });
writeFileSync(filePath, content, "utf8");
printSuccess(` ✓ profiles/${cfg.name}/settings.json (${id})`);
}
written++;
}
const skipped = models.length - written;
if (!dryRun) {
console.log("");
printSuccess(`${written} Claude Code profiles written to ${profilesRoot}`);
if (skipped > 0) printInfo(`${skipped} models skipped (no matching profile pattern)`);
console.log("\nTo use a profile:");
console.log(" omniroute launch --profile <name> # e.g. omniroute launch --profile glm52");
console.log(
" # or: CLAUDE_CONFIG_DIR=~/.claude/profiles/<name> claude (export ANTHROPIC_AUTH_TOKEN first)"
);
console.log(" # or: CLAUDE_CONFIG_DIR=~/.claude/profiles/<name> claude (export ANTHROPIC_AUTH_TOKEN first)");
} else {
console.log(`\n[dry-run] ${written} profiles would be written (${skipped} skipped)`);
}
@@ -207,10 +143,7 @@ export function registerSetupClaude(program) {
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
.option("--claude-home <dir>", "Claude home dir (default: ~/.claude)")
.option(
"--only <patterns>",
"Comma-separated substrings — only matching model IDs (e.g. glm,kimi)"
)
.option("--only <patterns>", "Comma-separated substrings — only matching model IDs (e.g. glm,kimi)")
.option("--dry-run", "Print what would be written without touching the filesystem")
.action(async (opts) => {
const exitCode = await runSetupClaudeCommand(opts);

View File

@@ -39,83 +39,29 @@ export function categoriseModel(modelId) {
{ re: /kmc\/kimi-k2\.6/, name: "kimi-k26", ctx: 131072, compact: 112000, toolLimit: 32768 },
{ re: /glm\/glm-5\.2-max/, name: "glm52max", ctx: 131072, compact: 112000, toolLimit: 32768 },
{ re: /glm\/glm-5\.2$/, name: "glm52", ctx: 131072, compact: 112000, toolLimit: 32768 },
{
re: /opencode-go\/mimo-v2\.5-pro/,
name: "mimo-pro",
ctx: 131072,
compact: 112000,
toolLimit: 32768,
},
{
re: /opencode-go\/qwen3\.7-plus/,
name: "qwen37plus",
ctx: 32768,
compact: 28000,
toolLimit: 16384,
},
{ re: /opencode-go\/mimo-v2\.5-pro/, name: "mimo-pro", ctx: 131072, compact: 112000, toolLimit: 32768 },
{ re: /opencode-go\/qwen3\.7-plus/, name: "qwen37plus", ctx: 32768, compact: 28000, toolLimit: 16384 },
];
// ── Good models (high effort) ─────────────────────────────────────────────
const goodPatterns = [
{
re: /ollamacloud\/deepseek-v4-pro/,
name: "deepseek-pro",
ctx: 131072,
compact: 112000,
toolLimit: 32768,
},
{
re: /opencode-go\/mimo-v2\.5$/,
name: "mimo",
ctx: 131072,
compact: 112000,
toolLimit: 32768,
},
{ re: /ollamacloud\/deepseek-v4-pro/, name: "deepseek-pro", ctx: 131072, compact: 112000, toolLimit: 32768 },
{ re: /opencode-go\/mimo-v2\.5$/, name: "mimo", ctx: 131072, compact: 112000, toolLimit: 32768 },
];
// ── Simple models (no effort) ─────────────────────────────────────────────
const simplePatterns = [
{ re: /ollamacloud\/gemma4:31b/, name: "gemma4", ctx: 32768, compact: 28000, toolLimit: 16384 },
{
re: /ollamacloud\/nemotron-3-super/,
name: "nemotron",
ctx: 32768,
compact: 28000,
toolLimit: 16384,
},
{
re: /ollamacloud\/gpt-oss:20b/,
name: "gptoss",
ctx: 32768,
compact: 28000,
toolLimit: 16384,
},
{ re: /ollamacloud\/nemotron-3-super/, name: "nemotron", ctx: 32768, compact: 28000, toolLimit: 16384 },
{ re: /ollamacloud\/gpt-oss:20b/, name: "gptoss", ctx: 32768, compact: 28000, toolLimit: 16384 },
];
// ── Fast models (low effort) ──────────────────────────────────────────────
const fastPatterns = [
{
re: /ollamacloud\/deepseek-v4-flash/,
name: "deepseek-flash",
ctx: 65536,
compact: 56000,
toolLimit: 16384,
},
{
re: /ollamacloud\/gemini-3-flash/,
name: "gemini-flash",
ctx: 1000000,
compact: 850000,
toolLimit: 32768,
},
{ re: /ollamacloud\/deepseek-v4-flash/, name: "deepseek-flash", ctx: 65536, compact: 56000, toolLimit: 16384 },
{ re: /ollamacloud\/gemini-3-flash/, name: "gemini-flash", ctx: 1000000, compact: 850000, toolLimit: 32768 },
{ re: /glm\/glm-5-turbo/, name: "glm5turbo", ctx: 131072, compact: 112000, toolLimit: 16384 },
{
re: /glm\/glm-4\.7-flash/,
name: "glm47flash",
ctx: 131072,
compact: 112000,
toolLimit: 16384,
},
{ re: /glm\/glm-4\.7-flash/, name: "glm47flash", ctx: 131072, compact: 112000, toolLimit: 16384 },
];
for (const p of thinkingPatterns) {
@@ -134,92 +80,6 @@ export function categoriseModel(modelId) {
return null;
}
function firstPositiveNumber(...values) {
for (const value of values) {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return value;
}
}
return null;
}
function shortHash(value) {
let hash = 5381;
for (let i = 0; i < value.length; i++) {
hash = ((hash << 5) + hash) ^ value.charCodeAt(i);
}
return (hash >>> 0).toString(36);
}
export function profileNameFromModelId(modelId) {
const normalized = String(modelId)
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
const base = normalized || "model";
if (base.length <= 96) return base;
return `${base.slice(0, 84).replace(/-+$/g, "")}-${shortHash(base)}`;
}
function hasAnyValue(values, patterns) {
return values.some((value) => patterns.some((pattern) => pattern.test(value)));
}
export function isCodexCompatibleTextModel(model) {
if (typeof model === "string") return true;
const id = String(model?.id ?? "").toLowerCase();
const type = String(model?.type ?? "").toLowerCase();
const outputModalities = Array.isArray(model?.output_modalities)
? model.output_modalities.map((value) => String(value).toLowerCase())
: [];
if (type && !["chat", "text", "language", "llm", "model"].includes(type)) {
return false;
}
const unsupportedPatterns = [
/(^|[/_-])(image|img|video|veo|seedance|audio|speech|voice|tts|stt|whisper)([/_-]|$)/,
/(^|[/_-])(embedding|embeddings|embed|rerank|moderation|transcription)([/_-]|$)/,
];
if (hasAnyValue([id, type], unsupportedPatterns)) return false;
const nonTextModalities = [/^(image|video|audio)$/];
if (hasAnyValue(outputModalities, nonTextModalities)) return false;
if (outputModalities.length > 0 && !outputModalities.includes("text")) {
return false;
}
return true;
}
export function fallbackCodexProfile(modelId, model) {
if (!isCodexCompatibleTextModel(model)) return null;
const ctx =
typeof model === "string"
? 128000
: (firstPositiveNumber(
model.context_length,
model.max_context_window_tokens,
model.max_input_tokens
) ?? 128000);
const maxOutput =
typeof model === "string"
? null
: firstPositiveNumber(model.max_output_tokens, model.output_token_limit);
const toolLimit = Math.min(Math.max(maxOutput ?? 16384, 8192), 32768);
return {
name: profileNameFromModelId(modelId),
ctx,
compact: Math.floor(ctx * 0.85),
summary: false,
toolLimit,
};
}
/** Build the TOML content for a single profile. */
function buildProfileToml(modelId, cfg) {
const lines = [
@@ -245,52 +105,6 @@ function buildProfileToml(modelId, cfg) {
return lines.join("\n") + "\n";
}
export async function syncCodexProfilesFromModels(models, opts = {}) {
const codexHome = opts.codexHome || join(os.homedir(), ".codex");
const dryRun = Boolean(opts.dryRun);
const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null;
if (!dryRun && !existsSync(codexHome)) {
mkdirSync(codexHome, { recursive: true });
}
let written = 0;
let skipped = 0;
const profiles = [];
for (const m of models) {
const id = typeof m === "string" ? m : (m.id ?? "");
if (!id) {
skipped++;
continue;
}
if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) {
skipped++;
continue;
}
const cfg = categoriseModel(id) ?? fallbackCodexProfile(id, m);
if (!cfg) {
skipped++;
continue;
}
const filePath = join(codexHome, `${cfg.name}.config.toml`);
const content = buildProfileToml(id, cfg);
if (dryRun) {
console.log(`\n── [dry-run] ${filePath} ──`);
console.log(content);
} else {
writeFileSync(filePath, content, "utf8");
}
profiles.push({ name: cfg.name, model: id, filePath });
written++;
}
return { written, skipped, profiles };
}
// ── Command ───────────────────────────────────────────────────────────────────
/**
@@ -332,17 +146,39 @@ export async function runSetupCodexCommand(opts = {}) {
printInfo(`Received ${models.length} models from ${baseUrl}`);
// ── Ensure codex home exists ──────────────────────────────────────────────
if (!dryRun && !existsSync(codexHome)) {
mkdirSync(codexHome, { recursive: true });
}
// ── Generate profiles ─────────────────────────────────────────────────────
const { written, skipped, profiles } = await syncCodexProfilesFromModels(models, {
codexHome,
dryRun,
only: opts.only,
});
let written = 0;
let skipped = 0;
for (const m of models) {
const id = typeof m === "string" ? m : (m.id ?? "");
if (!id) continue;
if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) continue;
const cfg = categoriseModel(id);
if (!cfg) continue;
const filePath = join(codexHome, `${cfg.name}.config.toml`);
const content = buildProfileToml(id, cfg);
if (dryRun) {
console.log(`\n── [dry-run] ${filePath} ──`);
console.log(content);
} else {
writeFileSync(filePath, content, "utf8");
printSuccess(`${cfg.name}.config.toml (${id})`);
}
written++;
}
skipped = models.length - written;
if (!dryRun) {
for (const profile of profiles) {
printSuccess(`${profile.name}.config.toml (${profile.model})`);
}
console.log("");
printSuccess(`${written} profiles written to ${codexHome}`);
if (skipped > 0) {
@@ -374,7 +210,10 @@ export function registerSetupCodex(program) {
"--api-key <key>",
"OmniRoute API key for the remote instance (defaults to OMNIROUTE_API_KEY env var)"
)
.option("--codex-home <dir>", "Directory where profile files are written (default: ~/.codex)")
.option(
"--codex-home <dir>",
"Directory where profile files are written (default: ~/.codex)"
)
.option(
"--only <patterns>",
"Comma-separated substrings — only generate profiles for matching model IDs (e.g. glm,kimi)"

View File

@@ -0,0 +1,148 @@
/**
* omniroute setup-gemini — point the Gemini CLI at OmniRoute's Gemini endpoint.
*
* The Gemini CLI is NOT OpenAI-compatible — it speaks the native Gemini API.
* OmniRoute exposes a Gemini-native surface at /v1beta (e.g.
* /v1beta/models/<model>:generateContent), so the CLI can target it via the
* @google/genai SDK env `GOOGLE_GEMINI_BASE_URL` (ROOT — the SDK appends /v1beta)
* + `GEMINI_API_KEY`. There is no settings.json key for the base URL, so this is
* primarily an env recipe; we optionally write ~/.gemini/settings.json `model`.
*
* ⚠ Known Gemini CLI caveat: it may ignore GOOGLE_GEMINI_BASE_URL if a cached
* Google login exists — run `gemini` logged-out / API-key-only for it to take.
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import os from "node:os";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
function stripToRoot(url) {
const s = String(url || "").replace(/\/+$/, "");
return s.endsWith("/v1beta") ? s.slice(0, -7) : s.endsWith("/v1") ? s.slice(0, -3) : s;
}
/** Resolve GOOGLE_GEMINI_BASE_URL (ROOT — SDK appends /v1beta) + apiKey. */
export function resolveGeminiTarget(opts = {}) {
let root;
if (opts.remote) root = stripToRoot(opts.remote);
else {
try {
root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
} catch {
/* none */
}
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
}
let apiKey = opts.apiKey ?? opts["api-key"];
if (!apiKey) {
try {
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
apiKey = c?.accessToken || c?.apiKey;
} catch {
/* none */
}
}
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
return { baseUrl: root, apiKey };
}
/** The guaranteed env recipe (pure → testable). */
export function buildGeminiRecipe({ baseUrl, model }) {
return [
`export GOOGLE_GEMINI_BASE_URL=${baseUrl}`,
"export GEMINI_API_KEY=$OMNIROUTE_API_KEY",
`export GEMINI_MODEL=${model}`,
`gemini -p "reply OK" # or: gemini (interactive)`,
].join("\n");
}
/** Merge the model into ~/.gemini/settings.json (base URL is env-only). */
export function buildGeminiSettings(existing, { model }) {
const s = existing && typeof existing === "object" ? { ...existing } : {};
if (model) s.model = model;
return s;
}
function readJson(path) {
try {
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
} catch {
/* corrupt/missing */
}
return {};
}
async function fetchGeminiModelIds(baseUrl, apiKey) {
try {
const res = await fetch(`${baseUrl}/v1beta/models`, {
headers: { "x-goog-api-key": apiKey || "" },
signal: AbortSignal.timeout(8000),
});
if (!res.ok) return [];
const body = await res.json();
return (body.models || []).map((m) => String(m.name || "").replace(/^models\//, "")).filter(Boolean);
} catch {
return [];
}
}
export async function runSetupGeminiCommand(opts = {}) {
const { baseUrl, apiKey } = resolveGeminiTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".gemini", "settings.json");
printHeading("OmniRoute → Gemini CLI (native Gemini /v1beta endpoint)");
printInfo(`GOOGLE_GEMINI_BASE_URL: ${baseUrl} (root — SDK appends /v1beta)`);
let model = opts.model;
if (!model) {
const ids = await fetchGeminiModelIds(baseUrl, apiKey);
if (ids.length && !opts.yes) {
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
const prompt = createPrompt();
try {
model = await prompt.ask("Model id for Gemini CLI");
} finally {
prompt.close();
}
}
}
if (!model) {
printError("A model is required. Pass --model <id>.");
return 2;
}
if (dryRun) {
console.log(`\n── [dry-run] ${configPath} ── { "model": "${model}" }`);
} else {
const merged = buildGeminiSettings(readJson(configPath), { model });
mkdirSync(join(configPath, ".."), { recursive: true });
writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n", "utf8");
printSuccess(`Wrote ${configPath} (model)`);
}
printInfo("\nThe base URL is env-only for Gemini CLI — export these:");
console.log(buildGeminiRecipe({ baseUrl, model }));
printInfo("\n⚠ If Gemini CLI ignores the base URL, you have a cached Google login —");
printInfo(" run logged-out (API-key only) so GOOGLE_GEMINI_BASE_URL takes effect.");
return 0;
}
export function registerSetupGemini(program) {
program
.command("setup-gemini")
.description("Point the Gemini CLI at OmniRoute's native Gemini /v1beta endpoint (env recipe + settings model)")
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
.option("--model <id>", "Model id for Gemini CLI (required unless picked interactively)")
.option("--config-path <path>", "settings.json path (default: ~/.gemini/settings.json)")
.option("--yes", "Non-interactive: do not prompt (requires --model)")
.option("--dry-run", "Print what would be written without touching the filesystem")
.action(async (opts) => {
const code = await runSetupGeminiCommand(opts);
if (code !== 0) process.exit(code);
});
}

View File

@@ -218,29 +218,13 @@ function registerPluginInOpenCodeConfig({
* a clear "could not run opencode" message instead of a hard import
* failure.
*/
/**
* Pure resolver for the `opencode auth login` spawn descriptor. Extracted so the
* platform-branching logic is unit-testable without mocking child_process or
* mutating process.platform.
*
* On Windows the `opencode` binary is an npm `.cmd` shim that Node's hardened
* spawnSync (post CVE-2024-27980) refuses to run without a shell — spawning it
* with shell:false throws EINVAL (#7913). Mirror the same fix already applied to
* codex (resolveCodexSpawn in launch-codex.mjs, crediting #6263) and
* qodercli/Auggie (#6263/#6304): shell:true on win32, shell:false everywhere else.
*/
export function resolveOpenCodeAuthSpawn(providerId, platform = process.platform) {
const isWin = platform === "win32";
return {
command: isWin ? "opencode.cmd" : "opencode",
args: ["auth", "login", "--provider", providerId],
options: { stdio: "inherit", shell: isWin },
};
}
export function runOpenCodeAuth(providerId) {
const { command, args, options } = resolveOpenCodeAuthSpawn(providerId);
const res = spawnSync(command, args, options);
function runOpenCodeAuth(providerId) {
const isWin = process.platform === "win32";
const opencodeBin = isWin ? "opencode.cmd" : "opencode";
const res = spawnSync(opencodeBin, ["auth", "login", "--provider", providerId], {
stdio: "inherit",
shell: false,
});
if (res.error) {
// ENOENT = opencode is not on PATH
if (res.error.code === "ENOENT") {

View File

@@ -1,166 +1,149 @@
/** Configure Qwen Code's OpenAI-compatible provider for OmniRoute. */
/**
* omniroute setup-qwen — configure Qwen Code (QwenLM/qwen-code) for OmniRoute.
*
* Qwen Code is a terminal AI agent (gemini-cli fork) with a file-based config at
* ~/.qwen/settings.json. For a custom OpenAI-compatible endpoint it uses a
* `modelProviders` entry with authType "openai", baseUrl WITH /v1, and an
* `envKey` naming the env var holding the key (secret stays in the env, never the
* file). Remote-aware; headless test via `qwen -p "..."`.
*/
import {
chmodSync,
existsSync,
mkdirSync,
readFileSync,
renameSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import os from "node:os";
import path from "node:path";
import {
mergeQwenCodeEnv,
mergeQwenCodeSettings,
normalizeQwenCodeBaseUrl,
} from "../../../src/shared/services/qwenCodeConfig.ts";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs";
import { createPrompt, printError, printHeading, printInfo, printSuccess } from "../io.mjs";
/** Resolve base URL and key from flags, active context, then local defaults. */
export function resolveQwenTarget(opts = {}) {
let root = opts.remote ? String(opts.remote) : "";
let context;
if (!root || !(opts.apiKey ?? opts["api-key"])) {
try {
context = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
} catch {
// An active context is optional for local setup.
}
}
if (!root) root = context?.baseUrl || "";
if (!root) {
const port = Number(opts.port ?? process.env.PORT ?? 20128) || 20128;
root = `http://localhost:${port}`;
}
const apiKey =
opts.apiKey ??
opts["api-key"] ??
context?.accessToken ??
context?.apiKey ??
process.env.OMNIROUTE_API_KEY ??
"sk_omniroute";
return { baseUrl: normalizeQwenCodeBaseUrl(root), apiKey };
function ensureV1(url) {
const s = String(url || "").replace(/\/+$/, "");
return s.endsWith("/v1") ? s : `${s}/v1`;
}
const readSettings = (filePath) => {
if (!existsSync(filePath)) return {};
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("Qwen Code settings.json must contain a JSON object");
}
return parsed;
};
const readText = (filePath) => (existsSync(filePath) ? readFileSync(filePath, "utf8") : "");
const writeAtomic = (filePath, content, mode) => {
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
try {
writeFileSync(tempPath, content, { encoding: "utf8", mode });
if (mode !== undefined) chmodSync(tempPath, mode);
renameSync(tempPath, filePath);
} catch (error) {
/** Resolve baseUrl (WITH /v1) + apiKey from flags → active context → localhost. */
export function resolveQwenTarget(opts = {}) {
let root;
if (opts.remote) root = String(opts.remote).replace(/\/+$/, "");
else {
try {
unlinkSync(tempPath);
root = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl;
} catch {
// The temporary file may not have been created.
/* none */
}
throw error;
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
}
};
let apiKey = opts.apiKey ?? opts["api-key"];
if (!apiKey) {
try {
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
apiKey = c?.accessToken || c?.apiKey;
} catch {
/* none */
}
}
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
return { baseUrl: ensureV1(root), apiKey };
}
const fetchModelIds = async (baseUrl, apiKey) => {
/** Merge the OmniRoute modelProvider into Qwen's settings.json (preserve rest). */
export function buildQwenSettings(existing, { baseUrl, model }) {
const s = existing && typeof existing === "object" ? { ...existing } : {};
const providers = Array.isArray(s.modelProviders) ? s.modelProviders.filter((p) => p?.id !== "omniroute") : [];
providers.push({
id: "omniroute",
name: "OmniRoute",
authType: "openai",
baseUrl,
envKey: "OMNIROUTE_API_KEY",
});
s.modelProviders = providers;
if (model) {
s.selectedProvider = "omniroute";
s.model = model;
}
return s;
}
function readJson(path) {
try {
const response = await fetch(`${baseUrl}/models`, {
headers: { Authorization: `Bearer ${apiKey}` },
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
} catch {
/* corrupt/missing */
}
return {};
}
async function fetchModelIds(baseUrl, apiKey) {
try {
const headers = { "Content-Type": "application/json" };
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
const res = await fetch(`${baseUrl.replace(/\/v1$/, "")}/v1/models`, {
headers,
signal: AbortSignal.timeout(8000),
});
if (!response.ok) return [];
const body = await response.json();
const models = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
return models.map((entry) => (typeof entry === "string" ? entry : entry?.id)).filter(Boolean);
if (!res.ok) return [];
const body = await res.json();
const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch {
return [];
}
};
}
export async function runSetupQwenCommand(opts = {}) {
const { baseUrl, apiKey } = resolveQwenTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const settingsPath =
opts.configPath ?? opts["config-path"] ?? path.join(os.homedir(), ".qwen", "settings.json");
const envPath = opts.envPath ?? opts["env-path"] ?? path.join(path.dirname(settingsPath), ".env");
const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".qwen", "settings.json");
printHeading("OmniRoute → Qwen Code (OpenAI-compatible)");
printHeading("OmniRoute → Qwen Code (openai-compatible)");
printInfo(`baseUrl: ${baseUrl}`);
let model = String(opts.model || "").trim();
if (!model && !opts.yes) {
const modelIds = await fetchModelIds(baseUrl, apiKey);
if (modelIds.length > 0) {
printInfo(`Examples: ${modelIds.slice(0, 20).join(", ")}${modelIds.length > 20 ? " …" : ""}`);
}
const prompt = createPrompt();
try {
model = String(await prompt.ask("Model id for Qwen Code")).trim();
} finally {
prompt.close();
let model = opts.model;
if (!model) {
const ids = await fetchModelIds(baseUrl, apiKey);
if (ids.length && !opts.yes) {
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
const prompt = createPrompt();
try {
model = await prompt.ask("Model id for Qwen");
} finally {
prompt.close();
}
}
}
if (!model) {
printError("A model is required. Pass --model <id>.");
return 2;
}
try {
const settings = mergeQwenCodeSettings(readSettings(settingsPath), { baseUrl, model });
const envText = mergeQwenCodeEnv(readText(envPath), apiKey);
const settingsText = `${JSON.stringify(settings, null, 2)}\n`;
const merged = buildQwenSettings(readJson(configPath), { baseUrl, model });
const out = JSON.stringify(merged, null, 2) + "\n";
if (dryRun) {
console.log(`\n${settingsText}`);
printInfo(`[dry-run] settings → ${settingsPath}`);
printInfo(`[dry-run] credential → ${envPath} (OMNIROUTE_API_KEY)`);
return 0;
}
mkdirSync(path.dirname(settingsPath), { recursive: true, mode: 0o700 });
mkdirSync(path.dirname(envPath), { recursive: true, mode: 0o700 });
writeAtomic(settingsPath, settingsText);
writeAtomic(envPath, envText, 0o600);
printSuccess(`Wrote ${settingsPath}`);
printSuccess(`Updated ${envPath} (OMNIROUTE_API_KEY only)`);
printInfo('Run: qwen (or headless: qwen -p "reply OK")');
return 0;
} catch (error) {
printError(`Failed to configure Qwen Code: ${error?.message || error}`);
return 1;
if (dryRun) {
console.log("\n" + out);
printInfo(`[dry-run] ${configPath}`);
} else {
mkdirSync(join(configPath, ".."), { recursive: true });
writeFileSync(configPath, out, "utf8");
printSuccess(`Wrote ${configPath}`);
}
printInfo("\nProvide the key (settings reference OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=...");
printInfo('Then run: qwen (or headless: qwen -p "reply OK")');
return 0;
}
export function registerSetupQwen(program) {
program
.command("setup-qwen")
.description("Configure Qwen Code's upstream V4 modelProviders format for OmniRoute")
.description("Configure Qwen Code for OmniRoute: write ~/.qwen/settings.json (openai modelProvider)")
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
.option("--remote <url>", "Remote OmniRoute URL")
.option("--api-key <key>", "OmniRoute API key")
.option("--model <id>", "Model id for Qwen Code")
.option("--config-path <path>", "Qwen Code settings.json path")
.option("--env-path <path>", "Qwen Code .env path")
.option("--yes", "Non-interactive; requires --model")
.option("--dry-run", "Print settings without writing files or secrets")
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
.option("--model <id>", "Model id for Qwen (required unless picked interactively)")
.option("--config-path <path>", "settings.json path (default: ~/.qwen/settings.json)")
.option("--yes", "Non-interactive: do not prompt (requires --model)")
.option("--dry-run", "Print what would be written without touching the filesystem")
.action(async (opts) => {
const code = await runSetupQwenCommand(opts);
if (code !== 0) process.exitCode = code;
if (code !== 0) process.exit(code);
});
}

View File

@@ -8,7 +8,6 @@ import {
sleep,
} from "../utils/pid.mjs";
import { t } from "../i18n.mjs";
import { stopProcessGracefully } from "../../../src/shared/platform/windowsProcess.ts";
const execFileAsync = promisify(execFile);
@@ -28,11 +27,18 @@ export async function runStopCommand(opts = {}) {
if (pid && isPidRunning(pid)) {
console.log(t("stop.stopping", { pid }));
try {
// #8045: on win32, process.kill(pid, "SIGTERM") unconditionally force-terminates
// the target instead of delivering an interceptable signal, racing (and beating)
// the server's own async graceful shutdown / WAL checkpoint. stopProcessGracefully
// skips the immediate SIGTERM on win32 and just polls before escalating to SIGKILL.
await stopProcessGracefully({ pid, timeoutMs: 5000, isPidRunning, sleep });
process.kill(pid, "SIGTERM");
let waited = 0;
while (waited < 5000 && isPidRunning(pid)) {
await sleep(100);
waited += 100;
}
if (isPidRunning(pid)) {
process.kill(pid, "SIGKILL");
await sleep(500);
}
killAllSubprocesses();
cleanupPidFile("server");

View File

@@ -133,20 +133,6 @@
"listTitle": "Keys expiring within {days} days:"
}
},
"authExport": {
"description": "Export DECRYPTED provider credentials (local-only, plaintext output)",
"idOpt": "Export only the connection matching this id/name/provider",
"formatOpt": "Output format: json or env",
"outOpt": "Write output to a file instead of stdout (written with 0600 permissions)",
"forceOpt": "Confirm you understand this prints/writes plaintext secrets",
"warning": "⚠ This prints/writes DECRYPTED plaintext API keys and OAuth tokens. Make sure your screen, shell history, and any output file stay private.",
"confirmHeading": "⚠ WARNING: this exports DECRYPTED provider credentials in plaintext",
"confirmBody": "This command decrypts and prints/writes apiKey, accessToken, refreshToken, and\nidToken for the selected connection(s). Treat the output as a secret.",
"confirmFooter": "To confirm, run:\n omniroute auth export --force",
"missingKey": "STORAGE_ENCRYPTION_KEY is required to export credentials.",
"notFound": "Connection not found: {id}",
"invalidFormat": "Invalid format: {format}. Use json or env."
},
"stream": {
"description": "Stream a chat response with SSE inspection modes",
"file": "Read prompt from file",
@@ -255,9 +241,7 @@
"no_recovery": "Disable auto-restart on crash (debugging mode)",
"max_restarts": "Max crash restarts within 30s before giving up (default: 2)",
"tray": "Show system tray icon (desktop only, opt-in)",
"no_tray": "Disable system tray icon",
"tls_cert": "Path to a TLS certificate (PEM) to serve HTTPS (also OMNIROUTE_TLS_CERT)",
"tls_key": "Path to the TLS private key (PEM) to serve HTTPS (also OMNIROUTE_TLS_KEY)"
"no_tray": "Disable system tray icon"
},
"backup": {
"title": "Backup",

View File

@@ -132,20 +132,6 @@
"listTitle": "Chaves que expiram nos próximos {days} dias:"
}
},
"authExport": {
"description": "Exportar credenciais DESCRIPTOGRAFADAS de provedores (somente local, saída em texto puro)",
"idOpt": "Exportar apenas a conexão correspondente a este id/nome/provedor",
"formatOpt": "Formato de saída: json ou env",
"outOpt": "Gravar a saída em um arquivo em vez do stdout (gravado com permissão 0600)",
"forceOpt": "Confirma que você entende que isso imprime/grava segredos em texto puro",
"warning": "⚠ Isso imprime/grava chaves de API e tokens OAuth DESCRIPTOGRAFADOS em texto puro. Garanta que sua tela, o histórico do shell e qualquer arquivo de saída permaneçam privados.",
"confirmHeading": "⚠ AVISO: isso exporta credenciais de provedores DESCRIPTOGRAFADAS em texto puro",
"confirmBody": "Este comando descriptografa e imprime/grava apiKey, accessToken, refreshToken e\nidToken da(s) conexão(ões) selecionada(s). Trate a saída como um segredo.",
"confirmFooter": "Para confirmar, execute:\n omniroute auth export --force",
"missingKey": "STORAGE_ENCRYPTION_KEY é obrigatório para exportar credenciais.",
"notFound": "Conexão não encontrada: {id}",
"invalidFormat": "Formato inválido: {format}. Use json ou env."
},
"stream": {
"description": "Transmitir resposta de chat com modos de inspeção SSE",
"file": "Ler prompt de arquivo",

View File

@@ -29,16 +29,16 @@
"setup": {
"title": "OmniRoute 设置",
"passwordPrompt": "管理员密码",
"providerPrompt": "默认提供(留空跳过)",
"providerPrompt": "默认提供(留空跳过)",
"done": "设置完成",
"passwordSet": "管理员密码已配置",
"providerSet": "提供已配置:{name}",
"testingProvider": "正在测试提供连接:{name}",
"testPassed": "提供测试通过",
"testFailed": "提供测试失败:{error}",
"providerSet": "提供已配置:{name}",
"testingProvider": "正在测试提供连接:{name}",
"testPassed": "提供测试通过",
"testFailed": "提供测试失败:{error}",
"loginEnabled": "登录:已启用(密码已更新)",
"loginDisabled": "登录:已禁用",
"providerInfo": "提供{info}"
"providerInfo": "提供{info}"
},
"doctor": {
"title": "OmniRoute 诊断",
@@ -52,29 +52,29 @@
"warnings": "{count} 个警告 — 见上文。"
},
"providers": {
"title": "提供",
"noProviders": "未配置提供。运行omniroute setup",
"title": "提供",
"noProviders": "未配置提供。运行omniroute setup",
"testing": "正在测试 {name}...",
"available": "{count} 个提供可用",
"available": "{count} 个提供可用",
"connected": "已连接",
"disconnected": "未连接",
"validationFailed": "验证失败:{error}",
"metrics": {
"description": "显示提供性能指标(延迟、成功率、成本)",
"provider": "按提供 ID 筛选",
"description": "显示提供性能指标(延迟、成功率、成本)",
"provider": "按提供 ID 筛选",
"connection_id": "按连接 ID 筛选",
"period": "时间范围1h|6h|24h|7d|30d默认24h",
"metric": "关注特定指标字段",
"sort": "按字段排序(降序)",
"limit": "最大行数默认50",
"watch": "每 5 秒刷新(实时模式)",
"compare": "逗号分隔的提供 ID并排比较"
"compare": "逗号分隔的提供 ID并排比较"
},
"metric_single": {
"description": "获取特定连接的单个指标值"
},
"rotate": {
"description": "轮换提供连接的上游 API 密钥",
"description": "轮换提供连接的上游 API 密钥",
"newKeyOpt": "新 API 密钥值(避免使用:优先使用 --from-env",
"fromEnvOpt": "从环境变量 VAR 读取新密钥",
"oauthOpt": "改为触发 OAuth 重新认证流程",
@@ -89,18 +89,18 @@
"testFailed": "轮换后测试失败:{error}"
},
"status": {
"description": "显示所有提供连接的密钥健康状态(期限、过期、冷却)",
"providerOpt": "按提供名称筛选",
"header": "ID 提供 名称 过期状态 测试状态 冷却至",
"noData": "没有可用的提供连接数据。",
"description": "显示所有提供连接的密钥健康状态(期限、过期、冷却)",
"providerOpt": "按提供名称筛选",
"header": "ID 提供 名称 过期状态 测试状态 冷却至",
"noData": "没有可用的提供连接数据。",
"requiresServer": "providers status 需要 OmniRoute 服务器正在运行。"
}
},
"keys": {
"title": "API 密钥",
"addDescription": "为提供添加或更新 API 密钥",
"addDescription": "为提供添加或更新 API 密钥",
"listDescription": "列出所有已配置的 API 密钥",
"removeDescription": "移除提供的 API 密钥",
"removeDescription": "移除提供的 API 密钥",
"regenerateDescription": "重新生成 OmniRoute API 密钥",
"revokeDescription": "撤销 OmniRoute API 密钥",
"revealDescription": "显示未掩码的 API 密钥值",
@@ -122,10 +122,10 @@
"revoked": "密钥 {id} 已撤销。",
"rotated": "密钥 {id} 已轮换。新密钥 ID{newId}",
"revealWarning": "⚠ 这将显示完整的未掩码密钥。请确保您的屏幕不被人看到。",
"providerRequired": "需要提供。",
"providerRequired": "需要提供。",
"keyRequired": "需要 API 密钥。",
"stdinEmpty": "未通过标准输入提供 API 密钥。",
"unknownProvider": "未知提供{provider}",
"unknownProvider": "未知提供{provider}",
"policy": {
"title": "密钥策略",
"showDescription": "显示密钥的速率/成本策略",
@@ -165,7 +165,7 @@
"analytics": {
"description": "显示汇总使用分析",
"period": "时间范围1d|7d|30d|90d|ytd|all默认30d",
"provider": "按提供 ID 筛选"
"provider": "按提供 ID 筛选"
},
"budget": {
"description": "管理成本预算",
@@ -175,8 +175,8 @@
}
},
"quota": {
"description": "显示提供配额使用情况",
"provider": "按提供 ID 筛选",
"description": "显示提供配额使用情况",
"provider": "按提供 ID 筛选",
"check": "显示新请求是否有可用配额"
},
"logs": {
@@ -201,7 +201,7 @@
}
},
"cost": {
"description": "按提供、模型、组合或 API 密钥显示成本报告",
"description": "按提供、模型、组合或 API 密钥显示成本报告",
"period": "时间范围1d|7d|30d|90d|ytd|all默认30d",
"since": "开始日期ISO 格式,例如 2026-01-01— 覆盖 --period",
"until": "结束日期ISO 格式)",
@@ -210,7 +210,7 @@
"limit": "显示的最大行数默认100"
},
"simulate": {
"description": "模拟路由(空运行)— 显示将选择哪些提供而不调用上游",
"description": "模拟路由(空运行)— 显示将选择哪些提供而不调用上游",
"file": "从 JSON 文件加载完整请求体",
"model": "模型 ID默认auto",
"combo": "强制指定组合名称",
@@ -301,7 +301,7 @@
"cost": "成本24h"
},
"quota": {
"description": "显示提供配额使用情况",
"description": "显示提供配额使用情况",
"noServer": "服务器未运行。启动omniroute serve",
"noData": "没有可用的配额信息。"
},
@@ -315,12 +315,12 @@
"description": "一键启动本地 Redis 容器Podman 或 Docker用于 OmniRoute 缓存和配额跟踪"
},
"test": {
"description": "测试提供连接",
"description": "测试提供连接",
"noServer": "服务器未运行。启动omniroute serve",
"testing": "正在测试 {provider} / {model}...",
"passed": "连接成功!",
"failed": "连接失败:{error}",
"allProvidersOpt": "测试所有已配置的提供",
"allProvidersOpt": "测试所有已配置的提供",
"latencyOpt": "显示延迟测量值(平均/最小/最大 毫秒)",
"repeatOpt": "重复测试 N 次并汇总结果",
"compareOpt": "逗号分隔的要比较的模型(例如 gpt-4o,claude-3-5-sonnet",
@@ -328,7 +328,7 @@
"saved": "结果已保存至 {path}",
"compareTitle": "模型比较",
"compareMinTwo": "--compare 需要至少两个模型(逗号分隔)",
"noProviders": "未配置提供。添加omniroute keys add"
"noProviders": "未配置提供。添加omniroute keys add"
},
"update": {
"checking": "正在检查更新...",
@@ -509,7 +509,7 @@
},
"models": {
"description": "列出可用模型(需要服务器)",
"search": "按 ID、名称、提供或描述筛选模型",
"search": "按 ID、名称、提供或描述筛选模型",
"noServer": "服务器未运行。启动omniroute serve",
"noModels": "未找到模型。"
},
@@ -656,25 +656,25 @@
}
},
"oauth": {
"description": "管理 OAuth 提供连接",
"description": "管理 OAuth 提供连接",
"providers": {
"description": "列出支持 OAuth 的提供及其流程类型"
"description": "列出支持 OAuth 的提供及其流程类型"
},
"start": {
"description": "为提供启动 OAuth 授权流程",
"provider": "提供 IDgemini, copilot, cursor, ...",
"description": "为提供启动 OAuth 授权流程",
"provider": "提供 IDgemini, copilot, cursor, ...",
"no_browser": "仅打印 URL — 不打开浏览器",
"import_system": "从本地系统配置自动导入凭据",
"social": "社交登录提供google|github— kiro 需要",
"social": "社交登录提供google|github— kiro 需要",
"timeout": "等待授权超时时间毫秒默认300000"
},
"status": {
"description": "列出活动的 OAuth 连接",
"provider": "按提供 ID 筛选"
"provider": "按提供 ID 筛选"
},
"revoke": {
"description": "撤销 OAuth 连接",
"provider": "要撤销的提供 ID",
"provider": "要撤销的提供 ID",
"connection_id": "按 ID 撤销特定连接",
"yes": "跳过确认提示"
}
@@ -884,11 +884,11 @@
"description": "管理模型定价数据",
"sync": {
"description": "从上游同步价格",
"provider": "按提供筛选",
"provider": "按提供筛选",
"force": "强制重新同步"
},
"list": {
"provider": "按提供筛选",
"provider": "按提供筛选",
"model": "按模型筛选",
"limit": "最大结果数"
},
@@ -907,25 +907,25 @@
"resilience": {
"description": "检查和管理弹性机制",
"status": {
"provider": "按提供筛选"
"provider": "按提供筛选"
},
"breakers": {
"provider": "按提供筛选"
"provider": "按提供筛选"
},
"cooldowns": {
"provider": "按提供筛选",
"provider": "按提供筛选",
"connectionId": "按连接 ID 筛选"
},
"lockouts": {
"provider": "按提供筛选",
"provider": "按提供筛选",
"model": "按模型筛选"
},
"reset": {
"description": "重置断路器/冷却状态",
"provider": "要重置的提供",
"provider": "要重置的提供",
"connectionId": "要重置的连接 ID",
"model": "要重置锁定状态的模型",
"allCooldowns": "重置提供的所有冷却",
"allCooldowns": "重置提供的所有冷却",
"yes": "跳过确认"
},
"profile": {
@@ -940,13 +940,13 @@
}
},
"nodes": {
"description": "管理提供节点(端点)",
"description": "管理提供节点(端点)",
"list": {
"provider": "按提供筛选",
"provider": "按提供筛选",
"enabled": "仅显示已启用的节点"
},
"add": {
"provider": "提供名称",
"provider": "提供名称",
"baseUrl": "节点的基础 URL",
"name": "节点名称",
"weight": "负载均衡权重",
@@ -965,7 +965,7 @@
},
"validate": {
"baseUrl": "要验证的 URL",
"provider": "要验证的提供"
"provider": "要验证的提供"
},
"test": {
"description": "向节点发送测试请求"
@@ -1091,7 +1091,7 @@
"oneproxy": {
"description": "管理 OneProxy 上游代理池",
"stats": {
"provider": "按提供筛选",
"provider": "按提供筛选",
"period": "时间范围默认24h"
},
"fetch": {
@@ -1101,14 +1101,14 @@
},
"rotate": {
"description": "强制轮换代理",
"provider": "要轮换的提供",
"provider": "要轮换的提供",
"connectionId": "要轮换的特定连接 ID"
},
"config": {
"description": "显示或更新 OneProxy 配置",
"enabled": "启用代理池true|false",
"poolSize": "池大小",
"providerSource": "代理提供的 URL",
"providerSource": "代理提供的 URL",
"rotationPolicy": "轮换策略sticky|per-request|periodic"
},
"pool": {
@@ -1201,7 +1201,7 @@
"bash": "打印 bash 补全脚本",
"fish": "打印 fish 补全脚本",
"install": "为检测到的 Shell 全局安装补全脚本",
"refresh": "刷新组合/提供/模型缓存"
"refresh": "刷新组合/提供/模型缓存"
},
"logs": {
"description": "流式传输或导出请求日志",

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,6 @@ function inferSchema(sample) {
function formatCell(v, col) {
if (v == null) return "";
if (col.formatter) return col.formatter(v);
if (typeof v === "object") return JSON.stringify(v);
return String(v);
}

View File

@@ -52,31 +52,6 @@ export function hasModule(name) {
return existsSync(join(runtimeModules(), name, "package.json"));
}
/**
* Probe whether a native addon (.node) file can actually be dlopen'd by the Node runtime that
* is going to load it. Runs in a throwaway subprocess so a real ABI mismatch (which can segfault
* the process instead of throwing) never takes down the caller — only the probe subprocess.
*/
function probeNativeBinaryLoadable(binary) {
try {
const res = spawnSync(
process.execPath,
[
"-e",
"try { require(process.argv[1]); process.exit(0); } catch (e) { process.exit(1); }",
binary,
],
{ timeout: 10_000, stdio: "ignore" }
);
// status === 0 means require() (and therefore dlopen) succeeded. Anything else — a thrown
// ERR_DLOPEN_FAILED/NODE_MODULE_VERSION mismatch (status 1) or a crash (status null with a
// signal, e.g. SIGSEGV) — means the binary is not safe to load.
return res.status === 0;
} catch {
return false;
}
}
export function isBetterSqliteBinaryValid() {
const binary = join(
runtimeModules(),
@@ -93,18 +68,10 @@ export function isBetterSqliteBinaryValid() {
closeSync(fd);
const magic = buf.toString("hex");
const os = platform();
let formatOk;
if (os === "linux") formatOk = magic.startsWith("7f454c46"); // ELF
else if (os === "darwin")
formatOk = magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O
else if (os === "win32") formatOk = magic.startsWith("4d5a"); // PE/MZ
else formatOk = true;
if (!formatOk) return false;
// File-format magic bytes alone do not guarantee the binary was built for the Node ABI
// (NODE_MODULE_VERSION) that will load it — a stale/foreign-ABI binary passes the header
// check and then crashes (segfault) on load instead of triggering a rebuild. Actually
// attempt to load it, isolated in a subprocess.
return probeNativeBinaryLoadable(binary);
if (os === "linux") return magic.startsWith("7f454c46"); // ELF
if (os === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O
if (os === "win32") return magic.startsWith("4d5a"); // PE/MZ
return true;
} catch {
return false;
}

View File

@@ -1,6 +1,6 @@
import { spawn } from "node:child_process";
import { dirname } from "node:path";
import { writePidFile, cleanupPidFile, killAllSubprocesses, isPidRunning } from "../utils/pid.mjs";
import { writePidFile, cleanupPidFile, killAllSubprocesses } from "../utils/pid.mjs";
import {
RESTART_RESET_MS,
DEFAULT_MAX_RESTARTS,
@@ -8,8 +8,6 @@ import {
computeRestartDelayMs,
waitUntilPortFree,
} from "./supervisorPolicy.mjs";
import { buildNodeHeapArgs } from "../../../scripts/build/runtime-env.mjs";
import { stopProcessGracefully } from "../../../src/shared/platform/windowsProcess.ts";
const CRASH_LOG_LINES = 50;
@@ -32,39 +30,22 @@ export class ServerSupervisor {
this.crashLog = [];
const showLog = process.env.OMNIROUTE_SHOW_LOG === "1";
// #5238: skip the explicit CLI --max-old-space-size when the user pinned the
// heap via NODE_OPTIONS (a CLI arg would shadow/override their value). The
// calibrated heap is already carried by env.NODE_OPTIONS either way.
const heapArgs = buildNodeHeapArgs(process.env, this.memoryLimit);
// #6321: stdout used to be discarded (`"ignore"`) whenever `--log`/OMNIROUTE_SHOW_LOG
// wasn't set (the default) — any debug/pino output written to stdout vanished
// silently, so a boot that never becomes ready looked like a dead hang with zero
// output even at APP_LOG_LEVEL=debug. Pipe stdout too and buffer it alongside
// stderr so a readiness timeout can surface what the child actually printed.
this.child = spawn(process.versions.bun ? process.execPath : "node", [
...(process.versions.bun ? [] : heapArgs),
this.serverPath,
], {
this.child = spawn("node", [`--max-old-space-size=${this.memoryLimit}`, this.serverPath], {
cwd: dirname(this.serverPath),
env: this.env,
stdio: showLog ? "inherit" : ["ignore", "pipe", "pipe"],
stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"],
});
writePidFile("server", this.child.pid);
const bufferOutput = (data) => {
const lines = data.toString().split("\n").filter(Boolean);
this.crashLog.push(...lines);
if (this.crashLog.length > CRASH_LOG_LINES) {
this.crashLog = this.crashLog.slice(-CRASH_LOG_LINES);
}
};
if (this.child.stdout) {
this.child.stdout.on("data", bufferOutput);
}
if (this.child.stderr) {
this.child.stderr.on("data", bufferOutput);
this.child.stderr.on("data", (data) => {
const lines = data.toString().split("\n").filter(Boolean);
this.crashLog.push(...lines);
if (this.crashLog.length > CRASH_LOG_LINES) {
this.crashLog = this.crashLog.slice(-CRASH_LOG_LINES);
}
});
}
this.child.on("error", (err) => this.handleExit(-1, err));
@@ -73,32 +54,12 @@ export class ServerSupervisor {
return this.child;
}
handleExit(code, err) {
handleExit(code) {
// Node.js v24+ requires process.exit() to receive a number. Spawn-error events
// deliver err.code (a string like 'ENOENT') via the 'error' listener; normalise here.
const exitCode = typeof code === "number" ? code : null;
cleanupPidFile("server");
// #8091: the child's spawn 'error' listener passes `err` through as a second
// argument, but it used to be silently dropped — the user only ever saw the
// hardcoded "code=-1" with a permanently empty crash log, with no way to
// diagnose why the child never started (ENOENT/EACCES/bad path/etc.). Surface
// the real reason immediately, both on the console and in the crash-log buffer
// so `dumpCrashLog()` shows it too.
if (err) {
const detail = [
err.code && `code=${err.code}`,
err.syscall && `syscall=${err.syscall}`,
err.path && `path=${err.path}`,
err.message,
]
.filter(Boolean)
.join(" ");
const line = `⚠ Spawn error: ${detail || String(err)}`;
console.error(line);
this.crashLog.push(line);
}
// #4425: only exit on an intentional shutdown. A spontaneous code-0 exit (e.g. a
// systemd MemoryMax cgroup kill, which reports the process exited cleanly) is anomalous
// and must be restarted, not treated as a graceful stop that leaves the gateway dead.
@@ -141,12 +102,6 @@ export class ServerSupervisor {
}, delay);
}
// #6321: exposes the buffered stdout+stderr lines so a caller (e.g. a readiness
// timeout) can print what the child actually said instead of silence.
getRecentLog() {
return [...this.crashLog];
}
dumpCrashLog() {
console.error("\n--- Server crash log ---");
this.crashLog.forEach((l) => console.error(l));
@@ -156,13 +111,14 @@ export class ServerSupervisor {
stop() {
this.isShuttingDown = true;
if (this.child?.pid) {
// #8045: on win32, process.kill(pid, "SIGTERM") unconditionally force-terminates
// the target — it is never a real, interceptable signal there. The child already
// receives the real CTRL_C_EVENT/CTRL_CLOSE_EVENT independently (it shares the
// console) and runs its own async graceful shutdown (WAL checkpoint). Sending
// SIGTERM immediately on win32 races and beats that cleanup. Fire-and-forget:
// stop() itself stays sync so callers keep their existing control flow.
void stopProcessGracefully({ pid: this.child.pid, timeoutMs: 5000, isPidRunning });
try {
process.kill(this.child.pid, "SIGTERM");
} catch {}
setTimeout(() => {
try {
process.kill(this.child.pid, "SIGKILL");
} catch {}
}, 5000);
}
killAllSubprocesses();
}

View File

@@ -3,10 +3,7 @@ import { resolveDataDir, resolveStoragePath } from "./data-dir.mjs";
import { ensureProviderSchema } from "./provider-store.mjs";
import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./settings-store.mjs";
async function loadSqlite() {
if (process.versions.bun) {
return (await import("bun:sqlite")).Database;
}
async function loadBetterSqlite() {
try {
return (await import("better-sqlite3")).default;
} catch {
@@ -14,57 +11,6 @@ async function loadSqlite() {
}
}
function openBunSqlite(Database, dbPath, options) {
const raw = new Database(dbPath, options);
const prepare = (sql) => {
const statement = raw.query(sql);
return {
run: (...params) => statement.run(...normalizeBunSqliteParams(params)),
get: (...params) => statement.get(...normalizeBunSqliteParams(params)),
all: (...params) => statement.all(...normalizeBunSqliteParams(params)),
};
};
return {
prepare,
query: (sql) => raw.query(sql),
exec: (sql) => raw.exec(sql),
transaction: (fn) => raw.transaction(fn),
close: () => raw.close(),
serialize: () => raw.serialize(),
pragma: (pragmaStr, pragmaOptions) => {
const statement = raw.query(`PRAGMA ${pragmaStr}`);
if (pragmaOptions?.simple) {
const row = statement.get();
return row ? (Object.values(row)[0] ?? null) : null;
}
return statement.all();
},
};
}
export function normalizeBunSqliteParams(params) {
if (
params.length !== 1 ||
params[0] === null ||
typeof params[0] !== "object" ||
Array.isArray(params[0]) ||
params[0] instanceof Uint8Array ||
(typeof Buffer !== "undefined" && Buffer.isBuffer(params[0]))
) {
return params;
}
const expanded = {};
for (const [key, value] of Object.entries(params[0])) {
if (/^[:@$]/.test(key)) expanded[key] = value;
else {
expanded[`@${key}`] = value;
expanded[`:${key}`] = value;
expanded[`$${key}`] = value;
}
}
return [expanded];
}
export function createSqliteNativeError(error) {
const message = error instanceof Error ? error.message : String(error);
if (message.includes("NODE_MODULE_VERSION") || message.includes("ERR_DLOPEN_FAILED")) {
@@ -75,35 +21,13 @@ export function createSqliteNativeError(error) {
"(rebuilds into a user-writable runtime; works without a C++ toolchain)."
);
}
if (
message.includes("Could not locate the bindings file") ||
message.includes("MODULE_NOT_FOUND") ||
message.includes("Cannot find module 'better-sqlite3'")
) {
return new Error(
"better-sqlite3 native binding could not be found (no prebuilt addon for this platform). " +
"This is common under `npx`, which runs a fresh, ephemeral install that never built the addon. " +
"Run: omniroute runtime repair " +
"(rebuilds into a user-writable runtime; works without a C++ toolchain)."
);
}
return error;
}
async function openSqliteDatabase(dbPath, options = {}) {
const Database = await loadSqlite();
if (process.versions.bun) {
if (options.fileMustExist && !fs.existsSync(dbPath)) {
throw new Error(`SQLite file does not exist: ${dbPath}`);
}
options = options.readonly
? { readonly: true }
: { readwrite: true, create: options.fileMustExist !== true };
}
const Database = await loadBetterSqlite();
try {
return process.versions.bun
? openBunSqlite(Database, dbPath, options)
: new Database(dbPath, options);
return new Database(dbPath, options);
} catch (error) {
throw createSqliteNativeError(error);
}
@@ -135,16 +59,7 @@ export async function withReadonlySqlite(dbPath, callback) {
export async function backupSqliteFile(sourcePath, destPath) {
const db = await openSqliteDatabase(sourcePath, { readonly: true });
try {
if (typeof db.backup === "function") {
await db.backup(destPath);
} else if (sourcePath === ":memory:" && typeof db.serialize === "function") {
fs.writeFileSync(destPath, Buffer.from(db.serialize()));
} else {
try {
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
} catch {}
fs.copyFileSync(sourcePath, destPath);
}
await db.backup(destPath);
} finally {
db.close();
}

View File

@@ -6,7 +6,6 @@ import { fileURLToPath } from "node:url";
const APP_LABEL = "com.omniroute.autostart";
const WIN_REG_VALUE = "OmniRoute";
const WIN_STARTUP_FILE = "OmniRoute.vbs";
const LINUX_SERVICE_NAME = "omniroute.service";
const LINUX_DESKTOP_NAME = "omniroute.desktop";
@@ -324,101 +323,38 @@ function isEnabledMac() {
);
}
/**
* Returns the absolute path to the Windows Startup folder
* (%APPDATA%\Microsoft\Windows\Start Menu\Programs\Startup\),
* or null when APPDATA is not set.
*/
function winStartupDir() {
if (!process.env.APPDATA) return null;
return join(process.env.APPDATA, "Microsoft", "Windows", "Start Menu", "Programs", "Startup");
}
/**
* Returns the full path to the VBS autostart script in the Startup folder,
* or null when the Startup folder cannot be resolved.
*/
function winStartupPath() {
const dir = winStartupDir();
return dir ? join(dir, WIN_STARTUP_FILE) : null;
}
/**
* Builds the VBScript source that launches OmniRoute with WSH's Run method
* using SW_HIDE (0) so no console window appears.
*
* 9Router uses the same pattern: a .vbs file in the Startup folder that calls
* WScript.Shell.Run with window style 0 (hidden). This avoids the console
* window flash that HKCU\Run causes for console-mode node.exe.
*/
function buildWinVbsContent(cliPath) {
const execLine = buildServeExecLine(cliPath, { tray: true });
// VBScript doubles `"` inside a string to escape them. The execLine already
// contains quoted paths; escape each `"` to `""` so the VBS parser reads
// them as literal quote characters in the command string passed to Run().
const vbsSafe = execLine.replace(/"/g, '""');
return [
'Set WshShell = CreateObject("WScript.Shell")',
`WshShell.Run "${vbsSafe}", 0, False`,
"",
].join("\n");
}
/**
* Removes the legacy HKCU\Run registry value if present. Callers use this
* during migration so stale Run entries don't linger after switching to the
* VBS-based autostart.
*/
function cleanLegacyWinReg() {
try {
execSync(
`reg delete HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v ${WIN_REG_VALUE} /f 2>nul`,
{ stdio: "ignore", windowsHide: true }
);
} catch {
// entry did not exist or already removed — noop
}
}
function enableWin() {
const cliPath = resolveCliPath();
if (!cliPath) return false;
const vbsPath = winStartupPath();
if (!vbsPath) return false;
const dir = dirname(vbsPath);
mkdirSync(dir, { recursive: true });
writeFileSync(vbsPath, buildWinVbsContent(cliPath), { mode: 0o644 });
// Migrate away from the legacy HKCU\Run entry — it launches node.exe with
// a visible console window. The VBS approach is the replacement.
cleanLegacyWinReg();
return existsSync(vbsPath);
const value = `"${process.execPath}" "${cliPath}" serve --tray --no-open`;
try {
execSync(
`reg add HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v ${WIN_REG_VALUE} /t REG_SZ /d "${value}" /f`,
{ stdio: "ignore", windowsHide: true }
);
return true;
} catch {
return false;
}
}
function disableWin() {
const vbsPath = winStartupPath();
if (!vbsPath) return false;
try {
unlinkSync(vbsPath);
execSync(
`reg delete HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v ${WIN_REG_VALUE} /f`,
{ stdio: "ignore", windowsHide: true }
);
return true;
} catch {
// already removed
return false;
}
// Also clean up any lingering legacy registry entry as a safety net.
cleanLegacyWinReg();
return !existsSync(vbsPath);
}
function isEnabledWin() {
const vbsPath = winStartupPath();
if (!vbsPath) return false;
// Primary check: VBS file exists in the Startup folder.
if (existsSync(vbsPath)) return true;
// Fallback check: legacy HKCU\Run entry (for users who enabled autostart
// before the VBS migration). Treat it as enabled so the tray menu shows
// the correct toggle state, and calling disable() will clean it up.
try {
const out = execSync(
`reg query HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run /v ${WIN_REG_VALUE}`,
{ stdio: "pipe", windowsHide: true, encoding: "utf8", timeout: 3000 }
{ stdio: "pipe", windowsHide: true, encoding: "utf8" }
);
return out.includes(WIN_REG_VALUE);
} catch {

View File

@@ -5,12 +5,10 @@ let active = null;
export { isTraySupported };
export async function initTray({ port, onQuit, onOpenDashboard, onShowLogs }) {
export function initTray({ port, onQuit, onOpenDashboard, onShowLogs }) {
if (!isTraySupported()) return null;
const ctx = { port, onQuit, onOpenDashboard, onShowLogs };
// initSystrayUnix is async: it lazily installs/loads systray2 from the runtime
// dir (trayRuntime.ts) rather than from node_modules. (#4605)
active = process.platform === "win32" ? initWinTray(ctx) : await initSystrayUnix(ctx);
active = process.platform === "win32" ? initWinTray(ctx) : initSystrayUnix(ctx);
return active;
}

View File

@@ -14,31 +14,30 @@ export function isTraySupported() {
return true;
}
// systray2 is NOT a static dependency — it is lazily installed into
// ~/.omniroute/runtime by trayRuntime.ts (loadSystray). The previous inline
// loader called `require("module")`, which throws `ReferenceError: require is
// not defined` in this ESM file (package "type":"module"); the throw was
// silently swallowed, so the tray never appeared on macOS/Linux with no error
// printed (#4605, regressed in v3.8.34). Delegate to the runtime loader, which
// resolves systray2 from the runtime dir and surfaces install/import failures.
async function loadSystray2() {
const { loadSystray } = await import("../runtime/trayRuntime.ts");
return loadSystray();
function loadSystray2() {
const candidates = [
() => {
const { createRequire } = require("module");
const req = createRequire(import.meta.url);
return req("systray2").default;
},
];
for (const attempt of candidates) {
try {
return attempt();
} catch {}
}
return null;
}
function getIconBase64() {
// Icon ships at bin/cli/tray/icon.png — the previous "icons/icon.png" path
// never existed, so the tray was created with an empty icon (#4605).
const iconPath = join(__dirname, "icon.png");
const iconPath = join(__dirname, "icons", "icon.png");
if (existsSync(iconPath)) return readFileSync(iconPath).toString("base64");
return "";
}
export async function initSystrayUnix(
{ port, onQuit, onOpenDashboard, onShowLogs },
loadCtor = loadSystray2
) {
const SysTray = await loadCtor();
export function initSystrayUnix({ port, onQuit, onOpenDashboard, onShowLogs }) {
const SysTray = loadSystray2();
if (!SysTray) return null;
const autostartEnabled = isAutostartEnabled();
@@ -58,10 +57,7 @@ export async function initSystrayUnix(
tray = new SysTray({
menu: {
icon: getIconBase64(),
// isTemplateIcon must be false: icon.png is a full-color RGBA logo, and
// macOS template mode uses only the alpha channel → a solid white square
// (the icon looked "missing" even when the tray loaded). (PR #1080)
isTemplateIcon: false,
isTemplateIcon: process.platform === "darwin",
title: "",
tooltip: `OmniRoute — port ${port}`,
items,

View File

@@ -65,52 +65,34 @@ export function sleep(ms) {
// cold start due to filesystem watchers, antivirus, etc.) get a working
// "server ready" signal instead of a phantom timeout while the server is
// still booting. TCP fallback marks the server as ready when the port
// has been listening for >= 3s consecutively AND the health route is
// actively rejecting/resetting connections fast (route not mounted yet,
// but the HTTP server is clearly alive and responsive) — never for a
// socket that merely accepts TCP and then hangs without ever completing
// a single request (#6800: that's a still-booting/CPU-bound process, not
// a "route not mounted" gap, and must NOT be reported as ready).
// has been listening for >= 3s consecutively but /api/monitoring/health
// has not yet been mounted — common during dev cold start.
export async function waitForServer(port, timeout = 60000) {
const start = Date.now();
let tcpListeningSince = null;
while (Date.now() - start < timeout) {
const outcome = await pollHealthOnce(port);
if (outcome === "ready") return true;
if (outcome === "fast-reject") {
try {
const res = await fetch(`http://localhost:${port}/api/monitoring/health`, {
signal: AbortSignal.timeout(2000),
});
if (res.ok) return true;
// Server responded but health endpoint is not ready yet — keep
// polling, but the fact that we got a response means TCP is open.
if (tcpListeningSince === null) tcpListeningSince = Date.now();
if (Date.now() - tcpListeningSince >= 3000) return true;
} else {
// "hanging" (request timed out with no response at all) or
// "not-listening" — neither counts toward the grace window.
tcpListeningSince = null;
} catch {
const listening = await isPortListening(port).catch(() => false);
if (listening) {
if (tcpListeningSince === null) tcpListeningSince = Date.now();
if (Date.now() - tcpListeningSince >= 3000) return true;
} else {
tcpListeningSince = null;
}
}
await sleep(500);
}
return false;
}
// Polls /api/monitoring/health once and classifies the outcome:
// - "ready": got a 2xx HTTP response.
// - "fast-reject": got a non-2xx HTTP response, or the connection was
// actively refused/reset (not a timeout) — the HTTP server is alive and
// answering quickly, just not routing this endpoint yet (#2460).
// - "hanging": the request timed out waiting for any response — the
// process accepted the TCP connection but never answered (#6800).
// - "not-listening": nothing is accepting connections on the port at all.
async function pollHealthOnce(port) {
try {
const res = await fetch(`http://localhost:${port}/api/monitoring/health`, {
signal: AbortSignal.timeout(2000),
});
return res.ok ? "ready" : "fast-reject";
} catch (err) {
if (err?.name === "TimeoutError") return "hanging";
const listening = await isPortListening(port).catch(() => false);
return listening ? "fast-reject" : "not-listening";
}
}
async function isPortListening(port) {
const net = await import("node:net");
return new Promise((resolve) => {

View File

@@ -1,25 +0,0 @@
/**
* Decide whether a CLI invocation is a bare `--version`/`-V` query that should
* short-circuit BEFORE the runtime polyfill import, env-file loading, and
* Commander's command registration (~70 command modules) are loaded.
*
* Scope is intentionally narrow — only a single, unambiguous `--version`/`-V`
* argument fast-paths. Anything else (extra args, a subcommand, `--help`,
* global options like `--lang`/`--output` alongside it) falls through to the
* normal Commander flow. Unlike `--version`, OmniRoute's `--help` output is
* generated dynamically from every registered subcommand, so skipping
* registration would change (truncate) the help text — that flag is
* deliberately NOT fast-pathed here.
*
* Mirrors the intent of upstream 9router PR #2414 (fast-path help/version
* before expensive self-heal hooks), adapted to OmniRoute's Commander-based
* CLI where the equivalent expensive work is eager command registration
* rather than npm-install-based runtime self-healing.
*
* @param {string[]} argv - process.argv (node + script + args).
* @returns {boolean}
*/
export function isVersionFastPath(argv) {
const args = Array.isArray(argv) ? argv.slice(2) : [];
return args.length === 1 && (args[0] === "--version" || args[0] === "-V");
}

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# bin/cold-start-bench.sh — measure OmniRoute cold-start against the target
# budgets (container start → HTTP listening ≤ 800 ms; first warm
# 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
@@ -13,7 +13,7 @@ Usage: bin/cold-start-bench.sh [--port <n>] [--start-cmd "<cmd>"] [--url <base>]
[--listen-budget-ms <n>] [--ttfb-budget-ms <n>] [-h|--help]
Boots OmniRoute, times cold-start to the first /api/health/ping 200, measures
warm TTFB, and compares against the cold-start budgets
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

View File

@@ -1,6 +1,7 @@
#!/usr/bin/env node
export const SECURE_NODE_LINES = Object.freeze([
Object.freeze({ major: 20, minor: 20, patch: 2 }),
Object.freeze({ major: 22, minor: 22, patch: 2 }),
Object.freeze({ major: 24, minor: 0, patch: 0 }),
Object.freeze({ major: 25, minor: 0, patch: 0 }),
@@ -8,9 +9,9 @@ export const SECURE_NODE_LINES = Object.freeze([
]);
export const RECOMMENDED_NODE_VERSION = "24.14.1";
export const SUPPORTED_NODE_RANGE = ">=22.22.2 <23 || >=24.0.0 <27";
export const SUPPORTED_NODE_RANGE = ">=20.20.2 <21 || >=22.22.2 <23 || >=24.0.0 <27";
export const SUPPORTED_NODE_DISPLAY =
"Node.js 22.22.2+ (22.x LTS), 24.0.0+ (24.x LTS), 25.0.0+ (25.x), or 26.0.0+ (26.x)";
"Node.js 20.20.2+ (20.x LTS), 22.22.2+ (22.x LTS), 24.0.0+ (24.x LTS), 25.0.0+ (25.x), or 26.0.0+ (26.x)";
function formatVersion(version) {
return `${version.major}.${version.minor}.${version.patch}`;
@@ -77,7 +78,7 @@ export function getNodeRuntimeWarning(version = process.versions.node) {
}
if (support.reason === "unreleased-major") {
return `Node.js ${support.nodeVersion} is outside the supported LTS lines. OmniRoute currently supports Node.js 22.x, 24.x, 25.x, and 26.x.`;
return `Node.js ${support.nodeVersion} is outside the supported LTS lines. OmniRoute currently supports Node.js 20.x, 22.x, 24.x, 25.x, and 26.x.`;
}
return `Node.js ${support.nodeVersion} is outside OmniRoute's approved secure runtime policy.`;

View File

@@ -4,17 +4,13 @@
* OmniRoute CLI entry point.
*
* Special bypasses (handled before Commander):
* --version / -V (alone) Fast-path: print the version and exit, skipping the
* tsx/esm + polyfill imports, env-file loading, and
* Commander's ~70-command registration entirely.
* --mcp Start MCP server over stdio
* reset-encrypted-columns Recovery tool for broken encrypted credentials
* reset-password Reset the admin/management password
*
* All other commands are routed through Commander (bin/cli/program.mjs).
*/
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import updateNotifier from "update-notifier";
@@ -22,26 +18,6 @@ import { isNativeBinaryCompatible } from "../scripts/build/native-binary-compat.
import { getNodeRuntimeSupport, getNodeRuntimeWarning } from "./nodeRuntimeSupport.mjs";
import { getDefaultDataDir } from "./cli/data-dir.mjs";
import { shouldProvisionStorageKey } from "./cli/utils/storageKeyProvision.mjs";
import { isVersionFastPath } from "./cli/utils/versionFastPath.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = join(__dirname, "..");
// Fast-path a bare `--version`/`-V` query BEFORE the tsx/esm registration, the
// polyfill import, env-file loading, or Commander's command registration (~70
// modules — DB, providers, OAuth, etc.) run. None of that work is needed to answer
// "what version is this" — mirrors upstream 9router PR #2414 (fast-path help/version
// ahead of expensive self-heal hooks), adapted to OmniRoute's Commander CLI where the
// equivalent expensive work is eager command registration rather than npm-install-based
// runtime self-healing. `--help` is intentionally NOT fast-pathed here: its output is
// generated dynamically from every registered subcommand, so skipping registration
// would truncate the help text instead of just speeding it up.
if (isVersionFastPath(process.argv)) {
const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
console.log(pkg.version);
process.exit(0);
}
// Register tsx so dynamic imports of .ts source files (referenced as .js per
// TypeScript conventions) resolve correctly. The build never emits .js for
@@ -49,14 +25,9 @@ if (isVersionFastPath(process.argv)) {
await import("tsx/esm");
await import("../open-sse/utils/setupPolyfill.ts");
// #7791: tsx's tsconfig-path resolution does not apply when OmniRoute is
// installed globally (files live under node_modules/omniroute/), so bare
// `@/...` specifiers (declared in tsconfig.json paths as `@/* → ./src/*`)
// fail with ERR_MODULE_NOT_FOUND. Register an ESM resolve hook that maps
// `@/...` to absolute file URLs under <ROOT>/src/. Safe no-op in dev checkout
// (paths already resolve via tsconfig) and when ROOT has no `src/` dir.
const { registerAliasResolver } = await import("./aliasResolver.mjs");
await registerAliasResolver(ROOT);
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const ROOT = join(__dirname, "..");
// MCP stdio transport uses stdout exclusively for JSON-RPC messages.
// Redirect console.log/warn to stderr early (before loadEnvFile and DB init)
@@ -68,28 +39,6 @@ if (process.argv.includes("--mcp")) {
console.warn = stderrConsole.warn.bind(stderrConsole);
}
// Electron persists secrets (JWT_SECRET, API_KEY_SECRET, STORAGE_ENCRYPTION_KEY) to
// `<DATA_DIR>/server.env` (electron/main.js), never `.env`. Migrating an existing
// install (storage.sqlite + server.env) to the CLI left those secrets undiscoverable —
// the CLI only ever looked for `.env`, so the STORAGE_ENCRYPTION_KEY needed to decrypt
// the migrated database was silently dropped (#7302). One-time, one-directory migration:
// if `<dataDir>/.env` is absent but `<dataDir>/server.env` is present, copy it to `.env`
// so it flows through the normal env-loading path below. Never overwrites an existing
// `.env` — an explicit `.env` always wins over a legacy `server.env`.
function migrateElectronServerEnv(dataDir) {
try {
const envPath = join(dataDir, ".env");
const serverEnvPath = join(dataDir, "server.env");
if (existsSync(envPath) || !existsSync(serverEnvPath)) return;
writeFileSync(envPath, readFileSync(serverEnvPath, "utf-8"), "utf-8");
console.log(
` \x1b[2m♻ Migrated Electron secrets from ${serverEnvPath} to ${envPath}\x1b[0m`
);
} catch {
// Ignore errors migrating server.env — fall back to normal env loading below.
}
}
function loadEnvFile() {
const envPaths = [];
const loadedEnvPaths = [];
@@ -100,8 +49,6 @@ function loadEnvFile() {
envPaths.push(envPath);
};
migrateElectronServerEnv(process.env.DATA_DIR || getDefaultDataDir());
if (process.env.DATA_DIR) {
addEnvPath(join(process.env.DATA_DIR, ".env"));
}
@@ -263,15 +210,6 @@ if (process.argv.includes("reset-encrypted-columns")) {
process.exit(exitCode ?? 0);
}
if (process.argv.includes("reset-password")) {
// bin/reset-password.mjs self-executes its `main()` on import and calls
// process.exit() on completion/error. Await a never-resolving promise so
// control never falls through to Commander (which would then reject
// `reset-password` as an unknown command). See #6261.
await import(pathToFileURL(join(ROOT, "bin", "reset-password.mjs")).href);
await new Promise(() => {});
}
try {
const { createProgram } = await import(
pathToFileURL(join(ROOT, "bin", "cli", "program.mjs")).href

View File

@@ -5,15 +5,10 @@
*
* Usage:
* node bin/reset-password.mjs
* omniroute reset-password
*
* Non-interactive / scripted usage (piped stdin, e.g. CI or Docker):
* printf 'NewPass123\nNewPass123\n' | omniroute reset-password
* printf 'NewPass123' | omniroute reset-password --password-stdin
* npx omniroute reset-password
*
* Resets the admin password for OmniRoute.
* Prompts for a new password (interactive TTY) or reads it from stdin
* (non-TTY) and updates the database directly.
* Prompts for a new password and updates the database directly.
*
* @module bin/reset-password
*/
@@ -26,61 +21,19 @@ import { readManagementPasswordState, resetManagementPassword } from "./cli/sqli
const DATA_DIR = resolveDataDir();
const DB_PATH = resolveStoragePath(DATA_DIR);
const MIN_PASSWORD_LENGTH = 8;
const rl = createInterface({
input: process.stdin,
output: process.stdout,
});
/** Read the entire stdin stream as a UTF-8 string (used for non-TTY input). */
function readAllStdin() {
return new Promise((resolve) => {
let data = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
data += chunk;
});
process.stdin.on("end", () => resolve(data));
process.stdin.on("error", () => resolve(data));
// Resuming is implied by attaching a 'data' listener, but be explicit so a
// paused stream (some spawn setups) still flows to EOF.
process.stdin.resume();
});
function ask(question) {
return new Promise((resolve) => rl.question(question, resolve));
}
/**
* Obtain the new password (and its confirmation).
*
* - `--password-stdin`: the ENTIRE stdin is the password, no confirmation.
* - non-TTY stdin (piped): read all of stdin once; first line is the password,
* second line — when present — is the confirmation, else the first line is
* reused (a single-line pipe means "no separate confirmation").
* - interactive TTY: two sequential prompts (unchanged behavior).
*
* The non-TTY path exists because two sequential `rl.question` promises never
* settle under a piped EOF — the second read blocks forever, so the reset was
* silently never applied (#6258).
*/
async function collectPassword() {
if (process.argv.includes("--password-stdin")) {
const raw = await readAllStdin();
const password = raw.replace(/[\r\n]+$/, "");
return { password, confirm: password };
}
if (!process.stdin.isTTY) {
const raw = await readAllStdin();
const lines = raw.split(/\r?\n/);
const password = lines[0] ?? "";
const confirm = lines[1] ? lines[1] : password;
return { password, confirm };
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
try {
const ask = (question) => new Promise((resolve) => rl.question(question, resolve));
const password = await ask("Enter new password (min 8 chars): ");
const confirm = await ask("Confirm new password: ");
return { password, confirm };
} finally {
rl.close();
}
function exitWithError(message) {
console.error(message);
rl.close();
process.exit(1);
}
console.log("\n🔑 OmniRoute — Password Reset\n");
@@ -101,34 +54,27 @@ async function main() {
console.log(" No password is currently set.");
}
const { password, confirm } = await collectPassword();
const password = await ask("Enter new password (min 8 chars): ");
if (!password || password.length < MIN_PASSWORD_LENGTH) {
console.error(`\n❌ Password must be at least ${MIN_PASSWORD_LENGTH} characters.\n`);
process.exit(1);
if (!password || password.length < 8) {
exitWithError("\n❌ Password must be at least 8 characters.\n");
}
const confirm = await ask("Confirm new password: ");
if (password !== confirm) {
console.error("\n❌ Passwords do not match.\n");
process.exit(1);
exitWithError("\n❌ Passwords do not match.\n");
}
await resetManagementPassword(password, DB_PATH);
rl.close();
console.log("\n✅ Password reset successfully!");
console.log(" Restart OmniRoute for changes to take effect.\n");
}
main()
.then(() => {
// Explicit exit(0) so a caller that imports this module (bin/omniroute.mjs
// routes `omniroute reset-password` here) terminates cleanly instead of
// hanging / exiting with code 13 on an unsettled wrapper await. On POSIX,
// console.log to a pipe is synchronous, so the success line is already
// flushed by the time we exit.
process.exit(0);
})
.catch((err) => {
console.error(`\n❌ Error: ${err.message}\n`);
process.exit(1);
});
main().catch((err) => {
console.error(`\n❌ Error: ${err.message}\n`);
rl.close();
process.exit(1);
});

View File

@@ -1,7 +1,7 @@
#!/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-recovery
# flow after stopping writers.
# 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.

View File

@@ -1,7 +1,7 @@
#!/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-recovery flow when policies_active is empty
# 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

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash
# bin/rollback.sh — roll OmniRoute back to a previous release to mitigate a bad
# deploy. Part of the deploy-rollback incident-recovery flow.
# deploy. Used by the incident runbook (docs/INCIDENT_RESPONSE.md §3 / §4).
#
# Methods (auto-detected; override with --method):
# • npm — `npm install -g omniroute@<version>` and, if PM2 manages it,

View File

@@ -1,7 +1,7 @@
#!/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-recovery flow before any restore.
# 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_<UTC>[_<label>] holding a
# `VACUUM INTO` copy of storage.sqlite (consistent even with WAL writers active)

View File

@@ -1,42 +0,0 @@
# changelog.d/ — changelog fragments
**A PR never edits `CHANGELOG.md` directly during the cycle.** Instead it adds ONE new
file here — its changelog entry as a *fragment*. Two PRs never touch the same file, so
changelog merge conflicts (the "CHANGELOG-eat" cascade that forced a re-sync push + full
CI re-run after every sibling merge) are structurally impossible.
## Convention
| Directory | Aggregates under |
| -------------- | ----------------------- |
| `features/` | `### ✨ New Features` |
| `fixes/` | `### 🐛 Bug Fixes` |
| `maintenance/` | `### 📝 Maintenance` |
- **Filename**: `<PR-number>-<short-slug>.md` (e.g. `fixes/6700-dockerfile-better-sqlite3.md`).
The PR number prefix keeps aggregation order deterministic.
- **Content**: the exact bullet line(s) that should land in `CHANGELOG.md`, starting with
`- `. Multi-line (continuation) bullets are fine. Keep the repo's credit format:
`(#PR — thanks @user)`.
- One fragment per PR (rarely more, e.g. a PR that both fixes and adds).
## Example
`changelog.d/fixes/6496-cloudflare-relay-worker-syntax.md`:
```markdown
- **fix(providers):** Cloudflare relay Worker deploys use Service Worker syntax with `body_part` metadata ([#6496](https://github.com/diegosouzapw/OmniRoute/pull/6496)) — thanks @SeaXen
```
## Aggregation
The release captain (or `/generate-release`) folds all fragments into `CHANGELOG.md` and
deletes them:
```bash
node scripts/release/aggregate-changelog.mjs # write + delete fragments
node scripts/release/aggregate-changelog.mjs --dry-run # preview only
```
Fragment well-formedness is enforced by `npm run check:changelog-integrity` (the same
gate that guards against CHANGELOG-eat for legacy direct edits).

View File

@@ -1 +0,0 @@
- **feat(sse):** every completion response now carries an `X-OmniRoute-Decision: strategy=<name>; provider=<alias>; latency_ms=<n>` header exposing the routing decision — `<name>` is the combo strategy (`priority`, `weighted`, `fusion`, etc.) or `single` for a non-combo request — for client-side debugging/analytics without server log access (#6022 — thanks @chirag127).

View File

@@ -1 +0,0 @@
- **feat(ws):** the live-dashboard WebSocket server now auto-starts in-process (via `instrumentation-node.ts`) across every deployment mode — dev, production, Docker, Electron — with no separate sidecar script; the default WS port moved from 20129 to **20132** to avoid colliding with `API_PORT` in split-port setups, the deprecated `OMNIROUTE_DISABLE_LIVE_WS` env was consolidated into `OMNIROUTE_ENABLE_LIVE_WS` (default enabled), and the WS path is now derived from `NEXT_PUBLIC_LIVE_WS_PUBLIC_URL`'s pathname (`/live-ws` fallback) (#6072 — thanks @ianriizky).

View File

@@ -1 +0,0 @@
- feat(sse): configurable per-model upstream header-response timeout override, precedence model > provider > global; applied to codex reasoning-heavy tiers (gpt-5.5-high/xhigh, gpt-5.6-\*-high/xhigh) (#6354)

Some files were not shown because too many files have changed in this diff Show More