From 8fac6bcd48090e56ccb361a78579db3b80eaf2d9 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza Date: Sat, 1 Aug 2026 10:44:23 -0300 Subject: [PATCH] =?UTF-8?q?feat(.50):=20completa=20itens=20restantes=20?= =?UTF-8?q?=E2=80=94=20G13,=20G14,=20gap34,=20docs,=20R0.2=20(#9126)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ci): G0 — quality rail (PR→release/**) ganha ratchets+segurança do trilho A O refactor de god-files do trilho 3.8.50→3.9.0 acontece em PRs→release/**, e esse trilho pulava o motor de ratchet, o CodeQL ratchet e todos os scanners de segurança — exatamente onde a rede era necessária (5 das 13 causas da reconciliação de 07-24 eram regressões reais shipadas por CI verde por-PR). Modo enxuto, jobs EXISTENTES (a .51 consolida lanes; nenhum job novo): - lint-guard: quality:collect + ratchet --allow-missing + require-tighten + check:codeql-ratchet. O job já escreve .artifacts/eslint-results.json, então o motor entra a custo ZERO de ESLint (um inventário, dois consumidores). Coverage ausente degrada gracioso (--allow-missing); autoridade de coverage segue no trilho A. + permissions security-events:read para o CodeQL ratchet. - fast-gates: check:cycles, check:lockfile, duplication, dead-code, type-coverage, compression-budget + install endurecido dos scanners (gh release download, zizmor PINADO 1.25.2 = mesmo auditor do ci.yml) + secrets/vuln/workflows/ openapi-breaking com --ratchet (self-skip sem binário; só regressão medida bloqueia). - Fora de propósito: bundle-size (self-skip sem build → configuração morta) e o run de coverage (fast-unit já roda a suíte cheia). Runners intocados: guard tests/unit/vps-runner-variable-scope.test.ts verde; teste novo tests/unit/quality-rail-gate-membership.test.ts pina a MEMBERSHIP dos gates no trilho B (red antes da edição, green depois). Validação no tip puro (nenhum base-red fabricado para a fila de PRs abertos): cycles OK · lockfile OK · duplication 4.26% (base 5.72%) · dead-code 226 (base 227) · type-coverage 94.13% (base 92.17%) · compression OK · secrets 0 (base 0) · vuln 5 (base 10) · codeql 0 (base 0) · oasdiff 0 (base 0) · zizmor 178 (base 190) · actionlint exit 0 no arquivo editado · quality-ratchet 56 métricas OK + require-tighten OK com --allow-missing. Refs #8084 * feat(.50): G13 golden-set, G14 import boundaries, gap34 deterministic, docs sync, R0.2 dead hooks Integra os itens restantes da 3.8.50: - G13: golden-set determinístico para combo.ts e chatCore.ts via seams públicas - G14: no-restricted-imports para localDb barrel fora de src/lib/db/ e executors em src/app/ - Gap34: teste determinístico de timeout DuckDuckGo sem rede real - Docs: golden path de contribuição + sincronização de números canônicos - R0.2: remoção dos 7 hooks mortos do BUILTIN_EVENTS + UI marketplace ajustada * fix(r0.2): remove marketplace tab remnants from plugins page — fixes dashboard typecheck regression * chore(r0.2): remove pluginWorker.ts, signing.ts, sandbox.ts — zero importers confirmed * fix(docs): remove OMNIROUTE_PLUGINS_ALLOW_EXEC reference — env var removed with pluginWorker.ts in R0.2 * fix(env): remove dead OMNIROUTE_PLUGINS_ALLOW_EXEC from .env.example — consumer removed in R0.2 * fix(test): update sidebar-visibility assertion for R0.2 marketplace removal --------- Co-authored-by: diegosouzapw --- .env.example | 4 - .github/pull_request_template.md | 11 +- CLAUDE.md | 6 +- CONTRIBUTING.md | 20 +- config/quality/eslint-suppressions.json | 984 ++++++++- docs/dev/CONTRIBUTION_GOLDEN_PATH.md | 229 ++ docs/reference/ENVIRONMENT.md | 1 - docs/routing/AUTO-COMBO.md | 82 +- eslint.config.mjs | 52 +- package.json | 2 +- .../(dashboard)/dashboard/plugins/page.tsx | 211 +- src/lib/plugins/hooks.ts | 7 - src/lib/plugins/pluginWorker.ts | 246 --- src/lib/plugins/sandbox.ts | 29 - src/lib/plugins/signing.ts | 34 - .../g13/combo-chatcore-public-seams.json | 1892 +++++++++++++++++ tests/unit/duckduckgo-web-executor.test.ts | 91 +- tests/unit/eslint-import-boundaries.test.ts | 77 + tests/unit/g13-combo-chatcore-golden.test.ts | 397 ++++ tests/unit/plugin-sandbox-permissions.test.ts | 127 -- tests/unit/plugins-sandbox.test.ts | 19 - tests/unit/plugins-welcome-banner-e2e.test.ts | 47 +- tests/unit/sidebar-visibility.test.ts | 7 +- 23 files changed, 3832 insertions(+), 743 deletions(-) create mode 100644 docs/dev/CONTRIBUTION_GOLDEN_PATH.md delete mode 100644 src/lib/plugins/pluginWorker.ts delete mode 100644 src/lib/plugins/sandbox.ts delete mode 100644 src/lib/plugins/signing.ts create mode 100644 tests/snapshots/g13/combo-chatcore-public-seams.json create mode 100644 tests/unit/eslint-import-boundaries.test.ts create mode 100644 tests/unit/g13-combo-chatcore-golden.test.ts delete mode 100644 tests/unit/plugin-sandbox-permissions.test.ts delete mode 100644 tests/unit/plugins-sandbox.test.ts diff --git a/.env.example b/.env.example index 2ba4099968..4cb877425c 100644 --- a/.env.example +++ b/.env.example @@ -1414,10 +1414,6 @@ APP_LOG_TO_FILE=true # Default: ~/.omniroute/plugins/ Override in dev/CI to point at a local plugin tree. # OMNIROUTE_PLUGIN_PATH= -# Allow plugins to request the 'exec' permission (spawn child processes from the -# plugin worker sandbox). Disabled by default; set to 1 to enable (local operator only). -# OMNIROUTE_PLUGINS_ALLOW_EXEC=0 - # ── Prompt cache (system prompt deduplication) ── # Used by: open-sse/services — caches identical system prompts across requests. # PROMPT_CACHE_MAX_SIZE=50 # Max cached entries (default: 50) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 555efff047..9a80160f26 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -9,11 +9,14 @@ ## Validation -Run only the focused loop for what you changed — the full unit suite, Vitest, the -60% coverage gate, and the production build all run in CI on this PR (#8329): +Choose the change type and focused loop from the +[Contribution Golden Path](../docs/dev/CONTRIBUTION_GOLDEN_PATH.md). The full unit suite, +Vitest, the 60% coverage gate, and the production build all run in CI on this PR (#8329): -- [ ] Focused tests for the change: `node --import tsx/esm --test tests/unit/.test.ts` +- [ ] Change type: provider / routing / UI / i18n / CLI / DB / build-deploy / other +- [ ] Focused tests and category gates from the golden path - [ ] `npm run lint` +- [ ] Reconciled with the current active release base; focused checks rerun afterward - [ ] Production-code changes include a new or updated automated test in this PR - [ ] SonarQube PR analysis is green or any remaining issues are explicitly documented below @@ -29,4 +32,4 @@ Run only the focused loop for what you changed — the full unit suite, Vitest, ## Reviewer Notes -- Call out any risky areas, migrations, feature flags, or manual validation that reviewers should know about. \ No newline at end of file +- Call out any risky areas, migrations, feature flags, or manual validation that reviewers should know about. diff --git a/CLAUDE.md b/CLAUDE.md index a3cf9aa2d5..262799988b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep archit | Translators | `open-sse/translator/` | Format conversion (OpenAI↔Claude↔Gemini) | | Transformer | `open-sse/transformer/` | Responses API ↔ Chat Completions | | Services | `open-sse/services/` | Combo routing, rate limits, caching, etc | -| Database | `src/lib/db/` | SQLite domain modules (95 files, 110 migrations) | +| Database | `src/lib/db/` | SQLite domain modules (130 migrations) | | Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic | | MCP Server | `open-sse/mcp-server/` | 104 tools (42 base + memory/skill/agentSkill/pool/notion/obsidian/gamification/plugin modules), 3 transports (stdio / SSE / Streamable HTTP), 31 scopes | | A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol | @@ -72,7 +72,7 @@ Client → /v1/chat/completions (Next.js route) API routes follow a consistent pattern: `Route → CORS preflight → Zod body validation → Optional auth (extractApiKey/isValidApiKey) → API key policy enforcement → Handler delegation (open-sse)`. No global Next.js middleware — interception is route-specific. -**Combo routing** (`open-sse/services/combo.ts`): 18 strategies (priority, weighted, fill-first, round-robin, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, context-relay, fusion, pipeline). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. The `fusion` strategy is the exception: it fans out to a panel of models in parallel, then a judge model synthesizes one final answer (`open-sse/services/fusion.ts`). See `docs/routing/AUTO-COMBO.md` for the 12-factor Auto-Combo scoring + the full strategy table and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers. +**Combo routing** (`open-sse/services/combo.ts`): 19 public strategies (priority, weighted, fill-first, round-robin, p2c, random, least-used, cost-optimized, reset-aware, reset-window, headroom, strict-random, auto, lkgp, context-optimized, cache-optimized, context-relay, fusion, pipeline). Each target calls `handleSingleModel()` which wraps `handleChatCore()` with per-target error handling and circuit breaker checks. The `fusion` strategy is the exception: it fans out to a panel of models in parallel, then a judge model synthesizes one final answer (`open-sse/services/fusion.ts`). See `docs/routing/AUTO-COMBO.md` for the 13-factor Auto-Combo scoring + the full strategy table and `docs/architecture/RESILIENCE_GUIDE.md` for the 3 resilience layers. --- @@ -332,7 +332,7 @@ For any non-trivial change, read the matching deep-dive first: | Repo navigation | `docs/architecture/REPOSITORY_MAP.md` | | Architecture | `docs/architecture/ARCHITECTURE.md` | | Engineering reference | `docs/architecture/CODEBASE_DOCUMENTATION.md` | -| Auto-Combo (12-factor scoring, 18 strategies) | `docs/routing/AUTO-COMBO.md` | +| Auto-Combo (13-factor scoring, 19 strategies) | `docs/routing/AUTO-COMBO.md` | | Resilience (3 mechanisms) | `docs/architecture/RESILIENCE_GUIDE.md` | | Reasoning replay | `docs/routing/REASONING_REPLAY.md` | | Skills framework | `docs/frameworks/SKILLS.md` | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c302bc82d4..8688d253d6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,11 @@ Thank you for your interest in contributing! This guide covers everything you need to get started. +For the official per-change workflow, start with the +[Contribution Golden Path](docs/dev/CONTRIBUTION_GOLDEN_PATH.md). It maps provider, routing, +UI/UX, i18n, CLI, database, and build/deploy changes to their contracts, focused tests, CI +coverage, and reconciliation steps. + --- ## Development Setup @@ -198,10 +203,11 @@ Coverage notes: ### Pull Request Requirements -Before opening a PR, run the focused loop for what you changed. The full unit suite -(4 CI shards), Vitest, the **60%+** coverage gate, and the production build are CI's -responsibility — running them locally adds no signal the PR checks will not already -give you, and on smaller machines it can saturate the host (#8084): +Before opening a PR, use the +[Contribution Golden Path](docs/dev/CONTRIBUTION_GOLDEN_PATH.md) to run the focused loop for +what you changed. The full unit suite (4 CI shards), Vitest, the **60%+** coverage gate, and +the production build are CI's responsibility — running them locally adds no signal the PR +checks will not already give you, and on smaller machines it can saturate the host (#8084): - Run the test files that cover your change: `node --import tsx/esm --test tests/unit/.test.ts` - Run `npm run lint` @@ -271,7 +277,7 @@ src/ # TypeScript (.ts / .tsx) │ ├── a2a/ # Agent-to-Agent v0.3 protocol server │ ├── acp/ # Agent Communication Protocol registry │ ├── compliance/ # Compliance policy engine -│ ├── db/ # SQLite database layer (21 modules + 16 migrations) +│ ├── db/ # SQLite domain modules + 130 migrations │ ├── memory/ # Persistent conversational memory │ ├── oauth/ # OAuth providers, services, and utilities │ ├── skills/ # Extensible skill framework @@ -281,7 +287,7 @@ src/ # TypeScript (.ts / .tsx) ├── mitm/ # MITM proxy (cert, DNS, target routing) ├── shared/ │ ├── components/ # React components (.tsx) -│ ├── constants/ # Provider definitions (177), MCP scopes, 14 routing strategies +│ ├── constants/ # Provider definitions (290), MCP scopes, 19 routing strategies │ ├── utils/ # Circuit breaker, sanitizer, auth helpers │ └── validation/ # Zod v4 schemas └── sse/ # SSE proxy pipeline @@ -289,7 +295,7 @@ src/ # TypeScript (.ts / .tsx) open-sse/ # @omniroute/open-sse workspace ├── executors/ # 14 provider-specific request executors ├── handlers/ # 11 request handlers (chat, responses, embeddings, images, etc.) -├── mcp-server/ # MCP server (25 tools, 3 transports, 10 scopes) +├── mcp-server/ # MCP server (104 tools, 3 transports, 31 scopes) ├── services/ # 36+ services (combo, autoCombo, rateLimitManager, etc.) ├── translator/ # Format translators (OpenAI ↔ Claude ↔ Gemini ↔ Responses ↔ Ollama) ├── transformer/ # Responses API transformer diff --git a/config/quality/eslint-suppressions.json b/config/quality/eslint-suppressions.json index a29906c409..b8dc92452c 100644 --- a/config/quality/eslint-suppressions.json +++ b/config/quality/eslint-suppressions.json @@ -148,6 +148,9 @@ }, "no-restricted-syntax": { "count": 1 + }, + "no-restricted-imports": { + "count": 2 } }, "open-sse/services/claudeWebAutoRefresh.ts": { @@ -368,6 +371,9 @@ "src/lib/monitoring/providerHealthAutopilot.ts": { "no-restricted-syntax": { "count": 1 + }, + "no-restricted-imports": { + "count": 1 } }, "src/lib/monitoring/providerHealthMatrix.ts": { @@ -443,6 +449,9 @@ "src/sse/services/auth.ts": { "no-restricted-syntax": { "count": 1 + }, + "no-restricted-imports": { + "count": 1 } }, "tests/e2e/api.spec.ts": { @@ -1518,6 +1527,9 @@ "tests/unit/file-expiration-policy.test.ts": { "@typescript-eslint/no-explicit-any": { "count": 1 + }, + "no-restricted-imports": { + "count": 1 } }, "tests/unit/fix-tool-adjacency.test.ts": { @@ -2409,5 +2421,975 @@ "@typescript-eslint/no-explicit-any": { "count": 5 } + }, + "open-sse/handlers/chatCore.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "open-sse/handlers/chatCore/codexFailover.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "open-sse/handlers/chatCore/comboContextCache.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "open-sse/services/combo/concurrencyCaps.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "open-sse/services/combo/quotaExhaustionCutoff.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "open-sse/services/keyGroupAuth.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "open-sse/services/tokenLimitCounter.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "open-sse/utils/proxyFallback.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/(dashboard)/home/page.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/auth/login/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/auth/oidc/callback/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/auth/oidc/login/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/batches/[id]/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/batches/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/cache/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/cli-tools/claude-settings/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/cli-tools/codex-settings/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/cli-tools/keys/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/cli/connect/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/combos/[id]/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/combos/reorder/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/combos/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/combos/test/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/compression/compare/verify/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/db-backups/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/evals/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/evals/suites/[suiteId]/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/evals/suites/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/files/[id]/content/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/files/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/internal/codex-responses-ws/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/keys/[id]/regenerate/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/keys/[id]/reveal/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/keys/[id]/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/keys/groups/[id]/keys/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/keys/groups/[id]/permissions/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/keys/groups/[id]/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/keys/groups/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/keys/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/memory/reindex/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/middleware/hooks/[name]/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/middleware/hooks/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/model-combo-mappings/[id]/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/model-combo-mappings/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/models/test-all/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/monitoring/health/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/pricing/models/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/pricing/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/provider-models/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/providers/[id]/login/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/providers/[id]/models/discovery/providerModelsConfig.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/providers/[id]/models/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/providers/[id]/refresh/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/providers/[id]/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/providers/[id]/sync-models/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/providers/[id]/test/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/providers/bulk/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/providers/client/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/providers/command-code/auth/apply/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/providers/import/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/providers/quota-windows/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/providers/validate/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/quota/groups/[id]/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/quota/groups/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/quota/keys/[id]/models/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/quota/plans/[connectionId]/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/quota/plans/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/quota/pools/[id]/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/quota/pools/[id]/usage/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/quota/pools/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/quota/preview/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/rate-limits/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/resilience/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/__tests__/memory.test.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/__tests__/settings.test.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/authz-inventory/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/auto-disable-accounts/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/combo-defaults/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/export-json/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/free-proxies/[id]/add-to-pool/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/free-proxies/bulk-add-to-pool/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/free-proxies/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/free-proxies/stats/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/memory/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/payload-rules/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/proxies/[id]/repair-relay/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/proxies/assignments/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/proxies/auto-test/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/proxies/batch-activate/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/proxies/batch-delete/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/proxies/bulk-assign/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/proxies/bulk-import/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/proxies/health/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/proxies/migrate/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/proxies/pool/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/proxies/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/proxy/cloudflare-deploy/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/proxy/deno-deploy/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/proxy/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/proxy/test/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/proxy/vercel-deploy/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/qdrant/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/quota-store/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/require-login/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/route.ts": { + "no-restricted-imports": { + "count": 2 + } + }, + "src/app/api/settings/system-prompt/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/settings/thinking-budget/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/skills/collect/chaos/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/sync/cloud/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/token-health/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/tools/agent-bridge/server/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/translator/send/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/translator/translate/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/usage/call-logs/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/usage/quota/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/usage/token-limits/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1/_helpers/apiKeyScope.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1/audio/speech/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1/audio/transcriptions/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1/audio/translations/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1/batches/[id]/cancel/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1/batches/[id]/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1/batches/delete-completed/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1/batches/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1/combos/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1/files/[id]/content/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1/files/[id]/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1/files/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1/images/edits/route.ts": { + "no-restricted-imports": { + "count": 2 + } + }, + "src/app/api/v1/images/generations/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1/management/proxies/assignments/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1/management/proxies/bulk-assign/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1/management/proxies/health/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1/management/proxies/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1/messages/count_tokens/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1/models/catalog.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1/rerank/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/v1beta/models/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/webhooks/[id]/deliveries/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/webhooks/[id]/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/app/api/webhooks/[id]/test/route.ts": { + "no-restricted-imports": { + "count": 2 + } + }, + "src/app/api/webhooks/route.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/domain/quotaCache.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/api/modelTestRunner.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/api/proxyRegistryRouteHandlers.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/cloudSync.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/combos/builderOptions.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/container.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/credentialHealth/scheduler.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/embeddings/service.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/evals/runtime.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/freeProxyProviders/scheduler.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/freeProxyProviders/syncCycle.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/idempotencyLayer.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/images/imageRouteModel.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/localHealthCheck.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/memory/embedding/index.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/memory/reindex.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/memory/store.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/memory/vectorStore.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/oauth/utils/agyAuthImport.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/oauth/utils/claudeAuthFile.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/oauth/utils/claudeAuthImport.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/oauth/utils/codexAuthFile.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/oauth/utils/codexAuthImport.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/providerModels/managedAvailableModels.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/proxyHealth/scheduler.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/quota/planResolver.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/quota/quotaCombos.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/quota/quotaKey.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/quota/redisQuotaStore.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/quota/sqliteQuotaStore.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/services/quotaAutoPing.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/sync/bundle.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/tokenHealthCheck.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/tokenHealthCheckCopilot.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/usage/codexResetCredits.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/usage/providerLimits.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/lib/ws/handshake.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/models/index.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/shared/services/apiKeyResolver.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/shared/services/cloudSyncScheduler.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/shared/services/initializeCloudSync.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/shared/services/modelSyncScheduler.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/shared/utils/apiAuth.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/shared/utils/apiKeyPolicy.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/sse/handlers/autoRouting.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/sse/handlers/chat.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/sse/handlers/chatHelpers.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/sse/services/model.ts": { + "no-restricted-imports": { + "count": 2 + } + }, + "src/sse/services/noAuthProviderSettings.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "src/sse/services/tokenRefresh.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "tests/integration/files-api.test.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "tests/unit/batch-deletion.test.ts": { + "no-restricted-imports": { + "count": 1 + } } -} \ No newline at end of file +} diff --git a/docs/dev/CONTRIBUTION_GOLDEN_PATH.md b/docs/dev/CONTRIBUTION_GOLDEN_PATH.md new file mode 100644 index 0000000000..76168e7c56 --- /dev/null +++ b/docs/dev/CONTRIBUTION_GOLDEN_PATH.md @@ -0,0 +1,229 @@ +--- +title: "Contribution Golden Path" +--- + +# Contribution Golden Path + +Use this guide to choose the smallest reliable development loop for a pull request. It does not +replace the area-specific architecture and security documents linked below; it connects each common +change type to its contracts, focused checks, and CI coverage. + +## The path every change follows + +1. **Choose the base before editing.** Find the highest active `release/v*` branch and branch from + its tip. Target that branch, not `main`. If a release freeze is active, do not target the frozen + branch; use the next active cycle described in + [Branching & Release Model](../ops/BRANCHING_MODEL.md). +2. **Name the contracts.** Identify every catalog, schema, generated artifact, public API, or user + interface that the change affects. The table below gives the minimum starting set. +3. **Write or update focused tests.** Production changes in `src/`, `open-sse/`, `electron/`, or + `bin/` require an automated test in the same PR. Run the smallest test files that prove the + behavior, then the listed focused gates. +4. **Let CI run the broad matrix.** The complete unit shards, Vitest, coverage ratchet, and + production build run on the PR. Run a broad suite locally only when a focused failure points to + wider impact or when the change spans several subsystems. +5. **Reconcile before review.** Fetch the active base, inspect its new commits and your diff against + it, then rebase or merge the base according to the contributor workflow. Resolve generated-file + and catalog conflicts from their source, regenerate them, rerun the focused loop, and confirm the + PR still targets the active release branch. +6. **Record evidence.** In the PR template, list the commands run, every test file added or changed, + migrations or feature flags, and any CI-only validation still pending. + +## Golden paths by change type + +Commands below are minimum focused checks, not permission to skip a test that directly covers the +behavior you changed. + +### Provider + +**Contracts** + +- Provider definition in `src/shared/constants/providers/` and its composition in + `src/shared/constants/providers.ts`. +- Models and capabilities in `open-sse/config/providerRegistry.ts` or its extracted registry files. +- Executor/translator selection, OAuth or API-key configuration, dashboard assets, and generated + provider reference when applicable. +- Public credentials must use `resolvePublicCred()`; error responses must use the shared sanitized + error helpers. See [Public Credentials](../security/PUBLIC_CREDS.md) and + [Error Sanitization](../security/ERROR_SANITIZATION.md). + +**Focused loop** + +```bash +npm run check:provider-consistency +npm run check:provider-assets +node --import tsx/esm --test tests/unit/provider-translate-path-golden.test.ts +node --import tsx/esm --test tests/unit/.test.ts +npm run gen:provider-reference # when the catalog changes; commit the generated diff +npm run lint +``` + +Also test every affected request family: chat, Responses, images, embeddings, audio, or video. +Review generated catalog and golden diffs as contract changes; do not accept them blindly. + +### Routing + +**Contracts** + +- Public strategy values and UI metadata in `src/shared/constants/routingStrategies.ts`. +- Dispatch and ordering under `open-sse/services/combo.ts` and `open-sse/services/combo/`. +- Combo schemas, persistence, resilience state, model capabilities, and API/UI controls. +- [Auto-Combo Engine](../routing/AUTO-COMBO.md) and resilience documentation when behavior changes. + +**Focused loop** + +```bash +node --import tsx/esm --test tests/unit/combo-.test.ts +npm run test:combo:matrix # strategy or dispatch changes +npm run check:known-symbols # strategy registration changes +npm run lint +``` + +Use deterministic mocked-upstream tests locally. Live combo smokes require credentials and are +manual, not CI substitutes. + +### UI / UX + +**Contracts** + +- Next.js route/page and shared component boundaries under `src/app/` and + `src/shared/components/`. +- API response shapes, loading/empty/error states, keyboard and screen-reader behavior, + responsive layout, theming, and locale expansion. +- English UI source strings in `src/i18n/messages/en.json`; do not hard-code new user-facing copy. + +**Focused loop** + +```bash +node --import tsx --test tests/unit/dashboard/.test.ts +npx vitest run --config vitest.config.ts tests/unit/ui/.test.tsx +npm run check:dashboard-typecheck +npm run lint +``` + +Run the app for interaction or visual changes and check both narrow and wide viewports. CI runs the +production build and broader suites; visual behavior still needs a focused component, Playwright, +or documented manual check appropriate to the change. + +### i18n + +**Contracts** + +- `src/i18n/messages/en.json` is the UI source; `config/i18n.json` is the locale source. +- CLI catalogs live separately under `bin/cli/locales/`. +- Preserve ICU placeholders and tags exactly. Do not translate product/provider/model names, + protocol and header names, commands, code/JSON identifiers, URLs, environment variables, or + protected terms such as `OmniRoute`, `OAuth`, `MCP`, and `A2A`. The current source list is + `scripts/i18n/glossary/protected-terms.json`. + +**Focused loop** + +```bash +npm run i18n:sync-ui:dry +npm run i18n:check-ui-coverage +npm run i18n:check-value-drift +npm run i18n:check-glossary +npm run check:cli-i18n # when CLI strings/catalogs change +npm run lint +``` + +This is guidance for the existing system, not an invitation to expand its tooling or key model. +Keep i18n patches surgical while the replacement system is being designed. Do not run translation +commands that call external services unless the task explicitly requires generated translations and +you have reviewed the resulting diff. + +### CLI + +**Contracts** + +- Public commands and flags in `bin/cli/`, generated API commands, exit codes, stdout/stderr and + JSON output shapes, config/environment behavior, and packaged files. +- CLI user-facing strings must use the CLI i18n layer and keep `en`/`pt-BR` catalogs aligned. +- Preserve Node as the supported runtime and the published binary contract. + +**Focused loop** + +```bash +node --import tsx/esm --test tests/unit/cli/.test.ts +npm run check:cli-i18n +npm run build:cli # generated/bundled CLI changes +npm run check:pack-policy # package-surface changes +npm run lint +``` + +Use the exact command in a temporary data directory when behavior depends on parsing, files, or exit +status. CI performs the broader package artifact and ecosystem checks. + +### Database + +**Contracts** + +- Domain modules under `src/lib/db/`; `src/lib/localDb.ts` remains a re-export layer only. +- Numbered, idempotent SQL migrations under `src/lib/db/migrations/`, transaction safety, upgrade + behavior, indexes, and every caller affected by the schema. +- Routes and handlers never issue raw SQL directly. + +**Focused loop** + +```bash +npm run check:migration-numbering +npm run check:db-rules +node --import tsx/esm --test tests/unit/db/.test.ts +node --import tsx/esm --test tests/unit/db/migration-.test.ts +npm run lint +``` + +Test both a fresh database and upgrade from the prior schema when adding a migration. Database tests +must close handles and call `resetDbInstance()` during cleanup. Run `npm run test:bun:db` only when +the best-effort Bun adapter path changes; Node remains authoritative. + +### Build / deploy + +**Contracts** + +- Root and workspace manifests/lockfile, `scripts/build/`, Next.js standalone assembly, `dist/` + package contents, Electron platform metadata, CI workflows, and deployment sentinels. +- Supported Node ranges and the allow-listed Bun use in `CLAUDE.md` must remain intact. +- Build artifacts stay untracked; dependency, license, workflow, and package policies apply. + +**Focused loop** + +```bash +node --import tsx/esm --test tests/unit/build/.test.ts +npm run check:build-scope +npm run check:lockfile # dependency or lockfile changes +npm run check:pack-policy # published package surface changes +npm run lint +``` + +Use `npm run build` locally only when the change affects compilation, standalone assembly, assets, +or runtime bundling. Use `npm run build:release` only for release/deploy validation. CI's build is +the final cross-platform signal; platform-specific Electron changes need the matching focused build +or smoke evidence. + +## Local loop versus CI + +| Run locally for each patch | CI supplies the broad signal | +| ----------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Direct behavior tests and category gates above | Sharded full unit suite and serial tests | +| `npm run lint` | Vitest suites and coverage/quality ratchets | +| Typecheck or build only when the affected contract calls for it | Production build, security, docs, dependency, and PR-policy gates | +| Manual interaction/live checks only when automation cannot prove the behavior | Cross-job integration and platform checks configured by workflow | + +A green focused loop is evidence about the changed contract, not proof that unrelated CI checks +will pass. Conversely, do not make every local edit wait for the full repository matrix. + +## Reconciliation checklist + +Before requesting review: + +- Confirm the PR base is still the highest active `release/v*` branch. +- Fetch that base and review commits that landed since you branched. +- Review `git diff ...HEAD` for accidental or generated churn. +- Resolve catalog and generated-document conflicts by updating the source and regenerating output. +- Rerun every focused test/gate listed in the PR description after reconciliation. +- Never weaken assertions or drop required tests merely to match a moved base. + +For release-freeze and retargeting rules, use +[Branching & Release Model](../ops/BRANCHING_MODEL.md). For the complete CI inventory, use +[Quality Gates Reference](../architecture/QUALITY_GATES.md). diff --git a/docs/reference/ENVIRONMENT.md b/docs/reference/ENVIRONMENT.md index f1099872bd..1bf2f0f064 100644 --- a/docs/reference/ENVIRONMENT.md +++ b/docs/reference/ENVIRONMENT.md @@ -414,7 +414,6 @@ detection above). | `OMNIROUTE_HTTP_TIMEOUT_MS` | `30000` | `bin/cli/api.mjs` | Per-attempt HTTP timeout (ms) for CLI → server requests. | | `OMNIROUTE_VERBOSE` | `0` | `bin/cli/api.mjs` | Set to `1` to print retry/backoff diagnostics to stderr during CLI commands. | | `OMNIROUTE_PLUGIN_PATH` | _(unset)_ | `bin/cli/plugins.mjs` | Custom directory for CLI plugin discovery (`omniroute-cmd-*` packages). Defaults to `~/.omniroute/plugins/` when unset. | -| `OMNIROUTE_PLUGINS_ALLOW_EXEC` | `0` | `src/lib/plugins/pluginWorker.ts` | Set to `1` to allow plugins to request the `exec` permission (spawn child processes from the worker sandbox). Local operator only. | --- diff --git a/docs/routing/AUTO-COMBO.md b/docs/routing/AUTO-COMBO.md index 948cd255fa..1b42354be7 100644 --- a/docs/routing/AUTO-COMBO.md +++ b/docs/routing/AUTO-COMBO.md @@ -167,21 +167,21 @@ The Auto-Combo Engine dynamically selects the best provider/model for each reque > Source: [diagrams/auto-combo-12factor.mmd](../diagrams/auto-combo-12factor.mmd) (regenerate via `npm run docs:render-diagrams`). Diagram/filename predate the `cacheAffinity` factor added by #8008 and still show 12 factors. -| Factor | Default Weight | Description | -| :-------------------- | :------------- | :------------------------------------------------------------------------------------------------- | -| `health` | 0.20 | Health score from circuit breaker (CLOSED=1.0, HALF_OPEN=0.5, OPEN=0.0) | -| `quota` | 0.15 | Remaining quota / rate-limit headroom [0..1] | -| `costInv` | 0.15 | Inverse **blended** cost (60% input + 40% output token price, normalized) — cheaper = higher score | -| `latencyInv` | 0.12 | Inverse p95 latency normalized to pool — faster = higher score | -| `taskFit` | 0.08 | Task-type fitness (coding, review, planning, analysis, debugging, docs) | -| `stability` | 0.05 | Variance-based stability (low latency stdDev / error rate) | -| `tierPriority` | 0.05 | Account-tier priority — Ultra=1.0, Pro=0.67, Standard=0.33, Free=0.0 | -| `tierAffinity` | 0.05 | Affinity between the candidate's tier and the manifest-recommended tier | -| `specificityMatch` | 0.05 | Match between request specificity (manifest hint) and model tier | -| `contextAffinity` | 0.05 | Affinity between the request's context-window need and the model's context window | -| `connectionDensity` | 0.05 | Spreads load across connections of the same provider (anti-concentration) | +| Factor | Default Weight | Description | +| :-------------------- | :------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `health` | 0.20 | Health score from circuit breaker (CLOSED=1.0, HALF_OPEN=0.5, OPEN=0.0) | +| `quota` | 0.15 | Remaining quota / rate-limit headroom [0..1] | +| `costInv` | 0.15 | Inverse **blended** cost (60% input + 40% output token price, normalized) — cheaper = higher score | +| `latencyInv` | 0.12 | Inverse p95 latency normalized to pool — faster = higher score | +| `taskFit` | 0.08 | Task-type fitness (coding, review, planning, analysis, debugging, docs) | +| `stability` | 0.05 | Variance-based stability (low latency stdDev / error rate) | +| `tierPriority` | 0.05 | Account-tier priority — Ultra=1.0, Pro=0.67, Standard=0.33, Free=0.0 | +| `tierAffinity` | 0.05 | Affinity between the candidate's tier and the manifest-recommended tier | +| `specificityMatch` | 0.05 | Match between request specificity (manifest hint) and model tier | +| `contextAffinity` | 0.05 | Affinity between the request's context-window need and the model's context window | +| `connectionDensity` | 0.05 | Spreads load across connections of the same provider (anti-concentration) | | `cacheAffinity` | 0.00 | Rendezvous-hash affinity toward the connection likeliest to already hold this request's prompt-cache prefix (`open-sse/services/combo/promptCacheAffinity.ts`); disabled by default (#8008) | -| `resetWindowAffinity` | 0.00 | Bias toward connections whose quota reset window is favorable (disabled by default) | +| `resetWindowAffinity` | 0.00 | Bias toward connections whose quota reset window is favorable (disabled by default) | **Sum:** `0.20 + 0.15 + 0.15 + 0.12 + 0.08 + 0.05 + 0.05 + 0.05 + 0.05 + 0.05 + 0.05 + 0.00 + 0.00 = 1.0` (validated by `validateWeights()`). @@ -215,11 +215,11 @@ combo's stored config. These apply only to the `auto` strategy and only for the that carries them; the combo's saved `modePack`/`budgetCap`/`budgetFallback` are used when the header is absent. -| Header | Accepts | Effect | -| :----------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `X-OmniRoute-Mode` | a preset alias (`fast`, `balanced`, `quality`, `cheap`, `reliable`, `offline`) or a raw pack name (`ship-fast`, `cost-saver`, `quality-first`, `offline-friendly`, `reliability-first`) | Overrides the scoring weights for this request. `balanced`/`default` force the default weights (no pack). Unknown values are ignored (config preserved). | -| `X-OmniRoute-Budget` | a positive number (max USD per request) | Hard cost ceiling: candidates whose estimated cost exceeds it are filtered before selection. What happens when **every** candidate exceeds it is controlled by `X-OmniRoute-Budget-Fallback` below. | -| `X-OmniRoute-Budget-Fallback` | `cheapest` (default, aliases: `cheapest-viable`, `soft`) or `strict` (aliases: `block`, `hard`) | `cheapest`: falls back to the globally cheapest candidate even though it still exceeds the cap (legacy behavior). `strict`: refuses to select — the request fails fast with `HTTP 402` instead of silently overspending. Unknown values are ignored. | +| Header | Accepts | Effect | +| :---------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `X-OmniRoute-Mode` | a preset alias (`fast`, `balanced`, `quality`, `cheap`, `reliable`, `offline`) or a raw pack name (`ship-fast`, `cost-saver`, `quality-first`, `offline-friendly`, `reliability-first`) | Overrides the scoring weights for this request. `balanced`/`default` force the default weights (no pack). Unknown values are ignored (config preserved). | +| `X-OmniRoute-Budget` | a positive number (max USD per request) | Hard cost ceiling: candidates whose estimated cost exceeds it are filtered before selection. What happens when **every** candidate exceeds it is controlled by `X-OmniRoute-Budget-Fallback` below. | +| `X-OmniRoute-Budget-Fallback` | `cheapest` (default, aliases: `cheapest-viable`, `soft`) or `strict` (aliases: `block`, `hard`) | `cheapest`: falls back to the globally cheapest candidate even though it still exceeds the cap (legacy behavior). `strict`: refuses to select — the request fails fast with `HTTP 402` instead of silently overspending. Unknown values are ignored. | ```bash # Force the fastest profile, cap this request at $0.05, and hard-block instead of overspending @@ -240,27 +240,27 @@ resolved values feed the engine's existing `config.modePack` / `config.budgetCap OmniRoute's combo engine supports **19 routing strategies** (declared in `src/shared/constants/routingStrategies.ts` → `ROUTING_STRATEGY_VALUES`). The Auto Combo engine itself is exposed under the `auto` strategy; the others are available for persisted combos. -| Strategy | Description | -| :------------------ | :--------------------------------------------------------------------------------------------------------------------------- | -| `priority` | First-target ordered list with explicit priority | -| `weighted` | Weighted random by per-target weight | -| `round-robin` | Cycle through targets in order | -| `context-relay` | Hand off context across targets (long conversations) | -| `fill-first` | Fill each target's quota before moving to next | -| `p2c` | Power-of-2-choices random load balancing | -| `random` | Uniform random selection | -| `least-used` | Pick target with lowest current load | -| `cost-optimized` | Minimize $ per request given catalog pricing | -| `reset-aware` ⭐ | Prioritize by quota reset time — short reset windows ranked higher | -| `reset-window` | Prefer targets whose quota window resets soonest | -| `headroom` | Pick the target with the most remaining quota headroom | -| `strict-random` | Random without deduplication of repeats | -| `auto` | Use Auto Combo scoring (9-factor) — **recommended** | -| `lkgp` | Last-Known-Good Path (sticky route to last successful target) | -| `context-optimized` | Pick target with best fit for current context size | +| Strategy | Description | +| :------------------ | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `priority` | First-target ordered list with explicit priority | +| `weighted` | Weighted random by per-target weight | +| `round-robin` | Cycle through targets in order | +| `context-relay` | Hand off context across targets (long conversations) | +| `fill-first` | Fill each target's quota before moving to next | +| `p2c` | Power-of-2-choices random load balancing | +| `random` | Uniform random selection | +| `least-used` | Pick target with lowest current load | +| `cost-optimized` | Minimize $ per request given catalog pricing | +| `reset-aware` ⭐ | Prioritize by quota reset time — short reset windows ranked higher | +| `reset-window` | Prefer targets whose quota window resets soonest | +| `headroom` | Pick the target with the most remaining quota headroom | +| `strict-random` | Random without deduplication of repeats | +| `auto` | Use Auto Combo scoring (9-factor) — **recommended** | +| `lkgp` | Last-Known-Good Path (sticky route to last successful target) | +| `context-optimized` | Pick target with best fit for current context size | | `cache-optimized` | Reorder targets by prompt-cache affinity — the connection likeliest to already hold this request's cached prefix is tried first (`open-sse/services/combo/promptCacheAffinity.ts`, #8008) | -| `fusion` 🧬 | Fan out to a panel of models in parallel, then synthesize one answer via a judge (see below) | -| `pipeline` | Run targets sequentially, threading each step's output into the next step's input; only the final answer is returned (#6396) | +| `fusion` 🧬 | Fan out to a panel of models in parallel, then synthesize one answer via a judge (see below) | +| `pipeline` | Run targets sequentially, threading each step's output into the next step's input; only the final answer is returned (#6396) | ⭐ = New in v3.8.0 · 🧬 = New in v3.8.36 @@ -679,11 +679,11 @@ See `docs/marketing/TIERS.md` for tier definitions and provider classification. ### Deterministic routing-decision matrix (`npm run test:combo:matrix`) -`tests/integration/combo-matrix/*.test.ts` proves the routing **decision** of all 18 +`tests/integration/combo-matrix/*.test.ts` proves the routing **decision** of all 19 public strategies end-to-end through the real combo pipeline with a mocked upstream. Coverage includes: -- All 18 `ROUTING_STRATEGY_VALUES` strategies (ordered, weighted, cost, context, fusion, …). +- All 19 `ROUTING_STRATEGY_VALUES` strategies (ordered, weighted, cost, context, fusion, …). - `quota-share` (internal) end-to-end: DRR fairness + saturation deprioritization via the real `selectQuotaShareTarget` seam (`registerQuotaFetcher` / `setLKGP` / `__setHeadroomSaturationFetcherForTests`). diff --git a/eslint.config.mjs b/eslint.config.mjs index 7dcca1e2b8..742a4b1c01 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -13,6 +13,29 @@ const TO_NUMBER_RESTRICTION = { "canonical coercion shape and the `toNumberOrNull`/`toNumberArray` variants.", }; +const LOCAL_DB_IMPORT_RESTRICTION = { + regex: "^(?:@/lib/localDb(?:\\.ts)?|(?:\\.\\.?/)+(?:lib/)?localDb(?:\\.ts)?)$", + message: + "The localDb compatibility barrel is restricted — import the owning domain module " + + "from `@/lib/db/` instead.", +}; + +const EXECUTOR_IMPORT_RESTRICTION = { + regex: "^(?:@omniroute/)?open-sse/executors(?:/|$)", + message: + "Executor implementations must stay behind an open-sse handler or service boundary.", +}; + +const PROP_TYPES_RESTRICTION = { + name: "prop-types", + message: "PropTypes are deprecated. Use TypeScript types/interfaces instead.", +}; + +const IMPORT_BOUNDARY_RESTRICTIONS = { + paths: [PROP_TYPES_RESTRICTION], + patterns: [LOCAL_DB_IMPORT_RESTRICTION], +}; + /** @type {import("eslint").Linter.Config[]} */ const eslintConfig = [ ...nextVitals, @@ -39,15 +62,32 @@ const eslintConfig = [ "no-eval": "error", "no-implied-eval": "error", "no-new-func": "error", + "no-restricted-imports": ["error", IMPORT_BOUNDARY_RESTRICTIONS], + }, + }, + // G14: DB internals may use the compatibility barrel while it is decomposed; all + // other source files must import the owning src/lib/db domain module directly. + { + files: ["src/lib/db/**/*.{ts,tsx,js,jsx}"], + rules: { "no-restricted-imports": [ "error", { - paths: [ - { - name: "prop-types", - message: "PropTypes are deprecated. Use TypeScript types/interfaces instead.", - }, - ], + paths: [PROP_TYPES_RESTRICTION], + }, + ], + }, + }, + // G14: App routes/components must delegate provider execution through handlers or + // services instead of reaching into executor implementations. + { + files: ["src/app/**/*.{ts,tsx,js,jsx}"], + rules: { + "no-restricted-imports": [ + "error", + { + ...IMPORT_BOUNDARY_RESTRICTIONS, + patterns: [LOCAL_DB_IMPORT_RESTRICTION, EXECUTOR_IMPORT_RESTRICTION], }, ], }, diff --git a/package.json b/package.json index 5b995b365b..2adcd90694 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "omniroute", "version": "3.8.50", - "description": "Unified AI router with 160+ providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", + "description": "Unified AI router with 290 providers, RTK+Caveman compression, auto fallback, MCP/A2A, desktop, PWA, and OpenAI-compatible APIs.", "type": "module", "bin": { "omniroute": "bin/omniroute.mjs", diff --git a/src/app/(dashboard)/dashboard/plugins/page.tsx b/src/app/(dashboard)/dashboard/plugins/page.tsx index def30a2153..3fbcf4f741 100644 --- a/src/app/(dashboard)/dashboard/plugins/page.tsx +++ b/src/app/(dashboard)/dashboard/plugins/page.tsx @@ -1,7 +1,7 @@ "use client"; import { useState, useEffect, useCallback } from "react"; -import { Card, Button, EmptyState, Badge } from "@/shared/components"; +import { Card, Button, EmptyState } from "@/shared/components"; import { useNotificationStore } from "@/store/notificationStore"; import { useTranslations } from "next-intl"; @@ -19,10 +19,6 @@ export default function PluginsPage() { const { addNotification } = useNotificationStore(); const t = useTranslations("plugins"); const [plugins, setPlugins] = useState([]); - const [activeTab, setActiveTab] = useState<"installed" | "marketplace">("installed"); - const [marketplacePlugins, setMarketplacePlugins] = useState([]); - const [marketplaceUrl, setMarketplaceUrl] = useState(""); - const [savingUrl, setSavingUrl] = useState(false); const [loading, setLoading] = useState(true); const [scanning, setScanning] = useState(false); @@ -42,51 +38,7 @@ export default function PluginsPage() { useEffect(() => { fetchPlugins(); - fetch("/api/settings") - .then((res) => res.ok ? res.json() : null) - .then((data) => { - if (data?.pluginMarketplaceUrl) setMarketplaceUrl(data.pluginMarketplaceUrl); - }) - .catch(() => {}); }, [fetchPlugins]); - - const fetchMarketplace = useCallback(async () => { - try { - const res = await fetch("/api/plugins/marketplace"); - if (res.ok) { - const data = await res.json(); - setMarketplacePlugins(data.plugins || []); - } - } catch {} - }, []); - - useEffect(() => { - if (activeTab === "marketplace") { - fetchMarketplace(); - } - }, [activeTab, fetchMarketplace]); - - const handleSaveUrl = async () => { - setSavingUrl(true); - try { - const res = await fetch("/api/settings", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ pluginMarketplaceUrl: marketplaceUrl || null }), - }); - if (!res.ok) { - const errData = await res.json().catch(() => null); - addNotification({ type: "error", message: errData?.error || "Failed to save" }); - return; - } - addNotification({ type: "success", message: t("marketplaceUrlSaved") }); - await fetchMarketplace(); - } catch { - addNotification({ type: "error", message: t("saveConfigurationFailed") }); - } finally { - setSavingUrl(false); - } - }; const handleScan = async () => { setScanning(true); @@ -137,127 +89,58 @@ export default function PluginsPage() {

{t("title")}

-
- - -
- {activeTab === "marketplace" && ( - -
- - setMarketplaceUrl(e.target.value)} - /> -
- -
- )} - - {activeTab === "installed" ? ( - <> -
- -
- {plugins.length === 0 ? ( - - ) : ( -
- {plugins.map((plugin) => ( - -
-
-

{plugin.name}

-

- v{plugin.version} - {plugin.author ? ` by ${plugin.author}` : ""} - {plugin.description ? ` — ${plugin.description}` : ""} -

-
- {plugin.hooks.map((hook) => ( - - {hook} - - ))} -
-
-
- - -
-
-
- ))} -
- )} - +
+ +
+ {plugins.length === 0 ? ( + ) : (
- {marketplacePlugins.length === 0 ? ( -
{t("marketplaceEmpty")}
- ) : ( - marketplacePlugins.map((plugin) => ( - -
-
-

- {plugin.name} - {plugin.verified && {t("verified")}} -

-

- v{plugin.version} by {plugin.author} — {plugin.description} -

-
- {plugin.tags?.map((tag: string) => ( - - {tag} - - ))} -
-
-
- + {plugins.map((plugin) => ( + +
+
+

{plugin.name}

+

+ v{plugin.version} + {plugin.author ? ` by ${plugin.author}` : ""} + {plugin.description ? ` — ${plugin.description}` : ""} +

+
+ {plugin.hooks.map((hook) => ( + + {hook} + + ))}
- - )) - )} +
+ + +
+
+
+ ))}
)}
diff --git a/src/lib/plugins/hooks.ts b/src/lib/plugins/hooks.ts index 6a197a050e..2ad5345308 100644 --- a/src/lib/plugins/hooks.ts +++ b/src/lib/plugins/hooks.ts @@ -36,13 +36,6 @@ export const BUILTIN_EVENTS = [ "onRequest", "onResponse", "onError", - "onModelSelect", - "onComboResolve", - "onRateLimit", - "onQuotaExhaust", - "onProviderError", - "onStreamStart", - "onStreamEnd", "onInstall", "onActivate", "onDeactivate", diff --git a/src/lib/plugins/pluginWorker.ts b/src/lib/plugins/pluginWorker.ts deleted file mode 100644 index 7e133ddd06..0000000000 --- a/src/lib/plugins/pluginWorker.ts +++ /dev/null @@ -1,246 +0,0 @@ -/** - * Plugin Worker Thread — runs plugins in isolated Worker threads. - * - * Receives messages from the main thread: - * - { type: "load", entryPoint, permissions, name } → load plugin, send back hooks - * - { type: "call", hook, payload, response?, error? } → call hook, send back result - * - { type: "cleanup" } → terminate gracefully - * - * @module plugins/pluginWorker - */ - -import { parentPort, workerData } from "worker_threads"; -import { readFile, readdir, stat, writeFile, mkdir, rm } from "fs/promises"; -import { resolve } from "path"; -import * as vm from "vm"; - -if (!parentPort) { - throw new Error("pluginWorker must be run as a Worker thread"); -} - -const port = parentPort; - -interface LoadMessage { - type: "load"; - entryPoint: string; - permissions: string[]; - name: string; -} - -interface CallMessage { - type: "call"; - hook: string; - payload: unknown; - response?: unknown; - error?: string; -} - -interface CleanupMessage { - type: "cleanup" | "exit" | "terminate"; -} - -type WorkerMessage = LoadMessage | CallMessage | CleanupMessage; - -/** - * createSandbox — capability-gated object passed to vm.createContext(). - * - * TRUST MODEL: vm is NOT a security boundary (shares the worker's V8 heap; - * prototype-chain escapes are possible). Plugin execution is safe only because: - * 1. /api/plugins/ is classified LOCAL_ONLY in routeGuard — loopback enforced - * before any auth check (Hard Rules #15/#17). - * 2. The `exec` permission additionally requires OMNIROUTE_PLUGINS_ALLOW_EXEC=1 - * (opt-in, default OFF) — child_process is never wired silently. - * Treat plugins as local-operator-trusted code, not sandboxed untrusted code. - */ -function createSandbox(permissions: string[], pluginDir: string): Record { - const activeTimers = new Set>(); - - const sandbox: Record = { - console: { - log: (...args: unknown[]) => port.postMessage({ type: "log", level: "info", args }), - warn: (...args: unknown[]) => port.postMessage({ type: "log", level: "warn", args }), - error: (...args: unknown[]) => port.postMessage({ type: "log", level: "error", args }), - }, - setTimeout: (fn: (...args: unknown[]) => void, ms?: number) => { const t = setTimeout(fn, ms); activeTimers.add(t); return t; }, - clearTimeout: (t: unknown) => { activeTimers.delete(t as ReturnType); clearTimeout(t as ReturnType); }, - setInterval: (fn: (...args: unknown[]) => void, ms?: number) => { const t = setInterval(fn, ms); activeTimers.add(t); return t; }, - clearInterval: (t: unknown) => { activeTimers.delete(t as ReturnType); clearInterval(t as ReturnType); }, - Promise, - JSON, - Math, - Date, - Array, - Object, - String, - Number, - Boolean, - RegExp, - Error, - TypeError, - RangeError, - SyntaxError, - URIError, - Map, - Set, - WeakMap, - WeakSet, - Symbol, - parseInt, - parseFloat, - isNaN, - isFinite, - URL, - URLSearchParams, - }; - - if (permissions.includes("file-read") || permissions.includes("file-write")) { - sandbox.Buffer = Buffer; - } - - if (permissions.includes("network")) { - sandbox.fetch = globalThis.fetch; - sandbox.AbortController = globalThis.AbortController; - sandbox.Headers = globalThis.Headers; - sandbox.Request = globalThis.Request; - sandbox.Response = globalThis.Response; - } - - if (permissions.includes("file-read")) { - sandbox.fs = { - readFile: (p: string, enc?: string) => readFile(resolve(pluginDir, p), enc as BufferEncoding), - readdir: (p: string) => readdir(resolve(pluginDir, p)), - stat: (p: string) => stat(resolve(pluginDir, p)), - }; - } - - if (permissions.includes("file-write")) { - const fs = sandbox.fs as Record || {}; - fs.writeFile = (p: string, data: string) => writeFile(resolve(pluginDir, p), data); - fs.mkdir = (p: string) => mkdir(resolve(pluginDir, p), { recursive: true }); - fs.rm = (p: string) => rm(resolve(pluginDir, p), { recursive: true, force: true }); - sandbox.fs = fs; - } - - if (permissions.includes("env")) { - sandbox.process = { env: new Proxy({}, { - get: (_t, key) => typeof key === "string" ? process.env[key] : undefined, - set: () => false, - has: (_t, key) => typeof key === "string" ? key in process.env : false, - }) }; - } - - if (permissions.includes("exec")) { - if (process.env.OMNIROUTE_PLUGINS_ALLOW_EXEC !== "1") { - throw new Error( - `Plugin '${name}' requested the 'exec' permission, which is disabled. Set OMNIROUTE_PLUGINS_ALLOW_EXEC=1 to enable (local operator only).` - ); - } - sandbox.child_process = { - exec: require("child_process").exec, - execSync: require("child_process").execSync, - }; - } - - sandbox.__activeTimers = activeTimers; - return sandbox; -} - -let context: vm.Context | null = null; -let pluginExports: Record | null = null; -let activeTimers: Set> | null = null; - -async function loadPlugin(entryPoint: string, permissions: string[], name: string): Promise { - const pluginDir = resolve(entryPoint, ".."); - const sandbox = createSandbox(permissions, pluginDir); - context = vm.createContext(sandbox); - activeTimers = sandbox.__activeTimers as Set>; - - const moduleExports: Record = {}; - const moduleObj = { exports: moduleExports }; - sandbox.module = moduleObj; - sandbox.exports = moduleExports; - sandbox.require = (id: string) => { - const allowed: Record = {}; - if (id === "crypto") allowed.crypto = require("crypto"); - if (allowed[id]) return allowed[id]; - throw new Error(`Module '${id}' is not allowed in plugin sandbox`); - }; - - const source = await readFile(entryPoint, "utf-8"); - const wrapped = `(async function(module, exports, require) { ${source} })(module, exports, require);`; - vm.runInContext(wrapped, context, { filename: entryPoint, timeout: 10000 }); - - pluginExports = moduleObj.exports; - - const hooks: string[] = []; - const sources = [pluginExports]; - if (pluginExports.default && typeof pluginExports.default === "object") { - sources.push(pluginExports.default as Record); - } - - for (const src of sources) { - if (typeof src.onRequest === "function" && !hooks.includes("onRequest")) hooks.push("onRequest"); - if (typeof src.onResponse === "function" && !hooks.includes("onResponse")) hooks.push("onResponse"); - if (typeof src.onError === "function" && !hooks.includes("onError")) hooks.push("onError"); - } - - return hooks; -} - -function callHook(hook: string, payload: unknown, extra?: { response?: unknown; error?: string }): unknown { - if (!context || !pluginExports) throw new Error("Plugin not loaded"); - - const sources = [pluginExports]; - if (pluginExports.default && typeof pluginExports.default === "object") { - sources.push(pluginExports.default as Record); - } - - for (const src of sources) { - const fn = src[hook]; - if (typeof fn === "function") { - if (hook === "onResponse" && extra?.response !== undefined) { - return fn(payload, extra.response); - } - if (hook === "onError" && extra?.error !== undefined) { - return fn(payload, new Error(extra.error)); - } - return fn(payload); - } - } - - throw new Error(`Hook '${hook}' not found in plugin exports`); -} - -function cleanup(): void { - if (activeTimers) { - for (const t of activeTimers) { - clearTimeout(t); - clearInterval(t); - } - activeTimers.clear(); - } - context = null; - pluginExports = null; - activeTimers = null; -} - -port.on("message", async (msg: WorkerMessage) => { - try { - if (msg.type === "load") { - const hooks = await loadPlugin(msg.entryPoint, msg.permissions, msg.name); - port.postMessage({ type: "loaded", hooks }); - } else if (msg.type === "call") { - const result = callHook(msg.hook, msg.payload, { response: (msg as CallMessage).response, error: (msg as CallMessage).error }); - port.postMessage({ type: "result", value: result }); - } else if (msg.type === "cleanup" || msg.type === "exit" || msg.type === "terminate") { - cleanup(); - port.postMessage({ type: "cleaned" }); - process.exit(0); - } - } catch (err: unknown) { - const errMsg = err instanceof Error ? err.message : String(err); - port.postMessage({ type: "error", error: errMsg, hook: (msg as CallMessage).hook }); - } -}); - -port.postMessage({ type: "ready" }); diff --git a/src/lib/plugins/sandbox.ts b/src/lib/plugins/sandbox.ts deleted file mode 100644 index f4d4bfe652..0000000000 --- a/src/lib/plugins/sandbox.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Plugin sandbox — configurable isolation levels. - * - * @module plugins/sandbox - */ - -export enum SandboxLevel { - /** Run in-process (no isolation, fastest) */ - IN_PROCESS = 0, - /** Run in child process with full environment */ - CHILD_FULL_ENV = 1, - /** Run in child process with filtered environment */ - CHILD_FILTERED_ENV = 2, - /** Run in child process with isolated environment (no env vars) */ - CHILD_ISOLATED = 3, -} - -export function getSandboxLabel(level: SandboxLevel): string { - switch (level) { - case SandboxLevel.IN_PROCESS: - return "In-Process"; - case SandboxLevel.CHILD_FULL_ENV: - return "Child (Full Env)"; - case SandboxLevel.CHILD_FILTERED_ENV: - return "Child (Filtered Env)"; - case SandboxLevel.CHILD_ISOLATED: - return "Child (Isolated)"; - } -} diff --git a/src/lib/plugins/signing.ts b/src/lib/plugins/signing.ts deleted file mode 100644 index 46cad2acbe..0000000000 --- a/src/lib/plugins/signing.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Plugin signing — Ed25519 signature verification for plugin packages. - * - * @module plugins/signing - */ - -import { createHash, createPublicKey, verify } from "crypto"; - -/** - * Compute SHA-256 hash of a buffer. - */ -export function sha256(data: Buffer): string { - return createHash("sha256").update(data).digest("hex"); -} - -/** - * Verify SHA-256 hash matches expected value. - */ -export function verifySha256(data: Buffer, expectedHash: string): boolean { - const actual = sha256(data); - return actual === expectedHash; -} - -/** - * Verify Ed25519 signature. - */ -export function verifyEd25519(data: Buffer, signature: Buffer, publicKeyDer: Buffer): boolean { - try { - const key = createPublicKey(publicKeyDer); - return verify(null, data, key, signature); - } catch { - return false; - } -} diff --git a/tests/snapshots/g13/combo-chatcore-public-seams.json b/tests/snapshots/g13/combo-chatcore-public-seams.json new file mode 100644 index 0000000000..bf58e93350 --- /dev/null +++ b/tests/snapshots/g13/combo-chatcore-public-seams.json @@ -0,0 +1,1892 @@ +{ + "chatCore": { + "nativeCodexPassthrough": [ + { + "input": { + "endpointPath": "/v1/chat/completions", + "provider": "codex", + "sourceFormat": null + }, + "output": false + }, + { + "input": { + "endpointPath": "/v1/responses", + "provider": null, + "sourceFormat": null + }, + "output": false + }, + { + "input": { + "endpointPath": "/v1/responses", + "provider": "openai", + "sourceFormat": "openai-chat" + }, + "output": false + }, + { + "input": { + "endpointPath": "/v1/responses", + "provider": "codex", + "sourceFormat": "openai-chat" + }, + "output": false + }, + { + "input": { + "endpointPath": "/v1/chat/completions", + "provider": "openai", + "sourceFormat": "openai-responses" + }, + "output": false + }, + { + "input": { + "endpointPath": "/v1/responses/", + "provider": "openai", + "sourceFormat": "openai-responses" + }, + "output": false + }, + { + "input": { + "endpointPath": "/v1/responses", + "provider": "codex", + "sourceFormat": "openai-responses" + }, + "output": true + }, + { + "input": { + "endpointPath": "/v1/responses/", + "provider": "openai", + "sourceFormat": null + }, + "output": false + }, + { + "input": { + "endpointPath": "/v1/responses", + "provider": "openai", + "sourceFormat": "openai-responses" + }, + "output": false + }, + { + "input": { + "endpointPath": "responses", + "provider": null, + "sourceFormat": "openai-responses" + }, + "output": false + }, + { + "input": { + "endpointPath": "responses", + "provider": null, + "sourceFormat": null + }, + "output": false + }, + { + "input": { + "endpointPath": "responses", + "provider": null, + "sourceFormat": "openai-chat" + }, + "output": false + }, + { + "input": { + "endpointPath": "/v1/chat/completions", + "provider": null, + "sourceFormat": null + }, + "output": false + }, + { + "input": { + "endpointPath": "/v1/chat/completions", + "provider": "openai", + "sourceFormat": "openai-chat" + }, + "output": false + }, + { + "input": { + "endpointPath": "/v1/chat/completions", + "provider": null, + "sourceFormat": "openai-responses" + }, + "output": false + }, + { + "input": { + "endpointPath": "responses", + "provider": "codex", + "sourceFormat": "openai-responses" + }, + "output": true + }, + { + "input": { + "endpointPath": "responses", + "provider": "codex", + "sourceFormat": "openai-chat" + }, + "output": false + }, + { + "input": { + "endpointPath": "/v1/chat/completions", + "provider": null, + "sourceFormat": "openai-chat" + }, + "output": false + } + ], + "semanticClaudePassthrough": [ + { + "input": { + "provider": "claude", + "signal": "sessionHeader", + "sourceFormat": "openai-chat", + "targetFormat": "openai-chat" + }, + "output": false + }, + { + "input": { + "provider": "anthropic-compatible-acme", + "signal": "none", + "sourceFormat": "openai-chat", + "targetFormat": "claude" + }, + "output": false + }, + { + "input": { + "provider": "anthropic-compatible-acme", + "signal": "userAgent", + "sourceFormat": "claude", + "targetFormat": "claude" + }, + "output": false + }, + { + "input": { + "provider": "anthropic-compatible-acme", + "signal": "none", + "sourceFormat": "claude", + "targetFormat": "claude" + }, + "output": false + }, + { + "input": { + "provider": "openai", + "signal": "none", + "sourceFormat": "openai-chat", + "targetFormat": "claude" + }, + "output": false + }, + { + "input": { + "provider": "openai", + "signal": "appHeader", + "sourceFormat": "claude", + "targetFormat": "openai-chat" + }, + "output": false + }, + { + "input": { + "provider": "claude", + "signal": "sessionHeader", + "sourceFormat": "claude", + "targetFormat": "claude" + }, + "output": true + }, + { + "input": { + "provider": "claude", + "signal": "none", + "sourceFormat": "claude", + "targetFormat": "claude" + }, + "output": false + }, + { + "input": { + "provider": "openai", + "signal": "appHeader", + "sourceFormat": "openai-chat", + "targetFormat": "openai-chat" + }, + "output": false + }, + { + "input": { + "provider": "openai", + "signal": "appHeader", + "sourceFormat": "openai-chat", + "targetFormat": "claude" + }, + "output": false + }, + { + "input": { + "provider": "anthropic-compatible-acme", + "signal": "appHeader", + "sourceFormat": "openai-chat", + "targetFormat": "openai-chat" + }, + "output": false + }, + { + "input": { + "provider": "anthropic-compatible-acme", + "signal": "sessionHeader", + "sourceFormat": "claude", + "targetFormat": "claude" + }, + "output": false + }, + { + "input": { + "provider": "claude", + "signal": "userAgent", + "sourceFormat": "claude", + "targetFormat": "openai-chat" + }, + "output": false + }, + { + "input": { + "provider": "openai", + "signal": "userAgent", + "sourceFormat": "openai-chat", + "targetFormat": "openai-chat" + }, + "output": false + }, + { + "input": { + "provider": "claude", + "signal": "sessionHeader", + "sourceFormat": "openai-chat", + "targetFormat": "claude" + }, + "output": false + }, + { + "input": { + "provider": "anthropic-compatible-acme", + "signal": "appHeader", + "sourceFormat": "claude", + "targetFormat": "openai-chat" + }, + "output": false + }, + { + "input": { + "provider": "claude", + "signal": "userAgent", + "sourceFormat": "openai-chat", + "targetFormat": "openai-chat" + }, + "output": false + }, + { + "input": { + "provider": "anthropic-compatible-acme", + "signal": "none", + "sourceFormat": "claude", + "targetFormat": "openai-chat" + }, + "output": false + }, + { + "input": { + "provider": "openai", + "signal": "none", + "sourceFormat": "openai-chat", + "targetFormat": "openai-chat" + }, + "output": false + }, + { + "input": { + "provider": "openai", + "signal": "sessionHeader", + "sourceFormat": "openai-chat", + "targetFormat": "openai-chat" + }, + "output": false + }, + { + "input": { + "provider": "openai", + "signal": "userAgent", + "sourceFormat": "openai-chat", + "targetFormat": "claude" + }, + "output": false + }, + { + "input": { + "provider": "openai", + "signal": "userAgent", + "sourceFormat": "claude", + "targetFormat": "claude" + }, + "output": false + }, + { + "input": { + "provider": "openai", + "signal": "none", + "sourceFormat": "claude", + "targetFormat": "claude" + }, + "output": false + }, + { + "input": { + "provider": "openai", + "signal": "sessionHeader", + "sourceFormat": "claude", + "targetFormat": "openai-chat" + }, + "output": false + } + ], + "staleForwardingHeaders": [ + { + "input": { + "content-encoding": "gzip", + "content-length": "10", + "transfer-encoding": "chunked", + "x-request-id": "req-3" + }, + "output": { + "x-request-id": "req-3" + } + }, + { + "input": { + "Content-Length": "20", + "x-provider": "kept" + }, + "output": { + "x-provider": "kept" + } + } + ], + "streamingHeaders": [ + { + "input": 0, + "output": { + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "Content-Type": "text/event-stream", + "X-Accel-Buffering": "no", + "X-OmniRoute-Cache": "MISS", + "X-OmniRoute-Cache-Hit": "false", + "X-OmniRoute-Decision": "provider=test-provider; latency_ms=0", + "X-OmniRoute-Latency-Ms": "0", + "X-OmniRoute-Model": "test-model", + "X-OmniRoute-Provider": "test-provider", + "X-OmniRoute-Request-Id": "request-0", + "X-OmniRoute-Response-Cost": "0.0000000000", + "X-OmniRoute-Tokens-In": "0", + "X-OmniRoute-Tokens-Out": "0", + "X-OmniRoute-Version": "", + "retry-after": "15", + "x-ratelimit-remaining": "7", + "x-request-id": "req-1", + "x-upstream-debug": "safe" + } + }, + { + "input": 1, + "output": { + "Cache-Control": "no-cache, no-transform", + "Connection": "keep-alive", + "Content-Type": "text/event-stream", + "X-Accel-Buffering": "no", + "X-OmniRoute-Cache": "MISS", + "X-OmniRoute-Cache-Hit": "false", + "X-OmniRoute-Decision": "provider=test-provider; latency_ms=0", + "X-OmniRoute-Latency-Ms": "0", + "X-OmniRoute-Model": "test-model", + "X-OmniRoute-Provider": "test-provider", + "X-OmniRoute-Request-Id": "request-1", + "X-OmniRoute-Response-Cost": "0.0000000000", + "X-OmniRoute-Tokens-In": "0", + "X-OmniRoute-Tokens-Out": "0", + "X-OmniRoute-Version": "", + "request-id": "req-2", + "x-provider": "kept" + } + } + ], + "systemRoleExtraction": [ + { + "input": { + "messages": [ + { + "content": [ + { + "text": "lifted", + "type": "text" + }, + { + "source": "ignored", + "type": "image" + } + ], + "role": "SYSTEM" + } + ], + "system": [ + { + "text": "existing-block", + "type": "text" + } + ] + }, + "output": { + "messages": [], + "system": [ + { + "text": "existing-block", + "type": "text" + }, + { + "text": "lifted", + "type": "text" + } + ] + } + }, + { + "input": { + "messages": [ + { + "content": "new", + "role": "developer" + }, + { + "content": "ok", + "role": "assistant" + } + ], + "system": "existing" + }, + "output": { + "messages": [ + { + "content": "ok", + "role": "assistant" + } + ], + "system": [ + { + "text": "existing", + "type": "text" + }, + { + "text": "new", + "type": "text" + } + ] + } + }, + { + "input": { + "messages": [ + { + "content": "policy", + "role": "system" + }, + { + "content": "go", + "role": "user" + } + ] + }, + "output": { + "messages": [ + { + "content": "go", + "role": "user" + } + ], + "system": [ + { + "text": "policy", + "type": "text" + } + ] + } + }, + { + "input": { + "messages": [ + { + "content": "", + "role": "developer" + }, + { + "content": "kept", + "role": "user" + } + ] + }, + "output": { + "messages": [ + { + "content": "kept", + "role": "user" + } + ] + } + }, + { + "input": { + "messages": null, + "model": "claude" + }, + "output": { + "messages": null, + "model": "claude" + } + }, + { + "input": { + "messages": [ + { + "content": "hello", + "role": "user" + } + ] + }, + "output": { + "messages": [ + { + "content": "hello", + "role": "user" + } + ] + } + } + ], + "thinkingSignaturePassthrough": [ + { + "input": [ + { + "content": [ + { + "signature": "sig", + "text": "x", + "type": "thinking" + } + ], + "role": "assistant" + } + ], + "output": [ + { + "content": [ + { + "signature": "sig", + "text": "x", + "type": "thinking" + } + ], + "role": "assistant" + } + ] + }, + { + "input": "plain", + "output": "plain" + }, + { + "input": { + "messages": [ + { + "content": [ + { + "data": "x", + "type": "redacted_thinking" + } + ], + "role": "assistant" + } + ] + }, + "output": { + "messages": [ + { + "content": [ + { + "data": "x", + "type": "redacted_thinking" + } + ], + "role": "assistant" + } + ] + } + }, + { + "input": null, + "output": null + } + ], + "tokenExpiry": [ + { + "input": { + "bufferMs": 86400000, + "expiresAt": "2999-01-01T00:00:00.000Z" + }, + "output": false + }, + { + "input": { + "bufferMs": 0, + "expiresAt": "2999-01-01T00:00:00.000Z" + }, + "output": false + }, + { + "input": { + "bufferMs": 0, + "expiresAt": null + }, + "output": false + }, + { + "input": { + "bufferMs": 300000, + "expiresAt": "invalid" + }, + "output": false + }, + { + "input": { + "bufferMs": 86400000, + "expiresAt": "invalid" + }, + "output": false + }, + { + "input": { + "bufferMs": 300000, + "expiresAt": "2000-01-01T00:00:00.000Z" + }, + "output": true + }, + { + "input": { + "bufferMs": 300000, + "expiresAt": "2999-01-01T00:00:00.000Z" + }, + "output": false + }, + { + "input": { + "bufferMs": 0, + "expiresAt": "invalid" + }, + "output": false + }, + { + "input": { + "bufferMs": 86400000, + "expiresAt": "2000-01-01T00:00:00.000Z" + }, + "output": true + }, + { + "input": { + "bufferMs": 0, + "expiresAt": "2000-01-01T00:00:00.000Z" + }, + "output": true + } + ] + }, + "combo": { + "badRequestClassifiers": [ + { + "input": "this model does not support the Responses API", + "output": { + "contextOverflow": false, + "modelScoped": true, + "parameterValidation": false + } + }, + { + "input": "unsupported_api_for_model", + "output": { + "contextOverflow": false, + "modelScoped": true, + "parameterValidation": false + } + }, + { + "input": "maximum context length exceeded", + "output": { + "contextOverflow": true, + "modelScoped": false, + "parameterValidation": false + } + }, + { + "input": "model is not supported", + "output": { + "contextOverflow": false, + "modelScoped": true, + "parameterValidation": false + } + }, + { + "input": "", + "output": { + "contextOverflow": false, + "modelScoped": false, + "parameterValidation": false + } + }, + { + "input": "your input exceeds the context window", + "output": { + "contextOverflow": true, + "modelScoped": false, + "parameterValidation": false + } + }, + { + "input": "parameter is illegal", + "output": { + "contextOverflow": false, + "modelScoped": false, + "parameterValidation": true + } + }, + { + "input": "plain upstream failure", + "output": { + "contextOverflow": false, + "modelScoped": false, + "parameterValidation": false + } + }, + { + "input": "max_tokens must be between 1 and 4096", + "output": { + "contextOverflow": true, + "modelScoped": false, + "parameterValidation": true + } + }, + { + "input": "invalid message format", + "output": { + "contextOverflow": false, + "modelScoped": false, + "parameterValidation": false + } + } + ], + "clampDepth": [ + { + "input": "NaN", + "output": 3 + }, + { + "input": "nope", + "output": 3 + }, + { + "input": "10", + "output": 10 + }, + { + "input": "999", + "output": 10 + }, + { + "input": "1.9", + "output": 1 + }, + { + "input": "-4", + "output": 3 + }, + { + "input": "null", + "output": 3 + }, + { + "input": "0", + "output": 3 + }, + { + "input": "0.9", + "output": 3 + }, + { + "input": "1", + "output": 1 + }, + { + "input": "9.9", + "output": 9 + }, + { + "input": "undefined", + "output": 3 + } + ], + "connectionDisable": [ + { + "input": { + "errorCode": null, + "hasExtraKeys": false, + "is401": true, + "provider": "codex", + "status": 499 + }, + "output": true + }, + { + "input": { + "errorCode": "plugin_block", + "hasExtraKeys": true, + "is401": false, + "provider": "openai", + "status": 401 + }, + "output": true + }, + { + "input": { + "errorCode": "plugin_block", + "hasExtraKeys": true, + "is401": true, + "provider": "openai", + "status": 401 + }, + "output": true + }, + { + "input": { + "errorCode": "client_disconnected", + "hasExtraKeys": false, + "is401": false, + "provider": "openai", + "status": 408 + }, + "output": true + }, + { + "input": { + "errorCode": null, + "hasExtraKeys": true, + "is401": true, + "provider": "openai", + "status": 401 + }, + "output": true + }, + { + "input": { + "errorCode": "context_length_exceeded", + "hasExtraKeys": false, + "is401": true, + "provider": "codex", + "status": 401 + }, + "output": true + }, + { + "input": { + "errorCode": null, + "hasExtraKeys": false, + "is401": false, + "provider": "codex", + "status": 408 + }, + "output": false + }, + { + "input": { + "errorCode": "context_length_exceeded", + "hasExtraKeys": true, + "is401": true, + "provider": "openai", + "status": 499 + }, + "output": true + }, + { + "input": { + "errorCode": "context_length_exceeded", + "hasExtraKeys": false, + "is401": false, + "provider": "openai", + "status": 401 + }, + "output": true + }, + { + "input": { + "errorCode": "context_length_exceeded", + "hasExtraKeys": true, + "is401": false, + "provider": "codex", + "status": 408 + }, + "output": true + }, + { + "input": { + "errorCode": "client_disconnected", + "hasExtraKeys": false, + "is401": true, + "provider": "codex", + "status": 401 + }, + "output": true + }, + { + "input": { + "errorCode": "plugin_block", + "hasExtraKeys": true, + "is401": false, + "provider": "codex", + "status": 401 + }, + "output": true + }, + { + "input": { + "errorCode": null, + "hasExtraKeys": false, + "is401": true, + "provider": "openai", + "status": 502 + }, + "output": false + }, + { + "input": { + "errorCode": "plugin_block", + "hasExtraKeys": true, + "is401": false, + "provider": "codex", + "status": 499 + }, + "output": true + }, + { + "input": { + "errorCode": null, + "hasExtraKeys": true, + "is401": false, + "provider": "codex", + "status": 401 + }, + "output": false + }, + { + "input": { + "errorCode": "context_length_exceeded", + "hasExtraKeys": false, + "is401": true, + "provider": "codex", + "status": 502 + }, + "output": true + }, + { + "input": { + "errorCode": "plugin_block", + "hasExtraKeys": false, + "is401": false, + "provider": "openai", + "status": 408 + }, + "output": true + }, + { + "input": { + "errorCode": "context_length_exceeded", + "hasExtraKeys": false, + "is401": true, + "provider": "codex", + "status": 499 + }, + "output": true + }, + { + "input": { + "errorCode": "client_disconnected", + "hasExtraKeys": true, + "is401": true, + "provider": "openai", + "status": 502 + }, + "output": true + }, + { + "input": { + "errorCode": null, + "hasExtraKeys": false, + "is401": false, + "provider": "openai", + "status": 499 + }, + "output": true + }, + { + "input": { + "errorCode": "context_length_exceeded", + "hasExtraKeys": false, + "is401": true, + "provider": "codex", + "status": 408 + }, + "output": true + }, + { + "input": { + "errorCode": null, + "hasExtraKeys": false, + "is401": true, + "provider": "codex", + "status": 408 + }, + "output": false + }, + { + "input": { + "errorCode": "context_length_exceeded", + "hasExtraKeys": true, + "is401": true, + "provider": "codex", + "status": 502 + }, + "output": true + }, + { + "input": { + "errorCode": "plugin_block", + "hasExtraKeys": true, + "is401": false, + "provider": "codex", + "status": 502 + }, + "output": true + } + ], + "connectionStatusCutoff": [ + { + "input": { + "rateLimitedUntil": "2999-01-01T00:00:00.000Z", + "testStatus": "healthy" + }, + "output": null + }, + { + "input": { + "rateLimitedUntil": "2000-01-01T00:00:00.000Z", + "testStatus": "unavailable" + }, + "output": null + }, + { + "output": null + }, + { + "input": { + "rateLimitedUntil": "2999-01-01T00:00:00.000Z", + "testStatus": "unavailable" + }, + "output": "rate_limited" + }, + { + "input": { + "testStatus": " CREDITS_EXHAUSTED " + }, + "output": "credits_exhausted" + }, + { + "input": { + "testStatus": "banned" + }, + "output": "banned" + }, + { + "input": {}, + "output": null + } + ], + "predictiveTtft": [ + { + "input": { + "avgLatencyMs": 1001, + "predictiveTtftMs": 3000, + "requests": 20 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 1001, + "predictiveTtftMs": -1, + "requests": 20 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 0, + "predictiveTtftMs": 1000, + "requests": 4 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 5000, + "predictiveTtftMs": 0, + "requests": 4 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 0, + "predictiveTtftMs": 0, + "requests": 5 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 1000, + "predictiveTtftMs": 3000, + "requests": 5 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 1000, + "predictiveTtftMs": 1000, + "requests": 4 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 1001, + "predictiveTtftMs": 3000, + "requests": 0 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 1001, + "predictiveTtftMs": 1000, + "requests": 4 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 5000, + "predictiveTtftMs": 0, + "requests": 0 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 999, + "predictiveTtftMs": 1000, + "requests": 4 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 0, + "predictiveTtftMs": 3000, + "requests": 4 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 1001, + "predictiveTtftMs": 0, + "requests": 5 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 5000, + "predictiveTtftMs": 1000, + "requests": 5 + }, + "output": true + }, + { + "input": { + "avgLatencyMs": 0, + "predictiveTtftMs": -1, + "requests": 0 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 999, + "predictiveTtftMs": -1, + "requests": 0 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 1000, + "predictiveTtftMs": 0, + "requests": 4 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 5000, + "predictiveTtftMs": 3000, + "requests": 4 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 0, + "predictiveTtftMs": 0, + "requests": 6 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 999, + "predictiveTtftMs": -1, + "requests": 20 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 999, + "predictiveTtftMs": 1000, + "requests": 20 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 1001, + "predictiveTtftMs": 0, + "requests": 4 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 999, + "predictiveTtftMs": -1, + "requests": 4 + }, + "output": false + }, + { + "input": { + "avgLatencyMs": 1001, + "predictiveTtftMs": 1000, + "requests": 5 + }, + "output": true + } + ], + "providerBreaker": [ + { + "input": { + "error": "Client disconnected: aborted", + "isProxyUnreachable": false, + "isStreamReadinessFailure": false, + "requestScopedFailure": false, + "sameProviderNext": false, + "skipProviderBreaker": false, + "status": 503 + }, + "output": false + }, + { + "input": { + "error": "upstream failed", + "isProxyUnreachable": true, + "isStreamReadinessFailure": true, + "requestScopedFailure": false, + "sameProviderNext": true, + "skipProviderBreaker": false, + "status": 400 + }, + "output": false + }, + { + "input": { + "error": "upstream failed", + "isProxyUnreachable": true, + "isStreamReadinessFailure": true, + "requestScopedFailure": true, + "sameProviderNext": false, + "skipProviderBreaker": false, + "status": 504 + }, + "output": false + }, + { + "input": { + "error": "upstream failed", + "isProxyUnreachable": false, + "isStreamReadinessFailure": false, + "requestScopedFailure": true, + "sameProviderNext": true, + "skipProviderBreaker": true, + "status": 502 + }, + "output": false + }, + { + "input": { + "error": "Client disconnected: aborted", + "isProxyUnreachable": true, + "isStreamReadinessFailure": true, + "requestScopedFailure": false, + "sameProviderNext": true, + "skipProviderBreaker": false, + "status": 502 + }, + "output": false + }, + { + "input": { + "error": "upstream failed", + "isProxyUnreachable": true, + "isStreamReadinessFailure": true, + "requestScopedFailure": false, + "sameProviderNext": true, + "skipProviderBreaker": true, + "status": 408 + }, + "output": false + }, + { + "input": { + "error": "Client disconnected: aborted", + "isProxyUnreachable": false, + "isStreamReadinessFailure": true, + "requestScopedFailure": true, + "sameProviderNext": false, + "skipProviderBreaker": false, + "status": 400 + }, + "output": false + }, + { + "input": { + "error": "upstream failed", + "isProxyUnreachable": false, + "isStreamReadinessFailure": false, + "requestScopedFailure": false, + "sameProviderNext": true, + "skipProviderBreaker": true, + "status": 408 + }, + "output": false + }, + { + "input": { + "error": "upstream failed", + "isProxyUnreachable": true, + "isStreamReadinessFailure": true, + "requestScopedFailure": true, + "sameProviderNext": true, + "skipProviderBreaker": true, + "status": 502 + }, + "output": false + }, + { + "input": { + "error": "Client disconnected: aborted", + "isProxyUnreachable": false, + "isStreamReadinessFailure": true, + "requestScopedFailure": false, + "sameProviderNext": true, + "skipProviderBreaker": false, + "status": 408 + }, + "output": false + }, + { + "input": { + "error": "Client disconnected: aborted", + "isProxyUnreachable": true, + "isStreamReadinessFailure": false, + "requestScopedFailure": false, + "sameProviderNext": false, + "skipProviderBreaker": false, + "status": 504 + }, + "output": false + }, + { + "input": { + "error": "upstream failed", + "isProxyUnreachable": true, + "isStreamReadinessFailure": false, + "requestScopedFailure": true, + "sameProviderNext": true, + "skipProviderBreaker": false, + "status": 502 + }, + "output": false + }, + { + "input": { + "error": "upstream failed", + "isProxyUnreachable": false, + "isStreamReadinessFailure": false, + "requestScopedFailure": false, + "sameProviderNext": false, + "skipProviderBreaker": false, + "status": 408 + }, + "output": true + }, + { + "input": { + "error": "Client disconnected: aborted", + "isProxyUnreachable": true, + "isStreamReadinessFailure": true, + "requestScopedFailure": false, + "sameProviderNext": true, + "skipProviderBreaker": true, + "status": 504 + }, + "output": false + }, + { + "input": { + "error": "upstream failed", + "isProxyUnreachable": false, + "isStreamReadinessFailure": true, + "requestScopedFailure": false, + "sameProviderNext": true, + "skipProviderBreaker": true, + "status": 502 + }, + "output": false + }, + { + "input": { + "error": "Client disconnected: aborted", + "isProxyUnreachable": false, + "isStreamReadinessFailure": false, + "requestScopedFailure": true, + "sameProviderNext": false, + "skipProviderBreaker": true, + "status": 504 + }, + "output": false + }, + { + "input": { + "error": "Client disconnected: aborted", + "isProxyUnreachable": true, + "isStreamReadinessFailure": false, + "requestScopedFailure": true, + "sameProviderNext": true, + "skipProviderBreaker": false, + "status": 504 + }, + "output": false + }, + { + "input": { + "error": "upstream failed", + "isProxyUnreachable": true, + "isStreamReadinessFailure": true, + "requestScopedFailure": true, + "sameProviderNext": false, + "skipProviderBreaker": true, + "status": 504 + }, + "output": false + }, + { + "input": { + "error": "Client disconnected: aborted", + "isProxyUnreachable": true, + "isStreamReadinessFailure": false, + "requestScopedFailure": false, + "sameProviderNext": true, + "skipProviderBreaker": false, + "status": 504 + }, + "output": false + }, + { + "input": { + "error": "Client disconnected: aborted", + "isProxyUnreachable": true, + "isStreamReadinessFailure": true, + "requestScopedFailure": false, + "sameProviderNext": true, + "skipProviderBreaker": true, + "status": 503 + }, + "output": false + }, + { + "input": { + "error": "Client disconnected: aborted", + "isProxyUnreachable": true, + "isStreamReadinessFailure": true, + "requestScopedFailure": true, + "sameProviderNext": true, + "skipProviderBreaker": false, + "status": 400 + }, + "output": false + }, + { + "input": { + "error": "upstream failed", + "isProxyUnreachable": false, + "isStreamReadinessFailure": true, + "requestScopedFailure": true, + "sameProviderNext": true, + "skipProviderBreaker": false, + "status": 408 + }, + "output": false + }, + { + "input": { + "error": "upstream failed", + "isProxyUnreachable": false, + "isStreamReadinessFailure": true, + "requestScopedFailure": false, + "sameProviderNext": false, + "skipProviderBreaker": true, + "status": 500 + }, + "output": false + }, + { + "input": { + "error": "Client disconnected: aborted", + "isProxyUnreachable": true, + "isStreamReadinessFailure": true, + "requestScopedFailure": false, + "sameProviderNext": false, + "skipProviderBreaker": false, + "status": 408 + }, + "output": false + }, + { + "input": { + "error": "upstream failed", + "isProxyUnreachable": false, + "isStreamReadinessFailure": false, + "requestScopedFailure": true, + "sameProviderNext": false, + "skipProviderBreaker": true, + "status": 408 + }, + "output": false + }, + { + "input": { + "error": "Client disconnected: aborted", + "isProxyUnreachable": true, + "isStreamReadinessFailure": false, + "requestScopedFailure": true, + "sameProviderNext": false, + "skipProviderBreaker": true, + "status": 400 + }, + "output": false + }, + { + "input": { + "error": "upstream failed", + "isProxyUnreachable": true, + "isStreamReadinessFailure": true, + "requestScopedFailure": false, + "sameProviderNext": true, + "skipProviderBreaker": false, + "status": 408 + }, + "output": false + }, + { + "input": { + "error": "upstream failed", + "isProxyUnreachable": true, + "isStreamReadinessFailure": true, + "requestScopedFailure": false, + "sameProviderNext": true, + "skipProviderBreaker": false, + "status": 502 + }, + "output": false + }, + { + "input": { + "error": "Client disconnected: aborted", + "isProxyUnreachable": true, + "isStreamReadinessFailure": true, + "requestScopedFailure": false, + "sameProviderNext": true, + "skipProviderBreaker": false, + "status": 400 + }, + "output": false + }, + { + "input": { + "error": "Client disconnected: aborted", + "isProxyUnreachable": false, + "isStreamReadinessFailure": false, + "requestScopedFailure": false, + "sameProviderNext": false, + "skipProviderBreaker": true, + "status": 504 + }, + "output": false + }, + { + "input": { + "error": "upstream failed", + "isProxyUnreachable": true, + "isStreamReadinessFailure": false, + "requestScopedFailure": false, + "sameProviderNext": false, + "skipProviderBreaker": false, + "status": 502 + }, + "output": true + }, + { + "input": { + "error": "upstream failed", + "isProxyUnreachable": true, + "isStreamReadinessFailure": false, + "requestScopedFailure": true, + "sameProviderNext": false, + "skipProviderBreaker": true, + "status": 400 + }, + "output": false + }, + { + "input": { + "error": "Client disconnected: aborted", + "isProxyUnreachable": true, + "isStreamReadinessFailure": true, + "requestScopedFailure": false, + "sameProviderNext": false, + "skipProviderBreaker": true, + "status": 503 + }, + "output": false + }, + { + "input": { + "error": "Client disconnected: aborted", + "isProxyUnreachable": true, + "isStreamReadinessFailure": true, + "requestScopedFailure": true, + "sameProviderNext": true, + "skipProviderBreaker": true, + "status": 500 + }, + "output": false + }, + { + "input": { + "error": "upstream failed", + "isProxyUnreachable": false, + "isStreamReadinessFailure": false, + "requestScopedFailure": false, + "sameProviderNext": true, + "skipProviderBreaker": false, + "status": 500 + }, + "output": false + }, + { + "input": { + "error": "upstream failed", + "isProxyUnreachable": true, + "isStreamReadinessFailure": false, + "requestScopedFailure": false, + "sameProviderNext": false, + "skipProviderBreaker": true, + "status": 504 + }, + "output": false + }, + { + "input": { + "error": "Client disconnected: aborted", + "isProxyUnreachable": true, + "isStreamReadinessFailure": true, + "requestScopedFailure": false, + "sameProviderNext": false, + "skipProviderBreaker": false, + "status": 429 + }, + "output": false + }, + { + "input": { + "error": "Client disconnected: aborted", + "isProxyUnreachable": false, + "isStreamReadinessFailure": false, + "requestScopedFailure": false, + "sameProviderNext": false, + "skipProviderBreaker": true, + "status": 429 + }, + "output": false + }, + { + "input": { + "error": "upstream failed", + "isProxyUnreachable": true, + "isStreamReadinessFailure": false, + "requestScopedFailure": true, + "sameProviderNext": true, + "skipProviderBreaker": true, + "status": 429 + }, + "output": false + }, + { + "input": { + "error": "Client disconnected: aborted", + "isProxyUnreachable": true, + "isStreamReadinessFailure": false, + "requestScopedFailure": false, + "sameProviderNext": true, + "skipProviderBreaker": false, + "status": 503 + }, + "output": false + } + ], + "requestScoped": [ + { + "input": { + "code": "upstream_empty_response", + "type": "server_error" + }, + "output": true + }, + { + "input": { + "code": "context_length_exceeded", + "type": "context_length_exceeded" + }, + "output": true + }, + { + "input": { + "code": "upstream_empty_response", + "type": null + }, + "output": true + }, + { + "input": { + "code": "context_length_exceeded", + "type": null + }, + "output": true + }, + { + "input": { + "code": "upstream_empty_response", + "type": "context_length_exceeded" + }, + "output": true + }, + { + "input": { + "code": "other", + "type": null + }, + "output": false + }, + { + "input": { + "code": "other", + "type": "server_error" + }, + "output": false + }, + { + "input": { + "code": null, + "type": "server_error" + }, + "output": false + }, + { + "input": { + "code": "other", + "type": "context_length_exceeded" + }, + "output": true + }, + { + "input": { + "code": null, + "type": null + }, + "output": false + } + ] + }, + "seed": "0x5013c0de" +} diff --git a/tests/unit/duckduckgo-web-executor.test.ts b/tests/unit/duckduckgo-web-executor.test.ts index c340e03421..0566efa965 100644 --- a/tests/unit/duckduckgo-web-executor.test.ts +++ b/tests/unit/duckduckgo-web-executor.test.ts @@ -1,6 +1,11 @@ -import { describe, it, mock } from "node:test"; +import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { DuckDuckGoWebExecutor, DUCKDUCKGO_BASE } from "../../open-sse/executors/duckduckgo-web.ts"; +import { FETCH_TIMEOUT_MS } from "../../open-sse/config/constants.ts"; +import { + DuckDuckGoWebExecutor, + DUCKDUCKGO_BASE, + STATUS_URL, +} from "../../open-sse/executors/duckduckgo-web.ts"; describe("DuckDuckGoWebExecutor", () => { describe("class instantiation", () => { @@ -16,18 +21,26 @@ describe("DuckDuckGoWebExecutor", () => { it("should have testConnection method", () => { const executor = new DuckDuckGoWebExecutor(); - assert.equal(typeof executor.testConnection, "function", "testConnection should be a function"); + assert.equal( + typeof executor.testConnection, + "function", + "testConnection should be a function" + ); }); it("should export DUCKDUCKGO_BASE constant", () => { - assert.equal(DUCKDUCKGO_BASE, "https://duckduckgo.com", "DUCKDUCKGO_BASE should be correct URL"); + assert.equal( + DUCKDUCKGO_BASE, + "https://duckduckgo.com", + "DUCKDUCKGO_BASE should be correct URL" + ); }); }); describe("execute method validation", () => { it("should reject empty messages array", async () => { const executor = new DuckDuckGoWebExecutor(); - + const response = await executor.execute({ model: "gpt-4o-mini", messages: [], @@ -36,14 +49,14 @@ describe("DuckDuckGoWebExecutor", () => { assert.ok(response instanceof Response, "should return Response"); assert.equal(response.status, 400, "should return 400 for empty messages"); - + const body = await response.json(); assert.ok(body.error, "error response should have error field"); }); it("should accept non-empty messages array", async () => { const executor = new DuckDuckGoWebExecutor(); - + // This will fail due to network, but should pass input validation try { const response = await executor.execute({ @@ -62,7 +75,7 @@ describe("DuckDuckGoWebExecutor", () => { it("should handle missing model parameter", async () => { const executor = new DuckDuckGoWebExecutor(); - + try { await executor.execute({ model: undefined, @@ -70,7 +83,10 @@ describe("DuckDuckGoWebExecutor", () => { stream: false, } as any); } catch (error) { - assert.ok(error instanceof Error || error instanceof Response, "should handle missing model"); + assert.ok( + error instanceof Error || error instanceof Response, + "should handle missing model" + ); } }); }); @@ -78,7 +94,7 @@ describe("DuckDuckGoWebExecutor", () => { describe("testConnection method", () => { it("should return boolean", async () => { const executor = new DuckDuckGoWebExecutor(); - + try { const result = await executor.testConnection({}); assert.equal(typeof result, "boolean", "testConnection should return boolean"); @@ -88,18 +104,31 @@ describe("DuckDuckGoWebExecutor", () => { } }); - it("should complete within timeout", async () => { - const executor = new DuckDuckGoWebExecutor(); - const startTime = Date.now(); - - try { - await executor.testConnection({}); - } catch (error) { - // Expected to fail or timeout - } - - const elapsed = Date.now() - startTime; - assert.ok(elapsed < 35000, `testConnection should complete within 35 seconds, took ${elapsed}ms`); + it("should abort the status request when its timeout expires", async (t) => { + t.mock.timers.enable({ apis: ["setTimeout"] }); + let requestSignal: AbortSignal | null = null; + + t.mock.method(globalThis, "fetch", async (input, init) => { + assert.equal(String(input), STATUS_URL); + assert.equal(init?.method, "GET"); + requestSignal = init?.signal ?? null; + + return new Promise((_resolve, reject) => { + requestSignal?.addEventListener("abort", () => reject(requestSignal?.reason), { + once: true, + }); + }); + }); + + const resultPromise = new DuckDuckGoWebExecutor().testConnection({}); + assert.ok(requestSignal, "status fetch should receive an AbortSignal"); + assert.equal(requestSignal.aborted, false); + + t.mock.timers.tick(FETCH_TIMEOUT_MS); + + assert.equal(requestSignal.aborted, true); + assert.equal(requestSignal.reason?.name, "TimeoutError"); + assert.equal(await resultPromise, false); }); }); @@ -107,10 +136,10 @@ describe("DuckDuckGoWebExecutor", () => { it("should handle AbortSignal", async () => { const executor = new DuckDuckGoWebExecutor(); const controller = new AbortController(); - + // Abort immediately controller.abort(); - + const response = await executor.execute({ model: "gpt-4o-mini", body: { messages: [{ role: "user", content: "test" }] }, @@ -124,7 +153,7 @@ describe("DuckDuckGoWebExecutor", () => { it("should support streaming parameter", async () => { const executor = new DuckDuckGoWebExecutor(); - + try { // Test with stream: true const response1 = await executor.execute({ @@ -151,7 +180,7 @@ describe("DuckDuckGoWebExecutor", () => { describe("error handling", () => { it("should handle network timeouts gracefully", async () => { const executor = new DuckDuckGoWebExecutor(); - + try { const response = await executor.execute({ model: "gpt-4o-mini", @@ -169,7 +198,7 @@ describe("DuckDuckGoWebExecutor", () => { it("should return valid error responses with JSON", async () => { const executor = new DuckDuckGoWebExecutor(); - + const response = await executor.execute({ model: "gpt-4o-mini", messages: [], @@ -179,7 +208,7 @@ describe("DuckDuckGoWebExecutor", () => { assert.equal(response.status, 400); const contentType = response.headers.get("content-type"); assert.ok(contentType?.includes("application/json"), "error response should be JSON"); - + const body = await response.json(); assert.ok(body.error, "error response should have error object"); assert.ok(body.error.message, "error should have message"); @@ -198,7 +227,11 @@ describe("DuckDuckGoWebExecutor", () => { const { getExecutor } = await import("../../open-sse/executors/index.ts"); const executor = getExecutor("duckduckgo-web"); assert.ok(executor, "executor should be registered in index"); - assert.equal(typeof executor.execute, "function", "registered executor should have execute method"); + assert.equal( + typeof executor.execute, + "function", + "registered executor should have execute method" + ); }); }); }); diff --git a/tests/unit/eslint-import-boundaries.test.ts b/tests/unit/eslint-import-boundaries.test.ts new file mode 100644 index 0000000000..27525d3b59 --- /dev/null +++ b/tests/unit/eslint-import-boundaries.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { ESLint } from "eslint"; + +const eslint = new ESLint({ cwd: process.cwd() }); + +async function restrictedImportMessages(code: string, filePath: string): Promise { + const [result] = await eslint.lintText(code, { filePath }); + + return result.messages + .filter(({ ruleId }) => ruleId === "no-restricted-imports") + .map(({ message }) => message); +} + +test("G14 rejects localDb barrel imports outside src/lib/db", async () => { + const cases = [ + { + code: 'import { getSettings } from "@/lib/localDb";\nvoid getSettings;', + filePath: "src/lib/example.ts", + }, + { + code: 'import { getSettings } from "@/lib/localDb.ts";\nvoid getSettings;', + filePath: "src/shared/example.ts", + }, + { + code: 'import { getSettings } from "../localDb";\nvoid getSettings;', + filePath: "src/lib/feature/example.ts", + }, + { + code: 'import { getSettings } from "../../lib/localDb.ts";\nvoid getSettings;', + filePath: "src/app/example.ts", + }, + ]; + + for (const fixture of cases) { + const messages = await restrictedImportMessages(fixture.code, fixture.filePath); + assert.equal(messages.length, 1, `expected ${fixture.code} to be rejected`); + assert.match(messages[0], /domain module from `@\/lib\/db\//); + } +}); + +test("G14 rejects executor implementation imports from src/app", async () => { + for (const importPath of [ + "@omniroute/open-sse/executors/default.ts", + "open-sse/executors/default.ts", + ]) { + const messages = await restrictedImportMessages( + `import { DefaultExecutor } from "${importPath}";\nvoid DefaultExecutor;`, + "src/app/api/example/route.ts" + ); + + assert.equal(messages.length, 1, `expected ${importPath} to be rejected from src/app`); + assert.match(messages[0], /handler or service boundary/); + } +}); + +test("G14 allows imports through the intended boundaries", async () => { + const cases = [ + { + code: 'import { getSettings } from "@/lib/localDb";\nvoid getSettings;', + filePath: "src/lib/db/example.ts", + }, + { + code: 'import { DefaultExecutor } from "@omniroute/open-sse/executors/default.ts";\nvoid DefaultExecutor;', + filePath: "src/sse/handlers/example.ts", + }, + { + code: 'import { handleChat } from "@omniroute/open-sse/handlers/chat";\nvoid handleChat;', + filePath: "src/app/api/example/route.ts", + }, + ]; + + for (const fixture of cases) { + assert.deepEqual(await restrictedImportMessages(fixture.code, fixture.filePath), []); + } +}); diff --git a/tests/unit/g13-combo-chatcore-golden.test.ts b/tests/unit/g13-combo-chatcore-golden.test.ts new file mode 100644 index 0000000000..0cd7a4cd18 --- /dev/null +++ b/tests/unit/g13-combo-chatcore-golden.test.ts @@ -0,0 +1,397 @@ +import assert from "node:assert/strict"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + clampComboDepth, + getConnectionStatusQuotaCutoffReason, + isContextOverflow400, + isModelScoped400, + isParamValidation400, + isRequestScopedUpstreamFailure, + shouldRecordProviderBreakerFailure, + shouldSkipConnDisable, + shouldSkipForPredictedTtft, +} from "../../open-sse/services/combo.ts"; +import { + buildStreamingResponseHeaders, + extractSystemRoleMessages, + isClaudeCodeSemanticPassthroughRequest, + isTokenExpiringSoon, + redactPassthroughThinkingSignatures, + shouldUseNativeCodexPassthrough, + stripStaleForwardingHeaders, +} from "../../open-sse/handlers/chatCore.ts"; +import { goldenSnapshot } from "../helpers/goldenSnapshot.ts"; + +const GOLDEN_NAME = "g13/combo-chatcore-public-seams"; +const GOLDEN_FILE = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + `../snapshots/${GOLDEN_NAME}.json` +); +const SEED = 0x50_13_c0_de; + +type ContextOverflowPredicate = typeof isContextOverflow400; + +type GoldenOverrides = { + isContextOverflow400?: ContextOverflowPredicate; +}; + +function seededRandom(seed: number): () => number { + let state = seed >>> 0; + return () => { + state ^= state << 13; + state ^= state >>> 17; + state ^= state << 5; + return (state >>> 0) / 0x1_0000_0000; + }; +} + +function sampleSeeded(values: readonly T[], count: number, salt: number): T[] { + const random = seededRandom(SEED ^ salt); + const shuffled = [...values]; + for (let index = shuffled.length - 1; index > 0; index--) { + const swapIndex = Math.floor(random() * (index + 1)); + [shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]]; + } + return shuffled.slice(0, Math.min(count, shuffled.length)); +} + +function cartesian>( + dimensions: T +): Array<{ [K in keyof T]: T[K][number] }> { + let rows: Array> = [{}]; + for (const [key, values] of Object.entries(dimensions)) { + rows = rows.flatMap((row) => values.map((value) => ({ ...row, [key]: value }))); + } + return rows as Array<{ [K in keyof T]: T[K][number] }>; +} + +function snapshotCombo(overrides: GoldenOverrides): Record { + const contextOverflow = overrides.isContextOverflow400 ?? isContextOverflow400; + const clampInputs = [ + undefined, + null, + "", + "nope", + Number.NaN, + Number.NEGATIVE_INFINITY, + -4, + 0, + 0.9, + 1, + 1.9, + 3, + 9.9, + 10, + 11, + 999, + ]; + const ttftMatrix = cartesian({ + requests: [0, 4, 5, 6, 20], + avgLatencyMs: [0, 999, 1000, 1001, 5000], + predictiveTtftMs: [-1, 0, 1000, 3000], + }); + const breakerMatrix = [ + { + status: 500, + isStreamReadinessFailure: false, + sameProviderNext: false, + skipProviderBreaker: false, + requestScopedFailure: false, + isProxyUnreachable: false, + error: "upstream failed", + }, + ...cartesian({ + status: [400, 408, 429, 500, 502, 503, 504], + isStreamReadinessFailure: [false, true], + sameProviderNext: [false, true], + skipProviderBreaker: [false, true], + requestScopedFailure: [false, true], + isProxyUnreachable: [false, true], + error: ["upstream failed", "Client disconnected: aborted"], + }), + ]; + const requestScopeMatrix = cartesian({ + code: [null, "context_length_exceeded", "upstream_empty_response", "other"], + type: [null, "context_length_exceeded", "server_error"], + }); + const connDisableMatrix = cartesian({ + status: [401, 408, 499, 502], + errorCode: [null, "client_disconnected", "plugin_block", "context_length_exceeded"], + is401: [false, true], + hasExtraKeys: [false, true], + provider: ["openai", "codex"], + }); + const messages = [ + "", + "invalid message format", + "maximum context length exceeded", + "your input exceeds the context window", + "max_tokens must be between 1 and 4096", + "parameter is illegal", + "model is not supported", + "unsupported_api_for_model", + "this model does not support the Responses API", + "plain upstream failure", + ]; + const connectionStates: Array | undefined> = [ + undefined, + {}, + { testStatus: "banned" }, + { testStatus: " CREDITS_EXHAUSTED " }, + { testStatus: "unavailable", rateLimitedUntil: "2999-01-01T00:00:00.000Z" }, + { testStatus: "unavailable", rateLimitedUntil: "2000-01-01T00:00:00.000Z" }, + { testStatus: "healthy", rateLimitedUntil: "2999-01-01T00:00:00.000Z" }, + ]; + + return { + clampDepth: sampleSeeded(clampInputs, 12, 0x01).map((input) => ({ + input: String(input), + output: clampComboDepth(input), + })), + predictiveTtft: sampleSeeded(ttftMatrix, 24, 0x02).map((input) => ({ + input, + output: shouldSkipForPredictedTtft( + { requests: input.requests, avgLatencyMs: input.avgLatencyMs }, + input.predictiveTtftMs + ), + })), + providerBreaker: sampleSeeded(breakerMatrix, 40, 0x03).map((input) => ({ + input, + output: shouldRecordProviderBreakerFailure(input), + })), + requestScoped: sampleSeeded(requestScopeMatrix, 10, 0x04).map((input) => ({ + input, + output: isRequestScopedUpstreamFailure(input), + })), + connectionDisable: sampleSeeded(connDisableMatrix, 24, 0x05).map((input) => ({ + input, + output: shouldSkipConnDisable( + { + status: input.status, + errorCode: input.errorCode, + }, + input.is401, + input.hasExtraKeys, + input.provider + ), + })), + badRequestClassifiers: sampleSeeded(messages, messages.length, 0x06).map((input) => ({ + input, + output: { + contextOverflow: contextOverflow(input), + parameterValidation: isParamValidation400(input), + modelScoped: isModelScoped400(input), + }, + })), + connectionStatusCutoff: sampleSeeded(connectionStates, connectionStates.length, 0x07).map( + (input) => ({ input, output: getConnectionStatusQuotaCutoffReason(input) ?? null }) + ), + }; +} + +function normalizeStreamingHeaders(headers: Record): Record { + return { + ...headers, + "X-OmniRoute-Version": "", + }; +} + +function snapshotChatCore(): Record { + const systemPayloads: Array> = [ + { messages: [{ role: "user", content: "hello" }] }, + { + messages: [ + { role: "system", content: "policy" }, + { role: "user", content: "go" }, + ], + }, + { + system: "existing", + messages: [ + { role: "developer", content: "new" }, + { role: "assistant", content: "ok" }, + ], + }, + { + system: [{ type: "text", text: "existing-block" }], + messages: [ + { + role: "SYSTEM", + content: [ + { type: "text", text: "lifted" }, + { type: "image", source: "ignored" }, + ], + }, + ], + }, + { + messages: [ + { role: "developer", content: "" }, + { role: "user", content: "kept" }, + ], + }, + { model: "claude", messages: null }, + ]; + const nativeCodexMatrix = cartesian({ + provider: ["codex", "openai", null], + sourceFormat: ["openai-responses", "openai-chat", null], + endpointPath: ["/v1/responses", "/v1/responses/", "/v1/chat/completions", "responses"], + }); + const semanticMatrix = cartesian({ + provider: ["claude", "openai", "anthropic-compatible-acme"], + sourceFormat: ["claude", "openai-chat"], + targetFormat: ["claude", "openai-chat"], + signal: ["userAgent", "appHeader", "sessionHeader", "none"], + }); + const signatureInputs = [ + null, + "plain", + [{ role: "assistant", content: [{ type: "thinking", signature: "sig", text: "x" }] }], + { messages: [{ role: "assistant", content: [{ type: "redacted_thinking", data: "x" }] }] }, + ]; + const streamingHeaderCases = [ + new Headers({ + "content-type": "application/json", + "x-request-id": "req-1", + "retry-after": "15", + "x-ratelimit-remaining": "7", + "x-upstream-debug": "safe", + "x-omniroute-internal": "drop", + "x-middleware-rewrite": "/internal", + }), + new Headers({ + connection: "x-hop", + "x-hop": "drop", + "request-id": "req-2", + "set-cookie": "secret=yes", + "x-provider": "kept", + }), + ]; + + return { + systemRoleExtraction: sampleSeeded(systemPayloads, systemPayloads.length, 0x11).map((input) => { + const payload = structuredClone(input); + extractSystemRoleMessages(payload); + return { input, output: payload }; + }), + nativeCodexPassthrough: sampleSeeded(nativeCodexMatrix, 18, 0x12).map((input) => ({ + input, + output: shouldUseNativeCodexPassthrough(input), + })), + semanticClaudePassthrough: sampleSeeded(semanticMatrix, 24, 0x13).map((input) => { + const headers = new Headers(); + let userAgent: string | null = null; + if (input.signal === "userAgent") userAgent = "claude-code/2.0"; + if (input.signal === "appHeader") headers.set("x-app", "cli"); + if (input.signal === "sessionHeader") headers.set("x-claude-code-session-id", "session-1"); + return { + input, + output: isClaudeCodeSemanticPassthroughRequest({ + provider: input.provider, + sourceFormat: input.sourceFormat, + targetFormat: input.targetFormat, + headers, + userAgent, + }), + }; + }), + thinkingSignaturePassthrough: sampleSeeded(signatureInputs, signatureInputs.length, 0x14).map( + (input) => ({ + input, + output: redactPassthroughThinkingSignatures( + structuredClone(input), + "replacement-signature" + ), + }) + ), + streamingHeaders: streamingHeaderCases.map((headers, index) => ({ + input: index, + output: normalizeStreamingHeaders( + buildStreamingResponseHeaders( + headers, + { + provider: "test-provider", + model: "test-model", + requestId: `request-${index}`, + }, + null + ) + ), + })), + staleForwardingHeaders: [ + { + "content-encoding": "gzip", + "content-length": "10", + "transfer-encoding": "chunked", + "x-request-id": "req-3", + }, + { + "Content-Length": "20", + "x-provider": "kept", + }, + ].map((entries) => { + const headers = new Headers(entries); + stripStaleForwardingHeaders(headers); + return { input: entries, output: Object.fromEntries(headers.entries()) }; + }), + tokenExpiry: sampleSeeded( + cartesian({ + expiresAt: [null, "2000-01-01T00:00:00.000Z", "2999-01-01T00:00:00.000Z", "invalid"], + bufferMs: [0, 300_000, 86_400_000], + }), + 10, + 0x15 + ).map((input) => ({ input, output: isTokenExpiringSoon(input.expiresAt, input.bufferMs) })), + }; +} + +export function buildG13GoldenSnapshot(overrides: GoldenOverrides = {}): Record { + return { + seed: `0x${SEED.toString(16)}`, + combo: snapshotCombo(overrides), + chatCore: snapshotChatCore(), + }; +} + +test("G13 golden locks sampled combo.ts and chatCore.ts public behavior", () => { + assert.ok( + process.env.UPDATE_GOLDEN === "1" || fs.existsSync(GOLDEN_FILE), + `committed golden is missing: ${GOLDEN_FILE}` + ); + goldenSnapshot(GOLDEN_NAME, buildG13GoldenSnapshot()); +}); + +test("G13 seeded public-seam sampling is deterministic", () => { + assert.deepEqual(buildG13GoldenSnapshot(), buildG13GoldenSnapshot()); +}); + +test("G13 golden detects a public behavior mutation", () => { + const baseline = buildG13GoldenSnapshot(); + const mutated = buildG13GoldenSnapshot({ + isContextOverflow400: (input) => !isContextOverflow400(input), + }); + assert.notDeepEqual(mutated, baseline, "mutation must alter the sampled behavior set"); + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "g13-golden-")); + const previousUpdateGolden = process.env.UPDATE_GOLDEN; + try { + process.env.UPDATE_GOLDEN = "1"; + goldenSnapshot(GOLDEN_NAME, baseline, tmpDir); + delete process.env.UPDATE_GOLDEN; + + assert.doesNotThrow(() => goldenSnapshot(GOLDEN_NAME, baseline, tmpDir)); + assert.throws( + () => goldenSnapshot(GOLDEN_NAME, mutated, tmpDir), + /golden mismatch for "g13\/combo-chatcore-public-seams"/ + ); + } finally { + if (previousUpdateGolden === undefined) delete process.env.UPDATE_GOLDEN; + else process.env.UPDATE_GOLDEN = previousUpdateGolden; + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +}); diff --git a/tests/unit/plugin-sandbox-permissions.test.ts b/tests/unit/plugin-sandbox-permissions.test.ts deleted file mode 100644 index 0c96e20ef8..0000000000 --- a/tests/unit/plugin-sandbox-permissions.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * Source-scan tests for src/lib/plugins/pluginWorker.ts sandbox hardening. - * - * Why source-scan (not behavioral)? - * pluginWorker.ts is a worker-thread entry point: it throws at import time when - * parentPort is null (line 17-19), so it cannot be imported directly in a test - * runner. createSandbox is also not exported. Source-scan tests mirror the pattern - * used in tests/unit/electron-preload.test.ts for the same reason. - * - * Assertions cover: - * - exec permission gates child_process behind OMNIROUTE_PLUGINS_ALLOW_EXEC==="1" - * - the throw path exists when env is absent - * - vm.runInContext is called with a finite timeout (no infinite-loop DoS) - * - the trust-model comment is present - */ - -import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; - -const source = readFileSync( - resolve(process.cwd(), "src/lib/plugins/pluginWorker.ts"), - "utf8" -); - -describe("pluginWorker sandbox — exec permission gating", () => { - it("gates child_process behind OMNIROUTE_PLUGINS_ALLOW_EXEC === '1'", () => { - assert.ok( - source.includes('OMNIROUTE_PLUGINS_ALLOW_EXEC !== "1"') || - source.includes("OMNIROUTE_PLUGINS_ALLOW_EXEC !== '1'"), - "exec block must check process.env.OMNIROUTE_PLUGINS_ALLOW_EXEC !== \"1\"" - ); - }); - - it("checks the env flag inside the exec permission block", () => { - // The env guard must appear inside the exec permission block. - // Use the SECOND occurrence of OMNIROUTE_PLUGINS_ALLOW_EXEC (the first is in - // the trust-model comment above createSandbox; the second is the actual guard). - const execIdx = source.indexOf('permissions.includes("exec")'); - assert.ok(execIdx !== -1, 'source must contain permissions.includes("exec")'); - // Find the env check that occurs *after* the exec block opens - const envIdxInBlock = source.indexOf("OMNIROUTE_PLUGINS_ALLOW_EXEC", execIdx); - assert.ok( - envIdxInBlock !== -1, - "OMNIROUTE_PLUGINS_ALLOW_EXEC check must appear inside the exec permission block" - ); - }); - - it("throws when exec is requested but env is not set", () => { - // The throw statement must be inside the exec block and before child_process assignment - const execIdx = source.indexOf('permissions.includes("exec")'); - const throwIdx = source.indexOf("throw new Error", execIdx); - const childProcessIdx = source.indexOf("sandbox.child_process", execIdx); - assert.ok(throwIdx !== -1, "a throw must exist after the exec permission check"); - assert.ok( - throwIdx < childProcessIdx, - "the throw must appear before sandbox.child_process is wired" - ); - }); - - it("throw message references the disabled exec permission without internal paths", () => { - // Message must be operator-readable, not expose stack/paths - assert.ok( - source.includes("exec' permission, which is disabled"), - "throw message must mention the exec permission being disabled" - ); - assert.ok( - source.includes("OMNIROUTE_PLUGINS_ALLOW_EXEC=1"), - "throw message must reference the opt-in env var" - ); - }); - - it("does NOT wire child_process without the env guard in place", () => { - // Ensure child_process assignment is nested under the env check, not at the exec-block top level. - // The OMNIROUTE_PLUGINS_ALLOW_EXEC check must come before sandbox.child_process. - const envIdx = source.indexOf("OMNIROUTE_PLUGINS_ALLOW_EXEC"); - const childProcessIdx = source.indexOf("sandbox.child_process ="); - assert.ok(envIdx < childProcessIdx, "env guard must precede sandbox.child_process assignment"); - }); -}); - -describe("pluginWorker sandbox — vm.runInContext timeout", () => { - it("passes a finite timeout to vm.runInContext", () => { - assert.ok( - source.includes("vm.runInContext"), - "vm.runInContext must be present in the source" - ); - // The call must include a timeout option - assert.match( - source, - /vm\.runInContext\([^)]*timeout\s*:/, - "vm.runInContext must be called with a timeout option" - ); - }); - - it("timeout value is 10000 ms (10 seconds)", () => { - assert.match( - source, - /timeout\s*:\s*10000/, - "timeout must be 10000 ms" - ); - }); -}); - -describe("pluginWorker sandbox — trust-model comment", () => { - it("documents that vm is NOT a security boundary", () => { - assert.ok( - source.includes("vm is NOT a security boundary"), - "createSandbox must have a trust-model comment stating vm is NOT a security boundary" - ); - }); - - it("references the loopback-only routeGuard classification", () => { - assert.ok( - source.includes("LOCAL_ONLY") || source.includes("routeGuard"), - "trust-model comment must reference LOCAL_ONLY or routeGuard" - ); - }); - - it("references the OMNIROUTE_PLUGINS_ALLOW_EXEC opt-in in the comment", () => { - assert.ok( - source.includes("OMNIROUTE_PLUGINS_ALLOW_EXEC"), - "trust-model comment must reference OMNIROUTE_PLUGINS_ALLOW_EXEC" - ); - }); -}); diff --git a/tests/unit/plugins-sandbox.test.ts b/tests/unit/plugins-sandbox.test.ts deleted file mode 100644 index 46240e42e2..0000000000 --- a/tests/unit/plugins-sandbox.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert"; -import { SandboxLevel, getSandboxLabel } from "../../src/lib/plugins/sandbox.ts"; - -describe("SandboxLevel", () => { - it("has 4 levels", () => { - assert.strictEqual(SandboxLevel.IN_PROCESS, 0); - assert.strictEqual(SandboxLevel.CHILD_FULL_ENV, 1); - assert.strictEqual(SandboxLevel.CHILD_FILTERED_ENV, 2); - assert.strictEqual(SandboxLevel.CHILD_ISOLATED, 3); - }); - - it("getSandboxLabel returns correct labels", () => { - assert.strictEqual(getSandboxLabel(SandboxLevel.IN_PROCESS), "In-Process"); - assert.strictEqual(getSandboxLabel(SandboxLevel.CHILD_FULL_ENV), "Child (Full Env)"); - assert.strictEqual(getSandboxLabel(SandboxLevel.CHILD_FILTERED_ENV), "Child (Filtered Env)"); - assert.strictEqual(getSandboxLabel(SandboxLevel.CHILD_ISOLATED), "Child (Isolated)"); - }); -}); diff --git a/tests/unit/plugins-welcome-banner-e2e.test.ts b/tests/unit/plugins-welcome-banner-e2e.test.ts index c1f0c41e79..f09338e709 100644 --- a/tests/unit/plugins-welcome-banner-e2e.test.ts +++ b/tests/unit/plugins-welcome-banner-e2e.test.ts @@ -248,25 +248,38 @@ test("plugin hooks system", async (t) => { resetHooks(); }); - await t.test("BUILTIN_EVENTS has all 14 events (incl. lifecycle)", () => { - assert.equal(BUILTIN_EVENTS.length, 14); - assert.ok(BUILTIN_EVENTS.includes("onRequest")); - assert.ok(BUILTIN_EVENTS.includes("onResponse")); - assert.ok(BUILTIN_EVENTS.includes("onError")); - assert.ok(BUILTIN_EVENTS.includes("onModelSelect")); - assert.ok(BUILTIN_EVENTS.includes("onComboResolve")); - assert.ok(BUILTIN_EVENTS.includes("onRateLimit")); - assert.ok(BUILTIN_EVENTS.includes("onQuotaExhaust")); - assert.ok(BUILTIN_EVENTS.includes("onProviderError")); - assert.ok(BUILTIN_EVENTS.includes("onStreamStart")); - assert.ok(BUILTIN_EVENTS.includes("onStreamEnd")); - // Lifecycle events added in #3473. - assert.ok(BUILTIN_EVENTS.includes("onInstall")); - assert.ok(BUILTIN_EVENTS.includes("onActivate")); - assert.ok(BUILTIN_EVENTS.includes("onDeactivate")); - assert.ok(BUILTIN_EVENTS.includes("onUninstall")); + await t.test("BUILTIN_EVENTS contains only emitted/public plugin events", () => { + assert.deepEqual(BUILTIN_EVENTS, [ + "onRequest", + "onResponse", + "onError", + "onInstall", + "onActivate", + "onDeactivate", + "onUninstall", + ]); resetHooks(); }); + + await t.test("BUILTIN_EVENTS does not advertise events with no emission path", () => { + for (const deadEvent of [ + "onModelSelect", + "onComboResolve", + "onRateLimit", + "onQuotaExhaust", + "onProviderError", + "onStreamStart", + "onStreamEnd", + ]) { + assert.equal((BUILTIN_EVENTS as readonly string[]).includes(deadEvent), false, deadEvent); + } + }); + + await t.test("lifecycle events remain represented because manager emits them", () => { + for (const event of ["onInstall", "onActivate", "onDeactivate", "onUninstall"] as const) { + assert.ok(BUILTIN_EVENTS.includes(event)); + } + }); }); // ── Welcome banner PoC E2E ── diff --git a/tests/unit/sidebar-visibility.test.ts b/tests/unit/sidebar-visibility.test.ts index c5fb36e38a..24406fb828 100644 --- a/tests/unit/sidebar-visibility.test.ts +++ b/tests/unit/sidebar-visibility.test.ts @@ -141,20 +141,21 @@ test("help sidebar exposes changelog after docs and issues", () => { assert.equal(sidebarVisibility.HIDEABLE_SIDEBAR_ITEM_IDS.includes("changelog"), true); }); -test("plugins (marketplace) has a discoverable sidebar entry (#3656 follow-up)", async () => { +test("plugins has a discoverable sidebar entry (#3656 follow-up)", async () => { const items = sectionItems("agentic-features"); const plugins = items.find((item) => item.id === "plugins"); assert.ok(plugins, "expected a plugins item in the agentic-features section"); assert.equal(plugins.href, "/dashboard/plugins"); assert.equal(sidebarVisibility.HIDEABLE_SIDEBAR_ITEM_IDS.includes("plugins"), true); - // It must be a real page (plugin manager + marketplace tab), not a legacy redirect stub. + // It must be a real page (plugin manager), not a legacy redirect stub. const pluginsPage = await readFile( join(repoRoot, "src/app/(dashboard)/dashboard/plugins/page.tsx"), "utf8" ); assert.doesNotMatch(pluginsPage, /^\s*redirect\(/m); - assert.match(pluginsPage, /marketplace/i); + // R0.2: marketplace tab removed (dead code) — verify the page still has plugin management UI. + assert.match(pluginsPage, /scanForPlugins|installedTab|fetchPlugins/i); }); test("legacy dashboard routes redirect to their consolidated surfaces", async () => {