Compare commits

..

9 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
2e7705debb fix(dashboard): restore the 8 bulk-import state declarations deleted by #6625 hook extraction (ReferenceError: bulkImportOpen — release E2E) 2026-07-13 06:51:20 -03:00
Diego Rodrigues de Sa e Souza
ffdc1282de fix(dashboard): restore poolLoaded/poolSaving state deleted by #6909 refactor (settings page runtime crash, caught by release E2E) 2026-07-13 06:07:31 -03:00
Diego Rodrigues de Sa e Souza
3cf0b1cd58 fix(security): linear-time Basic-auth regex in the dev webdav handler (CodeQL js/polynomial-redos #708) 2026-07-13 05:00:49 -03:00
Diego Rodrigues de Sa e Souza
1c3cca399b fix(combo,ws): comboStickyRoundRobinLimit inherits instead of shadowing batched default; LiveWS standalone script boots again (#6678/#6072 follow-ups caught by release CI) 2026-07-13 03:35:35 -03:00
Diego Rodrigues de Sa e Souza
7e9962eac0 fix(skills): remove uncataloged skills/cli-skill-collector orphan (added by #6294 without a catalog entry — skills/ is generated from the catalog) 2026-07-13 03:19:48 -03:00
Diego Rodrigues de Sa e Souza
7eaff019b2 chore(quality): allowlist remaining verified assert migrations (#6862 GPT-5.6 matrix, #6675 provider removals) 2026-07-13 03:18:45 -03:00
Diego Rodrigues de Sa e Souza
213b4175f1 chore(quality): allowlist verified assert reductions from #6897 de-flake and #6862 stronger deepEqual (test-masking) 2026-07-13 03:02:49 -03:00
Diego Rodrigues de Sa e Souza
bf4032abaf docs(release): v3.8.47 feature documentation sync (What's New + doc updates + provider reference regen) 2026-07-13 02:19:05 -03:00
Diego Rodrigues de Sa e Souza
67601cc5e6 docs(changelog): v3.8.47 reconciliation — aggregate 125 fragments, backfill 41 missing bullets, contributors hall (55), date header 2026-07-13 02:12:23 -03:00
5803 changed files with 149248 additions and 977176 deletions

View File

@@ -1,201 +0,0 @@
# codebase-memory-mcp ignore list
#
# Padrão gitignore-style. Linhas começando com `#` são comentários.
# Barra final (`/`) = só diretório. Sem barra = casa arquivo OU diretório.
#
# O CBM também lê `.gitignore` automaticamente — esta lista deixa explícito o que
# os hooks do CBM vão pular. Se uma regra entrar em conflito entre os dois arquivos,
# vale a união. Editar este arquivo é mais barato do que confiar na herança implícita.
#
# Última reconciliação: 2026-07-31, status `ready` (513k nodes / 689k edges),
# `auto_index_limit=50000`, total indexável medido ≈11.546 arquivos (folga 4,3×).
#
# Fontes cruzadas:
# - `codebase-memory-mcp cli index_status --project home-diegosouzapw-dev-proxys-OmniRoute`
# → `not_indexed.dirs` (27) + `not_indexed.files` (336), todos `BY DESIGN`.
# - `.gitignore` deste repo (5.691 B) — fonte canônica secundária.
#
# Como auditar mudanças: depois de editar este arquivo, rodar `index_repository`
# (ou esperar `auto_watch` re-indexar) e re-checar `cli index_status` → comparar
# contagens em `not_indexed.dirs_count` e `not_indexed.files_count`.
# ─────────────────────────────────────────────────────────────────────────────
# 1. Diretorios de runtime / pacote — nao sao codigo-fonte
# ─────────────────────────────────────────────────────────────────────────────
node_modules/
node_modules
# Builds e artefatos reproduziveis (Layer 1 Next.js / Electron)
.build/
dist/
.next/
out/
# Electron especifico
electron/dist-electron/
electron/node_modules/
icon.iconset/
# Workspaces internos que tem proprio node_modules
@omniroute/opencode-plugin/dist/
@omniroute/opencode-plugin/node_modules/
@omniroute/opencode-provider/dist/
@omniroute/opencode-provider/node_modules/
# Recursos nativos compilados (C/JNI/wasm)
src/mitm/tproxy/native/build/
# Artefatos locais do Stryker / Playwright / coverage
.stryker-tmp/
reports/mutation/
stryker-output-*.json
.playwright-mcp/
test-results/
playwright-report/
blob-report/
# Analise / linters / caches
.analysis/
.sisyphus/
.plans/
.gitnexus
.worktrees
.codegraph/
# Quality artifacts (gerados por npm run lint --cache etc)
.eslintcache
.eslintcache-complexity
# Claude Code local state
.claude/scheduled_tasks.lock
.claude/scheduled_tasks/
.claude/sessions/
.claude/state.json
.claude/settings.local.json
# Serena / Antigravity / outras tools locais
.serena/
.antigravitycli/
.gemini/
.config/
# ─────────────────────────────────────────────────────────────────────────────
# 2. Diretorios com prefixo `_` — locais / privados (regra global do .gitignore)
# ─────────────────────────────────────────────────────────────────────────────
_*/
_artifacts/
_cache/
_mono_repo/
_references/
_tasks/
# ─────────────────────────────────────────────────────────────────────────────
# 3. Diretorios de tooling IA (state local, nao codigo)
# ─────────────────────────────────────────────────────────────────────────────
.agents/
.claude/
.vscode/
.idea/
.junie/
.omc/
.data/
.data-dev/
.local-data/
.logs/
.artifacts/
.source/
.superpowers/
.claude-flow/
.omnivscodeagent/
omnirouteCloud/
omnirouteSite/
.omniroute/
.stent/
# Subpaths especificos do Claude Code que nao estao em .claude/ (criados sob repo)
.claude/worktrees/
# ─────────────────────────────────────────────────────────────────────────────
# 4. Diretorios de dados / runtime locais (storage, env, secrets, scratch)
# ─────────────────────────────────────────────────────────────────────────────
data/
# NOTA: src/lib/env/, src/app/api/{cloud,sync/cloud,system/env,agent-skills/coverage}/
# foram removidos daqui (2026-08-05). Os nomes sugerem dados/segredos locais, mas os
# 8 arquivos sao route handlers e modulos rastreados no git — escondia-los do grafo
# criava pontos cegos em buscas e em analise de impacto.
tests/golden-set/data/
# Logs e saida de teste
logs/*
test_output.log
home-diegosouzapw-dev-automacoes-*.txt
# ─────────────────────────────────────────────────────────────────────────────
# 5. Diretorios do monorepo por subprojeto (nao fazem parte do app principal)
# ─────────────────────────────────────────────────────────────────────────────
security-analysis/
vscode-extension/
obsidian-plugin/node_modules/
# ─────────────────────────────────────────────────────────────────────────────
# 6. Diretorios de documentacao interna / workflow
# ─────────────────────────────────────────────────────────────────────────────
docs/superpowers/
# Docs traduzidas: 1.215 arquivos / 94 MB (inclui 20+ copias do CHANGELOG).
# Sao traducoes do tree em ingles, ja indexado — no grafo so geram ruido em
# search_code e consomem o auto_index_limit.
docs/i18n/
# ─────────────────────────────────────────────────────────────────────────────
# 7. Arquivos especificos (nao diretorios inteiros)
# ─────────────────────────────────────────────────────────────────────────────
# Segredos e env — NUNCA indexar
.env
.env.*
!.env.example
!.env.homolog.example
# TypeScript build info e next env declaration
*.tsbuildinfo
next-env.d.ts
typescript
# SQLite transient files (WAL/SHM/journal)
*.sqlite-shm
*.sqlite-wal
*.sqlite-journal
# Mapas e source maps
*.map
# Bun / npm lockfiles ruidosos
bun.lock
# `cheaper-inference-gateway.svg` e arquivos de midia na raiz/asset ja cobertos
# pelos `ignored-suffix` do indexador (svg/png/jpg/ico/etc >50kB ou >500linhas);
# manter a regra explicita aqui ajuda a auditar:
cheaper-inference-gateway.svg
cheaper-inference-gateway-*.svg
# Husky internals
.husky/_/
# CI / quality metric artifacts
config/quality/quality-metrics.json
config/quality/test-impact-map.json
audit-report.json
.gh-discussions.json
# i18n audit (gerado por npm run scripts)
scripts/i18n/_audit.json
scripts/i18n/_pending-keys.json
# NOTA: bin/omniroute.mjs foi removido daqui (2026-08-05). Estava marcado como
# "scratch", mas e o entrypoint real do CLI publicado (package.json -> bin.omniroute)
# e consta em PACK_ARTIFACT_REQUIRED_PATHS. Precisa estar no grafo.
# Deploy / docker backups
deploy.sh
docker-compose.yml.bak
docker-compose.minimal.yml

View File

@@ -7,13 +7,7 @@
**/.vscode **/.vscode
# Dependencies and build output # Dependencies and build output
# `node_modules` alone matches the ROOT only — Docker's matcher does not cross
# `/` like .gitignore does. Without the `**/` form, nested installs ship in the
# build context (e.g. @omniroute/opencode-provider/node_modules, ~79 MB of
# devDependencies). Both forms are kept: the bare one is the documented root
# rule, the `**/` one covers every nested package.
node_modules node_modules
**/node_modules
.next .next
.build .build
out out
@@ -24,7 +18,6 @@ coverage
# Runtime data and logs # Runtime data and logs
data data
logs logs
.sandbox
# Local env files (inject at runtime via --env-file or -e) # Local env files (inject at runtime via --env-file or -e)
.env .env
@@ -44,19 +37,6 @@ tests
test-results test-results
playwright-report playwright-report
blob-report blob-report
output
.playwright-cli
.playwright-mcp
.stryker-tmp
reports/mutation
# Local caches and quality-gate artifacts (all gitignored). `_*` does not match
# dot-prefixed names, so these need explicit entries.
.artifacts
.eslintcache*
.fakebin-*
MAX
quality-ratchet/
# Documentation # Documentation
# Issue #2348: The Dashboard Docs viewer reads markdown from `/app/docs` at # Issue #2348: The Dashboard Docs viewer reads markdown from `/app/docs` at
@@ -69,10 +49,6 @@ quality-ratchet/
# (English) sources at runtime, so translations are not required in the # (English) sources at runtime, so translations are not required in the
# container image. # container image.
docs/i18n/** docs/i18n/**
# Internal planning artifacts (gitignored). `*.md` above only matches the root,
# so without this rule these land in /app/docs and become readable through the
# dashboard's Docs viewer at runtime.
docs/superpowers/**
docs/diagrams/**/*.png docs/diagrams/**/*.png
docs/diagrams/**/*.jpg docs/diagrams/**/*.jpg
docs/diagrams/**/*.jpeg docs/diagrams/**/*.jpeg

View File

@@ -1,6 +0,0 @@
ENABLE_LIVE_DEVIN_TESTS=0
DEVIN_BRIDGE_MODEL=devin-cli-agentic/swe-1-7
DEVIN_BRIDGE_SONNET_MODEL=devin-cli-agentic/swe-1-7
DEVIN_BRIDGE_OPUS_MODEL=devin-cli-agentic/swe-1-7
DEVIN_BRIDGE_HAIKU_MODEL=devin-cli-agentic/swe-1-7
DEVIN_BRIDGE_SUBAGENT_MODEL=devin-cli-agentic/swe-1-7

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +0,0 @@
# Homologação E2E real — copie para .env.homolog (NUNCA commitar o real)
HOMOLOG_BASE_URL=http://192.168.0.15:20128
# Senha de management do dashboard da VPS (a mesma do /login)
HOMOLOG_ADMIN_PASSWORD=
# Deixe vazio: a suíte cria uma API key efêmera via admin e revoga no fim.
# Só preencha para depurar uma camada isolada com uma key fixa.
HOMOLOG_API_KEY=
# Tier crítico (chat real, max_tokens=5). Demais providers: só validação de catálogo.
HOMOLOG_CRITICAL_PROVIDERS=openai,anthropic,gemini,codex,grok,glm,deepseek,openrouter

View File

@@ -24,32 +24,12 @@ updates:
update-types: ["version-update:semver-major"] update-types: ["version-update:semver-major"]
- dependency-name: "eslint-config-next" - dependency-name: "eslint-config-next"
update-types: ["version-update:semver-major"] update-types: ["version-update:semver-major"]
# typescript majors are peer-blocked by typescript-eslint, which pins a hard
# upper bound (8.64.0 → peerDependencies.typescript ">=4.8.4 <6.1.0"). A TS 7
# bump therefore violates the peer and takes down the whole toolchain at once —
# #7068 grouped it with 6 harmless bumps and turned Build + Lint + Quality Ratchet
# + Unit (6/8, 8/8) + Integration (1/2, 2/2) + dast-smoke red in one shot, blocking
# the innocuous updates riding along with it. Un-ignore once typescript-eslint
# widens the peer, and migrate TS majors intentionally (own PR, own CI run).
- dependency-name: "typescript"
update-types: ["version-update:semver-major"]
# jscpd v5 is a Rust rewrite (native binary, no Node.js programmatic API). # jscpd v5 is a Rust rewrite (native binary, no Node.js programmatic API).
# scripts/check/check-duplication.mjs is deliberately pinned to jscpd@4 (it # scripts/check/check-duplication.mjs is deliberately pinned to jscpd@4 (it
# parses jscpd-report.json against a frozen baseline). A v5 major would break # parses jscpd-report.json against a frozen baseline). A v5 major would break
# the duplication gate — migrate the gate intentionally, not via dependabot. # the duplication gate — migrate the gate intentionally, not via dependabot.
- dependency-name: "jscpd" - dependency-name: "jscpd"
update-types: ["version-update:semver-major"] update-types: ["version-update:semver-major"]
# ioredis is a SOFT/optional dependency loaded through a dynamic import
# (src/lib/quota/redisQuotaStore.ts — "Redis driver requires ioredis package"),
# so a breaking major never fails at build or typecheck time: the only consumers
# are the distributed quota store (redisQuotaStore.ts, storeFactory.ts) and the
# `import type Redis` in src/shared/utils/rateLimiter.ts. Nothing in the unit or
# vitest suites exercises a live Redis connection, so a v5→v6 API break would ship
# green and only surface at runtime for operators running distributed quota — the
# exact users least able to absorb it. #9310 grouped that major with 9 harmless
# bumps; majors here need their own PR and a deliberate migration review.
- dependency-name: "ioredis"
update-types: ["version-update:semver-major"]
# @huggingface/transformers is HARD-PINNED at 3.5.2 (exact, no caret) — FROZEN. # @huggingface/transformers is HARD-PINNED at 3.5.2 (exact, no caret) — FROZEN.
# It is load-bearing for the LLMLingua ONNX compression engine (open-sse/services/ # It is load-bearing for the LLMLingua ONNX compression engine (open-sse/services/
# compression/engines/llmlingua/ — worker.ts pins @huggingface/transformers@3.5.2) # compression/engines/llmlingua/ — worker.ts pins @huggingface/transformers@3.5.2)

View File

@@ -9,16 +9,11 @@
## Validation ## Validation
Choose the change type and focused loop from the
[Contribution Golden Path](../docs/ops/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):
- [ ] Change type: provider / routing / UI / i18n / CLI / DB / build-deploy / other
- [ ] Focused tests and category gates from the golden path
- [ ] `npm run lint` - [ ] `npm run lint`
- [ ] Reconciled with the current active release base; focused checks rerun afterward - [ ] `npm run test:unit`
- [ ] Production-code changes include a new or updated automated test in this PR - [ ] `npm run test:coverage`
- SonarQube is temporarily opt-in while the private project has no quota; it is not a PR gate. - [ ] Coverage is still `>= 60%` for statements, lines, functions, and branches
- [ ] SonarQube PR analysis is green or any remaining issues are explicitly documented below
## Tests Added Or Updated ## Tests Added Or Updated

71
.github/workflows/build-fork.yml vendored Normal file
View File

@@ -0,0 +1,71 @@
name: Publish Fork Image to GHCR
on:
push:
branches: [main]
tags:
- "v*"
workflow_dispatch:
# Least-privilege default: read-only at the top level; the build job that pushes to
# GHCR grants packages: write itself (Scorecard TokenPermissions).
permissions:
contents: read
env:
IMAGE_NAME: ghcr.io/kang-heewon/omniroute
jobs:
build:
name: Build and Push Fork Image
if: github.repository == 'kang-heewon/OmniRoute'
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Login to GitHub Container Registry
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.IMAGE_NAME }}
tags: |
type=raw,value=latest,enable={{is_default_branch}}
type=sha,prefix=sha-
type=ref,event=tag
labels: |
org.opencontainers.image.title=omniroute
org.opencontainers.image.description=Unified AI proxy/router — fork image
org.opencontainers.image.url=https://github.com/kang-heewon/OmniRoute
org.opencontainers.image.source=https://github.com/kang-heewon/OmniRoute
org.opencontainers.image.licenses=MIT
- name: Build and push
uses: docker/build-push-action@v7
with:
context: .
target: runner-base
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

View File

@@ -1,57 +0,0 @@
name: Build App
on:
workflow_dispatch:
push:
branches: ["**"]
permissions:
contents: read
jobs:
build:
name: Fast Production Build
runs-on: ubuntu-latest
steps:
- name: Expand Virtual Memory (Native 10GB Swap)
run: |
sudo swapoff -a || true
sudo rm -f /mnt/swapfile /swapfile
sudo fallocate -l 10G /mnt/swapfile || sudo dd if=/dev/zero of=/mnt/swapfile bs=1M count=10240
sudo chmod 600 /mnt/swapfile
sudo mkswap /mnt/swapfile
sudo swapon /mnt/swapfile
free -h
- name: Checkout repository
uses: actions/checkout@v7
with:
persist-credentials: false
- name: Setup Node.js
uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- name: Install dependencies
run: npm ci
- name: Build Next.js app & CLI bundle
run: |
npm run build:release
env:
NODE_OPTIONS: "--max-old-space-size=12288"
OMNIROUTE_BUILD_MEMORY_MB: "12288"
OMNIROUTE_USE_TURBOPACK: "1"
- name: Archive build outputs
run: |
tar -czf omniroute-build.tar.gz .build dist
- name: Upload build artifact
uses: actions/upload-artifact@v4
with:
name: omniroute-build
path: omniroute-build.tar.gz
retention-days: 7

View File

@@ -33,27 +33,14 @@ jobs:
docs: ${{ steps.classify.outputs.docs }} docs: ${{ steps.classify.outputs.docs }}
i18n: ${{ steps.classify.outputs.i18n }} i18n: ${{ steps.classify.outputs.i18n }}
workflow: ${{ steps.classify.outputs.workflow }} workflow: ${{ steps.classify.outputs.workflow }}
testsOnly: ${{ steps.classify.outputs.testsOnly }}
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
with: with:
persist-credentials: false persist-credentials: false
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
# Refuse a PR that targets its own head branch before spending anything on it. #8912 has
# head == base == release/v3.8.50: no diff, can never merge, and it sits in the queue with
# a full check board attached on every push to that branch. One field comparison.
- name: Reject a PR that targets its own branch
if: github.event_name == 'pull_request'
env:
HEAD_REF: ${{ github.head_ref }}
BASE_REF: ${{ github.base_ref }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: node scripts/check/check-pr-self-target.mjs
- id: classify - id: classify
env: env:
EVENT_NAME: ${{ github.event_name }} EVENT_NAME: ${{ github.event_name }}
@@ -94,7 +81,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
@@ -137,34 +124,11 @@ jobs:
- run: npm run check:route-guard-membership - run: npm run check:route-guard-membership
- run: npm run check:test-discovery - run: npm run check:test-discovery
- run: npm run check:tracked-artifacts - run: npm run check:tracked-artifacts
# (gap 30) Also lives in quality.yml's PR-only "Merge integrity" job — because the
# CHANGELOG half of that job needs a base to diff against. This half does NOT: the
# generator either reproduces the committed SKILL.md files or it does not.
#
# Keeping it PR-only left a real hole. This cycle's merge trains landed in batches with
# `--admin`, which bypasses required checks, so three SKILL.md files drifted from the route
# catalog, rode the release squash into `main`, and the next cycle's sync-back turned them
# into a base-red that blocked EVERY PR into release/v3.8.50 until #8954. Running it here
# means a push to `main` catches the drift at the source instead of the next cycle
# inheriting it.
- run: npm run check:agent-skills-sync
# WS1.7 (v3.8.49 plan): Dockerfile lint (hadolint, pinned by digest).
# failure-threshold=error keeps the 5 pre-existing warnings (DL3008/DL3003/
# DL3016 version pinning / WORKDIR) visible without blocking; any ERROR fails.
- name: hadolint (Dockerfile)
run: docker run --rm -i hadolint/hadolint@sha256:27086352fd5e1907ea2b934eb1023f217c5ae087992eb59fde121dce9c9ff21e hadolint --failure-threshold error - < Dockerfile
- run: npm run check:lockfile - run: npm run check:lockfile
- run: npm run check:licenses - run: npm run check:licenses
# check:docs-sync is run by the docs-sync-strict job (via check:docs-all) and the # check:docs-sync is run by the docs-sync-strict job (via check:docs-all) and the
# husky pre-commit hook; the standalone copy here was redundant (ROI dedup). # husky pre-commit hook; the standalone copy here was redundant (ROI dedup).
- run: npm run typecheck:core - run: npm run typecheck:core
# #7033: typecheck:core's curated file allowlist does not cover
# src/app/(dashboard) TSX (and next.config.mjs sets ignoreBuildErrors:
# true, so `next build` never type-checks it either) — orphaned
# identifiers there (see #6625/#6909) were invisible to CI. This gate
# runs tsc scoped to the dashboard tree against a frozen baseline of
# pre-existing errors; only NEW errors fail it.
- run: npm run check:dashboard-typecheck
# typecheck:noimplicit:core dropped from this job (2026-07 optimize): # typecheck:noimplicit:core dropped from this job (2026-07 optimize):
# it was advisory (continue-on-error) and largely subsumed by the blocking # it was advisory (continue-on-error) and largely subsumed by the blocking
# check:type-coverage ratchet in quality-gate. Local: npm run typecheck:noimplicit:core. # check:type-coverage ratchet in quality-gate. Local: npm run typecheck:noimplicit:core.
@@ -184,7 +148,7 @@ jobs:
# The coverage.* metrics degrade gracefully: the download is continue-on-error and # The coverage.* metrics degrade gracefully: the download is continue-on-error and
# the ratchet runs with --allow-missing, so absent coverage is skipped, not failed. # the ratchet runs with --allow-missing, so absent coverage is skipped, not failed.
# Path filter: code-only — pure docs/i18n PRs have nothing for these ratchets to guard. # Path filter: code-only — pure docs/i18n PRs have nothing for these ratchets to guard.
if: ${{ !cancelled() && !contains(github.event.pull_request.labels.*.name, 'hotfix') && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true' && (needs.lint.result == 'success' || needs.lint.result == 'failure'))) }} if: ${{ !cancelled() && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true' && (needs.lint.result == 'success' || needs.lint.result == 'failure'))) }}
# security-events: read lets the CodeQL ratchet read open code-scanning alerts # security-events: read lets the CodeQL ratchet read open code-scanning alerts
# via `gh api .../code-scanning/alerts`. contents: read keeps checkout working. # via `gh api .../code-scanning/alerts`. contents: read keeps checkout working.
permissions: permissions:
@@ -194,7 +158,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
@@ -294,7 +258,7 @@ jobs:
# P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam # P3 (plano mestre): a release-PR viva fica DRAFT o ciclo inteiro — jobs pesados pulam
# drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados). # drafts (ciclo v3.8.44: 123 runs pesados re-disparados por merges na release, 88 cancelados).
# Path filter: code-only (scanners/ratchets target production surface). # Path filter: code-only (scanners/ratchets target production surface).
if: ${{ !contains(github.event.pull_request.labels.*.name, 'hotfix') && (github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true')) }} if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
steps: steps:
# fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base # fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base
# spec via `git show <base_ref>:docs/openapi.yaml`; a shallow clone # spec via `git show <base_ref>:docs/openapi.yaml`; a shallow clone
@@ -303,7 +267,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
@@ -353,16 +317,8 @@ jobs:
install -m 0755 /tmp/osv/*linux_amd64 "$HOME/.local/bin/osv-scanner" install -m 0755 /tmp/osv/*linux_amd64 "$HOME/.local/bin/osv-scanner"
# actionlint — official download script # actionlint — official download script
bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) latest "$HOME/.local/bin" bash <(curl -fsSL https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) latest "$HOME/.local/bin"
# zizmor — PyPI (pipx preferred, pip --user fallback); lands in ~/.local/bin. # zizmor — PyPI (pipx preferred, pip --user fallback); lands in ~/.local/bin
# PINNED on purpose. Unpinned, the runner installed whatever PyPI served that day and pipx install zizmor || pip install --user zizmor
# measured 1 finding MORE than the devbox on the identical commit (190 vs 189) during
# the v3.8.49 cycle — which cost a second rebaseline push per release, chasing a
# number that was never the code's. The ratchet compares counts across machines, so
# the auditor version has to be the same on both. Bump this deliberately, and
# rebaseline in the same commit: check-workflows.mjs now prints `zizmorVersion=` next
# to the count so the new number is traceable to the tool that produced it.
ZIZMOR_VERSION=1.25.2
pipx install "zizmor==$ZIZMOR_VERSION" || pip install --user "zizmor==$ZIZMOR_VERSION"
# oasdiff — download latest linux amd64 tarball via gh (authed), extract binary # oasdiff — download latest linux amd64 tarball via gh (authed), extract binary
rm -rf /tmp/oasd && mkdir -p /tmp/oasd rm -rf /tmp/oasd && mkdir -p /tmp/oasd
gh release download --repo oasdiff/oasdiff --pattern '*linux_amd64.tar.gz' --dir /tmp/oasd gh release download --repo oasdiff/oasdiff --pattern '*linux_amd64.tar.gz' --dir /tmp/oasd
@@ -419,7 +375,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
@@ -455,7 +411,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
@@ -480,48 +436,15 @@ jobs:
# UI keys move with dashboard code OR message catalogs. # UI keys move with dashboard code OR message catalogs.
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.i18n == 'true' || needs.changes.outputs.code == 'true')) }} if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.i18n == 'true' || needs.changes.outputs.code == 'true')) }}
steps: steps:
# fetch-depth: 0 — the value-drift gate diffs en.json against the merge base to
# find rewritten English strings. On a shallow clone the base ref is missing and
# the gate self-skips (base-unresolved), so it would never actually run.
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
fetch-depth: 0 - uses: actions/setup-node@v6
- uses: actions/setup-node@v7
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
- uses: ./.github/actions/npm-ci-retry - uses: ./.github/actions/npm-ci-retry
- run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65 - run: node scripts/i18n/check-ui-keys-coverage.mjs --threshold=65
# #8463: a rewritten English value used to leave its 39 translations behind
# silently (googleOAuthWarning shipped wrong copy in 39 locales for months).
# Key parity above cannot see it — a stale translation counts as covered.
- name: i18n UI value drift (stale translations)
env:
BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}
run: node scripts/i18n/check-ui-value-drift.mjs
# #8038: cheap glossary/protected-terms consistency gate —
# complements i18n-ui-coverage (key parity) and the ICU `i18n` job below
# without needing app-boot/Playwright infra. Same gating as i18n-ui-coverage.
# ko added after the #8224 ko.json mistranslation cleanup so the fixed
# terminology cannot silently regress on the next machine-translation run.
i18n-glossary-zhcn:
name: i18n Glossary (zh-CN, ko)
runs-on: ubuntu-latest
needs: changes
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.i18n == 'true' || needs.changes.outputs.code == 'true')) }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: node scripts/i18n/check-glossary-consistency.mjs --locale=zh-CN
- run: node scripts/i18n/check-glossary-consistency.mjs --locale=zh-TW
# D4 (plano mestre testes+CI): a matrix de ~40 jobs de <1min por idioma saturava sozinha # D4 (plano mestre testes+CI): a matrix de ~40 jobs de <1min por idioma saturava sozinha
# a concorrência de jobs da conta (Free = 20 slots, compartilhados entre TODOS os repos) # a concorrência de jobs da conta (Free = 20 slots, compartilhados entre TODOS os repos)
@@ -541,7 +464,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-python@v7 - uses: actions/setup-python@v6
with: with:
python-version: "3.12" python-version: "3.12"
- name: Validate all languages - name: Validate all languages
@@ -579,11 +502,11 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
- name: Fetch base branch - name: Fetch base branch
run: git fetch --no-tags origin "${GITHUB_BASE_REF}" run: git fetch --no-tags origin "${GITHUB_BASE_REF}" --depth=1
- name: Validate source changes include tests - name: Validate source changes include tests
run: node scripts/check/check-pr-test-policy.mjs --summary-file .artifacts/pr-test-policy.md run: node scripts/check/check-pr-test-policy.mjs --summary-file .artifacts/pr-test-policy.md
# Anti test-masking: flag net assert removal / new assert.ok(true) in changed tests. # Anti test-masking: flag net assert removal / new assert.ok(true) in changed tests.
@@ -618,7 +541,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
@@ -666,7 +589,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
@@ -686,27 +609,12 @@ jobs:
- name: Assert dist/server.js exists - name: Assert dist/server.js exists
run: test -f dist/server.js || (echo "dist/server.js missing — build:cli did not assemble correctly" && exit 1) run: test -f dist/server.js || (echo "dist/server.js missing — build:cli did not assemble correctly" && exit 1)
- run: npm run check:pack-artifact - run: npm run check:pack-artifact
# WS1.2 (#7065 class): pack the real tarball, install it into a clean prefix and
# BOOT it to a healthy /api/monitoring/health — the gate that structure checks
# cannot provide (3 releases shipped boot-crashing tarballs with green lists).
- name: Boot-smoke the packed tarball
run: npm run check:pack-boot
electron-package-smoke: electron-package-smoke:
name: Electron Package Smoke (${{ matrix.os }}) name: Electron Package Smoke
runs-on: ${{ matrix.os }} runs-on: ubuntu-latest
timeout-minutes: 30 timeout-minutes: 25
needs: build needs: build
# WS1.5 (v3.8.49 plan): the Electron native-module path previously executed for
# the FIRST time on the release tag — the v3.8.48 Windows bug (npx.cmd spawned
# without shell, CVE-2024-27980 behavior change) could only surface at release.
# windows-latest runs prepare:bundle (better-sqlite3 prebuild verification since
# v13 — the node-gyp rebuild is gone) per release PR; ubuntu keeps the full
# pack + headless smoke.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest]
env: env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
CSC_IDENTITY_AUTO_DISCOVERY: "false" CSC_IDENTITY_AUTO_DISCOVERY: "false"
@@ -714,7 +622,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
@@ -732,21 +640,9 @@ jobs:
working-directory: electron working-directory: electron
run: npm install --no-audit --no-fund run: npm install --no-audit --no-fund
- name: Pack Electron app - name: Pack Electron app
if: runner.os == 'Linux'
working-directory: electron working-directory: electron
run: npm run pack run: npm run pack
# ADVISORY while the new Windows leg matures (repo convention, dast-smoke
# precedent): its first-ever real run (2026-07-15, run 29457533565) died in
# 0.7s with the error swallowed by pwsh — bash shell captures stderr and
# continue-on-error keeps the heavy gate green while we harden it (#7336).
- name: Prepare Electron standalone (Windows prebuild verification)
if: runner.os == 'Windows'
working-directory: electron
continue-on-error: true
shell: bash
run: npm run prepare:bundle 2>&1
- name: Smoke packaged Electron app - name: Smoke packaged Electron app
if: runner.os == 'Linux'
env: env:
ELECTRON_SMOKE_TIMEOUT_MS: 60000 ELECTRON_SMOKE_TIMEOUT_MS: 60000
run: xvfb-run -a npm run electron:smoke:packaged run: xvfb-run -a npm run electron:smoke:packaged
@@ -754,13 +650,7 @@ jobs:
test-unit: test-unit:
name: Unit Tests (${{ matrix.shard }}/8) name: Unit Tests (${{ matrix.shard }}/8)
# Same dynamic-runner rule as Build (own-origin only; fallback ubuntu-latest). # Same dynamic-runner rule as Build (own-origin only; fallback ubuntu-latest).
# PINNED to hosted, deliberately not on the USE_VPS_RUNNER switch (gap 19). One variable runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
# governed the build and the test jobs, which want OPPOSITE machines: the build needs the
# .113's RAM, the tests need the hosted runner's link. Measured on 2026-07-29 —
# actions/setup-node took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm
# cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So
# self-hosted is strictly worse here and there is nothing to configure.
runs-on: ubuntu-latest
timeout-minutes: 25 timeout-minutes: 25
# needs: changes (not build) — this job never downloads the next-build artifact; # needs: changes (not build) — this job never downloads the next-build artifact;
# gating it on Build only serialized ~20min of wall-clock for nothing. Jobs that # gating it on Build only serialized ~20min of wall-clock for nothing. Jobs that
@@ -781,7 +671,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
@@ -812,47 +702,10 @@ jobs:
path: coverage-shard/*.json path: coverage-shard/*.json
if-no-files-found: error if-no-files-found: error
test-bun-sqlite:
name: Bun SQLite Compatibility
strategy:
matrix:
os: [ubuntu-latest, windows-latest]
fail-fast: false
runs-on: ${{ matrix.os }}
continue-on-error: ${{ matrix.os == 'windows-latest' }}
timeout-minutes: 10
needs: changes
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- name: Install Bun (Windows)
if: runner.os == 'Windows'
shell: pwsh
run: |
powershell -c "iwr bun.sh/install.ps1 -useb | iex"
echo "$env:USERPROFILE\.bun\bin" | Out-File -FilePath $env:GITHUB_PATH -Append
- name: Install Bun (non-Windows)
if: runner.os != 'Windows'
run: npm install -g bun
- run: npm run test:bun:db
test-vitest: test-vitest:
name: Vitest (MCP / autoCombo / UI components) name: Vitest (MCP / autoCombo / UI components)
# Same dynamic-runner rule as Build (own-origin only; fallback ubuntu-latest). # Same dynamic-runner rule as Build (own-origin only; fallback ubuntu-latest).
# PINNED to hosted, deliberately not on the USE_VPS_RUNNER switch (gap 19). One variable runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
# governed the build and the test jobs, which want OPPOSITE machines: the build needs the
# .113's RAM, the tests need the hosted runner's link. Measured on 2026-07-29 —
# actions/setup-node took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm
# cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So
# self-hosted is strictly worse here and there is nothing to configure.
runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
# needs: changes (not build) — no artifact consumed; see test-unit note. # needs: changes (not build) — no artifact consumed; see test-unit note.
needs: changes needs: changes
@@ -865,30 +718,19 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
- uses: ./.github/actions/npm-ci-retry - uses: ./.github/actions/npm-ci-retry
# The second test runner (CLAUDE.md: "Both test runners must pass") — was never # The second test runner (CLAUDE.md: "Both test runners must pass") — was never
# wired into CI until the 2026-06-09 quality audit (Fase 6A.2). # wired into CI until the 2026-06-09 quality audit (Fase 6A.2).
# WS5.2/5.3 (v3.8.49 plan): JUnit output feeds Trunk Flaky Tests (advisory upload - run: npm run test:vitest
# below). node:test stays OUT of the first wave (fd1-sensitive reporter stream). # vitest:ui is RED today (14 fails — UI component drift accumulated while the
- run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-mcp.xml # suite never ran in CI). Informational until the Fase 6A triage (2026-06-16+)
# vitest:ui went back to 870/870 green in the v3.8.49 quality plan (WS6.1, # fixes the components/tests; then drop continue-on-error to make it blocking.
# PR #7127 — 69 fails triaged: matchMedia polyfill, node:testvitest migration, - run: npm run test:vitest:ui
# CompareTab D22 cap). Promoted to BLOCKING per the plan's post-merge step.
- run: npm run test:vitest:ui -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-ui.xml
# Trunk Flaky Tests upload — advisory (never blocks), own-origin only (fork PRs
# have no TRUNK_TOKEN). Pinned by SHA (tag v2.1.2).
- name: Upload test results to Trunk (advisory)
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
continue-on-error: true continue-on-error: true
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
with:
junit-paths: trunk-junit/**/*.xml
org-slug: omniroute
token: ${{ secrets.TRUNK_TOKEN }}
# Node 24/26 compatibility matrices moved to .github/workflows/nightly-compat.yml # Node 24/26 compatibility matrices moved to .github/workflows/nightly-compat.yml
# (plano mestre testes+CI, Eixo D2 — they cost ~28% of every heavy run to catch a # (plano mestre testes+CI, Eixo D2 — they cost ~28% of every heavy run to catch a
@@ -897,12 +739,9 @@ jobs:
test-coverage: test-coverage:
name: Coverage name: Coverage
runs-on: ubuntu-latest runs-on: ubuntu-latest
# 10min was sized before #7114 added the lcov reporter (Codecov/Sonar need it); timeout-minutes: 10
# merging 8 shard JSONs + text+json+lcov now takes ~10-12min — three consecutive
# release-tip runs died at exactly 10m as job-timeout "cancelled" (2026-07-15/16).
timeout-minutes: 20
needs: test-unit needs: test-unit
if: ${{ !cancelled() && needs.test-unit.result == 'success' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }} if: ${{ !cancelled() && needs.test-unit.result == 'success' }}
env: env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long API_KEY_SECRET: ci-test-api-key-secret-long
@@ -910,7 +749,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
@@ -950,7 +789,6 @@ jobs:
--merge-async \ --merge-async \
--reporter=text-summary \ --reporter=text-summary \
--reporter=json-summary \ --reporter=json-summary \
--reporter=lcov \
--exclude=tests/** \ --exclude=tests/** \
--exclude=**/*.test.* \ --exclude=**/*.test.* \
--check-coverage \ --check-coverage \
@@ -972,18 +810,6 @@ jobs:
> coverage/coverage-report.md > coverage/coverage-report.md
fi fi
cat coverage/coverage-report.md >> "$GITHUB_STEP_SUMMARY" cat coverage/coverage-report.md >> "$GITHUB_STEP_SUMMARY"
# WS5.6 (D7, v3.8.49 plan): patch coverage on the PR diff via Codecov —
# informational during calibration (codecov.yml sets informational: true);
# promote to blocking only after ~2 weeks without false blocks. The lcov
# reporter above also fixes coverage/lcov.info being silently absent
# (if-no-files-found: warn) — Sonar consumes the same file.
- name: Upload coverage to Codecov (informational)
if: always()
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: coverage/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: false
- name: Upload coverage artifacts - name: Upload coverage artifacts
if: always() if: always()
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
@@ -999,10 +825,7 @@ jobs:
name: SonarQube name: SonarQube
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test-coverage needs: test-coverage
# Temporarily opt-in: the private project currently has no Sonar quota. if: ${{ !cancelled() && needs.test-coverage.result == 'success' }}
# Re-enable without another code change by setting the repository Actions
# variable SONARQUBE_ENABLED=true after quota/project access is restored.
if: ${{ vars.SONARQUBE_ENABLED == 'true' && !cancelled() && needs.test-coverage.result == 'success' }}
env: env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }} SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
@@ -1011,14 +834,10 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
fetch-depth: 0 fetch-depth: 0
# The upload strips the common `coverage/` prefix, so the artifact root holds
# lcov.info directly — download into coverage/ so it lands at coverage/lcov.info,
# where sonar.javascript.lcov.reportPaths expects it (path: . left the Sonar
# new-code coverage at 0% every scan).
- uses: actions/download-artifact@v8 - uses: actions/download-artifact@v8
with: with:
name: coverage-report name: coverage-report
path: coverage/ path: .
- name: Explain SonarQube skip - name: Explain SonarQube skip
if: ${{ github.event_name != 'pull_request' || env.SONAR_TOKEN == '' || env.SONAR_HOST_URL == '' }} if: ${{ github.event_name != 'pull_request' || env.SONAR_TOKEN == '' || env.SONAR_HOST_URL == '' }}
run: | run: |
@@ -1137,12 +956,7 @@ jobs:
# ~33%. Playwright browser is cached across runs (~1.5min saved per shard). # ~33%. Playwright browser is cached across runs (~1.5min saved per shard).
# Heavy shard target: ≤20min (was ~40min). Timeout 45min to cover slow runners. # Heavy shard target: ≤20min (was ~40min). Timeout 45min to cover slow runners.
timeout-minutes: 45 timeout-minutes: 45
needs: [build, changes] needs: build
# WS3.1 hotfix fast-lane: the 9-shard E2E matrix is the CI critical path (~25min).
# It skips for (a) PRs labeled `hotfix` (entry policy in docs/ops/RELEASE_CHECKLIST.md:
# production-broken only, full-suite evidence from the previous green run linked in the
# PR) and (b) tests-only diffs outside tests/e2e/ (cannot change the served app).
if: ${{ needs.changes.outputs.testsOnly != 'true' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }}
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -1156,7 +970,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
@@ -1177,34 +991,7 @@ jobs:
- name: Extract Next.js build artifact - name: Extract Next.js build artifact
run: | run: |
tar -xzf /tmp/e2e-build.tar.gz tar -xzf /tmp/e2e-build.tar.gz
# WS4.1: duration-balanced shards (LPT over config/quality/e2e-timings.json). - run: npx playwright test tests/e2e/*.spec.ts --shard=${{ matrix.shard }}/9
# Measured skew of plain --shard was 14× (24m47s vs 1m47s) — E2E was the CI
# critical path. The balancer self-verifies completeness and exits non-zero on
# any inconsistency, falling back to plain --shard (never fewer specs).
- name: Run E2E tests (duration-balanced shard)
env:
SHARD: ${{ matrix.shard }}
PLAYWRIGHT_JUNIT_OUTPUT_NAME: junit-e2e-results.xml
run: |
if FILES=$(node scripts/quality/balance-e2e-shards.mjs "$SHARD" 9); then
if [ -z "$FILES" ]; then echo "[e2e-balance] shard $SHARD has no files"; exit 0; fi
echo "[e2e-balance] shard $SHARD runs:"; echo "$FILES"
# FILES is our own newline-separated path list, so word-splitting is intended
# shellcheck disable=SC2086,SC2046
npx playwright test $(echo "$FILES" | tr '\n' ' ') --reporter=line,junit
else
echo "[e2e-balance] balancer unavailable — plain --shard fallback"
npx playwright test tests/e2e/*.spec.ts --shard="$SHARD"/9 --reporter=line,junit
fi
# WS5.2/5.3: Trunk Flaky Tests upload — advisory, own-origin only, SHA-pinned.
- name: Upload test results to Trunk (advisory)
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
continue-on-error: true
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
with:
junit-paths: junit-e2e-results.xml
org-slug: omniroute
token: ${{ secrets.TRUNK_TOKEN }}
test-integration: test-integration:
name: Integration Tests (${{ matrix.shard }}/2) name: Integration Tests (${{ matrix.shard }}/2)
@@ -1227,16 +1014,14 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
- uses: ./.github/actions/npm-ci-retry - uses: ./.github/actions/npm-ci-retry
- run: npm run check:node-runtime - run: npm run check:node-runtime
- name: Integration tests (shard ${{ matrix.shard }}/2) # (tsx/esm = QW-b; o alinhamento de ESCOPO do integration com o npm script fica p/ follow-up)
env: - run: node --import tsx/esm --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/integration/*.test.ts
TEST_SHARD: ${{ matrix.shard }}/2
run: npm run test:integration:ci
test-security: test-security:
name: Security Tests name: Security Tests
@@ -1252,7 +1037,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
@@ -1260,63 +1045,6 @@ jobs:
- run: npm run check:node-runtime - run: npm run check:node-runtime
- run: npm run test:security - run: npm run test:security
# Live-server E2E. Both suites boot a real OmniRoute via their own runner and
# drive it over HTTP; neither needs provider credentials. They were documented in
# AGENTS.md's test matrix but wired to NO workflow, and had additionally been
# unrunnable (vitest.config.ts excluded the very files their runners passed as a
# positional filter) — so nothing had executed them for as long as that was true.
test-ecosystem:
name: Ecosystem E2E (live server)
runs-on: ubuntu-latest
timeout-minutes: 20
# needs: changes (not build) — the runner boots its own dev server.
needs: changes
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm run check:node-runtime
- run: npm run test:ecosystem
test-protocols-e2e:
name: Protocol Clients E2E (live server, advisory)
runs-on: ubuntu-latest
timeout-minutes: 20
needs: changes
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
# ADVISORY until #10049 is resolved. Restoring this suite immediately surfaced a
# real discrepancy that had been invisible while it could not run: GET
# /api/mcp/audit answers 403 over loopback where the suite expects 200|401. That
# is a pre-existing contract question, not a defect introduced by wiring the job
# up, so it must not block every PR in the meantime. Flip to blocking (drop this
# continue-on-error) the moment #10049 lands.
continue-on-error: true
env:
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-test-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm run check:node-runtime
- run: npm run test:protocols:e2e
ci-summary: ci-summary:
name: CI Dashboard name: CI Dashboard
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -1326,7 +1054,6 @@ jobs:
- lint - lint
- docs-sync-strict - docs-sync-strict
- i18n-ui-coverage - i18n-ui-coverage
- i18n-glossary-zhcn
- i18n - i18n
- pr-test-policy - pr-test-policy
- build - build
@@ -1339,8 +1066,6 @@ jobs:
- test-e2e - test-e2e
- test-integration - test-integration
- test-security - test-security
- test-ecosystem
- test-protocols-e2e
steps: steps:
- name: Download i18n results - name: Download i18n results
continue-on-error: true continue-on-error: true
@@ -1353,8 +1078,6 @@ jobs:
- name: Generate dashboard - name: Generate dashboard
env: env:
EVENT_NAME: ${{ github.event_name }} EVENT_NAME: ${{ github.event_name }}
# Workflow-controlled data (job results), not user input — safe to read here.
NEEDS_JSON: ${{ toJSON(needs) }}
run: | run: |
status() { status() {
case "$1" in case "$1" in
@@ -1369,29 +1092,6 @@ jobs:
echo "# 🚀 CI Dashboard" >> "$GITHUB_STEP_SUMMARY" echo "# 🚀 CI Dashboard" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY"
# (gap 12) A cancelled job never reported a verdict, and in a long table that reads the
# same as a green one. `cancel-in-progress` plus incremental fixing cancels jobs on every
# push, and this cycle the Vitest job was cancelled in rounds 1, 2 and 3 — it only ran to
# completion in round 4, where it revealed a suite that had been broken the whole cycle
# plus two production bugs. A gate that never finishes is indistinguishable from one that
# passes, so name them at the TOP instead of leaving them to be spotted mid-table.
CANCELLED_JOBS=$(printf '%s' "$NEEDS_JSON" \
| jq -r 'to_entries | map(select(.value.result == "cancelled")) | .[].key' 2>/dev/null \
| sort | paste -sd", " -) || CANCELLED_JOBS=""
if [ -n "$CANCELLED_JOBS" ]; then
{
echo "> ### ⚫ Cancelled — no verdict was reported"
echo ">"
echo "> \`$CANCELLED_JOBS\`"
echo ">"
echo "> These did not fail; they never finished, so nothing was checked. Treat this"
echo "> run as INCOMPLETE for those gates. If the cancellation came from"
echo "> \`cancel-in-progress\` on a newer push, the newer run covers it — otherwise"
echo "> re-run them before reading this dashboard as green."
echo ""
} >> "$GITHUB_STEP_SUMMARY"
fi
echo "## 🧱 Core Checks" >> "$GITHUB_STEP_SUMMARY" echo "## 🧱 Core Checks" >> "$GITHUB_STEP_SUMMARY"
echo "| Job | Status |" >> "$GITHUB_STEP_SUMMARY" echo "| Job | Status |" >> "$GITHUB_STEP_SUMMARY"
echo "|-----|--------|" >> "$GITHUB_STEP_SUMMARY" echo "|-----|--------|" >> "$GITHUB_STEP_SUMMARY"
@@ -1399,9 +1099,8 @@ jobs:
echo "| Lint | $(status '${{ needs.lint.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| Lint | $(status '${{ needs.lint.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Docs Sync (Strict) | $(status '${{ needs.docs-sync-strict.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| Docs Sync (Strict) | $(status '${{ needs.docs-sync-strict.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| i18n UI Coverage | $(status '${{ needs.i18n-ui-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| i18n UI Coverage | $(status '${{ needs.i18n-ui-coverage.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| i18n Glossary (zh-CN, ko) | $(status '${{ needs.i18n-glossary-zhcn.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| PR Test Policy | $(status '${{ needs.pr-test-policy.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| PR Test Policy | $(status '${{ needs.pr-test-policy.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| SonarQube (opt-in; disabled without SONARQUBE_ENABLED=true) | $(status '${{ needs.sonarqube.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| SonarQube | $(status '${{ needs.sonarqube.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY"
echo "## 🏗️ Build" >> "$GITHUB_STEP_SUMMARY" echo "## 🏗️ Build" >> "$GITHUB_STEP_SUMMARY"
@@ -1421,8 +1120,6 @@ jobs:
echo "| E2E | $(status '${{ needs.test-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| E2E | $(status '${{ needs.test-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Integration | $(status '${{ needs.test-integration.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| Integration | $(status '${{ needs.test-integration.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Security Tests | $(status '${{ needs.test-security.result }}') |" >> "$GITHUB_STEP_SUMMARY" echo "| Security Tests | $(status '${{ needs.test-security.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Ecosystem E2E | $(status '${{ needs.test-ecosystem.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "| Protocol Clients E2E (advisory, #10049) | $(status '${{ needs.test-protocols-e2e.result }}') |" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY"
echo "## 🌍 Translations" >> "$GITHUB_STEP_SUMMARY" echo "## 🌍 Translations" >> "$GITHUB_STEP_SUMMARY"

View File

@@ -19,13 +19,13 @@ jobs:
actions: read actions: read
contents: read contents: read
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 - uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with: with:
languages: javascript-typescript languages: javascript-typescript
queries: security-extended queries: security-extended
- uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6 - uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with: with:
category: "/language:javascript-typescript" category: "/language:javascript-typescript"

View File

@@ -2,45 +2,28 @@ name: DAST smoke (PR)
on: on:
pull_request: pull_request:
branches: ["main", "release/**"] branches: ["main", "release/**"]
# Runner-cost guard (#8084): the CLI-bundle build alone is 6-11min; a docs-only PR
# cannot change DAST behavior, so skip the whole workflow for pure docs/markdown
# changes. Any code path in the diff still runs the full smoke.
paths-ignore:
- "docs/**"
- "**/*.md"
permissions: permissions:
contents: read contents: read
# Superseded runs on the same PR must not stack 25-minute advisory builds
# (force-push storms were holding 2-3 runners each). Same group rule as quality.yml.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
dast-smoke: dast-smoke:
runs-on: ubuntu-latest runs-on: ubuntu-latest
# ADVISORY while this new gate matures (repo convention: advisory -> blocking). # ADVISORY while this new gate matures (repo convention: advisory -> blocking).
# Flip to blocking (remove continue-on-error) once it's proven stable across a few PRs. # Flip to blocking (remove continue-on-error) once it's proven stable across a few PRs.
continue-on-error: true continue-on-error: true
# Build CLI bundle alone varies 6-11min on GitHub-hosted runners (3 consecutive timeout-minutes: 12
# timeouts observed on 2026-07-14 with the old 12min cap killing schemathesis
# mid-run) — 25min leaves real headroom for the actual DAST steps.
timeout-minutes: 25
env: env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-api-key-secret-with-sufficient-length-aaaa API_KEY_SECRET: ci-api-key-secret-with-sufficient-length-aaaa
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with: with:
node-version: "24" node-version: "24"
cache: npm cache: npm
- uses: ./.github/actions/npm-ci-retry - run: npm ci
- name: Build CLI bundle - name: Build CLI bundle
env:
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
run: npm run build:cli run: npm run build:cli
- name: Start OmniRoute - name: Start OmniRoute
env: env:
@@ -53,20 +36,14 @@ jobs:
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi
sleep 2 sleep 2
done done
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "3.12" python-version: "3.12"
- run: pip install schemathesis - run: pip install schemathesis
- name: Schemathesis smoke (high-risk endpoints, blocking) - name: Schemathesis smoke (high-risk endpoints, blocking)
run: | run: |
# /api/auth/oidc/* is a BROWSER redirect flow (302 to the IdP, 302 back to
# /login?oidc_error=... on every failure), not a REST endpoint: Schemathesis reads
# those 302s as "the API accepted a schema-violating request" and the configured-off
# 400 as "rejected a schema-compliant request". Documenting the flow in the spec is
# still right (operators need it); fuzzing it is not what this smoke is for.
schemathesis run docs/openapi.yaml --url http://localhost:20128 \ schemathesis run docs/openapi.yaml --url http://localhost:20128 \
--include-path-regex '^/v1/(chat/completions|models)$|^/api/(auth|keys)' \ --include-path-regex '^/v1/(chat/completions|models)$|^/api/(auth|keys)' \
--exclude-path-regex '^/api/auth/oidc/' \
--max-examples 8 --workers 4 --checks all --max-response-time 30 \ --max-examples 8 --workers 4 --checks all --max-response-time 30 \
--request-timeout 20 --suppress-health-check all --no-color --request-timeout 20 --suppress-health-check all --no-color
- name: promptfoo injection-guard (blocking) - name: promptfoo injection-guard (blocking)

View File

@@ -4,7 +4,6 @@ on:
push: push:
branches: branches:
- main - main
- "release/v*"
tags: tags:
- "v*" - "v*"
paths-ignore: paths-ignore:
@@ -58,20 +57,39 @@ jobs:
REF_TYPE: ${{ github.ref_type }} REF_TYPE: ${{ github.ref_type }}
INPUT_VERSION: ${{ inputs.version }} INPUT_VERSION: ${{ inputs.version }}
PROMOTE_INPUT: ${{ inputs.promote_latest }} PROMOTE_INPUT: ${{ inputs.promote_latest }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: | run: |
set -euo pipefail set -euo pipefail
# 1) Resolve version/channel from the trigger. Only the current default # 1) Resolve version string from the trigger (all inputs come via env).
# release branch publishes the mutable `next` channel; main keeps `main`. case "$EVENT_NAME" in
VERSION=$(bash scripts/ci/resolve-docker-publish-version.sh \ workflow_dispatch)
"$EVENT_NAME" "$REF_TYPE" "$REF_NAME" "$INPUT_VERSION" "$DEFAULT_BRANCH") VERSION="${INPUT_VERSION#v}"
;;
push)
if [ "$REF_TYPE" = "tag" ]; then
VERSION="${REF_NAME#v}"
else
# Push to main → build & tag as `main` only. Never touch :latest.
VERSION="main"
fi
;;
release)
VERSION="${REF_NAME#v}"
;;
*)
VERSION="${REF_NAME#v}"
;;
esac
# Sanity-check: only allow [A-Za-z0-9._-] in VERSION (defense in depth).
if ! printf '%s' "$VERSION" | grep -qE '^[A-Za-z0-9._-]+$'; then
echo "Refusing to use unsafe VERSION value: $VERSION" >&2
exit 1
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT"
# 2) Decide whether to promote :latest. Floating channels are never # 2) Decide whether to promote :latest.
# eligible, and the helper independently fails closed for non-semver.
PROMOTE="false" PROMOTE="false"
if [ "$VERSION" = "main" ] || [ "$VERSION" = "next" ]; then if [ "$VERSION" = "main" ]; then
PROMOTE="false" PROMOTE="false"
elif printf '%s' "$VERSION" | grep -qE -- '-(rc|alpha|beta|pre|next)'; then elif printf '%s' "$VERSION" | grep -qE -- '-(rc|alpha|beta|pre|next)'; then
echo "Pre-release identifier detected — skipping :latest." echo "Pre-release identifier detected — skipping :latest."
@@ -91,10 +109,10 @@ jobs:
fi fi
echo "promote_latest=$PROMOTE" >> "$GITHUB_OUTPUT" echo "promote_latest=$PROMOTE" >> "$GITHUB_OUTPUT"
# 3) Skip immutable version tags that already exist. Floating `main` # 3) Skip if this exact version is already published in Docker Hub.
# and `next` channels are intentionally rebuilt on every matching push. # `main` is always rebuilt (mutable floating tag).
SKIP="false" SKIP="false"
if [ "$VERSION" != "main" ] && [ "$VERSION" != "next" ]; then if [ "$VERSION" != "main" ]; then
if docker manifest inspect "diegosouzapw/omniroute:${VERSION}" >/dev/null 2>&1; then if docker manifest inspect "diegosouzapw/omniroute:${VERSION}" >/dev/null 2>&1; then
echo "Image diegosouzapw/omniroute:${VERSION} already exists on Docker Hub — skipping rebuild." echo "Image diegosouzapw/omniroute:${VERSION} already exists on Docker Hub — skipping rebuild."
SKIP="true" SKIP="true"
@@ -137,13 +155,13 @@ jobs:
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -237,13 +255,13 @@ jobs:
uses: docker/setup-buildx-action@v4 uses: docker/setup-buildx-action@v4
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@v4.6.0 uses: docker/login-action@v4
with: with:
registry: ghcr.io registry: ghcr.io
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -372,14 +390,14 @@ jobs:
- name: Upload Trivy SARIF to Security tab - name: Upload Trivy SARIF to Security tab
if: needs.prepare.outputs.version != 'main' if: needs.prepare.outputs.version != 'main'
continue-on-error: true continue-on-error: true
uses: github/codeql-action/upload-sarif@v4.37.6 uses: github/codeql-action/upload-sarif@v4
with: with:
sarif_file: trivy-results.sarif sarif_file: trivy-results.sarif
category: trivy-image category: trivy-image
- name: Update Docker Hub description - name: Update Docker Hub description
# Only refresh README/description when we actually promote :latest # Only refresh README/description when we actually promote :latest
# (avoids overwriting from main, next, or back-fill builds). # (avoids overwriting from main pushes or back-fill builds).
if: needs.prepare.outputs.promote_latest == 'true' if: needs.prepare.outputs.promote_latest == 'true'
uses: peter-evans/dockerhub-description@v5 uses: peter-evans/dockerhub-description@v5
with: with:

View File

@@ -55,75 +55,9 @@ jobs:
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "✓ Valid version: $VERSION" echo "✓ Valid version: $VERSION"
web-build:
name: Build shared Next standalone
needs: validate
# Stage 8 (issue #10321): the four desktop legs used to each run the full
# `npm run build` (Next standalone) — ~111 runner-minutes per release just to
# produce the same platform-independent bundle four times. This job builds it
# once on ubuntu; every leg then restores the byte-verified archive and
# re-forks its native optionals (scripts/build/standaloneBundle.mjs).
#
# Rollback lever: set the repo variable ELECTRON_SHARED_STANDALONE=disabled.
# This job then skips, every leg falls back to building its own web bundle
# (the legacy step below), and the pipeline behaves exactly like pre-Stage 8 —
# no revert needed.
if: ${{ !cancelled() && needs.validate.result == 'success' && vars.ELECTRON_SHARED_STANDALONE != 'disabled' }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 24
- name: Install dependencies
run: npm ci
env:
NPM_CONFIG_LEGACY_PEER_DEPS: true
- name: Build Next.js standalone
# webpack, not Turbopack, for the same hosted-runner RAM reason as the
# linux leg (see the long comment on the fallback step in `build`).
env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
NODE_OPTIONS: "--max_old_space_size=6144"
OMNIROUTE_USE_TURBOPACK: "0"
run: npm run build
- name: Pack standalone bundle
# Deterministic tar.gz + byte-level manifest; the manifest embeds the
# archive's own sha256 so artifact-transfer corruption is caught before
# extraction, and every entry is re-verified after extraction.
run: node scripts/build/standaloneBundle.mjs pack --out web-bundle.tar.gz
- name: Upload shared web bundle
uses: actions/upload-artifact@v7
with:
name: web-standalone-bundle
# compression-level 0: the payload is already a deterministic tar.gz;
# re-zipping would only burn runner CPU without shrinking it further.
compression-level: 0
# Legs consume this within minutes; no reason to retain it like the
# installer artifacts (default 90d).
retention-days: 3
path: |
web-bundle.tar.gz
web-bundle.tar.gz.manifest.json
build: build:
name: Build Electron (${{ matrix.platform }}) name: Build Electron (${{ matrix.platform }})
needs: [validate, web-build] needs: validate
# `web-build` is skipped when ELECTRON_SHARED_STANDALONE=disabled (rollback
# mode); legs then run the legacy per-leg web build below. If it ran and
# failed, fail closed: legs cannot package without the bundle, and silently
# falling back to four per-leg builds would hide exactly the regression the
# shared job exists to surface.
if: ${{ !cancelled() && needs.validate.result == 'success' && (needs.web-build.result == 'success' || needs.web-build.result == 'skipped') }}
runs-on: ${{ matrix.runner }} runs-on: ${{ matrix.runner }}
permissions: permissions:
contents: write # electron-builder may publish artifacts with GH_TOKEN contents: write # electron-builder may publish artifacts with GH_TOKEN
@@ -135,38 +69,38 @@ jobs:
runner: windows-latest runner: windows-latest
target: win target: win
ext: .exe ext: .exe
os: win32
arch: x64
- platform: macos-intel - platform: macos-intel
runner: macos-15-intel runner: macos-15-intel
target: mac-x64 target: mac-x64
ext: .dmg ext: .dmg
os: darwin
arch: x64
- platform: macos-arm64 - platform: macos-arm64
runner: macos-latest runner: macos-latest
target: mac-arm64 target: mac-arm64
ext: -arm64.dmg ext: -arm64.dmg
os: darwin
arch: arm64
- platform: linux - platform: linux
runner: ubuntu-latest runner: ubuntu-latest
target: linux target: linux
ext: .AppImage ext: .AppImage
deb_ext: .deb deb_ext: .deb
os: linux
arch: x64,arm64
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v7 uses: actions/setup-node@v6
with: with:
node-version: 24 node-version: 24
cache: npm cache: npm
- name: Cache node_modules
uses: actions/cache@v6.1.0
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
env: env:
@@ -182,52 +116,12 @@ jobs:
mkdir -p "$RUNNER_TEMP/home" mkdir -p "$RUNNER_TEMP/home"
echo "USERPROFILE=$RUNNER_TEMP/home" >> "$GITHUB_ENV" echo "USERPROFILE=$RUNNER_TEMP/home" >> "$GITHUB_ENV"
- name: Build Next.js standalone (legacy per-leg fallback) - name: Build Next.js standalone
# Stage 8: only runs in rollback mode (ELECTRON_SHARED_STANDALONE=disabled)
# or when the shared web-build job was skipped. Otherwise the leg restores
# the shared bundle from the `web-build` job below.
if: needs.web-build.result == 'skipped'
env: env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
NODE_OPTIONS: "--max_old_space_size=6144" NODE_OPTIONS: "--max_old_space_size=6144"
# Linux builds with webpack, not Turbopack. Turbopack's production build
# allocates natively (Rust, off the V8 heap), so --max_old_space_size does
# not bound it, and on this module graph it peaks above what the hosted
# runner can give — the VM is reclaimed mid-compile with "The runner has
# received a shutdown signal", no exit code. That is what silently took the
# whole desktop channel out of v3.8.49: the linux leg died, `release` was
# skipped, and the release shipped with ZERO assets. Measured on a 32 GB
# box the same build passes and peaks past 14 GB. The webpack fallback is
# the project's documented escape hatch for RAM-constrained machines
# (docs/reference/ENVIRONMENT.md, #6409) and is the same remedy already
# applied to nightly-compat's Node 26 build (#8090).
OMNIROUTE_USE_TURBOPACK: ${{ matrix.platform == 'linux' && '0' || '1' }}
run: npm run build run: npm run build
- name: Download shared web bundle
# Stage 8: inverse of the fallback step above — runs exactly when the
# shared `web-build` job produced the bundle.
if: needs.web-build.result == 'success'
uses: actions/download-artifact@v8
with:
name: web-standalone-bundle
- name: Restore + hydrate shared web bundle
if: needs.web-build.result == 'success'
shell: bash
# restore: verify the archive's sha256 against the manifest, extract, then
# re-verify every entry (existence + size + content hash + symlink
# targets, and no unlisted files) byte-for-byte.
# hydrate: the bundle was built on ubuntu, so install-machine-forked native
# optionals (@img/sharp-*, @img/sharp-libvips-*, @ngrok/ngrok-*,
# fsevents) carry linux forks. Replace them with the forks this
# leg's own `npm ci` resolved, then assert every bundled native
# (koffi triplets, better-sqlite3 prebuilds, wreq-js, onnxruntime)
# can service this leg's platform/arch before packaging starts.
run: |
node scripts/build/standaloneBundle.mjs restore --archive web-bundle.tar.gz
node scripts/build/standaloneBundle.mjs hydrate --platform ${{ matrix.os }} --arch ${{ matrix.arch }}
- name: Sync version in electron/package.json - name: Sync version in electron/package.json
shell: bash shell: bash
env: env:
@@ -252,7 +146,7 @@ jobs:
- name: Install Electron dependencies - name: Install Electron dependencies
working-directory: electron working-directory: electron
run: npm ci --no-audit --no-fund run: npm install --no-audit --no-fund
- name: Build Electron for ${{ matrix.platform }} - name: Build Electron for ${{ matrix.platform }}
working-directory: electron working-directory: electron
@@ -323,16 +217,6 @@ jobs:
release: release:
name: Create Release name: Create Release
needs: [validate, build] needs: [validate, build]
# Fail-partial, not fail-closed. `build` is a 4-leg matrix with `fail-fast: false`,
# so the legs that succeed still upload their artifacts — but a default `needs:`
# gate skips this job the moment ANY leg fails, discarding all of them. That is
# exactly what happened to v3.8.49: the linux leg died and the release shipped with
# ZERO assets, throwing away 1.7 GB of good Windows/macOS installers **and** the
# source archives + SBOM, which do not depend on a build at all. The result was
# indistinguishable from "this version has no desktop channel".
# Now: attach everything that did build, then fail the job loudly (see the last
# step) so an incomplete channel is visible instead of silent.
if: ${{ !cancelled() && needs.validate.result == 'success' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions: permissions:
contents: write # softprops/action-gh-release creates the GitHub Release contents: write # softprops/action-gh-release creates the GitHub Release
@@ -343,33 +227,11 @@ jobs:
persist-credentials: false persist-credentials: false
fetch-depth: 0 fetch-depth: 0
# `merge-multiple` is deliberately OFF. It resolves same-name collisions by ARRIVAL
# ORDER, and the two macOS jobs each emit their own `latest-mac.yml` listing only their
# own dmg (measured: 338 and 350 bytes, different content, identical name). One silently
# overwrote the other — arm64 won in the published v3.8.48, and since the Intel dmg
# carries no arch suffix in its name, electron-updater's
# `files.find(url includes process.arch) ?? files.shift()` sends every Intel Mac to the
# ARM dmg. Downloading into per-artifact subdirectories keeps both, so they can be
# merged on purpose instead of by luck.
- name: Download all artifacts - name: Download all artifacts
uses: actions/download-artifact@v8 uses: actions/download-artifact@v8
with: with:
path: artifacts path: release-assets
merge-multiple: true
# Writes release-assets/latest-mac.yml with BOTH dmgs, un-suffixed entry first (that is
# the one electron-updater can only reach through its fallback). Refuses to write when the
# inputs disagree on version — a manifest stitched from two builds is worse than none.
- name: Merge the per-arch macOS updater manifests
run: node scripts/release/merge-mac-update-manifest.mjs artifacts release-assets
# Everything else moves across as-is. The partial latest-mac.yml files are excluded so
# they cannot clobber the merged one; -n is a second belt on the same braces.
- name: Collect the remaining artifacts
run: |
mkdir -p release-assets
find artifacts -type f ! -name latest-mac.yml -exec cp -n {} release-assets/ \;
echo "release-assets:"
ls -la release-assets/
- name: Create source archives - name: Create source archives
env: env:
@@ -413,47 +275,6 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
verify-desktop-assets:
name: Verify desktop assets landed
needs: [validate, release]
# Deliberately a SEPARATE job, not a final step of `release`: failing inside
# `release` would cascade into `publish-npm` (which gates on `needs: release`) and
# block the npm channel over a desktop-only gap. Here the assets are attached, npm
# still publishes, and an incomplete desktop channel shows up as a red job instead
# of passing unnoticed — the v3.8.49 release had ZERO assets and every gate was
# green, because nothing ever asserted the release HAS binaries.
if: ${{ !cancelled() && needs.release.result == 'success' }}
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Assert every platform is present on the release
env:
# Regex-validated (^v[0-9]+\.[0-9]+\.[0-9]+$) in the `validate` job, and
# passed via env rather than interpolated into the script body.
VERSION: ${{ needs.validate.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
names=$(gh release view "$VERSION" --repo "$GITHUB_REPOSITORY" \
--json assets --jq '.assets[].name')
echo "Assets on $VERSION:"
echo "$names" | sed 's/^/ /'
missing=""
# `[ ... ] && missing=...` as the last command in a branch returns 1 and
# would abort the whole script under Actions' default `set -e`. Use if/fi.
for want in '\.exe$' '\.dmg$' '\.AppImage$' '\.deb$' '^latest.*\.yml$' '\.source\.tar\.gz$'; do
if ! echo "$names" | grep -qE "$want"; then
missing="$missing $want"
fi
done
if [ -n "$missing" ]; then
echo "::error::Desktop channel incomplete on $VERSION — no asset matching:$missing"
exit 1
fi
echo "✓ every platform present on $VERSION"
publish-npm: publish-npm:
name: Publish to npm name: Publish to npm
needs: [validate, release] needs: [validate, release]

View File

@@ -44,7 +44,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: "24" node-version: "24"
cache: npm cache: npm

View File

@@ -66,25 +66,12 @@ jobs:
with: with:
ref: ${{ needs.resolve-branch.outputs.target }} ref: ${{ needs.resolve-branch.outputs.target }}
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: "26" node-version: "26"
cache: npm cache: npm
- uses: ./.github/actions/npm-ci-retry - uses: ./.github/actions/npm-ci-retry
# #8090 — this is the ONLY Node 26 build in the whole CI matrix (ci.yml pins
# CI_NODE_VERSION=24). It failed every nightly with the runner-reclaimed
# signature ("The runner has received a shutdown signal" / "The operation was
# canceled", no exit code) always at the same Turbopack compile phase — a
# classic OOM kill on the memory-constrained ubuntu-latest runner. Turbopack's
# native (Rust, off-V8-heap) allocation is NOT bounded by --max-old-space-size
# and peaks far higher than webpack on this large module graph (#6409), and is
# heavier still under Node 26. Use the documented webpack fallback here: it still
# validates that the app *builds* on Node 26 (the point of this compat job) at a
# much lower memory peak. Turbopack-on-Node-24 stays covered by ci.yml's build
# job. See docs/reference/ENVIRONMENT.md (OMNIROUTE_USE_TURBOPACK) and #6409.
- run: npm run build - run: npm run build
env:
OMNIROUTE_USE_TURBOPACK: "0"
compat-tests: compat-tests:
name: Node ${{ matrix.node }} Compat Tests (${{ matrix.shard }}/4) name: Node ${{ matrix.node }} Compat Tests (${{ matrix.shard }}/4)
@@ -106,7 +93,7 @@ jobs:
with: with:
ref: ${{ needs.resolve-branch.outputs.target }} ref: ${{ needs.resolve-branch.outputs.target }}
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
cache: npm cache: npm

View File

@@ -15,13 +15,11 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: { node-version: "24", cache: npm } with: { node-version: "24", cache: npm }
- run: npm ci - run: npm ci
- name: Build CLI bundle - name: Build CLI bundle
env: env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
run: npm run build:cli run: npm run build:cli
- name: Start OmniRoute (block mode) - name: Start OmniRoute (block mode)
env: env:
@@ -67,16 +65,14 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
if: steps.gate.outputs.run == 'true' if: steps.gate.outputs.run == 'true'
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
if: steps.gate.outputs.run == 'true' if: steps.gate.outputs.run == 'true'
with: { node-version: "24", cache: npm } with: { node-version: "24", cache: npm }
- run: npm ci - run: npm ci
if: steps.gate.outputs.run == 'true' if: steps.gate.outputs.run == 'true'
- name: Build CLI bundle - name: Build CLI bundle
if: steps.gate.outputs.run == 'true' if: steps.gate.outputs.run == 'true'
env: env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
run: npm run build:cli run: npm run build:cli
- name: Start OmniRoute - name: Start OmniRoute
if: steps.gate.outputs.run == 'true' if: steps.gate.outputs.run == 'true'
@@ -90,7 +86,7 @@ jobs:
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi
sleep 2 sleep 2
done done
- uses: actions/setup-python@v7 - uses: actions/setup-python@v6
if: steps.gate.outputs.run == 'true' if: steps.gate.outputs.run == 'true'
with: { python-version: "3.12" } with: { python-version: "3.12" }
- run: pip install garak - run: pip install garak

View File

@@ -107,7 +107,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: "24" node-version: "24"
cache: npm cache: npm
@@ -148,10 +148,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 15 timeout-minutes: 15
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v6
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: "24" node-version: "24"
- name: Download all mutation reports - name: Download all mutation reports

View File

@@ -13,7 +13,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: "24" node-version: "24"
cache: npm cache: npm

View File

@@ -1,18 +1,11 @@
name: Release-Green (continuous) name: Nightly Release-Green
# Solution D — continuous, NON-BLOCKING drift signal for the active release branch. # Solution D — continuous, NON-BLOCKING drift signal for the active release branch.
# #
# WHY: the full gate (ci.yml) only runs on the release PR (PR → main), so reds # WHY: the full gate (ci.yml) only runs on the release PR (PR → main), so reds
# accrue silently on release/** and explode — in layers — at release time. This # accrue silently on release/** and explode — in layers — at release time. This
# workflow reproduces the release-equivalent validation on the release branch and, # nightly reproduces the release-equivalent validation on the active release branch
# when there are HARD failures, opens/updates a single tracking issue. # HEAD and, when there are HARD failures, opens/updates a single tracking issue.
#
# WS5.1 (v3.8.49 quality plan) — two modes:
# push to release/v* (code paths) → --quick (fast HARD gates, ~5-8min). Catches the
# captain's direct pushes (sync-back — the one ungated write path) AND the merged
# COMBINATION right after every PR merge, attributing the offending push range in
# the issue. Base-red MTTD drops from ≤24h to ≤~15min after the offending push.
# schedule (3×/day) → full --with-build --full-ci (the deep sweep incl. build+suites).
# #
# It is NOT a required status check and never touches a contributor PR — it only # It is NOT a required status check and never touches a contributor PR — it only
# reports. Ratchet drift (eslint warnings / cognitive-complexity / file-size) is # reports. Ratchet drift (eslint warnings / cognitive-complexity / file-size) is
@@ -21,23 +14,8 @@ name: Release-Green (continuous)
# package-artifact) flip the issue open. # package-artifact) flip the issue open.
on: on:
push:
branches: ["release/v*", "main"]
paths:
- "src/**"
- "open-sse/**"
- "bin/**"
- "electron/**"
- "scripts/**"
- "tests/**"
- "config/**"
- "package.json"
- "package-lock.json"
- "tsconfig*.json"
schedule: schedule:
- cron: "23 5 * * *" # full sweep — off-peak, distinct from other nightlies - cron: "23 5 * * *" # 05:23 UTC daily — off-peak, distinct from other nightlies
- cron: "23 12 * * *" # full sweep — midday (WS5.1: 3×/day instead of 1×)
- cron: "23 18 * * *" # full sweep — evening
workflow_dispatch: workflow_dispatch:
inputs: inputs:
branch: branch:
@@ -50,9 +28,7 @@ permissions:
issues: write issues: write
concurrency: concurrency:
# push storms during merge campaigns collapse to the newest commit per branch; group: nightly-release-green
# scheduled full sweeps keep their own single lane.
group: release-green-${{ github.event_name }}-${{ github.ref }}
cancel-in-progress: true cancel-in-progress: true
env: env:
@@ -61,9 +37,6 @@ env:
jobs: jobs:
release-green: release-green:
name: Validate active release branch name: Validate active release branch
# On a push, only run for release/* pushes — a push to main is handled by the
# main-green job below. Schedule/dispatch always run (they validate the highest release).
if: ${{ github.event_name != 'push' || startsWith(github.ref_name, 'release/') }}
# Dynamic runner: with USE_VPS_RUNNER=true (release window / on-demand pre-flight) # Dynamic runner: with USE_VPS_RUNNER=true (release window / on-demand pre-flight)
# this runs on the dedicated VPS runner — clean env (no operator OMNIROUTE_API_KEY, # this runs on the dedicated VPS runner — clean env (no operator OMNIROUTE_API_KEY,
# no local noauth CLIs => zero machine-specific false positives) and no contention. # no local noauth CLIs => zero machine-specific false positives) and no contention.
@@ -83,15 +56,10 @@ jobs:
id: branch id: branch
env: env:
INPUT_BRANCH: ${{ github.event.inputs.branch }} INPUT_BRANCH: ${{ github.event.inputs.branch }}
EVENT_NAME: ${{ github.event_name }}
PUSHED_REF: ${{ github.ref_name }}
run: | run: |
set -euo pipefail set -euo pipefail
if [ -n "${INPUT_BRANCH:-}" ]; then if [ -n "${INPUT_BRANCH:-}" ]; then
TARGET="$INPUT_BRANCH" TARGET="$INPUT_BRANCH"
elif [ "$EVENT_NAME" = "push" ]; then
# validate exactly what was pushed, not the highest branch
TARGET="$PUSHED_REF"
else else
# highest release/vX.Y.Z by semver among remote branches # highest release/vX.Y.Z by semver among remote branches
TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \ TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \
@@ -116,7 +84,7 @@ jobs:
git checkout "$TARGET" git checkout "$TARGET"
git log -1 --oneline git log -1 --oneline
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: "24" node-version: "24"
cache: npm cache: npm
@@ -125,27 +93,16 @@ jobs:
- name: Release-green validation (full) - name: Release-green validation (full)
id: validate id: validate
env:
EVENT_NAME: ${{ github.event_name }}
run: | run: |
set +e set +e
# --hermetic: scrub live-test trigger vars (self-hosted runner may carry # --hermetic: scrub live-test trigger vars (self-hosted runner may carry
# operator env; hosted ignores the unknown flag before #6300 lands). # operator env; hosted ignores the unknown flag before #6300 lands).
# push → --quick: fast HARD gates only (~5-8min), per-merge signal. # --full-ci: ALSO run every static gate from ci.yml's gate jobs (lint,
# schedule/dispatch → --with-build --full-ci: ALSO run every static gate from # quality-gate, quality-extended, docs-sync-strict, pr-test-policy). PRs into
# ci.yml's gate jobs (lint, quality-gate, quality-extended, docs-sync-strict, # release/** only get the fast-gates, so these accrue silently and explode in
# pr-test-policy) + build + full suites. PRs into release/** only get the # layers on the release PR (v3.8.46: 11 static base-reds leaked). Running them
# fast-gates, so these accrue silently and explode in layers on the release PR # nightly opens the tracking issue the moment one lands, not at release time.
# (v3.8.46: 11 static base-reds leaked). node scripts/quality/validate-release-green.mjs --json --with-build --hermetic --full-ci \
if [ "$EVENT_NAME" = "push" ]; then
MODE="--quick"
else
MODE="--with-build --full-ci"
fi
echo "[release-green] mode: $MODE (event: $EVENT_NAME)"
# MODE is an intentional flag list, so word-splitting is wanted here
# shellcheck disable=SC2086
node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \
1> release-green.json 2> release-green.log 1> release-green.json 2> release-green.log
echo "exit=$?" >> "$GITHUB_OUTPUT" echo "exit=$?" >> "$GITHUB_OUTPUT"
echo "------- report -------" echo "------- report -------"
@@ -157,28 +114,15 @@ jobs:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
TARGET: ${{ steps.branch.outputs.target }} TARGET: ${{ steps.branch.outputs.target }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
EVENT_NAME: ${{ github.event_name }}
BEFORE_SHA: ${{ github.event.before }}
AFTER_SHA: ${{ github.event.after }}
run: | run: |
set -euo pipefail set -euo pipefail
TITLE="🔴 Release branch not green: ${TARGET}" TITLE="🔴 Release branch not green: ${TARGET}"
{ {
echo "The **release-green** validation found HARD failures on \`${TARGET}\`." echo "The nightly **release-green** validation found HARD failures on \`${TARGET}\`."
echo "These are real defects that would block the release PR — fix them in the" echo "These are real defects that would block the release PR — fix them in the"
echo "originating PR branch (via co-authorship), not by demanding it from contributors." echo "originating PR branch (via co-authorship), not by demanding it from contributors."
echo "" echo ""
echo "**Run:** ${RUN_URL} (mode: ${EVENT_NAME})" echo "**Run:** ${RUN_URL}"
# WS5.1 attribution: on push events the offending change IS this push's range
# (one merge per push in the normal queue), so name it — no bisect needed.
if [ "$EVENT_NAME" = "push" ] && [ -n "${BEFORE_SHA:-}" ] && \
git cat-file -e "$BEFORE_SHA" 2>/dev/null; then
echo ""
echo "**Offending push range** (\`${BEFORE_SHA:0:9}..${AFTER_SHA:0:9}\`):"
echo '```'
git log --no-decorate --oneline "${BEFORE_SHA}..${AFTER_SHA}" | head -20
echo '```'
fi
echo "" echo ""
echo '```' echo '```'
sed -n '/──────── verdict ────────/,$p' release-green.log || tail -40 release-green.log sed -n '/──────── verdict ────────/,$p' release-green.log || tail -40 release-green.log
@@ -193,7 +137,7 @@ jobs:
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file issue-body.md gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file issue-body.md
echo "Updated existing issue #$EXISTING" echo "Updated existing issue #$EXISTING"
else else
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label base-red --body-file issue-body.md gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file issue-body.md
fi fi
- name: Upload report artifact - name: Upload report artifact
@@ -205,243 +149,3 @@ jobs:
release-green.json release-green.json
release-green.log release-green.log
if-no-files-found: ignore if-no-files-found: ignore
# Companion arm for `main`. Under the parallel-cycle model, main only receives merged
# work at the release squash — so a gate/infra fix that lands only on release leaves
# main red the whole cycle, and repo-wide gates (CodeQL alert count, ratchet baselines)
# turn EVERY PR into main red on a check unrelated to its diff. This detects that and
# opens a "🔴 main not green" tracking issue. The PREVENTION is the companion-PR reflex
# (Hard Rule #21 area / _shared/merge-gates.md §8); this is the automated backstop.
main-green:
name: Validate main branch
# On a push, only run for a push to main — a push to release/* is handled by
# release-green above. Schedule/dispatch always run (they also sweep main).
if: ${{ github.event_name != 'push' || github.ref_name == 'main' }}
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-latest' }}
env:
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-nightly-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true"
steps:
- uses: actions/checkout@v7
with:
ref: main # literal — no injection surface; scheduled runs default to the repo default branch (a release/v*), so pin main explicitly
fetch-depth: 0
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- uses: ./.github/actions/npm-ci-retry
- name: Main-green validation
id: validate
env:
EVENT_NAME: ${{ github.event_name }}
run: |
set +e
# push (a merge into main) → --quick fast HARD gates; schedule/dispatch → full sweep.
if [ "$EVENT_NAME" = "push" ]; then
MODE="--quick"
else
MODE="--with-build --full-ci"
fi
echo "[main-green] mode: $MODE (event: $EVENT_NAME)"
# MODE is an intentional flag list, so word-splitting is wanted here
# shellcheck disable=SC2086
node scripts/quality/validate-release-green.mjs --json --hermetic $MODE \
1> main-green.json 2> main-green.log
echo "exit=$?" >> "$GITHUB_OUTPUT"
echo "------- report -------"
cat main-green.log
- name: Open / update tracking issue on HARD failure
if: steps.validate.outputs.exit != '0'
env:
GH_TOKEN: ${{ github.token }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
EVENT_NAME: ${{ github.event_name }}
run: |
set -euo pipefail
TITLE="🔴 main branch not green"
{
echo "The **main-green** validation found HARD failures on \`main\`."
echo ""
echo "Because \`main\` only receives merged work at the release squash, a gate/infra"
echo "fix that landed only on the release branch leaves \`main\` broken for the whole"
echo "cycle — and repo-wide gates (CodeQL alert count, ratchet baselines) then turn"
echo "**every open PR into main** red on a check unrelated to its diff. The fix is a"
echo "companion PR \`--base main\` carrying the release-side fix (see"
echo "\`_shared/merge-gates.md\` §8), NOT chasing each contributor PR."
echo ""
echo "**Run:** ${RUN_URL} (mode: ${EVENT_NAME})"
echo ""
echo '```'
sed -n '/──────── verdict ────────/,$p' main-green.log || tail -40 main-green.log
echo '```'
echo ""
echo "_Ratchet drift (eslint warnings / cognitive-complexity / file-size) is expected mid-cycle and did NOT, on its own, open this issue._"
} > issue-body.md
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \
--search "in:title $TITLE" --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING" ]; then
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file issue-body.md
echo "Updated existing issue #$EXISTING"
else
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --label base-red --body-file issue-body.md
fi
- name: Upload report artifact
if: always()
uses: actions/upload-artifact@v7
with:
name: main-green-report
path: |
main-green.json
main-green.log
if-no-files-found: ignore
# ── Banking lane (#8584) ──────────────────────────────────────────────────
# The ratchet is asymmetric: RAISING a cap is a ten-second manual JSON edit made
# under merge pressure, LOWERING one requires someone to run `--update` and commit
# — which no workflow does. Grep `.github/workflows/` for `--update`: only
# wiki-sync.yml (unrelated) and ci.yml's check-quality-ratchet.mjs --require-tighten
# (a different script, a different metric). So a cap outlives the code that earned
# it and every completed decomposition silently becomes a growth allowance for
# whoever touches the file next. Measured on 2026-07-25: 18 frozen files already at
# or under the 800-line new-file cap, up to 132x (schemas.ts, 19 lines / 2523 cap),
# and 31 unfulfilled "tighten via --update next cycle" notes honoured exactly once
# (-1 unit) in six weeks.
#
# This job closes that loop by making the DOWNWARD direction as automatic as the
# upward one is easy. It measures the active release branch, runs the shrink-only
# `--update` paths, and opens ONE always-current PR with the result. It never
# pushes to release/* — a human still merges, so a bad measurement cannot land
# unreviewed. verify-ratchet-bank.mjs is the hard guarantee that the automation can
# only ever write in the shrink direction; if anything was raised, added, or a
# rebaseline note was touched, the job aborts and opens nothing.
#
# Schedule/dispatch only, deliberately NOT on push: banking has no latency
# requirement (a shrink banked within 8h is fine) and a per-merge run would rebuild
# the PR branch repeatedly during merge campaigns while paying for a full ESLint
# walk each time. Detection stays on push (release-green above); banking is batched.
bank-ratchet-shrinks:
name: Bank ratchet shrinks
if: ${{ github.event_name != 'push' }}
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0
- name: Resolve active release branch
id: branch
env:
INPUT_BRANCH: ${{ github.event.inputs.branch }}
run: |
set -euo pipefail
if [ -n "${INPUT_BRANCH:-}" ]; then
TARGET="$INPUT_BRANCH"
else
TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \
| sed 's#origin/##' \
| sort -t/ -k2 -V \
| tail -1)
fi
if [ -z "$TARGET" ]; then echo "No release/v* branch found"; exit 1; fi
# Same strict guard as the validation job — blocks ref/command injection
# through the workflow_dispatch input.
if ! printf '%s' "$TARGET" | grep -qE '^release/v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Refusing non-canonical branch name: $TARGET"; exit 1
fi
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
echo "bank_branch=chore/bank-ratchet-${TARGET#release/}" >> "$GITHUB_OUTPUT"
- name: Checkout the release branch
env:
TARGET: ${{ steps.branch.outputs.target }}
run: |
set -euo pipefail
git checkout "$TARGET"
git log -1 --oneline
- uses: actions/setup-node@v7
with:
node-version: "24"
cache: npm
- uses: ./.github/actions/npm-ci-retry
- name: Ratchet the baselines down
run: |
# Both --update paths are shrink-only by construction (file-size writes only
# on `improvements`, complexity-ratchets only when `.improved`), and both exit
# non-zero while the branch is over baseline — which is exactly when there is
# nothing to bank. Their exit code is not the signal; the verifier below is.
set +e
node scripts/check/check-file-size.mjs --update
node scripts/check/check-complexity-ratchets.mjs --update
exit 0
- name: Verify the write only went downward
id: verify
run: |
set -euo pipefail
# Exits 1 if ANYTHING was raised/added or a rebaseline note was touched.
# `set -e` then aborts the job before a commit exists — no PR is opened.
node scripts/quality/verify-ratchet-bank.mjs > bank-summary.md
if [ -n "$(git status --porcelain config/quality/)" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "Nothing to bank — baselines already match the code."
fi
- name: Open / update the banking PR
if: steps.verify.outputs.changed == 'true'
env:
GH_TOKEN: ${{ github.token }}
TARGET: ${{ steps.branch.outputs.target }}
BANK_BRANCH: ${{ steps.branch.outputs.bank_branch }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git checkout -B "$BANK_BRANCH"
git add config/quality/
git commit -m "chore(quality): bank ratchet shrinks measured on ${TARGET}"
git push --force origin "$BANK_BRANCH"
{
echo "Automated banking of quality-ratchet **shrinks** already present in"
echo "\`${TARGET}\` — the downward half of the ratchet, which nothing else runs (#8584)."
echo ""
echo "Produced by \`check:file-size --update\` + \`check:complexity-ratchets --update\`,"
echo "then verified by \`scripts/quality/verify-ratchet-bank.mjs\`: **nothing was raised,"
echo "nothing was added, no rebaseline note was touched** — the job aborts without"
echo "opening a PR if any of those is violated."
echo ""
echo "No product code changes. Merging retires growth allowances that the code no"
echo "longer needs; not merging leaves them available to whoever edits those files next."
echo ""
cat bank-summary.md
echo ""
echo "**Run:** ${RUN_URL}"
} > pr-body.md
EXISTING=$(gh pr list --repo "$GITHUB_REPOSITORY" --head "$BANK_BRANCH" \
--state open --json number --jq '.[0].number' 2>/dev/null || echo "")
if [ -n "$EXISTING" ]; then
gh pr edit "$EXISTING" --repo "$GITHUB_REPOSITORY" --body-file pr-body.md
echo "Updated existing PR #$EXISTING"
else
gh pr create --repo "$GITHUB_REPOSITORY" --base "$TARGET" --head "$BANK_BRANCH" \
--title "chore(quality): bank ratchet shrinks (${TARGET})" --body-file pr-body.md
fi

View File

@@ -15,7 +15,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: "24" node-version: "24"
cache: npm cache: npm
@@ -29,7 +29,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: "24" node-version: "24"
cache: npm cache: npm
@@ -43,7 +43,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: "24" node-version: "24"
cache: npm cache: npm
@@ -51,7 +51,6 @@ jobs:
- name: Build CLI bundle - name: Build CLI bundle
env: env:
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
run: npm run build:cli run: npm run build:cli
- name: Start OmniRoute (background) - name: Start OmniRoute (background)
env: env:
@@ -95,7 +94,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: "24" node-version: "24"
cache: npm cache: npm

View File

@@ -16,13 +16,11 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: { node-version: "24", cache: npm } with: { node-version: "24", cache: npm }
- run: npm ci - run: npm ci
- name: Build CLI bundle - name: Build CLI bundle
env: env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
run: npm run build:cli run: npm run build:cli
- name: Start OmniRoute (background) - name: Start OmniRoute (background)
env: env:
@@ -35,7 +33,7 @@ jobs:
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo "server up"; break; fi if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo "server up"; break; fi
sleep 2 sleep 2
done done
- uses: actions/setup-python@v7 - uses: actions/setup-python@v6
with: { python-version: "3.12" } with: { python-version: "3.12" }
- name: Install schemathesis - name: Install schemathesis
run: pip install schemathesis run: pip install schemathesis

View File

@@ -22,14 +22,6 @@ on:
- latest - latest
- next - next
- historic - historic
publish_mode:
description: "staged = npm stage publish (owner approves with 2FA after the staged boot-verify); direct = legacy immediate publish (emergency fallback only)"
required: false
default: "staged"
type: choice
options:
- staged
- direct
workflow_call: workflow_call:
inputs: inputs:
version: version:
@@ -56,15 +48,8 @@ env:
jobs: jobs:
publish: publish:
# Same dynamic-runner rule as ci.yml's `build`/`test-unit`: `build:cli` falls back to a runs-on: ubuntu-latest
# full `next build`, whose working set outgrew the 16 GB hosted runner during the
# v3.8.49 cycle — the publish died with "The runner has received a shutdown signal"
# mid-"Creating an optimized production build" while v3.8.48 had still fit in 16min.
# This job never runs on `pull_request`, so the fork-safety clause is always true here;
# it is kept verbatim so the expression stays greppable against ci.yml.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
permissions: permissions:
actions: read # find + download the CI run's next-build artifact for this SHA
contents: write # gh release upload (attach SBOM to the GitHub Release) contents: write # gh release upload (attach SBOM to the GitHub Release)
id-token: write # npm provenance id-token: write # npm provenance
packages: write # publish to npm.pkg.github.com packages: write # publish to npm.pkg.github.com
@@ -78,7 +63,7 @@ jobs:
fetch-depth: 0 fetch-depth: 0
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v7 uses: actions/setup-node@v6
with: with:
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }} node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org
@@ -152,74 +137,6 @@ jobs:
run: | run: |
npm version "$VERSION" --no-git-tag-version --allow-same-version npm version "$VERSION" --no-git-tag-version --allow-same-version
# Fast path: CI already built the standalone tree for THIS commit and uploaded it as
# `next-build`. `build:cli` (scripts/build/prepublish.ts) only shells out to a full
# `next build` when `.build/next/standalone/server.js` is missing — restoring the
# artifact turns the heaviest step of the publish into a download. Matching on
# `head_sha` is the tree-equality guarantee: same commit, same tree.
# Best-effort by design (retention is 1 day): every miss falls through to the build
# step below, which is why the dynamic runner above matters as the backstop.
#
# The `head_repository.full_name == env.REPO` clause is a supply-chain guard, not a
# filter refinement. This artifact becomes the published npm tarball. `pull_request`
# runs from forks execute in THIS repository's context and upload their own
# `next-build` built from fork-controlled source, and the runs API returns them for a
# matching `head_sha` — 57 such runs exist in this repo today. Without the clause,
# anything that made a fork's head commit coincide with the publish commit could put
# attacker-built bytes on npm. Requiring the run to originate from this repository
# excludes every fork run while keeping the fast path intact (verified: the same
# single run is selected either way for the current tip).
# CodeQL: actions/artifact-poisoning/critical.
- name: Reuse CI's next-build artifact (skips the heavy rebuild)
if: steps.resolve.outputs.skip != 'true'
continue-on-error: true
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
HEAD_SHA: ${{ github.sha }}
REPO: ${{ github.repository }}
run: |
set -uo pipefail
# The question is "which run HAS the artifact", not "which run passed" (gap 16).
# Requiring `conclusion == "success"` on the whole run discarded a perfectly good tree
# whenever any unrelated shard went red — one flaky test then pushed the publish into
# the 40-minute build this step exists to avoid. The artifact is only uploaded if the
# Build job itself succeeded, so its PRESENCE is the accurate signal; the run's overall
# conclusion is noise from jobs that have nothing to do with the tree.
#
# `head_repository.full_name == env.REPO` stays, and it is not a filter refinement:
# this tree becomes the published npm tarball, and fork `pull_request` runs execute in
# THIS repository's context uploading their own next-build. That clause is the
# supply-chain guard (CodeQL actions/artifact-poisoning).
CANDIDATES=$(gh api "repos/$REPO/actions/runs?head_sha=$HEAD_SHA&per_page=100" \
--jq '[.workflow_runs[]
| select(.name == "CI"
and .head_repository.full_name == env.REPO)]
| sort_by(.run_started_at) | reverse | .[0:5] | .[].id') || CANDIDATES=""
if [ -z "$CANDIDATES" ]; then
echo "::notice::no CI run from this repository for $HEAD_SHA — falling back to a full build"
exit 0
fi
RUN=""
for candidate in $CANDIDATES; do
if gh run download "$candidate" --repo "$REPO" --name next-build --dir /tmp/next-build 2>/dev/null; then
RUN="$candidate"
break
fi
echo " run $candidate carries no usable next-build — trying the next"
done
if [ -z "$RUN" ]; then
echo "::notice::none of the candidate runs still carries next-build (1-day retention) — falling back to a full build"
exit 0
fi
tar -xzf /tmp/next-build/e2e-build.tar.gz -C .
rm -rf /tmp/next-build
if [ -f .build/next/standalone/server.js ]; then
echo "✅ standalone tree restored from CI run $RUN — build:cli will skip next build"
else
echo "::notice::extract did not yield .build/next/standalone — falling back to a full build"
rm -rf .build
fi
- name: Build CLI bundle (standalone app) - name: Build CLI bundle (standalone app)
if: steps.resolve.outputs.skip != 'true' if: steps.resolve.outputs.skip != 'true'
env: env:
@@ -249,46 +166,8 @@ jobs:
TAG: ${{ github.ref_name }} TAG: ${{ github.ref_name }}
run: gh release upload "$TAG" sbom-npm.cdx.json --clobber run: gh release upload "$TAG" sbom-npm.cdx.json --clobber
# WS1.2/WS1.3 (#7065 class): the artifact that is about to be published must - name: Publish to npm
# BOOT. build:cli already assembled dist/ above; this packs+installs+boots the
# real tarball and fails the publish before anything reaches the registry.
- name: Boot-smoke the tarball before ANY publish
if: steps.resolve.outputs.skip != 'true' if: steps.resolve.outputs.skip != 'true'
run: npm run check:pack-boot
# The boot-smoke above proves a CLEAN install boots. It does not prove the path that
# actually broke us: installing over an existing version, where ~110 SQLite migrations
# run against a populated database. v3.8.48 shipped as a hotfix because the published
# 3.8.47 crashed on boot, and the v3.8.49 upgrade path was first exercised end-to-end
# by hand on a real 3.8.48 box (VPS .16) — after publishing, which is exactly backwards.
# Runs BEFORE `npm stage publish` so a broken upgrade never reaches the registry at all;
# a staged package that is never approved simply expires, with no `npm deprecate` needed.
- name: Prove clean-install AND upgrade-over-previous both boot
if: steps.resolve.outputs.skip != 'true'
timeout-minutes: 30
run: npm run check:install-upgrade
# WS1.3 (D2, v3.8.49 plan): STAGED publishing by default — `npm stage publish`
# parks the exact bytes on the registry WITHOUT making them installable; the
# owner then verifies and approves with 2FA (`npm stage approve`), moving the
# human gate to AFTER the proof instead of before it. Requires npm >= 11.15
# (staged publishing GA 2026-05-22). publish_mode=direct is the emergency
# fallback (legacy immediate publish) via workflow_dispatch.
- name: Ensure npm supports staged publishing
if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct')
run: |
set -euo pipefail
CUR=$(npm --version)
if ! node -e "const [a,b]='$(npm --version)'.split('.').map(Number); process.exit(a>11||(a===11&&b>=15)?0:1)"; then
# Pinned exact version (supply-chain: never float @latest in the publish
# job); bump deliberately when a newer npm is required.
echo "npm $CUR < 11.15 — installing pinned npm 11.15.0 for staged publishing"
npm install -g --ignore-scripts npm@11.15.0
fi
npm --version
- name: Publish to npm (staged — owner approves with 2FA)
if: steps.resolve.outputs.skip != 'true' && (github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct')
env: env:
VERSION: ${{ steps.resolve.outputs.version }} VERSION: ${{ steps.resolve.outputs.version }}
TAG: ${{ steps.resolve.outputs.tag }} TAG: ${{ steps.resolve.outputs.tag }}
@@ -296,32 +175,10 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
# Always pass --tag explicitly. Defense in depth: even if VERSION is # Always pass --tag explicitly. Defense in depth: even if VERSION is
# accidentally an older release, the historic tag will NOT claim `@latest`. # accidentally an older release, `npm publish --tag historic` will
npm stage publish --provenance --access public --tag "$TAG" # NOT promote it to `@latest`.
{
echo "## 📦 omniroute@$VERSION STAGED (not yet installable)"
echo ""
echo "The exact bytes are parked on the registry. To release them:"
echo '```'
echo "npm stage list omniroute # find the stage id"
echo "npm stage approve <id> # owner 2FA — THE publish"
echo '```'
echo "To verify the staged bytes first: npm stage download <id> → run"
echo "scripts/check/check-pack-boot.mjs against them (see RELEASE_CHECKLIST)."
echo "To discard: npm stage reject <id>."
} >> "$GITHUB_STEP_SUMMARY"
echo "✅ Staged omniroute@$VERSION (dist-tag=$TAG) — awaiting owner 'npm stage approve'"
- name: Publish to npm (DIRECT — emergency fallback)
if: steps.resolve.outputs.skip != 'true' && github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'direct'
env:
VERSION: ${{ steps.resolve.outputs.version }}
TAG: ${{ steps.resolve.outputs.tag }}
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: |
set -euo pipefail
npm publish --provenance --access public --tag "$TAG" npm publish --provenance --access public --tag "$TAG"
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) [DIRECT mode]" echo "✅ Published omniroute@$VERSION (dist-tag=$TAG)"
- name: Publish to GitHub Packages - name: Publish to GitHub Packages
if: steps.resolve.outputs.skip != 'true' if: steps.resolve.outputs.skip != 'true'
@@ -352,7 +209,7 @@ jobs:
# Full history needed for auto-bump: git diff against previous release tag # Full history needed for auto-bump: git diff against previous release tag
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v7 uses: actions/setup-node@v6
with: with:
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }} node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
registry-url: https://registry.npmjs.org registry-url: https://registry.npmjs.org

View File

@@ -35,7 +35,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
cache: npm cache: npm
@@ -52,7 +52,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: "22" node-version: "22"
cache: npm cache: npm

View File

@@ -35,7 +35,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ matrix.node }} node-version: ${{ matrix.node }}
cache: npm cache: npm
@@ -51,7 +51,7 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: "20" node-version: "20"
cache: npm cache: npm

View File

@@ -36,7 +36,7 @@ jobs:
with: with:
persist-credentials: false persist-credentials: false
fetch-depth: 0 fetch-depth: 0
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
- id: classify - id: classify
@@ -57,87 +57,22 @@ jobs:
git diff --name-only "$BASE_SHA" "$HEAD_SHA" > changed-files.txt git diff --name-only "$BASE_SHA" "$HEAD_SHA" > changed-files.txt
node scripts/quality/classify-pr-changes.mjs changed-files.txt >> "$GITHUB_OUTPUT" node scripts/quality/classify-pr-changes.mjs changed-files.txt >> "$GITHUB_OUTPUT"
build:
name: Build (advisory)
needs: changes
# FORK PRs ONLY. build.yml's `Fast Production Build` triggers on `push: branches: ["**"]`
# and runs `build:release` — a superset of this job — so for an own-origin branch this job
# was building the same tree twice. A fork contributor pushes to THEIR repo, so that push
# never fires here, and this is the only pre-merge build signal they get. Measured
# 2026-08-14: 72 of the last 100 PRs into release/** came from forks, so the fork case is
# the majority of the traffic, not the exception — this job earns its place, it just should
# not duplicate build.yml for the own-origin 28%.
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true' && github.event.pull_request.head.repo.full_name != github.repository) }}
# PINNED to hosted — this was the last job in THIS workflow still on the USE_VPS_RUNNER
# switch (ci.yml's Build, nightly-release-green and npm-publish keep it, so the variable
# stays meaningful), and with USE_VPS_RUNNER=true it produced NO signal at all here.
# Measured 2026-08-14 over the last 25
# quality.yml runs: not one Build (advisory) reached a conclusion. Every sample was either
# queued on the self-hosted pool (2 runners, `omniroute-113-6/7`, both permanently busy — one
# job sat queued 2h+ and was still unclaimed) or, when it did land, killed mid-build by this
# workflow's own `cancel-in-progress` concurrency. 6/6 sampled "failures" are exit 143 /
# "The runner has received a shutdown signal" at ~3.5 min into `npm run build` — zero OOM,
# zero build errors. So the job burned a scarce runner that the gates actually need while
# reporting a permanent red on every PR.
#
# Gap 19 left USE_VPS_RUNNER governing build-like jobs on the premise that "the build needs
# the .113's RAM". That premise no longer holds: `Fast Production Build` (build.yml) runs
# `build:release` — a SUPERSET of this job's `npm run build`, plus the CLI bundle — on plain
# ubuntu-latest and passed 24/25 of its last runs in ~15 min. What it has and this job did
# not is memory PROVISIONING: a 10 GB swapfile plus a 12 GB V8 heap. That matters because
# --max-old-space-size only bounds V8's JS heap, never Turbopack's native (Rust) allocation
# (#6409) — swap is what absorbs the native peak. Both are mirrored below.
runs-on: ubuntu-latest
# #7307: advisory for the first week of release-PR runs; remove
# continue-on-error after the production-build signal is stable.
continue-on-error: true
steps:
# Mirrors build.yml: Turbopack's native peak is not bounded by --max-old-space-size, so
# the hosted runner needs swap headroom before the build starts.
- name: Expand virtual memory (10 GB swap)
run: |
sudo swapoff -a || true
sudo rm -f /mnt/swapfile /swapfile
sudo fallocate -l 10G /mnt/swapfile || sudo dd if=/dev/zero of=/mnt/swapfile bs=1M count=10240
sudo chmod 600 /mnt/swapfile
sudo mkswap /mnt/swapfile
sudo swapon /mnt/swapfile
free -h
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: ${{ env.CI_NODE_VERSION }}
cache: npm
- uses: ./.github/actions/npm-ci-retry
- run: npm run check:node-runtime
- run: npm run build
env:
OMNIROUTE_USE_TURBOPACK: "1"
# Same heap build.yml proves sufficient. build-next-isolated.mjs defaults to 8192 and
# honours OMNIROUTE_BUILD_MEMORY_MB; NODE_OPTIONS is set for parity with build.yml.
NODE_OPTIONS: "--max-old-space-size=12288"
OMNIROUTE_BUILD_MEMORY_MB: "12288"
# No artifact upload here: the PR-to-release quality workflow has no
# downstream package/e2e jobs that consume the Next.js build output.
# Docs/OpenAPI contract gates only — existence reason is doc accuracy + route refs. # Docs/OpenAPI contract gates only — existence reason is doc accuracy + route refs.
# Split out of fast-gates so pure-docs PRs skip typecheck/unit while still validating docs. # Split out of fast-gates so pure-docs PRs skip typecheck/unit while still validating docs.
docs-gates: docs-gates:
name: Docs Gates (fast-path) name: Docs Gates (fast-path)
needs: changes needs: changes
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }} if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
- uses: ./.github/actions/npm-ci-retry - run: npm ci
# One walk of src/app/api for openapi-routes + docs-symbols (both still fail independently). # One walk of src/app/api for openapi-routes + docs-symbols (both still fail independently).
- run: npm run check:api-docs-refs - run: npm run check:api-docs-refs
- name: Docs accuracy (fabricated-docs + i18n mirrors, strict) - name: Docs accuracy (fabricated-docs + i18n mirrors, strict)
@@ -147,25 +82,12 @@ jobs:
name: Fast Quality Gates name: Fast Quality Gates
needs: changes needs: changes
# Code surface only — pure docs/i18n PRs skip this bag (docs-gates covers docs). # Code surface only — pure docs/i18n PRs skip this bag (docs-gates covers docs).
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }} if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
# Dynamic runner (same rule as ci.yml): use the self-hosted VPS pool only when the # Dynamic runner (same rule as ci.yml): use the self-hosted VPS pool only when the
# release captain has USE_VPS_RUNNER=true AND this is not a fork PR (own-origin # release captain has USE_VPS_RUNNER=true AND this is not a fork PR (own-origin
# branches only — a fork PR must never execute on the LAN runner). Var unset/false # branches only — a fork PR must never execute on the LAN runner). Var unset/false
# or a fork PR falls back to ubuntu-latest, so this is inert until the flag flips. # or a fork PR falls back to ubuntu-latest, so this is inert until the flag flips.
# PINNED to hosted (gap 19). This job carried the USE_VPS_RUNNER expression, and that runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
# expression was DEAD CONFIGURATION: across 160 quality.yml runs the job never once landed on
# a self-hosted runner — every non-skipped sample is `GitHub Actions NNNN`. The classifier is
# not at fault: in the same window ci.yml's Build demonstrably ran on omniroute-113-7 and
# omniroute-113-6, so self-hosted runs are visible when they happen.
#
# And if it ever HAD fired it would have inherited the measured penalty, because this job's
# first two steps are exactly the bottleneck: actions/setup-node + npm ci took 20m06s on .113
# with 4 concurrent runners versus 16s hosted (npm cache restore saturating the link). Median
# here is 5.6 min hosted across 72 successful runs.
#
# With this pinned, USE_VPS_RUNNER governs ONLY build-like jobs — one variable, one coherent
# purpose. That is what gap 19 asked for; a second variable turned out to be unnecessary.
runs-on: ubuntu-latest
# tsx gates (known-symbols, route-guard-membership) import modules that open # tsx gates (known-symbols, route-guard-membership) import modules that open
# SQLite on load; provide DB env so a fresh CI DB initializes cleanly. # SQLite on load; provide DB env so a fresh CI DB initializes cleanly.
env: env:
@@ -177,11 +99,11 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
- uses: ./.github/actions/npm-ci-retry - run: npm ci
- name: Restore ESLint file cache - name: Restore ESLint file cache
uses: actions/cache@v6 uses: actions/cache@v6
with: with:
@@ -191,151 +113,36 @@ jobs:
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }} key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
restore-keys: | restore-keys: |
eslint-${{ runner.os }}- eslint-${{ runner.os }}-
# Security scanners — same hardened install as ci.yml quality-extended - run: npm run check:provider-consistency
# (gh release download = authenticated, 5000 req/hr; curl to api.github.com - run: npm run check:fetch-targets
# is rate-limited to 60/hr and silently no-ops when throttled). The blocking # docs-all / openapi-routes / docs-symbols live in docs-gates (path-filtered).
# gates below SKIP (exit 0) when their binary is absent — only a measured - run: npm run check:deps
# regression vs config/quality/quality-baseline.json blocks. - run: npm run check:file-size
- name: Install security scanners (gitleaks/osv/actionlint/zizmor/oasdiff) - run: npm run check:error-helper
continue-on-error: true - run: npm run check:migration-numbering
env: - run: npm run check:public-creds
GH_TOKEN: ${{ github.token }} - run: npm run check:db-rules
run: | - run: npm run check:known-symbols
set +e - run: npm run check:route-guard-membership
mkdir -p "$HOME/.local/bin" - run: npm run check:test-discovery
# Ratchets compare scanner COUNTS across runs. Pin every auditor: a rule-set - run: npm run check:test-runner-api
# update must be an explicit PR that re-measures/rebaselines, never a random # Guards tap.testFiles drift: a covering unit test absent from stryker.conf.json
# red (or green) caused by whatever "latest" served that morning. # tap.testFiles makes its module's mutants survive on a cold nightly-mutation run,
GITLEAKS_VERSION=v8.30.1 # false-failing the blocking mutationScore ratchet. See check-mutation-test-coverage.mjs.
OSV_SCANNER_VERSION=v2.3.8 - run: npm run check:mutation-test-coverage
ACTIONLINT_VERSION=v1.7.12 - run: npm run check:any-budget:t11
ZIZMOR_VERSION=1.25.2 # Build-scope guard: fails if worktrees/cruft leak into the tsconfig include
OASDIFF_VERSION=v1.19.1 # scope (would OOM `next build`). Instant. See incident 2026-06-25 / #5031.
# gitleaks — pinned linux x64 tarball via gh (authed), extract binary - run: npm run check:build-scope
rm -rf /tmp/gl && mkdir -p /tmp/gl # Pack-policy (unexpected-files allowlist) WITHOUT a build — catches a stray file
gh release download "$GITLEAKS_VERSION" --repo gitleaks/gitleaks --pattern '*linux_x64.tar.gz' --dir /tmp/gl # leaking into the npm tarball (v3.8.36: 6 ops bin/*.sh) per-PR instead of only on
tar -xzf /tmp/gl/*linux_x64.tar.gz -C "$HOME/.local/bin" gitleaks # the release PR's heavy Package Artifact job.
# osv-scanner — pinned linux amd64 bare binary via gh (authed) - run: npm run check:pack-policy
rm -rf /tmp/osv && mkdir -p /tmp/osv # Complexity + cognitive-complexity: ONE ESLint walk (both baselines still
gh release download "$OSV_SCANNER_VERSION" --repo google/osv-scanner --pattern '*linux_amd64' --dir /tmp/osv # enforced separately by ruleId). Avoids two cold tree walks on fast-path.
install -m 0755 /tmp/osv/*linux_amd64 "$HOME/.local/bin/osv-scanner" - run: npm run check:complexity-ratchets
# actionlint — official installer from a pinned release tag (never main) - name: Typecheck (core)
bash <(curl -fsSL "https://raw.githubusercontent.com/rhysd/actionlint/${ACTIONLINT_VERSION}/scripts/download-actionlint.bash") "$ACTIONLINT_VERSION" "$HOME/.local/bin" run: npm run typecheck:core
# zizmor — pinned PyPI package (same version as ci.yml quality-extended)
pipx install "zizmor==$ZIZMOR_VERSION" || pip install --user "zizmor==$ZIZMOR_VERSION"
# oasdiff — pinned linux amd64 tarball via gh (authed), extract binary
rm -rf /tmp/oasd && mkdir -p /tmp/oasd
gh release download "$OASDIFF_VERSION" --repo Tufin/oasdiff --pattern '*linux_amd64.tar.gz' --dir /tmp/oasd
tar -xzf /tmp/oasd/*linux_amd64.tar.gz -C "$HOME/.local/bin" oasdiff
# ALWAYS export the bin dir (even if any step above failed)
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
"$HOME/.local/bin/gitleaks" version || true
"$HOME/.local/bin/actionlint" -version || true
"$HOME/.local/bin/osv-scanner" --version || true
"$HOME/.local/bin/oasdiff" --version || true
zizmor --version || true
- name: Forgotten sibling tests (advisory)
env:
GITHUB_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
node scripts/quality/build-test-impact-map.mjs
node scripts/check/check-forgotten-sibling-tests.mjs \
--summary-file forgotten-sibling-tests.md \
--json-file forgotten-sibling-tests.json
cat forgotten-sibling-tests.md >> "$GITHUB_STEP_SUMMARY"
- name: Upload forgotten sibling report
if: always()
uses: actions/upload-artifact@v7
with:
name: forgotten-sibling-tests
path: |
forgotten-sibling-tests.md
forgotten-sibling-tests.json
if-no-files-found: ignore
retention-days: 30
# Quality gates (all, non-fail-fast) — #8542: replaces 17 bare check:* steps,
# 6 G0 gates, 4 ratchet gates, and 3 typecheck steps with a single aggregation
# step. Each gate runs in a loop with ::group::; failures are collected and
# reported at the end. set -uo pipefail (NOT set -e) so one failing gate does
# not abort the job and mask every later gate. Release-added gates are folded
# in: open-sse typecheck (#8781) and file-size base-relative mode (#8522).
- name: Quality gates (all, non-fail-fast)
env:
# #8522: base-relative file-size mode on PR events — inherited drift (base
# already over frozen cap) must not red an innocent PR. Unset on
# workflow_dispatch (no PR base) → absolute comparison.
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}
run: |
set -uo pipefail
gates=(
provider-consistency fetch-targets deps file-size error-helper
migration-numbering public-creds db-rules known-symbols
route-guard-membership test-discovery test-runner-api
mutation-test-coverage any-budget:t11 build-scope pack-policy
complexity-ratchets
cycles lockfile duplication dead-code type-coverage compression-budget
# #8781: open-sse workspace typecheck gate — the workspace imports @/ which
# escapes to src/ via undeclared path aliases. See check-open-sse-typecheck.mjs.
open-sse-typecheck
)
ratchet_gates=(
secrets vuln-ratchet workflows openapi-breaking
)
failed=()
for g in "${gates[@]}"; do
echo "::group::check:$g"
# #8522: file-size is base-relative on PR events (compare against
# max(frozen, base)) so inherited drift doesn't red an innocent PR;
# workflow_dispatch (no PR base) falls back to absolute comparison.
if [ "$g" = "file-size" ] && [ -n "${PR_BASE_SHA:-}" ]; then
npm run "check:$g" -- --base-ref "$PR_BASE_SHA" || failed+=("$g")
else
npm run "check:$g" || failed+=("$g")
fi
echo "::endgroup::"
done
for g in "${ratchet_gates[@]}"; do
echo "::group::check:$g (ratchet)"
npm run "check:$g" -- --ratchet || failed+=("$g")
echo "::endgroup::"
done
echo "::group::typecheck:core"
npm run typecheck:core || failed+=("typecheck:core")
echo "::endgroup::"
echo "::group::check:dashboard-typecheck"
npm run check:dashboard-typecheck || failed+=("check:dashboard-typecheck")
echo "::endgroup::"
# #10134: TS7 zero-new-diagnostics ratchet — folded into this non-fail-fast
# loop (never a separate blocking step) so an earlier red gate cannot abort
# the job and mask it (#8542 mechanism). PR-only: the base-relative
# comparison needs the PR base SHA (empty on workflow_dispatch).
if [ -n "${PR_BASE_SHA:-}" ]; then
echo "::group::check:ts7-diagnostics-ratchet"
npm run check:ts7-diagnostics-ratchet -- --base-ref "$PR_BASE_SHA" || failed+=("ts7-diagnostics-ratchet")
echo "::endgroup::"
fi
if (( ${#failed[@]} )); then
printf '::error::%d gate(s) failed: %s\n' "${#failed[@]}" "${failed[*]}"
exit 1
fi
# WS4.2 (v3.8.49 plan): TypeScript 7 native-compiler SHADOW — advisory only.
# TS7 went GA 2026-07-08 with 8-12x type-check speedups; its Compiler API only
# arrives in 7.1, so typescript-eslint / type-coverage / Stryker stay on 6.x
# (the hybrid is the officially documented pattern). Isolated npx on purpose:
# installing an alias package could collide node_modules/.bin/tsc with 6.x.
# The full result stays advisory while #8484 has a backlog. The blocking
# base-relative ratchet (folded into the non-fail-fast gates step above)
# rejects only diagnostics added by the PR, so existing release debt does
# not block unrelated work.
- name: Typecheck (core) — TS7 native shadow (advisory)
continue-on-error: true
run: |
RC=0
START=$(date +%s)
npm exec --yes --package=typescript@7.0.2 -- tsc --pretty false -p tsconfig.typecheck-core.json || RC=$?
echo "[ts7-shadow] exit=$RC elapsed=$(( $(date +%s) - START ))s — the 6.x step above stays authoritative"
exit $RC
# TIA: build the impact map at runtime (gitignored, ~21MB) and run only the # TIA: build the impact map at runtime (gitignored, ~21MB) and run only the
# unit tests impacted by this PR's changed files. On hub/unmapped changes the # unit tests impacted by this PR's changed files. On hub/unmapped changes the
# selector returns __RUN_ALL__ — full-suite authority is the parallel # selector returns __RUN_ALL__ — full-suite authority is the parallel
@@ -350,29 +157,19 @@ jobs:
GITHUB_BASE_REF: ${{ github.base_ref }} GITHUB_BASE_REF: ${{ github.base_ref }}
run: | run: |
git fetch --no-tags origin "$GITHUB_BASE_REF" || true git fetch --no-tags origin "$GITHUB_BASE_REF" || true
# The advisory sibling-test step generates the same map earlier in this job. node scripts/quality/build-test-impact-map.mjs
[ -f config/quality/test-impact-map.json ] || node scripts/quality/build-test-impact-map.mjs
SEL="$(node scripts/quality/select-impacted-tests.mjs)" SEL="$(node scripts/quality/select-impacted-tests.mjs)"
# Shadow evidence (#8084): persist every selection so TIA false negatives can if [ -z "$SEL" ]; then echo "No source/test changes — skipping unit tests"; exit 0; fi
# be measured against fast-unit's full-suite verdict across releases BEFORE
# any gate authority moves off ordinary PRs. Artifact uploaded below.
printf '%s\n' "$SEL" > tia-selection.txt
if [ -z "$SEL" ]; then
echo "TIA selection: empty (no source/test changes)" >> "$GITHUB_STEP_SUMMARY"
echo "No source/test changes — skipping unit tests"; exit 0
fi
# CI runners are 4-vCPU; run at --test-concurrency=4 (matching the ci.yml unit # CI runners are 4-vCPU; run at --test-concurrency=4 (matching the ci.yml unit
# job) rather than test:unit's local-tuned concurrency=20. Oversubscribing the # job) rather than test:unit's local-tuned concurrency=20. Oversubscribing the
# runner makes timing-sensitive tests (db-backup, upstream-timeout, ...) flake, # runner makes timing-sensitive tests (db-backup, upstream-timeout, ...) flake,
# which must not happen on a blocking gate. DATA_DIR isolation keeps the parallel # which must not happen on a blocking gate. DATA_DIR isolation keeps the parallel
# run race-free regardless of concurrency. # run race-free regardless of concurrency.
if echo "$SEL" | grep -q "__RUN_ALL__"; then if echo "$SEL" | grep -q "__RUN_ALL__"; then
echo "TIA selection: __RUN_ALL__ (fail-safe) — full-suite authority stays with fast-unit" >> "$GITHUB_STEP_SUMMARY"
echo "Fail-safe: __RUN_ALL__ — deferring FULL unit suite to fast-unit (4-shard)." echo "Fail-safe: __RUN_ALL__ — deferring FULL unit suite to fast-unit (4-shard)."
echo "Not re-running unsharded test:unit:ci here (duplicate of fast-unit coverage)." echo "Not re-running unsharded test:unit:ci here (duplicate of fast-unit coverage)."
exit 0 exit 0
fi fi
echo "TIA selection: $(grep -c . tia-selection.txt) impacted test file(s) — full suite still runs in fast-unit (shadow-evidence phase, #8084)" >> "$GITHUB_STEP_SUMMARY"
echo "Running impacted tests:"; echo "$SEL" echo "Running impacted tests:"; echo "$SEL"
mapfile -t FILES <<< "$SEL" mapfile -t FILES <<< "$SEL"
# Loader parity with test:unit:ci:shard (#6787): tests/unit/dashboard/** runs # Loader parity with test:unit:ci:shard (#6787): tests/unit/dashboard/** runs
@@ -395,29 +192,13 @@ jobs:
node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${DASH[@]}" || RC=$? node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${DASH[@]}" || RC=$?
fi fi
exit $RC exit $RC
# #8084 shadow evidence: keep the raw selection downloadable so TIA misses can be
# audited against fast-unit failures on the same run (gate moves need this data).
- name: Upload TIA selection (shadow evidence)
if: always()
uses: actions/upload-artifact@v7
with:
name: tia-selection
path: tia-selection.txt
if-no-files-found: ignore
retention-days: 30
fast-vitest: fast-vitest:
name: Vitest (fast-path) name: Vitest (fast-path)
needs: changes needs: changes
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }} if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest). # Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
# PINNED to hosted, deliberately not on the USE_VPS_RUNNER switch (gap 19). One variable runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
# governed the build and the test jobs, which want OPPOSITE machines: the build needs the
# .113's RAM, the tests need the hosted runner's link. Measured on 2026-07-29 —
# actions/setup-node took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm
# cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So
# self-hosted is strictly worse here and there is nothing to configure.
runs-on: ubuntu-latest
env: env:
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-lint-api-key-secret-long API_KEY_SECRET: ci-lint-api-key-secret-long
@@ -426,40 +207,23 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
- uses: ./.github/actions/npm-ci-retry - run: npm ci
# WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast-path runs on EVERY PR, - run: npm run test:vitest
# which is where flaky-detection volume actually comes from (ci.yml's heavy
# jobs only run on the release PR). Advisory upload, own-origin only.
- run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-fastpath.xml
- name: Upload test results to Trunk (advisory)
if: ${{ always() && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) }}
continue-on-error: true
uses: trunk-io/analytics-uploader@385f1ccdf345b4532dc4b6c665dd432b702b8e28 # v2.1.2
with:
junit-paths: trunk-junit/**/*.xml
org-slug: omniroute
token: ${{ secrets.TRUNK_TOKEN }}
fast-unit: fast-unit:
name: Unit Tests fast-path (${{ matrix.shard }}/4) name: Unit Tests fast-path (${{ matrix.shard }}/4)
needs: changes needs: changes
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }} if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
# Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest). # Dynamic runner — see fast-gates (own-origin + flag; fork/unset → ubuntu-latest).
# This is the heaviest fast-path job; 4-way sharding (was 2, #6781) halves the # This is the heaviest fast-path job; 4-way sharding (was 2, #6781) halves the
# critical path again (~8.5min → ~4.5min on ubuntu-latest; ~2min on the 8-slot # critical path again (~8.5min → ~4.5min on ubuntu-latest; ~2min on the 8-slot
# runner box). Node's native --test-shard=N/total takes any denominator — only # runner box). Node's native --test-shard=N/total takes any denominator — only
# this matrix and the TEST_SHARD env below encode the shard count. # this matrix and the TEST_SHARD env below encode the shard count.
# PINNED to hosted, deliberately not on the USE_VPS_RUNNER switch (gap 19). One variable runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
# governed the build and the test jobs, which want OPPOSITE machines: the build needs the
# .113's RAM, the tests need the hosted runner's link. Measured on 2026-07-29 —
# actions/setup-node took 20m06s on .113 with 4 concurrent runners versus 16s hosted (npm
# cache restore saturating the link), while the tests themselves tied, 2m54 vs 2m31. So
# self-hosted is strictly worse here and there is nothing to configure.
runs-on: ubuntu-latest
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -472,11 +236,11 @@ jobs:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
- uses: ./.github/actions/npm-ci-retry - run: npm ci
# QW-d: fonte única — o mesmo npm script do CI pesado/local. Fecha dois drifts do # QW-d: fonte única — o mesmo npm script do CI pesado/local. Fecha dois drifts do
# comando inline antigo: os dirs `memory` e `usage` estavam FORA do glob (testes # comando inline antigo: os dirs `memory` e `usage` estavam FORA do glob (testes
# silenciosamente não rodavam no fast path) e o setupPolyfill não era importado. # silenciosamente não rodavam no fast path) e o setupPolyfill não era importado.
@@ -499,24 +263,18 @@ jobs:
lint-guard: lint-guard:
name: No new ESLint warnings name: No new ESLint warnings
needs: changes needs: changes
if: ${{ github.event_name != 'pull_request' || ((github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) && needs.changes.outputs.code == 'true') }} if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.code == 'true') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }} continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
# G0 (trilho .50): security-events:read lets the CodeQL ratchet below read open
# code-scanning alerts via `gh api .../code-scanning/alerts` (same as ci.yml's
# quality-gate job). contents: read keeps checkout working.
permissions:
contents: read
security-events: read
steps: steps:
- uses: actions/checkout@v7 - uses: actions/checkout@v7
with: with:
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@v7 - uses: actions/setup-node@v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
- uses: ./.github/actions/npm-ci-retry - run: npm ci
- name: Restore ESLint file cache - name: Restore ESLint file cache
uses: actions/cache@v6 uses: actions/cache@v6
with: with:
@@ -529,29 +287,6 @@ jobs:
- name: ESLint (baseline congelado — warning novo = vermelho) - name: ESLint (baseline congelado — warning novo = vermelho)
# lint:json writes the report; --max-warnings 0 keeps no-new-warnings policy. # lint:json writes the report; --max-warnings 0 keeps no-new-warnings policy.
run: npm run lint:json -- --max-warnings 0 run: npm run lint:json -- --max-warnings 0
# ── G0 (trilho .50): motor de ratchet também no trilho B ─────────────────────
# This job just wrote .artifacts/eslint-results.json — collect-metrics prefers
# that file, so the ratchet engine lands here at ZERO extra ESLint cost (one
# inventory, two consumers; same reason ci.yml chains lint → quality-gate).
# The coverage-report artifact does not exist on this rail, so both ratchet
# invocations run --allow-missing: coverage.* metrics skip gracefully while
# the deterministic ones (eslint / openapi-coverage / i18n-ui) stay BLOCKING.
# Coverage authority remains on the main rail (ci.yml test-coverage → quality-gate).
- run: npm run quality:collect
- name: Ratchet check (blocking)
run: node scripts/quality/check-quality-ratchet.mjs --allow-missing --summary .artifacts/quality-ratchet.md
- name: Require-tighten (blocking)
run: node scripts/quality/check-quality-ratchet.mjs --allow-missing --require-tighten
# CodeQL alerts ratchet — same semantics as ci.yml quality-gate: exits 1 ONLY
# on a real regression (open alerts > baseline in quality-baseline.json);
# a measurement failure (gh/auth/api) self-skips with exit 0.
- name: CodeQL alerts ratchet (blocking)
run: npm run check:codeql-ratchet
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Append ratchet summary
if: always()
run: cat .artifacts/quality-ratchet.md >> "$GITHUB_STEP_SUMMARY" || true
# Merge-integrity: pega no PR os dois vazamentos crônicos de merge que hoje só # Merge-integrity: pega no PR os dois vazamentos crônicos de merge que hoje só
# explodem na release-PR. (1) CHANGELOG-eat — o auto-resolve do merge come # explodem na release-PR. (1) CHANGELOG-eat — o auto-resolve do merge come
@@ -567,7 +302,7 @@ jobs:
merge-integrity: merge-integrity:
name: Merge integrity (changelog + generated skills) name: Merge integrity (changelog + generated skills)
# Always on non-draft PRs — CHANGELOG/skills can break on docs-only merges too. # Always on non-draft PRs — CHANGELOG/skills can break on docs-only merges too.
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false || startsWith(github.head_ref, 'mergify/merge-queue/')) }} if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }} continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
env: env:
@@ -575,15 +310,15 @@ jobs:
API_KEY_SECRET: ci-lint-api-key-secret-long API_KEY_SECRET: ci-lint-api-key-secret-long
DISABLE_SQLITE_AUTO_BACKUP: "true" DISABLE_SQLITE_AUTO_BACKUP: "true"
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with: with:
node-version: ${{ env.CI_NODE_VERSION }} node-version: ${{ env.CI_NODE_VERSION }}
cache: npm cache: npm
- uses: ./.github/actions/npm-ci-retry - run: npm ci
- name: CHANGELOG integrity (nenhum bullet da base pode sumir no merge-result) - name: CHANGELOG integrity (nenhum bullet da base pode sumir no merge-result)
run: npm run check:changelog-integrity run: npm run check:changelog-integrity
- name: Agent-skills generator sync (SKILL.md gerado ≡ catálogo) - name: Agent-skills generator sync (SKILL.md gerado ≡ catálogo)

View File

@@ -26,7 +26,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Run analysis - name: Run analysis
uses: ossf/scorecard-action@v2.4.4 uses: ossf/scorecard-action@v2.4.3
with: with:
results_file: results.sarif results_file: results.sarif
results_format: sarif results_format: sarif

View File

@@ -6,19 +6,13 @@ on:
branches: ["main"] branches: ["main"]
permissions: permissions:
contents: read contents: read
# Cancel superseded PR runs (same rule as quality.yml). No paths filter on purpose:
# p/secrets must keep scanning docs-only diffs too — credentials leak in .md files.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs: jobs:
semgrep: semgrep:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: semgrep/semgrep image: semgrep/semgrep
steps: steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with: with:
persist-credentials: false persist-credentials: false
- name: Run semgrep (advisory) - name: Run semgrep (advisory)

View File

@@ -40,7 +40,7 @@ jobs:
uses: actions/checkout@v7 uses: actions/checkout@v7
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v7 uses: actions/setup-node@v6
with: with:
node-version: "24" node-version: "24"

68
.gitignore vendored
View File

@@ -1,8 +1,6 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# project-specific directories # project-specific directories
/output/
.slim/deepwork/
.omnivscodeagent/ .omnivscodeagent/
omnirouteCloud/ omnirouteCloud/
omnirouteSite/ omnirouteSite/
@@ -19,7 +17,7 @@ _tasks/
.logs/** .logs/**
.tests/** .tests/**
.coverage/** .coverage/**
/coverage/ coverage/
.dist/** .dist/**
.next/** .next/**
.build/** .build/**
@@ -45,7 +43,6 @@ memory-bank/
# Root-level underscore-prefixed directories (private/draft — never commit) # Root-level underscore-prefixed directories (private/draft — never commit)
/_*/ /_*/
/_*
# Draft features documentation (internal only) # Draft features documentation (internal only)
docs/new-features/ docs/new-features/
@@ -59,6 +56,10 @@ node_modules/
*.map *.map
.DS_Store .DS_Store
# Obsidian sync plugin — committed for community distribution
!obsidian-plugin/
obsidian-plugin/node_modules/
# Serena AI assistant config (local-only tool, not project code) # Serena AI assistant config (local-only tool, not project code)
.serena/ .serena/
@@ -70,11 +71,7 @@ yarn-error.log*
# env files (can opt-in for committing if needed) # env files (can opt-in for committing if needed)
.env* .env*
# Local gitleaks artifacts (do not commit)
gitleaks-local.json
!.env.example !.env.example
!.env.homolog.example
!.env.devin-bridge.example
# Provider API keys (never commit) # Provider API keys (never commit)
*.api-key *.api-key
.nvidia-api-key .nvidia-api-key
@@ -87,7 +84,7 @@ gitleaks-local.json
next-env.d.ts next-env.d.ts
# data and logs # data and logs
/data/ data/
.data/ .data/
logs/* logs/*
test_output.log test_output.log
@@ -109,7 +106,7 @@ open-sse/test/*
test-results/ test-results/
playwright-report/ playwright-report/
blob-report/ blob-report/
/cloud/ cloud/
.tmp/ .tmp/
# Security Analysis (standalone project with own git) # Security Analysis (standalone project with own git)
@@ -123,8 +120,6 @@ app.log
deploy.sh deploy.sh
docker-compose.minimal.yml docker-compose.minimal.yml
# Docker Compose override (local-only, never commit)
docker-compose.override.yml
# Backup directories # Backup directories
app.__qa_backup/ app.__qa_backup/
@@ -160,7 +155,6 @@ vscode-extension/
# Empty/dangling files # Empty/dangling files
typescript typescript
/MAX
# Gemini Antigravity agent data # Gemini Antigravity agent data
.gemini/ .gemini/
@@ -176,6 +170,7 @@ config/quality/test-impact-map.json
# GitNexus local index # GitNexus local index
.gitnexus .gitnexus
.worktrees .worktrees
bin/omniroute.mjs
# Consistent with .dockerignore / .npmignore # Consistent with .dockerignore / .npmignore
.omc/ .omc/
@@ -204,16 +199,13 @@ scripts/i18n/_pending-keys.json
.claude/worktrees/ .claude/worktrees/
.codegraph/ .codegraph/
# Test executable shims belong in the OS temporary directory, not the repository root
/.fakebin-*/
# Fumadocs generated source # Fumadocs generated source
.source/ .source/
# AI agent local settings and configs # AI agent local settings and configs
.agents/ .agents/
.antigravitycli/ .antigravitycli/
/.claude/ .claude/
# PR Reviews and local feedback files # PR Reviews and local feedback files
pr_reviews*.json pr_reviews*.json
@@ -228,26 +220,6 @@ CODEX-SETUP-PROMPT.md
# Quality ratchet — métricas efêmeras (baseline commitado em config/quality/; métricas não) # Quality ratchet — métricas efêmeras (baseline commitado em config/quality/; métricas não)
config/quality/quality-metrics.json config/quality/quality-metrics.json
# Electron desktop build output unpacked into the repo root.
# `electron-builder` (squirrel-windows target) unpacks the packaged app — the
# entire Chromium runtime, ~24k files — directly into the repository root.
# Every rule below is ROOT-ANCHORED (leading `/`) on purpose: a bare `locales/`
# or `resources/` would also swallow tracked sources such as the CLI
# translations in `bin/cli/locales/*.json`.
/OmniRoute.exe
/Uninstall OmniRoute.exe
/uninstallerIcon.ico
/locales/
/resources/
/*.pak
/*.dll
/icudtl.dat
/snapshot_blob.bin
/v8_context_snapshot.bin
/vk_swiftshader_icd.json
/LICENSE.electron.txt
/LICENSES.chromium.html
# Runtime logs (diretório local, nunca versionado) # Runtime logs (diretório local, nunca versionado)
/logs/ /logs/
-home-diegosouzapw-dev-automações-bots-yt-downloader-20260504 .txt -home-diegosouzapw-dev-automações-bots-yt-downloader-20260504 .txt
@@ -260,31 +232,13 @@ omniroute.md
# mise configuration # mise configuration
mise.toml mise.toml
_artifacts/ # release-green artifacts _artifacts/
.claude-flow/ .claude-flow/
# ESLint file cache (npm run lint --cache / complexity ratchets) # ESLint file cache (npm run lint --cache / complexity ratchets)
.eslintcache .eslintcache
.eslintcache-complexity .eslintcache-complexity
/.eslintcache-*
# CI/local quality artifacts (eslint-results.json, quality-ratchet.md, etc.) # CI/local quality artifacts (eslint-results.json, etc.)
.artifacts/ .artifacts/
/perf-audit*.md
/quality-ratchet/
# Homologation E2E suite (npm run homolog) — real-environment credentials + report output
.env.homolog
tests/homolog/.auth/
tests/homolog/ui/.auth/
homolog-report/
docker-compose.yml.bak
# _tasks e um repo git SEPARADO (ver AGENTS.md). A linha _tasks/ (com barra) NAO
# ignora um SYMLINK chamado _tasks; /_tasks (ancorado) cobre arquivo/symlink/dir na raiz
# e impede que um git add -A recapture o symlink (incidente 2026-08-08).
/_tasks
# CLI local cache/state
.playwright-cli

View File

@@ -74,23 +74,3 @@
# '''tests/unit/''', # '''tests/unit/''',
# ] # ]
# #
[[rules]]
# Falsos-positivos comprovados do generic-api-key — zerados em 2026-07-13 (WS6/D3,
# plano v3.8.49). Revisar em v3.9.0. Nenhum é credencial: dois são NOMES DE CAMPO
# de métricas de latência; o terceiro é o valor PÚBLICO de um beta header da API
# da Anthropic (documentado publicamente, não é segredo).
id = "generic-api-key"
[rules.allowlist]
description = "Field names + public Anthropic beta-header value (não são segredos)"
regexes = [
'''latencyP\d{2}Ms''',
'''interleaved-thinking-2025-05-14''',
# v3.8.49 pre-flight (2026-07-28). Nenhum dos dois e credencial:
# - chave de localStorage do banner de patrocinio (#8723; #10200 bumpou v1->v2,
# generalizado para -v\d+ no round 3 de base-reds #9985), so um identificador de UI;
# - x-api-key PUBLICO do Firefly web (documentado em open-sse/utils/publicCreds.ts:207);
# as duas ocorrencias sinalizadas estao em COMENTARIOS JSDoc, o runtime le de resolvePublicCred().
'''omniroute-kimi-sponsor-banner-dismissed-v\d+''',
'''SunbreakWebUI1''',
]

View File

@@ -1,74 +0,0 @@
# Mergify merge queue — WS3.4/D5 of the v3.8.49 quality/velocity master plan.
#
# WHY: ~85-100 active PR authors/month and 300+ PRs/week peaks, all merged by ONE
# identity. The manual merge-train validated batches by hand; this queue automates
# it with batching + automatic batch bisection (a red batch of N costs ~log2(N)
# revalidations instead of N). Mergify Open Source plan: free, unlimited, public repo.
#
# GOVERNANCE (non-negotiable, mirrors CLAUDE.md Hard Rules #21/#22 + the owner's
# pre-merge ⭐ gate):
# • A PR enters the queue ONLY via the `queue` label — applied by the owner (or a
# session acting for the owner) AFTER the pre-merge ⭐ report/decision. The label
# IS the merge approval; Mergify only executes it.
# • During a release-freeze (open issue labeled `release-freeze`), do NOT label PRs
# targeting the frozen branch — the freeze is a human-honored coordination signal
# the queue cannot see. Retarget to the active release/vX+1 first (Hard Rule #21).
# • Never label a PR another session is actively working (Hard Rule #22b).
# • Fallback path if Mergify misbehaves or the OSS plan changes: the manual
# merge-train runbook (docs/ops/MERGE_TRAIN.md) — remove labels, proceed by hand.
# Auto-enqueue (current Mergify model, 2026): auto_merge_conditions in
# merge_protections_settings — the rules-based queue action / autoqueue path is
# deprecated (EOL 2026-07-16). The owner-applied `queue` label IS the approval.
merge_protections_settings:
auto_merge_conditions:
- label = queue
queue_rules:
- name: release
# Any current or future release branch — the reason GitHub's native queue was
# rejected (no wildcard support on personal-account repos).
queue_conditions:
- base~=^release/v\d+\.\d+\.\d+$
- label=queue
- -draft
- -conflict
# "Everything that ran is green, nothing still running, AND the always-on
# anchor check succeeded" — robust to the path-filtered fast-gates (docs-only
# PRs skip code jobs; matrix shard names vary) while never fail-open: a PR with
# zero checks cannot vacuously merge, because `Merge integrity` runs on EVERY
# non-draft PR (quality.yml) and must be an affirmative success. Review approval
# is intentionally NOT a condition here: the owner-applied `queue` label IS the
# approval in this repo's single-maintainer model (see governance header).
merge_conditions:
# "Zero failures" — EXCEPT the advisory "Build (advisory)" job (quality.yml):
# continue-on-error by design, and its GH-hosted Turbopack build hangs
# recurrently mid-"Creating an optimized production build" (100% failure rate
# across every sampled PR since the job was added 2026-07-27, always killed by
# a runner timeout/shutdown signal, never a real compile error). Any OTHER
# failure still blocks (anti-fail-open kept). The prior dast-smoke exception
# (#7225) was dropped here: dast-smoke's hang (#7226) has been dormant for
# weeks (0 failures in the last 30 runs; 2 all-time, none since 2026-07-13) —
# carrying its tolerance forward would mask problems it no longer causes.
- or:
- "#check-failure=0"
- and:
- "#check-failure=1"
- check-failure=Build (advisory)
- "#check-pending=0"
- "#check-success>=1"
- check-success=Merge integrity (changelog + generated skills)
# NO batching: 'Merge Queue Batch' requires a paid Mergify tier (live finding
# 2026-07-15 — the queue command fails with "Cannot use Merge Queue batch" on
# the free plan). Serial queue (1 PR at a time) still automates the train.
# Squash keeps the one-commit-per-PR history the CHANGELOG reconciliation expects.
merge_method: squash
pull_request_rules:
- name: clean up the queue label after merge
conditions:
- merged
actions:
label:
remove:
- queue

View File

@@ -4,14 +4,11 @@ data/
**/db.json **/db.json
# VS Code extension test runtime (large binary, not needed in npm package) # VS Code extension test runtime (large binary, not needed in npm package)
app/vscode-extension/
**/data/ **/data/
**/db.json **/db.json
# Source code (pre-built dist/ is published instead) # Source code (pre-built app/ is published instead)
#
# NOTA (2026-08-05): as entradas `app/*` foram removidas — o diretorio `app/`
# foi renomeado para `dist/` na Layer 1 e nao existe mais. Elas sugeriam um
# layout que ja nao e o do projeto.
# #
# NOTE (#3578 / #3821-review): package.json "files" is the source of truth for what # NOTE (#3578 / #3821-review): package.json "files" is the source of truth for what
# ships. It now allowlists the backend source closure the MCP server needs at runtime # ships. It now allowlists the backend source closure the MCP server needs at runtime
@@ -52,6 +49,8 @@ scripts/
.vscode/ .vscode/
.agents/ .agents/
.env* .env*
app/.env
app/.env*
eslint.config.mjs eslint.config.mjs
prettier.config.mjs prettier.config.mjs
postcss.config.mjs postcss.config.mjs
@@ -83,6 +82,8 @@ bun.lock
*.deb *.deb
*.rpm *.rpm
electron/ electron/
app/electron/
app/vscode-extension/
# Subprojects # Subprojects
clipr/ clipr/
@@ -92,12 +93,12 @@ vscode-extension/
# Root-level underscore-prefixed directories (private/draft — never publish) # Root-level underscore-prefixed directories (private/draft — never publish)
/_*/ /_*/
app/_*/
app/coverage/
app/logs/
app/tests/
# Consistent with .gitignore and .dockerignore # Consistent with .gitignore and .dockerignore
.claude/
.fakebin-*
.eslintcache*
_tasks/
.DS_Store .DS_Store
.idea/ .idea/
.config/ .config/

View File

@@ -1,24 +1,5 @@
# Long reference tables are manually aligned; formatting the whole file causes noisy diffs. # Long reference tables are manually aligned; formatting the whole file causes noisy diffs.
docs/reference/ENVIRONMENT.md docs/reference/ENVIRONMENT.md
# Generated by `npm run gen:provider-reference`; the generator aligns the tables and
# is their formatter of record. Without this, lint-staged reformats the file whenever
# it is staged and the next generator run reverts it — a diff ping-pong.
docs/reference/PROVIDER_REFERENCE.md
# Dense auto-generated free-tier budget rows (one object per line) — prettier multi-line expand blows past file-size cap 800. # Dense auto-generated free-tier budget rows (one object per line) — prettier multi-line expand blows past file-size cap 800.
open-sse/config/freeModelCatalog.data.ts open-sse/config/freeModelCatalog.data.ts
# Generated by scripts/skills/generate-agent-skills.mjs; the generator is their
# formatter of record and check:agent-skills-sync diffs its output byte-for-byte.
# Prettier reformats the frontmatter (blank line after ---), which makes the gate
# fail on any skill that happens to pass through lint-staged.
skills/*/SKILL.md
# check:changelog-integrity compares release bullets against the base as exact
# strings. Prettier normalizes markdown emphasis inside them (*from* -> _from_)
# and re-wraps table rows, so any PR that stages CHANGELOG.md would "lose" base
# bullets and turn the merge-integrity job red. The changelog is generated and
# reconciled by scripts/release/*, which are its formatter of record.
CHANGELOG.md
docs/i18n/*/CHANGELOG.md

8
.source/dynamic.ts Normal file
View File

@@ -0,0 +1,8 @@
// @ts-nocheck
import { dynamic } from 'fumadocs-mdx/runtime/dynamic';
import * as Config from '../source.config';
const create = await dynamic<typeof Config, import("fumadocs-mdx/runtime/types").InternalTypeConfig & {
DocData: {
}
}>(Config, {"configPath":"source.config.ts","environment":"next","outDir":".source"}, {"doc":{"passthroughs":["extractedReferences"]}});

22
.source/source.config.mjs Normal file
View File

@@ -0,0 +1,22 @@
// source.config.ts
import { defineDocs, defineConfig } from "fumadocs-mdx/config";
var docs = defineDocs({
dir: "docs",
docs: {
files: [
"./architecture/**/*.md",
"./guides/**/*.md",
"./reference/**/*.md",
"./frameworks/**/*.md",
"./routing/**/*.md",
"./security/**/*.md",
"./compression/**/*.md",
"./ops/**/*.md"
]
}
});
var source_config_default = defineConfig();
export {
source_config_default as default,
docs
};

View File

@@ -10,8 +10,7 @@ This plugin solves that by:
- Fetching `/v1/models` and `/api/combos` **at OpenCode startup, in Node.js** — no CORS, no WebView restrictions - Fetching `/v1/models` and `/api/combos` **at OpenCode startup, in Node.js** — no CORS, no WebView restrictions
- Emitting the provider block **dynamically** in the plugin's `config`/`provider` hook — so `opencode.json` only needs the plugin entry, not a static `provider.omniroute` - Emitting the provider block **dynamically** in the plugin's `config`/`provider` hook — so `opencode.json` only needs the plugin entry, not a static `provider.omniroute`
- Re-fetching on a configurable TTL (default 5 min) **and** background auto-discovery while OpenCode is running (`autoSyncIntervalMs`, default 5 min), so new models / combo changes appear without restarting OpenCode - Re-fetching on a configurable TTL (default 5 min), so new models / combo changes in the OmniRoute UI appear without restarting OpenCode
- Exposing a force-refresh path (`omniroute_sync_models` tool + `/omni-sync` command template) equivalent to Pi `/omni sync`
- Computing `limit.context` for combos as `min(member.context_length)` from the live catalog (no more `null` values that cause 4K-token truncation) - Computing `limit.context` for combos as `min(member.context_length)` from the live catalog (no more `null` values that cause 4K-token truncation)
- **Auto-pickup of `interleaved` capability** for thinking models (merged via PR #3138) - **Auto-pickup of `interleaved` capability** for thinking models (merged via PR #3138)
@@ -30,7 +29,7 @@ omniroute setup opencode --auth
# 3. Restart OpenCode — /models lists the full live catalog # 3. Restart OpenCode — /models lists the full live catalog
``` ```
The `--auth` flag runs `opencode auth login --provider opencode-omniroute` automatically. The `--auth` flag runs `opencode auth login --provider omniroute` automatically.
Use `--base-url` to point at a non-default OmniRoute address: Use `--base-url` to point at a non-default OmniRoute address:
```sh ```sh
@@ -74,9 +73,6 @@ Peer dep: `@opencode-ai/plugin` (managed by your OpenCode install).
{ {
"providerId": "omniroute", "providerId": "omniroute",
"baseURL": "https://or.example.com", "baseURL": "https://or.example.com",
// Background re-discovery while OpenCode is running (Pi parity).
// Default 300000 (5 min). Minimum 60000. Set 0 to disable.
"autoSyncIntervalMs": 300000,
}, },
], ],
], ],
@@ -84,7 +80,7 @@ Peer dep: `@opencode-ai/plugin` (managed by your OpenCode install).
``` ```
```sh ```sh
opencode auth login --provider opencode-omniroute opencode auth login --provider omniroute
# prompts for the OmniRoute API key, writes to ~/.local/share/opencode/auth.json # prompts for the OmniRoute API key, writes to ~/.local/share/opencode/auth.json
``` ```
@@ -92,27 +88,6 @@ opencode auth login --provider opencode-omniroute
Restart OpenCode. `/models` lists the full live catalog. Variants (`-low`, `-medium`, `-high`, `-thinking`) and combos appear as first-class IDs — OmniRoute is the source of truth, no client-side synthesis. Restart OpenCode. `/models` lists the full live catalog. Variants (`-low`, `-medium`, `-high`, `-thinking`) and combos appear as first-class IDs — OmniRoute is the source of truth, no client-side synthesis.
### Live catalog refresh (auto + force)
While OpenCode is running, the plugin keeps the model catalog fresh in two ways:
| Mechanism | Default | What it does |
| --- | --- | --- |
| `modelCacheTtl` | `300000` (5 min) | On-demand TTL: next provider/models hook after expiry re-fetches `/v1/models` |
| `autoSyncIntervalMs` | `300000` (5 min) | Background timer: proactively invalidates + re-fetches while the harness is running. Min `60000`. Set `0` to disable background polling (TTL still applies) |
**Force sync now** (Pi `/omni sync` equivalent) — OpenCode has no Pi-style slash-command registration API, so the plugin wires both a tool and command templates:
1. **Tool:** `omniroute_sync_models` — invalidates in-memory + disk caches, re-fetches `GET /v1/models` (and combos/enrichment when enabled), returns `{ ok, count, ... }`.
2. **Command templates** (type these in OpenCode):
- `/omni-sync` — asks the agent to call `omniroute_sync_models` and report the result
- `/omni-autosync` — asks the agent to report current `autoSyncIntervalMs` / `modelCacheTtl` status
```text
/omni-sync
/omni-autosync
```
## Multi-instance (prod + preprod side-by-side) ## Multi-instance (prod + preprod side-by-side)
> ⚠ OC ≤1.15.5 dedupes plugin loads by absolute module path. Two `plugin:` entries pointing at the same `dist/index.js` collapse into one (last-listed options win). Workaround: install the plugin twice into separate directories so each entry resolves to a distinct module file. v0.2.x will introduce an `instances: [...]` shape that registers N providers from a single load. > ⚠ OC ≤1.15.5 dedupes plugin loads by absolute module path. Two `plugin:` entries pointing at the same `dist/index.js` collapse into one (last-listed options win). Workaround: install the plugin twice into separate directories so each entry resolves to a distinct module file. v0.2.x will introduce an `instances: [...]` shape that registers N providers from a single load.
@@ -164,8 +139,8 @@ Then in `~/.config/opencode/opencode.json` reference each directory by absolute
Paths are relative to `~/.config/opencode/`. Each entry now resolves to a distinct module file, so OC loads them as two separate plugin instances. Authenticate each: Paths are relative to `~/.config/opencode/`. Each entry now resolves to a distinct module file, so OC loads them as two separate plugin instances. Authenticate each:
```sh ```sh
opencode auth login --provider opencode-omniroute opencode auth login --provider omniroute
opencode auth login --provider opencode-omniroute-preprod opencode auth login --provider omniroute-preprod
``` ```
Each entry gets its own provider id, its own model picker entry, its own slot in `auth.json`, and its own TTL cache. Closures are isolated per plugin instance — no cross-talk. Each entry gets its own provider id, its own model picker entry, its own slot in `auth.json`, and its own TTL cache. Closures are isolated per plugin instance — no cross-talk.
@@ -190,13 +165,12 @@ npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prepro
| Dynamic `/v1/models` | Pulls live catalog (455+ entries on prod) on each refresh, TTL-cached | `provider.models` | | Dynamic `/v1/models` | Pulls live catalog (455+ entries on prod) on each refresh, TTL-cached | `provider.models` |
| Variants pass-through | `-low`/`-medium`/`-high`/`-thinking` ship as first-class IDs from OmniRoute (no client synthesis) | `provider.models` | | Variants pass-through | `-low`/`-medium`/`-high`/`-thinking` ship as first-class IDs from OmniRoute (no client synthesis) | `provider.models` |
| Combo LCD aggregation | Combos appear with intersected capabilities + min context/output across members | `provider.models` + `config` | | Combo LCD aggregation | Combos appear with intersected capabilities + min context/output across members | `provider.models` + `config` |
| `combo/<slug>` namespace + `Combo:` prefix | Combos surface under `combo/claude-primary` (not the upstream UUID) and the picker shows `Combo: claude-primary` so they stand apart from raw provider/model pairs | both hooks | | `combo/<slug>` namespace + `Combo: ` prefix | Combos surface under `combo/claude-primary` (not the upstream UUID) and the picker shows `Combo: claude-primary` so they stand apart from raw provider/model pairs | both hooks |
| Nice names + cost | `/api/pricing/models` display names AND `/api/pricing` per-million-token cost overlaid onto the live catalog | both hooks | | Nice names + cost | `/api/pricing/models` display names AND `/api/pricing` per-million-token cost overlaid onto the live catalog | both hooks |
| Canonical-twin dedup + alias-fallback | `/v1/models` exposes the same upstream model under both short alias (`cc/claude-opus-4-7`) and canonical name (`claude/claude-opus-4-7`); the plugin drops the canonical twin when an alias twin exists (no duplicate rows in the picker) and reverse-maps canonical → alias to pick up enrichment for short aliases (`dg/nova-3 → Deepgram - Nova 3`) that `/api/pricing/models` only indexes by canonical | both hooks | | Canonical-twin dedup + alias-fallback | `/v1/models` exposes the same upstream model under both short alias (`cc/claude-opus-4-7`) and canonical name (`claude/claude-opus-4-7`); the plugin drops the canonical twin when an alias twin exists (no duplicate rows in the picker) and reverse-maps canonical → alias to pick up enrichment for short aliases (`dg/nova-3 → Deepgram - Nova 3`) that `/api/pricing/models` only indexes by canonical | both hooks |
| Compression pipeline tags | Combo names get tagged with their compression pipeline (e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`) when `features.compressionMetadata: true`. Intensity tokens render as a traffic-light emoji: 🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra | both hooks | | Compression pipeline tags | Combo names get tagged with their compression pipeline (e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`) when `features.compressionMetadata: true`. Intensity tokens render as a traffic-light emoji: 🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra | both hooks |
| Provider-tag prefix | Prepend short upstream-provider label to enriched names (e.g. `Claude - Claude Opus 4.7` vs `Kiro - Claude Opus 4.7`, `GHM - GPT 5`) so same-id models routed via different upstream connections group visibly in the picker (default-on, opt-out via `features.providerTag: false`) | both hooks | | Provider-tag prefix | Prepend short upstream-provider label to enriched names (e.g. `Claude - Claude Opus 4.7` vs `Kiro - Claude Opus 4.7`, `GHM - GPT 5`) so same-id models routed via different upstream connections group visibly in the picker (default-on, opt-out via `features.providerTag: false`) | both hooks |
| Usable-only filter | Filter to providers with at least one healthy connection in `/api/providers` (opt-in via `features.usableOnly`) | both hooks | | Usable-only filter | Filter to providers with at least one healthy connection in `/api/providers` (opt-in via `features.usableOnly`) | both hooks |
| Model allowlist/blocklist | Curate the model picker to a fixed set of IDs via `features.visibleModels` (allowlist) and/or `features.hiddenModels` (blocklist). Bare suffixes like `claude-opus-4-7` match any `{prefix}/claude-opus-4-7`. Both compose with `usableOnly` (all filters AND together). Blocklist wins over allowlist (deny takes precedence) | both hooks |
| Disk-cache fallback | Last-known-good catalog persisted to disk; hydrates on a cold start when `/v1/models` is unreachable (default-on, opt-out via `features.diskCache: false`) | `config` | | Disk-cache fallback | Last-known-good catalog persisted to disk; hydrates on a cold start when `/v1/models` is unreachable (default-on, opt-out via `features.diskCache: false`) | `config` |
| Bearer injection + suffix-spoof guard | Adds `Authorization` on baseURL-matched requests only | `auth.loader.fetch` | | Bearer injection + suffix-spoof guard | Adds `Authorization` on baseURL-matched requests only | `auth.loader.fetch` |
| Gemini schema sanitization | Strips `$schema`/`$ref`/`additionalProperties` for `gemini-*`/`google-vertex-gemini/*` | `auth.loader.fetch` wrap | | Gemini schema sanitization | Strips `$schema`/`$ref`/`additionalProperties` for `gemini-*`/`google-vertex-gemini/*` | `auth.loader.fetch` wrap |
@@ -205,35 +179,30 @@ npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prepro
## Plugin options ## Plugin options
| Option | Type | Default | Description | | Option | Type | Default | Description |
| --------------------- | -------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- | | --------------- | -------- | ------------------------------------------ | ---------------------------------------------------------- |
| `providerId` | `string` | `"omniroute"` | OpenCode provider id; must be unique across plugin entries | | `providerId` | `string` | `"omniroute"` | OpenCode provider id; must be unique across plugin entries |
| `displayName` | `string` | `"OmniRoute"` or `OmniRoute (<id>)` | Label in the OC UI | | `displayName` | `string` | `"OmniRoute"` or `OmniRoute (<id>)` | Label in the OC UI |
| `modelCacheTtl` | `number` | `300000` (5 min) | `/v1/models` TTL in ms | | `modelCacheTtl` | `number` | `300000` (5 min) | `/v1/models` TTL in ms |
| `baseURL` | `string` | resolved from `auth.json` after `/connect` | Override OmniRoute base URL | | `baseURL` | `string` | resolved from `auth.json` after `/connect` | Override OmniRoute base URL |
| `managementReadToken` | `string` | falls back to `apiKey` | Optional read-only token for management catalog GETs; `/v1` inference stays on the connected `apiKey` | | `features` | `object` | see below | Feature toggles (all opt-in/out, defaults preserve v0.1.0) |
| `features` | `object` | see below | Feature toggles (all opt-in/out, defaults preserve v0.1.0) |
For least-privilege deployments, set top-level `managementReadToken` to a read-only management token. It is sent only to catalog reads (`/api/combos`, `/api/combos/auto`, `/api/pricing/models`, `/api/pricing`, `/api/context/combos`, and `/api/providers`). Inference requests under `/v1`, including chat, continue to use the `apiKey` stored by OpenCode. `features.mcpToken` remains independent. If `managementReadToken` is omitted, catalog reads retain the previous `apiKey` behavior.
### `features` block ### `features` block
Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.json` files do not need to change. Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.json` files do not need to change.
| Feature | Type | Default | What it does | | Feature | Type | Default | What it does |
| --------------------- | --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------- | --------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `combos` | `boolean` | `true` | Discover `/api/combos` and surface them as pseudo-models with LCD capabilities. Combos are keyed under the `combo/<slug>` namespace and labelled `Combo: <name>` in the model picker so they're distinguishable from raw provider/model pairs. | | `combos` | `boolean` | `true` | Discover `/api/combos` and surface them as pseudo-models with LCD capabilities. Combos are keyed under the `combo/<slug>` namespace and labelled `Combo: <name>` in the model picker so they're distinguishable from raw provider/model pairs. |
| `enrichment` | `boolean` | `true` | Pull display names from `/api/pricing/models` AND per-million-token pricing (`input`, `output`, `cached``cacheRead`, `cache_creation``cacheWrite`) from `/api/pricing`, then overlay both onto the live catalog (so the UI shows `Claude 4.7 Opus` with `cost.input: 5`, `cost.output: 25` instead of raw IDs and zeroed cost). | | `enrichment` | `boolean` | `true` | Pull display names from `/api/pricing/models` AND per-million-token pricing (`input`, `output`, `cached``cacheRead`, `cache_creation``cacheWrite`) from `/api/pricing`, then overlay both onto the live catalog (so the UI shows `Claude 4.7 Opus` with `cost.input: 5`, `cost.output: 25` instead of raw IDs and zeroed cost). |
| `compressionMetadata` | `boolean` | `false` | Pull `/api/context/combos` so combo names get tagged with their compression pipeline, e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`. Intensity tokens render as traffic-light emoji (🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra) so the picker advertises "how compressed" each combo is at a glance. | | `compressionMetadata` | `boolean` | `false` | Pull `/api/context/combos` so combo names get tagged with their compression pipeline, e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`. Intensity tokens render as traffic-light emoji (🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra) so the picker advertises "how compressed" each combo is at a glance. |
| `providerTag` | `boolean` | `true` | Prepend a short upstream-provider label to the enriched display name with `" - "` separator, so `cc/claude-opus-4-7 → Claude - Claude Opus 4.7` differs visibly from `kr/claude-opus-4-7 → Kiro - Claude Opus 4.7` in the OC TUI model picker. Label resolution: use `/api/pricing/models[<alias>].name` verbatim when ≤8 chars (e.g. `Claude`, `Kiro`, `Codex`, `Qwen`), otherwise fall back to `UPPER(alias)` (e.g. `GitHub Models``GHM`, `Gemini``GEMINI`). Idempotent. Combos intentionally skipped (the `Combo:` prefix already conveys multi-upstream). | | `providerTag` | `boolean` | `true` | Prepend a short upstream-provider label to the enriched display name with `" - "` separator, so `cc/claude-opus-4-7 → Claude - Claude Opus 4.7` differs visibly from `kr/claude-opus-4-7 → Kiro - Claude Opus 4.7` in the OC TUI model picker. Label resolution: use `/api/pricing/models[<alias>].name` verbatim when ≤8 chars (e.g. `Claude`, `Kiro`, `Codex`, `Qwen`), otherwise fall back to `UPPER(alias)` (e.g. `GitHub Models``GHM`, `Gemini``GEMINI`). Idempotent. Combos intentionally skipped (the `Combo: ` prefix already conveys multi-upstream). |
| `usableOnly` | `boolean` | `false` | Read `/api/providers` and filter the catalog to providers that have at least one connection with `isActive: true` AND `testStatus: 'active'`. Subtract-filter semantics: providers unknown to BOTH the pricing-models catalog AND the connection table pass through (so synthetic prefixes like `agentrouter/*` survive). On fetch failure the filter is disabled for the refresh — never hides the whole catalog. | | `usableOnly` | `boolean` | `false` | Read `/api/providers` and filter the catalog to providers that have at least one connection with `isActive: true` AND `testStatus: 'active'`. Subtract-filter semantics: providers unknown to BOTH the pricing-models catalog AND the connection table pass through (so synthetic prefixes like `agentrouter/*` survive). On fetch failure the filter is disabled for the refresh — never hides the whole catalog. |
| `visibleModels` | `string[]` | _unset_ | Allowlist — when set and non-empty, only models whose raw `/v1/models` ID matches are emitted. Bare IDs (no slash, e.g. `claude-opus-4-7`) match any `{prefix}/claude-opus-4-7`; full IDs (e.g. `cc/claude-opus-4-7`) match exactly. Composes with `usableOnly` and `hiddenModels` (all filters AND together). Unset or empty = no filter. | | `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-<providerId>.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write — never blocks publishing. |
| `hiddenModels` | `string[]` | _unset_ | Blocklist — models whose raw ID matches are dropped. Same matching rules as `visibleModels`. When a model is in both `visibleModels` and `hiddenModels`, the blocklist wins (deny takes precedence). Composes with `usableOnly` and `visibleModels` (all filters AND together). Unset or empty = no filter. | | `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` |
| `diskCache` | `boolean` | `true` | Persist the last successful `/v1/models` + `/api/combos` + enrichment + connections + compression snapshot to `${OPENCODE_DATA_DIR ?? ~/.local/share/opencode}/plugins/omniroute-<providerId>.json`. On a subsequent cold start where `/v1/models` throws (network down / IP whitelist drop / 5xx) the static block hydrates from the snapshot so OC's model picker survives offline. Soft-fail on read/write — never blocks publishing. | | `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.<providerId>` remote entry into the OC config pointing at `<baseURL>/api/mcp/stream` with the resolved Bearer token |
| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` | | `mcpToken` | `string` | _unset_ | Optional separate Bearer for the auto-emitted MCP entry. Falls back to the provider's `apiKey` (from `auth.json`) when unset |
| `mcpAutoEmit` | `boolean` | `false` | Auto-write an `mcp.<providerId>` remote entry into the OC config pointing at `<baseURL>/api/mcp/stream` with the resolved Bearer token | | `fetchInterceptor` | `boolean` | `true` | Inject `Authorization: Bearer` + default `Content-Type` on every outbound request targeting `baseURL` (suffix-spoof guarded) |
| `mcpToken` | `string` | _unset_ | Optional separate Bearer for the auto-emitted MCP entry. Falls back to the provider's `apiKey` (from `auth.json`) when unset |
| `fetchInterceptor` | `boolean` | `true` | Inject `Authorization: Bearer` + default `Content-Type` on every outbound request targeting `baseURL` (suffix-spoof guarded) |
#### Example — enrichment + compression tags + MCP auto-emit #### Example — enrichment + compression tags + MCP auto-emit
@@ -245,7 +214,6 @@ Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.
{ {
"providerId": "omniroute", "providerId": "omniroute",
"baseURL": "https://or.example.com", "baseURL": "https://or.example.com",
"managementReadToken": "<read-only-management-token>",
"features": { "features": {
"combos": true, "combos": true,
"enrichment": true, "enrichment": true,
@@ -301,45 +269,7 @@ If you want a narrower-scoped Bearer for MCP (different from the chat/inference
- `compressionMetadata: true` annotates combo display names with their pipeline using traffic-light emoji for intensity (e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`) so the picker advertises which compression each combo applies and how heavy it is at a glance. Palette: 🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra. Unknown intensities fall through to raw text (`[rtk:custom-thing]`) so the plugin never hides a value OmniRoute knows but the plugin doesn't. - `compressionMetadata: true` annotates combo display names with their pipeline using traffic-light emoji for intensity (e.g. `Combo: claude-primary [rtk🟡 → caveman🟠]`) so the picker advertises which compression each combo applies and how heavy it is at a glance. Palette: 🟢 lite/minimal · 🟡 standard · 🟠 aggressive/full · 🔴 ultra. Unknown intensities fall through to raw text (`[rtk:custom-thing]`) so the plugin never hides a value OmniRoute knows but the plugin doesn't.
- `providerTag: true` (default) prepends a short upstream-provider label so the picker shows `Claude - Claude Opus 4.7` for `cc/claude-opus-4-7`, `Kiro - Claude Opus 4.7` for `kr/claude-opus-4-7`, and `GHM - GPT 5` for `ghm/gpt-5` (slot.name `GitHub Models` > 8 chars → abbreviated). Critical when the same model id is sold through multiple upstream connections with different cost/auth/rate-limit profiles. Set to `false` to keep the pre-v3.8.3 unsuffixed format. - `providerTag: true` (default) prepends a short upstream-provider label so the picker shows `Claude - Claude Opus 4.7` for `cc/claude-opus-4-7`, `Kiro - Claude Opus 4.7` for `kr/claude-opus-4-7`, and `GHM - GPT 5` for `ghm/gpt-5` (slot.name `GitHub Models` > 8 chars → abbreviated). Critical when the same model id is sold through multiple upstream connections with different cost/auth/rate-limit profiles. Set to `false` to keep the pre-v3.8.3 unsuffixed format.
#### Example — curating the model picker (allowlist + blocklist) ## Comparison vs `@omniroute/opencode-provider`
A typical OmniRoute instance serves 600+ models. The OpenCode TUI/CLI picker becomes unusable when you need to scroll through hundreds of entries to find the ~30 models you actually use. `visibleModels` and `hiddenModels` let you curate the picker to a fixed set of model IDs that persists in `opencode.json` across config resets.
```jsonc
{
"plugin": [
[
"@omniroute/opencode-plugin",
{
"providerId": "omniroute",
"baseURL": "https://or.example.com",
"features": {
"combos": true,
"enrichment": true,
"usableOnly": true,
"visibleModels": [
"claude-opus-4-7", // bare suffix: matches cc/claude-opus-4-7, kr/claude-opus-4-7, etc.
"cc/claude-sonnet-4-6", // exact: only the cc/ alias
"gemini-2.5-pro",
"gpt-5",
"o3",
"o3-pro",
"o4-mini",
],
"hiddenModels": [
"o3-mini", // hide the mini variant even if visibleModels is unset
],
},
},
],
],
}
```
- `visibleModels` is an allowlist — only models whose raw ID matches are emitted. Bare IDs (no slash) match any provider prefix; full IDs (with slash) match exactly.
- `hiddenModels` is a blocklist — listed models are dropped. When a model is in both lists, the blocklist wins (deny takes precedence).
- Both compose with `usableOnly` (all filters AND together: a model must pass usableOnly AND visibleModels AND not be in hiddenModels).
- Unset or empty = no filter (current behavior).
[`@omniroute/opencode-provider`](https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-provider) is the existing config-generator package — it writes a frozen `provider.<id>` block into `opencode.json` at build time. This plugin is the runtime integration. [`@omniroute/opencode-provider`](https://github.com/diegosouzapw/OmniRoute/tree/main/%40omniroute/opencode-provider) is the existing config-generator package — it writes a frozen `provider.<id>` block into `opencode.json` at build time. This plugin is the runtime integration.

View File

@@ -1,12 +1,12 @@
{ {
"name": "@omniroute/opencode-plugin", "name": "@omniroute/opencode-plugin",
"version": "0.2.1", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@omniroute/opencode-plugin", "name": "@omniroute/opencode-plugin",
"version": "0.2.1", "version": "0.2.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"zod": "^4.4.3" "zod": "^4.4.3"

View File

@@ -1,6 +1,6 @@
{ {
"name": "@omniroute/opencode-plugin", "name": "@omniroute/opencode-plugin",
"version": "0.2.1", "version": "0.2.0",
"description": "OpenCode plugin for the OmniRoute AI Gateway. Drives dynamic model discovery, /connect auth flow, and multi-instance OmniRoute providers via the official @opencode-ai/plugin contract.", "description": "OpenCode plugin for the OmniRoute AI Gateway. Drives dynamic model discovery, /connect auth flow, and multi-instance OmniRoute providers via the official @opencode-ai/plugin contract.",
"type": "module", "type": "module",
"main": "./dist/index.js", "main": "./dist/index.js",
@@ -23,7 +23,7 @@
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"clean": "rm -rf dist", "clean": "rm -rf dist",
"test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/feature-defaults.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts tests/management-read-token.test.ts tests/auto-sync.test.ts tests/model-allowlist.test.ts tests/log-level.test.ts", "test": "node --import tsx/esm --test tests/scaffold.test.ts tests/auth.test.ts tests/options-schema.test.ts tests/multi-instance.test.ts tests/fetch-interceptor.test.ts tests/provider.test.ts tests/gemini-sanitize.test.ts tests/combos.test.ts tests/config-shim.test.ts tests/features.test.ts tests/usable-combo.test.ts tests/disk-snapshot-perms.test.ts tests/fork-features.test.ts tests/auto-combo-context.test.ts tests/provider-id-routing.test.ts",
"prepublishOnly": "npm run clean && npm run build && npm test" "prepublishOnly": "npm run clean && npm run build && npm test"
}, },
"keywords": [ "keywords": [

File diff suppressed because it is too large Load Diff

View File

@@ -36,47 +36,39 @@ function fmt(level: LogLevel, msg: string, tag?: string): string {
return `${prefix} [${level.toUpperCase()}] ${msg}`; return `${prefix} [${level.toUpperCase()}] ${msg}`;
} }
function buildLogger(getLevel: () => LogLevel) { export const logger = {
return { error(msg: string, ...args: unknown[]): void {
error(msg: string, ...args: unknown[]): void { if (shouldLog(_level, "error")) console.error(fmt("error", msg), ...args);
if (shouldLog(getLevel(), "error")) console.error(fmt("error", msg), ...args); },
}, warn(msg: string, ...args: unknown[]): void {
warn(msg: string, ...args: unknown[]): void { if (shouldLog(_level, "warn")) console.warn(fmt("warn", msg), ...args);
if (shouldLog(getLevel(), "warn")) console.warn(fmt("warn", msg), ...args); },
}, info(msg: string, ...args: unknown[]): void {
info(msg: string, ...args: unknown[]): void { if (shouldLog(_level, "info")) console.warn(fmt("info", msg), ...args);
if (shouldLog(getLevel(), "info")) console.warn(fmt("info", msg), ...args); },
}, debug(msg: string, ...args: unknown[]): void {
debug(msg: string, ...args: unknown[]): void { if (shouldLog(_level, "debug")) console.warn(fmt("debug", msg), ...args);
if (shouldLog(getLevel(), "debug")) console.warn(fmt("debug", msg), ...args); },
}, /** Always emit regardless of level (for critical init breadcrumbs). */
/** Always emit regardless of level (for critical init breadcrumbs). */ always(msg: string, ...args: unknown[]): void {
always(msg: string, ...args: unknown[]): void { console.warn(TAG, msg, ...args);
console.warn(TAG, msg, ...args); },
},
// ── Tagged child loggers ──────────────────────────────────────────── // ── Tagged child loggers ──────────────────────────────────────────────
child(tag: string) { child(tag: string) {
return { return {
error: (msg: string, ...args: unknown[]) => error: (msg: string, ...args: unknown[]) =>
shouldLog(getLevel(), "error") && console.error(fmt("error", msg, tag), ...args), shouldLog(_level, "error") &&
warn: (msg: string, ...args: unknown[]) => console.error(fmt("error", msg, tag), ...args),
shouldLog(getLevel(), "warn") && console.warn(fmt("warn", msg, tag), ...args), warn: (msg: string, ...args: unknown[]) =>
info: (msg: string, ...args: unknown[]) => shouldLog(_level, "warn") &&
shouldLog(getLevel(), "info") && console.warn(fmt("info", msg, tag), ...args), console.warn(fmt("warn", msg, tag), ...args),
debug: (msg: string, ...args: unknown[]) => info: (msg: string, ...args: unknown[]) =>
shouldLog(getLevel(), "debug") && console.warn(fmt("debug", msg, tag), ...args), shouldLog(_level, "info") &&
}; console.warn(fmt("info", msg, tag), ...args),
}, debug: (msg: string, ...args: unknown[]) =>
}; shouldLog(_level, "debug") &&
} console.warn(fmt("debug", msg, tag), ...args),
};
export type Logger = ReturnType<typeof buildLogger>; },
};
/** Create an instance-scoped logger whose level cannot be changed by other plugin instances. */
export function createLogger(level: LogLevel): Logger {
return buildLogger(() => level);
}
/** Backward-compatible module-global logger controlled by setLogLevel(). */
export const logger: Logger = buildLogger(() => _level);

View File

@@ -1,219 +0,0 @@
/**
* Auto-discovery + force-sync (OpenCode parity with Pi `/omni sync`).
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
sanitizeAutoSyncIntervalMs,
DEFAULT_AUTO_SYNC_INTERVAL_MS,
MIN_AUTO_SYNC_INTERVAL_MS,
parseOmniRoutePluginOptions,
resolveOmniRoutePluginOptions,
invalidateOmniRouteFetchCache,
forceSyncOmniRouteModels,
type OmniRouteFetchCache,
} from "../src/index.js";
import { getLogLevel, setLogLevel } from "../src/logger.js";
async function captureConsole(run: () => Promise<void>): Promise<string[]> {
const lines: string[] = [];
const originalError = console.error;
const originalWarn = console.warn;
console.error = (...args: unknown[]) => lines.push(args.map(String).join(" "));
console.warn = (...args: unknown[]) => lines.push(args.map(String).join(" "));
try {
await run();
} finally {
console.error = originalError;
console.warn = originalWarn;
}
return lines;
}
test("sanitizeAutoSyncIntervalMs: unset → default 300000", () => {
assert.equal(sanitizeAutoSyncIntervalMs(undefined), DEFAULT_AUTO_SYNC_INTERVAL_MS);
assert.equal(sanitizeAutoSyncIntervalMs(null), DEFAULT_AUTO_SYNC_INTERVAL_MS);
});
test("sanitizeAutoSyncIntervalMs: 0 disables", () => {
assert.equal(sanitizeAutoSyncIntervalMs(0), 0);
});
test("sanitizeAutoSyncIntervalMs: clamps below min to 60000", () => {
assert.equal(sanitizeAutoSyncIntervalMs(1), MIN_AUTO_SYNC_INTERVAL_MS);
assert.equal(sanitizeAutoSyncIntervalMs(59_999), MIN_AUTO_SYNC_INTERVAL_MS);
});
test("sanitizeAutoSyncIntervalMs: keeps valid values", () => {
assert.equal(sanitizeAutoSyncIntervalMs(60_000), 60_000);
assert.equal(sanitizeAutoSyncIntervalMs(300_000), 300_000);
});
test("parseOmniRoutePluginOptions accepts autoSyncIntervalMs including 0", () => {
assert.equal(parseOmniRoutePluginOptions({ autoSyncIntervalMs: 0 }).autoSyncIntervalMs, 0);
assert.equal(
parseOmniRoutePluginOptions({ autoSyncIntervalMs: 120_000 }).autoSyncIntervalMs,
120_000
);
});
test("resolveOmniRoutePluginOptions defaults autoSyncIntervalMs to 300000", () => {
const r = resolveOmniRoutePluginOptions({});
assert.equal(r.autoSyncIntervalMs, DEFAULT_AUTO_SYNC_INTERVAL_MS);
});
test("resolveOmniRoutePluginOptions clamps low positive autoSyncIntervalMs", () => {
const r = resolveOmniRoutePluginOptions({ autoSyncIntervalMs: 5000 });
assert.equal(r.autoSyncIntervalMs, MIN_AUTO_SYNC_INTERVAL_MS);
});
test("invalidateOmniRouteFetchCache clears by baseURL prefix", () => {
const cache: OmniRouteFetchCache = new Map();
cache.set("https://a.example/v1::abc", {
rawModels: [],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
expiresAt: Date.now() + 1000,
});
cache.set("https://b.example/v1::def", {
rawModels: [],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
expiresAt: Date.now() + 1000,
});
const removed = invalidateOmniRouteFetchCache(cache, "https://a.example/v1");
assert.equal(removed, 1);
assert.equal(cache.size, 1);
assert.equal(cache.has("https://b.example/v1::def"), true);
});
test("forceSyncOmniRouteModels: fetches, populates cache, returns count", async () => {
const cache: OmniRouteFetchCache = new Map();
const resolved = resolveOmniRoutePluginOptions({
providerId: "omniroute",
baseURL: "https://omniroute.example/v1",
autoSyncIntervalMs: 0,
features: {
combos: false,
autoCombos: false,
enrichment: false,
compressionMetadata: false,
usableOnly: false,
diskCache: false,
},
});
const result = await forceSyncOmniRouteModels({
resolved,
cache,
readAuthJson: async () => ({
omniroute: { type: "api", key: "test-key" },
}),
fetcher: async () => [
{ id: "model-a", object: "model" },
{ id: "model-b", object: "model" },
],
now: () => 1_000_000,
});
assert.equal(result.ok, true);
assert.equal(result.count, 2);
assert.equal(result.provider, "omniroute");
assert.equal(cache.size, 1);
const entry = [...cache.values()][0];
assert.equal(entry.rawModels.length, 2);
assert.equal(entry.expiresAt, 1_000_000 + resolved.modelCacheTtl);
});
test("forceSyncOmniRouteModels suppresses successful lifecycle output at error level", async () => {
const previousLevel = getLogLevel();
const cache: OmniRouteFetchCache = new Map();
const resolved = resolveOmniRoutePluginOptions({
providerId: "omniroute",
baseURL: "https://omniroute.example/v1",
features: {
autoCombos: false,
combos: false,
compressionMetadata: false,
diskCache: false,
enrichment: false,
logLevel: "error",
usableOnly: false,
},
});
try {
setLogLevel("error");
const lines = await captureConsole(async () => {
const result = await forceSyncOmniRouteModels({
resolved,
cache,
readAuthJson: async () => ({ omniroute: { type: "api", key: "test-key" } }),
fetcher: async () => [{ id: "model-a", object: "model" }],
});
assert.equal(result.ok, true);
});
assert.deepEqual(lines, []);
} finally {
setLogLevel(previousLevel);
}
});
test("forceSyncOmniRouteModels preserves successful lifecycle output at info level", async () => {
const previousLevel = getLogLevel();
const cache: OmniRouteFetchCache = new Map();
const resolved = resolveOmniRoutePluginOptions({
providerId: "omniroute",
baseURL: "https://omniroute.example/v1",
features: {
autoCombos: false,
combos: false,
compressionMetadata: false,
diskCache: false,
enrichment: false,
logLevel: "info",
usableOnly: false,
},
});
try {
setLogLevel("info");
const lines = await captureConsole(async () => {
const result = await forceSyncOmniRouteModels({
resolved,
cache,
readAuthJson: async () => ({ omniroute: { type: "api", key: "test-key" } }),
fetcher: async () => [{ id: "model-a", object: "model" }],
});
assert.equal(result.ok, true);
});
assert.equal(lines.filter((line) => line.includes("force sync ok")).length, 1);
} finally {
setLogLevel(previousLevel);
}
});
test("forceSyncOmniRouteModels: missing auth returns error", async () => {
const cache: OmniRouteFetchCache = new Map();
const resolved = resolveOmniRoutePluginOptions({
providerId: "omniroute",
baseURL: "https://omniroute.example/v1",
autoSyncIntervalMs: 0,
features: { diskCache: false },
});
const result = await forceSyncOmniRouteModels({
resolved,
cache,
readAuthJson: async () => ({}),
});
assert.equal(result.ok, false);
assert.match(result.error ?? "", /credentials|baseURL|connect/i);
});

View File

@@ -33,7 +33,6 @@ import {
createOmniRouteProviderHook, createOmniRouteProviderHook,
OmniRoutePlugin, OmniRoutePlugin,
resolveOmniRoutePluginOptions, resolveOmniRoutePluginOptions,
_resetInflightRefresh,
type OmniRouteCombosFetcher, type OmniRouteCombosFetcher,
type OmniRouteEnrichmentEntry, type OmniRouteEnrichmentEntry,
type OmniRouteEnrichmentFetcher, type OmniRouteEnrichmentFetcher,
@@ -48,16 +47,6 @@ import {
type OmniRouteStaticProviderEntry, type OmniRouteStaticProviderEntry,
} from "../src/index.js"; } from "../src/index.js";
// ────────────────────────────────────────────────────────────────────────────
// Test isolation: reset the module-level in-flight refresh guard between
// tests so a detached refresh from a previous test doesn't leak into the
// next one.
// ────────────────────────────────────────────────────────────────────────────
test.beforeEach(() => {
_resetInflightRefresh();
});
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
// Fixtures // Fixtures
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
@@ -238,7 +227,7 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
// Stripped per-model shape: name + cap flags + modalities + (optional) // Stripped per-model shape: name + cap flags + modalities + (optional)
// cost. OC's SDK static schema accepts only `limit.{context,output}` — // cost. OC's SDK static schema accepts only `limit.{context,output}` —
// `limit.input` is NOT in the SDK shape and gets dropped silently. // `limit.input` is NOT in the SDK shape and gets dropped silently.
const claude = entry.models["claude-sonnet-4-6"]; const claude = entry.models["opencode-omniroute/claude-sonnet-4-6"];
assert.ok(claude, "claude model surfaced"); assert.ok(claude, "claude model surfaced");
assert.equal(claude.name, "claude-sonnet-4-6"); assert.equal(claude.name, "claude-sonnet-4-6");
assert.equal(claude.attachment, true); assert.equal(claude.attachment, true);
@@ -259,7 +248,7 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
// Combo surfaces under bare key + LCD'd // Combo surfaces under bare key + LCD'd
// (gemini's reasoning=false → combo reasoning=false). // (gemini's reasoning=false → combo reasoning=false).
const combo = entry.models["claude-tier"]; const combo = entry.models["opencode-omniroute/claude-tier"];
assert.ok(combo, "combo surfaced under bare key"); assert.ok(combo, "combo surfaced under bare key");
assert.equal(combo.name, "Claude Tier"); assert.equal(combo.name, "Claude Tier");
assert.equal(combo.reasoning, false, "LCD: any member reasoning=false → combo reasoning=false"); assert.equal(combo.reasoning, false, "LCD: any member reasoning=false → combo reasoning=false");
@@ -482,10 +471,10 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m
assert.ok(entry); assert.ok(entry);
const ids = Object.keys(entry.models).sort(); const ids = Object.keys(entry.models).sort();
assert.deepEqual(ids, [ assert.deepEqual(ids, [
"claude-sonnet-4-6", "opencode-omniroute/claude-sonnet-4-6",
"gemini-3-flash", "opencode-omniroute/gemini-3-flash",
]); ]);
assert.equal(entry.models["claude-tier"], undefined, "no combo entry"); assert.equal(entry.models["opencode-omniroute/claude-tier"], undefined, "no combo entry");
assert.ok( assert.ok(
logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")), logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")),
"combos-fetch breadcrumb emitted" "combos-fetch breadcrumb emitted"
@@ -734,7 +723,7 @@ test("buildStaticProviderEntry: stripped per-model shape matches sibling @omniro
} }
// Sanity: claude entry has all expected stripped fields. // Sanity: claude entry has all expected stripped fields.
const claude = block.models["claude-sonnet-4-6"]; const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
assert.equal(typeof claude.name, "string"); assert.equal(typeof claude.name, "string");
assert.equal(typeof claude.attachment, "boolean"); assert.equal(typeof claude.attachment, "boolean");
assert.equal(typeof claude.reasoning, "boolean"); assert.equal(typeof claude.reasoning, "boolean");
@@ -759,39 +748,8 @@ test("buildStaticProviderEntry: hidden combos are excluded", () => {
"https://or.example/v1", "https://or.example/v1",
"sk-test" "sk-test"
); );
assert.equal(block.models["claude-tier"], undefined); assert.equal(block.models["opencode-omniroute/claude-tier"], undefined);
assert.ok(block.models["claude-sonnet-4-6"]); assert.ok(block.models["opencode-omniroute/claude-sonnet-4-6"]);
});
test("buildStaticProviderEntry: expected raw auto twin does not warn and auto combo wins", () => {
const resolved = resolveOmniRoutePluginOptions({ providerId: "omniroute" });
const warnings: string[] = [];
const originalWarn = console.warn;
console.warn = (...args: unknown[]) => warnings.push(args.map(String).join(" "));
let block: OmniRouteStaticProviderEntry;
try {
block = buildStaticProviderEntry(
[{ id: "auto/coding" }],
[],
resolved,
"https://or.example/v1",
"sk-test",
undefined,
undefined,
undefined,
[{ id: "auto/coding", name: "Auto Coding", variant: "coding", candidateCount: 5 }]
);
} finally {
console.warn = originalWarn;
}
assert.equal(Object.keys(block.models).filter((key) => key === "auto/coding").length, 1);
assert.equal(block.models["auto/coding"].tool_call, true, "auto-combo entry wins over raw twin");
assert.deepEqual(
warnings.filter((warning) => warning.includes("collides with an existing model")),
[]
);
}); });
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────
@@ -807,7 +765,7 @@ test("buildStaticProviderEntry: emits modalities.input from raw.input_modalities
"https://or.example/v1", "https://or.example/v1",
"sk-test" "sk-test"
); );
const claude = block.models["claude-sonnet-4-6"]; const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
assert.deepEqual(claude.modalities?.input, ["text", "image"]); assert.deepEqual(claude.modalities?.input, ["text", "image"]);
assert.deepEqual(claude.modalities?.output, ["text"]); assert.deepEqual(claude.modalities?.output, ["text"]);
}); });
@@ -821,7 +779,7 @@ test("buildStaticProviderEntry: never emits limit.input (OC SDK rejects it)", ()
"https://or.example/v1", "https://or.example/v1",
"sk-test" "sk-test"
); );
const claude = block.models["claude-sonnet-4-6"]; const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
assert.equal((claude.limit as Record<string, unknown>).input, undefined); assert.equal((claude.limit as Record<string, unknown>).input, undefined);
assert.equal(typeof claude.limit?.context, "number"); assert.equal(typeof claude.limit?.context, "number");
assert.equal(typeof claude.limit?.output, "number"); assert.equal(typeof claude.limit?.output, "number");
@@ -849,7 +807,7 @@ test("buildStaticProviderEntry: emits cost when enrichment carries pricing", ()
"sk-test", "sk-test",
enrichment enrichment
); );
const claude = block.models["claude-sonnet-4-6"]; const claude = block.models["opencode-omniroute/claude-sonnet-4-6"];
assert.equal(claude.cost?.input, 3); assert.equal(claude.cost?.input, 3);
assert.equal(claude.cost?.output, 15); assert.equal(claude.cost?.output, 15);
assert.equal(claude.cost?.cache_read, 0.3); assert.equal(claude.cost?.cache_read, 0.3);
@@ -870,8 +828,8 @@ test("buildStaticProviderEntry: emits release_date when raw carries it; omits wh
"https://or.example/v1", "https://or.example/v1",
"sk-test" "sk-test"
); );
assert.equal(block.models["claude-with-date"].release_date, "2026-02-19"); assert.equal(block.models["opencode-omniroute/claude-with-date"].release_date, "2026-02-19");
assert.equal(block.models["gemini-3-flash"].release_date, undefined); assert.equal(block.models["opencode-omniroute/gemini-3-flash"].release_date, undefined);
}); });
test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)", () => { test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)", () => {
@@ -900,7 +858,7 @@ test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)
"https://or.example/v1", "https://or.example/v1",
"sk-test" "sk-test"
); );
const combo = block.models["mixed-tier"]; const combo = block.models["opencode-omniroute/mixed-tier"];
assert.ok(combo, "combo emitted under slug key"); assert.ok(combo, "combo emitted under slug key");
// claude has text+image, text-only has text → intersection drops image. // claude has text+image, text-only has text → intersection drops image.
assert.deepEqual(combo.modalities?.input, ["text"]); assert.deepEqual(combo.modalities?.input, ["text"]);
@@ -1009,10 +967,10 @@ test("config: enrichment fetched + name overlaid on raw-model entries", async ()
"opencode-omniroute" "opencode-omniroute"
]; ];
assert.ok(entry); assert.ok(entry);
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
assert.equal(entry.models["gemini-3-flash"].name, "Gemini 3 Flash"); assert.equal(entry.models["opencode-omniroute/gemini-3-flash"].name, "Gemini 3 Flash");
// Combo names still come from /api/combos — enrichment overlay does NOT touch combos. // Combo names still come from /api/combos — enrichment overlay does NOT touch combos.
assert.equal(entry.models["claude-tier"].name, "Claude Tier"); assert.equal(entry.models["opencode-omniroute/claude-tier"].name, "Claude Tier");
assert.equal(enrichmentFetcher.callCount(), 1); assert.equal(enrichmentFetcher.callCount(), 1);
}); });
@@ -1042,7 +1000,7 @@ test("config: features.enrichment=false skips enrichment fetch + keeps raw-id na
assert.ok(entry); assert.ok(entry);
assert.equal(enrichmentFetcher.callCount(), 0, "enrichment fetch suppressed by feature flag"); assert.equal(enrichmentFetcher.callCount(), 0, "enrichment fetch suppressed by feature flag");
assert.equal( assert.equal(
entry.models["claude-sonnet-4-6"].name, entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
"claude-sonnet-4-6", "claude-sonnet-4-6",
"raw id retained" "raw id retained"
); );
@@ -1069,7 +1027,7 @@ test("config: enrichment fetcher throws → soft-fail (warn + raw-id static cata
]; ];
assert.ok(entry, "static block still published on enrichment failure"); assert.ok(entry, "static block still published on enrichment failure");
assert.equal( assert.equal(
entry.models["claude-sonnet-4-6"].name, entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
"claude-sonnet-4-6", "claude-sonnet-4-6",
"raw id retained" "raw id retained"
); );
@@ -1271,20 +1229,17 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async (
"opencode-omniroute" "opencode-omniroute"
]; ];
assert.ok( assert.ok(
entry.models["claude-sonnet-4-6"], entry.models["opencode-omniroute/claude-sonnet-4-6"],
"stale snapshot hydrated into static block" "stale snapshot hydrated into static block"
); );
assert.equal( assert.equal(
entry.models["claude-sonnet-4-6"].name, entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
"Claude Sonnet 4.6 (cached)", "Claude Sonnet 4.6 (cached)",
"stale enrichment also reused" "stale enrichment also reused"
); );
assert.equal(writes, 0, "disk write skipped when live fetch failed"); assert.equal(writes, 0, "disk write skipped when live fetch failed");
assert.ok( assert.ok(
logger.entries.some((e) => logger.entries.some((e) => String(e[0]).includes("using stale disk cache")),
String(e[0]).includes("using stale disk cache") ||
String(e[0]).includes("warm startup from disk snapshot")
),
"disk-cache hydration breadcrumb emitted" "disk-cache hydration breadcrumb emitted"
); );
}); });
@@ -1326,7 +1281,7 @@ test("config: cached rawEnrichment from earlier provider hook is reused (no refe
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[ const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute" "opencode-omniroute"
]; ];
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
}); });
// ───────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────
@@ -1377,12 +1332,12 @@ test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-
]; ];
assert.ok(entry); assert.ok(entry);
assert.equal( assert.equal(
entry.models["claude-sonnet-4-6"].name, entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
"Claude - Claude Sonnet 4.6" "Claude - Claude Sonnet 4.6"
); );
assert.equal(entry.models["gemini-3-flash"].name, "Gemini - Gemini 3 Flash"); assert.equal(entry.models["opencode-omniroute/gemini-3-flash"].name, "Gemini - Gemini 3 Flash");
// Combos stay untouched — `Combo: ` prefix already conveys multi-upstream. // Combos stay untouched — `Combo: ` prefix already conveys multi-upstream.
assert.equal(entry.models["claude-tier"].name, "Claude Tier"); assert.equal(entry.models["opencode-omniroute/claude-tier"].name, "Claude Tier");
}); });
test("config: providerTag=false suppresses the suffix", async () => { test("config: providerTag=false suppresses the suffix", async () => {
@@ -1409,7 +1364,7 @@ test("config: providerTag=false suppresses the suffix", async () => {
"opencode-omniroute" "opencode-omniroute"
]; ];
assert.equal( assert.equal(
entry.models["claude-sonnet-4-6"].name, entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
"Claude Sonnet 4.6", "Claude Sonnet 4.6",
"enriched name kept, provider tag suppressed" "enriched name kept, provider tag suppressed"
); );
@@ -1441,7 +1396,7 @@ test("config: providerTag falls back to UPPER(alias) when providerDisplayName mi
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[ const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute" "opencode-omniroute"
]; ];
assert.equal(entry.models["claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6"); assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "CC - Claude Sonnet 4.6");
}); });
test("config: providerTag skipped entirely when neither providerDisplayName nor providerAlias set", async () => { test("config: providerTag skipped entirely when neither providerDisplayName nor providerAlias set", async () => {
@@ -1468,7 +1423,7 @@ test("config: providerTag skipped entirely when neither providerDisplayName nor
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[ const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute" "opencode-omniroute"
]; ];
assert.equal(entry.models["claude-sonnet-4-6"].name, "Claude Sonnet 4.6"); assert.equal(entry.models["opencode-omniroute/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
}); });
test("config: providerTag is idempotent — second hook call doesn't double-suffix", async () => { test("config: providerTag is idempotent — second hook call doesn't double-suffix", async () => {
@@ -1496,7 +1451,7 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff
"opencode-omniroute" "opencode-omniroute"
]; ];
assert.equal( assert.equal(
entryA.models["claude-sonnet-4-6"].name, entryA.models["opencode-omniroute/claude-sonnet-4-6"].name,
"Claude - Claude Sonnet 4.6" "Claude - Claude Sonnet 4.6"
); );
@@ -1507,7 +1462,7 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff
"opencode-omniroute" "opencode-omniroute"
]; ];
assert.equal( assert.equal(
entryB.models["claude-sonnet-4-6"].name, entryB.models["opencode-omniroute/claude-sonnet-4-6"].name,
"Claude - Claude Sonnet 4.6" "Claude - Claude Sonnet 4.6"
); );
}); });
@@ -1561,7 +1516,7 @@ test("buildStaticProviderEntry: nested combo-ref context is the bottleneck acros
); );
// Pre-fix: Parent would advertise 200_000 (only raw-big counted). // Pre-fix: Parent would advertise 200_000 (only raw-big counted).
// Post-fix: Parent should advertise 8_000 (TinyCombo bottleneck). // Post-fix: Parent should advertise 8_000 (TinyCombo bottleneck).
const parent = block.models["parent"]; const parent = block.models["opencode-omniroute/parent"];
assert.ok(parent, "Parent combo must be in the static catalog"); assert.ok(parent, "Parent combo must be in the static catalog");
assert.equal(parent.limit?.context, 8_000); assert.equal(parent.limit?.context, 8_000);
}); });

View File

@@ -40,7 +40,7 @@ test("defaultDiskSnapshotWriter writes an owner-only (no group/other) snapshot",
process.env.OPENCODE_DATA_DIR = tmp; process.env.OPENCODE_DATA_DIR = tmp;
try { try {
await defaultDiskSnapshotWriter("perm-test", makeEntry(), "test-snapshot-identity"); await defaultDiskSnapshotWriter("perm-test", makeEntry());
const file = diskSnapshotPath("perm-test"); const file = diskSnapshotPath("perm-test");
assert.ok(fs.existsSync(file), "snapshot file should be written"); assert.ok(fs.existsSync(file), "snapshot file should be written");

View File

@@ -1,95 +0,0 @@
/**
* #7624 — explicit feature-flag defaults.
*
* The `features` block in opencode.json marks every toggle `.optional()` with
* no default. The effective value was previously only knowable by tracing the
* implicit `features.X !== false` (default-ON) / `features.X === true`
* (default-OFF) convention scattered across each read site, which left
* operators unsure whether combos / autoCombos / enrichment were enabled when
* they omitted the block.
*
* `OMNIROUTE_FEATURE_DEFAULTS` declares those defaults explicitly and
* `resolveEffectiveFeatureFlags(features)` derives the effective state for any
* (possibly-undefined) features object, mirroring the read-site conventions
* exactly. These are purely derived — runtime routing behaviour is unchanged.
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
OMNIROUTE_FEATURE_DEFAULTS,
resolveEffectiveFeatureFlags,
} from "../src/index.js";
const DEFAULT_ON = [
"combos",
"autoCombos",
"enrichment",
"diskCache",
"providerTag",
"fetchInterceptor",
"geminiSanitization",
] as const;
const DEFAULT_OFF = [
"compressionMetadata",
"usableOnly",
"mcpAutoEmit",
"debugLog",
"startupDebug",
] as const;
test("OMNIROUTE_FEATURE_DEFAULTS: declares each flag with its documented default", () => {
for (const key of DEFAULT_ON) {
assert.equal(OMNIROUTE_FEATURE_DEFAULTS[key], true, `${key} defaults ON`);
}
for (const key of DEFAULT_OFF) {
assert.equal(OMNIROUTE_FEATURE_DEFAULTS[key], false, `${key} defaults OFF`);
}
});
test("resolveEffectiveFeatureFlags: undefined features → full declared default set", () => {
const flags = resolveEffectiveFeatureFlags(undefined);
for (const key of DEFAULT_ON) {
assert.equal(flags[key], true, `${key} effective ON when features omitted`);
}
for (const key of DEFAULT_OFF) {
assert.equal(flags[key], false, `${key} effective OFF when features omitted`);
}
});
test("resolveEffectiveFeatureFlags: empty features object → same as omitted", () => {
assert.deepEqual(
resolveEffectiveFeatureFlags({}),
resolveEffectiveFeatureFlags(undefined)
);
});
test("resolveEffectiveFeatureFlags: explicit false disables a default-ON flag", () => {
const flags = resolveEffectiveFeatureFlags({ autoCombos: false });
assert.equal(flags.autoCombos, false, "explicit autoCombos:false honoured");
// Untouched flags keep their declared defaults.
assert.equal(flags.combos, true);
assert.equal(flags.enrichment, true);
});
test("resolveEffectiveFeatureFlags: explicit true enables a default-OFF flag", () => {
const flags = resolveEffectiveFeatureFlags({ compressionMetadata: true });
assert.equal(flags.compressionMetadata, true, "explicit compressionMetadata:true honoured");
assert.equal(flags.usableOnly, false, "other opt-in flags stay OFF");
});
test("resolveEffectiveFeatureFlags: non-boolean sibling keys do not leak into flags", () => {
// features may also carry mcpToken/logLevel/apiFormat — the resolver must
// only ever return the boolean toggle keys.
const flags = resolveEffectiveFeatureFlags({
mcpAutoEmit: true,
mcpToken: "sk-mcp-token-abc",
logLevel: "debug",
});
assert.equal(flags.mcpAutoEmit, true);
assert.equal(Object.keys(flags).length, Object.keys(OMNIROUTE_FEATURE_DEFAULTS).length);
assert.equal("mcpToken" in flags, false);
assert.equal("logLevel" in flags, false);
});

View File

@@ -50,52 +50,6 @@ test("createOmniRouteFetchInterceptor: targets baseURL → Authorization header
} }
}); });
test("createOmniRouteFetchInterceptor: path-prefixed baseURL scopes auth to its normalized inference paths", async () => {
const { calls, restore } = installFetchRecorder();
try {
const prefixedBase = "https://or.example.com/tenant-a/v1";
const f = createOmniRouteFetchInterceptor({
apiKey: KEY,
baseURL: `${prefixedBase}///`,
});
const streamingBody = '{"stream":true}';
await f(`${prefixedBase}/chat/completions?trace=1`, {
method: "POST",
body: streamingBody,
headers: { Accept: "text/event-stream" },
});
await f(`${prefixedBase}/models/?refresh=1`);
await f("https://or.example.com/v1/chat/completions", { method: "POST", body: "{}" });
await f("https://or.example.com/v1/models");
await f("https://or.example.com/tenant-b/v1/chat/completions", {
method: "POST",
body: "{}",
});
await f(`${prefixedBase}/chat/completions/batch`, { method: "POST", body: "{}" });
const headers = calls.map(({ init }) => new Headers(init?.headers));
assert.equal(headers[0]?.get("Authorization"), `Bearer ${KEY}`);
assert.equal(headers[1]?.get("Authorization"), `Bearer ${KEY}`);
for (const index of [2, 3, 4, 5]) {
assert.equal(headers[index]?.get("Authorization"), null);
}
assert.equal(calls[0]?.input, `${prefixedBase}/chat/completions?trace=1`);
assert.equal(calls[0]?.init?.body, streamingBody);
assert.equal(headers[0]?.get("Accept"), "text/event-stream");
const suffixingInterceptor = createOmniRouteFetchInterceptor({
apiKey: KEY,
baseURL: "https://or.example.com/tenant-a/",
});
await suffixingInterceptor(`${prefixedBase}/models`);
const suffixedHeaders = new Headers(calls[6]?.init?.headers);
assert.equal(suffixedHeaders.get("Authorization"), `Bearer ${KEY}`);
} finally {
restore();
}
});
test("createOmniRouteFetchInterceptor: targets baseURL → Authorization OVERRIDES caller-supplied Bearer", async () => { test("createOmniRouteFetchInterceptor: targets baseURL → Authorization OVERRIDES caller-supplied Bearer", async () => {
const { calls, restore } = installFetchRecorder(); const { calls, restore } = installFetchRecorder();
try { try {
@@ -305,7 +259,7 @@ test("loader integration: wired interceptor actually injects Bearer when invoked
{} as never {} as never
); );
const wiredFetch = (result as { fetch: typeof fetch }).fetch; const wiredFetch = (result as { fetch: typeof fetch }).fetch;
await wiredFetch(`${BASE}/models`, {}); await wiredFetch(`${BASE}/v1/models`, {});
assert.equal(calls.length, 1); assert.equal(calls.length, 1);
const sentHeaders = new Headers((calls[0]!.init as RequestInit).headers); const sentHeaders = new Headers((calls[0]!.init as RequestInit).headers);
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`); assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);

View File

@@ -1,218 +0,0 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";
import type { Config } from "@opencode-ai/plugin";
import { createOmniRouteConfigHook, OmniRoutePlugin } from "../src/index.js";
import { getLogLevel, logger, setLogLevel, type LogLevel } from "../src/logger.js";
type ConsoleMethod = "error" | "info" | "log" | "warn";
type ConsoleEntries = Record<ConsoleMethod, unknown[][]>;
const fakeInput = {} as Parameters<typeof OmniRoutePlugin>[0];
const consoleMethods: ConsoleMethod[] = ["error", "info", "log", "warn"];
async function captureConsole(run: () => Promise<void>): Promise<ConsoleEntries> {
const entries: ConsoleEntries = { error: [], info: [], log: [], warn: [] };
const originals = Object.fromEntries(
consoleMethods.map((method) => [method, console[method]])
) as Record<ConsoleMethod, typeof console.warn>;
for (const method of consoleMethods) {
console[method] = (...args: unknown[]) => {
entries[method].push(args);
};
}
try {
await run();
} finally {
for (const method of consoleMethods) console[method] = originals[method];
}
return entries;
}
function rendered(entries: ConsoleEntries): string[] {
return consoleMethods.flatMap((method) =>
entries[method].map((args) => args.map((arg) => String(arg)).join(" "))
);
}
async function capturePluginLifecycle(args: {
level: LogLevel;
autoSyncIntervalMs: number;
invokeConfig?: boolean;
}): Promise<string[]> {
const previousDataDir = process.env.OPENCODE_DATA_DIR;
const previousLevel = getLogLevel();
const dataDir = await mkdtemp(join(tmpdir(), "omniroute-log-level-"));
process.env.OPENCODE_DATA_DIR = dataDir;
try {
const entries = await captureConsole(async () => {
const hooks = await OmniRoutePlugin(fakeInput, {
autoSyncIntervalMs: args.autoSyncIntervalMs,
features: { logLevel: args.level },
});
if (args.invokeConfig) {
assert.equal(typeof hooks.config, "function");
await hooks.config!({} as Config);
}
});
return rendered(entries);
} finally {
setLogLevel(previousLevel);
if (previousDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = previousDataDir;
await rm(dataDir, { recursive: true, force: true });
}
}
test("logLevel error suppresses the initialization banner", async () => {
const lines = await capturePluginLifecycle({ level: "error", autoSyncIntervalMs: 0 });
assert.equal(lines.filter((line) => line.includes("initialized")).length, 0);
});
test("logLevel error suppresses the auto-sync enabled lifecycle message", async () => {
const lines = await capturePluginLifecycle({ level: "error", autoSyncIntervalMs: 60_000 });
assert.equal(lines.filter((line) => line.includes("auto-sync enabled")).length, 0);
});
test("logLevel error suppresses factory config-shim diagnostics", async () => {
const lines = await capturePluginLifecycle({
level: "error",
autoSyncIntervalMs: 0,
invokeConfig: true,
});
assert.equal(lines.filter((line) => line.includes("config shim skipped")).length, 0);
});
test("logLevel debug preserves startup and config-shim diagnostics", async () => {
const lines = await capturePluginLifecycle({
level: "debug",
autoSyncIntervalMs: 60_000,
invokeConfig: true,
});
assert.ok(
lines.some((line) => line.includes("initialized")),
"initialization banner emitted"
);
assert.ok(
lines.some((line) => line.includes("auto-sync enabled")),
"auto-sync message emitted"
);
assert.ok(
lines.some((line) => line.includes("config shim skipped")),
"config breadcrumb emitted"
);
});
test("debug instance retains config diagnostics after an error instance is created", async () => {
const lines = rendered(
await captureConsole(async () => {
const debugHooks = await OmniRoutePlugin(fakeInput, {
autoSyncIntervalMs: 0,
features: { logLevel: "debug" },
});
await OmniRoutePlugin(fakeInput, {
autoSyncIntervalMs: 0,
features: { logLevel: "error" },
});
await debugHooks.config!({} as Config);
})
);
assert.equal(lines.filter((line) => line.includes("config shim skipped")).length, 1);
});
test("error instance keeps config diagnostics suppressed after a debug instance is created", async () => {
const lines = rendered(
await captureConsole(async () => {
const errorHooks = await OmniRoutePlugin(fakeInput, {
autoSyncIntervalMs: 0,
features: { logLevel: "error" },
});
await OmniRoutePlugin(fakeInput, {
autoSyncIntervalMs: 0,
features: { logLevel: "debug" },
});
await errorHooks.config!({} as Config);
})
);
assert.equal(lines.filter((line) => line.includes("config shim skipped")).length, 0);
});
test("error-level config fetch failures remain visible as concise injected-logger messages", async () => {
const entries: unknown[][] = [];
const hook = createOmniRouteConfigHook(
{
baseURL: "https://omniroute.example/v1",
features: {
autoCombos: false,
diskCache: false,
enrichment: false,
logLevel: "error",
},
},
{
readAuthJson: async () => ({
"opencode-omniroute": { type: "api", key: "test-key" },
}),
fetcher: async () => {
throw new Error("models unavailable");
},
combosFetcher: async () => {
throw new Error("combos unavailable");
},
logger: {
warn: (...args: unknown[]) => {
entries.push(args);
},
},
}
);
await hook({} as Config);
assert.equal(entries.length, 2, "both genuine fetch failures remain visible");
assert.deepEqual(
entries.map((args) => args.length),
[1, 1],
"each failure is emitted as one concise argument"
);
const lines = entries.map(([message]) => String(message));
assert.ok(
lines.some((line) => line.includes("/v1/models") && line.includes("models unavailable"))
);
assert.ok(
lines.some((line) => line.includes("/api/combos") && line.includes("combos unavailable"))
);
assert.equal(
entries.flat().some((arg) => arg instanceof Error),
false,
"no raw Error object emitted"
);
});
test("logger error output remains visible at error level", async () => {
const previousLevel = getLogLevel();
try {
setLogLevel("error");
const lines = rendered(
await captureConsole(async () => {
logger.error("genuine startup failure");
})
);
assert.ok(lines.some((line) => line.includes("genuine startup failure")));
} finally {
setLogLevel(previousLevel);
}
});

View File

@@ -1,271 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import {
createOmniRouteAuthHook,
createOmniRouteConfigHook,
createOmniRouteProviderHook,
parseOmniRoutePluginOptions,
type OmniRouteCompressionMetaFetcher,
type OmniRouteEnrichmentFetcher,
type OmniRouteProvidersFetcher,
type OmniRouteRawModelEntry,
} from "../src/index.js";
const BASE_URL = "https://or.example.com/v1";
const API_KEY = "sk-inference-only";
const MANAGEMENT_READ_TOKEN = "sk-management-read-only";
const RAW_MODELS: OmniRouteRawModelEntry[] = [
{
id: "openai/gpt-test",
context_length: 16_000,
max_output_tokens: 4_000,
capabilities: {
tool_calling: true,
reasoning: false,
vision: false,
thinking: false,
temperature: true,
},
input_modalities: ["text"],
output_modalities: ["text"],
},
];
function apiAuth(key: string) {
return { type: "api" as const, key };
}
test("options: managementReadToken is accepted and preserved", () => {
const parsed = parseOmniRoutePluginOptions({ managementReadToken: MANAGEMENT_READ_TOKEN });
assert.equal(parsed.managementReadToken, MANAGEMENT_READ_TOKEN);
});
test("provider hook: management GET fetchers use managementReadToken while /v1 uses apiKey", async () => {
const calls: Array<[string, string]> = [];
const enrichmentFetcher: OmniRouteEnrichmentFetcher = async (_baseURL, token) => {
calls.push(["pricing", token]);
return new Map();
};
const compressionMetaFetcher: OmniRouteCompressionMetaFetcher = async (_baseURL, token) => {
calls.push(["context", token]);
return [];
};
const providersFetcher: OmniRouteProvidersFetcher = async (_baseURL, token) => {
calls.push(["providers", token]);
return [];
};
const hook = createOmniRouteProviderHook(
{
baseURL: BASE_URL,
managementReadToken: MANAGEMENT_READ_TOKEN,
features: { compressionMetadata: true, usableOnly: true },
},
{
fetcher: async (_baseURL, token) => {
calls.push(["models", token]);
return RAW_MODELS;
},
combosFetcher: async (_baseURL, token) => {
calls.push(["combos", token]);
return [];
},
autoCombosFetcher: async (_baseURL, token) => {
calls.push(["auto-combos", token]);
return [];
},
enrichmentFetcher,
compressionMetaFetcher,
providersFetcher,
}
);
await hook.models!({} as never, { auth: apiAuth(API_KEY) as never });
assert.deepEqual(calls, [
["models", API_KEY],
["combos", MANAGEMENT_READ_TOKEN],
["auto-combos", MANAGEMENT_READ_TOKEN],
["pricing", MANAGEMENT_READ_TOKEN],
["context", MANAGEMENT_READ_TOKEN],
["providers", MANAGEMENT_READ_TOKEN],
]);
});
test("provider hook: absent managementReadToken preserves apiKey fallback", async () => {
const calls: Array<[string, string]> = [];
const hook = createOmniRouteProviderHook(
{ baseURL: BASE_URL, features: { enrichment: false, autoCombos: false } },
{
fetcher: async (_baseURL, token) => {
calls.push(["models", token]);
return RAW_MODELS;
},
combosFetcher: async (_baseURL, token) => {
calls.push(["combos", token]);
return [];
},
}
);
await hook.models!({} as never, { auth: apiAuth(API_KEY) as never });
assert.deepEqual(calls, [
["models", API_KEY],
["combos", API_KEY],
]);
});
test("config hook: managementReadToken stays out of provider inference and MCP config", async () => {
const calls: Array<[string, string]> = [];
const hook = createOmniRouteConfigHook(
{
baseURL: BASE_URL,
managementReadToken: MANAGEMENT_READ_TOKEN,
features: { enrichment: false, autoCombos: false, diskCache: false, mcpAutoEmit: true },
},
{
readAuthJson: async () => ({
"opencode-omniroute": { type: "api" as const, key: API_KEY },
}),
fetcher: async (_baseURL, token) => {
calls.push(["models", token]);
return RAW_MODELS;
},
combosFetcher: async (_baseURL, token) => {
calls.push(["combos", token]);
return [];
},
logger: { warn: () => {} },
}
);
const input: { provider?: Record<string, any>; mcp?: Record<string, any> } = {};
await hook(input as never);
assert.deepEqual(calls, [
["models", API_KEY],
["combos", MANAGEMENT_READ_TOKEN],
]);
assert.equal(input.provider?.["opencode-omniroute"]?.options?.apiKey, API_KEY);
assert.equal(
input.mcp?.["opencode-omniroute"]?.headers?.Authorization,
`Bearer ${API_KEY}`,
"mcpAutoEmit remains independent of managementReadToken"
);
});
test("auth fetch: only intended same-origin inference paths receive apiKey", async () => {
const calls: Array<{ input: RequestInfo | URL; init?: RequestInit }> = [];
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ input, init });
return new Response("ok");
}) as typeof fetch;
try {
const hook = createOmniRouteAuthHook({
baseURL: `${BASE_URL}/`,
managementReadToken: MANAGEMENT_READ_TOKEN,
});
const loaded = await hook.loader!(async () => apiAuth(API_KEY) as never, {} as never);
const interceptedFetch = (loaded as { fetch: typeof fetch }).fetch;
const streamingBody = '{"stream":true}';
await interceptedFetch(`${BASE_URL}/chat/completions?trace=1`, {
method: "POST",
body: streamingBody,
headers: { Accept: "text/event-stream" },
});
await interceptedFetch(`${BASE_URL}/models/?refresh=1`);
await interceptedFetch("https://or.example.com/api/combos");
await interceptedFetch("https://or.example.com/api/mcp/stream");
await interceptedFetch("https://or.example.com/v1/embeddings");
await interceptedFetch("https://third-party.example/v1/chat/completions", {
method: "POST",
body: "{}",
});
const headers = calls.map(({ init }) => new Headers(init?.headers));
assert.equal(headers[0]?.get("Authorization"), `Bearer ${API_KEY}`);
assert.equal(headers[1]?.get("Authorization"), `Bearer ${API_KEY}`);
for (const index of [2, 3, 4, 5]) {
assert.equal(headers[index]?.get("Authorization"), null);
}
assert.equal(calls[0]?.input, `${BASE_URL}/chat/completions?trace=1`);
assert.equal(calls[0]?.init?.body, streamingBody);
assert.equal(headers[0]?.get("Accept"), "text/event-stream");
assert.equal(
calls.some(({ init }) =>
[...new Headers(init?.headers).values()].some((value) =>
value.includes(MANAGEMENT_READ_TOKEN)
)
),
false,
"managementReadToken must never enter inference fetch headers"
);
} finally {
globalThis.fetch = originalFetch;
}
});
test("disk cache: snapshot written under management token A is rejected under token B", async () => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-token-snapshot-"));
const previousDataDir = process.env.OPENCODE_DATA_DIR;
process.env.OPENCODE_DATA_DIR = tmp;
try {
const commonDeps = {
readAuthJson: async () => ({
"opencode-omniroute": {
type: "api" as const,
key: API_KEY,
baseURL: BASE_URL,
},
}),
combosFetcher: async () => [],
logger: { warn: () => {} },
};
const features = {
enrichment: false,
autoCombos: false,
diskCache: true,
} as const;
const tokenAHook = createOmniRouteConfigHook(
{ managementReadToken: "token-A", features },
{
...commonDeps,
fetcher: async () => RAW_MODELS,
}
);
await tokenAHook({} as never);
const tokenBHook = createOmniRouteConfigHook(
{ managementReadToken: "token-B", features },
{
...commonDeps,
fetcher: async () => {
throw new Error("offline");
},
}
);
const input: { provider?: Record<string, { models: Record<string, unknown> }> } = {};
await tokenBHook(input as never);
assert.deepEqual(
input.provider?.["opencode-omniroute"]?.models,
{},
"catalog from token A must not hydrate after switching to token B"
);
} finally {
if (previousDataDir === undefined) delete process.env.OPENCODE_DATA_DIR;
else process.env.OPENCODE_DATA_DIR = previousDataDir;
fs.rmSync(tmp, { recursive: true, force: true });
}
});

View File

@@ -1,317 +0,0 @@
/**
* #9473 — Model allowlist/blocklist for the opencode-plugin.
*
* Tests for the pure filter helpers (`compileModelListFilter`,
* `passesModelAllowlist`, `passesComboAllowlist`) and the schema + hook-level
* integration. The allowlist/blocklist composes with `usableOnly` (all filters
* AND together), blocklist wins over allowlist (deny takes precedence), and
* bare-suffix entries (e.g. "claude-opus-4-7") match any "{prefix}/claude-opus-4-7".
*/
import test from "node:test";
import assert from "node:assert/strict";
import {
compileModelListFilter,
passesModelAllowlist,
passesComboAllowlist,
parseOmniRoutePluginOptions,
buildStaticProviderEntry,
resolveOmniRoutePluginOptions,
type OmniRouteRawCombo,
type OmniRouteRawModelEntry,
} from "../src/index.js";
// ─────────────────────────────────────────────────────────────────────────
// compileModelListFilter
// ─────────────────────────────────────────────────────────────────────────
test("compileModelListFilter: undefined list → undefined", () => {
assert.equal(compileModelListFilter(undefined), undefined);
});
test("compileModelListFilter: empty array → undefined", () => {
assert.equal(compileModelListFilter([]), undefined);
});
test("compileModelListFilter: raw IDs with slash → exact set populated", () => {
const f = compileModelListFilter(["cc/claude-opus-4-7", "glm/gpt-5"]);
assert.ok(f);
assert.equal(f.exact.has("cc/claude-opus-4-7"), true);
assert.equal(f.exact.has("glm/gpt-5"), true);
assert.equal(f.suffixes.size, 0);
});
test("compileModelListFilter: bare IDs (no slash) → suffixes set populated", () => {
const f = compileModelListFilter(["claude-opus-4-7", "gpt-5"]);
assert.ok(f);
assert.equal(f.suffixes.has("claude-opus-4-7"), true);
assert.equal(f.suffixes.has("gpt-5"), true);
assert.equal(f.exact.size, 0);
});
test("compileModelListFilter: mixed raw + bare → both sets populated", () => {
const f = compileModelListFilter(["cc/claude-opus-4-7", "gpt-5"]);
assert.ok(f);
assert.equal(f.exact.has("cc/claude-opus-4-7"), true);
assert.equal(f.suffixes.has("gpt-5"), true);
});
// ─────────────────────────────────────────────────────────────────────────
// passesModelAllowlist
// ─────────────────────────────────────────────────────────────────────────
test("passesModelAllowlist: no visible, no hidden → keep (passthrough)", () => {
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", undefined, undefined), true);
});
test("passesModelAllowlist: visible undefined, hidden undefined → keep", () => {
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", undefined, undefined), true);
});
test("passesModelAllowlist: visible set, id matches exact → keep", () => {
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", vis, undefined), true);
});
test("passesModelAllowlist: visible set, id matches suffix → keep", () => {
const vis = compileModelListFilter(["claude-opus-4-7"]);
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", vis, undefined), true);
});
test("passesModelAllowlist: visible set, id does NOT match → drop", () => {
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
assert.equal(passesModelAllowlist("glm/gpt-5", vis, undefined), false);
});
test("passesModelAllowlist: visible set, bare suffix matches different prefix → keep", () => {
const vis = compileModelListFilter(["claude-opus-4-7"]);
assert.equal(passesModelAllowlist("kr/claude-opus-4-7", vis, undefined), true);
});
test("passesModelAllowlist: hidden set, id matches exact → drop", () => {
const hid = compileModelListFilter(["cc/claude-opus-4-7"]);
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", undefined, hid), false);
});
test("passesModelAllowlist: hidden set, id matches suffix → drop", () => {
const hid = compileModelListFilter(["claude-opus-4-7"]);
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", undefined, hid), false);
});
test("passesModelAllowlist: hidden set, id does NOT match → keep", () => {
const hid = compileModelListFilter(["cc/claude-opus-4-7"]);
assert.equal(passesModelAllowlist("glm/gpt-5", undefined, hid), true);
});
test("passesModelAllowlist: id in BOTH visible and hidden → DROP (deny wins)", () => {
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
const hid = compileModelListFilter(["cc/claude-opus-4-7"]);
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", vis, hid), false);
});
test("passesModelAllowlist: visible allows, hidden blocks different id → keep the visible one", () => {
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
const hid = compileModelListFilter(["glm/gpt-5"]);
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", vis, hid), true);
assert.equal(passesModelAllowlist("glm/gpt-5", vis, hid), false);
});
test("passesModelAllowlist: bare-suffix hidden blocks exact match too", () => {
const hid = compileModelListFilter(["claude-opus-4-7"]);
assert.equal(passesModelAllowlist("cc/claude-opus-4-7", undefined, hid), false);
assert.equal(passesModelAllowlist("kr/claude-opus-4-7", undefined, hid), false);
});
test("passesModelAllowlist: no-slash id, visible set has bare match → keep", () => {
const vis = compileModelListFilter(["claude-primary"]);
assert.equal(passesModelAllowlist("claude-primary", vis, undefined), true);
});
test("passesModelAllowlist: no-slash id, visible set has no match → drop", () => {
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
assert.equal(passesModelAllowlist("claude-primary", vis, undefined), false);
});
// ─────────────────────────────────────────────────────────────────────────
// passesComboAllowlist
// ─────────────────────────────────────────────────────────────────────────
function combo(models: OmniRouteRawCombo["models"]): OmniRouteRawCombo {
return { id: "c1", name: "Test Combo", models };
}
test("passesComboAllowlist: visible undefined → keep", () => {
const c = combo([{ kind: "model", model: "cc/claude-opus-4-7" }]);
assert.equal(passesComboAllowlist(c, undefined), true);
});
test("passesComboAllowlist: ≥1 member matches visible → keep", () => {
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
const c = combo([
{ kind: "model", model: "dead/legacy" },
{ kind: "model", model: "cc/claude-opus-4-7" },
]);
assert.equal(passesComboAllowlist(c, vis), true);
});
test("passesComboAllowlist: zero members match visible → drop", () => {
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
const c = combo([
{ kind: "model", model: "glm/gpt-5" },
{ kind: "model", model: "kr/claude-opus-4-7" },
]);
assert.equal(passesComboAllowlist(c, vis), false);
});
test("passesComboAllowlist: bare suffix matches any prefix → keep", () => {
const vis = compileModelListFilter(["claude-opus-4-7"]);
const c = combo([{ kind: "model", model: "kr/claude-opus-4-7" }]);
assert.equal(passesComboAllowlist(c, vis), true);
});
test("passesComboAllowlist: zero members → keep", () => {
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
assert.equal(passesComboAllowlist(combo([]), vis), true);
assert.equal(passesComboAllowlist(combo(undefined), vis), true);
});
test("passesComboAllowlist: only combo-ref steps → keep", () => {
const vis = compileModelListFilter(["cc/claude-opus-4-7"]);
const c = combo([{ kind: "combo-ref", comboName: "nested" }]);
assert.equal(passesComboAllowlist(c, vis), true);
});
// ─────────────────────────────────────────────────────────────────────────
// Schema — visibleModels / hiddenModels
// ─────────────────────────────────────────────────────────────────────────
test("parseOmniRoutePluginOptions: visibleModels string[] → preserved", () => {
const r = parseOmniRoutePluginOptions({
features: { visibleModels: ["cc/claude-opus-4-7", "gpt-5"] },
});
assert.deepEqual(r.features?.visibleModels, ["cc/claude-opus-4-7", "gpt-5"]);
});
test("parseOmniRoutePluginOptions: hiddenModels string[] → preserved", () => {
const r = parseOmniRoutePluginOptions({
features: { hiddenModels: ["glm/gpt-5"] },
});
assert.deepEqual(r.features?.hiddenModels, ["glm/gpt-5"]);
});
test("parseOmniRoutePluginOptions: both lists together → preserved", () => {
const r = parseOmniRoutePluginOptions({
features: {
visibleModels: ["cc/claude-opus-4-7"],
hiddenModels: ["glm/gpt-5"],
},
});
assert.deepEqual(r.features?.visibleModels, ["cc/claude-opus-4-7"]);
assert.deepEqual(r.features?.hiddenModels, ["glm/gpt-5"]);
});
test("parseOmniRoutePluginOptions: empty string in visibleModels → rejects", () => {
assert.throws(
() =>
parseOmniRoutePluginOptions({
features: { visibleModels: [""] },
}),
/Invalid @omniroute\/opencode-plugin options/
);
});
test("parseOmniRoutePluginOptions: empty string in hiddenModels → rejects", () => {
assert.throws(
() =>
parseOmniRoutePluginOptions({
features: { hiddenModels: [""] },
}),
/Invalid @omniroute\/opencode-plugin options/
);
});
test("parseOmniRoutePluginOptions: unknown features key still rejects (strict invariant)", () => {
assert.throws(
() =>
parseOmniRoutePluginOptions({
features: { visibleModels: ["x"], unknownKey: true },
}),
/Invalid @omniroute\/opencode-plugin options/
);
});
// ─────────────────────────────────────────────────────────────────────────
// buildStaticProviderEntry — allowlist/blocklist integration
// ─────────────────────────────────────────────────────────────────────────
const FAKE_RAW_MODELS: OmniRouteRawModelEntry[] = [
{ id: "cc/claude-opus-4-7", owned_by: "anthropic" },
{ id: "glm/gpt-5", owned_by: "openai" },
{ id: "kr/claude-opus-4-7", owned_by: "anthropic" },
{ id: "claude-primary", owned_by: "combo" },
];
test("buildStaticProviderEntry: no allowlist → all models emitted", () => {
const opts = resolveOmniRoutePluginOptions({ features: {} });
const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test");
const ids = Object.keys(entry.models);
assert.ok(ids.includes("cc/claude-opus-4-7"), "cc/claude-opus-4-7 should be present");
assert.ok(ids.includes("glm/gpt-5"), "glm/gpt-5 should be present");
assert.ok(ids.includes("kr/claude-opus-4-7"), "kr/claude-opus-4-7 should be present");
});
test("buildStaticProviderEntry: visibleModels filters to only listed IDs", () => {
const opts = resolveOmniRoutePluginOptions({
features: { visibleModels: ["cc/claude-opus-4-7"] },
});
const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test");
const ids = Object.keys(entry.models);
assert.ok(ids.includes("cc/claude-opus-4-7"), "cc/claude-opus-4-7 should be present");
assert.equal(ids.includes("glm/gpt-5"), false, "glm/gpt-5 should be filtered out");
assert.equal(ids.includes("kr/claude-opus-4-7"), false, "kr/claude-opus-4-7 should be filtered out");
});
test("buildStaticProviderEntry: hiddenModels drops listed IDs", () => {
const opts = resolveOmniRoutePluginOptions({
features: { hiddenModels: ["glm/gpt-5"] },
});
const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test");
const ids = Object.keys(entry.models);
assert.ok(ids.includes("cc/claude-opus-4-7"), "cc/claude-opus-4-7 should be present");
assert.equal(ids.includes("glm/gpt-5"), false, "glm/gpt-5 should be hidden");
assert.ok(ids.includes("kr/claude-opus-4-7"), "kr/claude-opus-4-7 should be present");
});
test("buildStaticProviderEntry: bare-suffix visibleModels matches any prefix", () => {
const opts = resolveOmniRoutePluginOptions({
features: { visibleModels: ["claude-opus-4-7"] },
});
const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test");
const ids = Object.keys(entry.models);
assert.ok(ids.includes("cc/claude-opus-4-7"), "cc/claude-opus-4-7 should match via suffix");
assert.ok(ids.includes("kr/claude-opus-4-7"), "kr/claude-opus-4-7 should match via suffix");
assert.equal(ids.includes("glm/gpt-5"), false, "glm/gpt-5 should be filtered out");
});
test("buildStaticProviderEntry: id in both visible and hidden → hidden wins", () => {
const opts = resolveOmniRoutePluginOptions({
features: {
visibleModels: ["cc/claude-opus-4-7"],
hiddenModels: ["cc/claude-opus-4-7"],
},
});
const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test");
const ids = Object.keys(entry.models);
assert.equal(ids.includes("cc/claude-opus-4-7"), false, "deny takes precedence");
});
test("buildStaticProviderEntry: empty visibleModels → no filter (passthrough)", () => {
const opts = resolveOmniRoutePluginOptions({
features: { visibleModels: [] },
});
const entry = buildStaticProviderEntry(FAKE_RAW_MODELS, [], opts, "http://localhost:20128/v1", "sk-test");
const ids = Object.keys(entry.models);
assert.ok(ids.includes("cc/claude-opus-4-7"), "empty visibleModels should not filter");
assert.ok(ids.includes("glm/gpt-5"), "empty visibleModels should not filter");
});

View File

@@ -22,11 +22,9 @@
import test from "node:test"; import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { import {
buildStaticProviderEntry,
createOmniRouteProviderHook, createOmniRouteProviderHook,
mapRawModelToModelV2, mapRawModelToModelV2,
resolveOmniRoutePluginOptions, resolveOmniRoutePluginOptions,
type OmniRouteRawCombo,
} from "../src/index.js"; } from "../src/index.js";
/** /**
@@ -99,45 +97,3 @@ test("#6859: createOmniRouteProviderHook end-to-end — catalog keys/providerID
"the OC-gate prefix must never leak into ModelV2.providerID" "the OC-gate prefix must never leak into ModelV2.providerID"
); );
}); });
// #7976: buildStaticProviderEntry (the STATIC provider() config-hook path,
// exercised when the plugin writes `opencode.json` up front rather than
// registering the dynamic `provider.models()` hook) never received the
// #6859 fix. OC dispatches a static-catalog `models` map key verbatim as
// the `model` field of the outbound request — only the top-level
// `provider["<id>"]` segment is stripped for routing — so a bare-slug combo
// key built with the OC-gated `providerId` reaches OmniRoute's server
// doubled and fails credential lookup for the nonexistent provider
// `opencode-omniroute`. Confirmed against the issue's own curl repro
// (`model: "opencode-omniroute/hermes-smart-stack"` → "No active
// credentials for provider: opencode-omniroute").
// #9175 tightened this further: OC's `getModel` looks models up by BARE id,
// so combo dict keys now carry NO prefix at all (not even `omniroute/`).
test("#7976/#9175: buildStaticProviderEntry keys combos by bare slug (no prefix at all — never the OC-gate providerId)", () => {
const resolved = resolveOmniRoutePluginOptions({ providerId: "omniroute" });
assert.equal(resolved.providerId, "opencode-omniroute");
assert.equal(resolved.omnirouteProviderId, "omniroute");
const combo = {
id: "combo-abc123",
name: "Hermes Smart Stack",
isHidden: false,
models: [],
} as unknown as OmniRouteRawCombo;
const block = buildStaticProviderEntry(
[],
[combo],
resolved,
"https://or.example/v1",
"sk-test"
);
assert.deepEqual(Object.keys(block.models), ["hermes-smart-stack"]);
assert.equal(
block.models["opencode-omniroute/hermes-smart-stack"],
undefined,
"combo key must not carry the OC-gate-prefixed providerId — it doubles up once " +
"OC dispatches it verbatim as the `model` field"
);
});

View File

@@ -1,827 +0,0 @@
/**
* Warm-startup + parallel-refresh tests for the opencode-plugin config shim.
*
* Covers `createOmniRouteConfigHook(opts, deps)`:
* - (a) Warm startup: cache miss + matching snapshot → provider block
* populated from snapshot data (not live fetch data).
* - (b) Fingerprint mismatch: reader returns undefined → no warm publish,
* falls through to awaited fetch (cold-start behavior).
* - (c) Successful parallel refresh: all fetchers resolve → cache updated,
* disk snapshot written.
* - (d) Failed refresh keeps the snapshot: warm-served + models fetcher
* rejects → no disk overwrite, block stays at warm-snapshot shape.
* - (e) Parallelism: all six fetchers start concurrently (not sequential).
* - (f) Soft-fail parity under Promise.allSettled: per-endpoint
* fallbacks + logger.warn breadcrumbs preserved.
* - (g) No double-refresh: concurrent hook invocations on the same cacheKey
* trigger only one refresh (in-flight guard).
* - (h) features.diskCache: false disables the warm read entirely.
*
* Mocking strategy: every dependency is DI-injected at hook construction
* (same pattern as config-shim.test.ts). No global monkey-patching.
*/
import test from "node:test";
import assert from "node:assert/strict";
import type { Config } from "@opencode-ai/plugin";
import {
createOmniRouteConfigHook,
resolveOmniRoutePluginOptions,
_resetInflightRefresh,
type OmniRouteAutoCombosFetcher,
type OmniRouteCombosFetcher,
type OmniRouteCompressionMetaFetcher,
type OmniRouteEnrichmentEntry,
type OmniRouteEnrichmentFetcher,
type OmniRouteEnrichmentMap,
type OmniRouteFetchCache,
type OmniRouteModelsFetcher,
type OmniRouteProviderConnection,
type OmniRouteProvidersFetcher,
type OmniRouteRawAutoCombo,
type OmniRouteRawCombo,
type OmniRouteRawModelEntry,
type OmniRouteReadAuthJson,
type OmniRouteStaticProviderEntry,
type OmniRouteDiskSnapshotReader,
type OmniRouteDiskSnapshotWriter,
type OmniRouteCompressionCombo,
} from "../src/index.js";
// ────────────────────────────────────────────────────────────────────────────
// Test isolation: reset the module-level in-flight refresh guard between
// tests so a detached refresh from a previous test doesn't leak into the
// next one (same cacheKey, different cache instance).
// ────────────────────────────────────────────────────────────────────────────
test.beforeEach(() => {
_resetInflightRefresh();
});
// ────────────────────────────────────────────────────────────────────────────
// Fixtures
// ────────────────────────────────────────────────────────────────────────────
const MODEL_CLAUDE: OmniRouteRawModelEntry = {
id: "claude-sonnet-4-6",
capabilities: {
tool_calling: true,
reasoning: true,
vision: true,
thinking: false,
temperature: true,
},
context_length: 200_000,
max_output_tokens: 64_000,
max_input_tokens: 180_000,
input_modalities: ["text", "image"],
output_modalities: ["text"],
};
const MODEL_GEMINI: OmniRouteRawModelEntry = {
id: "gemini-3-flash",
capabilities: { tool_calling: true, reasoning: false, vision: true, thinking: false },
context_length: 1_000_000,
max_output_tokens: 8_192,
input_modalities: ["text", "image"],
output_modalities: ["text"],
};
const COMBO_CLAUDE_TIER: OmniRouteRawCombo = {
id: "combo-claude-tier",
name: "Claude Tier",
models: [
{ id: "s1", kind: "model", model: "claude-sonnet-4-6", weight: 100 },
{ id: "s2", kind: "model", model: "gemini-3-flash", weight: 50 },
],
};
const AUTO_COMBO: OmniRouteRawAutoCombo = {
id: "auto",
name: "Auto",
};
const COMPRESSION_COMBO: OmniRouteCompressionCombo = {
id: "ctx-combo-1",
name: "Context Combo",
pipeline: "gzip",
};
const CONNECTION_CLAUDE: OmniRouteProviderConnection = {
id: "c1",
provider: "claude",
isActive: true,
testStatus: "active",
};
// ────────────────────────────────────────────────────────────────────────────
// DI stub helpers
// ────────────────────────────────────────────────────────────────────────────
function stubReadAuthJson(
value: Record<string, unknown> | undefined | null
): OmniRouteReadAuthJson {
return async () => value as never;
}
function immediateFetcher<T extends (...args: unknown[]) => Promise<unknown>>(
payload: ReturnType<T> extends Promise<infer U> ? U : never
): T & { callCount: () => number; startedAt: () => number | undefined } {
let n = 0;
let start: number | undefined;
const f = async (..._args: unknown[]) => {
start = Date.now();
n++;
return payload;
};
return Object.assign(f as T, { callCount: () => n, startedAt: () => start });
}
function throwingFetcher<T extends (...args: unknown[]) => Promise<unknown>>(
msg = "ECONNREFUSED"
): T & { callCount: () => number } {
let n = 0;
const f = async (..._args: unknown[]) => {
n++;
throw new Error(msg);
};
return Object.assign(f as T, { callCount: () => n });
}
interface WarnCapture {
warn: (...args: unknown[]) => void;
entries: unknown[][];
}
function captureWarn(): WarnCapture {
const entries: unknown[][] = [];
return {
warn: (...args: unknown[]) => {
entries.push(args);
},
entries,
};
}
function makeInput(initialProvider: Record<string, unknown> = {}): Config {
return { provider: initialProvider } as unknown as Config;
}
/** Build a valid auth.json stub for the default providerId. */
function authStub() {
return stubReadAuthJson({
"opencode-omniroute": {
type: "api",
key: "sk-test",
baseURL: "https://or.example.com/v1",
},
});
}
// ────────────────────────────────────────────────────────────────────────────
// (a) Warm startup: cache miss + matching snapshot → provider block populated
// from snapshot data (not live fetch data)
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: snapshot data used when snapshot is present", async () => {
// Live fetch returns MODEL_CLAUDE, but snapshot has MODEL_GEMINI.
// With warm startup, the block should contain the snapshot data.
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const autoCombosFetcher = immediateFetcher<OmniRouteAutoCombosFetcher>([]);
const enrichmentFetcher = immediateFetcher<OmniRouteEnrichmentFetcher>(new Map());
const compressionMetaFetcher = immediateFetcher<OmniRouteCompressionMetaFetcher>([]);
const providersFetcher = immediateFetcher<OmniRouteProvidersFetcher>([]);
const logger = captureWarn();
const snapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> = {
rawModels: [MODEL_GEMINI],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
};
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
autoCombosFetcher,
enrichmentFetcher,
compressionMetaFetcher,
providersFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const provider = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider;
const entry = provider["opencode-omniroute"];
assert.ok(entry, "provider entry published");
// With warm startup, the block should contain the snapshot data (GEMINI),
// not the live fetch data (CLAUDE). This is the key assertion: the warm
// snapshot is served first, and the live refresh updates the cache in the
// background. On the next hook invocation, the cache will have the fresh data.
const hasGemini = entry.models["opencode-omniroute/gemini-3-flash"] !== undefined;
const hasClaude = entry.models["opencode-omniroute/claude-sonnet-4-6"] !== undefined;
assert.ok(
hasGemini || hasClaude,
"provider block has at least one model"
);
// The warm-startup breadcrumb should be emitted.
assert.ok(
logger.entries.some((e) =>
String(e[0]).includes("warm startup from disk snapshot")
),
"warm-startup breadcrumb emitted"
);
});
// ────────────────────────────────────────────────────────────────────────────
// (b) Fingerprint mismatch: reader returns undefined → no warm publish,
// falls through to awaited fetch
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: fingerprint mismatch → no warm publish, awaited fetch", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const logger = captureWarn();
// Reader returns undefined → fingerprint mismatch or missing snapshot.
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "provider entry published from live fetch");
// Live fetch data, not snapshot data.
assert.ok(
entry.models["opencode-omniroute/claude-sonnet-4-6"],
"live fetch model present"
);
assert.equal(fetcher.callCount(), 1, "fetcher was called (awaited cold path)");
// No warm-startup breadcrumb when no snapshot.
assert.ok(
!logger.entries.some((e) =>
String(e[0]).includes("warm startup from disk snapshot")
),
"no warm-startup breadcrumb when no snapshot"
);
});
// ────────────────────────────────────────────────────────────────────────────
// (c) Successful parallel refresh: all fetchers resolve → cache updated,
// disk snapshot written, block re-published with fresh data
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: parallel refresh updates cache + writes snapshot", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([COMBO_CLAUDE_TIER]);
const autoCombosFetcher = immediateFetcher<OmniRouteAutoCombosFetcher>([AUTO_COMBO]);
const enrichmentFetcher = immediateFetcher<OmniRouteEnrichmentFetcher>(
new Map<string, OmniRouteEnrichmentEntry>([
["claude-sonnet-4-6", { name: "Claude Sonnet 4.6" }],
])
);
const compressionMetaFetcher = immediateFetcher<OmniRouteCompressionMetaFetcher>([
COMPRESSION_COMBO,
]);
const providersFetcher = immediateFetcher<OmniRouteProvidersFetcher>([CONNECTION_CLAUDE]);
const logger = captureWarn();
const snapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> = {
rawModels: [MODEL_GEMINI],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
};
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot;
let snapshotWrites = 0;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {
snapshotWrites++;
};
const sharedCache: OmniRouteFetchCache = new Map();
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", modelCacheTtl: 60_000 },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
autoCombosFetcher,
enrichmentFetcher,
compressionMetaFetcher,
providersFetcher,
diskSnapshotReader,
diskSnapshotWriter,
cache: sharedCache,
logger,
}
);
const input = makeInput();
await hook(input);
// Warm block should have been published.
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "warm provider entry published");
// Give detached refresh time to complete.
await new Promise((r) => setTimeout(r, 100));
// After parallel refresh, the cache should have the fresh data.
const cacheKey = Array.from(sharedCache.keys())[0];
assert.ok(cacheKey, "cache entry created");
const cached = sharedCache.get(cacheKey)!;
assert.ok(cached.expiresAt > 0, "cache entry has expiresAt");
// Fresh data from the live fetchers (not the stale snapshot).
assert.equal(cached.rawModels.length, 1, "cache has fresh models");
assert.equal(cached.rawModels[0].id, "claude-sonnet-4-6", "cache has correct model");
// Disk snapshot should have been written.
assert.equal(snapshotWrites, 1, "disk snapshot written after successful refresh");
});
// ────────────────────────────────────────────────────────────────────────────
// (d) Failed refresh keeps the snapshot: warm-served + models fetcher
// rejects → no disk overwrite, block stays at warm-snapshot shape
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: failed refresh keeps the snapshot, no disk overwrite", async () => {
const fetcher = throwingFetcher<OmniRouteModelsFetcher>();
const combosFetcher = throwingFetcher<OmniRouteCombosFetcher>();
const logger = captureWarn();
const snapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> = {
rawModels: [MODEL_GEMINI],
rawCombos: [COMBO_CLAUDE_TIER],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
};
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot;
let snapshotWrites = 0;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {
snapshotWrites++;
};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "warm provider entry published");
// The block should contain the warm snapshot data (gemini), not be
// downgraded to a stub.
assert.ok(
entry.models["opencode-omniroute/gemini-3-flash"],
"warm snapshot model preserved (not downgraded to stub)"
);
// Give detached refresh time to complete.
await new Promise((r) => setTimeout(r, 100));
// No disk write on failed refresh.
assert.equal(snapshotWrites, 0, "no disk snapshot written when models fetch failed");
});
// ────────────────────────────────────────────────────────────────────────────
// (e) Parallelism: all six fetchers start concurrently (not sequential)
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: all fetchers start concurrently (parallel fan-out)", async () => {
const startTimes: number[] = [];
const barrier = new Promise<void>((r) => {
setTimeout(r, 30);
});
function instrumentedFetcher<T extends (...args: unknown[]) => Promise<unknown>>(
payload: ReturnType<T> extends Promise<infer U> ? U : never
): T & { callCount: () => number } {
let n = 0;
const f = async (..._args: unknown[]) => {
startTimes.push(Date.now());
n++;
await barrier;
return payload;
};
return Object.assign(f as T, { callCount: () => n });
}
const fetcher = instrumentedFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = instrumentedFetcher<OmniRouteCombosFetcher>([]);
const autoCombosFetcher = instrumentedFetcher<OmniRouteAutoCombosFetcher>([]);
const enrichmentFetcher = instrumentedFetcher<OmniRouteEnrichmentFetcher>(new Map());
const compressionMetaFetcher = instrumentedFetcher<OmniRouteCompressionMetaFetcher>([]);
const providersFetcher = instrumentedFetcher<OmniRouteProvidersFetcher>([]);
const logger = captureWarn();
// No snapshot → cold path (awaited). All fetchers must still start
// concurrently.
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", features: { enrichment: true, compressionMetadata: true, usableOnly: true } },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
autoCombosFetcher,
enrichmentFetcher,
compressionMetaFetcher,
providersFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
// All fetchers should have been called.
assert.equal(fetcher.callCount(), 1, "models fetcher called");
assert.equal(combosFetcher.callCount(), 1, "combos fetcher called");
assert.equal(autoCombosFetcher.callCount(), 1, "autoCombos fetcher called");
assert.equal(enrichmentFetcher.callCount(), 1, "enrichment fetcher called");
assert.equal(compressionMetaFetcher.callCount(), 1, "compressionMeta fetcher called");
assert.equal(providersFetcher.callCount(), 1, "providers fetcher called");
// All start times should be within 20ms of each other (parallel fan-out),
// NOT sequential (which would show ~30ms gaps between each).
assert.ok(startTimes.length >= 6, "all 6 fetchers started");
const minStart = Math.min(...startTimes);
const maxStart = Math.max(...startTimes);
assert.ok(
maxStart - minStart < 20,
`all fetchers started within 20ms (spread: ${maxStart - minStart}ms) — parallel fan-out confirmed`
);
});
// ────────────────────────────────────────────────────────────────────────────
// (f) Soft-fail parity under Promise.allSettled: per-endpoint fallbacks +
// logger.warn breadcrumbs preserved
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: combos reject → models-only catalog with warn", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = throwingFetcher<OmniRouteCombosFetcher>("403 Forbidden");
const logger = captureWarn();
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "provider entry published");
assert.ok(
entry.models["opencode-omniroute/claude-sonnet-4-6"],
"models-only catalog (no combos)"
);
assert.ok(
logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")),
"combos-fetch breadcrumb emitted"
);
});
test("warm-startup: enrichment rejects → raw-id catalog with warn", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const enrichmentFetcher = throwingFetcher<OmniRouteEnrichmentFetcher>("ETIMEDOUT");
const logger = captureWarn();
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
enrichmentFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "provider entry published");
assert.equal(
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
"claude-sonnet-4-6",
"raw id retained (no enrichment)"
);
assert.ok(
logger.entries.some((e) => String(e[0]).includes("/api/pricing/models fetch failed")),
"enrichment-fetch breadcrumb emitted"
);
});
test("warm-startup: providers reject → usableOnly filter disabled with warn", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const providersFetcher = throwingFetcher<OmniRouteProvidersFetcher>("ETIMEDOUT");
const logger = captureWarn();
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", features: { usableOnly: true } },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
providersFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "provider entry published");
// Soft-fail: model kept (filter disabled).
assert.ok(
entry.models["opencode-omniroute/claude-sonnet-4-6"],
"model kept (usableOnly filter disabled)"
);
assert.ok(
logger.entries.some((e) => String(e[0]).includes("/api/providers fetch failed")),
"providers-fetch breadcrumb emitted"
);
});
// ────────────────────────────────────────────────────────────────────────────
// (g) No double-refresh: concurrent hook invocations on the same cacheKey
// trigger only one refresh (in-flight guard)
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: concurrent hook invocations dedupe refresh", async () => {
let fetchCount = 0;
const slowResolve = new Promise<void>((r) => {
setTimeout(r, 100);
});
const fetcher: OmniRouteModelsFetcher = async () => {
fetchCount++;
await slowResolve;
return [MODEL_CLAUDE];
};
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const logger = captureWarn();
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => undefined;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const sharedCache: OmniRouteFetchCache = new Map();
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", modelCacheTtl: 60_000 },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
cache: sharedCache,
logger,
}
);
// Fire two concurrent hook invocations on the same cache.
const inputA = makeInput();
const inputB = makeInput();
await Promise.all([hook(inputA), hook(inputB)]);
// Both should have published, but the refresh should only run once.
assert.equal(
fetchCount,
1,
"models fetcher called only once across concurrent invocations (in-flight guard)"
);
});
// ────────────────────────────────────────────────────────────────────────────
// (h) features.diskCache: false disables the warm read entirely
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: diskCache=false disables warm read, falls through to awaited fetch", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const logger = captureWarn();
let readerCalled = false;
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => {
readerCalled = true;
return {
rawModels: [MODEL_GEMINI],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
};
};
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute", features: { diskCache: false } },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
assert.equal(readerCalled, false, "disk snapshot reader NOT called when diskCache=false");
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "provider entry published from live fetch");
assert.ok(
entry.models["opencode-omniroute/claude-sonnet-4-6"],
"live fetch model present (not snapshot)"
);
});
// ────────────────────────────────────────────────────────────────────────────
// Warm startup: snapshot age logged
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: snapshot age is logged when warm-starting from disk", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const logger = captureWarn();
const snapshot: Omit<import("../src/index.js").OmniRouteFetchCacheEntry, "expiresAt"> & {
writtenAt?: number;
} = {
rawModels: [MODEL_GEMINI],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
writtenAt: Date.now() - 3_600_000, // 1 hour ago
};
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => snapshot;
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
// The log should mention "warm startup from disk snapshot".
assert.ok(
logger.entries.some((e) =>
String(e[0]).includes("warm startup from disk snapshot")
),
"warm-startup breadcrumb emitted"
);
});
// ────────────────────────────────────────────────────────────────────────────
// Warm startup: empty snapshot (rawModels.length === 0) is skipped
// ────────────────────────────────────────────────────────────────────────────
test("warm-startup: empty snapshot (rawModels.length=0) is skipped, falls through to fetch", async () => {
const fetcher = immediateFetcher<OmniRouteModelsFetcher>([MODEL_CLAUDE]);
const combosFetcher = immediateFetcher<OmniRouteCombosFetcher>([]);
const logger = captureWarn();
const diskSnapshotReader: OmniRouteDiskSnapshotReader = async () => ({
rawModels: [],
rawCombos: [],
rawAutoCombos: [],
rawEnrichment: new Map(),
rawCompressionCombos: [],
rawConnections: [],
});
const diskSnapshotWriter: OmniRouteDiskSnapshotWriter = async () => {};
const hook = createOmniRouteConfigHook(
{ providerId: "omniroute" },
{
readAuthJson: authStub(),
fetcher,
combosFetcher,
diskSnapshotReader,
diskSnapshotWriter,
logger,
}
);
const input = makeInput();
await hook(input);
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
"opencode-omniroute"
];
assert.ok(entry, "provider entry published from live fetch");
// Live data, not empty snapshot.
assert.ok(
entry.models["opencode-omniroute/claude-sonnet-4-6"],
"live fetch model present (empty snapshot skipped)"
);
assert.equal(fetcher.callCount(), 1, "fetcher was called (awaited cold path)");
});

1193
AGENTS.md

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

577
CLAUDE.md
View File

@@ -1,42 +1,406 @@
# CLAUDE.md # CLAUDE.md
@AGENTS.md This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
**All project rules live in [`AGENTS.md`](AGENTS.md)** — the single source of truth for every AI ## Quick Start
assistant (architecture, conventions, testing, quality gates, git workflow, the 23 Hard Rules,
PII learnings). Read it in full; do not re-add project rules here. Everything below applies ONLY
to Claude Code — operational refinements of rules already defined in `AGENTS.md`.
## Worktree isolation — Claude Code specifics ```bash
npm install # Install deps (auto-generates .env from .env.example)
npm run dev # Dev server at http://localhost:20128
npm run build # Production build (Next.js 16 standalone)
npm run lint # ESLint (0 errors expected; warnings are pre-existing)
npm run typecheck:core # TypeScript check (should be clean)
npm run typecheck:noimplicit:core # Strict check (no implicit any)
npm run test:coverage # Unit tests + coverage gate (60/60/60/60 — statements/lines/functions/branches)
npm run check # lint + test combined
npm run check:cycles # Detect circular dependencies
```
The full mandatory worktree protocol (base-branch confirmation, `.claude/worktrees/` canonical ### Running Tests
path, `cp -al` node_modules, teardown rules) is in `AGENTS.md` → Git Workflow → "Worktree
isolation". Claude-Code-specific points:
- Confirm the base branch with the operator via `AskUserQuestion` (Hard Rule #19) unless they ```bash
already told you. # Single test file (Node.js native test runner — most tests)
- Prefer the native `EnterWorktree` tool — it already creates worktrees under node --import tsx/esm --test tests/unit/your-file.test.ts
`.claude/worktrees/` (the canonical path). Create the worktree with the documented `git
worktree add` command, then call `EnterWorktree` with its `path`.
## Cross-session safety — Claude Code specifics # Vitest (MCP server, autoCombo, cache)
npm run test:vitest
Hard Rules #19/#21/#22 (in `AGENTS.md`) govern parallel sessions. Operational reminders for this # All suites
harness: npm run test:all
```
- **Replicate the `git stash` ban verbatim in the prompt of every subagent that touches git** For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep architecture, see `AGENTS.md`.
(Agent tool / Workflow scripts) — subagents do not inherit this file, and the recorded
recurrence of the stash incident came through a subagent.
- Before merging or pushing to any PR you did not create _this session_, run `git worktree list`
and re-check `gh pr view <N> --json state,headRefOid` (Hard Rule #22b).
- End every session with the main checkout on the branch it started on.
## Superpowers / planning artifacts — path overrides ---
The `_tasks/` convention is defined in `AGENTS.md` → "Planning & Research Artifacts". The ## Project at a Glance
superpowers skills ship with defaults that point at `docs/…` — those defaults are **overridden
here**. When a superpowers skill announces a path like "saved to `docs/superpowers/plans/…`", **OmniRoute** — unified AI proxy/router. One endpoint, 250 LLM providers, auto-fallback.
rewrite it to the `_tasks/…` equivalent before writing:
| Layer | Location | Purpose |
| ------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| API Routes | `src/app/api/v1/` | Next.js App Router — entry points |
| Handlers | `open-sse/handlers/` | Request processing (chat, embeddings, etc) |
| Executors | `open-sse/executors/` | Provider-specific HTTP dispatch |
| 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) |
| Domain/Policy | `src/domain/` | Policy engine, cost rules, fallback logic |
| MCP Server | `open-sse/mcp-server/` | 94 tools (34 base + memory/skill/agentSkill/pool/notion/obsidian/gamification/plugin modules), 3 transports (stdio / SSE / Streamable HTTP), 30 scopes |
| A2A Server | `src/lib/a2a/` | JSON-RPC 2.0 agent protocol |
| Skills | `src/lib/skills/` | Extensible skill framework |
| Memory | `src/lib/memory/` | Persistent conversational memory |
Monorepo: `src/` (Next.js 16 app), `open-sse/` (streaming engine workspace), `electron/` (desktop app), `tests/`, `bin/` (CLI entry point).
---
## Request Pipeline
```
Client → /v1/chat/completions (Next.js route)
→ CORS → Zod validation → auth? → policy check → prompt injection guard
→ handleChatCore() [open-sse/handlers/chatCore.ts]
→ cache check → rate limit → combo routing?
→ resolveComboTargets() → handleSingleModel() per target
→ translateRequest() → getExecutor() → executor.execute()
→ fetch() upstream → retry w/ backoff
→ response translation → SSE stream or JSON
→ If Responses API: responsesTransformer.ts TransformStream
```
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.
---
## Resilience Runtime State
OmniRoute has three related but distinct temporary-failure mechanisms. Keep their
scope separate when debugging routing behavior. See the
[3-layer resilience diagram](./docs/diagrams/exported/resilience-3layers.svg)
(source: [docs/diagrams/resilience-3layers.mmd](./docs/diagrams/resilience-3layers.mmd))
for an at-a-glance map.
### Provider Circuit Breaker
**Scope**: whole provider, e.g. `glm`, `openai`, `anthropic`.
**Purpose**: stop sending traffic to a provider that is repeatedly failing at the
upstream/service level, so one unhealthy provider does not slow down every request.
**Implementation**:
- Core class: `src/shared/utils/circuitBreaker.ts`
- Chat gate/execution wiring: `src/sse/handlers/chatHelpers.ts`, `src/sse/handlers/chat.ts`
- Runtime status API: `src/app/api/monitoring/health/route.ts`
- Shared wrappers: `open-sse/services/accountFallback.ts`
- Persisted state table: `domain_circuit_breakers`
**States**:
- `CLOSED`: normal traffic is allowed.
- `OPEN`: provider is temporarily blocked; callers get a provider-circuit-open response
or combo routing skips to another target.
- `HALF_OPEN`: reset timeout has elapsed; allow a probe request. Success closes the
breaker, failure opens it again.
**Defaults** (`open-sse/config/constants.ts`):
- OAuth providers: threshold `3`, reset timeout `60s`.
- API-key providers: threshold `5`, reset timeout `30s`.
- Local providers: threshold `2`, reset timeout `15s`.
Only provider-level failure statuses should trip the provider breaker:
```ts
(408, 500, 502, 503, 504);
```
Do not trip the whole-provider breaker for normal account/key/model errors like most
`401`, `403`, or `429` cases. Those usually belong to connection cooldown or model
lockout. A generic API-key provider `403` should be recoverable unless it is classified
as a terminal provider/account error.
The breaker uses lazy recovery, not a background timer. When `OPEN` expires, reads such
as `getStatus()`, `canExecute()`, and `getRetryAfterMs()` refresh the state to
`HALF_OPEN`, so dashboards and combo candidate builders do not keep excluding an
expired provider forever.
### Connection Cooldown
**Scope**: one provider connection/account/key.
**Purpose**: temporarily skip one bad key/account while allowing other connections for
the same provider to continue serving requests.
**Implementation**:
- Write/update path: `src/sse/services/auth.ts::markAccountUnavailable()`
- Account selection/filtering: `src/sse/services/auth.ts::getProviderCredentials...`
- Cooldown calculation: `open-sse/services/accountFallback.ts::checkFallbackError()`
- Settings: `src/lib/resilience/settings.ts`
Important fields on provider connections:
```ts
rateLimitedUntil;
testStatus: "unavailable";
lastError;
lastErrorType;
errorCode;
backoffLevel;
```
During account selection, a connection is skipped while:
```ts
new Date(rateLimitedUntil).getTime() > Date.now();
```
Cooldowns are also lazy: when `rateLimitedUntil` is in the past, the connection becomes
eligible again. On successful use, `clearAccountError()` clears `testStatus`,
`rateLimitedUntil`, error fields, and `backoffLevel`.
Default connection cooldown behavior:
- OAuth base cooldown: `5s`.
- API-key base cooldown: `3s`.
- API-key `429` should prefer upstream retry hints (`Retry-After`, reset headers, or
parseable reset text) when available.
- Repeated recoverable failures use exponential backoff:
```ts
baseCooldownMs * 2 ** failureIndex;
```
The anti-thundering-herd guard prevents concurrent failures on the same connection from
repeatedly extending the cooldown or double-incrementing `backoffLevel`.
Terminal states are not cooldowns. `banned`, `expired`, and `credits_exhausted` are
intended to stay unavailable until credentials/settings change or an operator resets
them. Do not overwrite terminal states with transient cooldown state.
### Model Lockout
**Scope**: provider + connection + model.
**Purpose**: avoid disabling a whole connection when only one model is unavailable or
quota-limited for that connection.
Examples:
- Per-model quota providers returning `429`.
- Local providers returning `404` for one missing model.
- Provider-specific mode/model permission failures such as selected Grok modes.
Model lockout lives in `open-sse/services/accountFallback.ts` and lets the same
connection continue serving other models.
### Debugging Guidance
- If all keys for a provider are skipped, inspect both provider breaker state and each
connection's `rateLimitedUntil`/`testStatus`.
- If a provider appears permanently excluded after the reset window, check whether code
is reading raw `state` instead of using `getStatus()`/`canExecute()`.
- If one provider key fails but others should work, prefer connection cooldown over
provider breaker.
- If only one model fails, prefer model lockout over connection cooldown.
- If a state should self-recover, it should have a future timestamp/reset timeout and a
read path that refreshes expired state. Permanent statuses require manual credential
or config changes.
---
## Key Conventions
### Code Style
- **2 spaces**, semicolons, double quotes, 100 char width, es5 trailing commas (enforced by lint-staged via Prettier)
- **Imports**: external → internal (`@/`, `@omniroute/open-sse`) → relative
- **Naming**: files=camelCase/kebab, components=PascalCase, constants=UPPER_SNAKE
- **ESLint**: `no-eval`, `no-implied-eval`, `no-new-func` = error everywhere; `no-explicit-any` = **error** in `open-sse/` and `tests/` (since #6218 — pre-existing violations are frozen in `config/quality/eslint-suppressions.json`, new ones must be fixed; `npm run lint` applies the suppressions and is what CI runs)
- **TypeScript**: `strict: false`, target ES2022, module esnext, resolution bundler. Prefer explicit types.
### Database
- **Always** go through `src/lib/db/` domain modules — **never** write raw SQL in routes or handlers
- **Never** add logic to `src/lib/localDb.ts` (re-export layer only)
- **Never** barrel-import from `localDb.ts` — import specific `db/` modules instead
- DB singleton: `getDbInstance()` from `src/lib/db/core.ts` (WAL journaling)
- Migrations: `src/lib/db/migrations/` — versioned SQL files, idempotent, run in transactions
### Error Handling
- try/catch with specific error types, log with pino context
- Never swallow errors in SSE streams — use abort signals for cleanup
- Return proper HTTP status codes (4xx/5xx)
### Security
- **Never** use `eval()`, `new Function()`, or implied eval
- Validate all inputs with Zod schemas
- Encrypt credentials at rest (AES-256-GCM)
- Upstream header denylist: `src/shared/constants/upstreamHeaders.ts` — keep sanitize, Zod schemas, and unit tests aligned when editing
- **Public upstream credentials** (Gemini/Antigravity/Windsurf-style OAuth client_id/secret + Firebase Web keys extracted from public CLIs): **MUST** be embedded via `resolvePublicCred()` from `open-sse/utils/publicCreds.ts`**never** as string literals. See `docs/security/PUBLIC_CREDS.md` for the mandatory pattern.
- **Error responses** (HTTP / SSE / executor / MCP handler): **MUST** route through `buildErrorBody()` or `sanitizeErrorMessage()` from `open-sse/utils/error.ts`**never** put raw `err.stack` or `err.message` in a response body. See `docs/security/ERROR_SANITIZATION.md`.
- **Shell commands built from variables**: when calling `exec()`/`spawn()` with a script that needs runtime values, pass them via the `env` option (shell-escaped automatically) — **never** string-interpolate untrusted/external paths into the script body. Reference: `src/mitm/cert/install.ts::updateNssDatabases`.
- **Secure-by-default libraries** ([tldrsec/awesome-secure-defaults](https://github.com/tldrsec/awesome-secure-defaults)): prefer Helmet.js, DOMPurify, ssrf-req-filter, safe-regex, Google Tink over custom implementations whenever adding new security-sensitive surfaces.
---
## Common Modification Scenarios
### Adding a New Provider
1. Register in `src/shared/constants/providers.ts` (Zod-validated at load)
2. Add executor in `open-sse/executors/` if custom logic needed (extend `BaseExecutor`)
3. Add translator in `open-sse/translator/` if non-OpenAI format
4. Add OAuth config in `src/lib/oauth/constants/oauth.ts` if OAuth-based — if the upstream CLI ships a public client_id/secret, embed via `resolvePublicCred()` (see `docs/security/PUBLIC_CREDS.md`), **never** as a literal
5. Register models in `open-sse/config/providerRegistry.ts`
6. Write tests in `tests/unit/` (include the publicCreds shape assertion if you added a new embedded default)
### Adding a New API Route
1. Create directory under `src/app/api/v1/your-route/`
2. Create `route.ts` with `GET`/`POST` handlers
3. Follow pattern: CORS → Zod body validation → optional auth → handler delegation
4. Handler goes in `open-sse/handlers/` (import from there, not inline)
5. Error responses use `buildErrorBody()` / `errorResponse()` from `open-sse/utils/error.ts` (auto-sanitized — never put `err.stack` or `err.message` raw in the body). See `docs/security/ERROR_SANITIZATION.md`.
6. Add tests — including at least one assertion that error responses do not leak stack traces (`!body.error.message.includes("at /")`)
### Adding a New DB Module
1. Create `src/lib/db/yourModule.ts` — import `getDbInstance` from `./core.ts`
2. Export CRUD functions for your domain table(s)
3. Add migration in `src/lib/db/migrations/` if new tables needed
4. Re-export from `src/lib/localDb.ts` (add to the re-export list only)
5. Write tests
### Adding a New MCP Tool
1. Add tool definition in `open-sse/mcp-server/tools/` with Zod input schema + async handler
2. Register in tool set (wired by `createMcpServer()`)
3. Assign to appropriate scope(s)
4. Write tests (tool invocation logged to `mcp_audit` table)
### Adding a New A2A Skill
1. Create skill in `src/lib/a2a/skills/` (5 already exist: smart-routing, quota-management, provider-discovery, cost-analysis, health-report)
2. Skill receives task context (messages, metadata) → returns structured result
3. Register in `A2A_SKILL_HANDLERS` in `src/lib/a2a/taskExecution.ts`
4. Expose in `src/app/.well-known/agent.json/route.ts` (Agent Card)
5. Write tests in `tests/unit/`
6. Document in `docs/frameworks/A2A-SERVER.md` skill table
### Adding a New Cloud Agent
1. Create agent class in `src/lib/cloudAgent/agents/` extending `CloudAgentBase` (3 already exist: codex-cloud, devin, jules)
2. Implement `createTask`, `getStatus`, `approvePlan`, `sendMessage`, `listSources`
3. Register in `src/lib/cloudAgent/registry.ts`
4. Add OAuth/credentials handling if needed (`src/lib/oauth/providers/`)
5. Tests + document in `docs/frameworks/CLOUD_AGENT.md`
### Adding a New Embedded Service
1. Create installer in `src/lib/services/installers/{name}.ts` modeled on `ninerouter.ts` (use `runNpm` from `installers/utils.ts` — no shell interpolation, hard rule #13).
2. Register the service in `src/lib/services/bootstrap.ts` (add to `SERVICES[]` array and extend `buildSpawnArgsFactory()`).
3. Add a DB seed row for the new service in `src/lib/db/migrations/` (`version_manager` table, `status='not_installed'`, `auto_start=0`).
4. Create 7 API endpoints under `src/app/api/services/{name}/` (`_lib.ts`, `install`, `start`, `stop`, `restart`, `update`, `status`, `auto-start`). All delegate errors through `createErrorResponse()`. The shared `logs` endpoint is already wired via `[name]/logs/route.ts`.
5. Verify `/api/services/` is in `LOCAL_ONLY_API_PREFIXES` in `src/server/authz/routeGuard.ts`; add a test asserting `isLocalOnlyPath()` returns `true` for the new prefix if you add one (hard rule #17).
6. Add a UI tab in `src/app/(dashboard)/dashboard/providers/services/tabs/` reusing `ServiceStatusCard`, `ServiceLifecycleButtons`, `ServiceLogsPanel`.
7. Document in `docs/frameworks/EMBEDDED-SERVICES.md` (update §1 service table + §4 API reference) and `docs/openapi.yaml`.
8. Write tests: unit (`tests/unit/services/`), integration (`tests/integration/services/`, gated by `RUN_SERVICES_INT=1`), and update `docs/ops/RELEASE_CHECKLIST.md` smoke section.
### Adding a New Guardrail / Eval / Skill / Webhook event
- Guardrail: `src/lib/guardrails/` → docs: `docs/security/GUARDRAILS.md`
- Eval suite: `src/lib/evals/` → docs: `docs/frameworks/EVALS.md`
- Skill (sandbox): `src/lib/skills/` → docs: `docs/frameworks/SKILLS.md`
- Webhook event: `src/lib/webhookDispatcher.ts` → docs: `docs/frameworks/WEBHOOKS.md`
---
## Reference Documentation
For any non-trivial change, read the matching deep-dive first:
| Area | Doc |
| --------------------------------------------- | ------------------------------------------------------- |
| 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` |
| Resilience (3 mechanisms) | `docs/architecture/RESILIENCE_GUIDE.md` |
| Reasoning replay | `docs/routing/REASONING_REPLAY.md` |
| Skills framework | `docs/frameworks/SKILLS.md` |
| Memory system (FTS5 + Qdrant) | `docs/frameworks/MEMORY.md` |
| Cloud agents | `docs/frameworks/CLOUD_AGENT.md` |
| Guardrails (PII / injection / vision) | `docs/security/GUARDRAILS.md` |
| Public upstream credentials (Gemini/etc.) | `docs/security/PUBLIC_CREDS.md` |
| Error message sanitization | `docs/security/ERROR_SANITIZATION.md` |
| Evals | `docs/frameworks/EVALS.md` |
| Compliance / audit | `docs/security/COMPLIANCE.md` |
| Webhooks | `docs/frameworks/WEBHOOKS.md` |
| Authorization pipeline | `docs/architecture/AUTHZ_GUIDE.md` |
| Stealth (TLS / fingerprint) | `docs/security/STEALTH_GUIDE.md` |
| Agent protocols (A2A / ACP / Cloud) | `docs/frameworks/AGENT_PROTOCOLS_GUIDE.md` |
| MCP server | `docs/frameworks/MCP-SERVER.md` |
| A2A server | `docs/frameworks/A2A-SERVER.md` |
| API reference + OpenAPI | `docs/reference/API_REFERENCE.md` + `docs/openapi.yaml` |
| Provider catalog (auto-generated) | `docs/reference/PROVIDER_REFERENCE.md` |
| Release flow | `docs/ops/RELEASE_CHECKLIST.md` |
| Embedded services | `docs/frameworks/EMBEDDED-SERVICES.md` |
| Quality gates (~48 scripts, allowlist policy) | `docs/architecture/QUALITY_GATES.md` |
---
## Testing
| What | Command |
| ----------------------- | --------------------------------------------------------------------------- |
| Unit tests | `npm run test:unit` |
| Single file | `node --import tsx/esm --test tests/unit/file.test.ts` |
| Vitest (MCP, autoCombo) | `npm run test:vitest` |
| E2E (Playwright) | `npm run test:e2e` |
| Protocol E2E (MCP+A2A) | `npm run test:protocols:e2e` |
| Ecosystem | `npm run test:ecosystem` |
| Coverage gate | `npm run test:coverage` (60/60/60/60 — statements/lines/functions/branches) |
| Coverage report | `npm run coverage:report` |
**PR rule**: If you change production code in `src/`, `open-sse/`, `electron/`, or `bin/`, you must include or update tests in the same PR.
**Test layer preference**: unit first → integration (multi-module or DB state) → e2e (UI/workflow only). Encode bug reproductions as automated tests before or alongside the fix.
**Both test runners must pass**: `npm run test:unit` (Node native — most tests) AND `npm run test:vitest` (MCP server, autoCombo, cache) cover **non-overlapping files**. Both are wired in CI (jobs `test-unit` and `test-vitest`) and must be green before merging. A PR where only one suite passes may silently ship broken MCP tools or routing regressions.
**Bug fix / issue triage protocol (Hard Rule #18)**: Every fix for a reported issue must be validated by one of the following — no exceptions:
1. **TDD (preferred)** — write a failing test reproducing the bug → fix it → confirm the test passes. The test becomes the permanent regression guard. Touch only the files the test proves need changing; nothing more.
2. **Real-environment test (when TDD is not possible)** — deploy to the production VPS (`root@192.168.0.15`) and run a documented live test. Record the exact command + result in the PR description. Applies to: OAuth upstream flows, Cloudflare/WS upstream behavior, UI-only regressions, hardware-dependent behavior.
3. "It worked locally without a test" does not count. A fix without a test or a VPS validation record is not a fix — it is a guess.
Why this matters: fixing bug A while opening bug B is worse than not fixing at all. The TDD/VPS gate enforces surgical scope — you touch only what the failing test proves is broken. Examples where this paid off: #3090 (claude-web 403), #3113 (WS HTTP fallback), #3052 (heap-guard auto-calibration).
**Copilot coverage policy**: When a PR changes production code and coverage is below 60% (statements/lines/functions/branches), do not just report — add or update tests, rerun the coverage gate, then ask for confirmation. Include commands run, changed test files, and final coverage result in the PR report.
---
## Planning & Research Artifacts (superpowers, deep-research)
`_tasks/` is a **separate, isolated git repository** that is gitignored by the main
repo (`.gitignore``_tasks/`). It is the canonical home for working artifacts —
plans, specs/designs, research, hand-offs — so they stay **versioned in their own
repo** instead of polluting the main OmniRoute tree.
**Hard rule — never write superpowers / planning / research output under `docs/` or
the repo root.** The superpowers skills ship with defaults that point at `docs/…`
(`writing-plans``docs/superpowers/plans/`, `brainstorming``docs/superpowers/specs/`).
Those defaults are **overridden here**. Whenever you invoke superpowers (or any
plan/spec/research generator) in this project, save to `_tasks/` instead, using the
same filename convention:
| Artifact (skill) | Default (do NOT use) | Save here instead | | Artifact (skill) | Default (do NOT use) | Save here instead |
| ---------------------------------- | ------------------------- | ------------------------------------------------------------- | | ---------------------------------- | ------------------------- | ------------------------------------------------------------- |
@@ -45,11 +409,156 @@ rewrite it to the `_tasks/…` equivalent before writing:
| Research (`deep-research`, ad-hoc) | `docs/research/` | `_tasks/research/…` | | Research (`deep-research`, ad-hoc) | `docs/research/` | `_tasks/research/…` |
| Hand-offs (`/handoff`) | — | `_tasks/hands-off/<YYYY-MM-DD>_<branch>_v<versão>_sess-<id>/` | | Hand-offs (`/handoff`) | — | `_tasks/hands-off/<YYYY-MM-DD>_<branch>_v<versão>_sess-<id>/` |
Commit those artifacts inside the `_tasks/` repo (`git -C _tasks …`), never in the main repo. When a superpowers skill announces a path like "saved to `docs/superpowers/plans/…`",
rewrite it to the `_tasks/…` equivalent before writing. Commit those artifacts inside
the `_tasks/` repo (`git -C _tasks …`), never in the main repo.
## Base-green before opening PRs ## Git Workflow
Before cutting a branch or opening a PR, run the base-green check (`AGENTS.md` → Git Workflow → ```bash
"Base-green check"; project skills reference it as `.agents/skills/_shared/base-green.md`). A PR # Never commit directly to main
opened while the base tip is red must carry `⚠️ base-red inherited: #<issue>` in its body. To git checkout -b feat/your-feature
drain an accumulated red state (base tip + red PRs), use the `/sweep-reds` skill. git commit -m "feat: describe your change"
git push -u origin feat/your-feature
```
**Branch prefixes**: `feat/`, `fix/`, `refactor/`, `docs/`, `test/`, `chore/`
**Commit format** (Conventional Commits): `feat(db): add circuit breaker` — scopes: `db`, `sse`, `oauth`, `dashboard`, `api`, `cli`, `docker`, `ci`, `mcp`, `a2a`, `memory`, `skills`
**Husky hooks**:
- **pre-commit**: lint-staged + `check-docs-sync` + `check:any-budget:t11` + `check:tracked-artifacts`
- **pre-push**: intentionally light (PATH/npm sanity only). `any-budget` + `tracked-artifacts`
already run on pre-commit; re-running them on every push was pure double-pay. CI still
enforces both. (Was Fase 6A.12 full pre-push gate; folded into pre-commit in #6716.)
### Worktree isolation (MANDATORY for every development task)
Multiple sessions/agents work this repo in parallel. The main checkout is **shared**, so a
`git checkout`/branch switch in it silently discards another session's uncommitted work and
yanks the branch out from under whatever else is running (incidents: 2026-06-05, 2026-06-13).
**Rule: never develop on the shared main checkout. Every task gets its own git worktree on its
own dedicated branch, and you MUST confirm the base branch with the operator before creating it.**
1. **Ask first — which base branch?** Before creating anything, ask the operator (via
`AskUserQuestion`, unless they already told you) from which branch the new worktree/branch
should be cut. Do NOT assume `main` or "whatever I'm on" — the answer is usually the active
`release/vX.Y.Z`, but it can be another feature/release branch. Get the base explicitly.
2. **Create an isolated worktree + branch off that base** (never reuse the main checkout).
**🔴 MANDATORY PATH: every worktree lives under `.claude/worktrees/` — and nowhere else.**
This is the single canonical location (the same dir the native `EnterWorktree` tool uses). It
is gitignored AND in the `tsconfig.json` / `.dockerignore` excludes, so worktrees never leak
into the build scope. **Never** use `.worktrees/`, repo-root, or any other path — a worktree
outside `.claude/worktrees/` (a) escapes the build-scope excludes and poisons `next build` (the
`tsconfig` `include: **/*` globs ~70× the codebase → OOM; incident 2026-06-25) and (b) scatters
worktrees across two dirs.
```bash
BASE_BRANCH="release/vX.Y.Z" # ← the branch the operator confirmed in step 1
TASK="feat/your-feature" # feat/ fix/ refactor/ docs/ test/ chore/
git fetch origin "$BASE_BRANCH"
git worktree add ".claude/worktrees/${TASK##*/}" -b "$TASK" "origin/$BASE_BRANCH"
cd ".claude/worktrees/${TASK##*/}"
# symlink node_modules from the main checkout to skip a per-worktree npm install:
ln -s "$(git -C <main_checkout> rev-parse --show-toplevel)/node_modules" node_modules
```
In Claude Code prefer the native `EnterWorktree` tool (it already creates worktrees under
`.claude/worktrees/`): create the worktree with the command above, then call `EnterWorktree`
with its `path`.
3. **Work, commit, push, open the PR — all from inside the worktree.** Never `git checkout` a
different branch inside a worktree another session might share.
4. **Tear down only your own** worktree + branch when done, from the main checkout:
`git worktree remove .claude/worktrees/<dir>` then `git branch -D <task>`. Never blanket-delete
`fix/*`/`feat/*` — other sessions keep their own; delete only the branches you created, by name.
5. **Never touch another session's worktree, branch, or uncommitted changes.** If `git worktree
list` shows worktrees you didn't create, leave them alone. End every session with the main
checkout back on the branch it started on (the active `release/vX.Y.Z`, never `main`).
---
## Environment
- **Runtime**: Node.js ≥22.0.0 <23 || ≥24.0.0 <27, ES Modules. This is the **only** runtime for the published `omniroute` CLI, the server, and the test suites (`node:test` + vitest) — `engines.node` is authoritative and end users never need Bun.
- **Bun (build/dev script runner only)**: Bun `1.3.10` is pinned as an **exact devDependency** (provisioned through the existing `npm ci` via the lockfile's `@oven/bun-*` platform binaries — no `setup-bun`/ad-hoc install). It is used **only** to execute a small, allow-listed set of TypeScript **gate/generator scripts** (replacing `node --import tsx` for startup speed): the CI checks `check:provider-consistency`, `check:compression-budget`, `check:known-symbols`, and the non-CI `gen:provider-reference`, `bench:compression`. **Do NOT** widen Bun to `npm install`, the build (`build:cli*`), `check:pack-artifact`, the published runtime, or the test runners — those stay on Node. Any new Bun-invoking script must be validated byte-identical against its `node --import tsx` output first. After pulling the lockfile change, run `npm install` so `bun` resolves locally (a stale `node_modules` will fail those 5 scripts with `bun: not found`).
- **TypeScript**: 6.0+, target ES2022, module esnext, resolution bundler
- **Path aliases**: `@/*` → `src/`, `@omniroute/open-sse` → `open-sse/`, `@omniroute/open-sse/*` → `open-sse/*`
- **Default port**: 20128 (API + dashboard on same port)
- **Data directory**: `DATA_DIR` env var, defaults to `~/.omniroute/`
- **Key env vars**: `PORT`, `JWT_SECRET`, `API_KEY_SECRET`, `INITIAL_PASSWORD`, `REQUIRE_API_KEY`, `APP_LOG_LEVEL`
- Setup: `cp .env.example .env` then generate `JWT_SECRET` (`openssl rand -base64 48`) and `API_KEY_SECRET` (`openssl rand -hex 32`)
---
## Quality Gates & Ratchets
OmniRoute has **~48 quality-gate scripts** (`scripts/check/` + `scripts/quality/`) wired
across **9 gate-running jobs** in `.github/workflows/ci.yml` (`lint`, `quality-gate`,
`quality-extended`, `docs-sync-strict`, `i18n-ui-coverage`, `i18n`, `pr-test-policy`,
`test-vitest`, `sonarqube`), plus the `quality.yml` fast-gates job (PR→`release/**`) and
3 nightly workflows (`nightly-property`, `nightly-resilience`, `nightly-llm-security`;
`nightly-mutation` once merged). Full inventory, per-job breakdown, and operational
procedures are in [`docs/architecture/QUALITY_GATES.md`](docs/architecture/QUALITY_GATES.md).
**Quick reference:**
- Gates in jobs `lint` + `docs-sync-strict`: pass/fail policy gates —
fix the violation or add an allowlist entry with a justification comment + tracking issue.
- Gates in job `quality-gate`: ratchet — metrics (ESLint warnings, code coverage, duplication,
complexity) must not regress vs `quality-baseline.json`. Update via
`npm run quality:ratchet -- --update` when a metric genuinely improves.
- Job `test-vitest` runs `npm run test:vitest` (MCP tools, autoCombo, cache) — blocking.
`test:vitest:ui` is advisory until UI component tests are triaged.
**Allowlist policy (short form):** Fix the cause; use the allowlist only for pre-existing
violations you cannot fix in the same PR. Add a comment with justification + issue number.
Stale allowlist entries (suppressing a violation that no longer exists) will be caught by
the stale-enforcement added in Fase 6A.3.
---
## Hard Rules
1. Never commit secrets or credentials
2. Never add logic to `localDb.ts`
3. Never use `eval()` / `new Function()` / implied eval
4. Never commit directly to `main`
5. Never write raw SQL in routes — use `src/lib/db/` modules
6. Never silently swallow errors in SSE streams
7. Always validate inputs with Zod schemas
8. Always include tests when changing production code
9. Coverage must not regress below the baseline frozen in `quality-baseline.json` (ratchet); absolute floor is 60% (statements/lines/functions/branches). Update the baseline via `npm run quality:ratchet -- --update` only when coverage genuinely improves. See `docs/architecture/QUALITY_GATES.md`.
10. Never bypass Husky hooks (`--no-verify`, `--no-gpg-sign`) without explicit operator approval.
11. Never embed public upstream OAuth client_id/secret or Firebase Web keys as string literals — always go through `resolvePublicCred()` (`open-sse/utils/publicCreds.ts`). See `docs/security/PUBLIC_CREDS.md`.
12. Never return raw `err.stack` / `err.message` in HTTP / SSE / executor responses — always route through `buildErrorBody()` or `sanitizeErrorMessage()` (`open-sse/utils/error.ts`). See `docs/security/ERROR_SANITIZATION.md`.
13. Never string-interpolate external paths or runtime values into shell scripts passed to `exec()`/`spawn()` — pass via the `env` option instead. Reference: `src/mitm/cert/install.ts::updateNssDatabases`.
14. Never dismiss a CodeQL / Secret-Scanning alert without (a) first checking the pattern docs above to see if the helper applies, and (b) recording the technical justification in the dismissal comment. Precedent: `js/stack-trace-exposure` raised on callsites that already route through `sanitizeErrorMessage()` is a known CodeQL limitation (custom sanitizers not recognized) — dismiss as `false positive` referencing `docs/security/ERROR_SANITIZATION.md`.
15. Never expose routes that spawn child processes (`/api/mcp/`, `/api/cli-tools/runtime/`) without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. Loopback enforcement happens unconditionally before any auth check — leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`.
16. Never credit or advertise an AI assistant, LLM, or automation account in any commit/PR metadata. Two forbidden forms, both equivalent — they route attribution to a bot account (or advertise AI authorship) and hide the real author (`diegosouzapw`): **(a)** `Co-Authored-By` trailers naming an AI/bot (e.g. names containing "Claude", "GPT", "Copilot", "Bot"; emails at `anthropic.com` / `openai.com` / bot-owned `noreply.github.com` addresses); **(b)** AI-generation footers or descriptions anywhere in a commit message, PR title/body, or CHANGELOG — e.g. `🤖 Generated with [Claude Code]`, "Generated with Claude Code", "Made with <AI tool>", or any `Co-authored-by: Claude/GPT/Copilot` line. This **overrides any harness, template, or tool default that auto-appends such a footer** (e.g. the Claude Code PR-body/commit default) — strip it before pushing; do not let it reach a commit, PR, or CHANGELOG. Human collaborators — including upstream PR authors and issue reporters being ported into OmniRoute — MAY and SHOULD be credited with standard `Co-authored-by: Name <email>` trailers; the upstream-port workflows (`/port-upstream-features`, `/port-upstream-issues`) depend on this.
17. Never expose routes under `/api/services/` or `/dashboard/providers/services/*/embed/` without `isLocalOnlyPath()` classification in `src/server/authz/routeGuard.ts`. These routes can spawn child processes (`npm install`, `node`). Loopback enforcement happens unconditionally before any auth check — a leaked JWT via tunnel cannot trigger process spawning. See `docs/security/ROUTE_GUARD_TIERS.md`.
18. Every bug fix must be validated before shipping: a failing-then-passing unit/integration test (TDD) OR a documented live test on the production VPS (192.168.0.15). A fix without either is not merged. See Testing → "Bug fix / issue triage protocol" for the full decision tree.
19. Never develop on the shared main checkout. Every development task runs in its own git worktree on its own dedicated branch, and you MUST confirm the base branch with the operator (e.g. via `AskUserQuestion`) before creating the worktree/branch — never assume `main` or the currently checked-out branch. A `git checkout` in the shared checkout silently destroys other sessions' uncommitted work. Tear down only the worktrees/branches you created (by name, never `fix/*`/`feat/*` wildcards), leave other sessions' worktrees untouched, and end on the branch you started on (the active `release/vX.Y.Z`, never `main`). See Git Workflow → "Worktree isolation".
20. PII redaction/sanitization is **opt-in — never on by default**. OmniRoute proxies for self-hosted/local LLMs where the operator owns the data, so mutating request/response payloads by default would silently corrupt legitimate traffic. The two data-mutating PII feature flags **MUST** keep `defaultValue: "false"` in `src/shared/constants/featureFlagDefinitions.ts`: `PII_REDACTION_ENABLED` (request-side) and `PII_RESPONSE_SANITIZATION` (response + streaming). All three application points — `src/lib/guardrails/piiMasker.ts` (request guardrail), `src/lib/piiSanitizer.ts` (response), `src/lib/streamingPiiTransform.ts` (SSE) — are gated on these flags; with both off the `pii-masker` guardrail still runs but never mutates payloads (data passes through untouched). Flipping either default to `"true"` requires explicit operator approval. The regression guard is `tests/unit/pii-opt-in-default.test.ts` (asserts both definition defaults + behavioral pass-through). Opt-in is per-operator via env or the settings/DB override (`src/lib/db/featureFlags.ts`), never a silent default. See `docs/security/GUARDRAILS.md`.
21. **Release-freeze — the FROZEN release branch belongs to the release captain; development does NOT stop (parallel-cycle model, 2026-07-04).** `/generate-release` opens a marker issue labeled `release-freeze` at the start of reconciliation (Phase 0a), **immediately cuts the next cycle's branch `release/vX+1` from the frozen tip (Phase 0a.0b — bump + living release PR + re-home of open PRs)**, and closes the freeze once the release PR squash-merges to `main`. Before merging **any** PR, every campaign workflow (`/review-prs`, `/review-group-prs`, `/merge-prs`, `/triage-fix-bugs`, `/implement-fix-bugs`, `/triage-features`, `/implement-features`, `/green-prs`, `/port-upstream-*`) **MUST** check `gh issue list --repo diegosouzapw/OmniRoute --label release-freeze --state open` — if a freeze is active: **NEVER merge into the frozen `release/vX.Y.Z` named in the freeze title**; instead resolve the ACTIVE development branch (the **highest** `release/v*` by semver — normally `release/vX+1`, announced in a freeze-issue comment) and **retarget the PR there** (`gh pr edit <N> --base release/vX+1`, then VERIFY with `gh pr view <N> --json baseRefName` — the edit fails silently) and merge normally. **HOLD only when the highest release/v\* branch IS the frozen one** (the short window before 0a.0b completes, or a pre-parallel-cycle release) — in that case leave the PR ready and open, tell the operator, and resume when the next branch appears or the freeze lifts. The just-shipped fixes reach `release/vX+1` via the Phase 5 sync-back (`scripts/release/sync-next-cycle.mjs`); do not try to sync mid-release. This is a **coordination signal, not a permission lock**: the release captain and the campaign sessions share the `diegosouzapw` identity, so a GitHub branch-protection lock cannot distinguish them — only this honored marker prevents the mid-release commit races that forced full CHANGELOG re-reconciliation in v3.8.40/v3.8.41 (a parallel campaign advanced `release/vX.Y.Z` by 34 commits mid-run). The release captain's own reconciliation/cycle-open pushes are exempt — they _are_ the release. Fixes that must land during a freeze (a homologation finding) follow the post-merge read-only rule: land on `main` first via `fix/release-vX.Y.Z-*`. **⛔ ONLY `/generate-release` may raise a release-freeze, and ONLY at its Phase 0a (start of generating a new version) — lifted at Phase 12c after the squash-merge to `main`.** No campaign, session, or agent may open a `release-freeze` marker at any other time — a freeze is **never** a mid-development coordination tool. If a session ever believes a freeze is genuinely, unavoidably necessary outside the `/generate-release` flow, it **MUST first ask the operator (`diegosouzapw`) in chat, explicitly alert "estou criando um freeze" and get an explicit yes** — never open, extend, or re-open a `release-freeze` autonomously. Conversely, do **not** close/lift an active `/generate-release` freeze to unblock campaign merges: it protects the captain's single clean CI run and auto-lifts at Phase 12c — closing it early re-triggers the exact commit race it prevents. Verify a freeze is legitimate before acting on it: an open `release-freeze` whose title/body references an **OPEN** release PR (`gh pr view <N> --json state`) is the authorized captain freeze — hold, don't touch.
22. **Cross-session safety — this repo is worked by MANY parallel sessions/agents at once; never step on another's in-flight work.** Two absolute bans, both recurring incidents (this rule exists because they keep happening):
- **(a) Never `git stash` / `git stash pop` — ANYWHERE in this repo, including inside an isolated worktree, and including inside any subagent you dispatch.** `git stash` operates on the **shared repository object store**, not the per-worktree working tree — so a stash pushed or popped in one session can silently clobber or resurrect another parallel session's uncommitted changes. This is not hypothetical: 2026-07-02 a `#5923` quotaCache change leaked into the unrelated `#2296` worktree via a global `stash pop`, and the same class reincided through a **subagent**. To compare working changes against a base ref **without** stashing, use `git show <ref>:<path>` or `git diff <ref> -- <path>`; to confirm a typecheck/lint error is pre-existing on the base, inspect the base ref directly (`git show origin/release/vX.Y.Z:<path>`) — never stash your tree away to "get it clean". **Put this ban verbatim in the prompt of every subagent that touches git** (agents don't inherit this file's context — the recurrence was a subagent).
- **(b) Never merge, push, rebase, or force-push a PR / branch / worktree that another session is actively working.** An open PR whose head is a live fix worktree in `.claude/worktrees/` you did **not** create (e.g. `fix-5852`/`fix-5923` carrying fresh commits, even when they share your `diegosouzapw` identity), or any branch another session owns, is **off-limits — HOLD**, and let the owning session merge it. **Before** merging or pushing to any PR you did not create _this_ session, run `git worktree list` to check for a matching in-flight worktree and re-check `gh pr view <N> --json state,headRefOid`. Only the owning session merges its own in-flight PR; mid-flight merges race the owner and re-trigger the exact commit/CHANGELOG races Rule #19 and Rule #21 guard against. (Reinforces Rule #19.)
---
## PII & Stream Sanitization Learnings
### 1. Regex Security (ReDoS)
All regex patterns matching variable-length strings (e.g. IPv6 address, credit cards) must use strictly bounded, non-overlapping sequences (e.g., limit occurrences with bounded ranges `{1,7}`) to prevent catastrophic backtracking when processing untrusted inputs.
### 2. SSE Snapshot Handling
When parsing streaming LLM responses (e.g. Responses API), check if a chunk represents a final snapshot (`done` or `completed` events). Snapshot text must be sanitized directly as a standalone string (bypassing rolling delta buffers) to prevent text duplication at the end of the stream.
### 3. Database Handles in Tests
Ensure that any unit tests that trigger database migrations or establish SQLite connections call `resetDbInstance()` and properly clean up/close all DB handles in a `test.after(...)` hook. Failure to release database connection handles will cause Node's native test runner to hang indefinitely.

View File

@@ -2,11 +2,6 @@
Thank you for your interest in contributing! This guide covers everything you need to get started. 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/ops/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 ## Development Setup
@@ -15,12 +10,6 @@ coverage, and reconciliation steps.
- **Node.js** `>=22.22.3 <23`, or `>=24.0.0 <27` (recommended: 24 LTS) - **Node.js** `>=22.22.3 <23`, or `>=24.0.0 <27` (recommended: 24 LTS)
- **npm** 10+ - **npm** 10+
> **npm v11+ users (Node 24+):** After `npm install`, verify native modules were installed:
> `node -e "require('better-sqlite3')"`. If it fails with `MODULE_NOT_FOUND`,
> run `npm approve-scripts better-sqlite3 && npm install`. See
> [Troubleshooting](docs/guides/TROUBLESHOOTING.md#npm-v11-better-sqlite3-not-installed-cannot-find-module).
- **Git** - **Git**
### Clone & Install ### Clone & Install
@@ -114,19 +103,13 @@ Default URLs:
## Git Workflow ## Git Workflow
> ⚠️ **NEVER commit directly to `main`.** Always use feature branches. > ⚠️ **NEVER commit directly to `main`.** Always use feature branches.
>
> **PR base:** target the active `release/vX.Y.Z` branch (not `main`). See
> [`docs/ops/BRANCHING_MODEL.md`](docs/ops/BRANCHING_MODEL.md) for the
> release-per-branch + tag-at-ship model.
```bash ```bash
# Branch from the active release tip (example: release/v3.8.49) git checkout -b feat/your-feature-name
git fetch origin
git checkout -b feat/your-feature-name origin/release/v3.8.49
# ... make changes ... # ... make changes ...
git commit -m "feat: describe your change" git commit -m "feat: describe your change"
git push -u origin feat/your-feature-name git push -u origin feat/your-feature-name
# Open a Pull Request with base = release/v3.8.49 # Open a Pull Request on GitHub
``` ```
### Branch Naming ### Branch Naming
@@ -209,15 +192,11 @@ Coverage notes:
### Pull Request Requirements ### Pull Request Requirements
Before opening a PR, use the Before opening or merging a PR:
[Contribution Golden Path](docs/ops/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 test:unit`
- Run `npm run lint` - Run `npm run test:coverage`
- Include or update automated tests in the same PR whenever production code changes - Ensure the coverage gate stays at **60%+** statements/lines/functions/branches
- Include the changed or added test files in the PR description when production code changed - Include the changed or added test files in the PR description when production code changed
- Check the SonarQube result on the PR when the project secrets are configured in CI - Check the SonarQube result on the PR when the project secrets are configured in CI
@@ -243,31 +222,6 @@ Current test status: **122 unit test files** covering:
- **Zod validation** — Use Zod v4 schemas for all API input validation - **Zod validation** — Use Zod v4 schemas for all API input validation
- **Naming**: Files = camelCase/kebab-case, components = PascalCase, constants = UPPER_SNAKE - **Naming**: Files = camelCase/kebab-case, components = PascalCase, constants = UPPER_SNAKE
### Error handling / empty catch blocks
Never leave a `catch` unexplained. Classify it into one of two buckets (operationalizes
the hard rule "never silently swallow errors in SSE streams"):
- **Intentional (our own best-effort cleanup/telemetry)** — a failure here is expected and
harmless; add a one-line rationale comment, no logging (logging on every request is the
noise this convention avoids).
```ts
} catch {} // closing an already-closed controller after client disconnect is expected
```
- **Should log (external/caller-supplied code, or the swallow changes control flow)** — keep
the catch (never let it break the stream) but emit a contextual `console.debug`/`warn` so the
failure is discoverable.
```ts
} catch (e) {
console.debug("[STREAM] onFailure callback error:", e);
}
```
See `open-sse/utils/stream.ts` and `open-sse/utils/streamHandler.ts` for applied examples.
--- ---
## Project Structure ## Project Structure
@@ -283,7 +237,7 @@ src/ # TypeScript (.ts / .tsx)
│ ├── a2a/ # Agent-to-Agent v0.3 protocol server │ ├── a2a/ # Agent-to-Agent v0.3 protocol server
│ ├── acp/ # Agent Communication Protocol registry │ ├── acp/ # Agent Communication Protocol registry
│ ├── compliance/ # Compliance policy engine │ ├── compliance/ # Compliance policy engine
│ ├── db/ # SQLite domain modules + 130 migrations │ ├── db/ # SQLite database layer (21 modules + 16 migrations)
│ ├── memory/ # Persistent conversational memory │ ├── memory/ # Persistent conversational memory
│ ├── oauth/ # OAuth providers, services, and utilities │ ├── oauth/ # OAuth providers, services, and utilities
│ ├── skills/ # Extensible skill framework │ ├── skills/ # Extensible skill framework
@@ -293,16 +247,16 @@ src/ # TypeScript (.ts / .tsx)
├── mitm/ # MITM proxy (cert, DNS, target routing) ├── mitm/ # MITM proxy (cert, DNS, target routing)
├── shared/ ├── shared/
│ ├── components/ # React components (.tsx) │ ├── components/ # React components (.tsx)
│ ├── constants/ # Provider definitions (329), MCP scopes, 19 routing strategies │ ├── constants/ # Provider definitions (177), MCP scopes, 14 routing strategies
│ ├── utils/ # Circuit breaker, sanitizer, auth helpers │ ├── utils/ # Circuit breaker, sanitizer, auth helpers
│ └── validation/ # Zod v4 schemas │ └── validation/ # Zod v4 schemas
└── sse/ # SSE proxy pipeline └── sse/ # SSE proxy pipeline
open-sse/ # @omniroute/open-sse workspace open-sse/ # @omniroute/open-sse workspace
├── executors/ # 89 executor implementation modules ├── executors/ # 14 provider-specific request executors
├── handlers/ # 11 request handlers (chat, responses, embeddings, images, etc.) ├── handlers/ # 11 request handlers (chat, responses, embeddings, images, etc.)
├── mcp-server/ # MCP server (107 unique tools, 3 transports, 32 scopes) ├── mcp-server/ # MCP server (25 tools, 3 transports, 10 scopes)
├── services/ # 178 top-level services (combo, autoCombo, rateLimitManager, etc.) ├── services/ # 36+ services (combo, autoCombo, rateLimitManager, etc.)
├── translator/ # Format translators (OpenAI ↔ Claude ↔ Gemini ↔ Responses ↔ Ollama) ├── translator/ # Format translators (OpenAI ↔ Claude ↔ Gemini ↔ Responses ↔ Ollama)
├── transformer/ # Responses API transformer ├── transformer/ # Responses API transformer
└── utils/ # 22 utility modules (stream, TLS, proxy, logging) └── utils/ # 22 utility modules (stream, TLS, proxy, logging)

View File

@@ -1,5 +1,5 @@
# ── Common base with runtime deps ────────────────────────────────────────── # ── Common base with runtime deps ──────────────────────────────────────────
FROM node:26-trixie-slim AS base FROM node:24-trixie-slim AS base
WORKDIR /app WORKDIR /app
# `apt-get upgrade` pulls the security-patched versions of the Debian (trixie) # `apt-get upgrade` pulls the security-patched versions of the Debian (trixie)
@@ -8,61 +8,29 @@ WORKDIR /app
# that already have a fix published in trixie. CVEs without an upstream fix yet # that already have a fix published in trixie. CVEs without an upstream fix yet
# (local-only TOCTOU, etc.) remain until the distro patches them and the image # (local-only TOCTOU, etc.) remain until the distro patches them and the image
# is rebuilt; none are reachable from the proxy's request surface at runtime. # is rebuilt; none are reachable from the proxy's request surface at runtime.
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \ RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=shared \
--mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \ --mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=shared \
apt-get update \ apt-get update \
&& apt-get upgrade -y \ && apt-get upgrade -y \
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \ && apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# npm's *bundled* node_modules (brace-expansion, ip-address, tar, undici) are # Refresh the globally-installed npm so its *bundled* node_modules (undici, tar)
# npm's own internals — not application dependencies (the app resolves its own, # ship the patched versions. These are npm's own internals — not application
# already-fixed copies) — but the container scanner reads them off # dependencies (our app already resolves undici@8.5.0 / tar@7.5.16, both fixed) —
# /usr/local/lib/node_modules/npm/node_modules and reports 9 HIGH/MEDIUM CVEs. # but the container scanner flags the stale copies under
# # /usr/local/lib/node_modules/npm/node_modules. npm is not invoked at runtime in
# Refreshing npm does NOT fix them. Measured on npm@12.0.2 (2026-08-12, latest): # the runner stages, so this is hygiene, not an exploitable runtime path.
# brace-expansion 5.0.7 (needs >= 5.0.9) CVE-2026-69152, CVE-2026-14257 RUN npm install -g npm@latest \
# ip-address 10.2.0 (needs >= 10.3.1) CVE-2026-69192/-69198/-54272 && npm cache clean --force
# tar 7.5.19 (needs >= 7.5.21) GHSA-r292-9mhp-454m
# undici 6.27.0 (needs >= 6.28.0) CVE-2026-16729/-16728/-15157
# No published npm release carries patched copies, so `npm install -g npm@latest`
# alone was pure build time for zero CVEs — it is kept only to land on a known,
# current npm tree, and the patched copies are overlaid on top below.
#
# Deleting npm from the runner stages is NOT an option: the application shells
# out to npm at runtime (src/lib/services/installers/utils.ts::runNpm for the
# embedded services, src/lib/system/{autoUpdate,globalPackagePath}.ts,
# src/app/api/system/version). The previous version of this comment claimed the
# opposite; it was wrong.
#
# The overlay is semver-compatible with the ranges npm's own tree declares
# (minimatch → brace-expansion ^5.0.5, socks → ip-address ^10.1.1, node-gyp →
# tar ^7.5.4 and undici ^6.25.0 — hence undici stays on the 6.x line, NOT 8.x).
# --install-strategy=nested makes each replacement self-contained, so it cannot
# perturb the versions the rest of npm's flat tree resolves.
RUN set -eux; \
npm install -g npm@latest; \
npm install --prefix /tmp/npm-cve-patch --no-audit --no-fund --ignore-scripts \
--install-strategy=nested \
brace-expansion@5.0.9 ip-address@10.5.0 tar@7.5.22 undici@6.28.0; \
for pkg in brace-expansion ip-address tar undici; do \
test -d "/usr/local/lib/node_modules/npm/node_modules/$pkg"; \
rm -rf "/usr/local/lib/node_modules/npm/node_modules/$pkg"; \
cp -R "/tmp/npm-cve-patch/node_modules/$pkg" \
"/usr/local/lib/node_modules/npm/node_modules/$pkg"; \
done; \
rm -rf /tmp/npm-cve-patch; \
node -e "for (const p of ['brace-expansion','ip-address','tar','undici']) console.log(p, require('/usr/local/lib/node_modules/npm/node_modules/'+p+'/package.json').version);"; \
npm --version; \
npm cache clean --force
# ── Builder ──────────────────────────────────────────────────────────────── # ── Builder ────────────────────────────────────────────────────────────────
FROM base AS builder FROM base AS builder
# Build tools for native module compilation # Build tools for native module compilation
# apt-get update needed here because base's rm -rf clears the shared cache # apt-get update needed here because base's rm -rf clears the shared cache
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \ RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=shared \
--mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \ --mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=shared \
apt-get update \ apt-get update \
&& apt-get install -y --no-install-recommends python3 make g++ \ && apt-get install -y --no-install-recommends python3 make g++ \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
@@ -94,28 +62,10 @@ RUN test -f package-lock.json \
# from that indirection. Invoking `node-gyp rebuild` directly inside the package # from that indirection. Invoking `node-gyp rebuild` directly inside the package
# directory bypasses npm's script-running layer entirely and is deterministic # directory bypasses npm's script-running layer entirely and is deterministic
# regardless of npm version or ignore-scripts allowlist behavior. # regardless of npm version or ignore-scripts allowlist behavior.
# node-gyp comes from npm's own bundled copy (deterministic, already in the image) RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
# instead of `npx --yes`, which would install an arbitrary registry version npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
# on-demand and run its lifecycle scripts (Sonar docker:S6505). && (cd node_modules/better-sqlite3 && npx --yes node-gyp rebuild) \
# && node -e "require('better-sqlite3')(':memory:').close()"
# tls-client-node (chatgpt-web/claude-web/grok-web/lmarena/perplexity-web TLS
# impersonation) hits the same --ignore-scripts wall: its own postinstall.js
# fetches a platform .so/.dylib/.dll from the bogdanfinn/tls-client GitHub
# Releases API and is never invoked when npm ci skips lifecycle scripts. Unlike
# better-sqlite3 above, that script never throws on failure — it only
# `console.warn`s and exits 0 — so a rate-limited or offline build would
# otherwise succeed silently with an empty bin/ and only fail at first request
# in production (TlsClientUnavailableError, #7802). Run it explicitly here so
# a broken/rate-limited fetch fails the BUILD loudly instead of shipping a
# broken image.
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \
npm ci --include=optional --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
&& (cd node_modules/better-sqlite3 \
&& node /usr/local/lib/node_modules/npm/node_modules/node-gyp/bin/node-gyp.js rebuild) \
&& node -e "require('better-sqlite3')(':memory:').close()" \
&& node node_modules/tls-client-node/scripts/postinstall.js \
&& (test -n "$(find node_modules/tls-client-node/bin -mindepth 1 -print -quit 2>/dev/null)" \
|| (echo "tls-client-node native binary missing after postinstall — GitHub API fetch likely rate-limited or failed (#7802)" >&2 && exit 1))
# Build with Turbopack (stable in Next 16, the repo default). The v3.8.27-era # Build with Turbopack (stable in Next 16, the repo default). The v3.8.27-era
# TurbopackInternalError panic ("entered unreachable code: there must be a path to a # TurbopackInternalError panic ("entered unreachable code: there must be a path to a
@@ -125,20 +75,7 @@ RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,targe
# build from 17min to 9min on the same 32-core box. Webpack stays available as the # build from 17min to 9min on the same 32-core box. Webpack stays available as the
# escape hatch: `--build-arg`/-e OMNIROUTE_USE_TURBOPACK=0. # escape hatch: `--build-arg`/-e OMNIROUTE_USE_TURBOPACK=0.
# See docs/ops/QUALITY_GATE_PLAYBOOK.md Parte 6. # See docs/ops/QUALITY_GATE_PLAYBOOK.md Parte 6.
# ENV OMNIROUTE_USE_TURBOPACK=1
# Declared as ARG+ENV, not a bare ENV: a bare ENV shadows any same-named ARG for
# the rest of the stage, so `--build-arg OMNIROUTE_USE_TURBOPACK=0` was silently
# ignored and the escape hatch above only ever worked via `-e` at runtime, never
# at build time. Turbopack compiles in native Rust memory that lives outside the
# V8 heap, so OMNIROUTE_BUILD_MEMORY_MB cannot bound it and a memory-constrained
# build host gets SIGKILLed by the cgroup OOM killer with no error message.
ARG OMNIROUTE_USE_TURBOPACK=1
ENV OMNIROUTE_USE_TURBOPACK="${OMNIROUTE_USE_TURBOPACK}"
# Next.js basePath is fixed at build time; pass OMNIROUTE_BASE_PATH here when the
# image should serve under a reverse-proxy subpath without a runtime patch.
ARG OMNIROUTE_BASE_PATH=""
ENV OMNIROUTE_BASE_PATH=$OMNIROUTE_BASE_PATH
# Docker containers cannot run the MITM/Agent-Bridge stack (no host DNS/cert # Docker containers cannot run the MITM/Agent-Bridge stack (no host DNS/cert
# access), so keep @/mitm/manager on the graceful stub (#3390). This flag is # access), so keep @/mitm/manager on the graceful stub (#3390). This flag is
@@ -158,10 +95,8 @@ ARG OMNIROUTE_BUILD_MEMORY_MB=4096
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}" ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
COPY . ./ COPY . ./
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-next-cache,target=/app/.build/next/cache \ RUN --mount=type=cache,id=next-cache,target=/app/.build/next/cache \
mkdir -p /app/data \ mkdir -p /app/data && npm run build
&& npm run build \
&& node --input-type=module -e "import { createRequire } from 'node:module'; import { pathToFileURL } from 'node:url'; const standaloneRoot = '/app/.build/next/standalone/node_modules/'; const require = createRequire('/app/.build/next/standalone/package.json'); for (const pkg of ['@atjsh/llmlingua-2', '@huggingface/transformers', '@tensorflow/tfjs', 'js-tiktoken']) { const resolved = require.resolve(pkg); if (!resolved.startsWith(standaloneRoot)) throw new Error(pkg + ' resolved outside standalone: ' + resolved); await import(pathToFileURL(resolved).href); } const onnxRuntime = require.resolve('onnxruntime-node'); if (!onnxRuntime.startsWith(standaloneRoot)) throw new Error('onnxruntime-node resolved outside standalone: ' + onnxRuntime); await import(pathToFileURL(onnxRuntime).href);"
# ── Runner base ──────────────────────────────────────────────────────────── # ── Runner base ────────────────────────────────────────────────────────────
FROM base AS runner-base FROM base AS runner-base
@@ -175,12 +110,6 @@ LABEL org.opencontainers.image.title="omniroute" \
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=20128 ENV PORT=20128
ENV HOSTNAME=0.0.0.0 ENV HOSTNAME=0.0.0.0
# Runtime heap ceiling. 1024MB is enough for normal traffic but can be tight
# for large fusion-combo panels (many models fanned out in parallel, each
# response buffered in full — see open-sse/services/fusion.ts::FUSION_DEFAULTS
# .maxPanel, issue #1905). Override at `docker run` time with
# `-e OMNIROUTE_MEMORY_MB=2048` (or higher) if you raise fusionTuning.maxPanel
# above the default cap.
ENV OMNIROUTE_MEMORY_MB=1024 ENV OMNIROUTE_MEMORY_MB=1024
ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_MEMORY_MB}" ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_MEMORY_MB}"
@@ -221,8 +150,8 @@ EXPOSE 20128
USER node USER node
# Warns if the mounted data volume has wrong ownership # Warns if the mounted data volume has wrong ownership
COPY --chmod=755 scripts/check-permissions.sh /app/check-permissions.sh COPY --chmod=755 scripts/check-permissions.sh /tmp/check-permissions.sh
ENTRYPOINT ["/app/check-permissions.sh"] ENTRYPOINT ["/tmp/check-permissions.sh"]
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
CMD ["node", "healthcheck.mjs"] CMD ["node", "healthcheck.mjs"]
@@ -262,8 +191,8 @@ COPY --from=builder /app/node_modules/playwright ./node_modules/playwright
# browsers land under /home/node which persists across image layers and is # browsers land under /home/node which persists across image layers and is
# accessible to the non-root runtime user. # accessible to the non-root runtime user.
ENV PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright ENV PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \ RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \ --mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
apt-get update \ apt-get update \
&& node node_modules/playwright/cli.js install chromium --with-deps \ && node node_modules/playwright/cli.js install chromium --with-deps \
&& chown -R node:node /home/node/.cache \ && chown -R node:node /home/node/.cache \
@@ -278,21 +207,16 @@ FROM runner-base AS runner-cli
# runner-base runs. # runner-base runs.
USER root USER root
# The CLI image can use the internal ChatGPT Web (Codex) Chromium sidecar over
# CDP without installing a second browser in this container.
COPY --from=builder /app/node_modules/playwright-core ./node_modules/playwright-core
COPY --from=builder /app/node_modules/playwright ./node_modules/playwright
# Install system dependencies required by openclaw (git+ssh references). # Install system dependencies required by openclaw (git+ssh references).
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-cache,target=/var/cache/apt,sharing=locked \ RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
--mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-apt-lists,target=/var/lib/apt/lists,sharing=locked \ --mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
apt-get update \ apt-get update \
&& apt-get install -y --no-install-recommends git ca-certificates docker.io docker-compose \ && apt-get install -y --no-install-recommends git ca-certificates docker.io docker-compose \
&& rm -rf /var/lib/apt/lists/* \ && rm -rf /var/lib/apt/lists/* \
&& git config --system url."https://github.com/".insteadOf "ssh://git@github.com/" && git config --system url."https://github.com/".insteadOf "ssh://git@github.com/"
# Install CLI tools globally. Separate layer from apt for better cache reuse. # Install CLI tools globally. Separate layer from apt for better cache reuse.
RUN --mount=type=cache,id=s/92ca8a61-c1ba-421f-a389-d48ac7258c2d-npm-cache,target=/root/.npm \ RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest
USER node USER node

View File

@@ -1,13 +1,50 @@
# GEMINI.md # Security and Cleanliness Rules for AI Assistants
> **Single source of truth:** all project rules for AI assistants live in > **Scope:** rules for Gemini-based agents. For Claude Code, see `CLAUDE.md`. For other AI assistants, see `AGENTS.md`.
> [`AGENTS.md`](AGENTS.md). Read it in full before any change — it contains the 23 Hard Rules,
> quality gates, code conventions, file-placement / repo-root hygiene rules, the repository map
> and the local development access notes that used to live in this file.
Gemini-specific notes: ## 1. File Placement & Organization
- Skills activate via the `activate_skill` tool (skill metadata is loaded at session start and - **Test Files**: ALL unit tests, integration tests, ecosystem tests, or Vitest files MUST strictly be placed within the `tests/` directory (e.g., `tests/unit/`, `tests/integration/`). NEVER create test files in the project root (`/`).
the full content is activated on demand). - **Scripts and Utilities**: ALL maintenance, debugging, generation, or experimental scripts (`.cjs`, `.mjs`, `.js`, `.ts`) MUST be placed strictly inside one of the `scripts/` subfolders (`build/`, `dev/`, `check/`, `docs/`, `i18n/`, `ad-hoc/`). One-shot or experimental code goes under `scripts/ad-hoc/`. NEVER dump loose scripts in the project root (`/`) or the top-level `scripts/` folder.
- There are no other Gemini-only rules today. Do not re-add project rules here — edit
`AGENTS.md` instead, so every assistant sees the same instructions. **The Project Root MUST ONLY CONTAIN:**
- Configuration files (`vitest.config.ts`, `next.config.mjs`, `eslint.config.mjs`, `tsconfig*.json`, `playwright.config.ts`, `prettier.config.mjs`, `postcss.config.mjs`, `sonar-project.properties`, `fly.toml`, `docker-compose*.yml`, `Dockerfile`)
- Dependency files (`package.json`, `package-lock.json`)
- Documentation files (`README.md`, `CHANGELOG.md`, `LICENSE`, `AGENTS.md`, `CLAUDE.md`, `GEMINI.md`, `CONTRIBUTING.md`, `SECURITY.md`, `CODE_OF_CONDUCT.md`, `llm.txt`, `Tuto_Qdrant.md`)
- CI/CD files and ignore definitions (`.gitignore`, `.dockerignore`, `.npmignore`, `.npmrc`, `.node-version`, `.nvmrc`, `.env.example`)
When creating _any_ validation tests or one-off logic scripts, default to using `scripts/ad-hoc/` or the `tests/unit/` directories according to your goals. Do not pollute the `/` root context.
## 2. Hard Rules (mirror of `CLAUDE.md`)
1. **Never commit secrets or credentials.** Use `.env` (auto-generated from `.env.example`) or a vault. Passwords, OAuth secrets, API keys, and Cookie values must never appear in committed files.
2. **Never add logic to `src/lib/localDb.ts`.** It is a re-export barrel only.
3. **Never use `eval()`, `new Function()`, or any implied eval.** ESLint enforces this.
4. **Never commit directly to `main`.** Use `feat/`, `fix/`, `refactor/`, `docs/`, `test/`, or `chore/` branches.
5. **Never write raw SQL in routes** — always go through `src/lib/db/` domain modules.
6. **Never silently swallow errors in SSE streams** — propagate them or abort the stream cleanly.
7. **Never bypass Husky hooks** (`--no-verify`, `--no-gpg-sign`) without explicit operator approval.
8. **Always validate inputs with Zod schemas** from `src/shared/validation/schemas.ts`.
9. **Always include tests when changing production code** (`src/`, `open-sse/`, `electron/`, `bin/`).
10. **Coverage must stay** ≥ 75 % statements / 75 % lines / 75 % functions / 70 % branches (real measured: ~82 %).
## 3. Codebase navigation
| Task | Read this first |
| ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Understand the codebase | `docs/architecture/REPOSITORY_MAP.md` |
| Architecture overview | `docs/architecture/ARCHITECTURE.md` |
| Engineering reference | `docs/architecture/CODEBASE_DOCUMENTATION.md` |
| Add a feature | `CONTRIBUTING.md` + the matching `docs/<area>.md` |
| Per-area deep dives | `docs/frameworks/SKILLS.md`, `docs/frameworks/MEMORY.md`, `docs/frameworks/EVALS.md`, `docs/security/GUARDRAILS.md`, `docs/security/COMPLIANCE.md`, `docs/frameworks/CLOUD_AGENT.md`, `docs/frameworks/MCP-SERVER.md`, `docs/frameworks/A2A-SERVER.md`, `docs/architecture/AUTHZ_GUIDE.md`, `docs/architecture/RESILIENCE_GUIDE.md`, `docs/routing/AUTO-COMBO.md`, `docs/frameworks/WEBHOOKS.md`, `docs/routing/REASONING_REPLAY.md`, `docs/security/STEALTH_GUIDE.md`, `docs/ops/TUNNELS_GUIDE.md`, `docs/guides/ELECTRON_GUIDE.md`, `docs/reference/PROVIDER_REFERENCE.md` |
| Release flow | `docs/ops/RELEASE_CHECKLIST.md` |
## 4. Local development access
The dashboard is reachable at the operator's chosen URL/port (default `http://localhost:20128`). Credentials are operator-specific:
- **Initial admin password** is read from the `INITIAL_PASSWORD` env var on first install (defaults to `CHANGEME` in `.env.example`; rotate immediately after first login).
- **Local VPS / shared dev environments**: ask the operator for the URL and current credentials — they live in their personal vault, NOT in this repo.
> Any credential observed in a previous version of this file was a non-production demo value; treat it as compromised and do not reuse it.

View File

@@ -1,69 +0,0 @@
.PHONY: help install dev start build build-release lint typecheck typecheck-strict \
test test-unit test-vitest test-coverage test-all test-integration test-e2e \
check check-cycles check-docs env-sync clean
# OmniRoute — convenience wrapper around the npm scripts.
# All targets delegate to the canonical package.json scripts (single source of truth).
help: ## Show this help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-18s\033[0m %s\n", $$1, $$2}'
install: ## Install dependencies (auto-generates .env from .env.example)
npm install
dev: ## Dev server at http://localhost:20128
npm run dev
start: ## Production server (requires a prior build)
npm run start
build: ## Production build (Next.js 16 standalone)
npm run build
build-release: ## Release build
npm run build:release
lint: ## ESLint (0 errors expected)
npm run lint
typecheck: ## TypeScript check (core)
npm run typecheck:core
typecheck-strict: ## Strict check (no implicit any)
npm run typecheck:noimplicit:core
test: ## Unit tests (Node native runner)
npm run test:unit
test-unit: ## Alias for `test`
npm run test:unit
test-vitest: ## Vitest (MCP server, autoCombo, cache)
npm run test:vitest
test-coverage: ## Unit tests + coverage gate (60/60/60/60)
npm run test:coverage
test-all: ## All suites (unit + vitest + ecosystem + e2e)
npm run test:all
test-integration: ## Integration tests
npm run test:integration
test-e2e: ## E2E (Playwright)
npm run test:e2e
check: ## lint + test combined
npm run check
check-cycles: ## Detect circular dependencies
npm run check:cycles
check-docs: ## Validate documentation (incl. fabricated-docs)
npm run check:docs-all
env-sync: ## Sync .env from .env.example
npm run env:sync
clean: ## Remove build artifacts
rm -rf .build dist coverage .eslintcache

1550
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,96 +0,0 @@
---
title: "OmniRoute Roadmap"
version: 3.8.50
lastUpdated: 2026-08-06
---
# OmniRoute Roadmap
> Version-gated, not date-gated: each milestone ships when its quality gates pass.
> Current line: **v3.8.x** (this branch). Last updated: 2026-08-06.
OmniRoute is heading from a monolithic router to a **modular AI platform**: a lightweight
core engine, a typed SDK, and everything else as installable modules and plugins. The path
runs through a stabilization rail (3.8.50 → 3.8.59), an LTS anchor (**3.9.0**), and the
modular **4.0**.
## The rail at a glance
```
3.8.50 ─ 3.8.54 PREPARE non-breaking structural prep (all PRs welcome)
3.8.55 ─ 3.8.59 VALIDATE stabilization (fixes / docs / i18n / providers only)
3.9.0 LTS stable/v3 branch · long-term support line
4.0.0-nightly/rc MODULAR core + SDK + modules + marketplace (develop branch)
4.0.0 GA latest switches to v4 · v3 stays supported as LTS
```
## Phase 1 — Preparation (3.8.50 → 3.8.54)
Non-breaking structural work that de-risks the modular split. Every version closes with a
mandatory quality-gate battery before new merges open.
| Version | Focus |
| --- | --- |
| 3.8.50 | CI safety net on release branches · dead-code cleanup · community-reported catalog/topology bug fixes · contributor "golden path" guide |
| 3.8.51 | Executor registry (in-place) · end-to-end provider-journey contract test becomes a CI gate · official scoped-test dev loop · CI lane consolidation (shared install/setup across gate jobs, #8084) |
| 3.8.52 | `combo.ts` decomposition · routing-strategy registry · unified model-catalog contract for `/v1/models` · one CI policy for PRs to `release/**` and `main` (#8084) |
| 3.8.53 | `chatCore.ts` decomposition · headless mode (`OMNIROUTE_HEADLESS=1`) · local candidate build/promote loop |
| 3.8.54 | Release infrastructure (dormant): channels, labels, PR templates, merge queue · full-regression authority moves to the merge queue once TIA shadow evidence clears (#8084) · public feature-freeze announcement |
## Phase 2 — Validation (3.8.55 → 3.8.59)
**External feature PRs pause here** (they get the `v4-feature` label and are re-targeted to
the v4 channel when it opens). Fixes, docs, i18n, and provider updates keep flowing.
| Version | Focus |
| --- | --- |
| 3.8.55 | Characterization tests for every extraction candidate · coupling re-measurement |
| 3.8.56 | Extended canary · performance baselines (heap, TTFB, build) |
| 3.8.57 | Security & compliance sweep · publish provenance (OIDC) rehearsal |
| 3.8.58 | Full dry-run of the 3.9.0 cut (branches, channels, forward-port) — includes the PR preview-artifact + build-once promotion rehearsal (#8084) |
| 3.8.59 | Final freeze · full-suite audit · GO/NO-GO |
## Phase 3 — v3.9.0 LTS
After 3.8.59 the next version is **3.9.0** (there is no 3.8.60). It creates the long-lived
branch model:
- **`stable/v3`** — the LTS line (3.9.x). Receives fixes, security patches, and provider
updates. `npm install omniroute` (aka `latest`) stays on v3 during the whole v4 cycle.
- **`develop`** — v4 development, published as `4.0.0-nightly.*`.
- **`main`** — v4 release candidates (`next`) and, eventually, GA.
- Fixes merged to `stable/v3` are automatically forward-ported to `develop` with full
contributor credit (`Co-authored-by`).
New features land in the v4 channel. The LTS line is stability-first.
## Phase 4 — v4.0: the modular platform
The monolith is intentionally disassembled on `develop`:
- **`@omniroute/core`** (npm name stays `omniroute`) — just the engine: `/v1/*`, routing,
combo/fallback, providers.
- **`@omniroute/sdk`** — one typed contract: hooks, extension points, two-phase lifecycle,
UI contributions. The five extension systems that exist today (plugins, CLI plugins,
skills, MCP tools, A2A skills) collapse into one declarative manifest.
- **Modules** (`@omniroute/mod-*`) — cloud agents, traffic inspection (MITM), evals,
webhooks, memory, guardrails, observability and more move out of the core, each with its
own version and lifecycle.
- **Providers as plugins** — adding a provider stops touching the core.
- **Marketplace** — one-click install with verified integrity (hash pinning, signing,
sandbox). Free in v1; a paid tier later with revenue share for creators.
- Ships as `4.0.0-nightly.*``4.0.0-rc.N` (soak in production) → **4.0.0 GA**, when
`latest` switches to v4 and v3 enters its announced LTS support window.
**The core is MIT and free, forever.**
## For contributors
| You are sending... | Target today | From 3.8.55 | After 3.9.0 |
| --- | --- | --- | --- |
| Bug fix / security | active `release/v3.8.x` | same | `stable/v3` |
| Provider update | active `release/v3.8.x` | same | `stable/v3` |
| Docs / i18n | active `release/v3.8.x` | same | `stable/v3` |
| New feature | active `release/v3.8.x` | held with `v4-feature` label | `develop` (v4) |
See `CONTRIBUTING.md` for the golden path per change type.

View File

@@ -42,13 +42,13 @@ Request → CORS → Authz pipeline (classify → policies → enforce)
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- | | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
| **Dashboard Login** | Password-based auth with JWT tokens (HttpOnly cookies) | | **Dashboard Login** | Password-based auth with JWT tokens (HttpOnly cookies) |
| **API Key Auth** | HMAC-signed keys with CRC validation | | **API Key Auth** | HMAC-signed keys with CRC validation |
| **OAuth 2.0 + PKCE** | Provider-specific browser/device OAuth uses PKCE where supported; import-only Devin credentials are handled separately. | | **OAuth 2.0 + PKCE** | 14 providers (Claude, Codex, GitHub, Cursor, Antigravity, Gemini, Kimi Coding, Kilo Code, Cline, Qwen, Kiro, Qoder, Windsurf, GitLab Duo) |
| **Token Refresh** | Automatic OAuth token refresh before expiry | | **Token Refresh** | Automatic OAuth token refresh before expiry |
| **Secure Cookies** | `AUTH_COOKIE_SECURE=true` for HTTPS environments | | **Secure Cookies** | `AUTH_COOKIE_SECURE=true` for HTTPS environments |
| **Authz Pipeline** | Route classification (PUBLIC / CLIENT_API / MANAGEMENT) — see `docs/architecture/AUTHZ_GUIDE.md` | | **Authz Pipeline** | Route classification (PUBLIC / CLIENT_API / MANAGEMENT) — see `docs/architecture/AUTHZ_GUIDE.md` |
| **Route Guard Tiers** | 3-tier model for management routes (LOCAL_ONLY / ALWAYS_PROTECTED / MANAGEMENT) — see `docs/security/ROUTE_GUARD_TIERS.md` | | **Route Guard Tiers** | 3-tier model for management routes (LOCAL_ONLY / ALWAYS_PROTECTED / MANAGEMENT) — see `docs/security/ROUTE_GUARD_TIERS.md` |
| **Manage-Scope MCP** | Remote `/api/mcp/*` access gated by API keys with `manage` scope; `/api/cli-tools/runtime/*` stays strict-loopback. See ROUTE_GUARD_TIERS | | **Manage-Scope MCP** | Remote `/api/mcp/*` access gated by API keys with `manage` scope; `/api/cli-tools/runtime/*` stays strict-loopback. See ROUTE_GUARD_TIERS |
| **MCP Scopes** | 32 granular scopes (read:health, write:combos, execute:completions, etc.) — see `docs/frameworks/MCP-SERVER.md` | | **MCP Scopes** | ~13 granular scopes (read:health, write:combos, execute:completions, etc.) — see `docs/frameworks/MCP-SERVER.md` |
### 🛡️ Encryption at Rest ### 🛡️ Encryption at Rest
@@ -77,28 +77,21 @@ Custom guardrails register via `registerGuardrail(new MyGuardrail())`. The model
### 🧠 Prompt Injection Guard ### 🧠 Prompt Injection Guard
Best-effort heuristic middleware that detects prompt injection patterns in LLM requests. Middleware that detects and blocks prompt injection attacks in LLM requests:
**Not a complete prompt-injection firewall** — can produce false positives (benign
persona/RPG prompts) and false negatives (leetspeak, spacing, non-English patterns).
| Pattern Type | Severity | Example | | Pattern Type | Severity | Example |
| ------------------- | -------- | ---------------------------------------------- | | ------------------- | -------- | ---------------------------------------------- |
| System Override | High | "ignore all previous instructions" | | System Override | High | "ignore all previous instructions" |
| Role Hijack | Medium | "you are now DAN, you can do anything" | | Role Hijack | High | "you are now DAN, you can do anything" |
| Delimiter Injection | High | Encoded separators to break context boundaries | | Delimiter Injection | Medium | Encoded separators to break context boundaries |
| DAN/Jailbreak | Medium | Known jailbreak prompt patterns | | DAN/Jailbreak | High | Known jailbreak prompt patterns |
| Instruction Leak | High | "show me your system prompt" | | Instruction Leak | Medium | "show me your system prompt" |
| Encoding Evasion | Medium | base64/rot13/hex decode + instruction keywords |
Only **High** severity detections are blocked in `block` mode. Medium-severity
families are logged but never blocked by `sanitizeRequest`.
Configure via dashboard (Settings → Security) or `.env`: Configure via dashboard (Settings → Security) or `.env`:
```env ```env
INPUT_SANITIZER_ENABLED=true INPUT_SANITIZER_ENABLED=true
INPUT_SANITIZER_MODE=block # warn | block (injection policy; legacy "redact" does not strip injection text) INPUT_SANITIZER_MODE=block # warn | block | redact
INPUT_SANITIZER_BLOCK_THRESHOLD=high # high (default) | medium | low — severities at/above this are blocked in block mode
``` ```
### 🔒 PII Redaction ### 🔒 PII Redaction
@@ -115,8 +108,7 @@ Automatic detection and optional redaction of personally identifiable informatio
| SSN (US) | `123-45-6789` | `[SSN_REDACTED]` | | SSN (US) | `123-45-6789` | `[SSN_REDACTED]` |
```env ```env
PII_REDACTION_ENABLED=true # request PII rewrite; independent of INPUT_SANITIZER_MODE PII_REDACTION_ENABLED=true
PII_RESPONSE_SANITIZATION=true # optional: redact PII in provider responses returned to clients
``` ```
### 🌐 Network Security ### 🌐 Network Security

View File

@@ -1,26 +0,0 @@
# Third-Party Notices
## codex-chatgpt-web
Parts of `open-sse/vendor/codex-chatgpt-web/` are adapted from
[`miuuyy/codex-chatgpt-web`](https://github.com/miuuyy/codex-chatgpt-web), commit
`55592fca0ba19a27f1b769cec8fff61ff340a785`.
MIT License
Copyright (c) 2026 codex-chatgpt-web contributors
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction,
including without limitation the rights to use, copy, modify, merge, publish, distribute,
sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT
NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT
OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,230 +0,0 @@
/**
* ESM path-alias resolver for global installs.
*
* Problem (#7791): when OmniRoute is installed via `npm i -g omniroute`, the
* package files live under `node_modules/omniroute/`. tsx's tsconfig-path
* resolution does not apply there, so specifiers like `@/shared/utils/featureFlags`
* (declared in tsconfig.json `paths` as `@/* → ./src/*`) or
* `@omniroute/open-sse/services/usage` fail with `ERR_MODULE_NOT_FOUND`.
* The CLI crashes before any command can run.
*
* Fix: register a Node ESM `resolve` hook that rewrites alias specifiers to
* absolute file URLs. Covers all tsconfig.json `paths` entries:
* - `@/*` → `./src/*`
* - `@omniroute/open-sse` → `./open-sse/index.ts`
* - `@omniroute/open-sse/*` → `./open-sse/*`
* The hook runs after tsx so `.ts` extensions are already handled, and only
* intercepts matched prefixes — everything else falls through to Node's
* default resolver.
*
* Exposed as pure functions so the mapping logic is unit-testable without a
* running module loader.
*/
import { existsSync, statSync } from "node:fs";
import { dirname, join, relative, isAbsolute } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
/**
* Alias mapping table — mirrors tsconfig.json `paths`.
* Processed top-to-bottom; first matching prefix wins.
*
* Each entry:
* prefix — specifier prefix to match (e.g. `"@/"`, `"@omniroute/open-sse/"`)
* target — directory name under the package root (e.g. `"src"`, `"open-sse"`)
* exact — if true, the prefix also matches when the specifier equals the
* prefix *without* a trailing slash (e.g. `@omniroute/open-sse` →
* `<root>/open-sse/index.ts`).
*
* Exported for tests/consumers.
*/
export const ALIAS_MAP = [
{ prefix: "@/", target: "src", exact: false },
{ prefix: "@omniroute/open-sse/", target: "open-sse", exact: false },
{ prefix: "@omniroute/open-sse", target: "open-sse", exact: true },
];
/** @deprecated Use ALIAS_MAP instead. Kept for backward compat. */
export const ALIAS_PREFIX = "@/";
// This file is ESM (no CJS __dirname global) — derive it from import.meta.url
// so the pathToFileURL(join(__dirname, ...)) call below resolves correctly
// regardless of the caller's cwd.
const __dirname = dirname(fileURLToPath(import.meta.url));
/**
* Resolve an alias specifier to an absolute file URL.
*
* Rules mirror tsconfig.json `paths` via `ALIAS_MAP`:
* "@/..." → <root>/src/...
* "@omniroute/open-sse/..." → <root>/open-sse/...
* "@omniroute/open-sse" → <root>/open-sse/index.*
*
* - Strips the matched alias prefix and joins the remainder against the
* corresponding target directory.
* - Probes the underlying filesystem for the actual source file: the specifier
* itself, then with common source extensions (`.ts`, `.tsx`, `.js`, `.mjs`,
* `.cjs`, `.json`), then `<dir>/index.*`. Returns the first existing match
* as a `file://` URL.
* - Returns `null` for specifiers that do not match any alias, for malformed
* escapes, for path-traversal attempts, or when no corresponding source
* file exists on disk. The caller treats `null` as "defer to the default
* resolver".
*
* @param {string} specifier Module specifier from an `import` statement.
* @param {string} root Absolute path to the package root.
* @returns {string|null} Absolute `file://` URL, or `null` when unresolved.
*/
const SOURCE_EXTENSIONS = [".ts", ".tsx", ".js", ".mjs", ".cjs", ".json"];
export function resolveAlias(specifier, root) {
if (typeof specifier !== "string" || !root || typeof root !== "string") {
return null;
}
// Find the first matching alias entry (top-to-bottom order).
let matchedEntry = null;
let rest = null;
for (const entry of ALIAS_MAP) {
if (specifier.startsWith(entry.prefix)) {
// For non-exact entries, require at least one char after the prefix
// to avoid matching bare "@/" as "nothing".
const after = specifier.slice(entry.prefix.length);
if (after.length === 0 && !entry.exact) continue;
matchedEntry = entry;
rest = after;
break;
}
}
if (!matchedEntry) return null;
const targetDir = join(root, matchedEntry.target);
// Exact match (e.g. `@omniroute/open-sse` with no trailing path) →
// resolve to `<target>/index.*`.
if (rest === "" || rest === undefined) {
return probeIndex(targetDir);
}
// Guard against absolute-ish escapes (`@//etc/passwd`, `@/\x00`).
if (rest.startsWith("/") || rest.startsWith("\\")) {
return null;
}
// Guard against path-traversal escapes (`@/../../../etc/hostname`).
const segments = rest.split(/[\\\/]+/);
if (segments.includes("..")) {
return null;
}
const base = join(targetDir, rest);
if (!isWithinRoot(targetDir, base)) {
return null;
}
return probeFile(base) ?? probeIndex(base) ?? null;
}
/**
* Probe a bare path and its extension variants. Returns the first existing
* match as a `file://` URL, or `null`.
*/
function probeFile(base) {
// Try extension variants first — a bare `base` that happens to be a directory
// would match existsSync() but should NOT be returned as a file URL (the
// caller expects a file, not a directory). Extension-probing avoids this
// false positive (e.g. `usage` vs `usage.ts` vs `usage/`).
for (const ext of SOURCE_EXTENSIONS) {
const candidate = base + ext;
if (existsSync(candidate)) return pathToFileURL(candidate).href;
}
// Only accept the bare path if it is NOT a directory.
if (existsSync(base)) {
try {
const st = statSync(base);
if (!st.isDirectory()) return pathToFileURL(base).href;
} catch {}
}
return null;
}
/**
* Probe a directory for an `index.*` entry. Returns the first existing
* match as a `file://` URL, or `null`.
*/
function probeIndex(dir) {
const indexBase = join(dir, "index");
for (const ext of SOURCE_EXTENSIONS) {
const candidate = indexBase + ext;
if (existsSync(candidate)) return pathToFileURL(candidate).href;
}
return null;
}
/**
* True when `candidate` resolves to a location inside `ancestor` (or is
* `ancestor` itself). Used as a second, path-normalization-aware layer of
* defense against traversal beyond the literal `..` segment check above.
*
* @param {string} ancestor
* @param {string} candidate
* @returns {boolean}
*/
function isWithinRoot(ancestor, candidate) {
const rel = relative(ancestor, candidate);
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
}
/**
* Register the ESM resolve hook for the current process. Safe to call multiple
* times — subsequent calls are no-ops once the hook is installed.
*
* Uses Node's stable `module.register()` API (available since Node 20.6,
* required Node 22+ here). The hook runs in a worker thread but only reads the
* captured `root`, so no shared-state hazards.
*
* @param {string} root Absolute path to the package root.
* @returns {Promise<boolean>} Resolves `true` once registered (or if already
* registered), `false` on environments where `module.register` is unavailable.
*/
let _registered = false;
export async function registerAliasResolver(root) {
// Validate input FIRST, before the _registered short-circuit. Otherwise the
// second call in the same process (e.g. a test suite that already registered
// once) would silently return `true` for invalid input instead of rejecting,
// masking programmer errors. Input validation must be unconditional.
if (!root || typeof root !== "string") {
throw new TypeError("registerAliasResolver: root must be a non-empty string");
}
if (_registered) return true;
// if the directory does not exist we would only mask a real misconfiguration
// by installing a hook that rewrites to nowhere.
if (!existsSync(join(root, "src"))) {
return false;
}
try {
const { register } = await import("node:module");
// #7808: load the hook from a real file on disk via pathToFileURL() instead
// of building a `data:text/javascript,...` URL dynamically. CodeQL's
// `js/incomplete-url-substring-sanitization` flagged the interpolated
// `new URL(...)` call; a file URL produced by pathToFileURL() is a trusted,
// fully-parsed URL — no sanitization ambiguity. The hook source lives in
// `bin/aliasResolverHook.mjs` (sibling of this file), shipped via
// package.json "files": ["bin/"].
const hookPath = join(__dirname, "aliasResolverHook.mjs");
const hookUrl = pathToFileURL(hookPath);
register(hookUrl, { data: { root } });
_registered = true;
return true;
} catch {
// Older Node or sandboxed env without module.register — fall back to the
// default resolver. The bug will resurface only in the exact global-install
// scenario, which is what we explicitly patched; other entry points still
// work because they import via relative paths.
return false;
}
}
// #7808: the ESM loader hook source now lives in `bin/aliasResolverHook.mjs`,
// loaded via `pathToFileURL()` above. The previous inline `HOOK_SOURCE` template
// literal was removed because its `new URL(\`data:text/javascript,...\`)` wrapper
// triggered CodeQL `js/incomplete-url-substring-sanitization`. The hook logic
// itself is unchanged — see aliasResolverHook.mjs for the resolver behaviour.

View File

@@ -1,126 +0,0 @@
/**
* ESM loader hook for path-alias resolution (#7791 + #7808).
*
* This file runs in Node's loader worker thread after being registered via
* `module.register(url, data)` from `bin/aliasResolver.mjs`. It MUST NOT import
* anything from the parent module — all inputs arrive through `initialize(data)`.
*
* Behaviour:
* - Rewrites alias specifiers to absolute filesystem paths, mirroring
* tsconfig.json `paths`:
* - `@/*` → <root>/src/*
* - `@omniroute/open-sse` → <root>/open-sse/index.*
* - `@omniroute/open-sse/*` → <root>/open-sse/*
* - Probes the usual source extensions (`.ts`, `.tsx`, `.js`, `.mjs`, `.cjs`,
* `.json`) plus `index.*` for directory imports.
* - Returns `shortCircuit: true` only when a candidate file exists on disk;
* otherwise delegates to the next resolver (tsx/Node) so unrelated imports
* and legitimate "module not found" errors pass through unchanged.
*
* Why a separate file instead of an inline `data:` URL?
* CodeQL's `js/incomplete-url-substring-sanitization` flags dynamic `new URL(...)`
* construction with interpolated strings. A real file URL produced by
* `pathToFileURL()` is a trusted, fully-parsed URL — no sanitization ambiguity.
*/
import { pathToFileURL } from "node:url";
import { join, relative, isAbsolute } from "node:path";
import { existsSync, statSync } from "node:fs";
let ROOT = "";
export function initialize(data) {
ROOT = (data && data.root) || "";
}
const EXTENSIONS = [".ts", ".tsx", ".js", ".mjs", ".cjs", ".json"];
/**
* Alias prefix table — mirrors ALIAS_MAP in aliasResolver.mjs and
* tsconfig.json `paths`. Processed top-to-bottom; first match wins.
*
* @type {Array<{prefix: string, target: string, exact: boolean}>}
*/
const ALIAS_TABLE = [
{ prefix: "@/", target: "src", exact: false },
{ prefix: "@omniroute/open-sse/", target: "open-sse", exact: false },
{ prefix: "@omniroute/open-sse", target: "open-sse", exact: true },
];
function tryResolveAliasFsPath(specifier) {
if (!ROOT || typeof specifier !== "string") return null;
// Find the first matching alias entry.
let matchedEntry = null;
let rest = null;
for (const entry of ALIAS_TABLE) {
if (specifier.startsWith(entry.prefix)) {
const after = specifier.slice(entry.prefix.length);
if (after.length === 0 && !entry.exact) continue;
matchedEntry = entry;
rest = after;
break;
}
}
if (!matchedEntry) return null;
const targetDir = join(ROOT, matchedEntry.target);
// Exact match (e.g. `@omniroute/open-sse`) → resolve to `<target>/index.*`.
if (rest === "" || rest === undefined) {
return probeIndex(targetDir);
}
// Guard against absolute-ish escapes.
if (rest.startsWith("/") || rest.startsWith("\\")) return null;
// Guard against path-traversal escapes.
const segments = rest.split(/[\\\/]+/);
if (segments.includes("..")) return null;
const base = join(targetDir, rest);
if (!isWithinRoot(targetDir, base)) return null;
return probeFile(base) ?? probeIndex(base) ?? null;
}
function probeFile(base) {
// Extension variants first — avoids matching a bare directory name.
for (const ext of EXTENSIONS) {
const candidate = base + ext;
if (existsSync(candidate)) return candidate;
}
if (existsSync(base)) {
try {
const st = statSync(base);
if (!st.isDirectory()) return base;
} catch {}
}
return null;
}
function probeIndex(dir) {
const indexBase = join(dir, "index");
for (const ext of EXTENSIONS) {
const candidate = indexBase + ext;
if (existsSync(candidate)) return candidate;
}
return null;
}
/**
* True when `candidate` resolves to a location inside `ancestor` (or is
* `ancestor` itself). Path-normalization-aware defense against traversal.
*/
function isWithinRoot(ancestor, candidate) {
const rel = relative(ancestor, candidate);
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
}
export function resolve(specifier, context, nextResolve) {
const fsPath = tryResolveAliasFsPath(specifier);
if (fsPath) {
return {
url: pathToFileURL(fsPath).href,
shortCircuit: true,
};
}
return nextResolve(specifier, context);
}

View File

@@ -1,56 +0,0 @@
#!/usr/bin/env node
import { existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const root = join(here, "..");
export function resolveChatGptWebCodexMcpEntry(rootDir = root, exists = existsSync) {
const candidates = [
join(
rootDir,
"dist",
"open-sse",
"vendor",
"codex-chatgpt-web",
"adapters",
"chatgpt-web",
"mcp-server.js"
),
join(
rootDir,
"open-sse",
"vendor",
"codex-chatgpt-web",
"adapters",
"chatgpt-web",
"mcp-server.ts"
),
];
return candidates.find((candidate) => exists(candidate)) ?? null;
}
export async function startChatGptWebCodexMcp(args = process.argv.slice(2), rootDir = root) {
const socketIndex = args.indexOf("--broker-socket");
const brokerSocketPath = socketIndex >= 0 ? args[socketIndex + 1] : undefined;
if (!brokerSocketPath) throw new Error("--broker-socket is required");
const entry = resolveChatGptWebCodexMcpEntry(rootDir);
if (!entry) throw new Error("ChatGPT Web (Codex) MCP entrypoint was not found");
if (entry.endsWith(".ts")) {
const { register } = await import("node:module");
register("tsx/esm", pathToFileURL(`${rootDir}/`));
}
const module = await import(pathToFileURL(entry).href);
await module.runChatGptMcpServer({ brokerSocketPath });
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
startChatGptWebCodexMcp().catch((error) => {
console.error(
`ChatGPT Web (Codex) MCP konnte nicht gestartet werden: ${error?.message || error}`
);
process.exit(1);
});
}

View File

@@ -136,7 +136,7 @@ export const RETRY_DEFAULTS = {
- Every user-facing string goes through `t("module.key", vars)`. - Every user-facing string goes through `t("module.key", vars)`.
- Catalogs live in `bin/cli/locales/{locale}.json` (nested objects). - Catalogs live in `bin/cli/locales/{locale}.json` (nested objects).
43 files ship out-of-the-box: `en`, `pt-BR`, and 41 additional locales. 42 files ship out-of-the-box: `en`, `pt-BR`, and 40 additional locales.
11 locales are scaffold-only (empty `{}`); all keys fall back to `en` automatically. 11 locales are scaffold-only (empty `{}`); all keys fall back to `en` automatically.
- Detection order: `--lang` flag → `OMNIROUTE_LANG` env → `LC_ALL``LC_MESSAGES``LANG``en`. - Detection order: `--lang` flag → `OMNIROUTE_LANG` env → `LC_ALL``LC_MESSAGES``LANG``en`.
- Locale persisted via `config lang set <code>` — saves `OMNIROUTE_LANG` to `~/.omniroute/.env`. - Locale persisted via `config lang set <code>` — saves `OMNIROUTE_LANG` to `~/.omniroute/.env`.

View File

@@ -22,9 +22,9 @@ bin/cli/
├── provider-test.mjs ← testProviderApiKey() ├── provider-test.mjs ← testProviderApiKey()
├── settings-store.mjs ← DB CRUD for key_value settings ├── settings-store.mjs ← DB CRUD for key_value settings
├── locales/ ├── locales/
│ ├── en.json ← English strings (source of truth, 43 locales) │ ├── en.json ← English strings (source of truth, 42+ locales)
│ ├── pt-BR.json ← Portuguese (Brazil) — fully translated │ ├── pt-BR.json ← Portuguese (Brazil) — fully translated
│ └── {locale}.json ← 42 additional locales (ar, az, de, es, fr, ja, zh-CN, …) │ └── {locale}.json ← 40 additional locales (ar, az, de, es, fr, ja, zh-CN, …)
├── scripts/ ├── scripts/
│ └── generate-locales.mjs ← scaffold new locale files from config/i18n.json │ └── generate-locales.mjs ← scaffold new locale files from config/i18n.json
└── commands/ └── commands/

View File

@@ -139,21 +139,6 @@ export function shouldRetryError(err, opts = {}) {
return false; return false;
} }
/**
* True when a non-2xx status means "this server does not serve this route"
* rather than "your request was wrong".
*
* Commands that keep a local SQLite fallback must not treat these as fatal:
* a CLI newer (or older) than the server it is talking to will hit routes that
* simply are not mounted, and aborting there strands the user with an
* unactionable `HTTP 404` even though the local path would have worked.
* Genuine client errors (400/401/403/409/422 …) stay fatal — retrying them
* locally would paper over a real problem.
*/
export function isRouteUnavailableStatus(status) {
return status === 404 || status === 405 || status === 501;
}
export function statusToExitCode(status) { export function statusToExitCode(status) {
if (status >= 200 && status < 300) return 0; if (status >= 200 && status < 300) return 0;
if (status === 408) return 124; if (status === 408) return 124;

View File

@@ -1,163 +0,0 @@
import { chmodSync, existsSync, writeFileSync } from "node:fs";
import { decryptCredential } from "../encryption.mjs";
import { findProviderConnection, listProviderConnections } from "../provider-store.mjs";
import { openOmniRouteDb } from "../sqlite.mjs";
import { t } from "../i18n.mjs";
/**
* Local-only, operator-invoked command that dumps DECRYPTED provider credentials
* (apiKey/accessToken/refreshToken/idToken). This never runs inside the HTTP server
* process and must never be reachable over the network — no src/app/api/ route wraps
* this. See docs/security/ for the threat-model writeup referenced in issue #6683.
*/
const CREDENTIAL_FIELDS = [
{ key: "apiKey", envSuffix: "API_KEY" },
{ key: "accessToken", envSuffix: "ACCESS_TOKEN" },
{ key: "refreshToken", envSuffix: "REFRESH_TOKEN" },
{ key: "idToken", envSuffix: "ID_TOKEN" },
];
const VALID_FORMATS = new Set(["json", "env"]);
const SECURE_FILE_MODE = 0o600;
export function registerAuthExport(program) {
program
.command("auth export")
.description(t("authExport.description"))
.option("--id <id>", t("authExport.idOpt"))
.option("--format <format>", t("authExport.formatOpt"), "json")
.option("--out <file>", t("authExport.outOpt"))
.option("--force", t("authExport.forceOpt"))
.action(async (opts, cmd) => {
const globalOpts = cmd.optsWithGlobals();
const exitCode = await runAuthExportCommand({ ...opts, ...globalOpts });
if (exitCode !== 0) process.exit(exitCode);
});
}
export async function runAuthExportCommand(opts = {}) {
// Security control (a): confirmation gate BEFORE any DB access — a dry invocation
// never opens the database and never decrypts anything.
if (!opts.force) {
printConfirmationGate();
return 0;
}
const format = opts.format || "json";
if (!VALID_FORMATS.has(format)) {
console.error(t("authExport.invalidFormat", { format }));
return 1;
}
if (!process.env.STORAGE_ENCRYPTION_KEY) {
console.error(t("authExport.missingKey"));
return 1;
}
// Security control (b): stderr warning banner BEFORE any plaintext is emitted.
process.stderr.write(t("authExport.warning") + "\n");
const rows = await loadTargetConnections(opts.id);
if (rows === null) {
console.error(t("authExport.notFound", { id: opts.id }));
return 1;
}
const exported = rows.map(exportConnection);
const content = format === "env" ? formatAsEnv(exported) : formatAsJson(exported);
if (opts.out) {
writeSecureFile(opts.out, content);
} else {
console.log(content);
}
return 0;
}
function printConfirmationGate() {
console.log(
`\n${t("authExport.confirmHeading")}\n\n${t("authExport.confirmBody")}\n\n${t("authExport.confirmFooter")}\n`
);
}
async function loadTargetConnections(id) {
const { db } = await openOmniRouteDb();
try {
if (!id) return listProviderConnections(db);
const connection = findProviderConnection(db, id);
return connection ? [connection] : null;
} finally {
db.close();
}
}
function decryptField(rawValue) {
// Security control (d): a per-field decrypt failure surfaces as a boolean flag,
// never the caught error text. Security control (e): the caught error is never
// interpolated into any message.
try {
return { value: decryptCredential(rawValue), failed: false };
} catch {
return { value: null, failed: true };
}
}
function exportConnection(connection) {
const result = {
id: connection.id,
provider: connection.provider,
name: connection.name,
authType: connection.authType,
};
for (const { key } of CREDENTIAL_FIELDS) {
const rawValue = connection[key];
if (!rawValue) {
result[key] = null;
result[`${key}DecryptFailed`] = false;
continue;
}
const { value, failed } = decryptField(rawValue);
result[key] = value;
result[`${key}DecryptFailed`] = failed;
}
return result;
}
function formatAsJson(rows) {
return JSON.stringify(rows, null, 2);
}
function envSafeSegment(value) {
return String(value || "")
.toUpperCase()
.replace(/[^A-Z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}
function formatAsEnv(rows) {
const lines = [];
for (const row of rows) {
lines.push(`# ${row.provider} (${row.id})`);
const providerSegment = envSafeSegment(row.provider);
for (const { key, envSuffix } of CREDENTIAL_FIELDS) {
const value = row[key];
if (!value) continue;
lines.push(`OMNIROUTE_${providerSegment}_${envSuffix}=${value}`);
}
}
return lines.join("\n");
}
function writeSecureFile(filePath, content) {
// Security control (c): file output written with mode 0o600 (plus chmodSync if the
// file pre-existed, belt-and-suspenders against an already world-readable file).
const preExisted = existsSync(filePath);
writeFileSync(filePath, content, { mode: SECURE_FILE_MODE });
if (preExisted) {
chmodSync(filePath, SECURE_FILE_MODE);
}
}

View File

@@ -78,15 +78,18 @@ export function registerBackup(program) {
if (exitCode !== 0) process.exit(exitCode); if (exitCode !== 0) process.exit(exitCode);
}); });
// Legacy: `omniroute backup` without a subcommand still creates a backup // Legacy: `omniroute backup` without subcommand still creates a backup
// (documented as the canonical usage in USER_GUIDE.md / CLI-TOOLS.md /
// AGENT-SKILLS.md). No flags are declared here — declaring the same
// option names as `create`/`auto enable` here previously shadowed them
// (#8512), and no doc shows `omniroute backup` invoked with flags.
backup.action(async (opts) => { backup.action(async (opts) => {
const exitCode = await runBackupCommand(opts); const exitCode = await runBackupCommand(opts);
if (exitCode !== 0) process.exit(exitCode); if (exitCode !== 0) process.exit(exitCode);
}); });
backup
.option("--name <name>", t("backup.nameOpt"))
.option("--cloud", t("backup.cloudOpt"))
.option("--encrypt", t("backup.encryptOpt"))
.option("--key-file <path>", t("backup.keyFileOpt"))
.option("--exclude <pattern>", t("backup.excludeOpt"), (v, prev = []) => [...prev, v], [])
.option("--retention <n>", t("backup.retentionOpt"), parseInt);
} }
export function registerRestore(program) { export function registerRestore(program) {

View File

@@ -152,7 +152,6 @@ export async function runComboListCommand(opts = {}) {
return await withRuntime(async ({ kind, api, db }) => { return await withRuntime(async ({ kind, api, db }) => {
let combos = []; let combos = [];
let activeCombo = null; let activeCombo = null;
let listError = null;
if (kind === "http") { if (kind === "http") {
const [listRes, activeRes] = await Promise.all([ const [listRes, activeRes] = await Promise.all([
@@ -162,12 +161,6 @@ export async function runComboListCommand(opts = {}) {
if (listRes.ok) { if (listRes.ok) {
const data = await listRes.json(); const data = await listRes.json();
combos = Array.isArray(data) ? data : (data.combos ?? []); combos = Array.isArray(data) ? data : (data.combos ?? []);
} else {
// The server answered, but not with a combo list. Falling through to
// an empty array here rendered "No combos configured" — which is
// indistinguishable from genuine emptiness and reads as real state,
// so a transport/auth failure looked like a wiped configuration.
listError = listRes.status;
} }
if (activeRes.ok) { if (activeRes.ok) {
const settings = await activeRes.json(); const settings = await activeRes.json();
@@ -178,25 +171,11 @@ export async function runComboListCommand(opts = {}) {
} }
if (opts.json || opts.output === "json") { if (opts.json || opts.output === "json") {
console.log( console.log(JSON.stringify({ combos, active: activeCombo }, null, 2));
JSON.stringify( return 0;
{ combos, active: activeCombo, error: listError && `HTTP ${listError}` },
null,
2
)
);
return listError ? 1 : 0;
} }
printHeading(t("combo.title")); printHeading(t("combo.title"));
if (listError) {
console.error(
t("common.error", {
message: `could not list combos from the server (HTTP ${listError})`,
})
);
return 1;
}
if (combos.length === 0) { if (combos.length === 0) {
console.log(t("combo.noCombos")); console.log(t("combo.noCombos"));
return 0; return 0;

View File

@@ -208,7 +208,7 @@ _omniroute() {
COMPREPLY=() COMPREPLY=()
cur="\${COMP_WORDS[COMP_CWORD]}" cur="\${COMP_WORDS[COMP_CWORD]}"
prev="\${COMP_WORDS[COMP_CWORD-1]}" prev="\${COMP_WORDS[COMP_CWORD-1]}"
cmds="setup doctor status logs providers config test update serve stop restart keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills run" cmds="setup doctor status logs providers config test update serve stop restart keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills"
case "\${prev}" in case "\${prev}" in
combo) COMPREPLY=($(compgen -W "list switch create delete show suggest" -- "\${cur}")); return 0 ;; combo) COMPREPLY=($(compgen -W "list switch create delete show suggest" -- "\${cur}")); return 0 ;;
@@ -242,7 +242,7 @@ function generateFishScript() {
return `# OmniRoute CLI fish completion (dynamic) return `# OmniRoute CLI fish completion (dynamic)
complete -c omniroute -f complete -c omniroute -f
set -l commands serve stop restart setup doctor status logs providers config keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills update test run set -l commands serve stop restart setup doctor status logs providers config keys models combo chat stream completion dashboard open backup restore health quota cache mcp a2a tunnel env memory skills update test
for cmd in $commands for cmd in $commands
complete -c omniroute -n '__fish_is_nth_token 1' -a $cmd complete -c omniroute -n '__fish_is_nth_token 1' -a $cmd

View File

@@ -5,7 +5,6 @@ import fs from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { resolveDataDir } from "../data-dir.mjs"; import { resolveDataDir } from "../data-dir.mjs";
import { registerContexts } from "./contexts.mjs"; import { registerContexts } from "./contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
function ensureBackup(configPath) { function ensureBackup(configPath) {
if (!fs.existsSync(configPath)) return; if (!fs.existsSync(configPath)) return;
@@ -88,13 +87,6 @@ async function runConfigSetCommand(toolId, opts = {}) {
return 1; return 1;
} }
const guard = await guardHostConfigTarget(result.configPath, {
toolLabel: toolId,
hostCommand: `omniroute config set ${toolId}`,
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
});
if (guard !== 0) return guard;
const nonInteractive = opts.nonInteractive || opts.yes; const nonInteractive = opts.nonInteractive || opts.yes;
if (!nonInteractive) { if (!nonInteractive) {
@@ -279,10 +271,6 @@ export function registerConfig(program) {
.option("--model <model>", "Model identifier (where applicable)") .option("--model <model>", "Model identifier (where applicable)")
.option("--non-interactive", "Do not prompt for confirmation") .option("--non-interactive", "Do not prompt for confirmation")
.option("--yes", "Skip confirmation prompt") .option("--yes", "Skip confirmation prompt")
.option(
"--allow-container-write",
"Write the config even when OmniRoute runs in a container and the target is not mounted from the host"
)
.action(async (tool, opts, cmd) => { .action(async (tool, opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals(); const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runConfigSetCommand(tool, { const exitCode = await runConfigSetCommand(tool, {
@@ -318,10 +306,6 @@ export function registerConfig(program) {
.option("--model <model>", "Model identifier") .option("--model <model>", "Model identifier")
.option("--non-interactive", "Do not prompt for confirmation") .option("--non-interactive", "Do not prompt for confirmation")
.option("--yes", "Skip confirmation prompt") .option("--yes", "Skip confirmation prompt")
.option(
"--allow-container-write",
"Write the config even when OmniRoute runs in a container and the target is not mounted from the host"
)
.action(async (opts, cmd) => { .action(async (opts, cmd) => {
const globalOpts = cmd.parent.optsWithGlobals(); const globalOpts = cmd.parent.optsWithGlobals();
const exitCode = await runConfigSetCommand("opencode", { const exitCode = await runConfigSetCommand("opencode", {

View File

@@ -4,7 +4,6 @@ import { existsSync, mkdirSync, writeFileSync, copyFileSync } from "node:fs";
import { apiFetch } from "../api.mjs"; import { apiFetch } from "../api.mjs";
import { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs"; import { createPrompt, printSuccess, printError, printInfo, printHeading } from "../io.mjs";
import { t } from "../i18n.mjs"; import { t } from "../i18n.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
/** /**
* `omniroute configure <cli>` — interactive provider+model picker that writes a * `omniroute configure <cli>` — interactive provider+model picker that writes a
@@ -76,12 +75,6 @@ function buildCodexProfile(modelId, ctx) {
async function configureCodex(modelId, ctxWindow, opts) { async function configureCodex(modelId, ctxWindow, opts) {
const codexHome = opts.codexHome || path.join(os.homedir(), ".codex"); const codexHome = opts.codexHome || path.join(os.homedir(), ".codex");
const guard = await guardHostConfigTarget(codexHome, {
toolLabel: "Codex",
hostCommand: "omniroute configure codex",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
});
if (guard !== 0) return guard;
if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true }); if (!existsSync(codexHome)) mkdirSync(codexHome, { recursive: true });
const profile = opts.name || profileNameFromModel(modelId); const profile = opts.name || profileNameFromModel(modelId);
const filePath = path.join(codexHome, `${profile}.config.toml`); const filePath = path.join(codexHome, `${profile}.config.toml`);
@@ -93,7 +86,6 @@ async function configureCodex(modelId, ctxWindow, opts) {
printInfo(`Use it: codex --profile ${profile}`); printInfo(`Use it: codex --profile ${profile}`);
printInfo("Prereq: ~/.codex/config.toml must define the [model_providers.omniroute] block"); printInfo("Prereq: ~/.codex/config.toml must define the [model_providers.omniroute] block");
printInfo(" (run the Codex setup once — see docs/guides/CODEX-CLI-CONFIGURATION.md)."); printInfo(" (run the Codex setup once — see docs/guides/CODEX-CLI-CONFIGURATION.md).");
return 0;
} }
export async function runConfigureCommand(cli, opts = {}, cmd) { export async function runConfigureCommand(cli, opts = {}, cmd) {
@@ -138,9 +130,7 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
} }
const inProvider = ids.filter((id) => providerList.includes(providerOf(byId(models, id)))); const inProvider = ids.filter((id) => providerList.includes(providerOf(byId(models, id))));
const candidates = inProvider.length ? inProvider : ids; const candidates = inProvider.length ? inProvider : ids;
printInfo( printInfo(`Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}`);
`Models: ${candidates.slice(0, 40).join(", ")}${candidates.length > 40 ? " …" : ""}`
);
chosenId = await prompt.ask("Model id"); chosenId = await prompt.ask("Model id");
} finally { } finally {
prompt.close(); prompt.close();
@@ -159,7 +149,7 @@ export async function runConfigureCommand(cli, opts = {}, cmd) {
const ctxWindow = contextWindowOf(entry); const ctxWindow = contextWindowOf(entry);
if (target === "codex") { if (target === "codex") {
return await configureCodex(chosenId, ctxWindow, opts); await configureCodex(chosenId, ctxWindow, opts);
} }
return 0; return 0;
} }
@@ -183,10 +173,6 @@ export function registerConfigure(program) {
.option("--model <id>", "Model id (skips the interactive model prompt)") .option("--model <id>", "Model id (skips the interactive model prompt)")
.option("--name <name>", "Profile name to write (default: derived from model)") .option("--name <name>", "Profile name to write (default: derived from model)")
.option("--codex-home <dir>", "Codex home dir (default: ~/.codex)") .option("--codex-home <dir>", "Codex home dir (default: ~/.codex)")
.option(
"--allow-container-write",
"Write the config even when OmniRoute runs in a container and the target is not mounted from the host"
)
.action(async (cli, opts, cmd) => { .action(async (cli, opts, cmd) => {
const code = await runConfigureCommand(cli, opts, cmd); const code = await runConfigureCommand(cli, opts, cmd);
if (code !== 0) process.exit(code); if (code !== 0) process.exit(code);

View File

@@ -1,22 +1,17 @@
import { execFile } from "node:child_process"; import { execFile } from "node:child_process";
import { t } from "../i18n.mjs"; import { t } from "../i18n.mjs";
function parsePort(value, fallback) {
const parsed = parseInt(String(value), 10);
return Number.isFinite(parsed) && parsed > 0 && parsed <= 65535 ? parsed : fallback;
}
export function registerDashboard(program) { export function registerDashboard(program) {
program program
.command("dashboard") .command("dashboard")
.description(t("dashboard.description")) .description(t("dashboard.description"))
.option("--url", t("dashboard.urlOnly")) .option("--url", t("dashboard.urlOnly"))
.option("--port <port>", "Port the server is running on") .option("--port <port>", "Port the server is running on", "20128")
.option("--tui", t("dashboard.tui") || "Open interactive TUI dashboard (terminal UI)") .option("--tui", t("dashboard.tui") || "Open interactive TUI dashboard (terminal UI)")
.action(async (opts, cmd) => { .action(async (opts, cmd) => {
if (opts.tui) { if (opts.tui) {
const globalOpts = cmd.optsWithGlobals(); const globalOpts = cmd.optsWithGlobals();
const port = parsePort(opts.port ?? process.env.PORT ?? "20128", 20128); const port = opts.port ? parseInt(String(opts.port), 10) : 20128;
const baseUrl = globalOpts.baseUrl ?? `http://localhost:${port}`; const baseUrl = globalOpts.baseUrl ?? `http://localhost:${port}`;
const apiKey = globalOpts.apiKey ?? null; const apiKey = globalOpts.apiKey ?? null;
const { startInteractiveTui } = await import("../tui/Dashboard.jsx"); const { startInteractiveTui } = await import("../tui/Dashboard.jsx");
@@ -29,7 +24,7 @@ export function registerDashboard(program) {
} }
export async function runDashboardCommand(opts = {}) { export async function runDashboardCommand(opts = {}) {
const port = parsePort(opts.port ?? process.env.PORT ?? "20128", 20128); const port = opts.port ? parseInt(String(opts.port), 10) : 20128;
const dashboardUrl = `http://localhost:${port}`; const dashboardUrl = `http://localhost:${port}`;
if (opts.url) { if (opts.url) {
@@ -49,22 +44,22 @@ export async function runDashboardCommand(opts = {}) {
return 0; return 0;
} }
/**
* Resolve the command and args to open a URL in the default browser
* for a given platform. Exported for testing — callers should use openFallback().
* @param {"darwin"|"win32"|string} platform
* @param {string} url
* @returns {{ cmd: string, args: string[] }}
*/
export function resolveOpenCommand(platform, url) {
if (platform === "darwin") return { cmd: "open", args: [url] };
if (platform === "win32") return { cmd: "rundll32", args: ["url.dll,FileProtocolHandler", url] };
return { cmd: "xdg-open", args: [url] };
}
function openFallback(url) { function openFallback(url) {
return new Promise((resolve) => { return new Promise((resolve) => {
const { cmd, args } = resolveOpenCommand(process.platform, url); const { platform } = process;
let cmd, args;
if (platform === "darwin") {
cmd = "open";
args = [url];
} else if (platform === "win32") {
cmd = "cmd";
args = ["/c", "start", "", url];
} else {
cmd = "xdg-open";
args = [url];
}
execFile(cmd, args, { stdio: "ignore" }, () => resolve()); execFile(cmd, args, { stdio: "ignore" }, () => resolve());
}); });
} }

View File

@@ -288,44 +288,18 @@ async function checkNodeRuntime(rootDir) {
} }
} }
/**
* Name of the prebuilt binary better-sqlite3 ships for this platform, e.g.
* `linux-x64.node`. Musl-based Linux uses a distinct `linuxmusl-` prefix.
* Mirrors the lookup `prebuild-install`/`node-gyp-build` perform at require time.
*/
export function prebuiltBinaryName(
platform = process.platform,
arch = process.arch,
report = process.report
) {
let prefix = platform;
if (platform === "linux") {
let isMusl = false;
try {
// glibc builds expose `glibcVersionRuntime`; musl builds do not.
isMusl = !report?.getReport?.()?.header?.glibcVersionRuntime;
} catch {
isMusl = false;
}
prefix = isMusl ? "linuxmusl" : "linux";
}
return `${prefix}-${arch}.node`;
}
async function checkNativeBinary(rootDir) { async function checkNativeBinary(rootDir) {
// node-gyp layout — present only when better-sqlite3 was compiled locally.
const buildRoots = [
path.join(rootDir, "app", "node_modules", "better-sqlite3"),
path.join(rootDir, "dist", "node_modules", "better-sqlite3"),
path.join(rootDir, "node_modules", "better-sqlite3"),
];
const prebuildName = prebuiltBinaryName();
const candidates = [ const candidates = [
...buildRoots.map((root) => path.join(root, "build", "Release", "better_sqlite3.node")), path.join(
// Prebuilt layout — what `npm i -g omniroute` actually installs. Without rootDir,
// these, doctor warns on every prebuilt install even though the binary is "app",
// present and loading fine. "node_modules",
...buildRoots.map((root) => path.join(root, "prebuilds", prebuildName)), "better-sqlite3",
"build",
"Release",
"better_sqlite3.node"
),
path.join(rootDir, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node"),
]; ];
const binaryPath = candidates.find((candidate) => fs.existsSync(candidate)); const binaryPath = candidates.find((candidate) => fs.existsSync(candidate));
if (!binaryPath) { if (!binaryPath) {
@@ -421,10 +395,7 @@ async function checkServerLiveness(options = {}) {
// First attempt: configured health endpoint (may require auth token). // First attempt: configured health endpoint (may require auth token).
const primary = await probeUrl(url); const primary = await probeUrl(url);
if (primary.ok) { if (primary.ok) {
return ok("Server liveness", "Server health endpoint is reachable", { return ok("Server liveness", "Server health endpoint is reachable", { url, status: primary.status });
url,
status: primary.status,
});
} }
// #6162: /api/health and /api/health/degradation require a management token. // #6162: /api/health and /api/health/degradation require a management token.
@@ -455,12 +426,7 @@ async function checkServerLiveness(options = {}) {
return ok( return ok(
"Server liveness", "Server liveness",
`Server reachable (health endpoint returned ${primary.status}, likely requires MANAGEMENT_TOKEN)`, `Server reachable (health endpoint returned ${primary.status}, likely requires MANAGEMENT_TOKEN)`,
{ { primaryUrl: url, primaryStatus: primary.status, fallbackUrl, fallbackStatus: fallback.status }
primaryUrl: url,
primaryStatus: primary.status,
fallbackUrl,
fallbackStatus: fallback.status,
}
); );
} }
@@ -473,7 +439,8 @@ async function checkServerLiveness(options = {}) {
export async function collectDoctorChecks(context = {}, options = {}) { export async function collectDoctorChecks(context = {}, options = {}) {
const rootDir = const rootDir =
context.rootDir || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", ".."); context.rootDir ||
path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
const dataDir = resolveDataDir(); const dataDir = resolveDataDir();
const dbPath = resolveStoragePath(dataDir); const dbPath = resolveStoragePath(dataDir);

View File

@@ -8,7 +8,7 @@ import {
} from "../provider-store.mjs"; } from "../provider-store.mjs";
import { openOmniRouteDb } from "../sqlite.mjs"; import { openOmniRouteDb } from "../sqlite.mjs";
import { loadAvailableProviders } from "../provider-catalog.mjs"; import { loadAvailableProviders } from "../provider-catalog.mjs";
import { apiFetch, isServerUp, isRouteUnavailableStatus } from "../api.mjs"; import { apiFetch, isServerUp } from "../api.mjs";
import { t } from "../i18n.mjs"; import { t } from "../i18n.mjs";
function getValidProviderIds() { function getValidProviderIds() {
@@ -184,10 +184,7 @@ export async function runKeysAddCommand(provider, apiKey, opts = {}) {
console.log(t("keys.added", { provider: providerLower })); console.log(t("keys.added", { provider: providerLower }));
return 0; return 0;
} }
// A missing route means this server does not implement the endpoint — if (res.status >= 400 && res.status < 500) {
// fall through to the local SQLite path below rather than stranding the
// user. Real client errors still abort.
if (res.status >= 400 && res.status < 500 && !isRouteUnavailableStatus(res.status)) {
console.error(t("common.error", { message: `HTTP ${res.status}` })); console.error(t("common.error", { message: `HTTP ${res.status}` }));
return 1; return 1;
} }

View File

@@ -1,36 +1,6 @@
import { spawn, execFileSync } from "node:child_process"; import { spawn } from "node:child_process";
import { t } from "../i18n.mjs"; import { t } from "../i18n.mjs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { quoteShellArgs } from "../utils/winShellArgs.mjs";
/**
* Probe PATH for a Windows executable via `where.exe`, preferring a `.exe` over
* a `.cmd`/`.bat` shim. Returns the absolute path to the preferred binary, or
* `null` when `where.exe` finds nothing (or cannot run). Mirrors the same probe
* in launch.mjs and `locateCommand()` in `src/shared/services/cliRuntime.ts`.
*
* @param {string} command bare command name to look up
* @returns {Promise<string|null>} absolute path to the preferred match, or null
*/
function probeWindowsBinary(command) {
try {
const out = execFileSync("where.exe", [command], {
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
timeout: 3000,
windowsHide: true,
});
const lines = out
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
if (lines.length === 0) return null;
const winExt = /\.(exe|cmd|bat|com)$/i;
return lines.find((l) => winExt.test(l)) || null;
} catch {
return null;
}
}
/** OpenAI/Codex env keys stripped from the child so a stale OpenAI key/base-url /** OpenAI/Codex env keys stripped from the child so a stale OpenAI key/base-url
* in the shell can't shadow the omniroute provider (defense-in-depth). Mirrors * in the shell can't shadow the omniroute provider (defense-in-depth). Mirrors
@@ -52,42 +22,11 @@ const NO_AUTH_SENTINEL = "omniroute-no-auth";
// On Windows the `codex` binary is an npm `.cmd` shim that `spawn` cannot resolve // On Windows the `codex` binary is an npm `.cmd` shim that `spawn` cannot resolve
// without a shell (bare "codex" → ENOENT). Mirror the qodercli Windows fix (#6263): // without a shell (bare "codex" → ENOENT). Mirror the qodercli Windows fix (#6263):
// spawn `codex.cmd` through a shell on win32, and the bare binary elsewhere. // spawn `codex.cmd` through a shell on win32, and the bare binary elsewhere.
// export function resolveCodexSpawn(platform) {
// #9454: the native codex installer may ship a real `codex.exe` instead of the if (platform === "win32") {
// npm `.cmd` shim. Probe PATH for `codex` first: when `where.exe` resolves a return { command: "codex.cmd", shell: true };
// `.exe`, spawn it directly (no shell — cmd.exe would split an absolute path
// with spaces); otherwise fall back to `codex.cmd` + shell. Off Windows the bare
// binary is spawned unchanged (no shell, no probe).
/**
* @param {NodeJS.Platform|string} platform
* @param {{ probe?: (command: string) => Promise<string|null> }} [opts] injectable probe for tests
* @returns {Promise<{ command: string, shell: true|undefined }>}
*/
export async function resolveCodexSpawn(platform, opts = {}) {
if (platform !== "win32") return { command: "codex", shell: undefined };
const probe = opts.probe ?? probeWindowsBinary;
const located = await probe("codex");
if (located && /\.exe$/i.test(located)) {
return { command: located, shell: undefined };
} }
return { command: "codex.cmd", shell: true }; return { command: "codex", shell: undefined };
}
/**
* `shell: true` makes Node join argv with plain spaces and no escaping (the
* DEP0190 warning). That mangles every launch-codex invocation on Windows, not
* just the ones with a multi-word user argument: the injected `-c` provider
* flags carry TOML values whose quotes cmd.exe strips
* (`model_providers.omniroute.name="OmniRoute"` arrives unquoted and no longer
* parses as TOML). Quote the args ourselves on that path; off Windows there is
* no shell, so argv is passed through untouched. Same fix as `launch` (#8837).
*
* @param {string[]} args
* @param {NodeJS.Platform|string} platform
* @returns {string[]}
*/
export function quoteCodexArgs(args, platform) {
return quoteShellArgs(args, platform);
} }
function stripTrailingSlash(value) { function stripTrailingSlash(value) {
@@ -170,8 +109,8 @@ export function buildCodexEnv(baseEnv, authToken) {
* @param {string} baseUrl OmniRoute root URL (no /v1) * @param {string} baseUrl OmniRoute root URL (no /v1)
* @returns {string[]} * @returns {string[]}
*/ */
export function buildCodexProviderArgs(baseUrl, model) { export function buildCodexProviderArgs(baseUrl) {
const args = [ return [
"-c", "-c",
tomlAssign("model_provider", "omniroute"), tomlAssign("model_provider", "omniroute"),
"-c", "-c",
@@ -185,15 +124,6 @@ export function buildCodexProviderArgs(baseUrl, model) {
"-c", "-c",
tomlAssign("model_providers.omniroute.requires_openai_auth", false), tomlAssign("model_providers.omniroute.requires_openai_auth", false),
]; ];
if (model) {
const normalized = String(model).trim();
if (normalized) {
args.push("-c", tomlAssign("model_providers.omniroute.model", normalized));
}
}
return args;
} }
/** /**
@@ -216,15 +146,14 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
// Provider injected via -c (works without config.toml); then the profile (model), // Provider injected via -c (works without config.toml); then the profile (model),
// then the user's pass-through args. // then the user's pass-through args.
const providerArgs = buildCodexProviderArgs(baseUrl, opts.model); const providerArgs = buildCodexProviderArgs(baseUrl);
const profileArgs = opts.profile ? ["--profile", opts.profile] : []; const profileArgs = opts.profile ? ["--profile", opts.profile] : [];
const extraArgs = [...providerArgs, ...profileArgs, ...codexArgs]; const extraArgs = [...providerArgs, ...profileArgs, ...codexArgs];
const env = buildCodexEnv(process.env, authToken); const env = buildCodexEnv(process.env, authToken);
const { command: codexLaunch, shell: shellValue } = await resolveCodexSpawn(process.platform);
return await new Promise((resolve) => { return await new Promise((resolve) => {
const child = spawn(codexLaunch, quoteCodexArgs(extraArgs, process.platform), { const { command: codexLaunch, shell: shellValue } = resolveCodexSpawn(process.platform);
const child = spawn(codexLaunch, extraArgs, {
env, env,
stdio: "inherit", stdio: "inherit",
shell: shellValue, shell: shellValue,
@@ -266,10 +195,7 @@ export function registerLaunchCodex(program) {
.argument("[codexArgs...]", "arguments passed through to the codex binary") .argument("[codexArgs...]", "arguments passed through to the codex binary")
.action(async (codexArgs, opts) => { .action(async (codexArgs, opts) => {
const merged = { ...opts, profile: opts.profile ?? opts.p }; const merged = { ...opts, profile: opts.profile ?? opts.p };
// process.exit() here aborted the process with a libuv assertion on const exitCode = await runLaunchCodexCommand(merged, codexArgs ?? []);
// Windows (`!(handle->flags & UV_HANDLE_CLOSING)`, async.c:94): it tears if (exitCode !== 0) process.exit(exitCode);
// the loop down while the inherited stdio handles of the just-exited
// child are still closing. Setting exitCode lets the loop drain first.
process.exitCode = await runLaunchCodexCommand(merged, codexArgs ?? []);
}); });
} }

View File

@@ -1,9 +1,8 @@
import { spawn, execFileSync } from "node:child_process"; import { spawn } from "node:child_process";
import { join } from "node:path"; import { join } from "node:path";
import os from "node:os"; import os from "node:os";
import { t } from "../i18n.mjs"; import { t } from "../i18n.mjs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { quoteShellArgs } from "../utils/winShellArgs.mjs";
function stripTrailingSlash(value) { function stripTrailingSlash(value) {
let s = String(value); let s = String(value);
@@ -91,78 +90,6 @@ export function resolveLaunchTarget(opts = {}) {
return { baseUrl, authToken }; return { baseUrl, authToken };
} }
/**
* Probe PATH for a Windows executable via `where.exe`, preferring a `.exe` over
* a `.cmd`/`.bat` shim. Returns the absolute path to the preferred binary, or
* `null` when `where.exe` finds nothing (or cannot run).
*
* The native Anthropic installer (#9454) creates only `claude.exe` (no npm
* `.cmd` shim), so the launcher must look for the real PE and spawn it without
* a shell. Mirrors the existing `locateCommand()` probe in
* `src/shared/services/cliRuntime.ts`.
*
* @param {string} command bare command name to look up
* @returns {Promise<string|null>} absolute path to the preferred match, or null
*/
function probeWindowsBinary(command) {
try {
const out = execFileSync("where.exe", [command], {
stdio: ["ignore", "pipe", "ignore"],
encoding: "utf8",
timeout: 3000,
windowsHide: true,
});
const lines = out
.split(/\r?\n/)
.map((l) => l.trim())
.filter(Boolean);
if (lines.length === 0) return null;
const winExt = /\.(exe|cmd|bat|com)$/i;
return lines.find((l) => winExt.test(l)) || null;
} catch {
return null;
}
}
/**
* #8246 / #9454: on Windows, npm installs claude as a `.cmd` shim — spawn()
* without a shell cannot resolve PATHEXT shims (and Node refuses to exec `.cmd`
* directly since CVE-2024-27980), so the npm-shim path must go through cmd.exe.
* But the native installer creates only `claude.exe`, which is a real PE that
* must NOT go through a shell (cmd.exe would split an absolute path with spaces).
*
* So probe PATH for `claude` first: when `where.exe` resolves a `.exe`, spawn it
* directly (no shell); otherwise fall back to the npm `claude.cmd` + shell. Off
* Windows the bare binary is spawned unchanged (no shell, no probe).
*
* @param {NodeJS.Platform|string} platform
* @param {{ probe?: (command: string) => Promise<string|null> }} [opts] injectable probe for tests
* @returns {Promise<{ command: string, shell: true|undefined }>}
*/
export async function resolveClaudeSpawn(platform, opts = {}) {
if (platform !== "win32") return { command: "claude", shell: undefined };
const probe = opts.probe ?? probeWindowsBinary;
const located = await probe("claude");
if (located && /\.exe$/i.test(located)) {
return { command: located, shell: undefined };
}
return { command: "claude.cmd", shell: true };
}
/**
* `shell: true` makes Node join argv with plain spaces and no escaping (the
* DEP0190 warning), so `-p "two words"` used to reach claude as `-p two` plus
* three stray positional arguments. Quote the args ourselves on that path.
* Off Windows there is no shell, so argv is passed through untouched.
*
* @param {string[]} args
* @param {NodeJS.Platform|string} platform
* @returns {string[]}
*/
export function quoteClaudeArgs(args, platform) {
return quoteShellArgs(args, platform);
}
/** /**
* @param {{port?:string, remote?:string, token?:string, apiKey?:string, profile?:string, claudeHome?:string}} opts * @param {{port?:string, remote?:string, token?:string, apiKey?:string, profile?:string, claudeHome?:string}} opts
* @param {string[]} claudeArgs pass-through args for the claude binary * @param {string[]} claudeArgs pass-through args for the claude binary
@@ -179,10 +106,10 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
if (!res.ok) throw new Error(`status ${res.status}`); if (!res.ok) throw new Error(`status ${res.status}`);
} catch { } catch {
console.error( console.error(
( (t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace(
t("launch.notRunning") || "{port}",
"OmniRoute is not reachable at {port}. Start it with 'omniroute serve'." baseUrl
).replace("{port}", baseUrl) )
); );
return 1; return 1;
} }
@@ -190,20 +117,10 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
const configDir = opts.profile const configDir = opts.profile
? join(opts.claudeHome || join(os.homedir(), ".claude"), "profiles", opts.profile) ? join(opts.claudeHome || join(os.homedir(), ".claude"), "profiles", opts.profile)
: undefined; : undefined;
const env = buildClaudeEnv(process.env, baseUrl, authToken, { const env = buildClaudeEnv(process.env, baseUrl, authToken, { configDir });
configDir,
model: opts.model,
});
const { command, shell } = await resolveClaudeSpawn(process.platform);
return await new Promise((resolve) => { return await new Promise((resolve) => {
const child = spawn(command, quoteClaudeArgs(claudeArgs, process.platform), { const child = spawn("claude", claudeArgs, { env, stdio: "inherit" });
env,
stdio: "inherit",
shell,
...(process.platform === "win32" ? { windowsHide: true } : {}),
});
child.on("error", (err) => { child.on("error", (err) => {
if (err && err.code === "ENOENT") { if (err && err.code === "ENOENT") {
console.error(t("launch.notFound") || "The 'claude' CLI was not found in PATH."); console.error(t("launch.notFound") || "The 'claude' CLI was not found in PATH.");
@@ -225,20 +142,14 @@ export function registerLaunch(program) {
) )
.option("--port <port>", t("serve.port") || "Proxy port", "20128") .option("--port <port>", t("serve.port") || "Proxy port", "20128")
.option("--remote <url>", "Remote OmniRoute base URL (overrides --port and the active context)") .option("--remote <url>", "Remote OmniRoute base URL (overrides --port and the active context)")
.option( .option("--profile <name>", "Claude Code profile to use (CLAUDE_CONFIG_DIR ~/.claude/profiles/<name>)")
"--profile <name>",
"Claude Code profile to use (CLAUDE_CONFIG_DIR ~/.claude/profiles/<name>)"
)
.option("--token <token>", t("launch.token") || "Token Claude sends (ANTHROPIC_AUTH_TOKEN)") .option("--token <token>", t("launch.token") || "Token Claude sends (ANTHROPIC_AUTH_TOKEN)")
.option("--api-key <key>", "Alias for --token (OmniRoute access token / API key)") .option("--api-key <key>", "Alias for --token (OmniRoute access token / API key)")
.allowUnknownOption(true) .allowUnknownOption(true)
.allowExcessArguments(true) .allowExcessArguments(true)
.argument("[claudeArgs...]", "arguments passed through to the claude binary") .argument("[claudeArgs...]", "arguments passed through to the claude binary")
.action(async (claudeArgs, opts) => { .action(async (claudeArgs, opts) => {
// process.exit() here aborted the process with a libuv assertion on const exitCode = await runLaunchCommand(opts, claudeArgs ?? []);
// Windows (`!(handle->flags & UV_HANDLE_CLOSING)`, async.c:94): it tears if (exitCode !== 0) process.exit(exitCode);
// the loop down while the inherited stdio handles of the just-exited
// child are still closing. Setting exitCode lets the loop drain first.
process.exitCode = await runLaunchCommand(opts, claudeArgs ?? []);
}); });
} }

View File

@@ -19,19 +19,6 @@ import { randomUUID } from "node:crypto";
* *
* It talks ONLY to Google (no OmniRoute server needed locally), so it works even * It talks ONLY to Google (no OmniRoute server needed locally), so it works even
* if the remote VPS is firewalled from the user's machine. * if the remote VPS is firewalled from the user's machine.
*
* Push mode: when an active remote context exists (`omniroute connect <host>`), the
* blob is POSTed straight to that install instead of being printed for a manual
* copy-paste — every piece was already in place:
*
* - the context carries an admin-scoped token, and `apiFetch()` injects it;
* - `/api/oauth` requires admin scope (src/server/authz/accessScopes.ts) and stays
* remote-reachable — routeGuard.ts loopback-gates only `/api/oauth/cursor/auto-import`;
* - `/api/oauth/<provider>/paste-credentials` already decodes the blob and persists.
*
* The push NEVER becomes a hard requirement: this helper exists precisely because it
* needs no route to the VPS, so a failed push falls back to printing the blob rather
* than losing an authorization the operator just completed in their browser.
*/ */
const PROVIDER = "antigravity"; const PROVIDER = "antigravity";
@@ -67,7 +54,7 @@ function defaultStartServer(preferredPort) {
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
res.end( res.end(
"<!doctype html><meta charset=utf-8><title>OmniRoute</title>" + "<!doctype html><meta charset=utf-8><title>OmniRoute</title>" +
'<body style="font-family:system-ui;padding:2rem">' + "<body style=\"font-family:system-ui;padding:2rem\">" +
"<h2>✅ Authorization received</h2>" + "<h2>✅ Authorization received</h2>" +
"<p>Return to your terminal — you can close this tab.</p></body>" "<p>Return to your terminal — you can close this tab.</p></body>"
); );
@@ -86,51 +73,6 @@ function defaultStartServer(preferredPort) {
}); });
} }
/**
* Is this context pointing at another machine? Loopback (and an unresolvable value)
* counts as local, so we never auto-push somewhere we cannot reason about.
*/
export function isRemoteBaseUrl(baseUrl) {
if (!baseUrl) return false;
try {
const { hostname } = new URL(baseUrl);
const host = hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
return host !== "localhost" && host !== "127.0.0.1" && host !== "::1";
} catch {
return false;
}
}
/**
* POST a credential blob to the active context's install. Never throws: the caller
* decides whether a failure is fatal (it is not — it falls back to printing).
*/
export async function pushCredentialBlob(provider, blob, deps = {}) {
try {
const fetchImpl = deps.fetchImpl ?? (await import("../api.mjs")).apiFetch;
const res = await fetchImpl(`/api/oauth/${provider}/paste-credentials`, {
method: "POST",
body: { blob },
});
const data = await res.json().catch(() => ({}));
if (!res.ok || data?.success === false) {
const message =
(typeof data?.error === "string" ? data.error : data?.error?.message) ||
`HTTP ${res.status}`;
return { ok: false, error: message };
}
return { ok: true, connectionId: data?.connection?.id };
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
}
/** Read the active CLI context (baseUrl + scoped token) written by `omniroute connect`. */
async function defaultResolveContext(overrideName) {
const { resolveActiveContext } = await import("../contexts.mjs");
return resolveActiveContext(overrideName);
}
/** Lazy-load the antigravity provider + blob codec (TS source via tsx). */ /** Lazy-load the antigravity provider + blob codec (TS source via tsx). */
async function loadDeps() { async function loadDeps() {
const { antigravity } = await import("../../../src/lib/oauth/providers/antigravity.ts"); const { antigravity } = await import("../../../src/lib/oauth/providers/antigravity.ts");
@@ -211,41 +153,10 @@ export async function runAntigravityLogin(opts = {}, deps = {}) {
const tokens = await exchange(params.code, redirectUri); const tokens = await exchange(params.code, redirectUri);
const blob = encodeCredentialBlob({ provider: PROVIDER, tokens }); const blob = encodeCredentialBlob({ provider: PROVIDER, tokens });
// Push when the operator explicitly asked, or when the active context already points
// at another machine — that is exactly the situation this helper was built for.
const resolveContext = deps.resolveContext ?? defaultResolveContext;
const push = deps.push ?? pushCredentialBlob;
let context = null;
try {
context = await resolveContext(opts.context);
} catch {
// No usable context store — fall through to printing.
}
const wantsPush =
opts.push === true || (opts.push !== false && isRemoteBaseUrl(context?.baseUrl));
if (wantsPush) {
log(`\nSending the credential to ${context?.baseUrl || "the active context"}...\n`);
const result = await push(PROVIDER, blob, { context });
if (result?.ok) {
log(
`Antigravity connected on ${context?.baseUrl || "the remote install"}` +
`${result.connectionId ? ` (connection ${result.connectionId})` : ""}.\n` +
"Nothing to paste — you can close this terminal.\n"
);
// Deliberately NOT printed: the blob wraps a refresh token and it already landed.
return blob;
}
log(
`\nCould not deliver the credential automatically: ${result?.error || "unknown error"}\n` +
"Falling back to manual paste — the authorization itself is still valid.\n"
);
}
print( print(
"\n" + "\n" +
"Antigravity authorized. Copy the line below and paste it into your remote\n" + "Antigravity authorized. Copy the line below and paste it into your remote\n" +
'OmniRoute dashboard: Providers → Antigravity → Connect → "Paste credentials".\n' + "OmniRoute dashboard: Providers → Antigravity → Connect → \"Paste credentials\".\n" +
"(This contains a refresh token — treat it like a password.)\n\n" + "(This contains a refresh token — treat it like a password.)\n\n" +
blob + blob +
"\n\n" "\n\n"
@@ -259,8 +170,6 @@ async function runLoginAntigravity(opts) {
browser: opts.browser, browser: opts.browser,
timeout: opts.timeout, timeout: opts.timeout,
port: opts.port, port: opts.port,
push: opts.push,
context: opts.context,
}); });
} catch (err) { } catch (err) {
process.stderr.write(`\nLogin failed: ${err?.message || err}\n`); process.stderr.write(`\nLogin failed: ${err?.message || err}\n`);
@@ -279,11 +188,5 @@ export function registerLogin(program) {
.option("--no-browser", "Do not auto-open the browser; print the URL instead") .option("--no-browser", "Do not auto-open the browser; print the URL instead")
.option("--port <n>", "Fixed loopback port (default: OS-assigned)", (v) => parseInt(v, 10)) .option("--port <n>", "Fixed loopback port (default: OS-assigned)", (v) => parseInt(v, 10))
.option("--timeout <ms>", "How long to wait for the callback", (v) => parseInt(v, 10), 300000) .option("--timeout <ms>", "How long to wait for the callback", (v) => parseInt(v, 10), 300000)
.option(
"--push",
"Send the credential to the active context instead of printing it (default when that context is remote)"
)
.option("--no-push", "Always print the blob, never contact the server")
.option("--context <name>", "Push to this context instead of the active one")
.action(runLoginAntigravity); .action(runLoginAntigravity);
} }

View File

@@ -6,31 +6,16 @@ import { t } from "../i18n.mjs";
const PROVIDERS_WITH_OAUTH = [ const PROVIDERS_WITH_OAUTH = [
{ id: "gemini", name: "Google Gemini", flow: "browser" }, { id: "gemini", name: "Google Gemini", flow: "browser" },
{ id: "antigravity", name: "Antigravity", flow: "browser" }, { id: "antigravity", name: "Antigravity", flow: "browser" },
{ id: "windsurf", name: "Windsurf", flow: "browser" },
{ id: "qwen", name: "Qwen Code", flow: "browser" },
{ id: "cursor", name: "Cursor", flow: "import" }, { id: "cursor", name: "Cursor", flow: "import" },
{ id: "zed", name: "Zed", flow: "import" }, { id: "zed", name: "Zed", flow: "import" },
{ id: "kiro", name: "Amazon Kiro", flow: "social" }, { id: "kiro", name: "Amazon Kiro", flow: "social" },
{ id: "claude-code", name: "Claude Code (OAuth)", flow: "browser" }, { id: "claude-code", name: "Claude Code (OAuth)", flow: "device" },
{ id: "codex", name: "OpenAI Codex (OAuth)", flow: "device" }, { id: "codex", name: "OpenAI Codex (OAuth)", flow: "device" },
{ id: "copilot", name: "GitHub Copilot", flow: "device" }, { id: "copilot", name: "GitHub Copilot", flow: "device" },
]; ];
// The user-facing provider id (the one shown by `omniroute oauth providers`)
// is NOT always the backend OAuth provider key the server's /api/oauth/[provider]/...
// route expects. `claude-code` is the CLI-facing alias for Anthropic's Claude
// OAuth, which the server registers under the key `claude` (see
// src/lib/oauth/providers/index.ts). Routing `claude-code` to the unrelated
// `command-code` (CommandCode.ai) provider — as the previous code did — sent
// the device-flow request to /api/providers/command-code/auth/start, which is
// gated by requireManagementAuth and returned 401 for a fresh CLI context
// (issue #9474). Map the alias to the real backend key instead.
const BACKEND_OAUTH_KEY = {
"claude-code": "claude",
};
function resolveBackendKey(id) {
return BACKEND_OAUTH_KEY[id] ?? id;
}
const oauthProviderSchema = [ const oauthProviderSchema = [
{ key: "id", header: "Provider ID", width: 16 }, { key: "id", header: "Provider ID", width: 16 },
{ key: "name", header: "Name", width: 28 }, { key: "name", header: "Name", width: 28 },
@@ -72,107 +57,32 @@ async function pollStatus(endpoint, timeoutMs) {
} }
async function runBrowserFlow(def, opts) { async function runBrowserFlow(def, opts) {
// The user-facing id (`def.id`, e.g. "claude-code") must be translated to the const startRes = await apiFetch(`/api/oauth/${def.id}/start`, { method: "POST" });
// backend OAuth provider key the server's /api/oauth/[provider]/... route
// expects (e.g. "claude"). The previous implementation called a non-existent
// `/api/oauth/${def.id}/start` action — no such action exists on the server
// (src/app/api/oauth/[provider]/[action]/route.ts), so the browser flow was
// broken for every browser-flow provider. Use the real `authorize` action and
// complete the PKCE (authorization_code / authorization_code_pkce) flow with a
// manual code paste, mirroring the dashboard's manual "input" step.
const backendKey = resolveBackendKey(def.id);
const redirectUri = opts.redirectUri ?? null;
const authorizeUrl = `/api/oauth/${backendKey}/authorize${
redirectUri ? `?redirect_uri=${encodeURIComponent(redirectUri)}` : ""
}`;
const startRes = await apiFetch(authorizeUrl, { method: "GET" });
if (!startRes.ok) { if (!startRes.ok) {
const detail = await safeErrorBody(startRes); process.stderr.write(`Failed to start OAuth for ${def.id}: ${startRes.status}\n`);
process.stderr.write(`Failed to start OAuth for ${def.id}: ${startRes.status}${detail}\n`);
process.exit(1); process.exit(1);
} }
const start = await startRes.json(); const start = await startRes.json();
const url = start.authUrl ?? start.authorizeUrl ?? start.url; const url = start.authorizeUrl ?? start.url;
if (!url) {
const hint = start.error ?? "no authUrl returned by the server";
process.stderr.write(`OAuth unavailable for ${def.id}: ${hint}\n`);
process.exit(1);
}
const { codeVerifier, state, redirectUri: returnedRedirectUri } = start;
const finalRedirectUri = returnedRedirectUri || redirectUri;
process.stdout.write(`\nOpen this URL to authorize:\n ${url}\n\n`); if (process.stdout.isTTY && opts.browser !== false) {
if (opts.browser !== false) await openBrowser(url); const { startOAuthTui } = await import("../tui/OAuthFlow.jsx");
await openBrowser(url);
const tuiResult = await startOAuthTui({ provider: def.name ?? def.id, url });
if (tuiResult.status === "cancelled") return;
} else {
process.stdout.write(`\nOpen this URL to authorize:\n ${url}\n\n`);
if (opts.browser !== false) await openBrowser(url);
process.stderr.write("Waiting for authorization... (Ctrl+C to cancel)\n");
}
const result = await pollStatus(
`/api/oauth/${def.id}/status?state=${encodeURIComponent(start.state ?? "")}`,
opts.timeout ?? 300000
);
process.stdout.write( process.stdout.write(
"After authorizing, paste the callback URL (or the Authentication Code\n" + `Authorized: ${result.email ?? result.userId ?? result.account ?? "connected"}\n`
"shown on the confirmation page) here:\n"
); );
const { createPrompt } = await import("../io.mjs");
const prompt = createPrompt();
const input = await prompt.ask("Callback URL or code");
prompt.close();
const trimmed = input.trim();
if (!trimmed) {
process.stderr.write("No authorization code provided.\n");
process.exit(1);
}
// The Anthropic Claude confirmation page (platform.claude.com/oauth/code/callback)
// shows a raw "Authentication Code" like `code#state` rather than a full URL.
// The dashboard's manual submit (src/shared/components/OAuthModal.tsx) parses
// both forms; mirror that here.
let code = null;
let codeState = state || null;
try {
const cbUrl = new URL(trimmed);
code = cbUrl.searchParams.get("code");
const stateParam = cbUrl.searchParams.get("state") || cbUrl.hash.replace(/^#/, "");
if (stateParam) codeState = stateParam;
} catch {
const [rawCode, rawState] = trimmed.split("#", 2);
code = rawCode || null;
if (rawState) codeState = rawState;
}
if (!code) {
process.stderr.write(
"No authorization code found. Paste the callback URL or the Authentication Code.\n"
);
process.exit(1);
}
const exchangeRes = await apiFetch(`/api/oauth/${backendKey}/exchange`, {
method: "POST",
body: {
code,
redirectUri: finalRedirectUri,
codeVerifier,
...(codeState ? { state: codeState } : {}),
},
});
if (!exchangeRes.ok) {
const detail = await safeErrorBody(exchangeRes);
process.stderr.write(`Token exchange failed: ${exchangeRes.status}${detail}\n`);
process.exit(1);
}
const result = await exchangeRes.json();
const conn = result.connection ?? {};
process.stdout.write(`Authorized: ${conn.email ?? conn.displayName ?? conn.id ?? "connected"}\n`);
}
async function safeErrorBody(res) {
try {
const data = await res.json();
if (data?.error) {
const msg = typeof data.error === "string" ? data.error : data.error?.message;
if (msg) return `: ${msg}`;
}
if (data?.message) return `: ${data.message}`;
} catch {
/* ignore */
}
return "";
} }
async function runImportFlow(def, opts) { async function runImportFlow(def, opts) {
@@ -215,7 +125,7 @@ async function runSocialFlow(def, opts) {
} }
async function runDeviceFlow(def, opts) { async function runDeviceFlow(def, opts) {
const providerKey = resolveBackendKey(def.id); const providerKey = def.id === "claude-code" ? "command-code" : def.id;
const startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, { method: "POST" }); const startRes = await apiFetch(`/api/providers/${providerKey}/auth/start`, { method: "POST" });
if (!startRes.ok) { if (!startRes.ok) {
process.stderr.write(`Failed to start device flow: ${startRes.status}\n`); process.stderr.write(`Failed to start device flow: ${startRes.status}\n`);

View File

@@ -41,83 +41,11 @@ function toYaml(obj, indent = 0) {
.trimStart(); .trimStart();
} }
// Keys that live alongside operations inside a Path Item Object but are not
// themselves operations (OpenAPI 3.x Path Item fields).
const NON_OPERATION_PATH_KEYS = new Set([
"parameters",
"summary",
"description",
"servers",
"$ref",
]);
/**
* `GET /api/openapi/spec` answers with a compact catalog
* (`{ info, servers, tags, endpoints[], schemas }`) rather than an OpenAPI
* document with a `paths` object, while `dist/docs/openapi.yaml` is a real
* spec. Normalize either shape into the flat rows the CLI renders so the
* commands work against both instead of silently printing nothing.
*/
export function extractEndpoints(spec) {
if (!spec || typeof spec !== "object") return [];
if (spec.paths && typeof spec.paths === "object") {
const rows = [];
for (const [path, pathItem] of Object.entries(spec.paths)) {
if (!pathItem || typeof pathItem !== "object") continue;
for (const [method, def] of Object.entries(pathItem)) {
if (NON_OPERATION_PATH_KEYS.has(method)) continue;
if (!def || typeof def !== "object") continue;
rows.push({
method: method.toUpperCase(),
path,
summary: def.summary ?? def.description ?? "",
operationId: def.operationId,
});
}
}
return rows;
}
if (Array.isArray(spec.endpoints)) {
return spec.endpoints
.filter((entry) => entry && typeof entry === "object" && entry.path)
.map((entry) => ({
method: String(entry.method ?? "GET").toUpperCase(),
path: entry.path,
summary: entry.summary ?? entry.description ?? "",
operationId: entry.operationId,
}));
}
return [];
}
/** Sorted, de-duplicated list of paths across either shape. */
export function extractPaths(spec) {
return [...new Set(extractEndpoints(spec).map((row) => row.path))].sort();
}
function matchesSearch(row, query) {
if (!query) return true;
const needle = query.toLowerCase();
return row.path.includes(query) || String(row.summary).toLowerCase().includes(needle);
}
function validateBasic(spec) { function validateBasic(spec) {
if (!spec || typeof spec !== "object") throw new Error("spec is not an object"); if (!spec || typeof spec !== "object") throw new Error("spec is not an object");
if (!spec.openapi && !spec.swagger) throw new Error("missing openapi/swagger version field");
if (!spec.info) throw new Error("missing info object"); if (!spec.info) throw new Error("missing info object");
if (!spec.paths) throw new Error("missing paths object");
// A real OpenAPI document must carry a version field and a paths object.
if (spec.openapi || spec.swagger) {
if (!spec.paths) throw new Error("missing paths object");
return;
}
// The compact catalog served by /api/openapi/spec carries endpoints[] instead.
if (Array.isArray(spec.endpoints)) return;
throw new Error("missing openapi/swagger version field and no endpoints[] catalog");
} }
const endpointSchema = [ const endpointSchema = [
@@ -204,7 +132,20 @@ export function registerOpenapi(program) {
process.exit(1); process.exit(1);
} }
const spec = await res.json(); const spec = await res.json();
const rows = extractEndpoints(spec).filter((row) => matchesSearch(row, opts.search)); const rows = [];
for (const [path, methods] of Object.entries(spec.paths ?? {})) {
for (const [method, def] of Object.entries(methods)) {
if (["parameters", "summary"].includes(method)) continue;
const summary = def.summary ?? def.description ?? "";
if (
opts.search &&
!path.includes(opts.search) &&
!summary.toLowerCase().includes(opts.search.toLowerCase())
)
continue;
rows.push({ method: method.toUpperCase(), path, summary, operationId: def.operationId });
}
}
emit(rows, cmd.optsWithGlobals(), endpointSchema); emit(rows, cmd.optsWithGlobals(), endpointSchema);
}); });
@@ -218,8 +159,9 @@ export function registerOpenapi(program) {
process.exit(1); process.exit(1);
} }
const spec = await res.json(); const spec = await res.json();
const paths = Object.keys(spec.paths ?? {}).sort();
emit( emit(
extractPaths(spec).map((p) => ({ path: p })), paths.map((p) => ({ path: p })),
cmd.optsWithGlobals() cmd.optsWithGlobals()
); );
}); });

View File

@@ -1,166 +0,0 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import { t } from "../i18n.mjs";
import { resolveDataDir } from "../data-dir.mjs";
import {
EXIT_CODES,
emit,
exitWith,
printError,
printInfo,
printSuccess,
printWarning,
} from "../output.mjs";
import { findPack } from "../../../scripts/packs/optionalPackManifest.mjs";
import {
findPackIndexFile,
installPack,
listPackStates,
packState,
packsRoot,
readPackIndex,
removePack,
} from "../../../scripts/packs/optionalPackInstaller.mjs";
const CLI_DIR = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
/**
* Locate + parse the bundle-shipped `optional-packs.index.json`.
* Search order: explicit --source dir, then walking up from the CLI module
* (bundle installs keep the index at the bundle root), then cwd.
*/
function loadIndex(sourceDir) {
const indexFile = findPackIndexFile([sourceDir, CLI_DIR, process.cwd()]);
if (!indexFile) return { indexFile: null, index: null };
return { indexFile, index: readPackIndex(indexFile) };
}
function stateRow(state, dataDir) {
return {
pack: state.name,
packVersion: state.packVersion,
installed: state.installed ? "yes" : "no",
verified: state.verified === null ? "-" : state.verified ? "ok" : "FAILED",
members: state.members.length,
installDir: path.join(packsRoot(dataDir), state.name),
errors: state.errors ?? [],
};
}
const STATE_SCHEMA = [
{ key: "pack", header: "pack" },
{ key: "packVersion", header: "packVersion" },
{ key: "installed", header: "installed" },
{ key: "verified", header: "verified" },
{ key: "members", header: "members" },
];
async function run(action) {
try {
await action();
} catch (err) {
exitWith(EXIT_CODES.ERROR, err instanceof Error ? err.message : String(err));
}
}
export function registerPacks(program) {
const packs = program.command("packs").description(t("packs.description"));
packs
.command("list")
.description(t("packs.listDescription"))
.option("--source <dir>", t("packs.sourceOpt"))
.action(async (opts) => {
await run(async () => {
const dataDir = resolveDataDir();
const { index } = loadIndex(opts.source);
emit(
(await listPackStates({ dataDir, index })).map((s) => stateRow(s, dataDir)),
opts,
STATE_SCHEMA
);
if (!index) printWarning(t("packs.warnNoIndex"));
});
});
packs
.command("install <name>")
.description(t("packs.installDescription"))
.option("--source <dir>", t("packs.sourceOpt"))
.action(async (name, opts) => {
await run(async () => {
if (!findPack(name)) exitWith(EXIT_CODES.INVALID_ARG, t("packs.errUnknown", { name }));
const { indexFile, index } = loadIndex(opts.source);
if (!index) exitWith(EXIT_CODES.ERROR, t("packs.errNoIndex"));
const dataDir = resolveDataDir();
// The payload (tarball or extracted pack dir) lives next to the index
// unless the caller pointed elsewhere via --source.
await installPack(name, {
dataDir,
index,
sourceDir: opts.source || path.dirname(indexFile),
log: (msg) => printInfo(msg.replace(/^\[optional-packs\]\s*/, "")),
});
const installDir = path.join(packsRoot(dataDir), name);
printSuccess(t("packs.installed", { name, dir: installDir }));
printInfo(t("packs.restartHint"));
emit({ pack: name, installed: "yes", verified: "ok", installDir }, opts, STATE_SCHEMA);
});
});
packs
.command("verify [name]")
.description(t("packs.verifyDescription"))
.option("--source <dir>", t("packs.sourceOpt"))
.action(async (name, opts) => {
await run(async () => {
if (name && !findPack(name))
exitWith(EXIT_CODES.INVALID_ARG, t("packs.errUnknown", { name }));
const { index } = loadIndex(opts.source);
if (!index) exitWith(EXIT_CODES.ERROR, t("packs.errNoIndex"));
const dataDir = resolveDataDir();
const states = name
? [await packState(name, { dataDir, index })]
: await listPackStates({ dataDir, index });
emit(
states.map((s) => stateRow(s, dataDir)),
opts,
STATE_SCHEMA
);
const broken = states.filter((s) => s.installed && s.verified !== true);
if (broken.length > 0) {
for (const state of broken) {
for (const error of state.errors ?? []) printError(`${state.name}: ${error}`);
}
exitWith(EXIT_CODES.ERROR, t("packs.verifyFailed", { count: broken.length }));
}
if (!states.some((s) => s.installed)) {
printInfo(t("packs.noneInstalled"));
return;
}
printSuccess(t("packs.verifyOk"));
});
});
packs
.command("remove <name>")
.description(t("packs.removeDescription"))
.action(async (name, opts) => {
await run(async () => {
if (!findPack(name)) exitWith(EXIT_CODES.INVALID_ARG, t("packs.errUnknown", { name }));
const dataDir = resolveDataDir();
const removed = removePack(name, {
dataDir,
log: (msg) => printInfo(msg.replace(/^\[optional-packs\]\s*/, "")),
});
if (removed) {
printSuccess(t("packs.removed", { name }));
printInfo(t("packs.restartHint"));
} else {
printInfo(t("packs.notInstalled", { name }));
}
emit({ pack: name, installed: removed ? "no" : "no" }, opts, STATE_SCHEMA);
});
});
}

View File

@@ -129,33 +129,9 @@ function buildTestInput(connection, apiKey) {
} }
async function runProviderTest(db, connection) { async function runProviderTest(db, connection) {
// Only API-key connections can be probed with a stored credential. OAuth /
// no-auth connections have nothing for testProviderApiKey() to send, and
// getProviderApiKey() throws for them by design — reporting that as a FAILED
// test marked perfectly healthy OAuth connections as broken *and* persisted
// that verdict to provider_connections.test_status.
if (connection.authType !== "apikey") {
return {
connection: publicConnection(connection),
valid: false,
skipped: true,
error: `No API-key probe for ${connection.authType || "unknown"} connections`,
};
}
try { try {
const apiKey = getProviderApiKey(connection); const apiKey = getProviderApiKey(connection);
const result = await testProviderApiKey(buildTestInput(connection, apiKey)); const result = await testProviderApiKey(buildTestInput(connection, apiKey));
// PROVIDER_TEST_CONFIGS only knows a handful of providers; "unsupported"
// means the CLI has no probe recipe, not that the provider is unhealthy.
// Persisting it would overwrite a good test_status with a failure.
if (result.unsupported) {
return {
connection: publicConnection(connection),
...result,
skipped: true,
};
}
updateProviderTestResult(db, connection.id, result); updateProviderTestResult(db, connection.id, result);
return { return {
connection: publicConnection(connection), connection: publicConnection(connection),

View File

@@ -1,76 +0,0 @@
import { apiFetch } from "../api.mjs";
import { t } from "../i18n.mjs";
import { emit } from "../output.mjs";
const statusSchema = [
{ key: "feed", header: "Feed" },
{ key: "available", header: "Available" },
{ key: "version", header: "Version" },
{ key: "tier", header: "Tier" },
{ key: "fetchedAt", header: "Fetched" },
];
const syncSchema = [
{ key: "feed", header: "Feed" },
{ key: "status", header: "Status" },
{ key: "version", header: "Version" },
{ key: "reason", header: "Reason" },
];
function exitCodeFor(response) {
return Number.isInteger(response.exitCode) ? response.exitCode : response.status === 401 ? 4 : 1;
}
export async function runRadarStatusCommand(opts = {}) {
const response = await apiFetch("/api/radar/status", { acceptNotOk: true });
if (!response.ok) return exitCodeFor(response);
const data = await response.json();
if (opts.output === "json") {
emit(data, opts);
return 0;
}
const rows = Object.entries(data.feeds ?? {}).map(([feed, value]) => ({
feed,
...(value && typeof value === "object" ? value : { available: false }),
}));
emit(rows, opts, statusSchema);
return 0;
}
export async function runRadarSyncCommand(opts = {}) {
const response = await apiFetch("/api/radar/sync-all", {
method: "POST",
body: {},
acceptNotOk: true,
});
if (!response.ok) return exitCodeFor(response);
const data = await response.json();
if (opts.output === "json") {
emit(data, opts);
return 0;
}
const rows = Object.entries(data).map(([feed, value]) => ({
feed,
...(value && typeof value === "object" ? value : { status: "error" }),
}));
emit(rows, opts, syncSchema);
return 0;
}
export function registerRadar(program) {
const radar = program.command("radar").description(t("radar.description"));
radar
.command("status")
.description(t("radar.status"))
.action(async (_opts, command) => {
const code = await runRadarStatusCommand(command.optsWithGlobals());
if (code !== 0) process.exitCode = code;
});
radar
.command("sync")
.description(t("radar.sync"))
.action(async (_opts, command) => {
const code = await runRadarSyncCommand(command.optsWithGlobals());
if (code !== 0) process.exitCode = code;
});
}

View File

@@ -10,25 +10,9 @@ const DEFAULT_IMAGE = "docker.io/redis:7-alpine";
const DEFAULT_NAME = "omniroute-redis"; const DEFAULT_NAME = "omniroute-redis";
const DEFAULT_PORT = "6379"; const DEFAULT_PORT = "6379";
const DEFAULT_VOLUME = "omniroute-redis-data"; const DEFAULT_VOLUME = "omniroute-redis-data";
// The launcher starts Redis without AUTH unless --password is given, so the
// published port stays on loopback. `-p 6379:6379` would bind 0.0.0.0 and hand
// the whole LAN an unauthenticated Redis.
const DEFAULT_BIND = "127.0.0.1";
const RUNTIME_PREFERENCE = ["podman", "docker"]; const RUNTIME_PREFERENCE = ["podman", "docker"];
/**
* Build the `-p` publish spec for the Redis container.
* Always host-qualified so the runtime never falls back to 0.0.0.0.
*/
export function buildRedisPublishSpec(bind = DEFAULT_BIND, port = DEFAULT_PORT) {
const host = String(bind || DEFAULT_BIND).trim() || DEFAULT_BIND;
const hostPort = String(port || DEFAULT_PORT).trim() || DEFAULT_PORT;
// Bracket IPv6 literals (e.g. ::1) so `host:port:port` stays unambiguous.
const normalizedHost = host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
return `${normalizedHost}:${hostPort}:6379`;
}
async function detectRuntime() { async function detectRuntime() {
for (const candidate of RUNTIME_PREFERENCE) { for (const candidate of RUNTIME_PREFERENCE) {
try { try {
@@ -43,14 +27,7 @@ async function detectRuntime() {
async function containerExists(runtime, name) { async function containerExists(runtime, name) {
try { try {
const { stdout } = await execFile(runtime, [ const { stdout } = await execFile(runtime, ["ps", "-a", "--filter", `name=^${name}$`, "--format", "{{.Names}}"]);
"ps",
"-a",
"--filter",
`name=^${name}$`,
"--format",
"{{.Names}}",
]);
return stdout.trim() === name; return stdout.trim() === name;
} catch { } catch {
return false; return false;
@@ -59,13 +36,7 @@ async function containerExists(runtime, name) {
async function containerRunning(runtime, name) { async function containerRunning(runtime, name) {
try { try {
const { stdout } = await execFile(runtime, [ const { stdout } = await execFile(runtime, ["ps", "--filter", `name=^${name}$`, "--format", "{{.Names}}"]);
"ps",
"--filter",
`name=^${name}$`,
"--format",
"{{.Names}}",
]);
return stdout.trim() === name; return stdout.trim() === name;
} catch { } catch {
return false; return false;
@@ -129,11 +100,6 @@ export function registerRedis(program) {
.command("up") .command("up")
.description("Start the local Redis container") .description("Start the local Redis container")
.option("-p, --port <port>", "Host port to expose", DEFAULT_PORT) .option("-p, --port <port>", "Host port to expose", DEFAULT_PORT)
.option(
"-b, --bind <host>",
"Host interface to publish on (use 0.0.0.0 only together with --password)",
DEFAULT_BIND
)
.option("-n, --name <name>", "Container name", DEFAULT_NAME) .option("-n, --name <name>", "Container name", DEFAULT_NAME)
.option("-i, --image <image>", "Container image", DEFAULT_IMAGE) .option("-i, --image <image>", "Container image", DEFAULT_IMAGE)
.option("--no-pull", "Skip pulling the image if it is missing") .option("--no-pull", "Skip pulling the image if it is missing")
@@ -194,7 +160,6 @@ export async function runRedisUpCommand(opts = {}) {
const name = opts.name || DEFAULT_NAME; const name = opts.name || DEFAULT_NAME;
const port = opts.port || DEFAULT_PORT; const port = opts.port || DEFAULT_PORT;
const bind = opts.bind || DEFAULT_BIND;
const image = opts.image || DEFAULT_IMAGE; const image = opts.image || DEFAULT_IMAGE;
const exists = await containerExists(runtime, name); const exists = await containerExists(runtime, name);
@@ -221,11 +186,7 @@ export async function runRedisUpCommand(opts = {}) {
info(`Checking if image '${image}' is present locally…`); info(`Checking if image '${image}' is present locally…`);
let present = false; let present = false;
try { try {
const { stdout } = await execFile(runtime, [ const { stdout } = await execFile(runtime, ["images", "--format", "{{.Repository}}:{{.Tag}}"]);
"images",
"--format",
"{{.Repository}}:{{.Tag}}",
]);
present = stdout.split("\n").some((line) => line.trim() === image); present = stdout.split("\n").some((line) => line.trim() === image);
} catch { } catch {
// ignore — fall through to pull // ignore — fall through to pull
@@ -244,14 +205,10 @@ export async function runRedisUpCommand(opts = {}) {
const args = [ const args = [
"run", "run",
"-d", "-d",
"--name", "--name", name,
name, "--restart", "unless-stopped",
"--restart", "-p", `${port}:6379`,
"unless-stopped", "-v", `${DEFAULT_VOLUME}:/data`,
"-p",
buildRedisPublishSpec(bind, port),
"-v",
`${DEFAULT_VOLUME}:/data`,
]; ];
if (opts.password) { if (opts.password) {
args.push("-e", `REDIS_PASSWORD=${opts.password}`); args.push("-e", `REDIS_PASSWORD=${opts.password}`);
@@ -262,13 +219,8 @@ export async function runRedisUpCommand(opts = {}) {
info(`Launching ${runtime} run ${args.join(" ")}`); info(`Launching ${runtime} run ${args.join(" ")}`);
try { try {
await execFile(runtime, args); await execFile(runtime, args);
success(`Container '${name}' is now running on redis://${bind}:${port}`); success(`Container '${name}' is now running on redis://127.0.0.1:${port}`);
info(`Set OMNIROUTE_REDIS_URL=redis://${bind}:${port} in your .env to wire OmniRoute to it.`); info(`Set OMNIROUTE_REDIS_URL=redis://127.0.0.1:${port} in your .env to wire OmniRoute to it.`);
if (bind !== DEFAULT_BIND && !opts.password) {
info(
`Warning: '${bind}' publishes Redis beyond loopback without AUTH. Re-run with --password <secret>.`
);
}
return 0; return 0;
} catch (err) { } catch (err) {
fail(`Failed to launch container: ${err.message}`); fail(`Failed to launch container: ${err.message}`);
@@ -315,13 +267,7 @@ export async function runRedisStatusCommand(opts = {}) {
const exists = await containerExists(runtime, name); const exists = await containerExists(runtime, name);
if (!exists) { if (!exists) {
console.log( console.log(JSON.stringify({ runtime, name, port, exists: false, running: false, reachable: false }, null, 2));
JSON.stringify(
{ runtime, name, port, exists: false, running: false, reachable: false },
null,
2
)
);
return 0; return 0;
} }
@@ -339,9 +285,7 @@ export async function runRedisStatusCommand(opts = {}) {
console.log(` Running: ${running ? "yes" : "no"}`); console.log(` Running: ${running ? "yes" : "no"}`);
console.log(` Reachable: ${reachable ? "yes" : "no"} (port ${port})`); console.log(` Reachable: ${reachable ? "yes" : "no"} (port ${port})`);
if (running && !reachable) { if (running && !reachable) {
warn( warn("Container is running but the port is not reachable. Is REDIS_PASSWORD set or another process bound?");
"Container is running but the port is not reachable. Is REDIS_PASSWORD set or another process bound?"
);
} }
if (!running) { if (!running) {
info(`Run 'omniroute redis up' to launch it.`); info(`Run 'omniroute redis up' to launch it.`);

View File

@@ -37,7 +37,6 @@ import { registerProviders } from "./providers.mjs";
import { registerProvider } from "./provider-cmd.mjs"; import { registerProvider } from "./provider-cmd.mjs";
import { registerConfig } from "./config.mjs"; import { registerConfig } from "./config.mjs";
import { registerKeys } from "./keys.mjs"; import { registerKeys } from "./keys.mjs";
import { registerAuthExport } from "./auth-export.mjs";
import { registerModels } from "./models.mjs"; import { registerModels } from "./models.mjs";
import { registerCombo } from "./combo.mjs"; import { registerCombo } from "./combo.mjs";
import { registerStatus } from "./status.mjs"; import { registerStatus } from "./status.mjs";
@@ -60,7 +59,6 @@ import { registerAutostart } from "./autostart.mjs";
import { registerRepl } from "./repl.mjs"; import { registerRepl } from "./repl.mjs";
import { registerLaunch } from "./launch.mjs"; import { registerLaunch } from "./launch.mjs";
import { registerLaunchCodex } from "./launch-codex.mjs"; import { registerLaunchCodex } from "./launch-codex.mjs";
import { registerRun } from "./run.mjs";
import { registerSetupCodex } from "./setup-codex.mjs"; import { registerSetupCodex } from "./setup-codex.mjs";
import { registerSetupClaude } from "./setup-claude.mjs"; import { registerSetupClaude } from "./setup-claude.mjs";
import { registerSetupOpencode } from "./setup-opencode.mjs"; import { registerSetupOpencode } from "./setup-opencode.mjs";
@@ -71,16 +69,14 @@ import { registerSetupCursor } from "./setup-cursor.mjs";
import { registerSetupRoo } from "./setup-roo.mjs"; import { registerSetupRoo } from "./setup-roo.mjs";
import { registerSetupCrush } from "./setup-crush.mjs"; import { registerSetupCrush } from "./setup-crush.mjs";
import { registerSetupGoose } from "./setup-goose.mjs"; import { registerSetupGoose } from "./setup-goose.mjs";
import { registerSetupAider } from "./setup-aider.mjs";
import { registerSetupQwen } from "./setup-qwen.mjs"; import { registerSetupQwen } from "./setup-qwen.mjs";
import { registerSetupAider } from "./setup-aider.mjs";
import { registerConnect } from "./connect.mjs"; import { registerConnect } from "./connect.mjs";
import { registerContexts } from "./contexts.mjs"; import { registerContexts } from "./contexts.mjs";
import { registerTokens } from "./tokens.mjs"; import { registerTokens } from "./tokens.mjs";
import { registerConfigure } from "./configure.mjs"; import { registerConfigure } from "./configure.mjs";
import { registerApiCommands } from "../api-commands/registry.mjs"; import { registerApiCommands } from "../api-commands/registry.mjs";
import { registerPlugin } from "./plugin.mjs"; import { registerPlugin } from "./plugin.mjs";
import { registerRadar } from "./radar.mjs";
import { registerPacks } from "./packs.mjs";
export function registerCommands(program) { export function registerCommands(program) {
registerMemory(program); registerMemory(program);
@@ -122,7 +118,6 @@ export function registerCommands(program) {
registerProvider(program); registerProvider(program);
registerConfig(program); registerConfig(program);
registerKeys(program); registerKeys(program);
registerAuthExport(program);
registerModels(program); registerModels(program);
registerCombo(program); registerCombo(program);
registerStatus(program); registerStatus(program);
@@ -146,7 +141,6 @@ export function registerCommands(program) {
registerRepl(program); registerRepl(program);
registerLaunch(program); registerLaunch(program);
registerLaunchCodex(program); registerLaunchCodex(program);
registerRun(program);
registerSetupCodex(program); registerSetupCodex(program);
registerSetupClaude(program); registerSetupClaude(program);
registerSetupOpencode(program); registerSetupOpencode(program);
@@ -157,14 +151,12 @@ export function registerCommands(program) {
registerSetupRoo(program); registerSetupRoo(program);
registerSetupCrush(program); registerSetupCrush(program);
registerSetupGoose(program); registerSetupGoose(program);
registerSetupAider(program);
registerSetupQwen(program); registerSetupQwen(program);
registerSetupAider(program);
registerConnect(program); registerConnect(program);
registerContexts(program); registerContexts(program);
registerTokens(program); registerTokens(program);
registerConfigure(program); registerConfigure(program);
registerApiCommands(program); registerApiCommands(program);
registerPlugin(program); registerPlugin(program);
registerRadar(program);
registerPacks(program);
} }

View File

@@ -1,316 +0,0 @@
import {
runLaunchCommand as runLaunchClaudeCommand,
buildClaudeEnv,
resolveClaudeSpawn,
quoteClaudeArgs,
resolveLaunchTarget,
} from "./launch.mjs";
import {
buildCodexEnv,
buildCodexProviderArgs,
resolveCodexSpawn,
quoteCodexArgs,
resolveCodexTarget,
runLaunchCodexCommand as runLaunchCodexCommand,
} from "./launch-codex.mjs";
import { t } from "../i18n.mjs";
import os from "node:os";
import { join } from "node:path";
import { resolveActiveContext } from "../contexts.mjs";
const RUN_TARGETS = {
claude: {
aliases: ["claude", "claude-code", "cc"],
description: "Claude Code",
},
codex: {
aliases: ["codex", "openai-codex", "openai"],
description: "OpenAI Codex CLI",
},
};
/** @type {Record<string,string>} */
const RUN_TARGET_ALIAS_TO_CANONICAL = {
claude: "claude",
"claude-code": "claude",
cc: "claude",
codex: "codex",
"openai-codex": "codex",
openai: "codex",
};
function isBlank(value) {
return value === undefined || value === null || String(value).trim() === "";
}
function toAuthSource(targetOpts) {
const explicit =
!isBlank(targetOpts.token) || !isBlank(targetOpts.apiKey) || !isBlank(targetOpts["api-key"]);
if (explicit) return "option";
try {
const context = resolveActiveContext(targetOpts.context || process.env.OMNIROUTE_CONTEXT);
if (context && (context.accessToken || context.apiKey)) return "context";
} catch {
// no active context
}
if (!isBlank(process.env.OMNIROUTE_API_KEY)) return "env";
if (!isBlank(process.env.ANTHROPIC_AUTH_TOKEN)) return "env";
return "none";
}
/** Resolve supported target to canonical id. */
export function resolveRunTarget(target) {
const raw = String(target || "")
.trim()
.toLowerCase();
return RUN_TARGET_ALIAS_TO_CANONICAL[raw];
}
export function listRunTargets() {
return Object.keys(RUN_TARGETS);
}
/**
* Normalize `--provider` + `--model` into one model id.
*
* - when model contains a slash, keep it as-is
* - when provider exists and model does not, prefix provider/
*/
export function resolveModelFromTargetOptions(targetOpts = {}) {
const provider = String(targetOpts.provider || "").trim();
const model = String(targetOpts.model || "").trim();
if (!model) return "";
if (provider && !model.includes("/")) return `${provider}/${model}`;
return model;
}
function describeCommand(command, shellMode) {
return `${command}${shellMode ? " [shell]" : ""}`;
}
function envPreview(before = {}, after = {}) {
const beforeKeys = new Set(Object.keys(before));
const changedOrAdded = [];
const removed = [];
for (const key of Object.keys(after)) {
if (!beforeKeys.has(key) || String(before[key]) !== String(after[key])) {
changedOrAdded.push(key);
}
}
for (const key of Object.keys(before)) {
if (!(key in after)) removed.push(key);
}
return {
changedOrAdded,
removed,
};
}
async function buildClaudePlan(rawOpts, args = []) {
const model = resolveModelFromTargetOptions(rawOpts);
const merged = {
...rawOpts,
model,
apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token,
token: rawOpts.token || rawOpts.apiKey || rawOpts["api-key"],
profile: rawOpts.profile ?? rawOpts.p,
};
const { baseUrl, authToken } = resolveLaunchTarget(merged);
const commandSpec = await resolveClaudeSpawn(process.platform);
const configDir = merged.profile
? join(merged.claudeHome || join(os.homedir(), ".claude"), "profiles", merged.profile)
: undefined;
const env = buildClaudeEnv(process.env, baseUrl, authToken, {
configDir,
model: merged.model || undefined,
});
const quotedArgs = quoteClaudeArgs(args, process.platform);
return {
target: "claude",
baseUrl,
command: commandSpec.command,
shell: commandSpec.shell,
args: quotedArgs,
model: merged.model || undefined,
envDiff: envPreview(process.env, env),
authSource: toAuthSource(merged),
commandDisplay: describeCommand(commandSpec.command, commandSpec.shell),
};
}
async function buildCodexPlan(rawOpts, args = []) {
const model = resolveModelFromTargetOptions(rawOpts);
const merged = {
...rawOpts,
apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token,
model,
profile: rawOpts.profile ?? rawOpts.p,
};
const { baseUrl, authToken } = resolveCodexTarget(merged);
const commandSpec = await resolveCodexSpawn(process.platform);
const providerArgs = buildCodexProviderArgs(baseUrl, merged.model || undefined);
const profileArgs = merged.profile ? ["--profile", merged.profile] : [];
const env = buildCodexEnv(process.env, authToken);
const fullArgs = [...providerArgs, ...profileArgs, ...args];
const quotedArgs = quoteCodexArgs(fullArgs, process.platform);
return {
target: "codex",
baseUrl,
command: commandSpec.command,
shell: commandSpec.shell,
args: quotedArgs,
model: merged.model || undefined,
envDiff: envPreview(process.env, env),
authSource: toAuthSource(merged),
commandDisplay: describeCommand(commandSpec.command, commandSpec.shell),
providerArgs,
profileArgs,
};
}
/** Build a launch plan and redact any resolved secret values. */
export async function buildRunPlan(target, rawOpts = {}, args = []) {
const canonical = resolveRunTarget(target);
if (!canonical) {
throw new Error("unsupported target");
}
if (canonical === "claude") {
return buildClaudePlan(rawOpts, args);
}
return buildCodexPlan(rawOpts, args);
}
function writeDryRunOutput(plan, opts = {}) {
const output = {
target: plan.target,
baseUrl: plan.baseUrl,
command: plan.command,
args: plan.args,
auth: {
source: plan.authSource,
present: plan.authSource !== "none",
},
shell: !!plan.shell,
model: plan.model || null,
env: {
changedOrAdded: plan.envDiff.changedOrAdded,
removed: plan.envDiff.removed,
},
};
if (opts.json) {
console.error(`Running in dry-run mode for '${plan.target}'.`);
console.log(JSON.stringify(output, null, 2));
} else {
console.log(`target: ${output.target}`);
console.log(`baseUrl: ${output.baseUrl}`);
console.log(`command: ${output.command}`);
console.log(`shell: ${output.shell ? "yes" : "no"}`);
console.log(`args: ${JSON.stringify(output.args)}`);
console.log(`auth: ${JSON.stringify(output.auth)}`);
console.log(`model: ${output.model || "(not set)"}`);
if (output.env.changedOrAdded.length) {
console.log(`env added/changed: ${output.env.changedOrAdded.join(", ")}`);
}
if (output.env.removed.length) {
console.log(`env removed: ${output.env.removed.join(", ")}`);
}
}
}
function buildExecutionOptionsForClaude(rawOpts) {
return {
...rawOpts,
model: resolveModelFromTargetOptions(rawOpts),
token: rawOpts.token || rawOpts.apiKey || rawOpts["api-key"],
apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token,
profile: rawOpts.profile || rawOpts.p,
};
}
function buildExecutionOptionsForCodex(rawOpts) {
return {
...rawOpts,
model: resolveModelFromTargetOptions(rawOpts),
apiKey: rawOpts.apiKey || rawOpts["api-key"] || rawOpts.token,
profile: rawOpts.profile || rawOpts.p,
};
}
/**
* Execute or preview one target launch.
*
* Return code conventions:
* 0 success, 1 runtime launch failure, 2 invalid args.
*/
export async function runCliTarget(target, opts = {}, args = []) {
const canonical = resolveRunTarget(target);
if (!canonical) {
process.stderr.write(
`Unsupported target '${target}'. Supported targets: ${Object.keys(RUN_TARGETS).join(", ")}\n`
);
return 2;
}
const plan = await buildRunPlan(target, opts, args);
if (opts.dryRun) {
writeDryRunOutput(plan, opts);
return 0;
}
if (canonical === "claude") {
return await runLaunchClaudeCommand(buildExecutionOptionsForClaude(opts), args);
}
return await runLaunchCodexCommand(buildExecutionOptionsForCodex(opts), args);
}
export function registerRun(program) {
program
.command("run <target>")
.description(t("run.description") || "Run a supported CLI target through OmniRoute")
.option(
"--port <port>",
"Local OmniRoute port (ignored when --remote or --base-url is set)",
"20128"
)
.option(
"--remote <url>",
"Remote OmniRoute base URL (overrides --port, --base-url, and the active context)"
)
.option("--provider <id>", "Provider id for shorthand model composition")
.option("--model <id>", "Model id to inject in the launched target where supported")
.option("--profile <name>", "Profile/alias argument for target launchers that support it")
.option("-p, --p <name>", "Alias for --profile")
.option("--token <token>", "Authentication token for the launched target (same as --api-key)")
.option("--api-key <key>", "Authentication token for the launched target")
.option("--dry-run", "Show planned command and env keys without executing")
.option("--json", "Return dry-run output in machine-readable format")
.allowUnknownOption(true)
.allowExcessArguments(true)
.argument("[toolArgs...]")
.action(async (target, toolArgs = [], opts, cmd) => {
const globalOpts = cmd?.optsWithGlobals ? cmd.optsWithGlobals() : {};
const merged = { ...globalOpts, ...opts };
const code = await runCliTarget(target, merged, toolArgs);
// process.exit() here can interrupt cleanup when the child terminates;
// setting process.exitCode lets the event loop drain first.
process.exitCode = code;
});
}

View File

@@ -34,14 +34,7 @@ async function runRepairAction(opts, cmd) {
if (ok) { if (ok) {
process.stdout.write("✓ better-sqlite3 repaired OK\n"); process.stdout.write("✓ better-sqlite3 repaired OK\n");
} else { } else {
process.stderr.write("✗ Repair failed\n"); process.stderr.write("✗ Repair failed — check npm availability\n");
process.stderr.write(
" Possible causes:\n" +
" • npm not available — check that Node.js/npm are on your PATH\n" +
" • npm install scripts are blocked — run: npm install-scripts approve better-sqlite3\n" +
" • Network issue — check your internet connection\n" +
" Try: npm install-scripts ls (to see if better-sqlite3 is blocked)\n"
);
process.exit(1); process.exit(1);
} }
} }

View File

@@ -2,17 +2,11 @@ import { spawn } from "node:child_process";
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path"; import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { platform, totalmem } from "node:os"; import { platform, totalmem, hostname as osHostname } from "node:os";
import { t } from "../i18n.mjs"; import { t } from "../i18n.mjs";
import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs"; import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs";
import { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs"; import { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs";
import { isTermux } from "../../../scripts/build/postinstallSupport.mjs"; import { isTermux } from "../../../scripts/build/postinstallSupport.mjs";
import {
ensureAndroidCacheDir,
isFatalInstrumentationHookFailure,
formatAndroidInstrumentationFailureHint,
} from "../utils/ensureAndroidCacheDir.mjs";
import { resolveServerHost } from "../utils/serverHost.mjs";
import { import {
resolveMaxOldSpaceMb, resolveMaxOldSpaceMb,
calibrateHeapFallbackMb, calibrateHeapFallbackMb,
@@ -68,37 +62,9 @@ export function registerServe(program) {
}); });
} }
/** Once-per-process guard so the Android/Termux cache hint is not spammed. */
let instrumentationFailureHintPrinted = false;
/**
* If child output looks like Next.js failed to load its instrumentation hook
* on Android/Termux, print a clear operator-facing fix hint.
* Exported for unit tests.
*
* @param {string} text
* @returns {boolean} true when a hint was printed
*/
export function maybeReportInstrumentationHookFailure(text) {
if (instrumentationFailureHintPrinted) return false;
if (!isFatalInstrumentationHookFailure(text)) return false;
instrumentationFailureHintPrinted = true;
process.stderr.write(formatAndroidInstrumentationFailureHint(process.env.XDG_CACHE_HOME));
return true;
}
/** Test-only reset for the once-per-process hint guard. */
export function resetInstrumentationFailureHintForTests() {
instrumentationFailureHintPrinted = false;
}
export async function runServe(opts = {}) { export async function runServe(opts = {}) {
const startedAt = performance.now(); const startedAt = performance.now();
// Same prep as bin/omniroute.mjs — keep it here so a direct `runServe()` call
// (tests / programmatic) still gets a writable Next.js cache dir before spawn.
ensureAndroidCacheDir({ env: process.env });
const { isNativeBinaryCompatible } = const { isNativeBinaryCompatible } =
await import("../../../scripts/build/native-binary-compat.mjs"); await import("../../../scripts/build/native-binary-compat.mjs");
const { getNodeRuntimeSupport, getNodeRuntimeWarning } = const { getNodeRuntimeSupport, getNodeRuntimeWarning } =
@@ -168,11 +134,7 @@ export async function runServe(opts = {}) {
"Release", "Release",
"better_sqlite3.node" "better_sqlite3.node"
); );
if ( if (existsSync(sqliteBinary) && !isNativeBinaryCompatible(sqliteBinary)) {
!process.versions.bun &&
existsSync(sqliteBinary) &&
!isNativeBinaryCompatible(sqliteBinary)
) {
console.error( console.error(
"\x1b[31m✖ better-sqlite3 native module is incompatible with this platform.\x1b[0m" "\x1b[31m✖ better-sqlite3 native module is incompatible with this platform.\x1b[0m"
); );
@@ -208,10 +170,16 @@ export async function runServe(opts = {}) {
PORT: String(dashboardPort), PORT: String(dashboardPort),
DASHBOARD_PORT: String(dashboardPort), DASHBOARD_PORT: String(dashboardPort),
API_PORT: String(apiPort), API_PORT: String(apiPort),
// #10492: HOSTNAME is standard shell state on Unix-like systems, not an // #6194: POSIX shells (bash/zsh) auto-set HOSTNAME to the machine name — the
// OmniRoute bind setting. The resolver only keeps its legacy meaning on // .env loader (first-wins) can never override it. Ignore HOSTNAME when it
// Windows; OMNIROUTE_SERVER_HOST is the cross-platform explicit setting. // matches the OS-reported hostname (the auto-set signature). OMNIROUTE_SERVER_HOST
HOSTNAME: resolveServerHost(), // takes precedence; legacy HOSTNAME values that don't match os.hostname() are
// still honoured for backward compatibility (e.g. Windows CMD/PowerShell users
// who set HOSTNAME in .env where it is NOT auto-set).
HOSTNAME:
process.env.OMNIROUTE_SERVER_HOST ||
(process.env.HOSTNAME !== osHostname() ? process.env.HOSTNAME : undefined) ||
"0.0.0.0",
NODE_ENV: "production", NODE_ENV: "production",
// #5238: preserve a user-set NODE_OPTIONS (incl. their own // #5238: preserve a user-set NODE_OPTIONS (incl. their own
// `--max-old-space-size=…`) instead of clobbering it with the calibrated // `--max-old-space-size=…`) instead of clobbering it with the calibrated
@@ -262,21 +230,12 @@ export async function runServe(opts = {}) {
function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) { function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) {
// #5238: skip the explicit CLI --max-old-space-size when the user pinned the // #5238: skip the explicit CLI --max-old-space-size when the user pinned the
// heap via NODE_OPTIONS (a CLI arg would shadow/override their value). // heap via NODE_OPTIONS (a CLI arg would shadow/override their value).
const server = spawn( const server = spawn("node", [...buildNodeHeapArgs(process.env, memoryLimit), serverJs], {
process.versions.bun ? process.execPath : "node", cwd: APP_DIR,
[ env,
...(process.versions.bun stdio: "ignore",
? ["--preload", join(APP_DIR, "open-sse/utils/setupPolyfill.ts")] detached: true,
: buildNodeHeapArgs(process.env, memoryLimit)), });
serverJs,
],
{
cwd: APP_DIR,
env,
stdio: "ignore",
detached: true,
}
);
writePidFile("server", server.pid); writePidFile("server", server.pid);
server.unref(); server.unref();
console.log(`\x1b[32m✔ OmniRoute started in background (PID: ${server.pid})\x1b[0m`); console.log(`\x1b[32m✔ OmniRoute started in background (PID: ${server.pid})\x1b[0m`);
@@ -287,20 +246,11 @@ function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) {
function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, noOpen, startedAt) { function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, noOpen, startedAt) {
// #5238: skip the explicit CLI --max-old-space-size when the user pinned the // #5238: skip the explicit CLI --max-old-space-size when the user pinned the
// heap via NODE_OPTIONS (a CLI arg would shadow/override their value). // heap via NODE_OPTIONS (a CLI arg would shadow/override their value).
const server = spawn( const server = spawn("node", [...buildNodeHeapArgs(process.env, memoryLimit), serverJs], {
process.versions.bun ? process.execPath : "node", cwd: APP_DIR,
[ env,
...(process.versions.bun stdio: "pipe",
? ["--preload", join(APP_DIR, "open-sse/utils/setupPolyfill.ts")] });
: buildNodeHeapArgs(process.env, memoryLimit)),
serverJs,
],
{
cwd: APP_DIR,
env,
stdio: "pipe",
}
);
writePidFile("server", server.pid); writePidFile("server", server.pid);
@@ -309,7 +259,6 @@ function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort,
server.stdout.on("data", (data) => { server.stdout.on("data", (data) => {
const text = data.toString(); const text = data.toString();
process.stdout.write(text); process.stdout.write(text);
maybeReportInstrumentationHookFailure(text);
if ( if (
!started && !started &&
(text.includes("Ready") || text.includes("started") || text.includes("listening")) (text.includes("Ready") || text.includes("started") || text.includes("listening"))
@@ -319,11 +268,7 @@ function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort,
} }
}); });
server.stderr.on("data", (data) => { server.stderr.on("data", (data) => process.stderr.write(data));
const text = data.toString();
process.stderr.write(text);
maybeReportInstrumentationHookFailure(text);
});
server.on("error", (err) => { server.on("error", (err) => {
console.error("\x1b[31m✖ Failed to start server:\x1b[0m", err.message); console.error("\x1b[31m✖ Failed to start server:\x1b[0m", err.message);
@@ -432,9 +377,6 @@ export function reportReadinessTimeout(dashboardPort, supervisor) {
console.error("--- Recent server output ---"); console.error("--- Recent server output ---");
recentLog.forEach((l) => console.error(l)); recentLog.forEach((l) => console.error(l));
console.error("--- End recent output ---\n"); console.error("--- End recent output ---\n");
// If the buffered log already shows the Android instrumentation failure,
// print the actionable hint even when --log was off (default).
maybeReportInstrumentationHookFailure(recentLog.join("\n"));
} }
} }

View File

@@ -13,7 +13,6 @@ import { join } from "node:path";
import os from "node:os"; import os from "node:os";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs"; import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
function stripToRoot(url) { function stripToRoot(url) {
const s = String(url || "").replace(/\/+$/, ""); const s = String(url || "").replace(/\/+$/, "");
@@ -26,9 +25,7 @@ export function resolveAiderTarget(opts = {}) {
if (opts.remote) root = stripToRoot(opts.remote); if (opts.remote) root = stripToRoot(opts.remote);
else { else {
try { try {
root = stripToRoot( root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl
);
} catch { } catch {
/* none */ /* none */
} }
@@ -81,7 +78,7 @@ async function fetchModelIds(apiBase, apiKey) {
const res = await fetch(`${apiBase}/v1/models`, { headers, signal: AbortSignal.timeout(8000) }); const res = await fetch(`${apiBase}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
if (!res.ok) return []; if (!res.ok) return [];
const body = await res.json(); const body = await res.json();
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []); const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch { } catch {
return []; return [];
@@ -91,16 +88,7 @@ async function fetchModelIds(apiBase, apiKey) {
export async function runSetupAiderCommand(opts = {}) { export async function runSetupAiderCommand(opts = {}) {
const { apiBase, apiKey } = resolveAiderTarget(opts); const { apiBase, apiKey } = resolveAiderTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const configPath = const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".aider.conf.yml");
opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".aider.conf.yml");
const guard = await guardHostConfigTarget(configPath, {
toolLabel: "Aider",
hostCommand: "omniroute setup-aider",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
printHeading("OmniRoute → Aider (openai-compatible via LiteLLM)"); printHeading("OmniRoute → Aider (openai-compatible via LiteLLM)");
printInfo(`OPENAI_API_BASE: ${apiBase} (no /v1 — LiteLLM appends it)`); printInfo(`OPENAI_API_BASE: ${apiBase} (no /v1 — LiteLLM appends it)`);
@@ -119,9 +107,7 @@ export async function runSetupAiderCommand(opts = {}) {
} }
} }
if (!model) { if (!model) {
printError( printError("A model is required. Pass --model <id> (the openai/ prefix is added automatically).");
"A model is required. Pass --model <id> (the openai/ prefix is added automatically)."
);
return 2; return 2;
} }
@@ -153,10 +139,6 @@ export function registerSetupAider(program) {
.option("--config-path <path>", ".aider.conf.yml path (default: ~/.aider.conf.yml)") .option("--config-path <path>", ".aider.conf.yml path (default: ~/.aider.conf.yml)")
.option("--yes", "Non-interactive: do not prompt (requires --model)") .option("--yes", "Non-interactive: do not prompt (requires --model)")
.option("--dry-run", "Print what would be written without touching the filesystem") .option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => { .action(async (opts) => {
const code = await runSetupAiderCommand(opts); const code = await runSetupAiderCommand(opts);
if (code !== 0) process.exit(code); if (code !== 0) process.exit(code);

View File

@@ -20,7 +20,6 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import os from "node:os"; import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
import { import {
categoriseModel, categoriseModel,
isCodexCompatibleTextModel, isCodexCompatibleTextModel,
@@ -148,14 +147,6 @@ export async function runSetupClaudeCommand(opts = {}) {
printHeading("OmniRoute → Claude Code profile generator"); printHeading("OmniRoute → Claude Code profile generator");
printInfo(`Connecting to ${baseUrl}`); printInfo(`Connecting to ${baseUrl}`);
const guard = await guardHostConfigTarget(profilesRoot, {
toolLabel: "Claude Code",
hostCommand: "omniroute setup-claude",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
// ── Fetch model catalog ─────────────────────────────────────────────────── // ── Fetch model catalog ───────────────────────────────────────────────────
let models; let models;
try { try {
@@ -165,15 +156,7 @@ export async function runSetupClaudeCommand(opts = {}) {
headers, headers,
signal: AbortSignal.timeout(10000), signal: AbortSignal.timeout(10000),
}); });
if (!res.ok) { if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
let detail = `HTTP ${res.status}`;
try {
const errorBody = await res.json();
const serverMsg = errorBody?.error?.message || errorBody?.error || errorBody?.message || "";
if (serverMsg) detail += `${serverMsg}`;
} catch {}
throw new Error(detail);
}
const body = await res.json(); const body = await res.json();
models = body.data ?? body.models ?? []; models = body.data ?? body.models ?? [];
} catch (err) { } catch (err) {
@@ -229,10 +212,6 @@ export function registerSetupClaude(program) {
"Comma-separated substrings — only matching model IDs (e.g. glm,kimi)" "Comma-separated substrings — only matching model IDs (e.g. glm,kimi)"
) )
.option("--dry-run", "Print what would be written without touching the filesystem") .option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => { .action(async (opts) => {
const exitCode = await runSetupClaudeCommand(opts); const exitCode = await runSetupClaudeCommand(opts);
if (exitCode !== 0) process.exit(exitCode); if (exitCode !== 0) process.exit(exitCode);

View File

@@ -16,7 +16,6 @@ import { join } from "node:path";
import os from "node:os"; import os from "node:os";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs"; import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
function stripToRoot(url) { function stripToRoot(url) {
let s = String(url || "").replace(/\/+$/, ""); let s = String(url || "").replace(/\/+$/, "");
@@ -29,14 +28,11 @@ export function resolveClineTarget(opts = {}) {
if (opts.remote) baseUrl = stripToRoot(opts.remote); if (opts.remote) baseUrl = stripToRoot(opts.remote);
else { else {
try { try {
baseUrl = stripToRoot( baseUrl = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl
);
} catch { } catch {
/* none */ /* none */
} }
if (!baseUrl) if (!baseUrl) baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
baseUrl = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
} }
let apiKey = opts.apiKey ?? opts["api-key"]; let apiKey = opts.apiKey ?? opts["api-key"];
if (!apiKey) { if (!apiKey) {
@@ -85,7 +81,7 @@ async function fetchModelIds(baseUrl, apiKey) {
const res = await fetch(`${baseUrl}/v1/models`, { headers, signal: AbortSignal.timeout(8000) }); const res = await fetch(`${baseUrl}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
if (!res.ok) return []; if (!res.ok) return [];
const body = await res.json(); const body = await res.json();
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []); const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch { } catch {
return []; return [];
@@ -97,14 +93,6 @@ export async function runSetupClineCommand(opts = {}) {
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const clineDir = opts.clineDir ?? opts["cline-dir"] ?? join(os.homedir(), ".cline", "data"); const clineDir = opts.clineDir ?? opts["cline-dir"] ?? join(os.homedir(), ".cline", "data");
const guard = await guardHostConfigTarget(clineDir, {
toolLabel: "Cline",
hostCommand: "omniroute setup-cline",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
printHeading("OmniRoute → Cline (OpenAI-compatible)"); printHeading("OmniRoute → Cline (OpenAI-compatible)");
printInfo(`Server: ${baseUrl}`); printInfo(`Server: ${baseUrl}`);
@@ -134,18 +122,7 @@ export async function runSetupClineCommand(opts = {}) {
if (dryRun) { if (dryRun) {
console.log(`\n── [dry-run] ${gsPath} ──`); console.log(`\n── [dry-run] ${gsPath} ──`);
console.log( console.log(JSON.stringify({ actModeApiProvider: globalState.actModeApiProvider, planModeApiProvider: globalState.planModeApiProvider, openAiBaseUrl: globalState.openAiBaseUrl, openAiModelId: globalState.openAiModelId }, null, 2));
JSON.stringify(
{
actModeApiProvider: globalState.actModeApiProvider,
planModeApiProvider: globalState.planModeApiProvider,
openAiBaseUrl: globalState.openAiBaseUrl,
openAiModelId: globalState.openAiModelId,
},
null,
2
)
);
console.log(`\n── [dry-run] ${secPath} ── (openAiApiKey: ${apiKey ? "set" : "sk_omniroute"})`); console.log(`\n── [dry-run] ${secPath} ── (openAiApiKey: ${apiKey ? "set" : "sk_omniroute"})`);
} else { } else {
if (!existsSync(clineDir)) mkdirSync(clineDir, { recursive: true }); if (!existsSync(clineDir)) mkdirSync(clineDir, { recursive: true });
@@ -156,9 +133,7 @@ export async function runSetupClineCommand(opts = {}) {
} }
// The VS Code extension uses opaque globalStorage — can't be file-written. // The VS Code extension uses opaque globalStorage — can't be file-written.
printInfo( printInfo("\nFor the Cline VS Code extension, set these in its Settings → API (OpenAI Compatible):");
"\nFor the Cline VS Code extension, set these in its Settings → API (OpenAI Compatible):"
);
printInfo(` Base URL: ${baseUrl} (NOT /v1 — Cline appends it)`); printInfo(` Base URL: ${baseUrl} (NOT /v1 — Cline appends it)`);
printInfo(` API Key: <your OMNIROUTE_API_KEY>`); printInfo(` API Key: <your OMNIROUTE_API_KEY>`);
printInfo(` Model: ${model}`); printInfo(` Model: ${model}`);
@@ -178,10 +153,6 @@ export function registerSetupCline(program) {
.option("--cline-dir <dir>", "Cline data dir (default: ~/.cline/data)") .option("--cline-dir <dir>", "Cline data dir (default: ~/.cline/data)")
.option("--yes", "Non-interactive: do not prompt (requires --model)") .option("--yes", "Non-interactive: do not prompt (requires --model)")
.option("--dry-run", "Print what would be written without touching the filesystem") .option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => { .action(async (opts) => {
const code = await runSetupClineCommand(opts); const code = await runSetupClineCommand(opts);
if (code !== 0) process.exit(code); if (code !== 0) process.exit(code);

View File

@@ -16,7 +16,6 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import os from "node:os"; import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
import { t } from "../i18n.mjs"; import { t } from "../i18n.mjs";
// ── Model categorisation ────────────────────────────────────────────────────── // ── Model categorisation ──────────────────────────────────────────────────────
@@ -307,14 +306,6 @@ export async function runSetupCodexCommand(opts = {}) {
const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null; const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null;
printHeading(`OmniRoute → Codex CLI profile generator`); printHeading(`OmniRoute → Codex CLI profile generator`);
const guard = await guardHostConfigTarget(codexHome, {
toolLabel: "Codex",
hostCommand: "omniroute setup-codex",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
printInfo(`Connecting to ${baseUrl}`); printInfo(`Connecting to ${baseUrl}`);
// ── Fetch model catalog ─────────────────────────────────────────────────── // ── Fetch model catalog ───────────────────────────────────────────────────
@@ -389,10 +380,6 @@ export function registerSetupCodex(program) {
"Comma-separated substrings — only generate profiles for matching model IDs (e.g. glm,kimi)" "Comma-separated substrings — only generate profiles for matching model IDs (e.g. glm,kimi)"
) )
.option("--dry-run", "Print what would be written without touching the filesystem") .option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => { .action(async (opts) => {
const exitCode = await runSetupCodexCommand(opts); const exitCode = await runSetupCodexCommand(opts);
if (exitCode !== 0) process.exit(exitCode); if (exitCode !== 0) process.exit(exitCode);

View File

@@ -14,7 +14,6 @@ import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { categoriseModel } from "./setup-codex.mjs"; import { categoriseModel } from "./setup-codex.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
const SECRET_REF = "${{ secrets.OMNIROUTE_API_KEY }}"; const SECRET_REF = "${{ secrets.OMNIROUTE_API_KEY }}";
@@ -93,7 +92,7 @@ async function fetchModelIds(apiBase, apiKey) {
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json(); const body = await res.json();
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []); const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch (e) { } catch (e) {
throw new Error(`Could not fetch models: ${e.message}`); throw new Error(`Could not fetch models: ${e.message}`);
@@ -103,22 +102,8 @@ async function fetchModelIds(apiBase, apiKey) {
export async function runSetupContinueCommand(opts = {}) { export async function runSetupContinueCommand(opts = {}) {
const { apiBase, apiKey } = resolveContinueTarget(opts); const { apiBase, apiKey } = resolveContinueTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const only = opts.only const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
? opts.only const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".continue", "config.yaml");
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: null;
const configPath =
opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".continue", "config.yaml");
const guard = await guardHostConfigTarget(configPath, {
toolLabel: "Continue",
hostCommand: "omniroute setup-continue",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
printHeading("OmniRoute → Continue (config.yaml)"); printHeading("OmniRoute → Continue (config.yaml)");
printInfo(`apiBase: ${apiBase}`); printInfo(`apiBase: ${apiBase}`);
@@ -165,7 +150,7 @@ export async function runSetupContinueCommand(opts = {}) {
printInfo("\nProvide the key (config.yaml references it, not stores it):"); printInfo("\nProvide the key (config.yaml references it, not stores it):");
printInfo(" cn CLI: export OMNIROUTE_API_KEY=... (read from your shell)"); printInfo(" cn CLI: export OMNIROUTE_API_KEY=... (read from your shell)");
printInfo(" IDE: echo 'OMNIROUTE_API_KEY=...' >> ~/.continue/.env"); printInfo(" IDE: echo 'OMNIROUTE_API_KEY=...' >> ~/.continue/.env");
printInfo('Run: cn -p "reply OK"'); printInfo("Run: cn -p \"reply OK\"");
return 0; return 0;
} }
@@ -181,10 +166,6 @@ export function registerSetupContinue(program) {
.option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs") .option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs")
.option("--config-path <path>", "config.yaml path (default: ~/.continue/config.yaml)") .option("--config-path <path>", "config.yaml path (default: ~/.continue/config.yaml)")
.option("--dry-run", "Print what would be written without touching the filesystem") .option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => { .action(async (opts) => {
const code = await runSetupContinueCommand(opts); const code = await runSetupContinueCommand(opts);
if (code !== 0) process.exit(code); if (code !== 0) process.exit(code);

View File

@@ -13,7 +13,6 @@ import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { categoriseModel } from "./setup-codex.mjs"; import { categoriseModel } from "./setup-codex.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
const API_KEY_REF = "$OMNIROUTE_API_KEY"; const API_KEY_REF = "$OMNIROUTE_API_KEY";
@@ -88,29 +87,15 @@ async function fetchModelIds(baseUrl, apiKey) {
}); });
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
const body = await res.json(); const body = await res.json();
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []); const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} }
export async function runSetupCrushCommand(opts = {}) { export async function runSetupCrushCommand(opts = {}) {
const { baseUrl, apiKey } = resolveCrushTarget(opts); const { baseUrl, apiKey } = resolveCrushTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const only = opts.only const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
? opts.only const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "crush", "crush.json");
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: null;
const configPath =
opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "crush", "crush.json");
const guard = await guardHostConfigTarget(configPath, {
toolLabel: "Crush",
hostCommand: "omniroute setup-crush",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
printHeading("OmniRoute → Crush (openai-compat)"); printHeading("OmniRoute → Crush (openai-compat)");
printInfo(`base_url: ${baseUrl}`); printInfo(`base_url: ${baseUrl}`);
@@ -135,17 +120,13 @@ export async function runSetupCrushCommand(opts = {}) {
if (dryRun) { if (dryRun) {
console.log("\n" + (out.length > 3500 ? out.slice(0, 3500) + "\n… (truncated)" : out)); console.log("\n" + (out.length > 3500 ? out.slice(0, 3500) + "\n… (truncated)" : out));
printInfo( printInfo(`[dry-run] ${provider.models.length} model(s) under providers.omniroute → ${configPath}`);
`[dry-run] ${provider.models.length} model(s) under providers.omniroute → ${configPath}`
);
return 0; return 0;
} }
mkdirSync(join(configPath, ".."), { recursive: true }); mkdirSync(join(configPath, ".."), { recursive: true });
writeFileSync(configPath, out, "utf8"); writeFileSync(configPath, out, "utf8");
printSuccess(`Wrote ${configPath} (${provider.models.length} models under providers.omniroute)`); printSuccess(`Wrote ${configPath} (${provider.models.length} models under providers.omniroute)`);
printInfo( printInfo("Provide the key (config references $OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=...");
"Provide the key (config references $OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=..."
);
printInfo("Then run: crush"); printInfo("Then run: crush");
return 0; return 0;
} }
@@ -160,10 +141,6 @@ export function registerSetupCrush(program) {
.option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs") .option("--only <patterns>", "Comma-separated substrings — keep only matching model IDs")
.option("--config-path <path>", "crush.json path (default: ~/.config/crush/crush.json)") .option("--config-path <path>", "crush.json path (default: ~/.config/crush/crush.json)")
.option("--dry-run", "Print what would be written without touching the filesystem") .option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => { .action(async (opts) => {
const code = await runSetupCrushCommand(opts); const code = await runSetupCrushCommand(opts);
if (code !== 0) process.exit(code); if (code !== 0) process.exit(code);

View File

@@ -10,7 +10,6 @@
import { printHeading, printInfo, printSuccess } from "../io.mjs"; import { printHeading, printInfo, printSuccess } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { isContainerRuntime } from "../utils/config-home-guard.mjs";
function ensureV1(url) { function ensureV1(url) {
const s = String(url || "").replace(/\/+$/, ""); const s = String(url || "").replace(/\/+$/, "");
@@ -72,7 +71,7 @@ async function fetchModelIds(apiBase, apiKey) {
}); });
if (!res.ok) return []; if (!res.ok) return [];
const body = await res.json(); const body = await res.json();
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []); const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch { } catch {
return []; return [];
@@ -85,32 +84,19 @@ export async function runSetupCursorCommand(opts = {}) {
printInfo(`Server: ${apiBase}`); printInfo(`Server: ${apiBase}`);
let models = []; let models = [];
const only = opts.only const only = opts.only ? opts.only.split(",").map((s) => s.trim()).filter(Boolean) : null;
? opts.only
.split(",")
.map((s) => s.trim())
.filter(Boolean)
: null;
const ids = await fetchModelIds(apiBase, apiKey); const ids = await fetchModelIds(apiBase, apiKey);
models = only ? ids.filter((id) => only.some((f) => id.includes(f))) : ids; models = only ? ids.filter((id) => only.some((f) => id.includes(f))) : ids;
console.log("\n" + buildCursorInstructions({ apiBase, models })); console.log("\n" + buildCursorInstructions({ apiBase, models }));
printSuccess("\nCursor is configured manually (no file written — Cursor's storage is opaque)."); printSuccess("\nCursor is configured manually (no file written — Cursor's storage is opaque).");
if (await isContainerRuntime()) {
printInfo(
"Note: this ran inside a container, so the base URL above is the container's own view. " +
"Use the address the host reaches OmniRoute on (e.g. the published port) in Cursor's settings."
);
}
return 0; return 0;
} }
export function registerSetupCursor(program) { export function registerSetupCursor(program) {
program program
.command("setup-cursor") .command("setup-cursor")
.description( .description("Print the steps to point Cursor at OmniRoute (chat panel; Cursor config is not file-writable)")
"Print the steps to point Cursor at OmniRoute (chat panel; Cursor config is not file-writable)"
)
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128") .option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") .option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") .option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")

View File

@@ -14,7 +14,6 @@ import { join } from "node:path";
import os from "node:os"; import os from "node:os";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs"; import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
function stripToRoot(url) { function stripToRoot(url) {
const s = String(url || "").replace(/\/+$/, ""); const s = String(url || "").replace(/\/+$/, "");
@@ -27,9 +26,7 @@ export function resolveGooseTarget(opts = {}) {
if (opts.remote) root = stripToRoot(opts.remote); if (opts.remote) root = stripToRoot(opts.remote);
else { else {
try { try {
root = stripToRoot( root = stripToRoot(resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl);
resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl
);
} catch { } catch {
/* none */ /* none */
} }
@@ -83,7 +80,7 @@ async function fetchModelIds(host, apiKey) {
const res = await fetch(`${host}/v1/models`, { headers, signal: AbortSignal.timeout(8000) }); const res = await fetch(`${host}/v1/models`, { headers, signal: AbortSignal.timeout(8000) });
if (!res.ok) return []; if (!res.ok) return [];
const body = await res.json(); const body = await res.json();
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []); const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch { } catch {
return []; return [];
@@ -93,16 +90,7 @@ async function fetchModelIds(host, apiKey) {
export async function runSetupGooseCommand(opts = {}) { export async function runSetupGooseCommand(opts = {}) {
const { host, apiKey } = resolveGooseTarget(opts); const { host, apiKey } = resolveGooseTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const configPath = const configPath = opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "goose", "config.yaml");
opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".config", "goose", "config.yaml");
const guard = await guardHostConfigTarget(configPath, {
toolLabel: "Goose",
hostCommand: "omniroute setup-goose",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
printHeading("OmniRoute → Goose (openai-compatible)"); printHeading("OmniRoute → Goose (openai-compatible)");
printInfo(`OPENAI_HOST: ${host} (no /v1 — Goose appends it)`); printInfo(`OPENAI_HOST: ${host} (no /v1 — Goose appends it)`);
@@ -140,16 +128,14 @@ export async function runSetupGooseCommand(opts = {}) {
printInfo("\nProvide the key (Goose reads it from the env / OS keyring):"); printInfo("\nProvide the key (Goose reads it from the env / OS keyring):");
console.log(buildGooseEnvRecipe({ host, model })); console.log(buildGooseEnvRecipe({ host, model }));
printInfo('Then run: goose session (or: goose run -t "reply OK")'); printInfo("Then run: goose session (or: goose run -t \"reply OK\")");
return 0; return 0;
} }
export function registerSetupGoose(program) { export function registerSetupGoose(program) {
program program
.command("setup-goose") .command("setup-goose")
.description( .description("Configure Goose for OmniRoute: write ~/.config/goose/config.yaml + print the env recipe")
"Configure Goose for OmniRoute: write ~/.config/goose/config.yaml + print the env recipe"
)
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128") .option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") .option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") .option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
@@ -157,10 +143,6 @@ export function registerSetupGoose(program) {
.option("--config-path <path>", "config.yaml path (default: ~/.config/goose/config.yaml)") .option("--config-path <path>", "config.yaml path (default: ~/.config/goose/config.yaml)")
.option("--yes", "Non-interactive: do not prompt (requires --model)") .option("--yes", "Non-interactive: do not prompt (requires --model)")
.option("--dry-run", "Print what would be written without touching the filesystem") .option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => { .action(async (opts) => {
const code = await runSetupGooseCommand(opts); const code = await runSetupGooseCommand(opts);
if (code !== 0) process.exit(code); if (code !== 0) process.exit(code);

View File

@@ -14,7 +14,6 @@ import { join } from "node:path";
import os from "node:os"; import os from "node:os";
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs"; import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
/** Ensure the URL ends with /v1 (Kilo appends /chat/completions to it). */ /** Ensure the URL ends with /v1 (Kilo appends /chat/completions to it). */
function ensureV1(url) { function ensureV1(url) {
@@ -62,11 +61,7 @@ export function buildKiloAuth(existing, { apiKey, baseUrl, model }) {
/** Merge the kilocode.* keys into VS Code settings.json (extension surface). */ /** Merge the kilocode.* keys into VS Code settings.json (extension surface). */
export function buildKiloVscodeSettings(existing, { apiKey, baseUrl, model }) { export function buildKiloVscodeSettings(existing, { apiKey, baseUrl, model }) {
const s = { ...(existing || {}) }; const s = { ...(existing || {}) };
s["kilocode.customProvider"] = { s["kilocode.customProvider"] = { name: "OmniRoute", baseURL: baseUrl, apiKey: apiKey || "sk_omniroute" };
name: "OmniRoute",
baseURL: baseUrl,
apiKey: apiKey || "sk_omniroute",
};
s["kilocode.defaultModel"] = model; s["kilocode.defaultModel"] = model;
return s; return s;
} }
@@ -90,7 +85,7 @@ async function fetchModelIds(root, apiKey) {
}); });
if (!res.ok) return []; if (!res.ok) return [];
const body = await res.json(); const body = await res.json();
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []); const list = Array.isArray(body) ? body : body.data ?? body.models ?? [];
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean); return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
} catch { } catch {
return []; return [];
@@ -100,22 +95,9 @@ async function fetchModelIds(root, apiKey) {
export async function runSetupKiloCommand(opts = {}) { export async function runSetupKiloCommand(opts = {}) {
const { baseUrl, apiKey } = resolveKiloTarget(opts); const { baseUrl, apiKey } = resolveKiloTarget(opts);
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]); const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
const authPath = const authPath = opts.authPath ?? opts["auth-path"] ?? join(os.homedir(), ".local", "share", "kilo", "auth.json");
opts.authPath ??
opts["auth-path"] ??
join(os.homedir(), ".local", "share", "kilo", "auth.json");
const guard = await guardHostConfigTarget(authPath, {
toolLabel: "Kilo Code",
hostCommand: "omniroute setup-kilo",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
dryRun,
});
if (guard !== 0) return guard;
const vscodePath = const vscodePath =
opts.vscodeSettings ?? opts.vscodeSettings ?? opts["vscode-settings"] ?? join(os.homedir(), ".config", "Code", "User", "settings.json");
opts["vscode-settings"] ??
join(os.homedir(), ".config", "Code", "User", "settings.json");
printHeading("OmniRoute → Kilo Code (OpenAI-compatible)"); printHeading("OmniRoute → Kilo Code (OpenAI-compatible)");
printInfo(`Server: ${baseUrl}`); printInfo(`Server: ${baseUrl}`);
@@ -134,9 +116,7 @@ export async function runSetupKiloCommand(opts = {}) {
} }
} }
if (!model) { if (!model) {
printError( printError("A model is required. Pass --model <id> (Kilo's extension has no model auto-discovery).");
"A model is required. Pass --model <id> (Kilo's extension has no model auto-discovery)."
);
return 2; return 2;
} }
@@ -152,19 +132,12 @@ export async function runSetupKiloCommand(opts = {}) {
console.log(`\n── [dry-run] ${authPath} ──`); console.log(`\n── [dry-run] ${authPath} ──`);
console.log( console.log(
JSON.stringify( JSON.stringify(
{ { "openai-compatible": { ...auth["openai-compatible"], apiKey: apiKey ? "set" : "sk_omniroute" } },
"openai-compatible": {
...auth["openai-compatible"],
apiKey: apiKey ? "set" : "sk_omniroute",
},
},
null, null,
2 2
) )
); );
console.log( console.log(`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would merge kilocode.* keys)" : "(skipped — file absent)"}`);
`\n── [dry-run] ${vscodePath} ── ${vscodeExists ? "(would merge kilocode.* keys)" : "(skipped — file absent)"}`
);
} else { } else {
mkdirSync(join(authPath, ".."), { recursive: true }); mkdirSync(join(authPath, ".."), { recursive: true });
writeFileSync(authPath, JSON.stringify(auth, null, 2) + "\n", "utf8"); writeFileSync(authPath, JSON.stringify(auth, null, 2) + "\n", "utf8");
@@ -194,20 +167,10 @@ export function registerSetupKilo(program) {
.option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128") .option("--remote <url>", "Remote OmniRoute URL, e.g. http://192.168.0.15:20128")
.option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)") .option("--api-key <key>", "OmniRoute API key (defaults to OMNIROUTE_API_KEY env var)")
.option("--model <id>", "Model id for Kilo (required unless picked interactively)") .option("--model <id>", "Model id for Kilo (required unless picked interactively)")
.option( .option("--auth-path <path>", "Kilo CLI auth.json path (default: ~/.local/share/kilo/auth.json)")
"--auth-path <path>", .option("--vscode-settings <path>", "VS Code settings.json (default: ~/.config/Code/User/settings.json)")
"Kilo CLI auth.json path (default: ~/.local/share/kilo/auth.json)"
)
.option(
"--vscode-settings <path>",
"VS Code settings.json (default: ~/.config/Code/User/settings.json)"
)
.option("--yes", "Non-interactive: do not prompt (requires --model)") .option("--yes", "Non-interactive: do not prompt (requires --model)")
.option("--dry-run", "Print what would be written without touching the filesystem") .option("--dry-run", "Print what would be written without touching the filesystem")
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts) => { .action(async (opts) => {
const code = await runSetupKiloCommand(opts); const code = await runSetupKiloCommand(opts);
if (code !== 0) process.exit(code); if (code !== 0) process.exit(code);

View File

@@ -30,7 +30,6 @@ import os from "node:os";
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs"; import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
import { t } from "../i18n.mjs"; import { t } from "../i18n.mjs";
import { resolveActiveContext } from "../contexts.mjs"; import { resolveActiveContext } from "../contexts.mjs";
import { guardHostConfigTarget } from "../utils/config-home-guard.mjs";
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); const __dirname = dirname(__filename);
@@ -219,56 +218,18 @@ function registerPluginInOpenCodeConfig({
* a clear "could not run opencode" message instead of a hard import * a clear "could not run opencode" message instead of a hard import
* failure. * failure.
*/ */
/** function runOpenCodeAuth(providerId) {
* Resolve the provider id used for `opencode auth login --provider <id>`. const isWin = process.platform === "win32";
* const opencodeBin = isWin ? "opencode.cmd" : "opencode";
* The bundled @omniroute/opencode-plugin registers its provider under const res = spawnSync(opencodeBin, ["auth", "login", "--provider", providerId], {
* `opencode-<id>` (the `opencode-` prefix is required by OpenCode >=1.17.8's stdio: "inherit",
* native-adapter gate). The auth login command must use the prefixed form shell: false,
* because OpenCode resolves `--provider <id>` against the provider id the });
* plugin actually registered.
*
* Idempotent: if the id already starts with `opencode-`, it passes through
* unchanged. This protects users who manually worked around the bug with
* `--provider opencode-omniroute`.
*
* @param {string} providerId
* @returns {string}
*/
export function resolveOpenCodeAuthProviderId(providerId) {
return providerId.startsWith("opencode-") ? providerId : `opencode-${providerId}`;
}
/**
* Pure resolver for the `opencode auth login` spawn descriptor. Extracted so the
* platform-branching logic is unit-testable without mocking child_process or
* mutating process.platform.
*
* On Windows the `opencode` binary is an npm `.cmd` shim that Node's hardened
* spawnSync (post CVE-2024-27980) refuses to run without a shell — spawning it
* with shell:false throws EINVAL (#7913). Mirror the same fix already applied to
* codex (resolveCodexSpawn in launch-codex.mjs, crediting #6263) and
* qodercli/Auggie (#6263/#6304): shell:true on win32, shell:false everywhere else.
*/
export function resolveOpenCodeAuthSpawn(providerId, platform = process.platform) {
const isWin = platform === "win32";
const authProviderId = resolveOpenCodeAuthProviderId(providerId);
return {
command: isWin ? "opencode.cmd" : "opencode",
args: ["auth", "login", "--provider", authProviderId],
options: { stdio: "inherit", shell: isWin },
};
}
export function runOpenCodeAuth(providerId) {
const authProviderId = resolveOpenCodeAuthProviderId(providerId);
const { command, args, options } = resolveOpenCodeAuthSpawn(providerId);
const res = spawnSync(command, args, options);
if (res.error) { if (res.error) {
// ENOENT = opencode is not on PATH // ENOENT = opencode is not on PATH
if (res.error.code === "ENOENT") { if (res.error.code === "ENOENT") {
printInfo( printInfo(
`opencode CLI not found on PATH. Run \`opencode auth login --provider ${authProviderId}\` manually after installing OpenCode.` `opencode CLI not found on PATH. Run \`opencode auth login --provider ${providerId}\` manually after installing OpenCode.`
); );
return 1; return 1;
} }
@@ -317,13 +278,6 @@ export async function runSetupOpenCodeCommand(opts = {}) {
printInfo(`OpenCode config dir: ${opencodeConfigDir}`); printInfo(`OpenCode config dir: ${opencodeConfigDir}`);
printInfo(`OpenCode data dir: ${opencodeDataDir}`); printInfo(`OpenCode data dir: ${opencodeDataDir}`);
const guard = await guardHostConfigTarget(opencodeConfigDir, {
toolLabel: "OpenCode",
hostCommand: "omniroute setup opencode",
allowContainerWrite: Boolean(opts.allowContainerWrite ?? opts["allow-container-write"]),
});
if (guard !== 0) return { exitCode: guard };
// 1. Resolve bundled plugin // 1. Resolve bundled plugin
let pluginInfo; let pluginInfo;
try { try {
@@ -373,8 +327,7 @@ export async function runSetupOpenCodeCommand(opts = {}) {
if (wantsAuth) { if (wantsAuth) {
if (nonInteractive) { if (nonInteractive) {
printInfo(`Skipping \`opencode auth login\` (non-interactive mode).`); printInfo(`Skipping \`opencode auth login\` (non-interactive mode).`);
const authProviderId = resolveOpenCodeAuthProviderId(providerId); printInfo(`Run manually: opencode auth login --provider ${providerId}`);
printInfo(`Run manually: opencode auth login --provider ${authProviderId}`);
} else { } else {
printHeading("Authenticating with OpenCode"); printHeading("Authenticating with OpenCode");
const authExit = runOpenCodeAuth(providerId); const authExit = runOpenCodeAuth(providerId);
@@ -383,9 +336,8 @@ export async function runSetupOpenCodeCommand(opts = {}) {
} }
} }
} else { } else {
const authProviderId = resolveOpenCodeAuthProviderId(providerId);
printInfo( printInfo(
`Next step: opencode auth login --provider ${authProviderId} (pass --auth to do this automatically)` `Next step: opencode auth login --provider ${providerId} (pass --auth to do this automatically)`
); );
} }
@@ -428,10 +380,6 @@ export function registerSetupOpenCode(setupCommand) {
false false
) )
.option("--non-interactive", "Do not prompt; skip the auth login step", false) .option("--non-interactive", "Do not prompt; skip the auth login step", false)
.option(
"--allow-container-write",
"Write even when the target is inside a container and not mounted from the host"
)
.action(async (opts, cmd) => { .action(async (opts, cmd) => {
// The parent `setup` command uses cmd.optsWithGlobals(); we mirror // The parent `setup` command uses cmd.optsWithGlobals(); we mirror
// that here so global flags (--json, --base-url, --api-key) still // that here so global flags (--json, --base-url, --api-key) still

Some files were not shown because too many files have changed in this diff Show More