feat(.50): completa itens restantes — G13, G14, gap34, docs, R0.2 (#9126)

* 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 <diegosouzapw@users.noreply.github.com>
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-08-01 10:44:23 -03:00
committed by GitHub
parent 45c91e22c2
commit 8fac6bcd48
23 changed files with 3832 additions and 743 deletions

View File

@@ -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)

View File

@@ -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/<file>.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.
- Call out any risky areas, migrations, feature flags, or manual validation that reviewers should know about.

View File

@@ -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` |

View File

@@ -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/<file>.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

File diff suppressed because it is too large Load Diff

View File

@@ -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/<provider-or-executor>.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-<behavior>.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/<feature>.test.ts
npx vitest run --config vitest.config.ts tests/unit/ui/<component>.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/<command>.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/<domain>.test.ts
node --import tsx/esm --test tests/unit/db/migration-<number>.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/<behavior>.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 <active-base>...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).

View File

@@ -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. |
---

View File

@@ -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`).

View File

@@ -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],
},
],
},

View File

@@ -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",

View File

@@ -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<PluginInfo[]>([]);
const [activeTab, setActiveTab] = useState<"installed" | "marketplace">("installed");
const [marketplacePlugins, setMarketplacePlugins] = useState<any[]>([]);
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() {
<div className="space-y-6 p-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">{t("title")}</h1>
<div className="flex gap-2">
<Button variant={activeTab === "installed" ? "primary" : "secondary"} onClick={() => setActiveTab("installed")}>
{t("installedTab")}
</Button>
<Button variant={activeTab === "marketplace" ? "primary" : "secondary"} onClick={() => setActiveTab("marketplace")}>
{t("marketplaceTab")}
</Button>
</div>
</div>
{activeTab === "marketplace" && (
<Card className="p-4 flex gap-4 items-end bg-gray-50">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">{t("marketplaceUrlLabel")}</label>
<input
type="text"
className="w-full rounded border-gray-300 p-2"
placeholder={t("marketplaceUrlPlaceholder")}
value={marketplaceUrl}
onChange={(e) => setMarketplaceUrl(e.target.value)}
/>
</div>
<Button onClick={handleSaveUrl} disabled={savingUrl}>
{t("saveMarketplaceUrl")}
</Button>
</Card>
)}
{activeTab === "installed" ? (
<>
<div className="flex items-center justify-end">
<Button onClick={handleScan} disabled={scanning}>
{scanning ? t("scanning") : t("scanForPlugins")}
</Button>
</div>
{plugins.length === 0 ? (
<EmptyState
title={t("noPlugins")}
description={t("noPluginsDescription")}
/>
) : (
<div className="grid gap-4">
{plugins.map((plugin) => (
<Card key={plugin.name} className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{plugin.name}</h3>
<p className="text-sm text-gray-500">
v{plugin.version}
{plugin.author ? ` by ${plugin.author}` : ""}
{plugin.description ? `${plugin.description}` : ""}
</p>
<div className="mt-1 flex gap-1">
{plugin.hooks.map((hook) => (
<span
key={hook}
className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700"
>
{hook}
</span>
))}
</div>
</div>
<div className="flex gap-2">
<Button
variant={plugin.enabled ? "secondary" : "primary"}
onClick={() => handleToggle(plugin.name, !plugin.enabled)}
>
{plugin.enabled ? t("deactivate") : t("activate")}
</Button>
<Button
variant="danger"
onClick={() => handleUninstall(plugin.name)}
>
{t("uninstall")}
</Button>
</div>
</div>
</Card>
))}
</div>
)}
</>
<div className="flex items-center justify-end">
<Button onClick={handleScan} disabled={scanning}>
{scanning ? t("scanning") : t("scanForPlugins")}
</Button>
</div>
{plugins.length === 0 ? (
<EmptyState
title={t("noPlugins")}
description={t("noPluginsDescription")}
/>
) : (
<div className="grid gap-4">
{marketplacePlugins.length === 0 ? (
<div className="text-gray-500 py-4">{t("marketplaceEmpty")}</div>
) : (
marketplacePlugins.map((plugin) => (
<Card key={plugin.name} className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold flex items-center gap-2">
{plugin.name}
{plugin.verified && <Badge variant="success">{t("verified")}</Badge>}
</h3>
<p className="text-sm text-gray-500">
v{plugin.version} by {plugin.author} {plugin.description}
</p>
<div className="mt-1 flex gap-1">
{plugin.tags?.map((tag: string) => (
<span key={tag} className="rounded bg-gray-100 px-2 py-0.5 text-xs text-gray-600">
{tag}
</span>
))}
</div>
</div>
<div className="flex gap-2">
<Button
variant="primary"
onClick={() => {
addNotification({ type: "info", message: t("marketplaceInstallComingSoon") });
}}
>
{t("install")}
</Button>
{plugins.map((plugin) => (
<Card key={plugin.name} className="p-4">
<div className="flex items-center justify-between">
<div>
<h3 className="font-semibold">{plugin.name}</h3>
<p className="text-sm text-gray-500">
v{plugin.version}
{plugin.author ? ` by ${plugin.author}` : ""}
{plugin.description ? `${plugin.description}` : ""}
</p>
<div className="mt-1 flex gap-1">
{plugin.hooks.map((hook) => (
<span
key={hook}
className="rounded bg-blue-100 px-2 py-0.5 text-xs text-blue-700"
>
{hook}
</span>
))}
</div>
</div>
</Card>
))
)}
<div className="flex gap-2">
<Button
variant={plugin.enabled ? "secondary" : "primary"}
onClick={() => handleToggle(plugin.name, !plugin.enabled)}
>
{plugin.enabled ? t("deactivate") : t("activate")}
</Button>
<Button
variant="danger"
onClick={() => handleUninstall(plugin.name)}
>
{t("uninstall")}
</Button>
</div>
</div>
</Card>
))}
</div>
)}
</div>

View File

@@ -36,13 +36,6 @@ export const BUILTIN_EVENTS = [
"onRequest",
"onResponse",
"onError",
"onModelSelect",
"onComboResolve",
"onRateLimit",
"onQuotaExhaust",
"onProviderError",
"onStreamStart",
"onStreamEnd",
"onInstall",
"onActivate",
"onDeactivate",

View File

@@ -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<string, unknown> {
const activeTimers = new Set<ReturnType<typeof setTimeout>>();
const sandbox: Record<string, unknown> = {
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<typeof setTimeout>); clearTimeout(t as ReturnType<typeof setTimeout>); },
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<typeof setInterval>); clearInterval(t as ReturnType<typeof setInterval>); },
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<string, unknown> || {};
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<string, unknown> | null = null;
let activeTimers: Set<ReturnType<typeof setTimeout>> | null = null;
async function loadPlugin(entryPoint: string, permissions: string[], name: string): Promise<string[]> {
const pluginDir = resolve(entryPoint, "..");
const sandbox = createSandbox(permissions, pluginDir);
context = vm.createContext(sandbox);
activeTimers = sandbox.__activeTimers as Set<ReturnType<typeof setTimeout>>;
const moduleExports: Record<string, unknown> = {};
const moduleObj = { exports: moduleExports };
sandbox.module = moduleObj;
sandbox.exports = moduleExports;
sandbox.require = (id: string) => {
const allowed: Record<string, unknown> = {};
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<string, unknown>);
}
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<string, unknown>);
}
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" });

View File

@@ -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)";
}
}

View File

@@ -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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -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<Response>((_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"
);
});
});
});

View File

@@ -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<string[]> {
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), []);
}
});

View File

@@ -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<T>(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<T extends Record<string, readonly unknown[]>>(
dimensions: T
): Array<{ [K in keyof T]: T[K][number] }> {
let rows: Array<Record<string, unknown>> = [{}];
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<string, unknown> {
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<Record<string, unknown> | 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<string, string>): Record<string, string> {
return {
...headers,
"X-OmniRoute-Version": "<APP_VERSION>",
};
}
function snapshotChatCore(): Record<string, unknown> {
const systemPayloads: Array<Record<string, unknown>> = [
{ 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<string, unknown> {
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 });
}
});

View File

@@ -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"
);
});
});

View File

@@ -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)");
});
});

View File

@@ -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 ──

View File

@@ -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 () => {