mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-02 05:12:11 +03:00
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:
committed by
GitHub
parent
45c91e22c2
commit
8fac6bcd48
@@ -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)
|
||||
|
||||
11
.github/pull_request_template.md
vendored
11
.github/pull_request_template.md
vendored
@@ -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.
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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
229
docs/dev/CONTRIBUTION_GOLDEN_PATH.md
Normal file
229
docs/dev/CONTRIBUTION_GOLDEN_PATH.md
Normal 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).
|
||||
@@ -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. |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -36,13 +36,6 @@ export const BUILTIN_EVENTS = [
|
||||
"onRequest",
|
||||
"onResponse",
|
||||
"onError",
|
||||
"onModelSelect",
|
||||
"onComboResolve",
|
||||
"onRateLimit",
|
||||
"onQuotaExhaust",
|
||||
"onProviderError",
|
||||
"onStreamStart",
|
||||
"onStreamEnd",
|
||||
"onInstall",
|
||||
"onActivate",
|
||||
"onDeactivate",
|
||||
|
||||
@@ -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" });
|
||||
@@ -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)";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
1892
tests/snapshots/g13/combo-chatcore-public-seams.json
Normal file
1892
tests/snapshots/g13/combo-chatcore-public-seams.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
77
tests/unit/eslint-import-boundaries.test.ts
Normal file
77
tests/unit/eslint-import-boundaries.test.ts
Normal 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), []);
|
||||
}
|
||||
});
|
||||
397
tests/unit/g13-combo-chatcore-golden.test.ts
Normal file
397
tests/unit/g13-combo-chatcore-golden.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
@@ -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"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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)");
|
||||
});
|
||||
});
|
||||
@@ -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 ──
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user