diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d69f0921b9..0ef629cf2e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -624,7 +624,7 @@ jobs: cache: npm - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime - - run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts" + - run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts" test-vitest: name: Vitest (MCP / autoCombo / UI components) @@ -676,7 +676,7 @@ jobs: cache: npm - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime - - run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/4 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts" + - run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/4 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts" node-26-compat-build: name: Node 26 Compatibility Build @@ -729,7 +729,7 @@ jobs: cache: npm - uses: ./.github/actions/npm-ci-retry - run: npm run check:node-runtime - - run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/4 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts" + - run: node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/4 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts" test-coverage-shard: name: Coverage Shard (${{ matrix.shard }}/8) @@ -770,7 +770,7 @@ jobs: --exclude=tests/** \ --exclude=**/*.test.* \ node --max-old-space-size=4096 --import tsx --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 \ - --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts" + --test-shard=${{ matrix.shard }}/8 tests/unit/*.test.ts "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts" - name: Upload raw shard coverage if: always() uses: actions/upload-artifact@v7 diff --git a/README.md b/README.md index 8b9a55eecd..29b92388de 100644 --- a/README.md +++ b/README.md @@ -335,7 +335,7 @@ Result: 4 layers of fallback = zero downtime - **πŸ—œοΈ Compression hardening** β€” a default-on **inflation guard** (discard the stacked result and send the verbatim original whenever compression would _grow_ the prompt), completed **Caveman rule packs** for German / French / Japanese (dedup + ultra) plus a new **Chinese (文言 / wΓ©nyΓ‘n) input pack** with zh-vs-ja auto-detection, and **RTK filters for Gradle & .NET (`dotnet`)** build output. β†’ [Compression](docs/compression/COMPRESSION_ENGINES.md) - **πŸ’Έ Honest flat-rate cost** β€” subscription / coding-plan providers (ChatGPT Web, grok-web, the Minimax / Kimi / GLM / Alibaba Coding plans, Xiaomi MiMo…) now read **$0** in cost analytics instead of an inflated per-token estimate, while budget / quota / routing keep estimating unchanged. β†’ [API Reference](docs/reference/API_REFERENCE.md) -- **βš–οΈ Quota-Share routing** β€” a dedicated combo strategy that spreads load across accounts by _available quota_: Deficit-Round-Robin scheduling, per-connection `max_concurrent` with cooldown-wait queueing, multi-window usage buckets (5h / 7d / per-model), per-(key,model) caps, session stickiness for prompt-cache integrity, and proactive saturation from upstream token-usage headers. β†’ [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md) +- **βš–οΈ Quota-Share routing** β€” a dedicated combo strategy that spreads load across accounts by _available quota_: Deficit-Round-Robin scheduling, per-connection `max_concurrent` with cooldown-wait queueing, multi-window usage buckets (5h / 7d / per-model), per-(key, model) caps, session stickiness for prompt-cache integrity, and proactive saturation from upstream token-usage headers. β†’ [Resilience Guide](docs/architecture/RESILIENCE_GUIDE.md) - **πŸ€– One-command CLI/agent setup** β€” a dedicated `setup-*` command configures each coding tool to route through OmniRoute (Claude Code, Codex, Cline, Continue, Cursor, Roo Code, Kilo Code, Crush, Goose, Qwen Code, Aider, OpenCode); `omniroute launch` / `omniroute launch-codex` are zero-config launchers. β†’ [CLI Integrations](docs/guides/CLI-INTEGRATIONS.md) - **πŸ›°οΈ Remote mode** β€” drive a remote OmniRoute from any machine with scoped access tokens (`omniroute connect` / `omniroute contexts` / `omniroute tokens`), plus an `omniroute login antigravity` helper that runs Google "native/desktop" OAuth on your own machine and pastes a credential blob into a remote/VPS install (where the loopback redirect is unreachable). β†’ [Remote Mode](docs/guides/REMOTE-MODE.md) - **🧭 Smarter auto-routing** β€” OpenRouter-style `auto/:` combos (e.g. `auto/coding:fast`, `auto/reasoning:pro`), a **Fusion** strategy (fan out to a panel of models in parallel, then synthesize via a judge), **task-aware routing** (best-fit connection per task type), per-request `X-Route-Model` override, live Arena-ELO + models.dev model intelligence, per-step account allowlists, provider-wildcard combo steps, nested combo-ref execution, sticky weighted selection, and `web_search`-aware routing. β†’ [Auto-Combo](docs/routing/AUTO-COMBO.md) @@ -344,7 +344,7 @@ Result: 4 layers of fallback = zero downtime - **πŸ’Έ Cost telemetry everywhere** β€” `X-OmniRoute-*` cost/usage headers on every endpoint (including media), a non-token cost engine, a cache-HIT `X-OmniRoute-Cost-Saved` header, and per-key USD spend quotas. β†’ [API Reference](docs/reference/API_REFERENCE.md) - **🧠 Memory you control** β€” opt-in int8 vector quantization (Qdrant + sqlite-vec), memory off by default, and a per-request `x-omniroute-no-memory` header. β†’ [Memory](docs/frameworks/MEMORY.md) - **πŸ›‘οΈ Security** β€” a prompt-injection guard across every LLM route (backed by a red-team suite), plus a free DuckDuckGo last-resort web search. β†’ [Guardrails](docs/security/GUARDRAILS.md) -- **🀝 More providers & agents** β€” Cursor Cloud Agent (a 4th cloud agent), CodeBuddy CN (`copilot.tencent.com`), a Google Flow video-generation provider, new gateways **DGrid** and **Pioneer AI** (Fastino Labs), inbound **xAI Grok** translators plus **Grok Build (xAI)** with an OAuth import-token flow, GPT-4 / GPT-4o-mini on the GitHub Copilot provider, multi-model **Factory Droid**, **ZenMux Free** (session-cookie free tier), **Alibaba DashScope** text-to-video (`wan2.7-t2v`), a refreshed 237-provider catalog (OrcaRouter, Wafer AI, OpenAdapter, dit.ai, TokenRouter, …), Vertex AI media generation (speech / transcription / music / video), and one-click account import from CLIProxyAPI (`~/.cli-proxy-api/`). β†’ [Providers](docs/reference/PROVIDER_REFERENCE.md) +- **🀝 More providers & agents** β€” Cursor Cloud Agent (a 4th cloud agent), CodeBuddy CN (`copilot.tencent.com`), a Google Flow video-generation provider, new gateways **DGrid** and **Pioneer AI** (Fastino Labs), inbound **xAI Grok** translators plus **Grok Build (xAI)** with an OAuth import-token flow, GPT-4 / GPT-4o-mini on the GitHub Copilot provider, multi-model **Factory Droid**, **ZenMux Free** (session-cookie free tier), **Alibaba DashScope** text-to-video (`wan2.7-t2v`), a refreshed 237-provider catalog (OrcaRouter, Wafer AI, OpenAdapter, dit.ai, TokenRouter, …), Vertex AI media generation (speech/transcription/music/video), and one-click account import from CLIProxyAPI (`~/.cli-proxy-api/`). β†’ [Providers](docs/reference/PROVIDER_REFERENCE.md) - **⚑ Local performance & infra** β€” a one-click local Redis launcher (`omniroute redis up`, plus a dashboard Redis panel), one-click **Cloudflare Workers** and **Deno Deploy** relay deployers wired into the proxy pool, and an optional Bifrost Go sidecar that offloads the hottest relay path (`BIFROST_BASE_URL`, with automatic fallback to the TypeScript path on timeout) β€” now with a relay-backend selector (`OMNIROUTE_RELAY_BACKEND=ts|bifrost|auto`) so the `/v1/relay` endpoint stays the stable surface while choosing the fastest backend internally. β†’ [Environment](docs/reference/ENVIRONMENT.md)
@@ -555,7 +555,7 @@ claude mcp add-server omniroute --type http --url http://localhost:20128/api/mcp -> **Why use many token when few token do trick?** Every request passes through OmniRoute's compression pipeline **transparently** β€” no client changes. It's now a **stack of 10 composable engines** that run in order and mix & match per routing combo β€” building on ideas from [RTK](https://github.com/rtk-ai/rtk), [Caveman](https://github.com/JuliusBrussee/caveman) (⭐ 78K+), [LLMLingua-2](https://github.com/microsoft/LLMLingua), and [Troglodita](https://github.com/leninejunior/troglodita) (PT-BR). +> **Why use many tokens when few tokens do the trick?** Every request passes through OmniRoute's compression pipeline **transparently** β€” no client changes. It's now a **stack of 10 composable engines** that run in order and mix & match per routing combo β€” building on ideas from [RTK](https://github.com/rtk-ai/rtk), [Caveman](https://github.com/JuliusBrussee/caveman) (⭐ 78K+), [LLMLingua-2](https://github.com/microsoft/LLMLingua), and [Troglodita](https://github.com/leninejunior/troglodita) (PT-BR). ### 🧱 The 10-engine stack diff --git a/config/quality/complexity-baseline.json b/config/quality/complexity-baseline.json index d429f43987..2321321c8f 100644 --- a/config/quality/complexity-baseline.json +++ b/config/quality/complexity-baseline.json @@ -1,6 +1,7 @@ { "_comment": "Catraca de complexidade (check-complexity.mjs, ESLint core rules complexity>=15 e max-lines-per-function>80 sobre src+open-sse+electron+bin via eslint.complexity.config.mjs). Conta total de violacoes; so pode cair. --update ratcheta.", - "count": 1981, + "count": 1982, + "_rebaseline_2026_07_01_v3843_release": "1981->1982 (+1). v3.8.43 cycle drift, surfaced after check:mutation-test-coverage was fixed (it was masked behind that earlier step in the Fast Quality Gates chain). 1982 = the value measured by check:complexity on BOTH fce85136c (release tip) and 6d7060e21 (release + the 5 CI fixes) β€” identical, so all +1 is inherited cycle drift; the fixes touch only test files + linkify.ts safeHttpHref (cyclomatic ~4, well under the >=15 threshold, 0 new violations) + config JSON. Tighten via --update next cycle.", "_rebaseline_2026_06_28_v3840_5237_reconcile": "1980->1981 (+1). Inherited release/v3.8.40 drift surfaced while merging PR #5237 (impersonation-UA refresh) β€” the +1 is present on the pristine release tip (d8a392a47) WITHOUT #5237's changes, so it is #5222 (antigravity fallback-LRU retry) / #5221 (command-code) growth that merged via --admin without ratcheting complexity (the PR->release fast-gates do not run check:complexity). #5237 itself is complexity-net-zero: its only edits are a single UA constant, a regenerated golden snapshot, and baseline JSONs. Structural reduction tracked in #3501.", "_rebaseline_2026_06_27_v3838_release": "1978->1980 (+2). v3.8.38 cycle-close drift surfaced by the release-green pre-flight (check:complexity does NOT run on PR->release fast-gates). +2 from late-cycle feature/fix merges (compression fidelity-gate steps #5143, SSE hardening). Release-finalize working tree touches ONLY CHANGELOG.md + i18n mirrors + the 2 baseline JSONs β€” 0 production-code change. Structural reduction tracked in #3501.", "_rebaseline_2026_06_26_v3838_ownerprs_batch": "1972->1978 (+6). Drift do lote de merges de PRs do dono + contribuidores em release/v3.8.38 (sessao /review-prs): #4845 (antigravity convertGeminiToOpenAI), #5105 (executor zenmux-free), #5020 (executor grok-cli), #4940 (usage dedupe guard), #5093 (resilience: quota cutoff/gemini mime/model-lockout cooldown), #5015 (quota hydration + auto-combo scoping). Cada um e crescimento de feature/fix legitimo recem-TDD'd, nao regressao; o gate check:complexity NAO roda no fast-path PR->release, entao o ramo acumula sem rebaselinar (mesma familia dos rebaselines anteriores). #5121 cookie-dedup foi mantido complexity-NEUTRO via extracao do helper findExistingCookieConnection. Reducao estrutural fica como debt (#3501).", diff --git a/config/quality/file-size-baseline.json b/config/quality/file-size-baseline.json index eff97788ed..930f1fbf2a 100644 --- a/config/quality/file-size-baseline.json +++ b/config/quality/file-size-baseline.json @@ -158,14 +158,14 @@ "open-sse/mcp-server/tools/advancedTools.ts": 1118, "_rebaseline_2026_06_27_5193_antigravity_basered": "Base-red (pre-existing release drift, fast-gate PR->release skips check:file-size): accountFallback.ts 1773->1777 and src/app/api/providers/[id]/test/route.ts 924->940 were already over their frozen caps on release/v3.8.39 independent of any antigravity change. Owner chose to rebaseline (keep the documented issue-reference comments #1846/#1449/#347 etc.) rather than accept the contributor comment-stripping in #5200/#5198. Reverted #5200 to restore the comments; bumped these two frozen caps to the actual base sizes. No logic change.", "open-sse/services/accountFallback.ts": 1783, - "open-sse/services/batchProcessor.ts": 828, + "open-sse/services/batchProcessor.ts": 915, "open-sse/services/browserBackedChat.ts": 850, "open-sse/services/claudeCodeCompatible.ts": 1202, "_rebaseline_pr4592_exclude_exhausted_auto": "Reconcile #4592 already-merged growth: combo.ts 2991->3036 (+45, terminal-status quota-cutoff exclusion in buildAutoCandidates + opt-in gate). Fast-gate PR->release does not run check:file-size.", "_rebaseline_2026_06_24_headroom_strategy": "Headroom-aware connection selection (dario technique): combo.ts 3168->3180 (+12 = a new `else if (strategy === \"headroom\")` dispatch branch in handleComboChat that delegates to orderTargetsByHeadroom + its log line, plus the import). The actual logic lives OUT of the god-file: the pure ranker rankByHeadroom/computeHeadroom is the new leaf open-sse/services/combo/headroomRanking.ts (91 LOC, 3190 (+10 = one new `else if (strategy === \"quota-share\")` dispatch branch in handleComboChat that delegates 100% to selectQuotaShareTarget + its log line, plus the import). All the new logic lives OUT of the god-file in two new leaves under open-sse/services/combo/: quotaShareInflight.ts (in-flight counter with TTL/lease, ~150 LOC 3225 (+35) = one new `else if (strategy === \"task-aware\")` dispatch branch delegating 100% to selectTaskAwareTarget + its imports/log lines. All scoring/classification logic lives OUT of the god-file in the new leaf open-sse/services/taskAwareRouting.ts (553 LOC 854, -35), but the StackOptions.fidelityGate field, the `const fidelityGate` reads at the two stacked-loop dispatch chokepoints, and the import of FidelityGateConfig are irreducible wiring that cannot leave strategySelector without an architectural refactor of the pre-existing stacked pipeline. Net: 889->854 (+6 vs the pre-Milestone-B frozen 848). Covered by tests/unit/compression/*.test.ts (940 pass).", "_rebaseline_2026_06_28_5243_risk_gate_prepass": "PR #5243 (compression risk-gate pre-pass) own growth: open-sse/services/compression/strategySelector.ts 854->899 (+45). The three exported entry points (applyCompression/applyStackedCompression/applyStackedCompressionAsync) become thin wrappers over pure-extracted private bodies (runCompression/runStackedCompression/runStackedCompressionAsync) so the risk-gate mask->run->restore wrapper sits strictly OUTSIDE the per-step loop β€” a single universal integration point. The wrapper logic itself (resolveRiskGate/withRiskGate) lives in the new riskGate/strategyWrap.ts (960 (+61 = the opt-in result-memoization branches in applyCompression/applyCompressionAsync β€” principal+determinism gate, makeMemoKey lookup/store with model+supportsVision folded into the key, recompute-with-memo-off). Default off (memoizeCompressionResults), so zero behavior change. The memo helpers live in the leaf resultMemo.ts (985 (correlation_id column on CallLogSummaryRow + read/map), usageHistory.ts 983->988 (correlationId metadata normalize), chat.ts 1575->1632 (withCorrelationId response wiring + combo-failure log carrying correlationId), chatHelpers.ts new 811 (withCorrelationId helper + reqId threading; was 7913017, combos/page 4594->4608, AddApiKeyModal 868->869, providerPageHelpers 974->996, chat.ts 1635->1647, auth.ts 2401->2403, batchProcessor 828->915, combo.ts 3368->3387) + 2 novos acima do cap (huggingchat.ts 813, tests web-cookie-providers-new 827) + 4 test files cresceram. Modularizacao deferida (blast-radius mid-release); congelado no estado atual p/ o proximo ciclo ratchetar daqui." }, "testCap": 800, "testFrozen": { @@ -266,11 +268,11 @@ "tests/integration/skills-pipeline.test.ts": 918, "tests/unit/account-fallback-service.test.ts": 1572, "tests/unit/arena-elo-sync.test.ts": 830, - "tests/unit/batch_api.test.ts": 1303, + "tests/unit/batch_api.test.ts": 1324, "tests/unit/cc-compatible-provider.test.ts": 1179, "tests/unit/chatcore-sanitization.test.ts": 829, "tests/unit/chatcore-translation-paths.test.ts": 2810, - "tests/unit/chatgpt-web.test.ts": 3159, + "tests/unit/chatgpt-web.test.ts": 3170, "tests/unit/combo-routing-engine.test.ts": 3213, "tests/unit/combo-strategy-fallbacks.test.ts": 880, "tests/unit/db-core-init.test.ts": 877, @@ -289,8 +291,8 @@ "_rebaseline_2026_06_27_5193_antigravity_test": "#5193 own test growth: oauth-providers-config.test.ts 870->873 (+3: antigravity projectId assertion + 50ms tick for the now fire-and-forget onboarding, matching the no-PKCE/no-openid flow).", "tests/unit/oauth-providers-config.test.ts": 873, "tests/unit/perplexity-web.test.ts": 959, - "tests/unit/provider-models-route.test.ts": 1618, - "tests/unit/provider-validation-specialty.test.ts": 2843, + "tests/unit/provider-models-route.test.ts": 1628, + "tests/unit/provider-validation-specialty.test.ts": 2874, "_rebaseline_pr4613_compatible_provider_groups": "Reconcile #4613 already-merged growth: providers-page-utils.test.ts 1004->1052 (+48, buildCompatibleProviderGroups partition unit test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.", "tests/unit/providers-page-utils.test.ts": 1052, "tests/unit/reasoning-cache.test.ts": 980, @@ -307,7 +309,8 @@ "tests/unit/translator-resp-gemini-to-openai.test.ts": 1234, "tests/unit/usage-service-hardening.test.ts": 1633, "tests/unit/vscode-token-routes.test.ts": 1212, - "tests/unit/combo-config.test.ts": 881 + "tests/unit/combo-config.test.ts": 881, + "tests/unit/web-cookie-providers-new.test.ts": 827 }, "_rebaseline_2026_06_09": "Re-baseline consciente pre-release v3.8.19: 9 arquivos cresceram durante o ciclo (features mergeadas: RequestLoggerV2 +281 request-logger rework, stream +101, combo +73, chatCore +45, catalog +32 fable-5/catalog-flag, callLogs +4, accountFallback +2, usageHistory novo 840) + core.ts +7 (fix resetAllDbModuleState, PR 3536). A catraca segue valendo destes valores β€” proximo crescimento falha. Decisao: encolher (esp. RequestLoggerV2/chatCore) e a issue #3501 ficam para o ciclo seguinte.", "_rebaseline_2026_06_11_phase1f": "Phase 1f (#3501): ProviderDetailPageClient.tsx 4948β†’4062 (-886 LOC); 3 novos hooks extraΓ­dos. useProviderConnections.ts=954 acima do cap=800 β€” justificado: extraΓ§Γ£o direta do god-component (zero lΓ³gica nova), prΓ³pria reduΓ§Γ£o do cliente supera o custo. useProviderSettings.ts=263 e useProviderModels.ts=154 jΓ‘ abaixo do cap.", diff --git a/config/quality/quality-baseline.json b/config/quality/quality-baseline.json index 9b4c150fa7..30c0e45639 100644 --- a/config/quality/quality-baseline.json +++ b/config/quality/quality-baseline.json @@ -2,8 +2,9 @@ "_comment": "Catraca de qualidade. 'down' = nao pode aumentar; 'up' = nao pode cair. Atualize via 'npm run quality:ratchet -- --update' (somente quando melhora). Cada valor e um numero REAL medido, nunca um chute. Cobertura entra na Fase 4 a partir de um run de cobertura mergeada no CI.", "metrics": { "eslintWarnings": { - "value": 4121, + "value": 4158, "direction": "down", + "_rebaseline_2026_07_01_v3843_release": "4121->4158 (+37). v3.8.43 cycle drift surfaced by the release-green pre-flight; the Quality Ratchet does NOT run on PR->release fast-gates, so warnings accrued unmeasured across this cycle. 4158 = the value measured by the CI Quality Ratchet on the release tip fce85136c (release PR #5609). Trust-but-verify: the fix/release-v3843-ci-reds branch touches only test files (rtk-mcp-tools de-flake, compression-studio e2e anchor, oauth-error-linkify hardening test) + src/shared/utils/linkify.ts (eslint-clean, 0 warnings) + stryker.conf.json + this baseline -> 0 new warnings, so all +37 is inherited cycle drift (any warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.", "_rebaseline_2026_06_30_v3842_release": "4116->4121 (+5). v3.8.42 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings accrued unmeasured across this cycle's 90 commits β€” chatgpt-web PoW sha3-512 BoringSSL fix #5540, provider baseUrl/i18n umbrella #5511, proxy union proxyUrlMap+acct.proxy #5521, dead-code + duplication waves #5468-#5495, tls-options packaging #5503, release-freeze + .npmrc fetch-retries #5506, dast-smoke spawn-prefix client-safe extraction #5546, plus ~30 SSE/translator/combo/dashboard fixes). Trust-but-verify: measured 4121 via `npm run check:release-green` on the working tree INCLUDING my reconciliation (CHANGELOG/i18n/golden snapshot + file-size baseline) β€” those touch only config JSON + a provider snapshot (eslint-ignored) and contribute 0 warnings; all +5 is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.", "_rebaseline_2026_06_29_v3841_release": "4103->4116 (+13). v3.8.41 cycle drift surfaced by the release-green collect (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings accrued unmeasured across this cycle's 52 commits β€” relay backend #5315, gemini catalog #5337, services dashboard #5299, empty-Claude-messages guard #5342, thinking-budget/redacted-replay + marker opt-out #5312/#5352/#5367, opencode proxy-pool + observability #5217/#5370/#5351, cors + HTTPS-serve #5242/#5360/#5361, grok cf_clearance #5350/#5358, oauth/chatgpt-web/routing/cli/dashboard/rerank #5326/#5240/#5239/#5238/#5264/#5332, partially offset by the dead-code sweep #5321-#5371). Trust-but-verify: measured 4116 via `npm run quality:collect` on the working tree INCLUDING my reconciliation (CHANGELOG/i18n/README/env docs + baselines) AND the lint-fix in useServiceLogs.ts β€” that fix REMOVES a setState-in-effect ERROR (eslintErrors stays 0) and adds an `open` listener with no `any`/unused, contributing 0 warnings; all +13 is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.", "_rebaseline_2026_06_29_v3840_release": "4090->4103 (+13). v3.8.40 cycle drift surfaced by the release-green pre-flight + the release PR Quality Ratchet (the ratchet does NOT run on PR->release fast-gates, so warnings accrued unmeasured across this cycle's ~57 commits β€” compression roadmap relevance/hard-budget/memoization/transparency/saliency/splitter/tool_search/RTK/QuantumLock #5289/#5288/#5286/#5284/#5285/#5283/#5269/#5268/#5260, ~20 SSE/translator/combo fixes #5248/#5250/#5254/#5261/#5255/#5273/#5258, M365 Copilot provider #5302, public-origin centralization #5278). Trust-but-verify: measured 4103 locally via `npm run quality:collect` on the release tip INCLUDING my reconciliation commits (CHANGELOG + main merge + the 2 regression test fixes 165c823f5) β€” the test fixes add 0 `any`/warnings (health-autopilot added a NextRequest import + asserts; chat-pipeline changed one Accept string + a comment), so all +13 is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle.", @@ -103,16 +104,18 @@ "_rebaseline_2026_06_28_v3839_release": "78.4 -> 77.5 (-0.9, beyond the 0.5 eps). v3.8.39 cycle drift surfaced ONLY on the release PR (i18n-ui-coverage does NOT run on PR->release fast-gates). The cycle added new UI strings (compression studio TOON A/B table, antigravity remote-login dashboard field, amber warning icon) to the en denominator faster than the 37 non-en locales were translated; those locales need `npm run i18n:run` with OMNIROUTE_TRANSLATION_API_KEY (unavailable locally) β€” same precedent as _rebaseline_2026_06_18_v3828_cycle_close + _quality_rebaseline_2026_06_20_ci_ratchet. Measured by CI collect-metrics (run 28317145160) = 77.5. My release-finalize tree changes no src/i18n/messages/*.json. Tightening is tracked as follow-up (run i18n:run with creds)." }, "deadExports": { - "value": 225, + "value": 227, "direction": "down", + "_rebaseline_2026_07_01_v3843_release": "225->227 (+2). v3.8.43 cycle drift, surfaced in the Quality Ratchet job after eslintWarnings was rebaselined (check:dead-code runs there). 227 = measured by check:dead-code (knip) on the release tip 4635076eb. The 5 CI fixes add 0 dead exports: safeHttpHref in linkify.ts is module-local AND used (called by linkifyText); no new exports; test files are not scanned. Tighten via --update next cycle.", "dedicatedGate": true, "_rebaseline_2026_06_30_v3842_deadcode_wave": "310 -> 225. Measured by `node scripts/check/check-dead-code.mjs` on the v3.8.42 tip after the JxnLexn dead-code (#5463/#5464/#5466) + duplication (#5471..#5500) wave landed: DEAD_EXPORTS=133 + DEAD_FILES=92 = 225. The stale 310 was the v3.8.38 release snapshot never ratcheted on PR->release fast-gates (check:dead-code runs only on ci.yml PR->main, not quality.yml). Tightening to the true measured value; release-time captain rebaselines up if parallel cycle merges add dead exports.", "_rebaseline_2026_06_27_v3838_release": "345->346 (+1). v3.8.38 cycle drift surfaced by the release-green pre-flight (Quality Ratchet does NOT run on PR->release fast-gates). Net +1 inherited from this cycle's feature/fix merges (new executors/providers, compression fidelity-gate module) minus #5138's removal of dead legacy store modules. Release-finalize working tree touches ONLY CHANGELOG.md + i18n mirrors + README + baselines β€” 0 production-code change. Structural cleanup tracked as debt.", "_rebaseline_2026_06_26_v3837_release": "343->345. v3.8.37 cycle drift surfaced by the release-green pre-flight (the Quality Ratchet does NOT run on PR->release fast-gates, so warnings/complexity accrued unmeasured across this cycle's 76 commits β€” provider adds DGrid/Pioneer/xAI, headroom proxy lifecycle #4649, ~50 SSE/translator fixes, Engine Combos #5062). Trust-but-verify: this release-finalize working tree touches ONLY CHANGELOG.md, docs/i18n/*/CHANGELOG.md mirrors, and these baselines β€” 0 production-code change, so all drift is inherited cycle drift (`any` warn-allowed in open-sse/ + tests/). Tighten via --require-tighten next cycle." }, "cognitiveComplexity": { - "value": 842, + "value": 845, "direction": "down", + "_rebaseline_2026_07_01_v3843_release": "842->845 (+3). v3.8.43 cycle drift, surfaced after check:mutation-test-coverage was fixed (masked behind it in the Fast Quality Gates chain). 845 = measured by check:cognitive-complexity on BOTH fce85136c and 6d7060e21 (identical) β€” all +3 is inherited cycle drift; the 5 CI fixes add 0 (safeHttpHref cognitive ~2, under the 15 threshold). Tighten via --update next cycle.", "dedicatedGate": true, "_rebaseline_2026_06_29_v3841_release": "841->842 (+1). v3.8.41 cycle drift surfaced by the release-PR Quality Ratchet (cognitive-complexity does NOT run on PR->release fast-gates). The Phase-0 pre-flight measured 840 on the pre-campaign tip; the campaign's +34 later commits (thinking-budget/marker #5312/#5352/#5367, opencode proxy-pool/observability #5217/#5370/#5351, cors/HTTPS #5242/#5360/#5361, grok #5350/#5358, oauth/routing/cli #5326/#5239/#5238) added +2 net. My release-finalize changes are docs/baselines + a lint-fix (useServiceLogs open-listener), a test alignment (encryption.spec) and a pack-allowlist entry β€” all complexity-neutral. Structural shrink tracked in #3501.", "_rebaseline_2026_06_27_v3838_release": "833->841 (+8). v3.8.38 cycle drift surfaced by the release-green pre-flight (cognitive-complexity does NOT run on PR->release fast-gates). Inherited drift from this cycle's ~78 feature/fix merges (compression fidelity-gate #5143, SSE/streaming hardening #5124/#5108/#5085, resilience #5093, quota keepalive #5102, contributor provider/translator branches). god-file decomposition #3501 is complexity-neutral. Release-finalize working tree touches ONLY CHANGELOG.md + i18n mirrors + README + baselines β€” 0 production-code change. Structural shrink tracked in #3501.", diff --git a/config/quality/test-masking-allowlist.json b/config/quality/test-masking-allowlist.json index 6e49b03066..31ee802e97 100644 --- a/config/quality/test-masking-allowlist.json +++ b/config/quality/test-masking-allowlist.json @@ -7,5 +7,6 @@ "tests/unit/compression/session-dedup.test.ts": "v3.8.29 #4226: the vestigial SessionDedup round-trip helper was removed from source; its 2 asserts were removed accordingly (32β†’30). Helper no longer exists. Verified legitimate, not masking. Prune after v3.8.29 merges to main.", "tests/unit/compression/ultra.test.ts": "v3.8.29 #4253: the vestigial SLM seam + dead deprecated alias were removed from the ultra compression engine; 6 asserts covering the removed seam were removed accordingly (49β†’43). Verified legitimate, not masking. Prune after v3.8.29 merges to main.", "tests/unit/db-backup-extended.test.ts": "v3.8.29 #4132: db-backup de-flake β€” 1 timing-sensitive assertion on fire-and-forget backup completion was removed in favor of awaiting actual completion (44β†’43). Verified legitimate, not masking. Prune after v3.8.29 merges to main.", - "@omniroute/opencode-plugin/tests/combos.test.ts": "v3.8.31 #4384: the plugin now prefixes every catalog key with the `omniroute` provider id and drops the legacy `combo/` namespace; the test asserting raw-deletion + a `combo/` key (a namespace that no longer exists) was removed and the remaining asserts switched to `omniroute/` keys (82β†’81). Asserts updated to the new key contract, not weakened. Verified legitimate. Prune after v3.8.31 merges to main." + "@omniroute/opencode-plugin/tests/combos.test.ts": "v3.8.31 #4384: the plugin now prefixes every catalog key with the `omniroute` provider id and drops the legacy `combo/` namespace; the test asserting raw-deletion + a `combo/` key (a namespace that no longer exists) was removed and the remaining asserts switched to `omniroute/` keys (82β†’81). Asserts updated to the new key contract, not weakened. Verified legitimate. Prune after v3.8.31 merges to main.", + "tests/unit/chatgpt-web.test.ts": "v3.8.43 #5549: fix(chatgpt-web) restore dot-form Pro model ids β€” dois assert.equal separados (base Pro slug + pass-through slug) consolidados num ΓΊnico assert.equal(body.model, expectedSlugById[omniId], ...) orientado por tabela de mapeamento (281β†’280). Asserts consolidados, nΓ£o enfraquecidos. Verificado legΓ­timo. Prune apΓ³s v3.8.43 mergear para main." } diff --git a/open-sse/services/modelDeprecation.ts b/open-sse/services/modelDeprecation.ts index b38b76c36f..cf965abf34 100644 --- a/open-sse/services/modelDeprecation.ts +++ b/open-sse/services/modelDeprecation.ts @@ -60,20 +60,40 @@ const BUILT_IN_ALIASES: Record = { }; // ── Custom Aliases (persisted via Settings API) ───────────────────────────── -let _customAliases: Record = {}; +// +// Backed by globalThis so the singleton store is shared across the SEPARATE webpack +// module graphs Next.js builds for `instrumentation.ts` (boot-time hydration via +// applyRuntimeSettings β†’ setCustomAliases) and the app-route `GET /api/settings/model-aliases`. +// A plain module-level `let` is DUPLICATED per graph, so startup hydration lands on the +// instrumentation graph's copy while the API route reads an empty copy β€” the exact +// symptom #5777 patched at the route layer. Migrating the store to globalThis fixes the +// root cause (both instances read/write one store), mirroring the #5312 pattern already +// applied to thinkingBudget.ts and backgroundTaskDetector.ts (and systemPrompt.ts #2470). +const CUSTOM_ALIASES_GLOBAL_KEY = "__omniroute_customAliases__"; +const _aliasStore = globalThis as unknown as Record< + string, + Record | undefined +>; + +function customAliases(): Record { + if (!_aliasStore[CUSTOM_ALIASES_GLOBAL_KEY]) { + _aliasStore[CUSTOM_ALIASES_GLOBAL_KEY] = {}; + } + return _aliasStore[CUSTOM_ALIASES_GLOBAL_KEY]!; +} /** * Set custom aliases (called from settings API or startup). */ export function setCustomAliases(aliases: Record): void { - _customAliases = { ...aliases }; + _aliasStore[CUSTOM_ALIASES_GLOBAL_KEY] = { ...aliases }; } /** * Get current custom aliases. */ export function getCustomAliases(): Record { - return { ..._customAliases }; + return { ...customAliases() }; } /** @@ -81,7 +101,7 @@ export function getCustomAliases(): Record { * Custom aliases take precedence over built-in. */ export function getAllAliases(): Record { - return { ...BUILT_IN_ALIASES, ..._customAliases }; + return { ...BUILT_IN_ALIASES, ...customAliases() }; } /** @@ -95,7 +115,8 @@ export function resolveModelAlias(modelId: string): string { if (!modelId) return modelId; // Check custom aliases first (higher priority) - if (_customAliases[modelId]) return _customAliases[modelId]; + const custom = customAliases(); + if (custom[modelId]) return custom[modelId]; // Then check built-in if (BUILT_IN_ALIASES[modelId]) return BUILT_IN_ALIASES[modelId]; @@ -129,15 +150,16 @@ export function isDeprecated(modelId: string): boolean { * Add a custom alias. */ export function addCustomAlias(from: string, to: string): void { - _customAliases[from] = to; + customAliases()[from] = to; } /** * Remove a custom alias. */ export function removeCustomAlias(from: string): boolean { - if (_customAliases[from]) { - delete _customAliases[from]; + const custom = customAliases(); + if (custom[from]) { + delete custom[from]; return true; } return false; diff --git a/open-sse/translator/request/openai-to-gemini.ts b/open-sse/translator/request/openai-to-gemini.ts index b2c03bffaa..703f2cbe10 100644 --- a/open-sse/translator/request/openai-to-gemini.ts +++ b/open-sse/translator/request/openai-to-gemini.ts @@ -24,6 +24,20 @@ import { cleanJSONSchemaForAntigravity, } from "../helpers/geminiHelper.ts"; import { buildGeminiTools, sanitizeGeminiToolName } from "../helpers/geminiToolsSanitizer.ts"; +import { + type GeminiGenerationConfig, + isVertexGeminiProvider, + buildChangedToolNameMap, + extractClientThoughtSignature, + deepCleanUndefined, + applyAntigravityGenerationDefaults, + stringifyHistoricalToolArguments, + buildInertHistoricalToolCallText, + buildInertHistoricalToolResponseText, + escapeHistoricalContextAttribute, + escapeHistoricalContextContent, + buildHistoricalToolResultContext, +} from "./openai-to-gemini/helpers.ts"; // Observed Antigravity wrapper output cap, not an underlying model capability. // Keep this bridge-local: Antigravity currently caps visible output around 16K. @@ -43,20 +57,6 @@ const GEMINI_BUILTIN_TOOL_NAMES = new Set([ type GeminiPart = Record; type GeminiContent = { role: string; parts: GeminiPart[] }; -type GeminiGenerationConfig = { - temperature?: unknown; - topP?: unknown; - topK?: unknown; - maxOutputTokens?: unknown; - thinkingConfig?: { - thinkingBudget: number; - includeThoughts: boolean; - }; - responseMimeType?: string; - responseSchema?: unknown; - stopSequences?: string[] | unknown[]; -}; - type GeminiFunctionDeclaration = { name: string; description: string; @@ -118,126 +118,6 @@ type GeminiToolNameOptions = { supportsSignatureBypass?: boolean; }; -// Vertex AI (and Vertex Partner models) reject the OpenAI-style `id` field inside -// function_call / function_response parts. Detect these by the routed provider id. -function isVertexGeminiProvider(provider: unknown): boolean { - return provider === "vertex" || provider === "vertex-partner"; -} - -type OpenAIToolCallLike = { - thoughtSignature?: unknown; - thought_signature?: unknown; - function?: { - thoughtSignature?: unknown; - thought_signature?: unknown; - }; -}; - -function buildChangedToolNameMap(toolNameMap: Map): Map | null { - const changedEntries = [...toolNameMap.entries()].filter( - ([sanitizedName, originalName]) => sanitizedName !== originalName - ); - return changedEntries.length > 0 ? new Map(changedEntries) : null; -} - -function extractClientThoughtSignature(toolCall: unknown): string | null { - if (!toolCall || typeof toolCall !== "object") return null; - const candidate = toolCall as OpenAIToolCallLike; - - const signature = - candidate.thoughtSignature || - candidate.thought_signature || - candidate.function?.thoughtSignature || - candidate.function?.thought_signature || - null; - return typeof signature === "string" && signature.length > 0 ? signature : null; -} - -function deepCleanUndefined(value: unknown, depth = 0): void { - if (depth > 10 || !value || typeof value !== "object") { - return; - } - if (Array.isArray(value)) { - for (const item of value) { - deepCleanUndefined(item, depth + 1); - } - } else { - const obj = value as Record; - for (const key of Object.keys(obj)) { - const val = obj[key]; - if (typeof val === "string" && val === "[undefined]") { - delete obj[key]; - } else { - deepCleanUndefined(val, depth + 1); - } - } - } -} - -function applyAntigravityGenerationDefaults(generationConfig: GeminiGenerationConfig) { - const config = { ...generationConfig }; - if (config.topK === undefined) { - config.topK = 40; - } - if (config.topP === undefined) { - config.topP = 1; - } - - const thinkingBudget = Number(config.thinkingConfig?.thinkingBudget); - const maxOutputTokens = Number(config.maxOutputTokens); - if ( - Number.isFinite(thinkingBudget) && - thinkingBudget > 0 && - (!Number.isFinite(maxOutputTokens) || maxOutputTokens <= thinkingBudget) - ) { - config.maxOutputTokens = Math.floor(thinkingBudget) + 1; - } - - return config; -} - -function stringifyHistoricalToolArguments(value: unknown): string { - if (typeof value === "string") return value; - try { - return JSON.stringify(value ?? {}); - } catch { - return String(value ?? "{}"); - } -} - -function buildInertHistoricalToolCallText(name: string | undefined, args: unknown): string { - const toolName = name || "unknown"; - return `[tool_history_call: ${toolName}] ${stringifyHistoricalToolArguments(args || "{}")}`; -} - -function buildInertHistoricalToolResponseText(name: string, response: unknown): string { - return `[tool_history_result: ${name || "unknown"}] ${typeof response === "string" ? response : stringifyHistoricalToolArguments(response)}`; -} - -function escapeHistoricalContextAttribute(value: string): string { - return value - .replaceAll("&", "&") - .replaceAll('"', """) - .replaceAll("<", "<") - .replaceAll(">", ">"); -} - -function escapeHistoricalContextContent(value: string): string { - return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); -} - -function buildHistoricalToolResultContext(name: string, response: unknown): string { - const source = escapeHistoricalContextAttribute(name || "unknown"); - const rawResult = - typeof response === "string" ? response : stringifyHistoricalToolArguments(response); - const result = escapeHistoricalContextContent(rawResult); - return [ - ``, - result, - "", - ].join("\n"); -} - // Gemini-family APIs (incl. Antigravity / Vertex) reject a `contents[]` array that // has two adjacent entries with the same role: // 400 INVALID_ARGUMENT "Request contains consecutive messages with the same role". diff --git a/open-sse/translator/request/openai-to-gemini/helpers.ts b/open-sse/translator/request/openai-to-gemini/helpers.ts new file mode 100644 index 0000000000..3dfd35cef1 --- /dev/null +++ b/open-sse/translator/request/openai-to-gemini/helpers.ts @@ -0,0 +1,142 @@ +// Pure, self-contained helpers extracted verbatim from ../openai-to-gemini.ts +// (god-file decomposition): historical-tool-context string builders, undefined- +// pruning, thought-signature extraction, tool-name remapping, and the Vertex +// provider check + Antigravity generation-config defaults. No I/O or module state; +// the host imports them back internally (these were module-private β€” no public API +// change). The GeminiGenerationConfig shape lives here with its only mutator. + +export type GeminiGenerationConfig = { + temperature?: unknown; + topP?: unknown; + topK?: unknown; + maxOutputTokens?: unknown; + thinkingConfig?: { + thinkingBudget: number; + includeThoughts: boolean; + }; + responseMimeType?: string; + responseSchema?: unknown; + stopSequences?: string[] | unknown[]; +}; + +// Vertex AI (and Vertex Partner models) reject the OpenAI-style `id` field inside +// function_call / function_response parts. Detect these by the routed provider id. +export function isVertexGeminiProvider(provider: unknown): boolean { + return provider === "vertex" || provider === "vertex-partner"; +} + +type OpenAIToolCallLike = { + thoughtSignature?: unknown; + thought_signature?: unknown; + function?: { + thoughtSignature?: unknown; + thought_signature?: unknown; + }; +}; + +export function buildChangedToolNameMap( + toolNameMap: Map +): Map | null { + const changedEntries = [...toolNameMap.entries()].filter( + ([sanitizedName, originalName]) => sanitizedName !== originalName + ); + return changedEntries.length > 0 ? new Map(changedEntries) : null; +} + +export function extractClientThoughtSignature(toolCall: unknown): string | null { + if (!toolCall || typeof toolCall !== "object") return null; + const candidate = toolCall as OpenAIToolCallLike; + + const signature = + candidate.thoughtSignature || + candidate.thought_signature || + candidate.function?.thoughtSignature || + candidate.function?.thought_signature || + null; + return typeof signature === "string" && signature.length > 0 ? signature : null; +} + +export function deepCleanUndefined(value: unknown, depth = 0): void { + if (depth > 10 || !value || typeof value !== "object") { + return; + } + if (Array.isArray(value)) { + for (const item of value) { + deepCleanUndefined(item, depth + 1); + } + } else { + const obj = value as Record; + for (const key of Object.keys(obj)) { + const val = obj[key]; + if (typeof val === "string" && val === "[undefined]") { + delete obj[key]; + } else { + deepCleanUndefined(val, depth + 1); + } + } + } +} + +export function applyAntigravityGenerationDefaults(generationConfig: GeminiGenerationConfig) { + const config = { ...generationConfig }; + if (config.topK === undefined) { + config.topK = 40; + } + if (config.topP === undefined) { + config.topP = 1; + } + + const thinkingBudget = Number(config.thinkingConfig?.thinkingBudget); + const maxOutputTokens = Number(config.maxOutputTokens); + if ( + Number.isFinite(thinkingBudget) && + thinkingBudget > 0 && + (!Number.isFinite(maxOutputTokens) || maxOutputTokens <= thinkingBudget) + ) { + config.maxOutputTokens = Math.floor(thinkingBudget) + 1; + } + + return config; +} + +export function stringifyHistoricalToolArguments(value: unknown): string { + if (typeof value === "string") return value; + try { + return JSON.stringify(value ?? {}); + } catch { + return String(value ?? "{}"); + } +} + +export function buildInertHistoricalToolCallText(name: string | undefined, args: unknown): string { + const toolName = name || "unknown"; + return `[tool_history_call: ${toolName}] ${stringifyHistoricalToolArguments(args || "{}")}`; +} + +export function buildInertHistoricalToolResponseText(name: string, response: unknown): string { + return `[tool_history_result: ${name || "unknown"}] ${typeof response === "string" ? response : stringifyHistoricalToolArguments(response)}`; +} + +export function escapeHistoricalContextAttribute(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">"); +} + +export function escapeHistoricalContextContent(value: string): string { + return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">"); +} + +export function buildHistoricalToolResultContext(name: string, response: unknown): string { + const source = escapeHistoricalContextAttribute(name || "unknown"); + const rawResult = + typeof response === "string" ? response : stringifyHistoricalToolArguments(response); + const result = escapeHistoricalContextContent(rawResult); + return [ + ``, + result, + "", + ].join("\n"); +} diff --git a/open-sse/utils/cursorAgentProtobuf.ts b/open-sse/utils/cursorAgentProtobuf.ts index 24cee2332b..bb17d9c944 100644 --- a/open-sse/utils/cursorAgentProtobuf.ts +++ b/open-sse/utils/cursorAgentProtobuf.ts @@ -18,6 +18,25 @@ import zlib from "node:zlib"; import crypto from "node:crypto"; +import { + WT_VARINT, + WT_LEN, + encodeVarint, + encodeTag, + encodeBytes, + encodeString, + encodeMessage, + encodeUInt32Field, + encodeBoolField, + encodeDoubleField, + decodeVarint, + checkedLen, + decodeFields, + findField, + decodeStringField, + decodeVarintField, + type Field, +} from "./cursorAgentProtobuf/wire.ts"; // ─── Field numbers (from agent.proto descriptor) ─────────────────────────── @@ -225,125 +244,6 @@ const LIST_VALUES = 1; // ListValue.values = repeated Value const MAP_KEY = 1; const MAP_VALUE = 2; -// ─── Wire-type constants ─────────────────────────────────────────────────── - -const WT_VARINT = 0; -const WT_LEN = 2; - -// ─── Primitive encoders ──────────────────────────────────────────────────── - -function encodeVarint(value: number | bigint): Buffer { - let v = typeof value === "bigint" ? value : BigInt(value); - const bytes: number[] = []; - while (v > 0x7fn) { - bytes.push(Number(v & 0x7fn) | 0x80); - v >>= 7n; - } - bytes.push(Number(v)); - return Buffer.from(bytes); -} - -function encodeTag(fieldNumber: number, wireType: number): Buffer { - return encodeVarint((fieldNumber << 3) | wireType); -} - -function encodeBytes(fieldNumber: number, value: Buffer | Uint8Array): Buffer { - const buf = Buffer.isBuffer(value) ? value : Buffer.from(value); - return Buffer.concat([encodeTag(fieldNumber, WT_LEN), encodeVarint(buf.length), buf]); -} - -function encodeString(fieldNumber: number, value: string): Buffer { - return encodeBytes(fieldNumber, Buffer.from(value, "utf8")); -} - -function encodeMessage(fieldNumber: number, parts: Buffer[]): Buffer { - const inner = Buffer.concat(parts); - return Buffer.concat([encodeTag(fieldNumber, WT_LEN), encodeVarint(inner.length), inner]); -} - -function encodeUInt32Field(fieldNumber: number, value: number): Buffer { - return Buffer.concat([encodeTag(fieldNumber, WT_VARINT), encodeVarint(value)]); -} - -function encodeBoolField(fieldNumber: number, value: boolean): Buffer { - return Buffer.concat([encodeTag(fieldNumber, WT_VARINT), encodeVarint(value ? 1 : 0)]); -} - -function encodeDoubleField(fieldNumber: number, value: number): Buffer { - // wire type 1 = 64-bit fixed (double) - const buf = Buffer.alloc(8); - buf.writeDoubleLE(value, 0); - return Buffer.concat([encodeTag(fieldNumber, 1), buf]); -} - -// ─── Primitive decoders ──────────────────────────────────────────────────── - -function decodeVarint(buf: Buffer, offset: number): [bigint, number] { - let result = 0n; - let shift = 0n; - let pos = offset; - while (pos < buf.length) { - const byte = buf[pos++]; - result |= BigInt(byte & 0x7f) << shift; - if ((byte & 0x80) === 0) return [result, pos]; - shift += 7n; - } - throw new Error("varint truncated"); -} - -type Field = - | { fieldNumber: number; wireType: 0; varint: bigint } - | { fieldNumber: number; wireType: 2; bytes: Buffer }; - -/** - * Validate a length-delimited field's declared length against the bytes that - * actually remain in the buffer. Cursor's frames are well-formed, but a - * corrupted or hostile upstream could declare a length that overruns the - * buffer; without this guard `Buffer.subarray` silently clamps to EOF and a - * truncated tool argument (or any nested message) is decoded as empty/partial - * data instead of being recognized as malformed. Throwing lets the caller β€” - * `processFrame`, wrapped in driveH2's per-frame try/catch β€” skip the bad - * frame rather than act on corrupted fields. Also rejects absurd lengths that - * would not fit a JS safe integer. - */ -function checkedLen(len: bigint, pos: number, buf: Buffer): number { - if (len < 0n || len > BigInt(buf.length - pos)) { - throw new Error( - `length-delimited field overruns buffer (len=${len}, remaining=${buf.length - pos})` - ); - } - return Number(len); -} - -function decodeFields(buf: Buffer): Field[] { - const fields: Field[] = []; - let pos = 0; - while (pos < buf.length) { - const [tag, np] = decodeVarint(buf, pos); - pos = np; - const fieldNumber = Number(tag >> 3n); - const wireType = Number(tag & 0x7n); - if (wireType === WT_VARINT) { - const [v, np2] = decodeVarint(buf, pos); - pos = np2; - fields.push({ fieldNumber, wireType: 0, varint: v }); - } else if (wireType === WT_LEN) { - const [len, np2] = decodeVarint(buf, pos); - pos = np2; - const lenN = checkedLen(len, pos, buf); - fields.push({ fieldNumber, wireType: 2, bytes: buf.subarray(pos, pos + lenN) }); - pos += lenN; - } else if (wireType === 5) { - pos += 4; - } else if (wireType === 1) { - pos += 8; - } else { - throw new Error(`unsupported wireType ${wireType}`); - } - } - return fields; -} - // ─── Connect-RPC framing ─────────────────────────────────────────────────── const FLAG_NONE = 0x00; @@ -547,9 +447,7 @@ export function encodeAgentRunRequest(input: AgentRunInput): Buffer { const selectedContextParts: Buffer[] = []; if (input.images && input.images.length > 0) { for (const img of input.images) { - selectedContextParts.push( - encodeMessage(SC_SELECTED_IMAGES, [encodeSelectedImageBody(img)]) - ); + selectedContextParts.push(encodeMessage(SC_SELECTED_IMAGES, [encodeSelectedImageBody(img)])); } } // The empty selected_context placeholder and mode=1 match cursor-agent's @@ -652,24 +550,6 @@ export type DecodedDelta = | { kind: "kv_server_message" } | { kind: "unknown"; field: number }; -function findField(fields: Field[], fieldNumber: number): Field | undefined { - return fields.find((f) => f.fieldNumber === fieldNumber); -} - -function decodeStringField(buf: Buffer, fieldNumber: number): string { - const fields = decodeFields(buf); - const f = findField(fields, fieldNumber); - if (f && f.wireType === 2) return f.bytes.toString("utf8"); - return ""; -} - -function decodeVarintField(buf: Buffer, fieldNumber: number): number { - const fields = decodeFields(buf); - const f = findField(fields, fieldNumber); - if (f && f.wireType === 0) return Number(f.varint); - return 0; -} - export function decodeAgentServerMessage(payload: Buffer): DecodedDelta[] { const out: DecodedDelta[] = []; for (const top of decodeFields(payload)) { diff --git a/open-sse/utils/cursorAgentProtobuf/wire.ts b/open-sse/utils/cursorAgentProtobuf/wire.ts new file mode 100644 index 0000000000..bd2d0a9697 --- /dev/null +++ b/open-sse/utils/cursorAgentProtobuf/wire.ts @@ -0,0 +1,143 @@ +// Low-level protobuf wire-format primitives for the Cursor Agent codec, extracted +// verbatim from ../cursorAgentProtobuf.ts (god-file decomposition). Pure and +// dependency-free (Buffer only): varint/tag/length-delimited encode+decode and the +// generic field walker. Framing, the value codec, and the message encoders/decoders +// all build on this layer. Nothing here was part of the module's public API, so the +// host imports these back internally (no re-export). + +// ─── Wire-type constants ─────────────────────────────────────────────────── + +export const WT_VARINT = 0; +export const WT_LEN = 2; + +// ─── Primitive encoders ──────────────────────────────────────────────────── + +export function encodeVarint(value: number | bigint): Buffer { + let v = typeof value === "bigint" ? value : BigInt(value); + const bytes: number[] = []; + while (v > 0x7fn) { + bytes.push(Number(v & 0x7fn) | 0x80); + v >>= 7n; + } + bytes.push(Number(v)); + return Buffer.from(bytes); +} + +export function encodeTag(fieldNumber: number, wireType: number): Buffer { + return encodeVarint((fieldNumber << 3) | wireType); +} + +export function encodeBytes(fieldNumber: number, value: Buffer | Uint8Array): Buffer { + const buf = Buffer.isBuffer(value) ? value : Buffer.from(value); + return Buffer.concat([encodeTag(fieldNumber, WT_LEN), encodeVarint(buf.length), buf]); +} + +export function encodeString(fieldNumber: number, value: string): Buffer { + return encodeBytes(fieldNumber, Buffer.from(value, "utf8")); +} + +export function encodeMessage(fieldNumber: number, parts: Buffer[]): Buffer { + const inner = Buffer.concat(parts); + return Buffer.concat([encodeTag(fieldNumber, WT_LEN), encodeVarint(inner.length), inner]); +} + +export function encodeUInt32Field(fieldNumber: number, value: number): Buffer { + return Buffer.concat([encodeTag(fieldNumber, WT_VARINT), encodeVarint(value)]); +} + +export function encodeBoolField(fieldNumber: number, value: boolean): Buffer { + return Buffer.concat([encodeTag(fieldNumber, WT_VARINT), encodeVarint(value ? 1 : 0)]); +} + +export function encodeDoubleField(fieldNumber: number, value: number): Buffer { + // wire type 1 = 64-bit fixed (double) + const buf = Buffer.alloc(8); + buf.writeDoubleLE(value, 0); + return Buffer.concat([encodeTag(fieldNumber, 1), buf]); +} + +// ─── Primitive decoders ──────────────────────────────────────────────────── + +export function decodeVarint(buf: Buffer, offset: number): [bigint, number] { + let result = 0n; + let shift = 0n; + let pos = offset; + while (pos < buf.length) { + const byte = buf[pos++]; + result |= BigInt(byte & 0x7f) << shift; + if ((byte & 0x80) === 0) return [result, pos]; + shift += 7n; + } + throw new Error("varint truncated"); +} + +export type Field = + | { fieldNumber: number; wireType: 0; varint: bigint } + | { fieldNumber: number; wireType: 2; bytes: Buffer }; + +/** + * Validate a length-delimited field's declared length against the bytes that + * actually remain in the buffer. Cursor's frames are well-formed, but a + * corrupted or hostile upstream could declare a length that overruns the + * buffer; without this guard `Buffer.subarray` silently clamps to EOF and a + * truncated tool argument (or any nested message) is decoded as empty/partial + * data instead of being recognized as malformed. Throwing lets the caller β€” + * `processFrame`, wrapped in driveH2's per-frame try/catch β€” skip the bad + * frame rather than act on corrupted fields. Also rejects absurd lengths that + * would not fit a JS safe integer. + */ +export function checkedLen(len: bigint, pos: number, buf: Buffer): number { + if (len < 0n || len > BigInt(buf.length - pos)) { + throw new Error( + `length-delimited field overruns buffer (len=${len}, remaining=${buf.length - pos})` + ); + } + return Number(len); +} + +export function decodeFields(buf: Buffer): Field[] { + const fields: Field[] = []; + let pos = 0; + while (pos < buf.length) { + const [tag, np] = decodeVarint(buf, pos); + pos = np; + const fieldNumber = Number(tag >> 3n); + const wireType = Number(tag & 0x7n); + if (wireType === WT_VARINT) { + const [v, np2] = decodeVarint(buf, pos); + pos = np2; + fields.push({ fieldNumber, wireType: 0, varint: v }); + } else if (wireType === WT_LEN) { + const [len, np2] = decodeVarint(buf, pos); + pos = np2; + const lenN = checkedLen(len, pos, buf); + fields.push({ fieldNumber, wireType: 2, bytes: buf.subarray(pos, pos + lenN) }); + pos += lenN; + } else if (wireType === 5) { + pos += 4; + } else if (wireType === 1) { + pos += 8; + } else { + throw new Error(`unsupported wireType ${wireType}`); + } + } + return fields; +} + +export function findField(fields: Field[], fieldNumber: number): Field | undefined { + return fields.find((f) => f.fieldNumber === fieldNumber); +} + +export function decodeStringField(buf: Buffer, fieldNumber: number): string { + const fields = decodeFields(buf); + const f = findField(fields, fieldNumber); + if (f && f.wireType === 2) return f.bytes.toString("utf8"); + return ""; +} + +export function decodeVarintField(buf: Buffer, fieldNumber: number): number { + const fields = decodeFields(buf); + const f = findField(fields, fieldNumber); + if (f && f.wireType === 0) return Number(f.varint); + return 0; +} diff --git a/package.json b/package.json index dbd8ecece4..ce571e997c 100644 --- a/package.json +++ b/package.json @@ -95,13 +95,13 @@ "electron:build:mac": "npm run build && cd electron && npm run build:mac", "electron:build:linux": "npm run build && cd electron && npm run build:linux", "electron:smoke:packaged": "node scripts/dev/smoke-electron-packaged.mjs", - "test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", - "test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", - "test:unit:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", - "test:unit:fast": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", + "test": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", + "test:unit": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=20 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", + "test:unit:ci": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", + "test:unit:fast": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-isolation=none tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", "test:unit:shard": "concurrently --kill-others-on-fail -n s1,s2 \"npm:test:unit:shard:1\" \"npm:test:unit:shard:2\"", - "test:unit:shard:1": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", - "test:unit:shard:2": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", + "test:unit:shard:1": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=1/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", + "test:unit:shard:2": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=10 --test-shard=2/2 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", "test:plan3": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/plan3-p0.test.ts", "test:fixes": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/fixes-p1.test.ts", "test:security": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test tests/unit/security-fase01.test.ts", @@ -191,7 +191,7 @@ "test:mutation": "stryker run", "test:ecosystem": "node scripts/dev/run-ecosystem-tests.mjs", "test:system": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 tests/e2e/system-failover.test.ts", - "test:coverage": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", + "test:coverage": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true NODE_OPTIONS=--max-old-space-size=8192 c8 --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 60 --lines 60 --functions 60 --branches 60 node --max-old-space-size=8192 --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=8 tests/unit/*.test.ts \"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts\"", "test:coverage:legacy": "c8 --output-dir coverage --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx --test tests/unit/*.test.ts", "coverage:report": "cross-env NODE_OPTIONS=--max-old-space-size=8192 c8 report --merge-async --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov", "coverage:summary": "node scripts/check/test-report-summary.mjs --input coverage/coverage-summary.json --output coverage/coverage-report.md", @@ -217,6 +217,7 @@ "@monaco-editor/react": "^4.7.0", "@ngrok/ngrok": "^1.7.0", "@swc/helpers": "0.5.23", + "@toon-format/toon": "^2.3.0", "@types/mdx": "^2.0.13", "@xyflow/react": "^12.11.1", "axios": "^1.16.1", @@ -265,6 +266,7 @@ "react-markdown": "^10.1.0", "react-reconciler": "^0.33.0", "recharts": "^3.8.1", + "safe-regex": "^2.1.1", "selfsigned": "^5.5.0", "socks": "^2.8.7", "sql.js": "^1.14.1", diff --git a/scripts/check/check-test-discovery.mjs b/scripts/check/check-test-discovery.mjs index 5a1bfe6b3f..9ea80e0774 100644 --- a/scripts/check/check-test-discovery.mjs +++ b/scripts/check/check-test-discovery.mjs @@ -53,7 +53,7 @@ export const COLLECTORS = [ // "vitest" e explodem no node runner). Subdir novo: adicione aqui E nos scripts // (o drift-check + o gate de Γ³rfΓ£os forΓ§am a manutenΓ§Γ£o em sincronia). { - glob: "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts", + glob: "tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,memory,runtime,security,services,settings,shared,ui,usage}/**/*.test.ts", sources: ["package.json", ".github/workflows/ci.yml"], }, // Node native runner β€” test:integration (top-level only; tests/integration/services/ NΓƒO roda) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx index 973d79ff1c..704a3ec7e9 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx @@ -19,6 +19,7 @@ import { normalizeAndValidateHttpBaseUrl, extractCommandCodeCredentialInput, providerText, + validationBadgeProps, type CommandCodeAuthFlowState, } from "../../providerPageHelpers"; import { getWebSessionCredentialRequirement } from "../../webSessionCredentials"; @@ -202,7 +203,7 @@ export default function AddApiKeyModal({ }); const data = await res.json(); const ok = !!data.valid; - setValidationResult(ok ? "success" : "failed"); + setValidationResult(ok ? "success" : data.unsupported ? "unsupported" : "failed"); // #5088: surface the detailed reason the backend returns (e.g. a TLS/EACCES // environment error for claude-web/chatgpt-web) instead of only a bare // "invalid" badge β€” otherwise the real cause is hidden and users are stuck. @@ -276,7 +277,7 @@ export default function AddApiKeyModal({ if (!isValid && data.error) { validationError = data.error; } - setValidationResult(isValid ? "success" : "failed"); + setValidationResult(isValid ? "success" : isUnsupported ? "unsupported" : "failed"); } catch { setValidationResult("failed"); } finally { @@ -674,8 +675,8 @@ export default function AddApiKeyModal({ /> )} {validationResult && ( - - {validationResult === "success" ? t("valid") : t("invalid")} + + {t(validationBadgeProps(validationResult).labelKey)} )} {saveError && ( diff --git a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts index 23ea2fd1f5..500732e067 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/providerPageHelpers.ts @@ -118,6 +118,22 @@ export function providerText( return fallback; } +/** + * #5442 β€” Badge variant + i18n label key for an add-credential validation result. + * A provider with no live validator returns `unsupported` (Save still succeeds); + * previously the modal only had success/failed states, so it rendered a red + * "Invalid" badge for those providers even though saving worked (LMArena, PiAPI…). + * "unsupported" now maps to a neutral `info` badge ("N/A"), not "Invalid". + */ +export function validationBadgeProps(result: string): { + variant: "success" | "error" | "info"; + labelKey: string; +} { + if (result === "success") return { variant: "success", labelKey: "valid" }; + if (result === "unsupported") return { variant: "info", labelKey: "notApplicable" }; + return { variant: "error", labelKey: "invalid" }; +} + /** A single model's outcome from a `/api/models/test-all` response. */ export interface TestAllModelOutcome { status: "ok" | "error"; diff --git a/src/app/api/system/version/route.ts b/src/app/api/system/version/route.ts index 3fa2c668d2..6032f70159 100644 --- a/src/app/api/system/version/route.ts +++ b/src/app/api/system/version/route.ts @@ -19,6 +19,10 @@ import { import { NEWS_JSON_URL, parseActiveNewsPayload } from "@/shared/utils/releaseNotes"; import { isNewer, resolveLatestVersion } from "@/lib/system/versionCheck"; import { resolveGlobalOmniroutePath } from "@/lib/system/globalPackagePath"; +// #5542 β€” On Windows npm is `npm.cmd`; Node β‰₯24 refuses to execFile a `.cmd` without +// a shell (nodejs/node#52554 β†’ "spawn npm ENOENT"). buildNpmExecOptions enables the +// shell on win32 only; SERVICE_VERSION_PATTERN keeps the shell-joined version safe. +import { buildNpmExecOptions, SERVICE_VERSION_PATTERN } from "@/lib/services/installers/utils"; const execFileAsync = promisify(execFile); @@ -208,10 +212,11 @@ export async function POST(req: NextRequest) { status: "running", message: "Installing dependencies...", }); - await execFileAsync("npm", ["install", "--legacy-peer-deps"], { - timeout: 300_000, - cwd: PROJECT_ROOT, - }); + await execFileAsync( + "npm", + ["install", "--legacy-peer-deps"], + buildNpmExecOptions(process.platform, { cwd: PROJECT_ROOT, timeoutMs: 300_000 }) + ); send({ step: "rebuild", status: "done", message: "Dependencies installed" }); try { @@ -228,10 +233,11 @@ export async function POST(req: NextRequest) { status: "running", message: "Building application...", }); - await execFileAsync("npm", ["run", "build"], { - timeout: 600_000, - cwd: PROJECT_ROOT, - }); + await execFileAsync( + "npm", + ["run", "build"], + buildNpmExecOptions(process.platform, { cwd: PROJECT_ROOT, timeoutMs: 600_000 }) + ); send({ step: "rebuild", status: "done", message: "Build complete" }); send({ step: "restart", status: "running", message: "Restarting service..." }); @@ -286,14 +292,19 @@ export async function POST(req: NextRequest) { try { // Step 1: Install + // #5542 β€” buildNpmExecOptions enables the shell on win32 (npm.cmd), which + // shell-joins argv, so the version spec must be metacharacter-free before it + // reaches the command line (Hard Rule #13). + if (!SERVICE_VERSION_PATTERN.test(latest)) { + send({ step: "install", status: "error", message: "Invalid version format" }); + controller.close(); + return; + } send({ step: "install", status: "running", message: `Installing omniroute@${latest}...` }); await execFileAsync( "npm", ["install", "-g", `omniroute@${latest}`, "--ignore-scripts", "--legacy-peer-deps"], - { - timeout: 300000, - cwd: PROJECT_ROOT, - } + buildNpmExecOptions(process.platform, { cwd: PROJECT_ROOT, timeoutMs: 300_000 }) ); send({ step: "install", status: "done", message: `Installed omniroute@${latest}` }); @@ -307,10 +318,7 @@ export async function POST(req: NextRequest) { await execFileAsync( "npm", ["rebuild", "better-sqlite3"], - { - cwd: omniPath, - timeout: 120000, - } + buildNpmExecOptions(process.platform, { cwd: omniPath, timeoutMs: 120_000 }) ); send({ step: "rebuild", status: "done", message: "Native modules rebuilt" }); diff --git a/src/lib/localDb.ts b/src/lib/localDb.ts index 6390ff3808..227f56f205 100755 --- a/src/lib/localDb.ts +++ b/src/lib/localDb.ts @@ -93,6 +93,7 @@ export { export * from "./db/compressionCacheStats"; export * from "./db/compressionCombos"; export * from "./db/compressionRunTelemetry"; +export * from "./db/modelContextOverrides"; export { // API Keys diff --git a/src/lib/oauth/providers/grok-cli.ts b/src/lib/oauth/providers/grok-cli.ts index 849602d6e3..59c7cf4c80 100644 --- a/src/lib/oauth/providers/grok-cli.ts +++ b/src/lib/oauth/providers/grok-cli.ts @@ -131,6 +131,13 @@ export const grokCli = { expiresIn = exp - currentSec; } + // #5775 follow-up: guard against an already-expired token yielding a negative + // expiresIn. A negative value is truthy downstream (import-token route) and maps + // to a PAST expiresAt, which AutoCombo reads as "already expired" and excludes the + // connection instead of refreshing it. Clamp to a tiny positive TTL so the token is + // treated as due-for-refresh. + expiresIn = Math.max(1, expiresIn); + return { accessToken, refreshToken, diff --git a/src/lib/system/versionCheck.ts b/src/lib/system/versionCheck.ts index 1900404b6f..0cc53822d6 100644 --- a/src/lib/system/versionCheck.ts +++ b/src/lib/system/versionCheck.ts @@ -19,6 +19,7 @@ import { execFile } from "child_process"; import { promisify } from "util"; import { createLogger } from "@/shared/utils/logger"; +import { buildNpmExecOptions } from "@/lib/services/installers/utils"; const execFileAsync = promisify(execFile); const log = createLogger("system/versionCheck"); @@ -71,9 +72,13 @@ export function isNewer(latest: string | null | undefined, current: string): boo /** Latest published version via the `npm` CLI (fast when npm is on PATH, e.g. source installs). */ export async function getLatestVersionFromNpmCli(): Promise { try { - const { stdout } = await execFileAsync("npm", ["info", "omniroute", "version", "--json"], { - timeout: LOOKUP_TIMEOUT_MS, - }); + // #5542 β€” win32 npm is npm.cmd; execFile without a shell throws "spawn npm ENOENT" + // on Node β‰₯24 (nodejs/node#52554). buildNpmExecOptions enables the shell on win32. + const { stdout } = await execFileAsync( + "npm", + ["info", "omniroute", "version", "--json"], + buildNpmExecOptions(process.platform, { timeoutMs: LOOKUP_TIMEOUT_MS }) + ); const parsed = JSON.parse(String(stdout).trim()); return typeof parsed === "string" && parsed ? parsed : null; } catch { diff --git a/src/shared/components/LinkifiedText.tsx b/src/shared/components/LinkifiedText.tsx new file mode 100644 index 0000000000..c798a049a9 --- /dev/null +++ b/src/shared/components/LinkifiedText.tsx @@ -0,0 +1,30 @@ +import { Fragment } from "react"; +import { linkifyText } from "@/shared/utils/linkify"; + +/** + * #5486 β€” Render a string with any embedded http(s) URLs as clickable links. + * Used by the OAuth error step so setup instructions (e.g. GitLab Duo's + * "register an OAuth application at https://gitlab.com/-/profile/applications …") + * are actionable instead of dead text. Links open in a new tab with a safe rel. + */ +export default function LinkifiedText({ text }: { text: string | null | undefined }) { + return ( + <> + {linkifyText(text || "").map((seg, i) => + seg.href ? ( + + {seg.text} + + ) : ( + {seg.text} + ) + )} + + ); +} diff --git a/src/shared/components/OAuthModal.tsx b/src/shared/components/OAuthModal.tsx index dac9e6fb22..0766a31f68 100644 --- a/src/shared/components/OAuthModal.tsx +++ b/src/shared/components/OAuthModal.tsx @@ -5,6 +5,7 @@ import { useTranslations } from "next-intl"; import Modal from "./Modal"; import Button from "./Button"; import Input from "./Input"; +import LinkifiedText from "./LinkifiedText"; import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard"; import { parseResponseBody, getErrorMessage } from "@/shared/utils/api"; import { isCredentialBlob, submitCredentialBlob } from "@/shared/components/oauthBlobSubmit"; @@ -948,7 +949,9 @@ export default function OAuthModal({ error

{t("error")}

-

{error}

+

+ +