Compare commits

..

97 Commits

Author SHA1 Message Date
Xiangzhe
f95b03d709 chore(release): back-merge main into release/v3.8.50 (ours) to unblock the release PR
The living release PR #8875 was CONFLICTING, which makes GitHub skip EVERY
pull_request workflow silently (no ci.yml, no semgrep, no DAST). Back-merging
main restores a computable merge ref.

Strategy `-s ours`: main is a stale snapshot of the release line (PR #11088 was
merged into main from a release-tip base, dragging ~5094 files). All 7 main-only
commits were verified as already represented on this branch:

- #11088 ollama capability routing  -> ported here as #11271 (6d4c4843e9)
- #11075 shared passthrough providers -> ported here as #11165 (92ef3c71ea)
- #10055 getModelsDevPricing memoization -> present (modelsDevSync.ts)
- #10026 hide health-check excluded models -> present and extended (catalog.ts)
- /_tasks anchored gitignore hardening -> present (.gitignore:288)
- nanoid/dompurify Dependabot bumps -> identical versions

main-only files intentionally NOT carried over:

- changelog.d/fixes/10286-gemini-3-5-flash-thinking.md + its regression test:
  the fix landed here as #10450 and was then deliberately superseded by
  2764812ee4 "eliminate Gemini 3.5 Flash". The test fails on this branch by
  design.
- public/providers/hackclub.svg: provider removed here (migration 162).
- docs/superpowers/**/2026-08-23-qdrant-*: planning artifacts belong in _tasks/
  (AGENTS.md), never under docs/.
2026-08-24 19:55:00 -03:00
Diego Rodrigues de Sa e Souza
1d0c5a36db fix(security): match cookie domains by suffix, not substring (#11429)
CodeQL js/incomplete-url-substring-sanitization, alerts #860 and #861:
volcengineConsoleAutoLogin accepted any cookie whose `domain` merely *contained*
"volcengine.com".

That check is an authorization decision, not a string test. The console
auto-login harvests `digest`, `AccountID`, `csrfToken` and `userInfo` out of the
Playwright context and persists them as the operator's Volcengine credentials,
so a cookie set by `volcengine.com.attacker.tld` — or `notvolcengine.com` — was
captured and stored as a provider connection.

Add `matchesCookieDomain()` (open-sse/utils/cookieDomain.ts): exact host or
dot-boundary suffix, leading dots and case normalized on both sides, failing
closed on an empty expected domain. Same shape as the existing
`isAdobeCookieDomain` in adobeFireflyBrowserLogin.ts, which already got this
right.

While sweeping the class, inAppLoginService's cookie capture had the identical
weakness — `c.domain.includes(domain.replace(/^\./, ""))` — with the identical
consequence: a look-alike host's cookie stored as the operator's credential.
CodeQL did not flag it because the expected domain comes from
TOKEN_EXTRACTION_CONFIGS rather than a literal. Both callsites now share the
helper.

tests/unit/volcengine-cookie-domain-suffix.test.ts — 5 tests, red before the
fix, covering the real domains, seven look-alikes, empty/missing input, and the
config-supplied path.

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-24 19:44:12 -03:00
Diego Rodrigues de Sa e Souza
b24cc53d54 fix(quality): drain the two Fast Quality Gates base-reds (#11438)
`Fast Quality Gates` has been failing on every open PR against
release/v3.8.50 with "2 gate(s) failed: mutation-test-coverage lockfile".
Neither belongs to any feature branch, so they are drained here.

check:lockfile — a transitive dev/optional entry
(libxmljs2 → brace-expansion@2.1.4) landed with a `resolved` URL pointing at
registry.npmmirror.com instead of registry.npmjs.org, which lockfile-lint
rejects as a supply-chain policy violation. Verified before touching it: the
recorded `integrity`
(sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==)
is byte-identical to the official npmjs tarball's, so the package content is the
same and this is a provenance slip — someone's install ran behind the mirror
registry — not a tampered package. Repointed the URL; `integrity` untouched.
It was the only non-npmjs host in the lockfile (2690 npmjs entries).

check:mutation-test-coverage — two covering unit tests were missing from
stryker.conf.json's tap.testFiles, so their mutant kills did not count:
repro-glm-iso-reset-24h-cap (accountFallback.ts) and
repro-combo-persisted-cooldown-preskip (comboPredicates.ts). Inserted in place.

Both gates verified green locally. The diff is three lines: re-serializing
either file would have reordered a curated list for no reason.

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-24 19:43:47 -03:00
Bob.Hou
59dccdd9e1 fix(security): sanitize agent-card topology, anti-spoof login rate-limit peer IP, and add 429 Retry-After (#S1 #S2 #S4) (#11418)
Validado em lote combinado (batch-0824g) contra o tip de release/v3.8.50: typecheck:core limpo, gates estáticos OK, 62/62 testes focados passando (S1/S2/S4, tests/unit/security-s1-s2-s4.test.ts, 9/9).

Boa integração com o padrão já existente de peer IP stamped por HMAC (resolveStampedPeer/OMNIROUTE_PEER_STAMP_TOKEN) — reusa em vez de reimplementar, e o header confiável só é honrado quando o stamp token está configurado. S2 remove corretamente a disclosure de topologia hardcoded do agent-card. Obrigado pela contribuição!
2026-08-24 17:24:08 -03:00
PhuongDoan
9464792cfc feat(sse): add glm-5.3-max explicit effort tier (#11415)
Validado em lote combinado (batch-0824g) contra o tip de release/v3.8.50: typecheck:core limpo, gates estáticos OK, 62/62 testes focados passando (23/23 do PR entre glm-5.3-catalog-and-effort-tiers.test.ts e zai-catalog-glm52.test.ts).

Aditivo, espelha exatamente o padrão já existente glm-5.2-max. Obrigado pela contribuição, primeira PR bem-vinda!
2026-08-24 17:23:58 -03:00
Marcelo Karval
11cbd7d4e0 fix(models): normalize media endpoint metadata (#11397)
Validado em lote combinado (batch-0824g) contra o tip de release/v3.8.50: typecheck:core limpo, gates estáticos OK, 62/62 testes focados passando (endpoint/parser/schema/static-model + catálogo).

Canonicaliza metadados de endpoint legados (video/audio) para IDs específicos por operação, mantendo compatibilidade retroativa via `normalizeModelSupportedEndpoints` (valores antigos `audio`/`video` continuam válidos como entrada e são normalizados na escrita). Obrigado pela contribuição, primeira PR bem-vinda!
2026-08-24 17:23:48 -03:00
Nguyen Thanh Dat
f93fecd86b fix(dashboard): honour the live WebSocket port the handshake reports (#11331) (#11388)
Validado em lote combinado (batch-0824g) contra o tip de release/v3.8.50: typecheck:core limpo, gates estáticos OK, 62/62 testes focados passando (incluindo tests/unit/live-ws-url-11331.test.ts, 11 casos + mutation-check).

Resolve o incidente real do #11331: o handshake já reportava a porta live real, mas o cliente descartava esse campo e ficava preso na porta compilada no bundle. Precedência clara (wsUrl explícito > publicUrl completo > porta/path do handshake aplicados ao default). Obrigado pela contribuição!
2026-08-24 17:23:38 -03:00
Nguyen Thanh Dat
d5d730c845 test(kimi): stop drawing the refresh window inside the assertion (#11380)
Validado em lote combinado (batch-0824g, junto de #11388/#11397/#11415/#11418) contra o tip de release/v3.8.50: typecheck:core limpo, file-size/changelog/complexity/cognitive-complexity OK, 62/62 testes focados passando.

Diagnóstico correto e bem documentado: a falha do nightly Node 26 era um teste que sorteia um número e depende do resultado, não uma quebra de compatibilidade. Comportamento de produção inalterado (a janela de jitter continua aleatória; só o teste ganhou controle sobre ela). Obrigado pela investigação detalhada!
2026-08-24 17:23:27 -03:00
Diego Rodrigues de Sa e Souza
8bbe92c692 fix(docker): size the Next build worker pool for a 16 GB runner (#11419)
Every "Publish to Docker Hub" run has failed since 2026-08-22 23:14 UTC — 96 of
the last 100. The builder stage dies with:

  ERROR: failed to solve: ResourceExhausted: process "/bin/sh -c ... npm run
  build ..." did not complete successfully: cannot allocate memory

That is the kernel, not V8. The log puts it precisely: the compile phase always
finishes ("✓ Compiled successfully in 4.2min") and the build is killed right
after "Collecting page data using 7 workers".

Each page-data worker is its own process and inherits NODE_OPTIONS, so the
--max-old-space-size ceiling is per PROCESS, not per build. CIRCLE_NODE_TOTAL=8
means 7 workers, and 7 of them alongside the parent no longer fit the 16 GB /
4 vCPU GitHub-hosted runners the pipeline builds on. It was intermittent for a
while before going 100%, which is what a threshold crossed by ordinary codebase
growth looks like — 7 was also oversubscribing a 4 vCPU runner.

Lower the pool to 3 (2 workers) and make it a build arg, so a big builder can
raise it back with `--build-arg OMNIROUTE_BUILD_WORKERS=8`.

tests/unit/docker-build-memory-budget.test.ts pins the budget: it reads the two
ARG defaults out of the Dockerfile and fails if `parent heap + workers × peak`
outgrows the runner, or if the pool oversubscribes its CPUs. Red on the base
(3/3), green here (3/3). The per-worker peak it budgets with is documented as an
inference from this failure, not a measurement.

DOCKER_GUIDE's build-arg table was stale (it still listed the pre-#10060 4096 MB
default); updated and given the new knob plus the symptom to recognize.
CIRCLE_NODE_TOTAL and OMNIROUTE_BUILD_WORKERS are allowlisted in the
fabricated-docs gate with the reason: neither is read via process.env here — one
is a Dockerfile ARG, the other is read by Next itself.

Note: the real proof is the next publish run. This failure mode only reproduces
on a memory-constrained host, so it cannot be reproduced by the unit suite; the
test guards the arithmetic, not the outcome.

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
2026-08-24 15:47:47 -03:00
Diego Rodrigues de Sa e Souza
bbc7bf4351 fix(authz): match exact public routes exactly, not as prefixes (#11417)
`isPublicApiRoute()` matched every entry of PUBLIC_API_ROUTE_PREFIXES with
`startsWith()`, but 11 of the 15 entries name ONE route, not a subtree. As a
prefix each also marked every adjacent path sharing its leading characters as
PUBLIC, which skips the MANAGEMENT auth gate.

That is reachable today: Next resolves `/api/usage/om-usage<anything>` to the
dynamic route `/api/usage/[connectionId]`, and that handler carries no auth of
its own — it relies entirely on being classified MANAGEMENT. An unauthenticated
caller therefore reaches `fetchAndPersistProviderLimits()`, which is an
existence oracle over connection ids (409/404/400/200) and, for a connection id
actually starting with `om-usage`, discloses live quota JSON and can drive an
OAuth token refresh (a write side effect) with no credentials.

Split the allowlist by shape:

- PUBLIC_API_ROUTE_PREFIXES keeps only genuine subtrees, every entry ending in
  "/" (asserted by a unit test, so the class cannot come back silently).
- PUBLIC_API_ROUTES_EXACT holds the single routes, matched exactly in both
  spellings.
- The three read-only "prefixes" were single routes too and move to
  PUBLIC_READONLY_CORS_API_ROUTES, matched exactly. classify.ts now asks
  `isPublicReadonlyCorsRoute()` instead of scanning the raw list, so the CORS
  origin relaxation pipeline.ts keys on cannot be inherited by a sibling either
  (`/api/monitoring/health-detail` was taking it).
- `/api/health` deliberately stays in its own set so it keeps classifying as
  `public_prefix`; folding it into the read-only set would widen CORS on it.

dashboardCsrf.ts had a second copy of the prefix scan; it now shares
`isPublicApiRoute()` so the client CSRF exemption and the server classification
cannot disagree. Side effect in the safe direction: the three LOCAL_ONLY oauth
auto-import routes were CSRF-exempt on the client while the server already
required the token — the client now attaches it.

Reported by @ntdat812 (GHSA-74g9-q8f6-793h), with the shape of the fix and the
two gotchas above called out in the report.

Closes GHSA-74g9-q8f6-793h

Co-authored-by: Xiangzhe <bakryun0718@proton.me>
Co-authored-by: Nguyen Thanh Dat <ntdat812.dev@gmail.com>
2026-08-24 15:47:34 -03:00
Jacob Stoner
56d64e29a4 fix(dashboard): expose custom mode-pack option (#11407)
Validado em lote combinado (batch-0824f, junto de #11399/#11400/#11402) contra o tip de release/v3.8.50: typecheck:core limpo, file-size/changelog/complexity/cognitive-complexity OK, 56/56 testes focados passando incluindo os deste PR (tests/unit/autocombo-unification.test.ts).

Baixo risco: expõe a opção "custom" já suportada em runtime (`getModePack("custom") === undefined`, cai de volta para os pesos explícitos dos sliders) no seletor compartilhado de mode-pack da UI. Obrigado pela contribuição!
2026-08-24 14:16:37 -03:00
Jacob Stoner
7b36e45df8 fix(dashboard): normalize explicit auto weights (#11402)
Validado em lote combinado (batch-0824f, junto de #11399/#11400/#11407) contra o tip de release/v3.8.50: typecheck:core limpo, file-size/changelog/complexity/cognitive-complexity OK, 56/56 testes focados passando incluindo os deste PR (tests/unit/combo-scoring-inspector.test.ts).

Baixo risco: normaliza pesos parciais/não-unitários no inspector de diagnóstico (`comboScoringInspector.ts`) reutilizando o normalizador já existente do motor real de scoring, mantendo diagnósticos consistentes com o runtime. Obrigado pela contribuição!
2026-08-24 14:16:20 -03:00
Jacob Stoner
ddee064f1b fix(sse): preserve auto scoring order (#11400)
Validado em lote combinado (batch-0824f, junto de #11399/#11402/#11407) contra o tip de release/v3.8.50: typecheck:core limpo, file-size/changelog/complexity/cognitive-complexity OK, 56/56 testes focados passando incluindo os deste PR (tests/unit/combo-task-aware.test.ts).

Remove `auto` da lista de estratégias task-routing genéricas — coerente com o #11399, que também protege a ordem já computada pelo `auto` contra reordenação por outro pós-processamento. Obrigado pela contribuição!
2026-08-24 14:16:04 -03:00
Jacob Stoner
b1fdfd5ea4 fix(sse): preserve auto-selected first target (#11399)
Validado em lote combinado (batch-0824f, junto de #11400/#11402/#11407) contra o tip de release/v3.8.50: typecheck:core limpo, file-size/changelog/complexity/cognitive-complexity OK (abaixo do baseline), 56/56 testes focados passando incluindo os deste PR (tests/unit/8370-priority-affinity-reorder.test.ts).

Aditivo e coerente: protege a ordem já decidida pelo `auto` contra reordenação pelo pós-processamento de prompt-cache-affinity — mesma linha do #11400. Obrigado pela contribuição!
2026-08-24 14:15:52 -03:00
Markus Hartung
71eeaf293c fix(combo): reconcile #11360 retry-loop persisted-cooldown recheck return shape
The retry-loop recheck returned a non-conforming {ok:false, reason} object
that breaks typecheck against the established {ok, response?} contract used
everywhere else in this function. Aligns with the pre-dispatch skip pattern
(return null after fallbackCount++), matching the PR's own intent: skip this
target and move to the next, not error the whole attempt.

This is a live fix — the broken shape reached origin/release/v3.8.50 via
#11360's own squash-merge and was breaking typecheck:core until now.
2026-08-24 12:32:09 -03:00
Markus Hartung
406f4524ff chore(quality): rebaseline file-size for #11355/#11344/#11381/#11362/#11382/#11383 growth
These entries were already validated in an earlier merge-batch worktree but
never reached origin (worktree discarded before pushing). Re-adding them
here since #11355's test/route.ts growth (1215->1237) is now live on
origin/release/v3.8.50 and fails the frozen cap otherwise.
2026-08-24 12:24:40 -03:00
Markus Hartung
dfc5b5eec4 perf(providers): lazy validate provider schema on demand to reduce startup heap 2026-08-24 12:23:50 -03:00
ggdayup
0a53c8a2ce test(providers): update reserved-prefix count fixture to 391 after upstream merge
Upstream 65e81158a added new providers to the registry; the reserved set
is a full REGISTRY walk, so the pinned count moves 329 -> 391. The
tracked-artifacts pre-commit gate fails on this branch because the same
upstream commit force-tracked two docs/superpowers/ files that its own
.gitignore excludes — an inherited upstream issue unrelated to this fix,
so hooks are skipped for this fixture-only commit with operator approval.
2026-08-24 12:22:38 -03:00
ggdayup
93da24cd79 fix(providers): reject reserved provider prefixes on compatible-node create/update
A compatible node created with prefix "tokenrouter" was silently
unreachable: the runtime model resolver (src/sse/services/model.ts)
skips compatible-node lookup for built-in registry ids/aliases, so
"tokenrouter/qwen/..." routed to the built-in tokenrouter provider and
failed with "No active credentials for provider: tokenrouter" even
though the node itself worked when addressed by its internal id.

Reject reserved prefixes at the write path instead:

- new shared module src/shared/constants/reservedProviderPrefixes.ts
  (REGISTRY ids + aliases, case-sensitive, built lazily) — single
  source of truth consumed by both the runtime guard and the
  validation schemas so they can never drift apart
- createProviderNodeSchema / updateProviderNodeSchema now reject
  reserved prefixes with a clear message naming the colliding prefix
- src/sse/services/model.ts consumes the shared module; runtime
  behavior is byte-for-byte unchanged (verified e2e)

Set semantics mirror the old inline guard exactly: manual alias ids
outside REGISTRY (xiaomi/llamacpp/aq) do not intercept nodes at
runtime and stay allowed; mixed-case input (TokenRouter) does not
collide with the exact-match runtime lookup either.
2026-08-24 12:22:38 -03:00
杨思源
815c7c2864 fix(volcengine): exempt volcengine-console from the -web naming convention
Upstream added a lint test requiring every web-cookie provider ID to end
with -web. volcengine-console extracts a console session (not a chat-web
credential), so it is exempted explicitly.
2026-08-24 12:22:00 -03:00
deploy
8f15b79a84 feat(volcengine): phone/SMS auto-login for console with MFA + identity selection
- Session-based headless login service (volcengineConsoleAutoLogin)
- API: POST /connect {phone} + /code /status /cancel /resend /identity sub-routes
- Dashboard modal: phone → SMS code → MFA step-up → identity selection
- Falls back to the legacy headful manual flow on risk-control/TOTP-binding
- Route guard: connect subtree stays LOCAL_ONLY + spawn-capable
2026-08-24 12:22:00 -03:00
yangsiyuan.rengar
07a378c86c feat(volcengine): switch Agent Plan discovery to ListAgentPlanLatestModel 2026-08-24 12:21:59 -03:00
yangsiyuan.rengar
34150506f2 fix(volcengine): retain API-callable Agent Plan models 2026-08-24 12:21:59 -03:00
yangsiyuan.rengar
76ac1c8b7e feat(volcengine): live model discovery for Ark plan providers
Replace the static curated model lists for volcengine-agent-plan and
volcengine-coding-plan with live discovery from the console APIs
(GetAgentPlanModelMappingMeta / ListArkCodeLatestModel), authenticated by
the console cookie+csrf already captured at plan binding time.

- Add volcenginePlanModelDiscovery.ts: fetch + parse + capability enrichment
  (family->contextLength/vision/reasoning map, conservative default fallback).
  Console calls go through a dynamic undici import to bypass OmniRoute's
  global fetch patch (built for LLM provider traffic, reroutes console hits).
  Coding plan's ListArkCodeLatestModel needs {AccountId:<number>} extracted
  from the console cookie; agent plan's GetAgentPlanModelMappingMeta filters
  PlatformAllowStatus===true && Type==='llm'.
- Remove both plan ids from CURATED_MODEL_ONLY_PROVIDERS so synced models
  merge into /v1/models and the dashboard Sync Models button works.
- sync-models route: short-circuit to console discovery for plan providers
  (the chat API has no /models endpoint); persist via
  replaceSyncedAvailableModelsForConnection.
- volcenginePlanBinding: set autoSync:true on new plan connections so the
  24h modelSyncScheduler refreshes them automatically.
- volcPlanAutoSyncBackfill: idempotent boot-time backfill so pre-existing
  plan connections also enter the scheduler.

Verified end-to-end on local OmniRoute build against live Volcano console:
agent plan synced 7 LLMs, coding plan synced 11 models, /v1/models exposes
all of them (incl. new glm-5-3-260801 / deepseek-v4-flash-260801).
2026-08-24 12:21:59 -03:00
yangsiyuan.rengar
d732cf615d feat(volcengine): add Ark plan providers 2026-08-24 12:21:59 -03:00
Yao Lu
f58e8bef6f fix(opencode): close Muse Responses streams at completion 2026-08-24 12:21:51 -03:00
Nicolas Duran Garces
243445f210 docs(changelog): record Codex tool call fix 2026-08-24 12:21:44 -03:00
Nicolas Duran Garces
13e29f2f39 fix(translator): preserve Claude tool call state 2026-08-24 12:21:44 -03:00
Zius
2544ee9498 feat: enable Linux PATH inheritance for autostart & extend loginShellPath to Linux (#11372)
Merged via consolidated batch validation. Fixes autostart on Linux failing to inherit the user's shell PATH (CLI-dependent features like Kiro's Google OAuth broke). Resolved a conflict against a batch sibling in bin/cli/commands/doctor.mjs (kept the more complete prebuilds-aware candidate list) and setup-claude.mjs (formatting only). Own test (login-shell-path-3321.test.ts, 10/10) passes + typecheck:core clean. Thanks!
2026-08-24 12:20:02 -03:00
Prabhudutt Dash
440113c8e8 fix(dashboard): align sync interval slider ticks via magnetic checkpoints (#11394)
Merged via consolidated batch validation. Model Database sync-interval slider used two incompatible coordinate systems (evenly spaced labels vs a linear 1-168h scale); moves the slider to checkpoint-space so the thumb and labels agree. Own test passes.
2026-08-24 12:13:45 -03:00
Bob.Hou
3c2906a80e fix(sse): kill entire process tree on Linux for adobe firefly sign-in to prevent orphan browser instances (#11387)
Merged via consolidated batch validation. Fixes orphaned browser processes on Linux for Adobe Firefly sign-in: spawns Chrome as a process-group leader (detached:true) and kills -pid instead of the single PID, with self-termination guards. Own test passes.
2026-08-24 12:13:40 -03:00
Bob.Hou
095f424658 fix(sse): spare live user message across all aggressive compression sub-paths (#11386)
Merged via consolidated batch validation. Aggressive compression could collapse the live user's active prompt into a [COMPRESSED:summary] marker; now spares the last user message across all sub-paths (applyAging, fallback summarizer, caveman/lite). Own test passes.
2026-08-24 12:13:36 -03:00
Mr White
20de0d9c79 fix(usage): parse CREDIT_LIMIT rows from z.ai coding-plan quota API (#11378)
Merged via consolidated batch validation. Z.ai's quota API now returns CREDIT_LIMIT rows for GLM Coding Plan subscription keys instead of TOKENS_LIMIT, breaking the dashboard quota card. Own test passes.
2026-08-24 12:13:19 -03:00
Nguyen Thanh Dat
9f30b76057 fix(live-ws): resolve the public socket URL at runtime (#11377)
Merged via consolidated batch validation. Fixes live-ws public socket URL resolution for prebuilt Docker/npm images, where NEXT_PUBLIC_* is inlined at build time and can never carry an operator's runtime value. Own test passes.
2026-08-24 12:13:15 -03:00
Nguyen Thanh Dat
019ad33a61 fix(auth): keep the real upstream reason in lastError (#11376)
Merged via consolidated batch validation. markAccountUnavailable collapsed every non-string upstream error reason to a generic 'Provider error' literal, hiding the actual upstream detail operators need in lastError. Own test passes.
2026-08-24 12:13:11 -03:00
Nguyen Thanh Dat
dfc9257b07 fix(cli): spawn npm the way Windows needs in omniroute update (#11374)
Merged via consolidated batch validation. Fixes omniroute update on Windows (npm.cmd cannot be execFile'd without a shell on Node >=24, nodejs/node#52554). Extracts a shared bin/cli/npm-exec.mjs (also handles Bun, windowsHide) mirroring the existing server-side pattern in src/lib/services/installers/utils.ts. Own tests pass. Note: #11336 fixed the same underlying bug (#11335) with a narrower inline change; closed as duplicate crediting this more complete fix.
2026-08-24 12:13:06 -03:00
MSiva
37e71915db fix(translator): preserve functionCall id in Gemini to OpenAI request translation (#11365)
Merged via consolidated batch validation. Fixes geminiToOpenAIRequest discarding functionCall.id in favor of a random generated id, causing multi-turn tool-call id mismatches against OpenAI-compatible upstreams. Own test passes.
2026-08-24 12:13:02 -03:00
Nguyễn Viết Tuấn
077bc1a8a2 fix(compression): use pathToFileURL for workerUrl to prevent bundler resolution failure (#11364)
Merged via consolidated batch validation. Fixes Webpack/Turbopack production build failure (Module not found: compressionWorker.js) by using pathToFileURL(join(...)) instead of new URL(..., import.meta.url), which static bundler scanning misidentifies as an asset import.
2026-08-24 12:12:43 -03:00
sprintberlin
378eff0f75 fix(combo): pre-skip targets with persisted connection cooldown and re-check on retry (#11360)
Merged via consolidated batch validation, with one fix applied during batch validation: the retry-loop persisted-cooldown recheck returned a non-conforming {ok:false, reason} shape that failed typecheck against the established {ok, response?} contract — aligned it with the pre-dispatch skip pattern (return null after fallbackCount++), matching this PR's own intent (skip the target, don't error the whole attempt). Pre-skips combo targets with a persisted connection cooldown and re-checks fresh before transient retries. Own regression suite (13/13, including the fixed retry-recheck path) passes.
2026-08-24 12:12:38 -03:00
Rouzbeh†
6de542b9b6 fix(providers): mark Antigravity connects with no Cloud Code projectId as degraded (#11284) (#11358)
Merged via consolidated batch validation. Production evidence (VPS docker instance): Antigravity OAuth connects ending without a Cloud Code projectId were persisted as silently active while every model call failed; now persisted as degraded. Own tests pass.
2026-08-24 12:12:33 -03:00
sprintberlin
315b0a94e1 fix(resilience): preserve active cooldowns during recovery and probes (#11355)
Merged via consolidated batch validation (fix applied for a cross-PR interaction with #11360, both boarded in the same batch — see combo.ts reconciliation commit). Startup crash recovery cleared every non-terminal transient cooldown unconditionally, erasing legitimate multi-day weekly quota cooldowns on restart. Now only clears expired/unparseable ones. Own repro tests pass.
2026-08-24 12:12:30 -03:00
sprintberlin
e1c2b347f9 fix(quota): parse absolute ISO datetime reset timestamps in weekly quota fallback (#11353)
Merged via consolidated batch validation. Fixes GLM/Z.AI weekly quota fallback: parseDayGranularityResetMs only recognized 'reset in N days', dropping the real multi-day cooldown when upstream returns a full absolute ISO datetime. Own repro test passes.
2026-08-24 12:12:26 -03:00
Paco Cartones
f88aa48847 test(db): make exclusive-connection-lease uniqueness test self-contained (#11341)
Merged via consolidated batch validation. Test-only fix: exclusive-connection-lease uniqueness test implicitly depended on lease state from an earlier test in the same file (shared DB instance, reset only in test.after) — now self-contained. No production change.
2026-08-24 12:12:04 -03:00
Paco Cartones
8301984734 fix(i18n): complete zh-CN/zh-TW CLI locales and guard their parity (#11339)
Merged via consolidated batch validation. Completes 45 missing zh-CN/zh-TW CLI locale keys and adds a parity guard so future gaps fail CI. Own tests pass.
2026-08-24 12:12:00 -03:00
Paco Cartones
028f1b91e4 fix(release): count sweep-stale matches by their real category in the summary (#11338)
Merged via consolidated batch validation. Fixes sweep-stale-fragments.mjs miscounting: classifyFragments never actually produces matchedBy==="ref" (only "pr-number"/"text"), so the pr-number bucket was permanently 0 in the release captain's report. Own test passes.
2026-08-24 12:11:56 -03:00
stanley
2af1326adf fix(catalog): add Stealth Ox Alpha (stealth/ox-alpha) to the openrouter free roster (#11337)
Merged via consolidated batch validation. Data fix so stealth/ox-alpha becomes visible in /v1/models under hidePaidModels (synced-provider-row filter drops pricing metadata before isFreeModel; adds :free suffix handling). Own test passes.
2026-08-24 12:11:52 -03:00
Paco Cartones
644dd32d3f fix(cli): resolve tray runtime import to a file:// URL so --tray works on Windows (#11332)
Merged via consolidated batch validation. Fixes omniroute server --tray on Windows: absolute paths passed to dynamic import() are parsed as URLs, and a Windows drive letter (C:) isn't a supported URL scheme. Resolves via pathToFileURL. Own regression test passes.
2026-08-24 12:11:49 -03:00
Diego Rodrigues de Sa e Souza
9df3f8923d fix(build): stop bundling the better-sqlite3 stub at runtime (#11343) (#11391)
Merged via consolidated batch validation (worktree `.claude/worktrees/batch-0824e`, 27-PR batch). Critical fix: next.config.mjs unconditionally aliased better-sqlite3 to its build-time stub, but Turbopack's resolveAlias applies at RUNTIME too — every request on any build from the release/v3.8.50 tip answered HTTP 500 because the real driver was never loaded. Gates green; own tests pass.
2026-08-24 12:11:36 -03:00
Markus Hartung
0b7ac870ef sync with tip before push 2026-08-24 09:55:31 -03:00
Markus Hartung
9fedc1c411 merge #11381 onto updated tip 2026-08-24 09:50:48 -03:00
Markus Hartung
e589831952 sync with tip before push 2026-08-24 09:46:03 -03:00
Diego Rodrigues de Sa e Souza
04d2a60331 fix(video): make one-frame scene sampling deterministic (#11344)
Merged via consolidated batch validation. Makes scene_aware Video Bridge sampling deterministic for a one-frame budget: falls back to the midpoint of the active full-video/focus window and reports policyEffective: uniform (a single scene candidate can't preserve both temporal ends). Adds opt-in real-FFmpeg fixture matrix (rapid edge cuts, one-frame budget, static/gradual scenes, sub-second clips, detector failure). Static gates green; own regression suite (videoBridgeSampler.test.ts, video-bridge-sampler-ffmpeg.test.ts) passed in the combined-batch run. Related to #9760. Thanks!
2026-08-24 09:44:50 -03:00
Markus Hartung
d23bfefec0 merge #11383 onto updated tip 2026-08-24 09:44:36 -03:00
Markus Hartung
c8ad44e018 merge #11350 onto updated tip 2026-08-24 09:41:55 -03:00
Diego Rodrigues de Sa e Souza
c83116e634 fix(video): isolate drill-down cache by principal (#11369)
Merged via consolidated batch validation (worktree `.claude/worktrees/batch-0824d`). Video Bridge FU-08 drill-down cache substrate hardening (explicitly PARTIAL per the PR body — no production producer/callsite feeds this cache yet): canonical isolation by principalId+sessionId+videoRef, loopback broker auth, strict Zod contracts, per-principal + global LRU quotas, full JPEG decode/re-encode with truncated-scan and polyglot-tail rejection, cancellation-safe atomic replacement. Static gates green; own regression suite (videoBridgeDrilldown.test.ts, video-bridge-drilldown-authz.test.ts, video-bridge-drilldown-route.test.ts) passed in the combined-batch run. Thanks!
2026-08-24 09:39:40 -03:00
Diego Rodrigues de Sa e Souza
7715825cb8 fix(video): harden visual frame deduplication (#11382)
Merged via consolidated batch validation (worktree `.claude/worktrees/batch-0824d`), stacked on the just-merged #11362 as documented. Moves the Video Bridge frame cap to post-dedup, bounds the perceptual candidate pool to at most 2x budget (max 16), includes the dedup policy/version in result-cache identity, adds cooperative abort checks to the comparator loop. Static gates green; own dedup/cache-version regression suite passed in the combined-batch run (grayscale-16x16-mean-cells-v2 policy, real fixtures). Thanks!
2026-08-24 09:31:00 -03:00
Diego Rodrigues de Sa e Souza
761d38f433 fix(video): harden result cache identity and coalescing (#11362)
Merged via consolidated batch validation (worktree `.claude/worktrees/batch-0824d`). Completes the Video Bridge FU-01 cache-hardening slice: fingerprints authorized video bytes + result-affecting dimensions before a persistent cache hit, strict metadata validation with corrupt-entry recompute, TTL/LRU bounds by count/entry-bytes/aggregate-bytes, coalesced protected HTTPS downloads isolated by tenant, deadline/abort-bounded model selection. Static gates green; own regression suite (tests/unit/guardrails/videoBridgeResultCache.test.ts) passed in the combined-batch run. Thanks!
2026-08-24 09:26:06 -03:00
Diego Rodrigues de Sa e Souza
c6963ca5dd fix(changelog): require verified reconciliation ledger (#11345)
Merged via consolidated batch validation (worktree `.claude/worktrees/batch-0824d`). Removes the broad ALLOW_CHANGELOG_REMOVALS bypass from the anti-CHANGELOG-eat gate and requires a reviewed, SHA-256-bound reconciliation ledger for intentional release-note rewrites (fails closed on malformed/stale/partial ledgers, retired bypass usage). Static gates green; own regression suite (tests/unit/check-changelog-integrity.test.ts, tests/unit/merge-train-plan.test.ts) passed in the combined-batch run — 15/15 CLI/ledger cases. Related to #9985. Thanks!
2026-08-24 09:25:40 -03:00
Diego Rodrigues de Sa e Souza
b010d8bf86 docs(readme): reconcile v3.8.50 metrics and contributors (#11356)
Merged via consolidated batch validation (worktree `.claude/worktrees/batch-0824d`). Reconciles README/diagram claims against the live release branch with explicit, non-conflated denominators (merged-PR ranking vs GitHub Contributors REST vs normalized Git census) and adds a repository-local SVG validator. Static gates green; own SVG-validator + render-pipeline tests (tests/unit/docs-validate-svg.test.ts) passed in the combined-batch run, docs:check-all clean per the PR's own evidence. Thanks!
2026-08-24 09:25:29 -03:00
Diego Rodrigues de Sa e Souza
fdcd15e6a9 docs(openapi): document try proxy operation (#11363)
Merged via consolidated batch validation (worktree `.claude/worktrees/batch-0824d`). Restores the OpenAPI operation-coverage ratchet by documenting POST /api/openapi/try (allowlist, verbs, header denylist, auth, response envelope). Static gates green (typecheck:core, file-size, changelog-integrity, complexity, cognitive-complexity); own contract test (tests/unit/openapi-security-tiers.test.ts) passed in the combined-batch run. Thanks!
2026-08-24 09:25:18 -03:00
Diego Rodrigues de Sa e Souza
12b8df02dd fix(catalog): keep large builds event-loop responsive (#11367)
Merged via consolidated batch validation (worktree `.claude/worktrees/batch-0824d`, 11-PR video-bridge/catalog/ops batch, tip `dafb4ae8`). Fixes the #9147 catalog-scale event-loop regression: reuses one build-local capability snapshot, yields cooperatively during catalog/virtual-pool construction, reads only persisted TTL settings. Static gates: typecheck:core, file-size, changelog-integrity, complexity, cognitive-complexity all green. Own regression test (tests/unit/9147-catalog-eventloop-yield.test.ts) reproduced the RED→GREEN transition in isolated runs per the PR's own evidence; under current shared-devbox load (10-15, multiple parallel sessions) the test intermittently reports INFRA-RED exactly as the PR body pre-disclosed (documented starvation signature, not a code defect). Thanks for the careful RED/GREEN + INFRA-RED discipline.
2026-08-24 09:24:59 -03:00
Diego Rodrigues de Sa e Souza
38d21afc2d docs(changelog): link FU-04 pull request 2026-08-24 08:40:33 -03:00
Diego Rodrigues de Sa e Souza
05e76d6e76 docs(changelog): link FU-07 pull request 2026-08-24 08:31:59 -03:00
Diego Rodrigues de Sa e Souza
93135f8e18 feat(guardrails): add focused video analysis mode 2026-08-24 07:30:28 -03:00
Diego Rodrigues de Sa e Souza
22086a73fa fix(video-bridge): validate structural segment sampling 2026-08-24 06:35:58 -03:00
Diego Rodrigues de Sa e Souza
d4ade9d1d3 fix(video): separate tenant scope from download hash 2026-08-24 05:55:00 -03:00
Diego Rodrigues de Sa e Souza
f54c93c879 fix(video): key download flights with process HMAC 2026-08-24 05:16:13 -03:00
Diego Rodrigues de Sa e Souza
e2e48fdab8 docs(changelog): link Video Bridge cache fix PR 2026-08-24 04:37:54 -03:00
Diego Rodrigues de Sa e Souza
d2cea0811a fix(video): harden result cache identity and bounds 2026-08-24 04:31:27 -03:00
Diego Rodrigues de Sa e Souza
2f18a85310 docs(changelog): link Video Bridge contact-sheet fix 2026-08-24 03:35:10 -03:00
Diego Rodrigues de Sa e Souza
38969ad16b fix(video-bridge): render timestamped contact sheets 2026-08-24 03:20:43 -03:00
Diego Rodrigues de Sa e Souza
dafb4ae808 fix(deps): keep unused pnpm peers out of production (#11342)
* fix(deps): keep unused pnpm peers out of production

* docs(changelog): link dependency policy fix to PR 11342
2026-08-24 02:26:02 -03:00
Erick Kinnee
338c05dc6a fix(models): expose Ollama Cloud native effort tiers (#11307)
Validated on a 17-PR combined board: models-catalog-combo-metadata + ollama-cloud-reasoning-effort-tiers-10788 within the board's 287/287, typecheck:core clean, check:open-sse-typecheck clean, vitest 405/405. Publishes Ollama Cloud's native none/low/medium/high/max effort vocabulary for reasoning-capable passthrough/tagged models with no exact registry declaration, adds none to DeepSeek V4/GLM 5.x, and preserves narrower exact-model vocabularies (GPT-OSS) via intersection. Refs #10788. Thank you @ekinnee!
2026-08-24 01:55:47 -03:00
Nguyen Thanh Dat
6945bbaaba fix(db): escape regex metacharacters in group model patterns (#11311)
Validated on a 17-PR combined board: group-model-pattern-regex-escape within the board's 287/287, typecheck:core clean. matchesModelPattern() only substituted * before compiling to RegExp — every other metacharacter kept its regex meaning, so a malformed group pattern (unbalanced parens/brackets) threw uncaught and broke EVERY request for keys in that group, not just the malformed rule (isModelAllowedForKey has no try/catch and runs on the chat completion path and the /v1/models catalog). Thank you @ntdat812!
2026-08-24 01:55:42 -03:00
Ravi Tharuma
690f684bfc feat(audio): add native ElevenLabs HTTP compatibility routes (#11312)
Validated on a 17-PR combined board: elevenlabs-native-routes + hard-session-lease-bypass-inventory (9/9) within the board's 287/287, typecheck:core clean. Native ElevenLabs compatibility routes (voices, TTS, STT) reusing the stored credential via quota-preflight, sent only as xi-api-key; client authorization headers never forwarded. Closes #10556. Thank you @RaviTharuma!
2026-08-24 01:55:36 -03:00
Ravi Tharuma
c3cd1f94c0 feat(services): expose sanitized CLIProxyAPI account health (#11314)
Validated on a 17-PR combined board: cliproxy-accounts + cliproxy-tab + cliproxy-account-health + cliproxy-resolve-spawn-args-6877 (16/16) within the board's 287/287, typecheck:core clean, env-doc-sync clean. Exposes a sanitized read-only CLIProxyAPI account health view (5s-bounded client, explicit allowlist excluding names/paths/emails/tokens/status messages) through a management-authenticated API + dashboard card. Closes #6342. Thank you @RaviTharuma!
2026-08-24 01:55:31 -03:00
Webman
c21460f22a fix(lint): drain release-green hard failures on release/v3.8.50 (#9985) (#11317)
Validated on the resolved merge against the current release tip: pack-artifact-policy + cli-mcp-call-commands + cli-resilience-commands + cli-skills-commands + model-hide-multikey-11300 39/39, typecheck:core clean, eslint clean. Resolved a pt-BR.json wording conflict against #11322 (kept the tip's wording, semantically identical). Drains the real lint-fallout from the wave that was blocking the release-green verdict — dead code + newly-enforced React-Compiler hook rules. Thank you @jonlwheat2-gif!
2026-08-24 01:54:31 -03:00
Ravi Tharuma
9b14896a6c feat(api): add Google AI Studio Gemini TTS (#11315)
Validated on a 17-PR combined board: gemini-tts + vertex-media + audio-speech-handler (41/41) within the board's 287/287, typecheck:core clean. Registers public google/gemini-*-tts speech models and translates OpenAI-compatible /v1/audio/speech to the AI Studio generateContent audio contract, reusing the Vertex inline-audio/PCM/WAV conversion path. Batch TTS only, Gemini Live is out of scope. Thank you @RaviTharuma!
2026-08-24 01:50:44 -03:00
Ravi Tharuma
29f26293c3 feat(compression): isolate sync engines in bounded worker pool (#11318)
Validated on a 17-PR combined board: compression-worker + colocate-standalone-esm-scope within the board's 287/287, typecheck:core clean, env-doc-sync clean. Offloads eligible sync compression engines into a bounded worker_threads pool with a strict serializable DTO boundary and fail-open on spawn/worker/timeout failure. Closes #11023. Thank you @RaviTharuma!
2026-08-24 01:50:39 -03:00
Nguyen Thanh Dat
cb11592441 fix(db): judge the proxy URL host by address, not by spelling (#11319)
Validated on a 17-PR combined board: upstream-proxy-host-spelling 8/8 within the board's 287/287, typecheck:core clean. Routes src/lib/db/upstreamProxy.ts through the shared outbound-guard helpers instead of a private dotted-quad regex copy that had drifted since #10843 — closes the IPv4-mapped IPv6, ULA, link-local and CGNAT bypasses while preserving the deliberate loopback allow (CLIProxyAPI on localhost:8317). Multicast widened from /224\. to the full 224.0.0.0/4, called out explicitly. Thank you @ntdat812!
2026-08-24 01:50:35 -03:00
Ravi Tharuma
5ee646e68e fix(github): verify access tokens during health checks (#11320)
Validated on a 17-PR combined board: token-health-check + token-health-no-refresh-token-expired-5326 + token-refresh-service within the board's 287/287, typecheck:core clean. GitHub access-token-only connections are now actively verified on each due health interval (via the existing Copilot token exchange); the parent credential is marked expired only on a confirmed 401, never on 403/429/5xx/network failures; response bodies and transport messages no longer enter token-refresh logs. Closes #10352. Thank you @RaviTharuma!
2026-08-24 01:50:30 -03:00
Paco Cartones
6984676d95 fix(quality): report the real failure line and stop double-counting ci.yml gates (#11321)
Validated on a 17-PR combined board: validate-release-green within the board's 287/287, typecheck:core clean. Two accuracy bugs in the release-green verdict tool: an unanchored regex blamed a passing test line (matching a filename containing 'fail'), and 6 gates were double-recorded as both hard-failure and drift due to an id-format mismatch (ci.yml script name vs curated id). Found while reading the #9985 verdict — good catch.
2026-08-24 01:50:26 -03:00
Paco Cartones
79f8ae9d1e fix(i18n): add the 3 pt-BR CLI keys that break the locale parity test (#11322)
Validated on a 17-PR combined board: typecheck:core clean, gates within baseline. Restores 3 missing pt-BR CLI keys (setup.opencode, serve.tls_cert, serve.tls_key) — parity restored, 823/823. Thank you @pacocartones!
2026-08-24 01:49:52 -03:00
Nguyen Thanh Dat
04b2c47940 fix(i18n): restore three placeholders dropped from the pt catalogue (#11325)
Validated on a 17-PR combined board: i18n-placeholder-parity within the board's 287/287, typecheck:core clean. Restores 3 dropped placeholders in pt.json (the visible one: the cache tile's subtitle was repeating its own label instead of showing the total) and adds a 42-locale placeholder-set gate so this class of drift can't recur silently. Thank you @ntdat812!
2026-08-24 01:49:48 -03:00
Paco Cartones
24ac71465e test(db): make singleton reset survive the full suite and un-skip the 3 DB-state tests (#11327)
Validated on a 17-PR combined board: capture-critical-db-state 7/7 (all three previously-skipped tests now run) within the board's 287/287, typecheck:core clean. Fixes the racy DATA_DIR-after-dynamic-import isolation and removes a duplicate type declaration. Thank you @pacocartones!
2026-08-24 01:49:42 -03:00
Nguyen Thanh Dat
8d6f91b558 fix(security): refuse proxy-authorization and proxy-authenticate upstream (#11328)
Validated on a 17-PR combined board: upstream-headers-proxy-auth within the board's 287/287, typecheck:core clean, gates within baseline. proxy-authorization and proxy-authenticate join the FORBIDDEN denylist — forwarding proxy-authorization to a model provider would hand that provider the operator's own proxy credential. Thank you @ntdat812!
2026-08-24 01:49:37 -03:00
Diego Rodrigues de Sa e Souza
c3698eedcb fix(dashboard): route the Adapta tutorial CTA through the branded shortener (#11329)
Validated on a 17-PR combined board: TSX parses clean, eslint clean. Adapta tutorial CTA href now points at the branded shortener (link.omniroute.online/adapta) while keeping the visible link text as the real domain. Completes #11196's shortener rollout.
2026-08-24 01:49:32 -03:00
Diego Rodrigues de Sa e Souza
adca3b881c fix(kie): map remaining google-imagen Market ids to their real KIE upstream ids (#11326)
Merging --admin with red discrimination (merge-gates §4). Fails: ESLint warnings ratchet drift (inherited base-red), Unit Tests shards containing stream-timing.test.ts (CPU-contention timing flake, assert.ok(total >= 15)ms — unrelated to this PR's scope, open-sse/handlers/imageGeneration.ts), and dast-smoke (advisory, isRequired:null).
2026-08-24 01:10:23 -03:00
Diego Rodrigues de Sa e Souza
ac02c5b42f fix(resilience): don't clear an active rate-limit cooldown for non-quota_exhausted errors (#11277) (#11310)
Merging --admin: only fails are ESLint warnings ratchet drift (inherited base-red) and dast-smoke (advisory, isRequired:null). Zero overlap with this PR's scope (src/lib/usage/providerLimits.ts).
2026-08-23 22:55:33 -03:00
Diego Rodrigues de Sa e Souza
07d1816a45 fix(providers): hidden models leak into GET /v1/models (#11300) (#11309)
Merging --admin: only fails are ESLint warnings ratchet drift (inherited) and dast-smoke (advisory, isRequired:null). Zero overlap with this PR's file scope (src/app/api/v1/models/catalog.ts).
2026-08-23 22:35:29 -03:00
Praveen K Palaniswamy
65e81158ab fix(ollama): route models by advertised capability (#11088)
Landed with the design call resolved per the owner's pick — **option 1**: the synced store is now endpoint-agnostic (persistDiscoveredModels and managedModelImport no longer drop non-chat models at write time), and chat selectability moved to read time (auto-pool expansion in autoStrategy applies filterChatSelectableModels; the models-route projection already had its chatOnly filter). Your discovery test now passes end-to-end (3/3): /api/show capabilities persist per connection and image/embedding requests route through the advertising host.

Reconciliation notes: conflicted areas merged onto the current tip (adobe discovery import, requestedModel preflight signature, resolvedProvider fast-path coexists with the synced-route override — explicit resolution wins); carried base-red drains (#10055 memoization, #11071 test variants) dropped as already-landed; the managed-model-import exclusion test was propagated to the new contract (image/video models persist; the read filter still hides them from chat pickers — pinned by a new assertion). Full battery: 205/206 focused (the one red is a confirmed periodic-timer timing flake on the loaded devbox — 20/20 isolated), autoCombo vitest 30/30, combo suites 46/46, gates + typecheck clean.

Thank you @yourspraveen — the capability probe + routing design was right; it just needed the store contract opened up. Fixes #11087.
2026-08-23 11:45:01 -03:00
Praveen K Palaniswamy
c68cda7dfb fix(resilience): honor shared passthrough providers (#11075)
5 — Fornecedores locais compartilhados (ollama-local, LM Studio, vLLM) declaram passthroughModels:true no registry, mas hasPerModelQuota() não consultava o registry compartilhado — fallha de modelo faltante virava cooldown de conexão inteira. Broadens a classificação de model-lockout. TDD + 78/291 testes + typecheck + lint verdes. Fecha #11071.
2026-08-21 22:06:21 -03:00
Ravi Tharuma
ca23eed77c fix(models): memoize getModelsDevPricing (event loop / healthz) (#10055)
* fix(models): memoize getModelsDevPricing for /v1/models catalog

resolveCatalogPricing called getModelsDevPricing once per model while
building GET /v1/models. Each call re-scanned models_dev_pricing and
JSON.parsed every row (~10k SQL scans + multi-GB parse work), pegging
the event loop so even /healthz timed out (#9685, #10052).

Memoize the parsed map until saveModelsDevPricing / clearModelsDevPricing
and add a unit test for invalidation.

Signed-off-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>

* fix(db): invalidate modelsDevPricing cache on DB reset (#10055)

Copilot review fixes:
1. Register invalidateModelsDevPricingCache() with DB state reset system
   so resetDbInstance() clears the process-local memo, preventing stale
   pricing data from surviving across DB reset/restore operations.
2. Add test assertion verifying DB reset bypasses the memo (Copilot #10055).

The process-local memo at modelsDevSync.ts:204 caches getModelsDevPricing()
results until saveModelsDevPricing()/clearModelsDevPricing() to avoid
re-scanning all pricing rows on every /v1/models request. Without this hook,
backup restore and test DB resets would serve stale cached data from the
previous connection.

Tests: npm run test:unit:serial -- tests/unit/modelsDevSync-extended.test.ts

---------

Signed-off-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Ravi Tharuma <RaviTharuma@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
2026-08-13 00:45:27 -03:00
ritheshcn25
5f0a394091 Hide health-check excluded models from /v1/models catalog (#10026)
Mirror the request-time exclusion rule (provider_specific_data.excludedModels)
in the unified catalog builder: a model is hidden when its provider has
connections but none of them is eligible for it. Applied across the
PROVIDER_MODELS, synced, custom, alias-backed, and managed-fallback loops
so ghost models no longer appear as available.

Co-authored-by: ritheshcn25 <ritheshcn25@users.noreply.github.com>
2026-08-13 00:43:38 -03:00
diegosouzapw
918fba5e39 fix(repo): harden .gitignore to also ignore a _tasks symlink (/_tasks)
_tasks is a SEPARATE nested git repo (gitignored). The pattern _tasks/ (trailing
slash) ignores only a directory, not a SYMLINK named _tasks. A self-referential
_tasks symlink can slip in via git add -A and, once pulled, checkout materializes
it over the real _tasks repo (destroying plans/specs/hands-off). Anchored /_tasks
ignores the symlink too, preventing re-capture.
2026-08-08 01:18:49 -03:00
Diego Rodrigues de Sa e Souza
026e1cadaa fix(deps): bump nanoid, dompurify for Dependabot #189, #190
Closes Dependabot #189 (dompurify 3.4.13) and #190 (nanoid 3.3.17). npm audit → 0.
2026-08-08 00:10:31 -03:00
diegosouzapw
b090b601a5 fix(deps): bump nanoid, dompurify for 2 new Dependabot alerts (#189, #190)
Bumps: nanoid ^3.3.17 (was transitive, now overridden), dompurify ^3.4.13
(with monaco-editor scoped override). Closes Dependabot #189, #190.

Remaining #182-#188 (js-yaml + mermaid) already closed by #9651 merge —
awaiting Dependabot re-scan.

npm audit → 0 vulnerabilities.
2026-08-08 00:08:57 -03:00
81 changed files with 251 additions and 1725 deletions

View File

@@ -16,18 +16,6 @@
---
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -1,37 +0,0 @@
import { sep } from "node:path";
/**
* A `.env` inside the installed package directory does not survive an update:
* `npm i -g` replaces that directory wholesale, and postinstall recreates the
* file from `.env.example`. The CLI announces every env file it loads without
* distinguishing the ones that last from the one that doesn't.
*
* Returns the warning to print, or null when there is nothing worth saying.
*
* Two conditions, both required, so a development checkout never sees this:
* - the file sits inside the package root, and that root is inside a
* `node_modules` directory — i.e. an installed package, not a checkout,
* where the same path is stable and documented in SETUP_GUIDE.md;
* - the file actually supplied at least one value. First writer wins, so a
* file entirely shadowed by a durable one supplied nothing, and losing it
* costs nothing.
*
* @param {{ envPath: string, packageRoot: string, durableEnvPath: string, suppliedKeys: boolean }} args
* @returns {string | null}
*/
export function describeVolatileEnvWarning({ envPath, packageRoot, durableEnvPath, suppliedKeys }) {
if (!suppliedKeys) return null;
if (envPath === durableEnvPath) return null;
if (!isInsideInstalledPackage(packageRoot)) return null;
if (!envPath.startsWith(packageRoot + sep)) return null;
return (
`${envPath} lives inside the installed package: updating OmniRoute replaces it. ` +
`Move the values you set to ${durableEnvPath}, which updates leave alone.`
);
}
/** True when the path sits under a `node_modules` directory. */
function isInsideInstalledPackage(dir) {
return typeof dir === "string" && dir.split(sep).includes("node_modules");
}

View File

@@ -29,7 +29,6 @@ import { getDefaultDataDir } from "./cli/data-dir.mjs";
import { shouldProvisionStorageKey } from "./cli/utils/storageKeyProvision.mjs";
import { isVersionFastPath } from "./cli/utils/versionFastPath.mjs";
import { parseEnvValue } from "./cli/utils/parseEnvValue.mjs";
import { describeVolatileEnvWarning } from "./cli/utils/volatileEnvPath.mjs";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
@@ -92,7 +91,9 @@ function migrateElectronServerEnv(dataDir) {
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`);
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.
}
@@ -163,21 +164,6 @@ function loadEnvFile() {
const setter = winner ? winner : "the environment";
console.warn(` \x1b[33m⚠ ${key} in ${loser} is ignored, ${setter} set it first\x1b[0m`);
}
// The package directory is replaced by the next `npm i -g`, so a .env kept
// there is silently lost. Say so once, and only when that file actually
// supplied something.
const durableEnvPath = join(process.env.DATA_DIR || getDefaultDataDir(), ".env");
const suppliedKeys = [...keyOrigin.values()].some((origin) => origin === join(ROOT, ".env"));
const volatileWarning = describeVolatileEnvWarning({
envPath: join(ROOT, ".env"),
packageRoot: ROOT,
durableEnvPath,
suppliedKeys,
});
if (volatileWarning && loadedEnvPaths.includes(join(ROOT, ".env"))) {
console.warn(` \x1b[33m⚠ ${volatileWarning}\x1b[0m`);
}
}
loadEnvFile();
@@ -261,16 +247,16 @@ if (shouldProvisionStorageKey(process.argv)) {
const langEnv = process.env.OMNIROUTE_LANG;
const chosen = langArg || langEnv;
if (chosen) {
const { setLocale } = await import(pathToFileURL(join(ROOT, "bin", "cli", "i18n.mjs")).href);
const { setLocale } = await import(
pathToFileURL(join(ROOT, "bin", "cli", "i18n.mjs")).href
);
setLocale(chosen);
}
}
// Register update notifier — checks npm once per 24h, notifies on exit via stderr.
const _pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8"));
const _notifier = updateNotifier
? updateNotifier({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 })
: null;
const _notifier = updateNotifier ? updateNotifier({ pkg: _pkg, updateCheckInterval: 1000 * 60 * 60 * 24 }) : null;
process.on("exit", () => {
if (!_notifier || !_notifier.update) return;
if (process.env.OMNIROUTE_NO_UPDATE_NOTIFIER) return;
@@ -279,15 +265,7 @@ process.on("exit", () => {
const outputIdx = process.argv.indexOf("--output");
const outputVal = outputIdx >= 0 ? process.argv[outputIdx + 1] : null;
if (outputVal === "json" || outputVal === "jsonl" || outputVal === "csv") return;
if (
process.argv.some(
(a) =>
a.startsWith("--output=json") ||
a.startsWith("--output=jsonl") ||
a.startsWith("--output=csv")
)
)
return;
if (process.argv.some((a) => a.startsWith("--output=json") || a.startsWith("--output=jsonl") || a.startsWith("--output=csv"))) return;
if (_notifier.update) {
_notifier.notify({
defer: false,

View File

@@ -1 +0,0 @@
- **fix(radar):** the catalog feed cache now keeps `generatedAt`, the date the feed's data was built, next to `fetchedAt`, the date this install downloaded it (#11435). The feed schema requires that date and the sync path validates it, but the cache dropped it — so a feed fetched minutes ago and one carrying weeks-old figures looked identical to everything downstream, including the dashboard's "Last fetched" line. `getRadarCatalog().meta` and `GET /api/radar/status` now report both dates, the latter as its own field rather than folded into `version` — and omitted entirely for the offers and intel caches, which keep no build date, where a `null` would read as "unknown" rather than "never stored". The dashboard still shows only the fetch time; surfacing the build date there needs a new translated label and is left to a follow-up. Rows cached before migration 163 read back as `null`: unknown stays unknown instead of borrowing the fetch time. The referrals cache has persisted the same date since migration 142.

View File

@@ -1 +0,0 @@
- **fix(cli):** postinstall no longer fills `JWT_SECRET` and `API_KEY_SECRET` in the installed package's `.env` (#11436). `.env.example` ships both blank on purpose: the server restores them from its durable store, or generates and persists them there, in `ensureSecrets()`. Pre-filling them defeated that — the file lives inside the package directory, so `npm i -g` replaced it and postinstall wrote _different_ values, while `ensureSecrets()` (which only acts on an empty variable) never got to restore the real ones. Both secrets rotated silently on every update, invalidating dashboard sessions and API-key CRCs. `STORAGE_ENCRYPTION_KEY` left the same list for the same reason in #1622; its comment pointed at a function that no longer exists and now names the real provisioning path.

View File

@@ -1 +0,0 @@
- **fix(cli):** the CLI now says when a loaded `.env` lives inside the installed package directory (#11437). It already announces every env file it reads, without distinguishing the ones that survive an update from the one that does not: `npm i -g` replaces the package directory wholesale, so values set there are gone at the next update, silently. The warning names the durable path to move them to, and fires only when that file actually supplied a value — a file entirely shadowed by a durable one supplied nothing. A development checkout stays silent: there the same path is stable and documented in `SETUP_GUIDE.md`.

View File

@@ -283,32 +283,6 @@ currently cached version (`compareVersions()`, dotted `YYYY.MM.DD.n` comparison)
`{ status: "stale" }`. This prevents a compromised or misconfigured feed endpoint from
rolling a client back to an older, differently-signed payload.
### Two dates, and why both are kept
A cached feed carries two distinct dates, and confusing them is the whole point of
keeping both:
| Field | Comes from | Answers |
| ------------- | -------------------- | ----------------------------------- |
| `generatedAt` | the signed feed body | how old the **data** is |
| `fetchedAt` | this install's clock | when this install **downloaded** it |
A feed fetched minutes ago can carry weeks-old figures, so `fetchedAt` alone cannot
tell an operator whether the overlay is fresher than the baseline it sits on. Both are
persisted in `radar_feed_cache`, returned by `getRadarCatalog().meta`, and reported
separately by `GET /api/radar/status`. A row cached before the `generated_at` column
existed (migration 163) reads back as `null` — unknown stays unknown rather than
borrowing the fetch time. `radar_referrals_cache` has kept its own `generated_at` since
migration 142.
The version floor above compares `version`, not either date.
Two gaps remain, both deliberate: the dashboard still shows only `Last fetched`, so reading
the build date there needs a new label (and its 42 locale entries); and the offers and intel
caches keep no build date at all, even though their feed schemas carry one — `GET
/api/radar/status` therefore omits the field for those two rather than reporting a `null`
that would read as "unknown".
### Schema validation
The downloaded bytes are parsed and validated against `RadarFeedSchema`

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -8,19 +8,6 @@
---
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -6,19 +6,6 @@
## [3.8.31] — 2026-06-20
## [3.8.51] — TBD
_Living section — cycle opened at the v3.8.50 freeze (parallel-cycle model). Bullets are aggregated from `changelog.d/` fragments at each `/generate-release` phase._
### ✨ New Features
### 🐛 Bug Fixes
### 📝 Maintenance
---
## [3.8.50] — TBD
_Living section — regenerated 2026-08-12 from all cycle commits (cycle open `ed2db6cb19` → tip). Bullets carry the merged PR and its author; direct pushes listed separately._

View File

@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: OmniRoute API
version: 3.8.51
version: 3.8.50
description: |
OmniRoute is a local-first AI API proxy router. It provides an OpenAI-compatible
endpoint that routes requests to multiple AI providers with load balancing,

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute-desktop",
"version": "3.8.51",
"version": "3.8.50",
"description": "OmniRoute Desktop Application",
"main": "main.js",
"author": {

View File

@@ -26,24 +26,12 @@ import { attachLogMeta } from "./cacheUsageMeta.ts";
* (see src/lib/db/responsesContinuationStore.ts). Only meaningful when the
* client actually used the Responses endpoint -- a Chat Completions
* `chatcmpl-*` id must never be mistaken for a Responses response id.
*
* A non-streaming clientResponse carries `id` directly. A streaming one goes
* through clientPayloadCollector.build(), which always nests the caller's
* summary under `.summary` (see createStructuredSSECollector in
* streamPayloadCollector.ts) -- check both shapes rather than assuming one.
*/
export function extractResponsesId(sourceFormat: unknown, clientResponse: unknown): string | null {
function extractResponsesId(sourceFormat: unknown, clientResponse: unknown): string | null {
if (sourceFormat !== FORMATS.OPENAI_RESPONSES) return null;
if (!clientResponse || typeof clientResponse !== "object") return null;
const record = clientResponse as { id?: unknown; summary?: unknown };
const directId = record.id;
if (typeof directId === "string" && directId.length > 0) return directId;
const summary = record.summary;
if (summary && typeof summary === "object") {
const summaryId = (summary as { id?: unknown }).id;
if (typeof summaryId === "string" && summaryId.length > 0) return summaryId;
}
return null;
const id = (clientResponse as { id?: unknown }).id;
return typeof id === "string" && id.length > 0 ? id : null;
}
export type PersistAttemptLogsArgs = {

View File

@@ -1,6 +1,6 @@
{
"name": "@omniroute/open-sse",
"version": "3.8.51",
"version": "3.8.50",
"description": "OmniRoute streaming engine — handles provider dispatch, protocol translation, and SSE streaming",
"type": "module",
"private": true

View File

@@ -18,6 +18,7 @@ import {
TokenExtractionConfig,
type TokenSource,
} from "./tokenExtractionConfig";
import { matchesCookieDomain } from "../utils/cookieDomain";
// ─── Types ──────────────────────────────────────────────────────────────────
@@ -196,9 +197,14 @@ export class InAppLoginService extends EventEmitter {
for (const source of tokenSources) {
if (source.type === "cookie") {
const domain = source.domain || undefined;
// Exact host or dot-boundary suffix, never `includes()`: a cookie
// from `<domain>.attacker.tld` would otherwise be captured and
// persisted as the operator's credential. Same class CodeQL flagged
// in volcengineConsoleAutoLogin (#860/#861); this callsite was not
// flagged because the expected domain is config-supplied.
const matched = cookies.find(
(c: any) =>
c.name === source.name && (!domain || c.domain.includes(domain.replace(/^\./, "")))
c.name === source.name && (!domain || matchesCookieDomain(c.domain, domain))
);
if (matched && !credentials[source.name]) {
credentials[source.name] = matched.value;

View File

@@ -28,6 +28,7 @@
*/
import { randomUUID } from "crypto";
import { matchesCookieDomain } from "../utils/cookieDomain";
// ─── Public types ───────────────────────────────────────────────────────────
@@ -96,13 +97,6 @@ const ARK_CONSOLE_URL =
/** Cookie names required for a valid console session (mirrors tokenExtractionConfig) */
const REQUIRED_COOKIES = ["digest", "AccountID", "csrfToken", "userInfo"] as const;
/** Exact-domain match for session cookies — substring checks would also accept
* look-alike hosts (e.g. `volcengine.com.evil.test`). Playwright may report the
* domain with or without a leading dot. */
function isVolcengineCookieDomain(domain: string): boolean {
return domain === "volcengine.com" || domain.endsWith(".volcengine.com");
}
const DEFAULT_SESSION_TIMEOUT = 300_000;
const SUBMIT_COOKIE_TIMEOUT = 90_000;
const CAPTURE_POLL_INTERVAL = 1_000;
@@ -238,6 +232,21 @@ export function normalizePhone(raw: string): string | null {
return /^1\d{10}$/.test(bare) ? bare : null;
}
/**
* Whether a cookie's `domain` belongs to the Volcengine console.
*
* Cookie domains must be matched by exact host or dot-boundary suffix, never by
* substring: `domain.includes("volcengine.com")` also accepted
* `volcengine.com.attacker.tld` and `notvolcengine.com`, so a cookie named
* `digest`/`AccountID`/`csrfToken`/`userInfo` set by a look-alike host was
* harvested as an operator credential and persisted as a provider connection
* (CodeQL js/incomplete-url-substring-sanitization #860/#861). Mirrors
* `isAdobeCookieDomain` in adobeFireflyBrowserLogin.ts.
*/
export function isVolcengineCookieDomain(domain: string | undefined): boolean {
return matchesCookieDomain(domain, "volcengine.com");
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View File

@@ -0,0 +1,34 @@
/**
* Cookie-domain matching for browser-driven credential capture.
*
* Every in-app / console login flow harvests cookies out of a Playwright
* context and persists them as operator credentials, so "is this cookie from
* the site I sent the browser to?" is an authorization decision. A substring
* test is not one: `domain.includes("example.com")` also accepts
* `example.com.attacker.tld` and `notexample.com`, which lets a look-alike host
* hand us cookies we then store as the operator's real credentials
* (CodeQL js/incomplete-url-substring-sanitization).
*
* A cookie domain is matched by exact host or dot-boundary suffix — nothing
* else. Leading dots (the RFC 6265 "domain-matches any subdomain" spelling) and
* case are normalized away on both sides.
*/
export function matchesCookieDomain(
cookieDomain: string | undefined,
expectedDomain: string | undefined
): boolean {
const expected = normalizeCookieDomain(expectedDomain);
if (!expected) return false;
const actual = normalizeCookieDomain(cookieDomain);
if (!actual) return false;
return actual === expected || actual.endsWith(`.${expected}`);
}
function normalizeCookieDomain(domain: string | undefined): string {
return String(domain || "")
.trim()
.replace(/^\.+/, "")
.toLowerCase();
}

View File

@@ -1657,21 +1657,6 @@ export function createSSEStream(options: StreamOptions = {}) {
output = `data: ${JSON.stringify(parsed)}\n\n`;
injectedUsage = true;
}
// Passthrough mode never pushes a Responses SSE event into
// clientPayloadCollector on the common (non-tool-call, non-
// commentary) path -- only the textual-tool-call conversion
// branch above pushes its own synthesized events. Push just
// the fully-processed terminal `response.completed` (after
// the backfill/strip/tool-call-merge above, so it matches
// exactly what the client receives): that alone is enough
// for buildStreamSummaryFromEvents' reducer to recover a
// real Responses `id` + `output` for previous_response_id
// continuation storage (src/lib/db/responsesContinuationStore.ts).
// Pushing every delta here would double-count events the
// tool-call branch already pushes its own synthesized copy of.
if (parsed.type === "response.completed") {
clientPayloadCollector.push(parsed);
}
} else if (isClaudeSSE) {
// Claude SSE: extract usage, track content, forward as-is
const thinkingSignatureInjected = injectThinkingSignature(parsed, provider);
@@ -2604,24 +2589,9 @@ export function createSSEStream(options: StreamOptions = {}) {
: { object: "chat.completion", ...responseBody },
{ includeEvents: false }
),
// Same OPENAI_RESPONSES carve-out as providerPayload above, but keyed on
// clientResponseFormat (what the client actually receives) rather than
// sourceFormat (what the upstream sent) -- they're equal in passthrough
// mode but conceptually distinct. Without this, `entry.responseId` in
// src/lib/usage/callLogs.ts is always null for a Responses-API client
// (extractResponsesId reads `clientResponse.id`, which the chat-shaped
// responseBody never has), so previous_response_id continuation lookups
// in src/lib/db/responsesContinuationStore.ts always miss.
clientPayload: clientPayloadCollector.build(
clientResponseFormat === FORMATS.OPENAI_RESPONSES
? buildStreamSummaryFromEvents(
clientPayloadCollector.getEvents(),
clientResponseFormat,
model
)
: responseBody,
{ includeEvents: false }
),
clientPayload: clientPayloadCollector.build(responseBody, {
includeEvents: false,
}),
});
} catch (e) {
console.debug(`[STREAM] onComplete callback error (${model || "unknown"}):`, e);
@@ -2923,24 +2893,9 @@ export function createSSEStream(options: StreamOptions = {}) {
: { object: "chat.completion", ...responseBody },
{ includeEvents: false }
),
// Same OPENAI_RESPONSES carve-out as providerPayload above and the
// passthrough branch's onComplete, but keyed on sourceFormat (what the
// client requested/receives in translate mode) rather than targetFormat
// (what the upstream provider speaks) -- translateResponse(targetFormat,
// sourceFormat, ...) above confirms that direction. emitTranslatedClientItem
// already pushes every client-visible translated item into
// clientPayloadCollector unconditionally, so the events are already there;
// this only fixes what gets built from them.
clientPayload: clientPayloadCollector.build(
sourceFormat === FORMATS.OPENAI_RESPONSES
? buildStreamSummaryFromEvents(
clientPayloadCollector.getEvents(),
sourceFormat,
model
)
: responseBody,
{ includeEvents: false }
),
clientPayload: clientPayloadCollector.build(responseBody, {
includeEvents: false,
}),
});
} catch (e) {
console.debug(

View File

@@ -81,7 +81,7 @@ function inferFormatFromEvents(
if (normalizedFallback) return normalizedFallback;
for (const evt of events) {
const payload = unwrapEventEnvelope(evt.data);
const payload = asRecord(evt.data);
const eventType = toString(payload.type || evt.event);
if (eventType.startsWith("response.") || payload.object === "response") {
@@ -761,27 +761,9 @@ function createSummaryReducer(
}
}
// A pushed payload is either the bare provider/passthrough event (what
// providerPayloadCollector always receives), or a `{event, data}` SSE
// envelope (what emitTranslatedClientItem pushes for every translate-mode
// client item, since formatSSE needs the `event:` line name separate from
// the `data:` payload) -- unwrap the latter so every reducer's ingest() sees
// the real payload's own `.type`/`.choices`/etc. either way. Without this,
// a client-facing summary built from translate-mode events (clientPayload
// when sourceFormat is Responses/Claude/Gemini) never found a real `type`
// field, since it was always one level too shallow.
function unwrapEventEnvelope(payload: unknown): JsonRecord {
const record = asRecord(payload);
const inner = record.data;
if (typeof record.event === "string" && inner && typeof inner === "object") {
return asRecord(inner);
}
return record;
}
function buildOpenAISummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
const reducer = createOpenAIReducer(fallbackModel);
for (const evt of events) reducer.ingest(unwrapEventEnvelope(evt.data));
for (const evt of events) reducer.ingest(asRecord(evt.data));
return reducer.finalize();
}
@@ -790,19 +772,19 @@ function buildResponsesSummary(
fallbackModel?: string | null
): unknown {
const reducer = createResponsesReducer(fallbackModel);
for (const evt of events) reducer.ingest(unwrapEventEnvelope(evt.data));
for (const evt of events) reducer.ingest(asRecord(evt.data));
return reducer.finalize();
}
function buildClaudeSummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
const reducer = createClaudeReducer(fallbackModel);
for (const evt of events) reducer.ingest(unwrapEventEnvelope(evt.data));
for (const evt of events) reducer.ingest(asRecord(evt.data));
return reducer.finalize();
}
function buildGeminiSummary(events: StructuredSSEEvent[], fallbackModel?: string | null): unknown {
const reducer = createGeminiReducer(fallbackModel);
for (const evt of events) reducer.ingest(unwrapEventEnvelope(evt.data));
for (const evt of events) reducer.ingest(asRecord(evt.data));
return reducer.finalize();
}
@@ -872,7 +854,7 @@ export function createStructuredSSECollector(options: CollectorOptions = {}) {
if (payload === null || payload === undefined) return;
const clonedData = cloneLogPayload(payload);
reducer?.ingest(unwrapEventEnvelope(clonedData));
reducer?.ingest(asRecord(clonedData));
const event: StructuredSSEEvent = {
index: events.length + droppedEvents,

8
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "omniroute",
"version": "3.8.51",
"version": "3.8.50",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "omniroute",
"version": "3.8.51",
"version": "3.8.50",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -25590,7 +25590,7 @@
},
"node_modules/libxmljs2/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.4.tgz",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"dev": true,
"license": "MIT",
@@ -38178,7 +38178,7 @@
},
"open-sse": {
"name": "@omniroute/open-sse",
"version": "3.8.51"
"version": "3.8.50"
},
"packages/browser-pool": {
"name": "@omniroute/browser-pool",

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "3.8.51",
"version": "3.8.50",
"description": "Unified AI router with 350 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.",
"type": "module",
"bin": {

View File

@@ -37,23 +37,13 @@ function resolveRootDir(rootDir) {
}
}
// Secrets this file may fill in when `.env.example` ships them blank.
//
// JWT_SECRET, API_KEY_SECRET and STORAGE_ENCRYPTION_KEY are deliberately NOT
// here: the server owns them. It restores each one from its durable store, or
// generates and persists it there on first use — STORAGE_ENCRYPTION_KEY in
// bin/omniroute.mjs (guarded by bin/cli/utils/storageKeyProvision.mjs), the
// other two in src/instrumentation-node.ts::ensureSecrets(), which persists to
// the `secrets` namespace of the database under DATA_DIR.
//
// Filling any of them here defeats that: this file lives inside the installed
// package, so `npm i -g` replaces it and postinstall writes a *different*
// value, while ensureSecrets() — which only acts on an empty variable — never
// gets to restore the real one. The secret then rotates silently on every
// update, invalidating dashboard sessions (JWT_SECRET) and API-key CRCs
// (API_KEY_SECRET). STORAGE_ENCRYPTION_KEY was pulled out first, for the same
// reason, when it cost users their encrypted credentials (issue #1622).
const CRYPTO_SECRETS = {
JWT_SECRET: () => randomBytes(64).toString("hex"),
API_KEY_SECRET: () => randomBytes(32).toString("hex"),
// STORAGE_ENCRYPTION_KEY: Generated at server startup instead of postinstall.
// Generated in bin/omniroute.mjs:ensureStorageEncryptionKey() and persisted to
// ~/.omniroute/.env to survive across upgrades. This prevents credential loss
// when upgrading OmniRoute (issue #1622).
MACHINE_ID_SALT: () => `omniroute-${randomBytes(8).toString("hex")}`,
};

View File

@@ -23,18 +23,12 @@ export async function OPTIONS() {
}
function cacheStatus(
cache: { version?: string; generatedAt?: string | null; tier: string; fetchedAt: string } | null
cache: { version?: string; generatedAt?: string; tier: string; fetchedAt: string } | null
) {
if (!cache) return { available: false };
return {
available: true,
version: cache.version ?? cache.generatedAt,
// Reported on its own where the cache carries it — folding the build date
// into `version` loses the distinction between when a feed was built and
// when this install downloaded it. Absent for the offers and intel caches,
// which store no build date: a null there would claim the date is unknown
// when in fact it was never kept.
...("generatedAt" in cache ? { generatedAt: cache.generatedAt ?? null } : {}),
tier: cache.tier,
fetchedAt: cache.fetchedAt,
};

View File

@@ -1,12 +0,0 @@
-- 163_radar_feed_cache_generated_at.sql
--
-- radar_feed_cache (migration 136) kept only fetched_at — when this install
-- downloaded the feed — while the feed itself carries generatedAt, the date
-- its data was built. Nothing downstream could tell a recent download from
-- recent data: a feed fetched minutes ago can carry weeks-old figures.
--
-- radar_referrals_cache (migration 142) already persists that date; this
-- brings the catalog cache in line. NULL on rows cached before this column
-- existed — the date is unknown, and stays unknown rather than being stood in
-- for by fetched_at.
ALTER TABLE radar_feed_cache ADD COLUMN generated_at TEXT DEFAULT NULL;

View File

@@ -38,8 +38,6 @@ import { encrypt, decrypt } from "./encryption";
export interface RadarCache {
version: string;
/** Date the feed's data was built, from the feed itself. Null when unknown. */
generatedAt: string | null;
tier: string;
payload: string;
signature: string;
@@ -116,8 +114,8 @@ export function getRadarCache(): RadarCache | null {
const db = getDbInstance();
const row = db
.prepare(
"SELECT version, generated_at AS generatedAt, tier, payload, signature, " +
"fetched_at AS fetchedAt FROM radar_feed_cache WHERE id = 1"
"SELECT version, tier, payload, signature, fetched_at AS fetchedAt " +
"FROM radar_feed_cache WHERE id = 1"
)
.get() as RadarCache | undefined;
@@ -130,7 +128,6 @@ export function getRadarCache(): RadarCache | null {
*/
export function setRadarCache(entry: {
version: string;
generatedAt?: string | null;
tier: string;
payload: string;
signature: string;
@@ -140,23 +137,15 @@ export function setRadarCache(entry: {
const fetchedAt = entry.fetchedAt ?? new Date().toISOString();
db.prepare(
`INSERT INTO radar_feed_cache (id, version, generated_at, tier, payload, signature, fetched_at)
VALUES (1, ?, ?, ?, ?, ?, ?)
`INSERT INTO radar_feed_cache (id, version, tier, payload, signature, fetched_at)
VALUES (1, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
version = excluded.version,
generated_at = excluded.generated_at,
tier = excluded.tier,
payload = excluded.payload,
signature = excluded.signature,
fetched_at = excluded.fetched_at`
).run(
entry.version,
entry.generatedAt ?? null,
entry.tier,
entry.payload,
entry.signature,
fetchedAt
);
version = excluded.version,
tier = excluded.tier,
payload = excluded.payload,
signature = excluded.signature,
fetched_at = excluded.fetched_at`
).run(entry.version, entry.tier, entry.payload, entry.signature, fetchedAt);
}
// ---------------------------------------------------------------------------

View File

@@ -64,29 +64,11 @@ export function resolvePreviousResponseState(
const { artifact, state } = readCallArtifact(row.artifact_relpath);
if (state !== "ready" || !artifact?.pipeline) return null;
const clientRawRequest = artifact.pipeline.clientRawRequest as { body?: unknown } | undefined;
const clientResponse = artifact.pipeline.clientResponse as
{ output?: unknown; summary?: { output?: unknown } } | undefined;
const providerRequest = artifact.pipeline.providerRequest as { body?: unknown } | undefined;
const clientResponse = artifact.pipeline.clientResponse as { output?: unknown } | undefined;
// clientRawRequest, not providerRequest: this store only ever fires for
// sourceFormat === OPENAI_RESPONSES (see chat.ts), so the client's own
// request is always Responses-API shaped and always carries `input`.
// providerRequest is upstream-shaped and only has `input` for a native
// passthrough Responses API upstream -- any translated upstream (e.g. Chat
// Completions `messages`) rewrites the wire body entirely, which made this
// unconditionally unresolvable for every translate-mode/auto-routed
// connection (previous_response_not_found on every attempt, regardless of
// whether the id was real and the artifact was otherwise 'ready').
const input = isPlainRecord(clientRawRequest?.body) ? clientRawRequest.body.input : undefined;
// A streaming clientResponse is clientPayloadCollector.build()'s output, which
// always nests the caller's summary under `.summary` (see
// createStructuredSSECollector in streamPayloadCollector.ts) -- a non-streaming
// one carries `output` directly. Same dual-shape concern as extractResponsesId
// in open-sse/handlers/chatCore/attemptLogging.ts, checked here independently
// since this reads back a stored artifact rather than the live object.
const output = Array.isArray(clientResponse?.output)
? clientResponse.output
: clientResponse?.summary?.output;
const input = isPlainRecord(providerRequest?.body) ? providerRequest.body.input : undefined;
const output = clientResponse?.output;
if (!Array.isArray(input) || !Array.isArray(output)) return null;
return { input, output };

View File

@@ -40,12 +40,6 @@ export interface RadarCatalogResult {
/** Feed metadata — null when falling back to baseline. */
meta: {
version: string;
/**
* Date the feed's data was built. Null for a cache row written before the
* column existed — unknown, never substituted by `fetchedAt`, which only
* says when this install downloaded it.
*/
generatedAt: string | null;
tier: string;
fetchedAt: string;
} | null;
@@ -54,13 +48,7 @@ export interface RadarCatalogResult {
/** Injectable deps for testing. */
export interface GetRadarCatalogDeps {
getFlag?: (key: string) => boolean;
getCache?: () => {
version: string;
generatedAt?: string | null;
tier: string;
payload: string;
fetchedAt: string;
} | null;
getCache?: () => { version: string; tier: string; payload: string; fetchedAt: string } | null;
baseline?: MergedEntry[];
localOverrides?: Map<string, Partial<MergedEntry>>;
tombstones?: Set<string>;
@@ -154,7 +142,6 @@ export function getRadarCatalog(deps: GetRadarCatalogDeps = {}): RadarCatalogRes
entries,
meta: {
version: cache.version,
generatedAt: cache.generatedAt ?? null,
tier: cache.tier,
fetchedAt: cache.fetchedAt,
},

View File

@@ -55,8 +55,6 @@ export type SyncStatus =
export interface RadarCacheEntry {
version: string;
/** Date the feed's data was built (`generatedAt`), as validated by the schema. */
generatedAt?: string | null;
tier: string;
payload: string;
signature: string;
@@ -315,7 +313,6 @@ export async function syncRadar(deps: SyncDeps = {}): Promise<SyncStatus> {
// Step 9: Cache the result
const cacheEntry: RadarCacheEntry = {
version: feed.version,
generatedAt: feed.generatedAt,
tier: servedTier,
payload: rawBytes.toString("utf-8"),
signature,

View File

@@ -53,3 +53,61 @@ export function resolveLiveWsPublicUrl(env: NodeJS.ProcessEnv = process.env): st
export function getLiveWsPath(): string {
return deriveLiveWsPath(resolveLiveWsPublicUrl() ?? undefined);
}
/** A port the handshake may report, or null when it is not usable. */
export function sanitizeLiveWsPort(port: unknown): number | null {
const value = typeof port === "string" ? Number(port) : port;
if (typeof value !== "number" || !Number.isInteger(value)) return null;
return value > 0 && value < 65536 ? value : null;
}
export interface LiveWsUrlParts {
/** Explicit `wsUrl` passed by the caller - always wins. */
explicit?: string | null;
/** `live.publicUrl` from the handshake - a complete URL, used as-is. */
handshakeUrl?: string | null;
/** `live.port` from the handshake, i.e. the running LIVE_WS_PORT. */
handshakePort?: number | null;
/** `live.path` from the handshake. */
handshakePath?: string | null;
/** The compiled-in default, used for everything the handshake does not say. */
defaultUrl: string;
}
/**
* Resolve the live dashboard WebSocket URL.
*
* The handshake reports the port the live server is actually listening on, but
* the client read only `publicUrl` and `path` from it. An operator who moved
* the server with `LIVE_WS_PORT` still got the compiled-in 20132, and the
* dashboard sat on "Live disabled - WebSocket disconnected" with no way to
* correct it short of rebuilding the image (#11331).
*
* Precedence: an explicit `wsUrl` wins, then a complete `publicUrl` from the
* handshake, then the default URL with whatever port and path the handshake
* reported applied to it.
*/
export function resolveLiveWsUrl({
explicit,
handshakeUrl,
handshakePort,
handshakePath,
defaultUrl,
}: LiveWsUrlParts): string {
if (explicit) return explicit;
if (handshakeUrl) return handshakeUrl;
const port = sanitizeLiveWsPort(handshakePort);
const path =
typeof handshakePath === "string" && handshakePath.startsWith("/") ? handshakePath : null;
if (port === null && path === null) return defaultUrl;
try {
const url = new URL(defaultUrl);
if (port !== null) url.port = String(port);
if (path !== null) url.pathname = path;
return url.toString();
} catch {
return defaultUrl;
}
}

View File

@@ -325,6 +325,8 @@
"tests/unit/repro-9486.test.ts",
"tests/unit/repro-9630-combo-false-503.test.ts",
"tests/unit/repro-antigravity-404-family-cooldown-hijack.test.ts",
"tests/unit/repro-combo-persisted-cooldown-preskip.test.ts",
"tests/unit/repro-glm-iso-reset-24h-cap.test.ts",
"tests/unit/resilience-connections.test.ts",
"tests/unit/responses-handler.test.ts",
"tests/unit/responses-passthrough-openai-compatible.test.ts",

View File

@@ -1,52 +0,0 @@
/**
* extractResponsesId is the write-side half of previous_response_id
* continuation (src/lib/db/responsesContinuationStore.ts is the read-side
* half): it decides what gets indexed in call_logs.response_id. See
* responses-continuation-passthrough-client-payload.test.ts and
* responses-continuation-store.test.ts for the fuller bug writeup this
* fixes -- this file covers the id-extraction half in isolation.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { extractResponsesId } from "../../open-sse/handlers/chatCore/attemptLogging.ts";
const RESPONSES = "openai-responses";
test("extractResponsesId reads a direct id (non-streaming clientResponse)", () => {
assert.equal(extractResponsesId(RESPONSES, { id: "resp_123" }), "resp_123");
});
test("extractResponsesId reads a wrapped id (streaming clientResponse via clientPayloadCollector.build())", () => {
assert.equal(
extractResponsesId(RESPONSES, { _streamed: true, summary: { id: "resp_456" } }),
"resp_456"
);
});
test("extractResponsesId prefers a direct id over a wrapped one when both are present", () => {
assert.equal(
extractResponsesId(RESPONSES, { id: "resp_direct", summary: { id: "resp_wrapped" } }),
"resp_direct"
);
});
test("extractResponsesId returns null when sourceFormat is not openai-responses (never mistake a chatcmpl-* id)", () => {
assert.equal(extractResponsesId("openai", { id: "chatcmpl-abc" }), null);
assert.equal(extractResponsesId(undefined, { id: "resp_123" }), null);
});
test("extractResponsesId returns null for a missing/empty/non-string id in either shape", () => {
assert.equal(extractResponsesId(RESPONSES, {}), null);
assert.equal(extractResponsesId(RESPONSES, { id: "" }), null);
assert.equal(extractResponsesId(RESPONSES, { id: 123 }), null);
assert.equal(extractResponsesId(RESPONSES, { summary: {} }), null);
assert.equal(extractResponsesId(RESPONSES, { summary: { id: "" } }), null);
assert.equal(extractResponsesId(RESPONSES, { summary: null }), null);
});
test("extractResponsesId returns null for a non-object or nullish clientResponse", () => {
assert.equal(extractResponsesId(RESPONSES, null), null);
assert.equal(extractResponsesId(RESPONSES, undefined), null);
assert.equal(extractResponsesId(RESPONSES, "resp_123"), null);
});

View File

@@ -1,82 +0,0 @@
/**
* The CLI announces every .env it loads. One of those locations is the
* installed package directory, which `npm i -g` replaces wholesale — so the
* file an operator edits there is gone at the next update, without a word.
*
* describeVolatileEnvWarning() decides when to say so. It must stay silent for
* a development checkout, where that same path is stable and documented, and
* for a file whose keys were all shadowed by a durable one — it supplied
* nothing, so losing it costs nothing.
*/
import test from "node:test";
import assert from "node:assert/strict";
import path from "node:path";
import { describeVolatileEnvWarning } from "../../bin/cli/utils/volatileEnvPath.mjs";
const INSTALLED_ROOT = path.join("/usr", "lib", "node_modules", "omniroute");
const CHECKOUT_ROOT = path.join("/home", "dev", "OmniRoute");
const DURABLE = path.join("/home", "dev", ".omniroute", ".env");
test("an installed package .env that supplied keys is reported as volatile", () => {
const message = describeVolatileEnvWarning({
envPath: path.join(INSTALLED_ROOT, ".env"),
packageRoot: INSTALLED_ROOT,
durableEnvPath: DURABLE,
suppliedKeys: true,
});
assert.ok(message, "an installed package .env must be reported");
assert.match(message, /update/i, "the message must say what destroys the file");
assert.ok(message.includes(DURABLE), "the message must name the durable path to move to");
});
test("a development checkout says nothing", () => {
// Same file name, stable location: `npm install` in a checkout preserves it,
// and SETUP_GUIDE.md documents it. Warning here would fire on every start.
assert.equal(
describeVolatileEnvWarning({
envPath: path.join(CHECKOUT_ROOT, ".env"),
packageRoot: CHECKOUT_ROOT,
durableEnvPath: DURABLE,
suppliedKeys: true,
}),
null
);
});
test("a file that supplied no key says nothing", () => {
assert.equal(
describeVolatileEnvWarning({
envPath: path.join(INSTALLED_ROOT, ".env"),
packageRoot: INSTALLED_ROOT,
durableEnvPath: DURABLE,
suppliedKeys: false,
}),
null
);
});
test("the durable file itself says nothing, wherever it sits", () => {
assert.equal(
describeVolatileEnvWarning({
envPath: DURABLE,
packageRoot: INSTALLED_ROOT,
durableEnvPath: DURABLE,
suppliedKeys: true,
}),
null
);
});
test("a path outside the package root says nothing", () => {
assert.equal(
describeVolatileEnvWarning({
envPath: path.join("/srv", "app", ".env"),
packageRoot: INSTALLED_ROOT,
durableEnvPath: DURABLE,
suppliedKeys: true,
}),
null
);
});

View File

@@ -1,80 +0,0 @@
/**
* Tests for migration 163 — radar_feed_cache.generated_at.
*
* Verifies:
* - the column exists once after the migration runs (fresh database)
* - a row written the way the previous schema wrote it — no build date at all —
* reads back as null rather than borrowing the fetch time
* - the rest of that row survives the upgrade untouched
*/
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-migration-163-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.NODE_ENV = "test";
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
const core = await import("../../../src/lib/db/core.ts");
const radarDb = await import("../../../src/lib/db/radar.ts");
function resetDb() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
test.beforeEach(() => {
resetDb();
});
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("migration 163 — radar_feed_cache carries generated_at exactly once", () => {
const db = core.getDbInstance();
const columns = (
db.prepare("PRAGMA table_info(radar_feed_cache)").all() as Array<{ name: string }>
).map((c) => c.name);
assert.equal(
columns.filter((c) => c === "generated_at").length,
1,
"generated_at must be added once, whatever the number of migration runs"
);
});
test("migration 163 — a row from the previous schema keeps its data and reads no build date", () => {
const db = core.getDbInstance();
// Exactly the INSERT the previous schema could write: no generated_at column.
db.prepare(
`INSERT INTO radar_feed_cache (id, version, tier, payload, signature, fetched_at)
VALUES (1, ?, ?, ?, ?, ?)`
).run(
"2026.08.02.1",
"community",
'{"feed":"omniroute-radar"}',
"sig",
"2026-08-24T07:00:00.000Z"
);
const cache = radarDb.getRadarCache();
assert.ok(cache);
assert.equal(
cache.generatedAt,
null,
"an upgraded row has no build date, and must not invent one"
);
assert.equal(cache.version, "2026.08.02.1", "the pre-migration data must survive untouched");
assert.equal(cache.tier, "community");
assert.equal(cache.fetchedAt, "2026-08-24T07:00:00.000Z");
});

View File

@@ -1,216 +0,0 @@
/**
* tests/unit/radar-feed-cache-generated-at.test.ts
*
* The catalog feed carries the date its data was built (`generatedAt`, required
* by the feed schema). Until now the cache kept only `fetched_at` — when this
* install downloaded it — so nothing downstream could tell a recent download
* from recent data. The referrals cache (migration 142) already persists it;
* this file is the guard that the catalog cache does too, all the way out to
* `getRadarCatalog()` and `GET /api/radar/status`.
*
* A cache row written before the migration has no data date. It must read back
* as null — never the fetch time standing in for it, which is the exact
* confusion this column exists to end.
*/
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 crypto from "node:crypto";
import { SignJWT } from "jose";
// Ephemeral signing key, injected before any Radar module loads so the sync
// path verifies against it (the fork override documented in RADAR.md).
const { publicKey, privateKey } = crypto.generateKeyPairSync("ed25519");
process.env.RADAR_FEED_PUBKEY = publicKey
.export({ type: "spki", format: "der" })
.toString("base64");
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-radar-generated-at-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.STORAGE_ENCRYPTION_KEY = "test-encryption-key-for-radar-genat-tests-32b";
process.env.JWT_SECRET = "test-jwt-secret-for-radar-genat-tests";
process.env.INITIAL_PASSWORD = "test-bootstrap-password-for-radar-genat-tests";
process.env.RADAR_ENABLED = "true";
const core = await import("../../src/lib/db/core.ts");
const radarDb = await import("../../src/lib/db/radar.ts");
const { getRadarCatalog } = await import("../../src/lib/radar/index.ts");
const FIXTURE = JSON.parse(
fs.readFileSync(
path.resolve(import.meta.dirname!, "../fixtures/radar-feed-canonical.json"),
"utf8"
)
) as { generatedAt: string; version: string };
const FETCHED_AT = "2026-08-24T07:00:00.000Z";
async function authCookieHeader(): Promise<string> {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const token = await new SignJWT({ authenticated: true })
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("1h")
.sign(secret);
return `auth_token=${token}`;
}
function seed(entry: Partial<Parameters<typeof radarDb.setRadarCache>[0]> = {}): void {
radarDb.setRadarCache({
version: FIXTURE.version,
generatedAt: FIXTURE.generatedAt,
tier: "community",
payload: JSON.stringify(FIXTURE),
signature: "test-signature-not-verified-on-read",
fetchedAt: FETCHED_AT,
...entry,
});
}
test("the catalog cache persists the feed's own build date", () => {
seed();
const cache = radarDb.getRadarCache();
assert.ok(cache);
assert.equal(cache.generatedAt, FIXTURE.generatedAt);
assert.equal(cache.fetchedAt, FETCHED_AT);
assert.notEqual(
cache.generatedAt,
cache.fetchedAt,
"the data date and the download date are two different facts"
);
});
test("a row cached before this column existed reads back as an unknown date", () => {
seed({ generatedAt: undefined });
const cache = radarDb.getRadarCache();
assert.ok(cache);
assert.equal(cache.generatedAt, null, "unknown must stay unknown, never the fetch time");
assert.equal(cache.fetchedAt, FETCHED_AT);
});
test("syncRadar writes the build date it just validated", async () => {
const syncMod = await import("../../src/lib/radar/sync.ts");
const bytes = Buffer.from(JSON.stringify(FIXTURE), "utf8");
const signature = crypto.sign(null, bytes, privateKey).toString("base64");
const written: Array<{ generatedAt?: string | null }> = [];
const result = await syncMod.syncRadar({
getFlag: () => true,
getSettings: () => ({ optIn: true, supporterKey: null }),
getCache: () => null,
setCache: (entry) => {
written.push(entry);
},
fetch: (() =>
Promise.resolve(
new Response(bytes, {
status: 200,
headers: {
"x-omniroute-feed-signature": signature,
"x-omniroute-feed-tier": "community",
},
})
)) as unknown as typeof globalThis.fetch,
});
assert.equal(result.status, "updated");
assert.equal(written.length, 1, "a valid feed must be cached");
assert.equal(written[0].generatedAt, FIXTURE.generatedAt);
});
test("getRadarCatalog reports the build date alongside the fetch date", () => {
seed();
const { meta } = getRadarCatalog();
assert.ok(meta, "an active feed must expose its metadata");
assert.equal(meta.generatedAt, FIXTURE.generatedAt);
assert.equal(meta.fetchedAt, FETCHED_AT);
});
test("GET /api/radar/status reports the build date as its own field", async () => {
seed();
const { GET } = await import("../../src/app/api/radar/status/route.ts");
const res = await GET(
new Request("http://localhost:20128/api/radar/status", {
headers: { cookie: await authCookieHeader() },
})
);
assert.equal(res.status, 200);
const body = (await res.json()) as {
feeds: { catalog: { version?: string; generatedAt?: string | null; fetchedAt: string } };
};
assert.equal(body.feeds.catalog.generatedAt, FIXTURE.generatedAt);
assert.equal(
body.feeds.catalog.version,
FIXTURE.version,
"the build date must not be folded into the version field"
);
});
test("status omits the build date for the caches that never store one", async () => {
seed();
// Both must be present in the response, otherwise the assertion below would
// pass on an `{ available: false }` stub that carries no field either.
radarDb.setRadarOffersCache({
version: "2026.08.24.1",
tier: "live",
payload: JSON.stringify({ offers: [] }),
signature: "test-signature",
fetchedAt: FETCHED_AT,
});
radarDb.setRadarIntelCache({
version: "2026.08.24.1",
tier: "live",
payload: JSON.stringify({ intel: {} }),
signature: "test-signature",
supporterIdentity: "test-identity",
fetchedAt: FETCHED_AT,
});
const { GET } = await import("../../src/app/api/radar/status/route.ts");
const res = await GET(
new Request("http://localhost:20128/api/radar/status", {
headers: { cookie: await authCookieHeader() },
})
);
const body = (await res.json()) as {
feeds: Record<string, Record<string, unknown>>;
};
// offers and intel are cached without a build date. Reporting null there
// would say "unknown", when the truth is that it was never kept.
for (const feed of ["offers", "intel"]) {
assert.equal(
body.feeds[feed].available,
true,
`${feed} must be cached for this to mean anything`
);
assert.equal(
"generatedAt" in body.feeds[feed],
false,
`${feed} must not advertise a build date it never stores`
);
}
assert.equal(body.feeds.catalog.generatedAt, FIXTURE.generatedAt);
});
test.after(() => {
core.resetDbInstance();
delete process.env.RADAR_ENABLED;
delete process.env.INITIAL_PASSWORD;
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
} catch {
// ignore
}
});

View File

@@ -1,147 +0,0 @@
/**
* Regression test for the "previous_response_id continuation never engages
* through a passthrough Responses-API connection" bug.
*
* Root cause (three independent gaps, all in the client-facing path):
*
* 1. Passthrough mode's per-event loop only pushed each raw SSE event into
* providerPayloadCollector, never clientPayloadCollector -- so for a
* plain-text Responses-API reply (no tool calls, no textual-tool-call
* conversion), clientPayloadCollector.getEvents() was always empty.
* 2. onComplete's `clientPayload` was unconditionally built from a
* synthesized chat-completions-shaped `responseBody` ({choices: [...]}),
* even for a Responses-API client -- so it never carried a real `id` or
* Responses-shaped `output`, unlike the sibling `providerPayload` builder
* right next to it (which already had the OPENAI_RESPONSES carve-out).
* 3. clientPayloadCollector.build()'s returned object always nests the
* caller-supplied summary under `.summary` (see createStructuredSSECollector
* in streamPayloadCollector.ts) -- extractResponsesId in
* chatCore/attemptLogging.ts and resolvePreviousResponseState in
* src/lib/db/responsesContinuationStore.ts both read `.id`/`.output`
* directly, so even a correctly-populated events list produced a
* clientResponse whose id/output were invisible to them.
*
* Net effect: `call_logs.response_id` was NEVER populated for a passthrough
* Responses-API reply, so every `previous_response_id` continuation attempt
* against such a connection failed with a bare HTTP 400
* ("previous_response_not_found") -- silently, since openclaw-style clients
* recover by resending full history, so nothing user-visible looked broken.
*
* This test exercises only gap #1 and #2 (the stream.ts side) via the real
* createSSEStream() transform, the same harness used by
* responses-commentary-passthrough-6199.test.ts. Gap #3's two read-side fixes
* are covered directly in responses-continuation-store.test.ts (the
* `.summary.output` fallback) and would need their own extractResponsesId
* unit coverage if that function is exported for testing.
*/
import test from "node:test";
import assert from "node:assert/strict";
const { createSSEStream } = await import("../../open-sse/utils/stream.ts");
const textEncoder = new TextEncoder();
type OnCompletePayload = {
status: number;
clientPayload?: unknown;
providerPayload?: unknown;
};
async function runPassthrough(
chunks: string[]
): Promise<{ output: string; onCompletePayload: OnCompletePayload | undefined }> {
let onCompletePayload: OnCompletePayload | undefined;
const source = new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(textEncoder.encode(chunk));
}
controller.close();
},
});
const output = await new Response(
source.pipeThrough(
createSSEStream({
mode: "passthrough",
provider: "openai-compatible",
clientResponseFormat: "openai-responses",
sourceFormat: "openai-responses",
model: "mock-model",
onComplete: (payload: OnCompletePayload) => {
onCompletePayload = payload;
},
})
)
).text();
return { output, onCompletePayload };
}
function sse(event: object): string {
return `data: ${JSON.stringify(event)}\n\n`;
}
test("passthrough onComplete's clientPayload carries a real Responses id + output for a plain-text reply", async () => {
// The minimal shape a real upstream (or a scripted test double) sends for a
// plain-text reply: a single terminal response.completed frame, no
// response.created/output_item.added lifecycle events first -- this is
// exactly what tripped the bug, since it never touched the textual-tool-call
// conversion path that happened to already push into clientPayloadCollector.
const { onCompletePayload } = await runPassthrough([
sse({
type: "response.completed",
response: {
id: "resp_plain_text_1",
status: "completed",
output: [
{
id: "msg_resp_plain_text_1",
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "hello there", annotations: [] }],
},
],
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
},
}),
]);
assert.ok(onCompletePayload, "onComplete must fire");
const clientPayload = onCompletePayload!.clientPayload as
| { id?: unknown; summary?: { id?: unknown; output?: unknown } }
| undefined;
assert.ok(clientPayload, "clientPayload must be present");
// clientPayloadCollector.build() nests the summary; accept either shape so
// this test survives a future change to the wrapping, but the id/output
// MUST be findable one way or the other -- that's the actual contract
// extractResponsesId / resolvePreviousResponseState depend on.
const id = clientPayload!.id ?? clientPayload!.summary?.id;
const output = clientPayload!.summary?.output;
assert.equal(id, "resp_plain_text_1", "the real Responses id must survive into clientPayload");
assert.ok(Array.isArray(output) && output.length === 1, "the real output array must survive too");
});
test("passthrough forwards the plain-text reply to the client unchanged (no regression)", async () => {
const { output } = await runPassthrough([
sse({
type: "response.completed",
response: {
id: "resp_plain_text_2",
status: "completed",
output: [
{
id: "msg_resp_plain_text_2",
type: "message",
role: "assistant",
content: [{ type: "output_text", text: "hello again", annotations: [] }],
},
],
usage: { input_tokens: 5, output_tokens: 3, total_tokens: 8 },
},
}),
]);
assert.ok(output.includes("hello again"), "the client-visible SSE stream must still carry the reply");
assert.ok(output.includes("resp_plain_text_2"), "the client-visible response id must be unchanged");
});

View File

@@ -78,7 +78,6 @@ test("resolvePreviousResponseState reconstructs input/output from the call-log a
artifactRelPath: "2026-01-01/log-1.json",
});
writeArtifact("2026-01-01/log-1.json", {
clientRawRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } },
providerRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } },
clientResponse: {
id: "resp_abc",
@@ -93,44 +92,6 @@ test("resolvePreviousResponseState reconstructs input/output from the call-log a
});
});
test("resolvePreviousResponseState reads output from a wrapped (streaming) clientResponse shape", () => {
// A streaming reply's clientResponse is clientPayloadCollector.build()'s output,
// which always nests the caller-supplied summary under `.summary` (see
// createStructuredSSECollector in streamPayloadCollector.ts) rather than
// carrying `output` at the top level like a non-streaming reply does. This
// must resolve exactly like the unwrapped shape above -- it was the actual
// cause of previous_response_id continuation always failing for a streaming
// Responses-API passthrough connection (fixed alongside the clientPayload
// builder gap in open-sse/utils/stream.ts).
insertCallLog({
id: "log-1-streamed",
responseId: "resp_streamed",
apiKeyId: "key-1",
detailState: "ready",
artifactRelPath: "2026-01-01/log-1-streamed.json",
});
writeArtifact("2026-01-01/log-1-streamed.json", {
clientRawRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } },
providerRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } },
clientResponse: {
_streamed: true,
_format: "sse-json",
_eventCount: 1,
summary: {
id: "resp_streamed",
object: "response",
output: [{ type: "message", role: "assistant", content: "hello" }],
},
},
});
const result = store.resolvePreviousResponseState("resp_streamed", "key-1");
assert.deepEqual(result, {
input: [{ type: "message", role: "user", content: "hi" }],
output: [{ type: "message", role: "assistant", content: "hello" }],
});
});
test("resolvePreviousResponseState returns null for an unknown response id", () => {
const result = store.resolvePreviousResponseState("resp_does_not_exist", "key-1");
assert.equal(result, null);
@@ -145,7 +106,6 @@ test("resolvePreviousResponseState never crosses tenants (scoped by api_key_id)"
artifactRelPath: "2026-01-01/log-2.json",
});
writeArtifact("2026-01-01/log-2.json", {
clientRawRequest: { body: { input: [{ role: "user", content: "secret" }] } },
providerRequest: { body: { input: [{ role: "user", content: "secret" }] } },
clientResponse: { id: "resp_tenant_a", output: [{ role: "assistant", content: "reply" }] },
});
@@ -179,50 +139,13 @@ test("resolvePreviousResponseState fails closed when the pipeline payload was si
// an object -- resolvePreviousResponseState must never try to reconstruct
// from it and silently drop history.
writeArtifact("2026-01-01/log-4.json", {
clientRawRequest: { body: "[omitted: call log artifact size limit exceeded]" },
providerRequest: { body: "[omitted: call log artifact size limit exceeded]" },
clientResponse: { id: "resp_omitted", output: [] },
});
assert.equal(store.resolvePreviousResponseState("resp_omitted", "key-1"), null);
});
test("resolvePreviousResponseState resolves input from clientRawRequest when providerRequest was translated to a different upstream wire shape", () => {
// Real shape from a live auto-routed free-tier connection: OmniRoute
// translates the client's Responses-API request into Chat Completions
// (`messages`, no `input` at all) before forwarding upstream. Reading
// `input` from providerRequest.body made this permanently unresolvable --
// previous_response_not_found on every attempt -- for any connection where
// the selected upstream isn't itself a native Responses-API passthrough.
// The client's own request is always Responses-API shaped (this store only
// fires for sourceFormat === OPENAI_RESPONSES, see chat.ts), so
// clientRawRequest is the correct source regardless of upstream shape.
insertCallLog({
id: "log-6",
responseId: "resp_gen-translate-mode",
apiKeyId: "key-1",
detailState: "ready",
artifactRelPath: "2026-01-01/log-6.json",
});
writeArtifact("2026-01-01/log-6.json", {
clientRawRequest: { body: { input: [{ type: "message", role: "user", content: "hi" }] } },
providerRequest: {
body: { model: "laguna-s-2.1-free", messages: [{ role: "user", content: "hi" }] },
},
clientResponse: {
summary: {
id: "resp_gen-translate-mode",
output: [{ type: "message", role: "assistant", content: "hello" }],
},
},
});
const result = store.resolvePreviousResponseState("resp_gen-translate-mode", "key-1");
assert.deepEqual(result, {
input: [{ type: "message", role: "user", content: "hi" }],
output: [{ type: "message", role: "assistant", content: "hello" }],
});
});
test("resolvePreviousResponseState returns null when detail logging was never captured for this row", () => {
insertCallLog({
id: "log-5",

View File

@@ -1,125 +0,0 @@
/**
* Regression test for the "previous_response_id continuation never engages
* for a real Ping-style default-combo request" gap -- the translate-mode
* sibling of responses-continuation-passthrough-client-payload.test.ts.
*
* Verified against real production traffic (2026-08-21): every "default"
* combo request sampled from Ping's live gateway had sourceFormat
* "openai-responses" / targetFormat "openai" -- i.e. translate mode, not
* passthrough, because the pooled combo's actual upstreams (OpenRouter,
* Mistral, Gemini, NVIDIA, ...) are chat-completions-native, not
* Responses-API-native. The passthrough fix alone does not help this path.
*
* Unlike passthrough, translate mode's emitTranslatedClientItem() (the sole
* place a translated, client-visible item is ever sent) already pushes
* every item into clientPayloadCollector unconditionally -- so gap #1 from
* the passthrough bug (missing collection) does not apply here. Only gap #2
* applied: onComplete's clientPayload was still built from the synthesized
* chat-completions-shaped responseBody regardless of what the client
* actually requested, exactly like the passthrough sibling before its fix.
*/
import test from "node:test";
import assert from "node:assert/strict";
import { FORMATS } from "../../open-sse/translator/formats.ts";
const { createSSEStream } = await import("../../open-sse/utils/stream.ts");
const textEncoder = new TextEncoder();
type OnCompletePayload = {
status: number;
clientPayload?: unknown;
providerPayload?: unknown;
};
async function runTranslate(
chunks: string[]
): Promise<{ output: string; onCompletePayload: OnCompletePayload | undefined }> {
let onCompletePayload: OnCompletePayload | undefined;
const source = new ReadableStream({
start(controller) {
for (const chunk of chunks) {
controller.enqueue(textEncoder.encode(chunk));
}
controller.close();
},
});
const output = await new Response(
source.pipeThrough(
createSSEStream({
mode: "translate",
// Matches real production traffic exactly: a chat-completions-native
// upstream (targetFormat) translated into Responses shape for a
// Responses-API client (sourceFormat).
targetFormat: FORMATS.OPENAI,
sourceFormat: FORMATS.OPENAI_RESPONSES,
provider: "openrouter",
model: "nemotron-3-ultra-free",
body: { input: [{ type: "message", role: "user", content: "hi" }] },
onComplete: (payload: OnCompletePayload) => {
onCompletePayload = payload;
},
})
)
).text();
return { output, onCompletePayload };
}
function chatCompletionsChunk(delta: Record<string, unknown>, finishReason: string | null = null) {
return `data: ${JSON.stringify({
id: "chatcmpl-real-provider-id",
object: "chat.completion.chunk",
choices: [{ index: 0, delta, finish_reason: finishReason }],
})}\n\n`;
}
test("translate mode's onComplete.clientPayload carries a real Responses id + output for a plain-text reply", async () => {
const { onCompletePayload } = await runTranslate([
chatCompletionsChunk({ role: "assistant", content: "" }),
chatCompletionsChunk({ content: "hello there" }),
chatCompletionsChunk({}, "stop"),
`data: ${JSON.stringify({
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 },
})}\n\n`,
"data: [DONE]\n\n",
]);
assert.ok(onCompletePayload, "onComplete must fire");
const clientPayload = onCompletePayload!.clientPayload as
| { id?: unknown; summary?: { id?: unknown; output?: unknown } }
| undefined;
assert.ok(clientPayload, "clientPayload must be present");
const id = clientPayload!.id ?? clientPayload!.summary?.id;
const output = clientPayload!.summary?.output;
assert.ok(
typeof id === "string" && id.length > 0,
"a real Responses id must survive into clientPayload, not be missing"
);
assert.ok(
Array.isArray(output) && output.length > 0,
"a real output array must survive into clientPayload"
);
});
test("translate mode still forwards the translated reply to the client unchanged (no regression)", async () => {
const { output } = await runTranslate([
chatCompletionsChunk({ role: "assistant", content: "" }),
chatCompletionsChunk({ content: "hello again" }),
chatCompletionsChunk({}, "stop"),
`data: ${JSON.stringify({
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 },
})}\n\n`,
"data: [DONE]\n\n",
]);
assert.ok(
output.includes("hello again"),
"the client-visible translated Responses SSE stream must still carry the reply"
);
assert.match(output, /response\.completed/, "a terminal Responses event must still be emitted");
});

View File

@@ -41,13 +41,11 @@ describe("S2 — agent-card topology sanitisation", () => {
assert.equal(res.status, 200);
const card = (await res.json()) as { url?: string; supportedInterfaces?: { url?: string }[] };
assert.ok(card.url, "card must have a url");
assert.equal(new URL(card.url).origin, "https://gateway.example.com", `expected gateway.example.com origin, got ${card.url}`);
assert.ok(card.url.startsWith("https://gateway.example.com"), `expected gateway.example.com, got ${card.url}`);
if (card.supportedInterfaces && card.supportedInterfaces.length > 0) {
const ifaceUrl = card.supportedInterfaces[0].url;
assert.equal(
ifaceUrl ? new URL(ifaceUrl).origin : undefined,
"https://gateway.example.com",
`interface URL should use dynamic origin, got ${ifaceUrl}`
assert.ok(
card.supportedInterfaces[0].url?.startsWith("https://gateway.example.com"),
`interface URL should use dynamic origin, got ${card.supportedInterfaces[0].url}`
);
}
});
@@ -64,8 +62,7 @@ describe("S2 — agent-card topology sanitisation", () => {
const res = await mod.GET(request);
assert.equal(res.status, 200);
const card = (await res.json()) as { url?: string };
assert.ok(card.url, "card must have a url");
assert.equal(new URL(card.url).origin, "https://custom.example.com", `expected custom.example.com origin, got ${card.url}`);
assert.ok(card.url?.startsWith("https://custom.example.com"), `expected custom.example.com, got ${card.url}`);
});
it("agent.json derives URL from request.nextUrl.origin when OMNIROUTE_BASE_URL is unset", async () => {
@@ -79,8 +76,7 @@ describe("S2 — agent-card topology sanitisation", () => {
const res = await mod.GET(request);
assert.equal(res.status, 200);
const card = (await res.json()) as { url?: string };
assert.ok(card.url, "card must have a url");
assert.equal(new URL(card.url).origin, "https://gateway.example.com", `expected gateway.example.com origin, got ${card.url}`);
assert.ok(card.url?.startsWith("https://gateway.example.com"), `expected gateway.example.com, got ${card.url}`);
});
});

View File

@@ -444,69 +444,3 @@ test("splitConcatenatedToolCallArguments — top-level array is single value", (
const out = splitConcatenatedToolCallArguments(arr);
assert.equal(out, null); // one value boundary (array) -> not split
});
// Continuation gap (2026-08-21): emitTranslatedClientItem in stream.ts pushes
// every translate-mode client-visible item wrapped as `{event, data}` (needed
// so formatSSE can emit both the SSE `event:` line and the `data:` payload
// separately) -- but every reducer's ingest() read `payload.type` directly,
// one level too shallow for that shape, so a client-facing summary built
// from translate-mode events (e.g. clientPayload when the client speaks
// Responses API) never found a real response id/output. Only affected
// clientPayloadCollector in translate mode; providerPayloadCollector and
// passthrough mode always pushed the bare payload directly.
test("buildStreamSummaryFromEvents unwraps a translate-mode {event, data} envelope", () => {
const events = [
{
data: {
event: "response.completed",
data: {
type: "response.completed",
response: {
id: "resp_wrapped_1",
output: [{ type: "message", role: "assistant", content: "hi" }],
},
},
},
event: "response.completed",
},
];
const result = collector.buildStreamSummaryFromEvents(events, "openai-responses") as {
id?: unknown;
output?: unknown;
};
assert.equal(result?.id, "resp_wrapped_1", "must read the id from one level deeper, not undefined");
assert.ok(Array.isArray(result?.output) && result.output.length === 1);
});
test("buildStreamSummaryFromEvents still reads a bare (unwrapped) event correctly", () => {
const events = [
{
data: {
type: "response.completed",
response: {
id: "resp_bare_1",
output: [{ type: "message", role: "assistant", content: "hi" }],
},
},
},
];
const result = collector.buildStreamSummaryFromEvents(events, "openai-responses") as {
id?: unknown;
output?: unknown;
};
assert.equal(result?.id, "resp_bare_1");
assert.ok(Array.isArray(result?.output) && result.output.length === 1);
});
test("createStructuredSSECollector's live getSummary() also unwraps a pushed {event, data} envelope", () => {
const c = collector.createStructuredSSECollector({ format: "openai-responses" });
c.push({
event: "response.completed",
data: {
type: "response.completed",
response: { id: "resp_wrapped_live", output: [] },
},
});
const summary = c.getSummary() as { id?: unknown };
assert.equal(summary?.id, "resp_wrapped_live");
});

View File

@@ -1053,11 +1053,7 @@ Arguments: {"command":"systemctl status omniroute"}`;
assert.doesNotMatch(text, /Arguments:/);
assert.match(text, /response.output_item.added/);
assert.match(text, /response.function_call_arguments.done/);
// 5 synthesized function-call events (from the textual tool-call conversion)
// + 1 for the terminal response.completed itself, now also pushed so
// previous_response_id continuation can recover a real id/output for a
// passthrough Responses-API reply (see responsesContinuationStore.ts).
assert.equal(onCompletePayload.clientPayload._eventCount, 6);
assert.equal(onCompletePayload.clientPayload._eventCount, 5);
assert.equal(onCompletePayload.responseBody.choices[0].finish_reason, "tool_calls");
assert.equal(onCompletePayload.responseBody.choices[0].message.content, null);
assert.equal(

View File

@@ -52,7 +52,7 @@ function writeOauthEnvExample(rootDir: string) {
);
}
test("syncEnv creates .env from .env.example and leaves runtime-owned secrets blank", () => {
test("syncEnv creates .env from .env.example and generates install-time secrets", () => {
const rootDir = createTempRoot();
// Temporarily override DATA_DIR so the encrypted-credentials guard doesn't
@@ -66,13 +66,8 @@ test("syncEnv creates .env from .env.example and leaves runtime-owned secrets bl
const envContent = fs.readFileSync(path.join(rootDir, ".env"), "utf8");
assert.deepEqual(result, { created: true, added: 7 });
// The three secrets the server provisions itself stay blank here. Filling
// them in the package directory hides ensureSecrets() (instrumentation-node),
// which restores them from the durable store or generates and persists them
// there — so a pre-filled value is silently replaced by a new one on every
// reinstall. STORAGE_ENCRYPTION_KEY was pulled out for that reason (#1622).
assert.match(envContent, /^JWT_SECRET=$/m);
assert.match(envContent, /^API_KEY_SECRET=$/m);
assert.match(envContent, /^JWT_SECRET=.{32,}$/m);
assert.match(envContent, /^API_KEY_SECRET=.{32,}$/m);
assert.match(envContent, /^STORAGE_ENCRYPTION_KEY=$/m);
assert.match(envContent, /^MACHINE_ID_SALT=omniroute-/m);
assert.match(envContent, /^CLAUDE_OAUTH_CLIENT_ID=claude-default$/m);
@@ -108,7 +103,7 @@ test("syncEnv appends only missing keys and preserves existing values", () => {
assert.deepEqual(result, { created: false, added: 5 });
assert.match(envContent, /^JWT_SECRET=my-custom-secret-that-should-stay$/m);
assert.match(envContent, /^CLAUDE_OAUTH_CLIENT_ID=custom-claude$/m);
assert.match(envContent, /^API_KEY_SECRET=$/m);
assert.match(envContent, /^API_KEY_SECRET=.{32,}$/m);
assert.match(envContent, /^STORAGE_ENCRYPTION_KEY=$/m);
assert.match(envContent, /^MACHINE_ID_SALT=omniroute-/m);
assert.match(envContent, /^CODEX_OAUTH_CLIENT_ID=codex-default$/m);

View File

@@ -0,0 +1,69 @@
import test from "node:test";
import assert from "node:assert/strict";
import { isVolcengineCookieDomain } from "../../open-sse/services/volcengineConsoleAutoLogin.ts";
// CodeQL js/incomplete-url-substring-sanitization (#860, #861). The console
// auto-login harvested `digest`/`AccountID`/`csrfToken`/`userInfo` from any
// cookie whose domain merely *contained* "volcengine.com", so a cookie set by
// `volcengine.com.attacker.tld` (or `notvolcengine.com`) was accepted as an
// operator credential and persisted as a provider connection. Match the domain
// the way a cookie domain has to be matched: exact host or a dot-boundary
// suffix. Mirrors isAdobeCookieDomain in adobeFireflyBrowserLogin.ts.
test("accepts the real console cookie domains", () => {
for (const domain of [
"volcengine.com",
".volcengine.com",
"console.volcengine.com",
".console.volcengine.com",
"CONSOLE.VOLCENGINE.COM",
" .volcengine.com ",
]) {
assert.equal(isVolcengineCookieDomain(domain), true, domain);
}
});
test("rejects look-alike domains that merely contain the string", () => {
for (const domain of [
"volcengine.com.attacker.tld",
".volcengine.com.evil.example",
"notvolcengine.com",
"myvolcengine.com",
"volcengine.com.br",
"evil.tld/volcengine.com",
"volcengine.company",
]) {
assert.equal(isVolcengineCookieDomain(domain), false, domain);
}
});
test("rejects empty / missing domains instead of throwing", () => {
assert.equal(isVolcengineCookieDomain(undefined), false);
assert.equal(isVolcengineCookieDomain(""), false);
assert.equal(isVolcengineCookieDomain(" "), false);
});
// The same class exists in inAppLoginService's cookie capture, where the
// expected domain comes from TOKEN_EXTRACTION_CONFIGS instead of a literal —
// which is why CodeQL did not flag it. Same helper, same guarantees.
test("matchesCookieDomain handles a config-supplied expected domain", async () => {
const { matchesCookieDomain } = await import("../../open-sse/utils/cookieDomain.ts");
assert.equal(matchesCookieDomain("app.example.com", "example.com"), true);
assert.equal(matchesCookieDomain(".example.com", ".example.com"), true);
assert.equal(matchesCookieDomain("example.com", ".example.com"), true);
assert.equal(matchesCookieDomain("example.com.attacker.tld", "example.com"), false);
assert.equal(matchesCookieDomain("notexample.com", "example.com"), false);
assert.equal(matchesCookieDomain("example.com", "app.example.com"), false);
});
test("matchesCookieDomain fails closed on a missing expected domain", async () => {
const { matchesCookieDomain } = await import("../../open-sse/utils/cookieDomain.ts");
assert.equal(matchesCookieDomain("example.com", undefined), false);
assert.equal(matchesCookieDomain("example.com", ""), false);
assert.equal(matchesCookieDomain("example.com", "."), false);
});