mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 10:43:43 +03:00
Compare commits
5 Commits
fix/audio-
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba71a68441 | ||
|
|
7a14a96b83 | ||
|
|
adede73fe2 | ||
|
|
cc2f19a965 | ||
|
|
60a3e08a55 |
201
.cbmignore
201
.cbmignore
@@ -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
|
||||
@@ -7,13 +7,7 @@
|
||||
**/.vscode
|
||||
|
||||
# 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
|
||||
.next
|
||||
.build
|
||||
out
|
||||
@@ -24,7 +18,6 @@ coverage
|
||||
# Runtime data and logs
|
||||
data
|
||||
logs
|
||||
.sandbox
|
||||
|
||||
# Local env files (inject at runtime via --env-file or -e)
|
||||
.env
|
||||
@@ -44,19 +37,6 @@ tests
|
||||
test-results
|
||||
playwright-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
|
||||
# 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
|
||||
# container image.
|
||||
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/**/*.jpg
|
||||
docs/diagrams/**/*.jpeg
|
||||
|
||||
@@ -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
|
||||
723
.env.example
723
.env.example
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
20
.github/dependabot.yml
vendored
20
.github/dependabot.yml
vendored
@@ -24,32 +24,12 @@ updates:
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "eslint-config-next"
|
||||
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).
|
||||
# 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
|
||||
# the duplication gate — migrate the gate intentionally, not via dependabot.
|
||||
- dependency-name: "jscpd"
|
||||
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.
|
||||
# It is load-bearing for the LLMLingua ONNX compression engine (open-sse/services/
|
||||
# compression/engines/llmlingua/ — worker.ts pins @huggingface/transformers@3.5.2)
|
||||
|
||||
13
.github/pull_request_template.md
vendored
13
.github/pull_request_template.md
vendored
@@ -9,15 +9,10 @@
|
||||
|
||||
## 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`
|
||||
- [ ] Reconciled with the current active release base; focused checks rerun afterward
|
||||
- [ ] Production-code changes include a new or updated automated test in this PR
|
||||
- [ ] `npm run test:unit`
|
||||
- [ ] `npm run test:coverage`
|
||||
- [ ] 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
|
||||
@@ -32,4 +27,4 @@ Vitest, the 60% coverage gate, and the production build all run in CI on this PR
|
||||
|
||||
## Reviewer Notes
|
||||
|
||||
- Call out any risky areas, migrations, feature flags, or manual validation that reviewers should know about.
|
||||
- Call out any risky areas, migrations, feature flags, or manual validation that reviewers should know about.
|
||||
71
.github/workflows/build-fork.yml
vendored
Normal file
71
.github/workflows/build-fork.yml
vendored
Normal 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
|
||||
57
.github/workflows/build.yml
vendored
57
.github/workflows/build.yml
vendored
@@ -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
|
||||
456
.github/workflows/ci.yml
vendored
456
.github/workflows/ci.yml
vendored
@@ -33,35 +33,17 @@ jobs:
|
||||
docs: ${{ steps.classify.outputs.docs }}
|
||||
i18n: ${{ steps.classify.outputs.i18n }}
|
||||
workflow: ${{ steps.classify.outputs.workflow }}
|
||||
testsOnly: ${{ steps.classify.outputs.testsOnly }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
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
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
# Single source of truth: scripts/quality/classify-pr-changes.mjs
|
||||
# (unit-tested). Push/dispatch always enable every lane.
|
||||
if [ "$EVENT_NAME" != "pull_request" ]; then
|
||||
{
|
||||
echo "code=true"
|
||||
@@ -72,18 +54,51 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
code=false
|
||||
docs=false
|
||||
i18n=false
|
||||
workflow=false
|
||||
|
||||
git diff --name-only "$BASE_SHA" "$HEAD_SHA" > changed-files.txt
|
||||
node scripts/quality/classify-pr-changes.mjs changed-files.txt >> "$GITHUB_OUTPUT"
|
||||
|
||||
while IFS= read -r file; do
|
||||
case "$file" in
|
||||
.github/workflows/*|.zizmor.yml)
|
||||
workflow=true
|
||||
code=true
|
||||
;;
|
||||
docs/*|*.md)
|
||||
docs=true
|
||||
;;
|
||||
src/i18n/*|src/i18n/messages/*|scripts/i18n/*|config/i18n.json)
|
||||
i18n=true
|
||||
code=true
|
||||
;;
|
||||
src/*|open-sse/*|bin/*|electron/*|tests/*|scripts/*|package.json|package-lock.json|tsconfig*.json|next.config.*|vitest*.config.*|playwright.config.*)
|
||||
code=true
|
||||
;;
|
||||
db/*|config/*)
|
||||
code=true
|
||||
;;
|
||||
*)
|
||||
code=true
|
||||
;;
|
||||
esac
|
||||
done < changed-files.txt
|
||||
|
||||
{
|
||||
echo "code=$code"
|
||||
echo "docs=$docs"
|
||||
echo "i18n=$i18n"
|
||||
echo "workflow=$workflow"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
lint:
|
||||
name: Lint
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
# 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).
|
||||
# Path filter: pure docs / pure message-catalog PRs skip the code lint bag + typecheck.
|
||||
# Existence reason of this job is code regression; docs/i18n have dedicated jobs.
|
||||
if: ${{ 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 }}
|
||||
env:
|
||||
# tsx gates below (known-symbols, route-guard-membership) import modules that
|
||||
# open SQLite on load; provide DB env so a fresh CI DB initializes cleanly.
|
||||
@@ -94,34 +109,14 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run audit:deps
|
||||
- name: Restore ESLint file cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||
restore-keys: |
|
||||
eslint-${{ runner.os }}-
|
||||
# Single ESLint inventory (JSON) — quality-gate reuses the artifact instead of
|
||||
# a second cold full-tree pass for eslintWarnings ratchet counts.
|
||||
- name: ESLint (JSON report)
|
||||
run: npm run lint:json
|
||||
- name: Upload ESLint results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: eslint-results
|
||||
path: .artifacts/eslint-results.json
|
||||
if-no-files-found: warn
|
||||
retention-days: 7
|
||||
- run: npm run lint
|
||||
- run: npm run check:cycles
|
||||
- run: npm run check:route-validation:t06
|
||||
- run: npm run check:any-budget:t11
|
||||
@@ -137,44 +132,21 @@ jobs:
|
||||
- run: npm run check:route-guard-membership
|
||||
- run: npm run check:test-discovery
|
||||
- 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:licenses
|
||||
# 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).
|
||||
- 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):
|
||||
# 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.
|
||||
# typecheck:noimplicit:core is a forward-looking gate (noImplicitAny).
|
||||
# Run informationally for now — many pre-existing call sites still need
|
||||
# explicit annotations; track in a dedicated follow-up.
|
||||
- run: npm run typecheck:noimplicit:core
|
||||
continue-on-error: true
|
||||
|
||||
quality-gate:
|
||||
name: Quality Ratchet
|
||||
runs-on: ubuntu-latest
|
||||
# needs lint so eslint-results artifact is available (same inventory as the
|
||||
# blocking lint step). Allow lint failure so other ratchets still run.
|
||||
needs: [changes, test-coverage, lint]
|
||||
needs: test-coverage
|
||||
# Run even when test-coverage was SKIPPED/FAILED (e.g. a single flaky Coverage
|
||||
# Shard breaks the shard→coverage→ratchet chain). The DETERMINISTIC ratchets
|
||||
# (eslint / complexity / cognitive-complexity / duplication / codeql) do NOT need
|
||||
@@ -183,8 +155,7 @@ jobs:
|
||||
# release PR #4854, where the drift cascade only surfaced post-merge in #5029).
|
||||
# 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.
|
||||
# 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) }}
|
||||
# security-events: read lets the CodeQL ratchet read open code-scanning alerts
|
||||
# via `gh api .../code-scanning/alerts`. contents: read keeps checkout working.
|
||||
permissions:
|
||||
@@ -194,20 +165,11 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- name: Restore ESLint file cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||
restore-keys: |
|
||||
eslint-${{ runner.os }}-
|
||||
# Coverage mergeada (coverage-summary.json) p/ o ratchet de cobertura.
|
||||
# continue-on-error: o artifact pode não existir se a job test-coverage foi
|
||||
# SKIPPED (shard flaky). Nesse caso collect-metrics pula coverage.* (ausente sem
|
||||
@@ -218,13 +180,6 @@ jobs:
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
# Prefer lint job's ESLint JSON (one inventory, two consumers).
|
||||
- name: Download ESLint results
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: eslint-results
|
||||
path: .artifacts/
|
||||
- run: npm run quality:collect
|
||||
# Catraca: falha se qualquer métrica regredir vs quality-baseline.json (commitado).
|
||||
# Hoje: contagem de warnings do ESLint. Fase 4 estende com cobertura (lida do
|
||||
@@ -246,13 +201,15 @@ jobs:
|
||||
# para não pesar no caminho crítico do lint.
|
||||
- name: Duplication ratchet
|
||||
run: npm run check:duplication
|
||||
# Complexity + cognitive: one ESLint walk, two independent baselines (by ruleId).
|
||||
- name: Complexity + cognitive ratchets
|
||||
run: npm run check:complexity-ratchets
|
||||
# Fase 7 INT: dead-code, type-coverage promovidos de advisory (quality-extended)
|
||||
# para BLOQUEANTES aqui. cognitive-complexity is folded into the step above.
|
||||
- name: Complexity ratchet
|
||||
run: npm run check:complexity
|
||||
# Fase 7 INT: dead-code, cognitive-complexity, type-coverage promovidos de
|
||||
# advisory (quality-extended) para BLOQUEANTES aqui. Os 3 leem seus baseline
|
||||
# de quality-baseline.json e saem 1 em regressão.
|
||||
- name: Dead-code ratchet (knip)
|
||||
run: npm run check:dead-code
|
||||
- name: Cognitive complexity ratchet (sonarjs)
|
||||
run: npm run check:cognitive-complexity
|
||||
- name: Type coverage ratchet
|
||||
run: npm run check:type-coverage
|
||||
- name: Compression budget ratchet (F2.4 / N4)
|
||||
@@ -290,11 +247,9 @@ jobs:
|
||||
quality-extended:
|
||||
name: Quality Gates (Extended)
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
# 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).
|
||||
# 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 }}
|
||||
steps:
|
||||
# fetch-depth: 0 — the OpenAPI breaking-change gate (oasdiff) reads the base
|
||||
# spec via `git show <base_ref>:docs/openapi.yaml`; a shallow clone
|
||||
@@ -303,7 +258,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
@@ -353,16 +308,8 @@ jobs:
|
||||
install -m 0755 /tmp/osv/*linux_amd64 "$HOME/.local/bin/osv-scanner"
|
||||
# actionlint — official download script
|
||||
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.
|
||||
# PINNED on purpose. Unpinned, the runner installed whatever PyPI served that day and
|
||||
# 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"
|
||||
# zizmor — PyPI (pipx preferred, pip --user fallback); lands in ~/.local/bin
|
||||
pipx install zizmor || pip install --user zizmor
|
||||
# oasdiff — download latest linux amd64 tarball via gh (authed), extract binary
|
||||
rm -rf /tmp/oasd && mkdir -p /tmp/oasd
|
||||
gh release download --repo oasdiff/oasdiff --pattern '*linux_amd64.tar.gz' --dir /tmp/oasd
|
||||
@@ -410,16 +357,14 @@ jobs:
|
||||
docs-sync-strict:
|
||||
name: Docs Sync (Strict)
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
# 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).
|
||||
# Run when docs OR code change: API/route code can break doc/OpenAPI contract gates.
|
||||
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && (needs.changes.outputs.docs == 'true' || needs.changes.outputs.code == 'true')) }}
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
@@ -434,20 +379,19 @@ jobs:
|
||||
run: npm run check:openapi-coverage
|
||||
- name: OpenAPI security-tier consistency (advisory)
|
||||
run: npm run check:openapi-security-tiers
|
||||
# One FS inventory of src/app/api for both anti-hallucination directions.
|
||||
- name: API docs refs (openapi + prose → routes)
|
||||
run: npm run check:api-docs-refs
|
||||
- name: OpenAPI spec paths resolve to real routes (anti-hallucination)
|
||||
run: npm run check:openapi-routes
|
||||
- name: Doc /api refs resolve to real routes (anti-hallucination)
|
||||
run: npm run check:docs-symbols
|
||||
- name: i18n translation drift (warn)
|
||||
run: node scripts/i18n/check-translation-drift.mjs --warn
|
||||
|
||||
docs-lint:
|
||||
name: Docs Lint (prose — advisory)
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
# 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).
|
||||
# Prose/markdown only — skip when the PR has no doc surface.
|
||||
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.docs == 'true') }}
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
||||
# Advisory (warning-first): prose/markdown style must not block merges while the
|
||||
# existing doc corpus is brought up to style. Promote to blocking once it converges.
|
||||
continue-on-error: true
|
||||
@@ -455,7 +399,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
@@ -474,54 +418,19 @@ jobs:
|
||||
i18n-ui-coverage:
|
||||
name: i18n UI Coverage
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
# 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).
|
||||
# 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 }}
|
||||
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
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- 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
|
||||
# a concorrência de jobs da conta (Free = 20 slots, compartilhados entre TODOS os repos)
|
||||
@@ -531,17 +440,15 @@ jobs:
|
||||
i18n:
|
||||
name: i18n Validation (all languages)
|
||||
runs-on: ubuntu-latest
|
||||
needs: changes
|
||||
# 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).
|
||||
# Message-catalog / i18n-tooling only — pure code without i18n surface skips this lane.
|
||||
if: ${{ github.event_name != 'pull_request' || (github.event.pull_request.draft == false && needs.changes.outputs.i18n == 'true') }}
|
||||
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.draft == false }}
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- name: Validate all languages
|
||||
@@ -579,11 +486,11 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
- 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
|
||||
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.
|
||||
@@ -618,7 +525,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
@@ -666,7 +573,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
@@ -686,26 +593,12 @@ jobs:
|
||||
- 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: 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:
|
||||
name: Electron Package Smoke (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
name: Electron Package Smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
needs: build
|
||||
# WS1.5 (v3.8.49 plan): the Electron rebuild/spawn 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 (the ABI rebuild + spawn plan) per release
|
||||
# PR; ubuntu keeps the full pack + headless smoke.
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
@@ -713,7 +606,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
@@ -731,21 +624,9 @@ jobs:
|
||||
working-directory: electron
|
||||
run: npm install --no-audit --no-fund
|
||||
- name: Pack Electron app
|
||||
if: runner.os == 'Linux'
|
||||
working-directory: electron
|
||||
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 ABI rebuild + spawn path)
|
||||
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
|
||||
if: runner.os == 'Linux'
|
||||
env:
|
||||
ELECTRON_SMOKE_TIMEOUT_MS: 60000
|
||||
run: xvfb-run -a npm run electron:smoke:packaged
|
||||
@@ -753,13 +634,7 @@ jobs:
|
||||
test-unit:
|
||||
name: Unit Tests (${{ matrix.shard }}/8)
|
||||
# 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
|
||||
# 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
|
||||
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' }}
|
||||
timeout-minutes: 25
|
||||
# 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
|
||||
@@ -780,7 +655,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
@@ -811,47 +686,10 @@ jobs:
|
||||
path: coverage-shard/*.json
|
||||
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:
|
||||
name: Vitest (MCP / autoCombo / UI components)
|
||||
# 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
|
||||
# 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
|
||||
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' }}
|
||||
timeout-minutes: 15
|
||||
# needs: changes (not build) — no artifact consumed; see test-unit note.
|
||||
needs: changes
|
||||
@@ -864,30 +702,19 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
# 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).
|
||||
# WS5.2/5.3 (v3.8.49 plan): JUnit output feeds Trunk Flaky Tests (advisory upload
|
||||
# below). node:test stays OUT of the first wave (fd1-sensitive reporter stream).
|
||||
- run: npm run test:vitest -- --reporter=default --reporter=junit --outputFile.junit=trunk-junit/vitest-mcp.xml
|
||||
# vitest:ui went back to 870/870 green in the v3.8.49 quality plan (WS6.1,
|
||||
# PR #7127 — 69 fails triaged: matchMedia polyfill, node:test→vitest migration,
|
||||
# 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) }}
|
||||
- run: npm run test:vitest
|
||||
# vitest:ui is RED today (14 fails — UI component drift accumulated while the
|
||||
# suite never ran in CI). Informational until the Fase 6A triage (2026-06-16+)
|
||||
# fixes the components/tests; then drop continue-on-error to make it blocking.
|
||||
- run: npm run test:vitest:ui
|
||||
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
|
||||
# (plano mestre testes+CI, Eixo D2 — they cost ~28% of every heavy run to catch a
|
||||
@@ -896,12 +723,9 @@ jobs:
|
||||
test-coverage:
|
||||
name: Coverage
|
||||
runs-on: ubuntu-latest
|
||||
# 10min was sized before #7114 added the lcov reporter (Codecov/Sonar need it);
|
||||
# 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
|
||||
timeout-minutes: 10
|
||||
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:
|
||||
JWT_SECRET: ci-test-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-test-api-key-secret-long
|
||||
@@ -909,7 +733,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
@@ -949,7 +773,6 @@ jobs:
|
||||
--merge-async \
|
||||
--reporter=text-summary \
|
||||
--reporter=json-summary \
|
||||
--reporter=lcov \
|
||||
--exclude=tests/** \
|
||||
--exclude=**/*.test.* \
|
||||
--check-coverage \
|
||||
@@ -971,18 +794,6 @@ jobs:
|
||||
> coverage/coverage-report.md
|
||||
fi
|
||||
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
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -1007,14 +818,10 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
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
|
||||
with:
|
||||
name: coverage-report
|
||||
path: coverage/
|
||||
path: .
|
||||
- name: Explain SonarQube skip
|
||||
if: ${{ github.event_name != 'pull_request' || env.SONAR_TOKEN == '' || env.SONAR_HOST_URL == '' }}
|
||||
run: |
|
||||
@@ -1133,12 +940,7 @@ jobs:
|
||||
# ~33%. Playwright browser is cached across runs (~1.5min saved per shard).
|
||||
# Heavy shard target: ≤20min (was ~40min). Timeout 45min to cover slow runners.
|
||||
timeout-minutes: 45
|
||||
needs: [build, changes]
|
||||
# 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') }}
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -1152,14 +954,14 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@v6
|
||||
uses: actions/cache@v6.1.0
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
@@ -1173,34 +975,7 @@ jobs:
|
||||
- name: Extract Next.js build artifact
|
||||
run: |
|
||||
tar -xzf /tmp/e2e-build.tar.gz
|
||||
# WS4.1: duration-balanced shards (LPT over config/quality/e2e-timings.json).
|
||||
# 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 }}
|
||||
- run: npx playwright test tests/e2e/*.spec.ts --shard=${{ matrix.shard }}/9
|
||||
|
||||
test-integration:
|
||||
name: Integration Tests (${{ matrix.shard }}/2)
|
||||
@@ -1223,16 +998,14 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- name: Integration tests (shard ${{ matrix.shard }}/2)
|
||||
env:
|
||||
TEST_SHARD: ${{ matrix.shard }}/2
|
||||
run: npm run test:integration:ci
|
||||
# (tsx/esm = QW-b; o alinhamento de ESCOPO do integration com o npm script fica p/ follow-up)
|
||||
- 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-security:
|
||||
name: Security Tests
|
||||
@@ -1248,7 +1021,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
@@ -1265,7 +1038,6 @@ jobs:
|
||||
- lint
|
||||
- docs-sync-strict
|
||||
- i18n-ui-coverage
|
||||
- i18n-glossary-zhcn
|
||||
- i18n
|
||||
- pr-test-policy
|
||||
- build
|
||||
@@ -1290,8 +1062,6 @@ jobs:
|
||||
- name: Generate dashboard
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
# Workflow-controlled data (job results), not user input — safe to read here.
|
||||
NEEDS_JSON: ${{ toJSON(needs) }}
|
||||
run: |
|
||||
status() {
|
||||
case "$1" in
|
||||
@@ -1306,29 +1076,6 @@ jobs:
|
||||
echo "# 🚀 CI Dashboard" >> "$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 "| Job | Status |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "|-----|--------|" >> "$GITHUB_STEP_SUMMARY"
|
||||
@@ -1336,7 +1083,6 @@ jobs:
|
||||
echo "| Lint | $(status '${{ needs.lint.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 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 "| SonarQube | $(status '${{ needs.sonarqube.result }}') |" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -19,13 +19,13 @@ jobs:
|
||||
actions: read
|
||||
contents: read
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
- uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
queries: security-extended
|
||||
- uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
- uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
|
||||
with:
|
||||
category: "/language:javascript-typescript"
|
||||
|
||||
31
.github/workflows/dast-smoke.yml
vendored
31
.github/workflows/dast-smoke.yml
vendored
@@ -2,45 +2,28 @@ name: DAST smoke (PR)
|
||||
on:
|
||||
pull_request:
|
||||
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:
|
||||
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:
|
||||
dast-smoke:
|
||||
runs-on: ubuntu-latest
|
||||
# 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.
|
||||
continue-on-error: true
|
||||
# Build CLI bundle alone varies 6-11min on GitHub-hosted runners (3 consecutive
|
||||
# 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
|
||||
timeout-minutes: 12
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-api-key-secret-with-sufficient-length-aaaa
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Build CLI bundle
|
||||
env:
|
||||
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
|
||||
run: npm run build:cli
|
||||
- name: Start OmniRoute
|
||||
env:
|
||||
@@ -53,20 +36,14 @@ jobs:
|
||||
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo up; break; fi
|
||||
sleep 2
|
||||
done
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
- run: pip install schemathesis
|
||||
- name: Schemathesis smoke (high-risk endpoints, blocking)
|
||||
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 \
|
||||
--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 \
|
||||
--request-timeout 20 --suppress-health-check all --no-color
|
||||
- name: promptfoo injection-guard (blocking)
|
||||
|
||||
54
.github/workflows/docker-publish.yml
vendored
54
.github/workflows/docker-publish.yml
vendored
@@ -4,7 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release/v*"
|
||||
tags:
|
||||
- "v*"
|
||||
paths-ignore:
|
||||
@@ -58,20 +57,39 @@ jobs:
|
||||
REF_TYPE: ${{ github.ref_type }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
PROMOTE_INPUT: ${{ inputs.promote_latest }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# 1) Resolve version/channel from the trigger. Only the current default
|
||||
# release branch publishes the mutable `next` channel; main keeps `main`.
|
||||
VERSION=$(bash scripts/ci/resolve-docker-publish-version.sh \
|
||||
"$EVENT_NAME" "$REF_TYPE" "$REF_NAME" "$INPUT_VERSION" "$DEFAULT_BRANCH")
|
||||
# 1) Resolve version string from the trigger (all inputs come via env).
|
||||
case "$EVENT_NAME" in
|
||||
workflow_dispatch)
|
||||
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"
|
||||
|
||||
# 2) Decide whether to promote :latest. Floating channels are never
|
||||
# eligible, and the helper independently fails closed for non-semver.
|
||||
# 2) Decide whether to promote :latest.
|
||||
PROMOTE="false"
|
||||
if [ "$VERSION" = "main" ] || [ "$VERSION" = "next" ]; then
|
||||
if [ "$VERSION" = "main" ]; then
|
||||
PROMOTE="false"
|
||||
elif printf '%s' "$VERSION" | grep -qE -- '-(rc|alpha|beta|pre|next)'; then
|
||||
echo "Pre-release identifier detected — skipping :latest."
|
||||
@@ -91,10 +109,10 @@ jobs:
|
||||
fi
|
||||
echo "promote_latest=$PROMOTE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# 3) Skip immutable version tags that already exist. Floating `main`
|
||||
# and `next` channels are intentionally rebuilt on every matching push.
|
||||
# 3) Skip if this exact version is already published in Docker Hub.
|
||||
# `main` is always rebuilt (mutable floating tag).
|
||||
SKIP="false"
|
||||
if [ "$VERSION" != "main" ] && [ "$VERSION" != "next" ]; then
|
||||
if [ "$VERSION" != "main" ]; 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."
|
||||
SKIP="true"
|
||||
@@ -137,13 +155,13 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4.6.0
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4.6.0
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -237,13 +255,13 @@ jobs:
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4.6.0
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4.6.0
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -372,14 +390,14 @@ jobs:
|
||||
- name: Upload Trivy SARIF to Security tab
|
||||
if: needs.prepare.outputs.version != 'main'
|
||||
continue-on-error: true
|
||||
uses: github/codeql-action/upload-sarif@v4.37.4
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: trivy-results.sarif
|
||||
category: trivy-image
|
||||
|
||||
- name: Update Docker Hub description
|
||||
# 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'
|
||||
uses: peter-evans/dockerhub-description@v5
|
||||
with:
|
||||
|
||||
98
.github/workflows/electron-release.yml
vendored
98
.github/workflows/electron-release.yml
vendored
@@ -88,7 +88,7 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
@@ -120,18 +120,6 @@ jobs:
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
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
|
||||
|
||||
- name: Sync version in electron/package.json
|
||||
@@ -213,12 +201,6 @@ jobs:
|
||||
[ -f "$file" ] && cp "$file" "../../release-assets/OmniRoute.exe" && break
|
||||
done
|
||||
fi
|
||||
# electron-updater manifests (latest.yml / latest-mac.yml / latest-linux.yml)
|
||||
# must be published alongside the installers, or autoUpdater fails with
|
||||
# "Cannot find latest.yml in the latest release artifacts" (#6766).
|
||||
for file in latest*.yml; do
|
||||
[ -f "$file" ] && cp "$file" ../../release-assets/
|
||||
done
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v7
|
||||
@@ -229,16 +211,6 @@ jobs:
|
||||
release:
|
||||
name: Create Release
|
||||
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
|
||||
permissions:
|
||||
contents: write # softprops/action-gh-release creates the GitHub Release
|
||||
@@ -249,33 +221,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
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
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
# 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/
|
||||
path: release-assets
|
||||
merge-multiple: true
|
||||
|
||||
- name: Create source archives
|
||||
env:
|
||||
@@ -313,53 +263,11 @@ jobs:
|
||||
release-assets/*.AppImage
|
||||
release-assets/*.deb
|
||||
release-assets/*.blockmap
|
||||
release-assets/*.yml
|
||||
release-assets/*.source.tar.gz
|
||||
release-assets/*.source.zip
|
||||
env:
|
||||
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:
|
||||
name: Publish to npm
|
||||
needs: [validate, release]
|
||||
|
||||
2
.github/workflows/mutation-redundancy.yml
vendored
2
.github/workflows/mutation-redundancy.yml
vendored
@@ -44,7 +44,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
17
.github/workflows/nightly-compat.yml
vendored
17
.github/workflows/nightly-compat.yml
vendored
@@ -66,25 +66,12 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.resolve-branch.outputs.target }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "26"
|
||||
cache: npm
|
||||
- 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
|
||||
env:
|
||||
OMNIROUTE_USE_TURBOPACK: "0"
|
||||
|
||||
compat-tests:
|
||||
name: Node ${{ matrix.node }} Compat Tests (${{ matrix.shard }}/4)
|
||||
@@ -106,7 +93,7 @@ jobs:
|
||||
with:
|
||||
ref: ${{ needs.resolve-branch.outputs.target }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
|
||||
14
.github/workflows/nightly-llm-security.yml
vendored
14
.github/workflows/nightly-llm-security.yml
vendored
@@ -15,13 +15,11 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with: { node-version: "24", cache: npm }
|
||||
- run: npm ci
|
||||
- name: Build CLI bundle
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
|
||||
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
|
||||
run: npm run build:cli
|
||||
- name: Start OmniRoute (block mode)
|
||||
env:
|
||||
@@ -67,16 +65,14 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
with: { node-version: "24", cache: npm }
|
||||
- run: npm ci
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
- name: Build CLI bundle
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
|
||||
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
|
||||
run: npm run build:cli
|
||||
- name: Start OmniRoute
|
||||
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
|
||||
sleep 2
|
||||
done
|
||||
- uses: actions/setup-python@v7
|
||||
- uses: actions/setup-python@v6
|
||||
if: steps.gate.outputs.run == 'true'
|
||||
with: { python-version: "3.12" }
|
||||
- run: pip install garak
|
||||
|
||||
6
.github/workflows/nightly-mutation.yml
vendored
6
.github/workflows/nightly-mutation.yml
vendored
@@ -107,7 +107,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
@@ -148,10 +148,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
- name: Download all mutation reports
|
||||
|
||||
2
.github/workflows/nightly-property.yml
vendored
2
.github/workflows/nightly-property.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
323
.github/workflows/nightly-release-green.yml
vendored
323
.github/workflows/nightly-release-green.yml
vendored
@@ -1,18 +1,11 @@
|
||||
name: Release-Green (continuous)
|
||||
name: Nightly Release-Green
|
||||
|
||||
# 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
|
||||
# accrue silently on release/** and explode — in layers — at release time. This
|
||||
# workflow reproduces the release-equivalent validation on the release branch 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).
|
||||
# nightly reproduces the release-equivalent validation on the active release branch
|
||||
# HEAD and, when there are HARD failures, opens/updates a single tracking issue.
|
||||
#
|
||||
# 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
|
||||
@@ -21,23 +14,8 @@ name: Release-Green (continuous)
|
||||
# package-artifact) flip the issue open.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["release/v*", "main"]
|
||||
paths:
|
||||
- "src/**"
|
||||
- "open-sse/**"
|
||||
- "bin/**"
|
||||
- "electron/**"
|
||||
- "scripts/**"
|
||||
- "tests/**"
|
||||
- "config/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "tsconfig*.json"
|
||||
schedule:
|
||||
- cron: "23 5 * * *" # full sweep — 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
|
||||
- cron: "23 5 * * *" # 05:23 UTC daily — off-peak, distinct from other nightlies
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
@@ -50,9 +28,7 @@ permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
# push storms during merge campaigns collapse to the newest commit per branch;
|
||||
# scheduled full sweeps keep their own single lane.
|
||||
group: release-green-${{ github.event_name }}-${{ github.ref }}
|
||||
group: nightly-release-green
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
@@ -61,9 +37,6 @@ env:
|
||||
jobs:
|
||||
release-green:
|
||||
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)
|
||||
# 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.
|
||||
@@ -83,15 +56,10 @@ jobs:
|
||||
id: branch
|
||||
env:
|
||||
INPUT_BRANCH: ${{ github.event.inputs.branch }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PUSHED_REF: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ -n "${INPUT_BRANCH:-}" ]; then
|
||||
TARGET="$INPUT_BRANCH"
|
||||
elif [ "$EVENT_NAME" = "push" ]; then
|
||||
# validate exactly what was pushed, not the highest branch
|
||||
TARGET="$PUSHED_REF"
|
||||
else
|
||||
# highest release/vX.Y.Z by semver among remote branches
|
||||
TARGET=$(git for-each-ref --format='%(refname:short)' 'refs/remotes/origin/release/v*' \
|
||||
@@ -116,7 +84,7 @@ jobs:
|
||||
git checkout "$TARGET"
|
||||
git log -1 --oneline
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
@@ -125,27 +93,11 @@ jobs:
|
||||
|
||||
- name: Release-green validation (full)
|
||||
id: validate
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
set +e
|
||||
# --hermetic: scrub live-test trigger vars (self-hosted runner may carry
|
||||
# operator env; hosted ignores the unknown flag before #6300 lands).
|
||||
# push → --quick: fast HARD gates only (~5-8min), per-merge signal.
|
||||
# schedule/dispatch → --with-build --full-ci: ALSO run every static gate from
|
||||
# ci.yml's gate jobs (lint, quality-gate, quality-extended, docs-sync-strict,
|
||||
# pr-test-policy) + build + full suites. PRs into release/** only get the
|
||||
# fast-gates, so these accrue silently and explode in layers on the release PR
|
||||
# (v3.8.46: 11 static base-reds leaked).
|
||||
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 \
|
||||
node scripts/quality/validate-release-green.mjs --json --with-build --hermetic \
|
||||
1> release-green.json 2> release-green.log
|
||||
echo "exit=$?" >> "$GITHUB_OUTPUT"
|
||||
echo "------- report -------"
|
||||
@@ -157,28 +109,15 @@ jobs:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TARGET: ${{ steps.branch.outputs.target }}
|
||||
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: |
|
||||
set -euo pipefail
|
||||
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 "originating PR branch (via co-authorship), not by demanding it from contributors."
|
||||
echo ""
|
||||
echo "**Run:** ${RUN_URL} (mode: ${EVENT_NAME})"
|
||||
# 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 "**Run:** ${RUN_URL}"
|
||||
echo ""
|
||||
echo '```'
|
||||
sed -n '/──────── verdict ────────/,$p' release-green.log || tail -40 release-green.log
|
||||
@@ -193,255 +132,15 @@ jobs:
|
||||
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
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body-file issue-body.md
|
||||
fi
|
||||
|
||||
- name: Upload report artifact
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-green-report
|
||||
path: |
|
||||
release-green.json
|
||||
release-green.log
|
||||
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
|
||||
|
||||
9
.github/workflows/nightly-resilience.yml
vendored
9
.github/workflows/nightly-resilience.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
@@ -51,7 +51,6 @@ jobs:
|
||||
- name: Build CLI bundle
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
|
||||
run: npm run build:cli
|
||||
- name: Start OmniRoute (background)
|
||||
env:
|
||||
@@ -95,7 +94,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: npm
|
||||
|
||||
8
.github/workflows/nightly-schemathesis.yml
vendored
8
.github/workflows/nightly-schemathesis.yml
vendored
@@ -16,13 +16,11 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with: { node-version: "24", cache: npm }
|
||||
- run: npm ci
|
||||
- name: Build CLI bundle
|
||||
env:
|
||||
JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation
|
||||
OMNIROUTE_BUILD_BACKEND_ONLY: "1"
|
||||
env: { JWT_SECRET: ci-build-secret-with-sufficient-length-for-validation }
|
||||
run: npm run build:cli
|
||||
- name: Start OmniRoute (background)
|
||||
env:
|
||||
@@ -35,7 +33,7 @@ jobs:
|
||||
if curl -sf http://localhost:20128/api/monitoring/health >/dev/null; then echo "server up"; break; fi
|
||||
sleep 2
|
||||
done
|
||||
- uses: actions/setup-python@v7
|
||||
- uses: actions/setup-python@v6
|
||||
with: { python-version: "3.12" }
|
||||
- name: Install schemathesis
|
||||
run: pip install schemathesis
|
||||
|
||||
157
.github/workflows/npm-publish.yml
vendored
157
.github/workflows/npm-publish.yml
vendored
@@ -22,14 +22,6 @@ on:
|
||||
- latest
|
||||
- next
|
||||
- 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:
|
||||
inputs:
|
||||
version:
|
||||
@@ -56,15 +48,8 @@ env:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
# Same dynamic-runner rule as ci.yml's `build`/`test-unit`: `build:cli` falls back to a
|
||||
# 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' }}
|
||||
runs-on: ubuntu-latest
|
||||
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)
|
||||
id-token: write # npm provenance
|
||||
packages: write # publish to npm.pkg.github.com
|
||||
@@ -78,7 +63,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
@@ -152,74 +137,6 @@ jobs:
|
||||
run: |
|
||||
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)
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
env:
|
||||
@@ -249,46 +166,8 @@ jobs:
|
||||
TAG: ${{ github.ref_name }}
|
||||
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
|
||||
# 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
|
||||
- name: Publish to npm
|
||||
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:
|
||||
VERSION: ${{ steps.resolve.outputs.version }}
|
||||
TAG: ${{ steps.resolve.outputs.tag }}
|
||||
@@ -296,32 +175,10 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Always pass --tag explicitly. Defense in depth: even if VERSION is
|
||||
# accidentally an older release, the historic tag will NOT claim `@latest`.
|
||||
npm stage publish --provenance --access public --tag "$TAG"
|
||||
{
|
||||
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
|
||||
# accidentally an older release, `npm publish --tag historic` will
|
||||
# NOT promote it to `@latest`.
|
||||
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
|
||||
if: steps.resolve.outputs.skip != 'true'
|
||||
@@ -352,7 +209,7 @@ jobs:
|
||||
# Full history needed for auto-bump: git diff against previous release tag
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NPM_PUBLISH_NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
4
.github/workflows/opencode-plugin-ci.yml
vendored
4
.github/workflows/opencode-plugin-ci.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
|
||||
4
.github/workflows/opencode-provider-ci.yml
vendored
4
.github/workflows/opencode-provider-ci.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "20"
|
||||
cache: npm
|
||||
|
||||
456
.github/workflows/quality.yml
vendored
456
.github/workflows/quality.yml
vendored
@@ -21,110 +21,8 @@ env:
|
||||
CI_NODE_VERSION: "24"
|
||||
|
||||
jobs:
|
||||
# Same classifier as ci.yml (scripts/quality/classify-pr-changes.mjs) so PR→release
|
||||
# path filters share existence reasons: code / docs / i18n / workflow.
|
||||
changes:
|
||||
name: Change Classification
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
code: ${{ steps.classify.outputs.code }}
|
||||
docs: ${{ steps.classify.outputs.docs }}
|
||||
i18n: ${{ steps.classify.outputs.i18n }}
|
||||
workflow: ${{ steps.classify.outputs.workflow }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
- id: classify
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
if [ "$EVENT_NAME" != "pull_request" ]; then
|
||||
{
|
||||
echo "code=true"
|
||||
echo "docs=true"
|
||||
echo "i18n=true"
|
||||
echo "workflow=true"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
git diff --name-only "$BASE_SHA" "$HEAD_SHA" > changed-files.txt
|
||||
node scripts/quality/classify-pr-changes.mjs changed-files.txt >> "$GITHUB_OUTPUT"
|
||||
|
||||
build:
|
||||
name: Build (advisory)
|
||||
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') }}
|
||||
# Dynamic runner — same fork-safe rule as ci.yml / fast-gates.
|
||||
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' }}
|
||||
# #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:
|
||||
- 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"
|
||||
# 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.
|
||||
# Split out of fast-gates so pure-docs PRs skip typecheck/unit while still validating docs.
|
||||
docs-gates:
|
||||
name: Docs Gates (fast-path)
|
||||
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')) }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# One walk of src/app/api for openapi-routes + docs-symbols (both still fail independently).
|
||||
- run: npm run check:api-docs-refs
|
||||
- name: Docs accuracy (fabricated-docs + i18n mirrors, strict)
|
||||
run: npm run check:docs-all
|
||||
|
||||
fast-gates:
|
||||
name: Fast Quality Gates
|
||||
needs: changes
|
||||
# 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') }}
|
||||
# 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
|
||||
# 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.
|
||||
# PINNED to hosted (gap 19). This job carried the USE_VPS_RUNNER expression, and that
|
||||
# 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
|
||||
# SQLite on load; provide DB env so a fresh CI DB initializes cleanly.
|
||||
@@ -137,246 +35,79 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Restore ESLint file cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||
restore-keys: |
|
||||
eslint-${{ runner.os }}-
|
||||
# Security scanners — same hardened install as ci.yml quality-extended
|
||||
# (gh release download = authenticated, 5000 req/hr; curl to api.github.com
|
||||
# is rate-limited to 60/hr and silently no-ops when throttled). The blocking
|
||||
# gates below SKIP (exit 0) when their binary is absent — only a measured
|
||||
# regression vs config/quality/quality-baseline.json blocks.
|
||||
- name: Install security scanners (gitleaks/osv/actionlint/zizmor/oasdiff)
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set +e
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
# Ratchets compare scanner COUNTS across runs. Pin every auditor: a rule-set
|
||||
# update must be an explicit PR that re-measures/rebaselines, never a random
|
||||
# red (or green) caused by whatever "latest" served that morning.
|
||||
GITLEAKS_VERSION=v8.30.1
|
||||
OSV_SCANNER_VERSION=v2.3.8
|
||||
ACTIONLINT_VERSION=v1.7.12
|
||||
ZIZMOR_VERSION=1.25.2
|
||||
OASDIFF_VERSION=v1.19.1
|
||||
# gitleaks — pinned linux x64 tarball via gh (authed), extract binary
|
||||
rm -rf /tmp/gl && mkdir -p /tmp/gl
|
||||
gh release download "$GITLEAKS_VERSION" --repo gitleaks/gitleaks --pattern '*linux_x64.tar.gz' --dir /tmp/gl
|
||||
tar -xzf /tmp/gl/*linux_x64.tar.gz -C "$HOME/.local/bin" gitleaks
|
||||
# osv-scanner — pinned linux amd64 bare binary via gh (authed)
|
||||
rm -rf /tmp/osv && mkdir -p /tmp/osv
|
||||
gh release download "$OSV_SCANNER_VERSION" --repo google/osv-scanner --pattern '*linux_amd64' --dir /tmp/osv
|
||||
install -m 0755 /tmp/osv/*linux_amd64 "$HOME/.local/bin/osv-scanner"
|
||||
# actionlint — official installer from a pinned release tag (never main)
|
||||
bash <(curl -fsSL "https://raw.githubusercontent.com/rhysd/actionlint/${ACTIONLINT_VERSION}/scripts/download-actionlint.bash") "$ACTIONLINT_VERSION" "$HOME/.local/bin"
|
||||
# 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
|
||||
- run: npm run check:provider-consistency
|
||||
- run: npm run check:fetch-targets
|
||||
- run: npm run check:openapi-routes
|
||||
- run: npm run check:docs-symbols
|
||||
- name: Docs accuracy (fabricated-docs + i18n mirrors, strict)
|
||||
run: npm run check:docs-all
|
||||
- run: npm run check:deps
|
||||
- run: npm run check:file-size
|
||||
- run: npm run check:error-helper
|
||||
- run: npm run check:migration-numbering
|
||||
- run: npm run check:public-creds
|
||||
- run: npm run check:db-rules
|
||||
- run: npm run check:known-symbols
|
||||
- run: npm run check:route-guard-membership
|
||||
- run: npm run check:test-discovery
|
||||
- run: npm run check:test-runner-api
|
||||
# Guards tap.testFiles drift: a covering unit test absent from stryker.conf.json
|
||||
# tap.testFiles makes its module's mutants survive on a cold nightly-mutation run,
|
||||
# false-failing the blocking mutationScore ratchet. See check-mutation-test-coverage.mjs.
|
||||
- run: npm run check:mutation-test-coverage
|
||||
- run: npm run check:any-budget:t11
|
||||
# Build-scope guard: fails if worktrees/cruft leak into the tsconfig include
|
||||
# scope (would OOM `next build`). Instant. See incident 2026-06-25 / #5031.
|
||||
- run: npm run check:build-scope
|
||||
# Pack-policy (unexpected-files allowlist) WITHOUT a build — catches a stray file
|
||||
# leaking into the npm tarball (v3.8.36: 6 ops bin/*.sh) per-PR instead of only on
|
||||
# the release PR's heavy Package Artifact job.
|
||||
- run: npm run check:pack-policy
|
||||
# Complexity + cognitive-complexity ratchets on the fast-path (PR→release) so
|
||||
# cycle drift is rebaselined PER-PR instead of cascading onto the release PR's
|
||||
# Quality Ratchet (v3.8.36: +30 complexity / +15 cognitive surfaced only post-merge).
|
||||
- run: npm run check:complexity
|
||||
- run: npm run check:cognitive-complexity
|
||||
- name: Typecheck (core)
|
||||
run: npm run typecheck:core
|
||||
# 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
|
||||
# selector returns __RUN_ALL__ — full-suite authority is the parallel
|
||||
# `fast-unit` 4-shard job (test:unit:ci:shard; was 2-shard, #6781), NOT an
|
||||
# unsharded re-run here. Stacking unsharded test:unit:ci on top of fast-unit
|
||||
# doubled wall time (~16 min extra on ubuntu-latest) without extra coverage.
|
||||
# unit tests impacted by this PR's changed files. Fail-safe runs the FULL
|
||||
# unit suite on hub/unmapped changes — TIA accelerates, never replaces, the net.
|
||||
#
|
||||
# BLOCKING for the *impacted subset* (flipped 2026-06-17). Fail-safe full
|
||||
# coverage remains required via `Unit Tests fast-path` (fast-unit).
|
||||
- name: Impacted unit tests (TIA subset; blocking)
|
||||
# BLOCKING (flipped 2026-06-17). The pre-existing release unit test-debt that kept
|
||||
# this advisory was cleared: #4030 (16 Zod/registry reds, lossless restore) and
|
||||
# #4063 (the last red — the LiveWS boot test — root-caused as a real event-loop
|
||||
# stall in the WS sidecar, fixed + relocated to the integration suite). A full
|
||||
# ci.yml run on release/v3.8.28 then showed all 8 unit shards green, so PR->release
|
||||
# now blocks on unit-test regressions in the impacted set (typecheck:core already
|
||||
# blocked above). Fail-safe still runs the FULL unit suite on hub/unmapped changes.
|
||||
- name: Impacted unit tests (TIA, fail-safe full; blocking)
|
||||
env:
|
||||
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
git fetch --no-tags origin "$GITHUB_BASE_REF" || true
|
||||
# The advisory sibling-test step generates the same map earlier in this job.
|
||||
[ -f config/quality/test-impact-map.json ] || node scripts/quality/build-test-impact-map.mjs
|
||||
node scripts/quality/build-test-impact-map.mjs
|
||||
SEL="$(node scripts/quality/select-impacted-tests.mjs)"
|
||||
# Shadow evidence (#8084): persist every selection so TIA false negatives can
|
||||
# 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
|
||||
if [ -z "$SEL" ]; then 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
|
||||
# job) rather than test:unit's local-tuned concurrency=20. Oversubscribing the
|
||||
# runner makes timing-sensitive tests (db-backup, upstream-timeout, ...) flake,
|
||||
# which must not happen on a blocking gate. DATA_DIR isolation keeps the parallel
|
||||
# run race-free regardless of concurrency.
|
||||
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 "Not re-running unsharded test:unit:ci here (duplicate of fast-unit coverage)."
|
||||
exit 0
|
||||
echo "Fail-safe: running FULL unit suite (CI concurrency)"; npm run test:unit:ci; exit $?
|
||||
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"
|
||||
mapfile -t FILES <<< "$SEL"
|
||||
# Loader parity with test:unit:ci:shard (#6787): tests/unit/dashboard/** runs
|
||||
# under `--import tsx` (CJS transform — required for ESM-only deep imports like
|
||||
# @lobehub/icons/es/* reached via lobeProviderIcons.ts); everything else under
|
||||
# `--import tsx/esm`. A single tsx/esm invocation false-reds every dashboard
|
||||
# module-shape test the impact map selects ("Unexpected token 'export'").
|
||||
DASH=(); REST=()
|
||||
for f in "${FILES[@]}"; do
|
||||
case "$f" in
|
||||
tests/unit/dashboard/*) DASH+=("$f") ;;
|
||||
*) REST+=("$f") ;;
|
||||
esac
|
||||
done
|
||||
RC=0
|
||||
if [ ${#REST[@]} -gt 0 ]; then
|
||||
node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${REST[@]}" || RC=$?
|
||||
fi
|
||||
if [ ${#DASH[@]} -gt 0 ]; then
|
||||
node --import tsx --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${DASH[@]}" || RC=$?
|
||||
fi
|
||||
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
|
||||
node --import tsx/esm --import ./open-sse/utils/setupPolyfill.ts --import ./tests/_setup/isolateDataDir.ts --test --test-force-exit --test-concurrency=4 "${FILES[@]}"
|
||||
|
||||
fast-vitest:
|
||||
name: Vitest (fast-path)
|
||||
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') }}
|
||||
# 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
|
||||
# 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:
|
||||
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
|
||||
@@ -386,44 +117,20 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
# WS5.2/5.3: JUnit feeds Trunk Flaky Tests — the fast-path runs on EVERY PR,
|
||||
# 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 }}
|
||||
- run: npm run test:vitest
|
||||
|
||||
fast-unit:
|
||||
name: Unit Tests fast-path (${{ matrix.shard }}/4)
|
||||
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') }}
|
||||
# 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
|
||||
# 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
|
||||
# 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
|
||||
# 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.
|
||||
name: Unit Tests fast-path (${{ matrix.shard }}/2)
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
shard: [1, 2]
|
||||
env:
|
||||
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
@@ -432,7 +139,7 @@ jobs:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
@@ -442,7 +149,7 @@ jobs:
|
||||
# silenciosamente não rodavam no fast path) e o setupPolyfill não era importado.
|
||||
- run: npm run test:unit:ci:shard
|
||||
env:
|
||||
TEST_SHARD: ${{ matrix.shard }}/4
|
||||
TEST_SHARD: ${{ matrix.shard }}/2
|
||||
|
||||
# ── Pacote 4 (plano mestre testes+CI, aprovado 2026-07-04) ─────────────────────────
|
||||
# No-new-warnings por PR via ESLint bulk suppressions nativo (>=9.24). O baseline
|
||||
@@ -458,60 +165,19 @@ jobs:
|
||||
# contribuidor NUNCA é bloqueado nem cobrado).
|
||||
lint-guard:
|
||||
name: No new ESLint warnings
|
||||
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') }}
|
||||
runs-on: ubuntu-latest
|
||||
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:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Restore ESLint file cache
|
||||
uses: actions/cache@v6
|
||||
with:
|
||||
path: |
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
key: eslint-${{ runner.os }}-${{ hashFiles('eslint.config.mjs', 'eslint.complexity-ratchets.config.mjs', 'config/quality/eslint-suppressions.json', 'package-lock.json') }}
|
||||
restore-keys: |
|
||||
eslint-${{ runner.os }}-
|
||||
- name: ESLint (baseline congelado — warning novo = vermelho)
|
||||
# lint:json writes the report; --max-warnings 0 keeps no-new-warnings policy.
|
||||
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
|
||||
run: npx eslint . --suppressions-location config/quality/eslint-suppressions.json --max-warnings 0
|
||||
|
||||
# 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
|
||||
@@ -526,8 +192,6 @@ jobs:
|
||||
# nunca é bloqueado.
|
||||
merge-integrity:
|
||||
name: Merge integrity (changelog + generated skills)
|
||||
# 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/')) }}
|
||||
runs-on: ubuntu-latest
|
||||
continue-on-error: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork == true }}
|
||||
env:
|
||||
@@ -535,11 +199,11 @@ jobs:
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
|
||||
2
.github/workflows/scorecard.yml
vendored
2
.github/workflows/scorecard.yml
vendored
@@ -26,7 +26,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run analysis
|
||||
uses: ossf/scorecard-action@v2.4.4
|
||||
uses: ossf/scorecard-action@v2.4.3
|
||||
with:
|
||||
results_file: results.sarif
|
||||
results_format: sarif
|
||||
|
||||
8
.github/workflows/semgrep.yml
vendored
8
.github/workflows/semgrep.yml
vendored
@@ -6,19 +6,13 @@ on:
|
||||
branches: ["main"]
|
||||
permissions:
|
||||
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:
|
||||
semgrep:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: semgrep/semgrep
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run semgrep (advisory)
|
||||
|
||||
2
.github/workflows/wiki-sync.yml
vendored
2
.github/workflows/wiki-sync.yml
vendored
@@ -40,7 +40,7 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
|
||||
|
||||
72
.gitignore
vendored
72
.gitignore
vendored
@@ -1,7 +1,6 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# project-specific directories
|
||||
.slim/deepwork/
|
||||
.omnivscodeagent/
|
||||
omnirouteCloud/
|
||||
omnirouteSite/
|
||||
@@ -18,7 +17,7 @@ _tasks/
|
||||
.logs/**
|
||||
.tests/**
|
||||
.coverage/**
|
||||
/coverage/
|
||||
coverage/
|
||||
.dist/**
|
||||
.next/**
|
||||
.build/**
|
||||
@@ -44,7 +43,6 @@ memory-bank/
|
||||
|
||||
# Root-level underscore-prefixed directories (private/draft — never commit)
|
||||
/_*/
|
||||
/_*
|
||||
|
||||
# Draft features documentation (internal only)
|
||||
docs/new-features/
|
||||
@@ -58,6 +56,10 @@ node_modules/
|
||||
*.map
|
||||
.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/
|
||||
|
||||
@@ -70,8 +72,6 @@ yarn-error.log*
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
!.env.homolog.example
|
||||
!.env.devin-bridge.example
|
||||
# Provider API keys (never commit)
|
||||
*.api-key
|
||||
.nvidia-api-key
|
||||
@@ -84,7 +84,7 @@ yarn-error.log*
|
||||
next-env.d.ts
|
||||
|
||||
# data and logs
|
||||
/data/
|
||||
data/
|
||||
.data/
|
||||
logs/*
|
||||
test_output.log
|
||||
@@ -106,7 +106,7 @@ open-sse/test/*
|
||||
test-results/
|
||||
playwright-report/
|
||||
blob-report/
|
||||
/cloud/
|
||||
cloud/
|
||||
.tmp/
|
||||
|
||||
# Security Analysis (standalone project with own git)
|
||||
@@ -120,8 +120,6 @@ app.log
|
||||
deploy.sh
|
||||
docker-compose.minimal.yml
|
||||
|
||||
# Docker Compose override (local-only, never commit)
|
||||
docker-compose.override.yml
|
||||
|
||||
# Backup directories
|
||||
app.__qa_backup/
|
||||
@@ -157,7 +155,6 @@ vscode-extension/
|
||||
|
||||
# Empty/dangling files
|
||||
typescript
|
||||
/MAX
|
||||
|
||||
# Gemini Antigravity agent data
|
||||
.gemini/
|
||||
@@ -173,6 +170,7 @@ config/quality/test-impact-map.json
|
||||
# GitNexus local index
|
||||
.gitnexus
|
||||
.worktrees
|
||||
bin/omniroute.mjs
|
||||
|
||||
# Consistent with .dockerignore / .npmignore
|
||||
.omc/
|
||||
@@ -201,16 +199,13 @@ scripts/i18n/_pending-keys.json
|
||||
.claude/worktrees/
|
||||
.codegraph/
|
||||
|
||||
# Test executable shims belong in the OS temporary directory, not the repository root
|
||||
/.fakebin-*/
|
||||
|
||||
# Fumadocs generated source
|
||||
.source/
|
||||
|
||||
# AI agent local settings and configs
|
||||
.agents/
|
||||
.antigravitycli/
|
||||
/.claude/
|
||||
.claude/
|
||||
|
||||
# PR Reviews and local feedback files
|
||||
pr_reviews*.json
|
||||
@@ -225,26 +220,6 @@ CODEX-SETUP-PROMPT.md
|
||||
# Quality ratchet — métricas efêmeras (baseline commitado em config/quality/; métricas não)
|
||||
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)
|
||||
/logs/
|
||||
-home-diegosouzapw-dev-automações-bots-yt-downloader-20260504 .txt
|
||||
@@ -257,31 +232,4 @@ omniroute.md
|
||||
|
||||
# mise configuration
|
||||
mise.toml
|
||||
_artifacts/ # release-green artifacts
|
||||
.claude-flow/
|
||||
|
||||
# ESLint file cache (npm run lint --cache / complexity ratchets)
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
/.eslintcache-*
|
||||
|
||||
|
||||
# CI/local quality artifacts (eslint-results.json, quality-ratchet.md, etc.)
|
||||
.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
|
||||
_artifacts/
|
||||
|
||||
@@ -74,23 +74,3 @@
|
||||
# '''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''',
|
||||
]
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
# .husky/pre-push — intentionally light.
|
||||
# any-budget + tracked-artifacts already run on pre-commit; re-running them on
|
||||
# every push only doubles local wall time for the same existence reason (CI still
|
||||
# enforces both). Keep this hook as a PATH/npm sanity check + reminder.
|
||||
# Intentionally excludes test:unit / typecheck (slow; covered by CI).
|
||||
# .husky/pre-push — fast deterministic gates (<10s total)
|
||||
# Intentionally excludes test:unit (slow; covered by CI pre-push remote run).
|
||||
# Activated: 2026-06-13 (6A.12 — replaced commented-out test:unit stub)
|
||||
|
||||
if ! command -v npm >/dev/null 2>&1; then
|
||||
echo "⚠️ npm not found in PATH — skipping pre-push hooks"
|
||||
@@ -11,5 +9,4 @@ if ! command -v npm >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# No-op success: real local gates live in pre-commit; CI owns the rest.
|
||||
exit 0
|
||||
npm run check:any-budget:t11 && npm run check:tracked-artifacts
|
||||
|
||||
74
.mergify.yml
74
.mergify.yml
@@ -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
|
||||
19
.npmignore
19
.npmignore
@@ -4,14 +4,11 @@ data/
|
||||
**/db.json
|
||||
|
||||
# VS Code extension test runtime (large binary, not needed in npm package)
|
||||
app/vscode-extension/
|
||||
**/data/
|
||||
**/db.json
|
||||
|
||||
# Source code (pre-built dist/ 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.
|
||||
# Source code (pre-built app/ is published instead)
|
||||
#
|
||||
# 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
|
||||
@@ -52,6 +49,8 @@ scripts/
|
||||
.vscode/
|
||||
.agents/
|
||||
.env*
|
||||
app/.env
|
||||
app/.env*
|
||||
eslint.config.mjs
|
||||
prettier.config.mjs
|
||||
postcss.config.mjs
|
||||
@@ -83,6 +82,8 @@ bun.lock
|
||||
*.deb
|
||||
*.rpm
|
||||
electron/
|
||||
app/electron/
|
||||
app/vscode-extension/
|
||||
|
||||
# Subprojects
|
||||
clipr/
|
||||
@@ -92,12 +93,12 @@ vscode-extension/
|
||||
|
||||
# Root-level underscore-prefixed directories (private/draft — never publish)
|
||||
/_*/
|
||||
app/_*/
|
||||
app/coverage/
|
||||
app/logs/
|
||||
app/tests/
|
||||
|
||||
# Consistent with .gitignore and .dockerignore
|
||||
.claude/
|
||||
.fakebin-*
|
||||
.eslintcache*
|
||||
_tasks/
|
||||
.DS_Store
|
||||
.idea/
|
||||
.config/
|
||||
|
||||
@@ -1,16 +1,2 @@
|
||||
# Long reference tables are manually aligned; formatting the whole file causes noisy diffs.
|
||||
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.
|
||||
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
|
||||
|
||||
8
.source/dynamic.ts
Normal file
8
.source/dynamic.ts
Normal 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
22
.source/source.config.mjs
Normal 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
|
||||
};
|
||||
10
.vscode/settings.json
vendored
10
.vscode/settings.json
vendored
@@ -48,19 +48,11 @@
|
||||
"**/.build",
|
||||
"**/dist",
|
||||
"**/coverage",
|
||||
"**/.worktrees",
|
||||
"**/.claude/worktrees",
|
||||
"**/electron",
|
||||
"**/_references",
|
||||
"**/_mono_repo",
|
||||
"**/_tasks"
|
||||
"**/.worktrees"
|
||||
]
|
||||
},
|
||||
|
||||
// Para esconder os diretórios gerados da árvore do Explorer, descomente:
|
||||
// (MANTIDO comentado — o dono precisa ver _references/_mono_repo/_tasks na árvore.
|
||||
// A performance é resolvida por watcherExclude + search.exclude + tsserver, sem
|
||||
// precisar escondê-los do Explorer.)
|
||||
// "files.exclude": {
|
||||
// "**/.worktrees": true,
|
||||
// "**/coverage": true,
|
||||
|
||||
@@ -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
|
||||
- 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
|
||||
- Exposing a force-refresh path (`omniroute_sync_models` tool + `/omni-sync` command template) equivalent to Pi `/omni sync`
|
||||
- Re-fetching on a configurable TTL (default 5 min), so new models / combo changes in the OmniRoute UI appear without restarting OpenCode
|
||||
- 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)
|
||||
|
||||
@@ -30,7 +29,7 @@ omniroute setup opencode --auth
|
||||
# 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:
|
||||
|
||||
```sh
|
||||
@@ -74,9 +73,6 @@ Peer dep: `@opencode-ai/plugin` (managed by your OpenCode install).
|
||||
{
|
||||
"providerId": "omniroute",
|
||||
"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
|
||||
opencode auth login --provider opencode-omniroute
|
||||
opencode auth login --provider omniroute
|
||||
# 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.
|
||||
|
||||
### 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)
|
||||
|
||||
> ⚠ 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:
|
||||
|
||||
```sh
|
||||
opencode auth login --provider opencode-omniroute
|
||||
opencode auth login --provider opencode-omniroute-preprod
|
||||
opencode auth login --provider omniroute
|
||||
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.
|
||||
@@ -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` |
|
||||
| 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/<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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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` |
|
||||
| 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 |
|
||||
@@ -205,35 +179,30 @@ npm install --prefix ~/.config/opencode/plugins/omniroute-opencode-plugin-prepro
|
||||
|
||||
## Plugin options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
| --------------------- | -------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| `providerId` | `string` | `"omniroute"` | OpenCode provider id; must be unique across plugin entries |
|
||||
| `displayName` | `string` | `"OmniRoute"` or `OmniRoute (<id>)` | Label in the OC UI |
|
||||
| `modelCacheTtl` | `number` | `300000` (5 min) | `/v1/models` TTL in ms |
|
||||
| `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) |
|
||||
|
||||
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.
|
||||
| Option | Type | Default | Description |
|
||||
| --------------- | -------- | ------------------------------------------ | ---------------------------------------------------------- |
|
||||
| `providerId` | `string` | `"omniroute"` | OpenCode provider id; must be unique across plugin entries |
|
||||
| `displayName` | `string` | `"OmniRoute"` or `OmniRoute (<id>)` | Label in the OC UI |
|
||||
| `modelCacheTtl` | `number` | `300000` (5 min) | `/v1/models` TTL in ms |
|
||||
| `baseURL` | `string` | resolved from `auth.json` after `/connect` | Override OmniRoute base URL |
|
||||
| `features` | `object` | see below | Feature toggles (all opt-in/out, defaults preserve v0.1.0) |
|
||||
|
||||
### `features` block
|
||||
|
||||
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 |
|
||||
| --------------------- | --------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `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). |
|
||||
| `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). |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` |
|
||||
| `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 |
|
||||
| `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) |
|
||||
| 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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `geminiSanitization` | `boolean` | `true` | Strip `$schema`/`$ref`/`additionalProperties` from tool params when the model id matches `gemini` |
|
||||
| `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 |
|
||||
| `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
|
||||
|
||||
@@ -245,7 +214,6 @@ Every field is optional. Defaults mirror v0.1.0 behaviour so existing `opencode.
|
||||
{
|
||||
"providerId": "omniroute",
|
||||
"baseURL": "https://or.example.com",
|
||||
"managementReadToken": "<read-only-management-token>",
|
||||
"features": {
|
||||
"combos": 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.
|
||||
- `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)
|
||||
|
||||
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).
|
||||
## Comparison vs `@omniroute/opencode-provider`
|
||||
|
||||
[`@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.
|
||||
|
||||
|
||||
4
@omniroute/opencode-plugin/package-lock.json
generated
4
@omniroute/opencode-plugin/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@omniroute/opencode-plugin",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@omniroute/opencode-plugin",
|
||||
"version": "0.2.1",
|
||||
"version": "0.2.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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.",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
@@ -23,7 +23,7 @@
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"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",
|
||||
"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",
|
||||
"prepublishOnly": "npm run clean && npm run build && npm test"
|
||||
},
|
||||
"keywords": [
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,130 +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";
|
||||
|
||||
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: 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);
|
||||
});
|
||||
@@ -447,14 +447,14 @@ test("models() returns combo entries merged into the map", async () => {
|
||||
|
||||
// 3 raw models + 1 combo = 4 entries
|
||||
assert.equal(Object.keys(out).length, 4);
|
||||
assert.ok(out["omniroute/claude-primary"]);
|
||||
assert.ok(out["omniroute/claude-secondary"]);
|
||||
assert.ok(out["omniroute/gemini-3-flash"]);
|
||||
assert.ok(out["omniroute/claude-tier"]);
|
||||
assert.ok(out["opencode-omniroute/claude-primary"]);
|
||||
assert.ok(out["opencode-omniroute/claude-secondary"]);
|
||||
assert.ok(out["opencode-omniroute/gemini-3-flash"]);
|
||||
assert.ok(out["opencode-omniroute/claude-tier"]);
|
||||
|
||||
const combo = out["omniroute/claude-tier"];
|
||||
const combo = out["opencode-omniroute/claude-tier"];
|
||||
assert.equal(combo.name, "Claude Tier");
|
||||
assert.equal(combo.providerID, "omniroute");
|
||||
assert.equal(combo.providerID, "opencode-omniroute");
|
||||
// LCD over claude-primary (200k, reasoning) + claude-secondary (100k, no reasoning)
|
||||
assert.equal(combo.limit.context, 100_000);
|
||||
assert.equal(combo.capabilities.reasoning, false);
|
||||
@@ -478,11 +478,11 @@ test("models(): combo with unknown member ids degrades to all-false LCD posture"
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.ok(out["omniroute/phantom-combo"]);
|
||||
assert.ok(out["opencode-omniroute/phantom-combo"]);
|
||||
// With zero resolvable members, LCD = all-false (defensive posture).
|
||||
assert.equal(out["omniroute/phantom-combo"].capabilities.toolcall, false);
|
||||
assert.equal(out["omniroute/phantom-combo"].capabilities.reasoning, false);
|
||||
assert.equal(out["omniroute/phantom-combo"].limit.context, 0);
|
||||
assert.equal(out["opencode-omniroute/phantom-combo"].capabilities.toolcall, false);
|
||||
assert.equal(out["opencode-omniroute/phantom-combo"].capabilities.reasoning, false);
|
||||
assert.equal(out["opencode-omniroute/phantom-combo"].limit.context, 0);
|
||||
});
|
||||
|
||||
test("models(): hidden combos are excluded from the map", async () => {
|
||||
@@ -505,8 +505,8 @@ test("models(): hidden combos are excluded from the map", async () => {
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.ok(out["omniroute/visible"]);
|
||||
assert.ok(!out["omniroute/hidden"], "hidden combo must be omitted");
|
||||
assert.ok(out["opencode-omniroute/visible"]);
|
||||
assert.ok(!out["opencode-omniroute/hidden"], "hidden combo must be omitted");
|
||||
});
|
||||
|
||||
test("models(): combo name exactly matches raw model id → raw deleted, raw deleted, no warn", async () => {
|
||||
@@ -530,8 +530,8 @@ test("models(): combo name exactly matches raw model id → raw deleted, raw del
|
||||
});
|
||||
|
||||
// Raw model replaced by combo of the same key; combo now lives at the bare slug.
|
||||
assert.ok(out["omniroute/claude-primary"], "combo surfaces under prefixed key");
|
||||
assert.equal(out["omniroute/claude-primary"].name, "claude-primary");
|
||||
assert.ok(out["opencode-omniroute/claude-primary"], "combo surfaces under prefixed key");
|
||||
assert.equal(out["opencode-omniroute/claude-primary"].name, "claude-primary");
|
||||
|
||||
// No collision warning fires — dedup makes keys disjoint.
|
||||
const collisionWarns = warnings.filter((w) => {
|
||||
@@ -565,8 +565,8 @@ test("models(): two combos with same slug → second gets disambiguator suffix",
|
||||
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
// First combo gets the bare slug; second gets disambiguated.
|
||||
assert.ok(out["omniroute/claude"], "first combo at prefixed slug");
|
||||
assert.ok(out["omniroute/claude-uuid"], "second combo disambiguated by id prefix");
|
||||
assert.ok(out["opencode-omniroute/claude"], "first combo at prefixed slug");
|
||||
assert.ok(out["opencode-omniroute/claude-uuid"], "second combo disambiguated by id prefix");
|
||||
});
|
||||
|
||||
test("models(): combos fetch fails → falls back to models-only, warn emitted, no throw", async () => {
|
||||
@@ -583,8 +583,8 @@ test("models(): combos fetch fails → falls back to models-only, warn emitted,
|
||||
|
||||
// Catalog includes the models but NOT any combo entries.
|
||||
assert.equal(Object.keys(out).length, 2);
|
||||
assert.ok(out["omniroute/claude-primary"]);
|
||||
assert.ok(out["omniroute/claude-secondary"]);
|
||||
assert.ok(out["opencode-omniroute/claude-primary"]);
|
||||
assert.ok(out["opencode-omniroute/claude-secondary"]);
|
||||
|
||||
// Soft-fail warning surfaced.
|
||||
const softFail = warnings.find((w) => {
|
||||
@@ -609,7 +609,7 @@ test("models(): combos cached + reused within TTL (one combo fetch per TTL windo
|
||||
const second = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
assert.equal(combosFetcher.callCount(), 1, "combos fetched only once within TTL");
|
||||
assert.equal(modelsFetcher.callCount(), 1, "models fetched only once within TTL");
|
||||
assert.ok(second["omniroute/claude-tier"]);
|
||||
assert.ok(second["opencode-omniroute/claude-tier"]);
|
||||
});
|
||||
|
||||
test("models(): combos refetched after TTL expiry (same key as models)", async () => {
|
||||
@@ -701,7 +701,7 @@ test("models(): nested combo-ref context is the min of nested + raw members", as
|
||||
{ fetcher: modelsFetcher, combosFetcher }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-z") as never });
|
||||
const masterLight = out["omniroute/master-light"];
|
||||
const masterLight = out["opencode-omniroute/master-light"];
|
||||
assert.ok(masterLight, "MASTER-LIGHT entry must exist");
|
||||
assert.equal(
|
||||
masterLight.limit.context,
|
||||
|
||||
@@ -33,7 +33,6 @@ import {
|
||||
createOmniRouteProviderHook,
|
||||
OmniRoutePlugin,
|
||||
resolveOmniRoutePluginOptions,
|
||||
_resetInflightRefresh,
|
||||
type OmniRouteCombosFetcher,
|
||||
type OmniRouteEnrichmentEntry,
|
||||
type OmniRouteEnrichmentFetcher,
|
||||
@@ -48,16 +47,6 @@ import {
|
||||
type OmniRouteStaticProviderEntry,
|
||||
} 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
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -238,7 +227,7 @@ test("config: with valid auth.json + apiKey + baseURL → mutates input.provider
|
||||
// Stripped per-model shape: name + cap flags + modalities + (optional)
|
||||
// cost. OC's SDK static schema accepts only `limit.{context,output}` —
|
||||
// `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.equal(claude.name, "claude-sonnet-4-6");
|
||||
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
|
||||
// (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.equal(combo.name, "Claude Tier");
|
||||
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);
|
||||
const ids = Object.keys(entry.models).sort();
|
||||
assert.deepEqual(ids, [
|
||||
"claude-sonnet-4-6",
|
||||
"gemini-3-flash",
|
||||
"opencode-omniroute/claude-sonnet-4-6",
|
||||
"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(
|
||||
logger.entries.some((e) => String(e[0]).includes("/api/combos fetch failed")),
|
||||
"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.
|
||||
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.attachment, "boolean");
|
||||
assert.equal(typeof claude.reasoning, "boolean");
|
||||
@@ -759,8 +748,8 @@ test("buildStaticProviderEntry: hidden combos are excluded", () => {
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
assert.equal(block.models["claude-tier"], undefined);
|
||||
assert.ok(block.models["claude-sonnet-4-6"]);
|
||||
assert.equal(block.models["opencode-omniroute/claude-tier"], undefined);
|
||||
assert.ok(block.models["opencode-omniroute/claude-sonnet-4-6"]);
|
||||
});
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
@@ -776,7 +765,7 @@ test("buildStaticProviderEntry: emits modalities.input from raw.input_modalities
|
||||
"https://or.example/v1",
|
||||
"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?.output, ["text"]);
|
||||
});
|
||||
@@ -790,7 +779,7 @@ test("buildStaticProviderEntry: never emits limit.input (OC SDK rejects it)", ()
|
||||
"https://or.example/v1",
|
||||
"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(typeof claude.limit?.context, "number");
|
||||
assert.equal(typeof claude.limit?.output, "number");
|
||||
@@ -818,7 +807,7 @@ test("buildStaticProviderEntry: emits cost when enrichment carries pricing", ()
|
||||
"sk-test",
|
||||
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?.output, 15);
|
||||
assert.equal(claude.cost?.cache_read, 0.3);
|
||||
@@ -839,8 +828,8 @@ test("buildStaticProviderEntry: emits release_date when raw carries it; omits wh
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
assert.equal(block.models["claude-with-date"].release_date, "2026-02-19");
|
||||
assert.equal(block.models["gemini-3-flash"].release_date, undefined);
|
||||
assert.equal(block.models["opencode-omniroute/claude-with-date"].release_date, "2026-02-19");
|
||||
assert.equal(block.models["opencode-omniroute/gemini-3-flash"].release_date, undefined);
|
||||
});
|
||||
|
||||
test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)", () => {
|
||||
@@ -869,7 +858,7 @@ test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
const combo = block.models["mixed-tier"];
|
||||
const combo = block.models["opencode-omniroute/mixed-tier"];
|
||||
assert.ok(combo, "combo emitted under slug key");
|
||||
// claude has text+image, text-only has text → intersection drops image.
|
||||
assert.deepEqual(combo.modalities?.input, ["text"]);
|
||||
@@ -978,10 +967,10 @@ test("config: enrichment fetched + name overlaid on raw-model entries", async ()
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(entry);
|
||||
assert.equal(entry.models["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/claude-sonnet-4-6"].name, "Claude Sonnet 4.6");
|
||||
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.
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -1011,7 +1000,7 @@ test("config: features.enrichment=false skips enrichment fetch + keeps raw-id na
|
||||
assert.ok(entry);
|
||||
assert.equal(enrichmentFetcher.callCount(), 0, "enrichment fetch suppressed by feature flag");
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id retained"
|
||||
);
|
||||
@@ -1038,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.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id retained"
|
||||
);
|
||||
@@ -1240,20 +1229,17 @@ test("config: diskCache hydrates stale snapshot when /v1/models throws", async (
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.ok(
|
||||
entry.models["claude-sonnet-4-6"],
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"],
|
||||
"stale snapshot hydrated into static block"
|
||||
);
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude Sonnet 4.6 (cached)",
|
||||
"stale enrichment also reused"
|
||||
);
|
||||
assert.equal(writes, 0, "disk write skipped when live fetch failed");
|
||||
assert.ok(
|
||||
logger.entries.some((e) =>
|
||||
String(e[0]).includes("using stale disk cache") ||
|
||||
String(e[0]).includes("warm startup from disk snapshot")
|
||||
),
|
||||
logger.entries.some((e) => String(e[0]).includes("using stale disk cache")),
|
||||
"disk-cache hydration breadcrumb emitted"
|
||||
);
|
||||
});
|
||||
@@ -1295,7 +1281,7 @@ test("config: cached rawEnrichment from earlier provider hook is reused (no refe
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"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");
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -1346,12 +1332,12 @@ test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-
|
||||
];
|
||||
assert.ok(entry);
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"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.
|
||||
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 () => {
|
||||
@@ -1378,7 +1364,7 @@ test("config: providerTag=false suppresses the suffix", async () => {
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entry.models["claude-sonnet-4-6"].name,
|
||||
entry.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude Sonnet 4.6",
|
||||
"enriched name kept, provider tag suppressed"
|
||||
);
|
||||
@@ -1410,7 +1396,7 @@ test("config: providerTag falls back to UPPER(alias) when providerDisplayName mi
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"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 () => {
|
||||
@@ -1437,7 +1423,7 @@ test("config: providerTag skipped entirely when neither providerDisplayName nor
|
||||
const entry = (input as { provider: Record<string, OmniRouteStaticProviderEntry> }).provider[
|
||||
"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 () => {
|
||||
@@ -1465,7 +1451,7 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entryA.models["claude-sonnet-4-6"].name,
|
||||
entryA.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
|
||||
@@ -1476,7 +1462,7 @@ test("config: providerTag is idempotent — second hook call doesn't double-suff
|
||||
"opencode-omniroute"
|
||||
];
|
||||
assert.equal(
|
||||
entryB.models["claude-sonnet-4-6"].name,
|
||||
entryB.models["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"Claude - Claude Sonnet 4.6"
|
||||
);
|
||||
});
|
||||
@@ -1530,7 +1516,7 @@ test("buildStaticProviderEntry: nested combo-ref context is the bottleneck acros
|
||||
);
|
||||
// Pre-fix: Parent would advertise 200_000 (only raw-big counted).
|
||||
// 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.equal(parent.limit?.context, 8_000);
|
||||
});
|
||||
|
||||
@@ -40,7 +40,7 @@ test("defaultDiskSnapshotWriter writes an owner-only (no group/other) snapshot",
|
||||
process.env.OPENCODE_DATA_DIR = tmp;
|
||||
|
||||
try {
|
||||
await defaultDiskSnapshotWriter("perm-test", makeEntry(), "test-snapshot-identity");
|
||||
await defaultDiskSnapshotWriter("perm-test", makeEntry());
|
||||
|
||||
const file = diskSnapshotPath("perm-test");
|
||||
assert.ok(fs.existsSync(file), "snapshot file should be written");
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -376,8 +376,7 @@ test("provider hook: enrichment fetcher called when features.enrichment !== fals
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
|
||||
assert.equal(called, 1, "enrichment fetcher called once");
|
||||
// #6859: dynamic-hook catalog keys use the unprefixed omnirouteProviderId.
|
||||
const m = out["omniroute/claude-sonnet-4-6"];
|
||||
const m = out["opencode-omniroute/claude-sonnet-4-6"];
|
||||
assert.equal(m.name, "Claude Sonnet 4.6", "enrichment name overlay applied");
|
||||
assert.equal(m.cost.input, 3, "enrichment pricing applied");
|
||||
assert.equal(m.cost.output, 15);
|
||||
@@ -403,7 +402,7 @@ test("provider hook: enrichment fetcher NOT called when features.enrichment:fals
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
|
||||
assert.equal(called, 0, "enrichment fetcher NOT called when gated off");
|
||||
assert.equal(
|
||||
out["omniroute/claude-sonnet-4-6"].name,
|
||||
out["opencode-omniroute/claude-sonnet-4-6"].name,
|
||||
"claude-sonnet-4-6",
|
||||
"raw id preserved"
|
||||
);
|
||||
@@ -464,7 +463,7 @@ test("provider hook: compression metadata fetcher called when opted in", async (
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk") as never });
|
||||
assert.equal(called, 1, "compression metadata fetcher called");
|
||||
const combo = out["omniroute/claude-primary"];
|
||||
const combo = out["opencode-omniroute/claude-primary"];
|
||||
assert.ok(combo, "combo entry present");
|
||||
assert.match(
|
||||
combo.name,
|
||||
|
||||
@@ -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 () => {
|
||||
const { calls, restore } = installFetchRecorder();
|
||||
try {
|
||||
@@ -305,7 +259,7 @@ test("loader integration: wired interceptor actually injects Bearer when invoked
|
||||
{} as never
|
||||
);
|
||||
const wiredFetch = (result as { fetch: typeof fetch }).fetch;
|
||||
await wiredFetch(`${BASE}/models`, {});
|
||||
await wiredFetch(`${BASE}/v1/models`, {});
|
||||
assert.equal(calls.length, 1);
|
||||
const sentHeaders = new Headers((calls[0]!.init as RequestInit).headers);
|
||||
assert.equal(sentHeaders.get("Authorization"), `Bearer ${KEY}`);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* Regression test for #6859.
|
||||
*
|
||||
* `resolveOmniRoutePluginOptions()` auto-prefixes `providerId` with
|
||||
* `"opencode-"` (commit 75b52e286) so OpenCode 1.17.8+'s native-adapter gate
|
||||
* accepts it as an OC-registered provider id. That prefixed value must stay
|
||||
* OC-internal (AuthHook.provider / provider registration keys) — it must
|
||||
* NEVER leak into the identifiers OmniRoute's own server parses to resolve
|
||||
* credentials (`mapRawModelToModelV2`'s `id`/`providerID`,
|
||||
* `mapComboToModelV2`'s `providerID`, and the dynamic-hook catalog keys).
|
||||
*
|
||||
* OmniRoute's server-side `parseModel()` (open-sse/services/model.ts) splits
|
||||
* a dispatched model string on `/` to recover the provider name and look up
|
||||
* credentials. If the plugin embeds the OC-gate-prefixed id in that string,
|
||||
* the server looks up credentials for a provider named "opencode-omniroute"
|
||||
* (which never exists in `src/shared/constants/providers.ts`) instead of
|
||||
* "omniroute" — producing the exact "No credentials for opencode-omniroute" /
|
||||
* "No active credentials for provider: opencode-omniroute" errors reported
|
||||
* in #6859.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildStaticProviderEntry,
|
||||
createOmniRouteProviderHook,
|
||||
mapRawModelToModelV2,
|
||||
resolveOmniRoutePluginOptions,
|
||||
type OmniRouteRawCombo,
|
||||
} from "../src/index.js";
|
||||
|
||||
/**
|
||||
* Minimal stand-in for OmniRoute's own `parseModel()` (open-sse/services/
|
||||
* model.ts), which splits a dispatched `<providerID>/<modelID>` string on the
|
||||
* FIRST "/" to recover the provider name used for credential lookup. Kept
|
||||
* local (rather than cross-importing the real module) so this package's
|
||||
* self-contained test suite (`cd @omniroute/opencode-plugin && npm test`)
|
||||
* doesn't depend on the root repo's `@/*` path-alias resolution.
|
||||
*/
|
||||
function splitProviderFromDispatchedModel(modelStr: string): string {
|
||||
const idx = modelStr.indexOf("/");
|
||||
return idx === -1 ? modelStr : modelStr.slice(0, idx);
|
||||
}
|
||||
|
||||
const apiAuth = (key: string) => ({ type: "api" as const, key });
|
||||
|
||||
test("#6859: server-facing model id/providerID must resolve to the unprefixed provider name", () => {
|
||||
const resolved = resolveOmniRoutePluginOptions();
|
||||
|
||||
// The OC-gate-compatible id stays prefixed — it is legitimate for
|
||||
// AuthHook.provider / provider registration.
|
||||
assert.equal(resolved.providerId, "opencode-omniroute");
|
||||
|
||||
// A second, unprefixed id must be exposed for anything that reaches
|
||||
// OmniRoute's own server (model id prefix, ModelV2.providerID, combo keys).
|
||||
assert.equal(
|
||||
resolved.omnirouteProviderId,
|
||||
"omniroute",
|
||||
"resolveOmniRoutePluginOptions() must expose an unprefixed omnirouteProviderId"
|
||||
);
|
||||
|
||||
// A bare raw /v1/models entry (no existing "/" in its id — the common
|
||||
// case for OmniRoute's catalog) mapped with the server-facing id.
|
||||
const model = mapRawModelToModelV2(
|
||||
{ id: "claude-opus-4-7" },
|
||||
{ providerId: resolved.omnirouteProviderId, baseURL: "http://localhost:20128" }
|
||||
);
|
||||
|
||||
assert.equal(model.providerID, "omniroute");
|
||||
assert.equal(model.id, "omniroute/claude-opus-4-7");
|
||||
|
||||
// OpenCode dispatches back to OmniRoute using `providerID/modelKey`
|
||||
// (matches the issue's own repro: `-m opencode-omniroute/oc/big-pickle`).
|
||||
const dispatchedModelString = `${model.providerID}/claude-opus-4-7`;
|
||||
const parsedProvider = splitProviderFromDispatchedModel(dispatchedModelString);
|
||||
|
||||
assert.equal(
|
||||
parsedProvider,
|
||||
"omniroute",
|
||||
`server-side provider split resolved '${parsedProvider}', expected 'omniroute' — ` +
|
||||
`credentials lookup would fail for an OC-gate-prefixed provider id`
|
||||
);
|
||||
});
|
||||
|
||||
test("#6859: createOmniRouteProviderHook end-to-end — catalog keys/providerID never carry the OC-gate prefix", async () => {
|
||||
const hook = createOmniRouteProviderHook(
|
||||
{ baseURL: "https://or.example.com/v1" },
|
||||
{
|
||||
fetcher: async () => [{ id: "claude-opus-4-7" }],
|
||||
combosFetcher: async () => [],
|
||||
}
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-test") as never });
|
||||
const model = out["omniroute/claude-opus-4-7"];
|
||||
assert.ok(model, "catalog keyed under the unprefixed provider name");
|
||||
assert.equal(model.providerID, "omniroute");
|
||||
assert.ok(
|
||||
!model.providerID.startsWith("opencode-"),
|
||||
"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"
|
||||
);
|
||||
});
|
||||
@@ -101,10 +101,7 @@ test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it
|
||||
assert.equal(fetcher.callCount(), 1);
|
||||
assert.deepEqual(fetcher.callsBy()[0], ["https://or.example.com/v1", "sk-abc"]);
|
||||
assert.equal(Object.keys(out).length, 3);
|
||||
// #6859: dynamic-hook catalog keys use the unprefixed omnirouteProviderId
|
||||
// ("omniroute"), not the OC-gate-prefixed hook.id ("opencode-omniroute") —
|
||||
// that prefix must never leak into anything OmniRoute's server parses.
|
||||
assert.ok(out["omniroute/claude-primary"]);
|
||||
assert.ok(out["opencode-omniroute/claude-primary"]);
|
||||
});
|
||||
|
||||
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
|
||||
@@ -155,17 +152,13 @@ test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => {
|
||||
{ fetcher, combosFetcher: async () => [] }
|
||||
);
|
||||
const out = await hook.models!({} as never, { auth: apiAuth("sk-abc") as never });
|
||||
// #6859: dynamic-hook catalog keys/ids/providerID use the unprefixed
|
||||
// omnirouteProviderId ("omniroute") — the OC-gate prefix ("opencode-")
|
||||
// must stay OC-internal (hook.id / AuthHook.provider) and never leak into
|
||||
// anything OmniRoute's own server parses for credential lookup.
|
||||
const claude = out["omniroute/claude-primary"];
|
||||
const claude = out["opencode-omniroute/claude-primary"];
|
||||
assert.ok(claude, "claude-primary present");
|
||||
// `mapRawModelToModelV2` stamps the provider prefix on the id so OC's
|
||||
// static-catalog reader resolves `(providerID, modelID)` from the key.
|
||||
assert.equal(claude.id, "omniroute/claude-primary");
|
||||
assert.equal(claude.id, "opencode-omniroute/claude-primary");
|
||||
assert.equal(claude.name, "claude-primary");
|
||||
assert.equal(claude.providerID, "omniroute");
|
||||
assert.equal(claude.providerID, "opencode-omniroute");
|
||||
assert.equal(claude.api.id, "openai-compatible");
|
||||
assert.equal(claude.api.url, "https://or.example.com/v1");
|
||||
assert.equal(claude.api.npm, "@ai-sdk/openai-compatible");
|
||||
|
||||
@@ -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)");
|
||||
});
|
||||
3224
CHANGELOG.md
3224
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
575
CLAUDE.md
575
CLAUDE.md
@@ -1,42 +1,406 @@
|
||||
# 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
|
||||
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`.
|
||||
## Quick Start
|
||||
|
||||
## 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
|
||||
path, `cp -al` node_modules, teardown rules) is in `AGENTS.md` → Git Workflow → "Worktree
|
||||
isolation". Claude-Code-specific points:
|
||||
### Running Tests
|
||||
|
||||
- Confirm the base branch with the operator via `AskUserQuestion` (Hard Rule #19) unless they
|
||||
already told you.
|
||||
- Prefer the native `EnterWorktree` tool — it already creates worktrees under
|
||||
`.claude/worktrees/` (the canonical path). Create the worktree with the documented `git
|
||||
worktree add` command, then call `EnterWorktree` with its `path`.
|
||||
```bash
|
||||
# Single test file (Node.js native test runner — most tests)
|
||||
node --import tsx/esm --test tests/unit/your-file.test.ts
|
||||
|
||||
## 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
|
||||
harness:
|
||||
# All suites
|
||||
npm run test:all
|
||||
```
|
||||
|
||||
- **Replicate the `git stash` ban verbatim in the prompt of every subagent that touches git**
|
||||
(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.
|
||||
For full test matrix, see `CONTRIBUTING.md` → "Running Tests". For deep architecture, see `AGENTS.md`.
|
||||
|
||||
## Superpowers / planning artifacts — path overrides
|
||||
---
|
||||
|
||||
The `_tasks/` convention is defined in `AGENTS.md` → "Planning & Research Artifacts". The
|
||||
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/…`",
|
||||
rewrite it to the `_tasks/…` equivalent before writing:
|
||||
## Project at a Glance
|
||||
|
||||
**OmniRoute** — unified AI proxy/router. One endpoint, 237 LLM providers, auto-fallback.
|
||||
|
||||
| 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`): 17 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). 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` = warn in `open-sse/` and `tests/`
|
||||
- **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, 17 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 |
|
||||
| ---------------------------------- | ------------------------- | ------------------------------------------------------------- |
|
||||
@@ -45,11 +409,154 @@ rewrite it to the `_tasks/…` equivalent before writing:
|
||||
| Research (`deep-research`, ad-hoc) | `docs/research/` | `_tasks/research/…` |
|
||||
| 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 →
|
||||
"Base-green check"; project skills reference it as `.agents/skills/_shared/base-green.md`). A PR
|
||||
opened while the base tip is red must carry `⚠️ base-red inherited: #<issue>` in its body. To
|
||||
drain an accumulated red state (base tip + red PRs), use the `/sweep-reds` skill.
|
||||
```bash
|
||||
# Never commit directly to main
|
||||
git checkout -b feat/your-feature
|
||||
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`
|
||||
- **pre-push**: fast deterministic gates (`check:any-budget:t11` + `check:tracked-artifacts`); intentionally excludes `test:unit` (slow — covered by the CI `test-unit` job). Activated 2026-06-13 (Quality Gates Fase 6A.12).
|
||||
|
||||
### 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-issues`, `/review-prs`, `/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.
|
||||
|
||||
@@ -2,11 +2,6 @@
|
||||
|
||||
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
|
||||
@@ -15,12 +10,6 @@ coverage, and reconciliation steps.
|
||||
|
||||
- **Node.js** `>=22.22.3 <23`, or `>=24.0.0 <27` (recommended: 24 LTS)
|
||||
- **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**
|
||||
|
||||
### Clone & Install
|
||||
@@ -114,19 +103,13 @@ Default URLs:
|
||||
## Git Workflow
|
||||
|
||||
> ⚠️ **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
|
||||
# Branch from the active release tip (example: release/v3.8.49)
|
||||
git fetch origin
|
||||
git checkout -b feat/your-feature-name origin/release/v3.8.49
|
||||
git checkout -b feat/your-feature-name
|
||||
# ... make changes ...
|
||||
git commit -m "feat: describe your change"
|
||||
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
|
||||
@@ -209,15 +192,11 @@ Coverage notes:
|
||||
|
||||
### Pull Request Requirements
|
||||
|
||||
Before opening a PR, use the
|
||||
[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):
|
||||
Before opening or merging a PR:
|
||||
|
||||
- Run the test files that cover your change: `node --import tsx/esm --test tests/unit/<file>.test.ts`
|
||||
- Run `npm run lint`
|
||||
- Include or update automated tests in the same PR whenever production code changes
|
||||
- Run `npm run test:unit`
|
||||
- Run `npm run test:coverage`
|
||||
- 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
|
||||
- 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
|
||||
- **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
|
||||
@@ -283,7 +237,7 @@ src/ # TypeScript (.ts / .tsx)
|
||||
│ ├── a2a/ # Agent-to-Agent v0.3 protocol server
|
||||
│ ├── acp/ # Agent Communication Protocol registry
|
||||
│ ├── compliance/ # Compliance policy engine
|
||||
│ ├── db/ # SQLite domain modules + 130 migrations
|
||||
│ ├── db/ # SQLite database layer (21 modules + 16 migrations)
|
||||
│ ├── memory/ # Persistent conversational memory
|
||||
│ ├── oauth/ # OAuth providers, services, and utilities
|
||||
│ ├── skills/ # Extensible skill framework
|
||||
@@ -293,16 +247,16 @@ src/ # TypeScript (.ts / .tsx)
|
||||
├── mitm/ # MITM proxy (cert, DNS, target routing)
|
||||
├── shared/
|
||||
│ ├── 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
|
||||
│ └── validation/ # Zod v4 schemas
|
||||
└── sse/ # SSE proxy pipeline
|
||||
|
||||
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.)
|
||||
├── mcp-server/ # MCP server (107 unique tools, 3 transports, 32 scopes)
|
||||
├── services/ # 178 top-level services (combo, autoCombo, rateLimitManager, etc.)
|
||||
├── mcp-server/ # MCP server (25 tools, 3 transports, 10 scopes)
|
||||
├── services/ # 36+ services (combo, autoCombo, rateLimitManager, etc.)
|
||||
├── translator/ # Format translators (OpenAI ↔ Claude ↔ Gemini ↔ Responses ↔ Ollama)
|
||||
├── transformer/ # Responses API transformer
|
||||
└── utils/ # 22 utility modules (stream, TLS, proxy, logging)
|
||||
@@ -387,7 +341,7 @@ Write unit tests in `tests/unit/` covering at minimum:
|
||||
- [ ] Error responses route through `buildErrorBody()` / `sanitizeErrorMessage()` — no raw stack traces in response bodies (see [`docs/security/ERROR_SANITIZATION.md`](./docs/security/ERROR_SANITIZATION.md))
|
||||
- [ ] Shell commands (`exec` / `spawn`) pass runtime values via `env`, not via string interpolation
|
||||
- [ ] All inputs validated with Zod schemas
|
||||
- [ ] Changelog **fragment** added under `changelog.d/{features|fixes|maintenance}/<PR>-<slug>.md` for user-facing changes (see [`changelog.d/README.md`](./changelog.d/README.md)) — do **not** edit `CHANGELOG.md` directly; fragments are aggregated at release time and never conflict between PRs
|
||||
- [ ] CHANGELOG updated (if user-facing change)
|
||||
- [ ] Documentation updated (if applicable)
|
||||
- [ ] No new CodeQL / Secret-Scanning alerts opened, or each one dismissed with technical justification referencing the relevant `docs/security/` doc
|
||||
- [ ] Routes that spawn child processes (`/api/mcp/`, `/api/cli-tools/runtime/`) classified as `isLocalOnlyPath()` in `src/server/authz/routeGuard.ts` — see [Hard Rule #15](docs/security/ROUTE_GUARD_TIERS.md)
|
||||
|
||||
128
Dockerfile
128
Dockerfile
@@ -1,5 +1,5 @@
|
||||
# ── Common base with runtime deps ──────────────────────────────────────────
|
||||
FROM node:26-trixie-slim AS base
|
||||
FROM node:24-trixie-slim AS base
|
||||
WORKDIR /app
|
||||
|
||||
# `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
|
||||
# (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.
|
||||
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
|
||||
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=shared \
|
||||
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=shared \
|
||||
apt-get update \
|
||||
&& apt-get upgrade -y \
|
||||
&& apt-get install -y --no-install-recommends libsecret-1-0 ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# npm's *bundled* node_modules (brace-expansion, ip-address, tar, undici) are
|
||||
# npm's own internals — not application dependencies (the app resolves its own,
|
||||
# already-fixed copies) — but the container scanner reads them off
|
||||
# /usr/local/lib/node_modules/npm/node_modules and reports 9 HIGH/MEDIUM CVEs.
|
||||
#
|
||||
# Refreshing npm does NOT fix them. Measured on npm@12.0.2 (2026-08-12, latest):
|
||||
# brace-expansion 5.0.7 (needs >= 5.0.9) CVE-2026-69152, CVE-2026-14257
|
||||
# ip-address 10.2.0 (needs >= 10.3.1) CVE-2026-69192/-69198/-54272
|
||||
# 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
|
||||
# Refresh the globally-installed npm so its *bundled* node_modules (undici, tar)
|
||||
# ship the patched versions. These are npm's own internals — not application
|
||||
# dependencies (our app already resolves undici@8.5.0 / tar@7.5.16, both fixed) —
|
||||
# but the container scanner flags the stale copies under
|
||||
# /usr/local/lib/node_modules/npm/node_modules. npm is not invoked at runtime in
|
||||
# the runner stages, so this is hygiene, not an exploitable runtime path.
|
||||
RUN npm install -g npm@latest \
|
||||
&& npm cache clean --force
|
||||
|
||||
# ── Builder ────────────────────────────────────────────────────────────────
|
||||
FROM base AS builder
|
||||
|
||||
# Build tools for native module compilation
|
||||
# apt-get update needed here because base's rm -rf clears the shared cache
|
||||
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
|
||||
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=shared \
|
||||
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=shared \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends python3 make g++ \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -87,35 +55,10 @@ ENV NPM_CONFIG_LEGACY_PEER_DEPS=true
|
||||
# are reproducible.
|
||||
RUN test -f package-lock.json \
|
||||
|| (echo "package-lock.json is required for reproducible Docker builds" >&2 && exit 1)
|
||||
# `npm rebuild <pkg>` re-runs the package's own install script, so under npm 11 +
|
||||
# `--ignore-scripts` on the parent `npm ci` it depends on npm's script-allowlist
|
||||
# machinery correctly re-enabling that one package's script. Some self-hosted build
|
||||
# environments (e.g. Dokploy) hit a broken/incomplete better-sqlite3 native binding
|
||||
# from that indirection. Invoking `node-gyp rebuild` directly inside the package
|
||||
# directory bypasses npm's script-running layer entirely and is deterministic
|
||||
# regardless of npm version or ignore-scripts allowlist behavior.
|
||||
# node-gyp comes from npm's own bundled copy (deterministic, already in the image)
|
||||
# instead of `npx --yes`, which would install an arbitrary registry version
|
||||
# on-demand and run its lifecycle scripts (Sonar docker:S6505).
|
||||
#
|
||||
# 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=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))
|
||||
npm ci --no-audit --no-fund --legacy-peer-deps --ignore-scripts \
|
||||
&& npm rebuild better-sqlite3 \
|
||||
&& node -e "require('better-sqlite3')(':memory:').close()"
|
||||
|
||||
# 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
|
||||
@@ -125,25 +68,7 @@ RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
|
||||
# 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.
|
||||
# See docs/ops/QUALITY_GATE_PLAYBOOK.md Parte 6.
|
||||
#
|
||||
# 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
|
||||
# access), so keep @/mitm/manager on the graceful stub (#3390). This flag is
|
||||
# Docker-only: npm/Electron/VPS builds must bundle the REAL manager (#6344).
|
||||
ENV OMNIROUTE_MITM_STUB=1
|
||||
ENV OMNIROUTE_USE_TURBOPACK=1
|
||||
|
||||
# Raise the V8 heap ceiling for the build. The webpack production optimization
|
||||
# pass needs more than V8's default ceiling (~2 GB) for a codebase this size; a
|
||||
@@ -159,9 +84,7 @@ ENV NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_BUILD_MEMORY_MB}"
|
||||
|
||||
COPY . ./
|
||||
RUN --mount=type=cache,id=next-cache,target=/app/.build/next/cache \
|
||||
mkdir -p /app/data \
|
||||
&& 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);"
|
||||
mkdir -p /app/data && npm run build
|
||||
|
||||
# ── Runner base ────────────────────────────────────────────────────────────
|
||||
FROM base AS runner-base
|
||||
@@ -175,12 +98,6 @@ LABEL org.opencontainers.image.title="omniroute" \
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=20128
|
||||
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 NODE_OPTIONS="--max-old-space-size=${OMNIROUTE_MEMORY_MB}"
|
||||
|
||||
@@ -221,8 +138,8 @@ EXPOSE 20128
|
||||
USER node
|
||||
|
||||
# Warns if the mounted data volume has wrong ownership
|
||||
COPY --chmod=755 scripts/check-permissions.sh /app/check-permissions.sh
|
||||
ENTRYPOINT ["/app/check-permissions.sh"]
|
||||
COPY --chmod=755 scripts/check-permissions.sh /tmp/check-permissions.sh
|
||||
ENTRYPOINT ["/tmp/check-permissions.sh"]
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
||||
CMD ["node", "healthcheck.mjs"]
|
||||
@@ -278,11 +195,6 @@ FROM runner-base AS runner-cli
|
||||
# runner-base runs.
|
||||
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).
|
||||
RUN --mount=type=cache,id=apt-cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,id=apt-lists,target=/var/lib/apt/lists,sharing=locked \
|
||||
|
||||
57
GEMINI.md
57
GEMINI.md
@@ -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
|
||||
> [`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.
|
||||
> **Scope:** rules for Gemini-based agents. For Claude Code, see `CLAUDE.md`. For other AI assistants, see `AGENTS.md`.
|
||||
|
||||
Gemini-specific notes:
|
||||
## 1. File Placement & Organization
|
||||
|
||||
- Skills activate via the `activate_skill` tool (skill metadata is loaded at session start and
|
||||
the full content is activated on demand).
|
||||
- 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.
|
||||
- **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 (`/`).
|
||||
- **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.
|
||||
|
||||
**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.
|
||||
|
||||
69
Makefile
69
Makefile
@@ -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
|
||||
96
ROADMAP.md
96
ROADMAP.md
@@ -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.
|
||||
26
SECURITY.md
26
SECURITY.md
@@ -42,13 +42,13 @@ Request → CORS → Authz pipeline (classify → policies → enforce)
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Dashboard Login** | Password-based auth with JWT tokens (HttpOnly cookies) |
|
||||
| **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 |
|
||||
| **Secure Cookies** | `AUTH_COOKIE_SECURE=true` for HTTPS environments |
|
||||
| **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` |
|
||||
| **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
|
||||
|
||||
@@ -77,28 +77,21 @@ Custom guardrails register via `registerGuardrail(new MyGuardrail())`. The model
|
||||
|
||||
### 🧠 Prompt Injection Guard
|
||||
|
||||
Best-effort heuristic middleware that detects prompt injection patterns 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).
|
||||
Middleware that detects and blocks prompt injection attacks in LLM requests:
|
||||
|
||||
| Pattern Type | Severity | Example |
|
||||
| ------------------- | -------- | ---------------------------------------------- |
|
||||
| System Override | High | "ignore all previous instructions" |
|
||||
| Role Hijack | Medium | "you are now DAN, you can do anything" |
|
||||
| Delimiter Injection | High | Encoded separators to break context boundaries |
|
||||
| DAN/Jailbreak | Medium | Known jailbreak prompt patterns |
|
||||
| Instruction Leak | High | "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`.
|
||||
| Role Hijack | High | "you are now DAN, you can do anything" |
|
||||
| Delimiter Injection | Medium | Encoded separators to break context boundaries |
|
||||
| DAN/Jailbreak | High | Known jailbreak prompt patterns |
|
||||
| Instruction Leak | Medium | "show me your system prompt" |
|
||||
|
||||
Configure via dashboard (Settings → Security) or `.env`:
|
||||
|
||||
```env
|
||||
INPUT_SANITIZER_ENABLED=true
|
||||
INPUT_SANITIZER_MODE=block # warn | block (injection policy; legacy "redact" does not strip injection text)
|
||||
INPUT_SANITIZER_BLOCK_THRESHOLD=high # high (default) | medium | low — severities at/above this are blocked in block mode
|
||||
INPUT_SANITIZER_MODE=block # warn | block | redact
|
||||
```
|
||||
|
||||
### 🔒 PII Redaction
|
||||
@@ -115,8 +108,7 @@ Automatic detection and optional redaction of personally identifiable informatio
|
||||
| SSN (US) | `123-45-6789` | `[SSN_REDACTED]` |
|
||||
|
||||
```env
|
||||
PII_REDACTION_ENABLED=true # request PII rewrite; independent of INPUT_SANITIZER_MODE
|
||||
PII_RESPONSE_SANITIZATION=true # optional: redact PII in provider responses returned to clients
|
||||
PII_REDACTION_ENABLED=true
|
||||
```
|
||||
|
||||
### 🌐 Network Security
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -136,7 +136,7 @@ export const RETRY_DEFAULTS = {
|
||||
|
||||
- Every user-facing string goes through `t("module.key", vars)`.
|
||||
- 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.
|
||||
- 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`.
|
||||
|
||||
@@ -22,9 +22,9 @@ bin/cli/
|
||||
├── provider-test.mjs ← testProviderApiKey()
|
||||
├── settings-store.mjs ← DB CRUD for key_value settings
|
||||
├── 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
|
||||
│ └── {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/
|
||||
│ └── generate-locales.mjs ← scaffold new locale files from config/i18n.json
|
||||
└── commands/
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -78,15 +78,18 @@ export function registerBackup(program) {
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
|
||||
// Legacy: `omniroute backup` without a 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.
|
||||
// Legacy: `omniroute backup` without subcommand still creates a backup
|
||||
backup.action(async (opts) => {
|
||||
const exitCode = await runBackupCommand(opts);
|
||||
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) {
|
||||
|
||||
@@ -15,11 +15,7 @@ function readCache() {
|
||||
try {
|
||||
const raw = JSON.parse(readFileSync(cachePath(), "utf8"));
|
||||
if (raw && typeof raw.ts === "number" && Date.now() - raw.ts < CACHE_TTL_MS) return raw;
|
||||
} catch (err) {
|
||||
if (process.env.OMNIROUTE_DEBUG_COMPLETION) {
|
||||
console.error("[omniroute completion] readCache failed:", err?.message ?? err);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -45,20 +41,12 @@ async function refreshCache(opts = {}) {
|
||||
const j = await mr.value.json();
|
||||
models = (Array.isArray(j) ? j : j.data || []).map((m) => m.id).filter(Boolean);
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.OMNIROUTE_DEBUG_COMPLETION) {
|
||||
console.error("[omniroute completion] refreshCache failed:", err?.message ?? err);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
const data = { combos, providers, models, ts: Date.now() };
|
||||
try {
|
||||
mkdirSync(dirname(cachePath()), { recursive: true });
|
||||
writeFileSync(cachePath(), JSON.stringify(data));
|
||||
} catch (err) {
|
||||
if (process.env.OMNIROUTE_DEBUG_COMPLETION) {
|
||||
console.error("[omniroute completion] writeCache failed:", err?.message ?? err);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ async function restCompressionStatus() {
|
||||
const combosBody = combosRes.ok ? await combosRes.json() : { combos: [] };
|
||||
const analytics = analyticsRes && analyticsRes.ok ? await analyticsRes.json() : null;
|
||||
return {
|
||||
strategy: settings.defaultMode || "standard",
|
||||
engine: settings.engine ?? null,
|
||||
settings,
|
||||
combos: combosBody.combos ?? combosBody,
|
||||
analytics,
|
||||
@@ -33,10 +33,7 @@ async function restCompressionStatus() {
|
||||
|
||||
async function restCompressionConfigure(config) {
|
||||
const body = { ...config };
|
||||
if (body.strategy) {
|
||||
body.defaultMode = body.strategy === "caveman" ? "standard" : normalizeEngine(body.strategy);
|
||||
delete body.strategy;
|
||||
}
|
||||
if (body.engine) body.engine = normalizeEngine(body.engine);
|
||||
const res = await apiFetch("/api/settings/compression", { method: "PUT", body });
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
@@ -46,10 +43,9 @@ async function restCompressionConfigure(config) {
|
||||
}
|
||||
|
||||
async function restSetEngine(name) {
|
||||
const normalized = normalizeEngine(name);
|
||||
const res = await apiFetch("/api/settings/compression", {
|
||||
method: "PUT",
|
||||
body: { defaultMode: normalized === "caveman" ? "standard" : normalized },
|
||||
body: { engine: normalizeEngine(name) },
|
||||
});
|
||||
if (!res.ok) {
|
||||
process.stderr.write(`Error: ${res.status}\n`);
|
||||
@@ -107,11 +103,7 @@ export async function runCompressionStatus(opts, cmd) {
|
||||
|
||||
export async function runCompressionConfigure(opts, cmd) {
|
||||
const config = {};
|
||||
// #6571 — both the MCP tool schema (compressionConfigureInput) and
|
||||
// handleCompressionConfigure expect `strategy`, not `engine`; a non-strict
|
||||
// MCP schema silently strips an unrecognized `engine` key on the primary
|
||||
// (MCP-mounted) path, so this must be `strategy` on both paths.
|
||||
if (opts.engine) config.strategy = normalizeEngine(opts.engine);
|
||||
if (opts.engine) config.engine = opts.engine;
|
||||
if (opts.cavemanAggressiveness !== undefined)
|
||||
config.caveman = { aggressiveness: opts.cavemanAggressiveness };
|
||||
if (opts.rtkBudget !== undefined) config.rtk = { tokenBudget: opts.rtkBudget };
|
||||
@@ -171,7 +163,7 @@ export function registerCompression(program) {
|
||||
engine.command("set <name>").action(runCompressionEngineSet);
|
||||
engine.command("get").action(async (opts, cmd) => {
|
||||
const data = await mcpCall("omniroute_compression_status", {}, restCompressionStatus);
|
||||
process.stdout.write(`${data.strategy ?? "(default)"}\n`);
|
||||
process.stdout.write(`${data.engine ?? "(default)"}\n`);
|
||||
});
|
||||
|
||||
const combos = cmp.command("combos").description(t("compression.combos.description"));
|
||||
|
||||
@@ -1,22 +1,17 @@
|
||||
import { execFile } from "node:child_process";
|
||||
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) {
|
||||
program
|
||||
.command("dashboard")
|
||||
.description(t("dashboard.description"))
|
||||
.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)")
|
||||
.action(async (opts, cmd) => {
|
||||
if (opts.tui) {
|
||||
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 apiKey = globalOpts.apiKey ?? null;
|
||||
const { startInteractiveTui } = await import("../tui/Dashboard.jsx");
|
||||
@@ -29,7 +24,7 @@ export function registerDashboard(program) {
|
||||
}
|
||||
|
||||
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}`;
|
||||
|
||||
if (opts.url) {
|
||||
@@ -49,22 +44,22 @@ export async function runDashboardCommand(opts = {}) {
|
||||
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) {
|
||||
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());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -299,15 +299,6 @@ async function checkNativeBinary(rootDir) {
|
||||
"Release",
|
||||
"better_sqlite3.node"
|
||||
),
|
||||
path.join(
|
||||
rootDir,
|
||||
"dist",
|
||||
"node_modules",
|
||||
"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));
|
||||
@@ -404,10 +395,7 @@ async function checkServerLiveness(options = {}) {
|
||||
// First attempt: configured health endpoint (may require auth token).
|
||||
const primary = await probeUrl(url);
|
||||
if (primary.ok) {
|
||||
return ok("Server liveness", "Server health endpoint is reachable", {
|
||||
url,
|
||||
status: primary.status,
|
||||
});
|
||||
return ok("Server liveness", "Server health endpoint is reachable", { url, status: primary.status });
|
||||
}
|
||||
|
||||
// #6162: /api/health and /api/health/degradation require a management token.
|
||||
@@ -438,12 +426,7 @@ async function checkServerLiveness(options = {}) {
|
||||
return ok(
|
||||
"Server liveness",
|
||||
`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 }
|
||||
);
|
||||
}
|
||||
|
||||
@@ -456,7 +439,8 @@ async function checkServerLiveness(options = {}) {
|
||||
|
||||
export async function collectDoctorChecks(context = {}, options = {}) {
|
||||
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 dbPath = resolveStoragePath(dataDir);
|
||||
|
||||
|
||||
@@ -48,11 +48,7 @@ export async function runHealthCommand(opts = {}) {
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await apiFetch("/api/monitoring/health", {
|
||||
retry: false,
|
||||
timeout: 5000,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
const res = await apiFetch("/api/health", { retry: false, timeout: 5000, acceptNotOk: true });
|
||||
if (!res.ok) {
|
||||
console.error(t("common.error", { message: `HTTP ${res.status}` }));
|
||||
return 1;
|
||||
@@ -70,22 +66,29 @@ export async function runHealthCommand(opts = {}) {
|
||||
if (health.uptime) console.log(t("health.uptime", { uptime: health.uptime }));
|
||||
if (health.version) console.log(` Version: ${health.version}`);
|
||||
|
||||
if (health.activeConnections !== undefined) {
|
||||
console.log(t("health.requests", { count: health.activeConnections }));
|
||||
if (health.requests !== undefined) {
|
||||
console.log(t("health.requests", { count: health.requests }));
|
||||
}
|
||||
|
||||
if (health.circuitBreakers && opts.verbose) {
|
||||
if (health.breakers && opts.verbose) {
|
||||
console.log("\n \x1b[1mCircuit Breakers\x1b[0m");
|
||||
const { open = 0, halfOpen = 0, closed = 0 } = health.circuitBreakers;
|
||||
console.log(` \x1b[32m● closed\x1b[0m ${closed}`);
|
||||
console.log(` \x1b[33m○ half-open\x1b[0m ${halfOpen}`);
|
||||
console.log(` \x1b[31m○ open\x1b[0m ${open}`);
|
||||
for (const [name, status] of Object.entries(health.breakers)) {
|
||||
const state =
|
||||
status.state === "closed" ? "\x1b[32m● closed\x1b[0m" : "\x1b[33m○ open\x1b[0m";
|
||||
console.log(` ${name.padEnd(20)} ${state}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (opts.verbose && health.memoryUsage) {
|
||||
if (health.cache && opts.verbose) {
|
||||
console.log("\n \x1b[1mCache\x1b[0m");
|
||||
console.log(` Semantic hits: ${health.cache.semanticHits || 0}`);
|
||||
console.log(` Signature hits: ${health.cache.signatureHits || 0}`);
|
||||
}
|
||||
|
||||
if (opts.verbose && health.memory) {
|
||||
console.log("\n \x1b[1mMemory\x1b[0m");
|
||||
console.log(` RSS: ${health.memoryUsage.rss || "N/A"}`);
|
||||
console.log(` Heap used: ${health.memoryUsage.heapUsed || "N/A"}`);
|
||||
console.log(` RSS: ${health.memory.rss || "N/A"}`);
|
||||
console.log(` Heap used: ${health.memory.heapUsed || "N/A"}`);
|
||||
}
|
||||
|
||||
return 0;
|
||||
@@ -97,17 +100,13 @@ export async function runHealthCommand(opts = {}) {
|
||||
|
||||
export async function runHealthComponentsCommand(opts = {}) {
|
||||
try {
|
||||
const res = await apiFetch("/api/monitoring/health", {
|
||||
retry: false,
|
||||
timeout: 5000,
|
||||
acceptNotOk: true,
|
||||
});
|
||||
const res = await apiFetch("/api/health", { retry: false, timeout: 5000, acceptNotOk: true });
|
||||
if (!res.ok) {
|
||||
console.error(`HTTP ${res.status}`);
|
||||
return 1;
|
||||
}
|
||||
const health = await res.json();
|
||||
const components = health.components || health.circuitBreakers || {};
|
||||
const components = health.components || health.breakers || {};
|
||||
for (const [name, info] of Object.entries(components)) {
|
||||
const status =
|
||||
typeof info === "object" ? info.state || info.status || "unknown" : String(info);
|
||||
|
||||
@@ -1,36 +1,6 @@
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import { spawn } from "node:child_process";
|
||||
import { t } from "../i18n.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
|
||||
* in the shell can't shadow the omniroute provider (defense-in-depth). Mirrors
|
||||
@@ -49,47 +19,6 @@ const STRIPPED_CODEX_ENV_KEYS = [
|
||||
/** Placeholder so codex's `env_key` is always satisfied when the backend is open. */
|
||||
const NO_AUTH_SENTINEL = "omniroute-no-auth";
|
||||
|
||||
// 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):
|
||||
// spawn `codex.cmd` through a shell on win32, and the bare binary elsewhere.
|
||||
//
|
||||
// #9454: the native codex installer may ship a real `codex.exe` instead of the
|
||||
// npm `.cmd` shim. Probe PATH for `codex` first: when `where.exe` resolves a
|
||||
// `.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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* `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) {
|
||||
let s = String(value);
|
||||
let end = s.length;
|
||||
@@ -197,10 +126,10 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
|
||||
|
||||
if (!(await healthCheck(baseUrl))) {
|
||||
console.error(
|
||||
(
|
||||
t("launch.notRunning") ||
|
||||
"OmniRoute is not reachable at {port}. Start it with 'omniroute serve'."
|
||||
).replace("{port}", baseUrl)
|
||||
(t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace(
|
||||
"{port}",
|
||||
baseUrl
|
||||
)
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
@@ -212,14 +141,8 @@ export async function runLaunchCodexCommand(opts = {}, codexArgs = []) {
|
||||
const extraArgs = [...providerArgs, ...profileArgs, ...codexArgs];
|
||||
const env = buildCodexEnv(process.env, authToken);
|
||||
|
||||
const { command: codexLaunch, shell: shellValue } = await resolveCodexSpawn(process.platform);
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const child = spawn(codexLaunch, quoteCodexArgs(extraArgs, process.platform), {
|
||||
env,
|
||||
stdio: "inherit",
|
||||
shell: shellValue,
|
||||
});
|
||||
const child = spawn("codex", extraArgs, { env, stdio: "inherit" });
|
||||
child.on("error", (err) => {
|
||||
if (err?.code === "ENOENT") {
|
||||
console.error(
|
||||
@@ -242,25 +165,16 @@ export function registerLaunchCodex(program) {
|
||||
t("launchCodex.description") || "Launch Codex CLI pointed at OmniRoute (local or remote VPS)"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option(
|
||||
"--remote <url>",
|
||||
"Remote OmniRoute base URL, e.g. http://192.168.0.15:20128 (overrides --port + context)"
|
||||
)
|
||||
.option("--remote <url>", "Remote OmniRoute base URL, e.g. http://192.168.0.15:20128 (overrides --port + context)")
|
||||
.option("--profile <name>", "Codex profile to activate (passed as --profile <name>)")
|
||||
.option("-p, --p <name>", "Alias for --profile")
|
||||
.option(
|
||||
"--api-key <key>",
|
||||
"OmniRoute API key (overrides OMNIROUTE_API_KEY env var for this invocation)"
|
||||
)
|
||||
.option("--api-key <key>", "OmniRoute API key (overrides OMNIROUTE_API_KEY env var for this invocation)")
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.argument("[codexArgs...]", "arguments passed through to the codex binary")
|
||||
.action(async (codexArgs, opts) => {
|
||||
const merged = { ...opts, profile: opts.profile ?? opts.p };
|
||||
// process.exit() here aborted the process with a libuv assertion on
|
||||
// Windows (`!(handle->flags & UV_HANDLE_CLOSING)`, async.c:94): it tears
|
||||
// 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 ?? []);
|
||||
const exitCode = await runLaunchCodexCommand(merged, codexArgs ?? []);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import { spawn } from "node:child_process";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
import { quoteShellArgs } from "../utils/winShellArgs.mjs";
|
||||
|
||||
function stripTrailingSlash(value) {
|
||||
let s = String(value);
|
||||
@@ -91,78 +90,6 @@ export function resolveLaunchTarget(opts = {}) {
|
||||
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 {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}`);
|
||||
} catch {
|
||||
console.error(
|
||||
(
|
||||
t("launch.notRunning") ||
|
||||
"OmniRoute is not reachable at {port}. Start it with 'omniroute serve'."
|
||||
).replace("{port}", baseUrl)
|
||||
(t("launch.notRunning") || "OmniRoute is not reachable at {port}. Start it with 'omniroute serve'.").replace(
|
||||
"{port}",
|
||||
baseUrl
|
||||
)
|
||||
);
|
||||
return 1;
|
||||
}
|
||||
@@ -192,15 +119,8 @@ export async function runLaunchCommand(opts = {}, claudeArgs = []) {
|
||||
: undefined;
|
||||
const env = buildClaudeEnv(process.env, baseUrl, authToken, { configDir });
|
||||
|
||||
const { command, shell } = await resolveClaudeSpawn(process.platform);
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const child = spawn(command, quoteClaudeArgs(claudeArgs, process.platform), {
|
||||
env,
|
||||
stdio: "inherit",
|
||||
shell,
|
||||
...(process.platform === "win32" ? { windowsHide: true } : {}),
|
||||
});
|
||||
const child = spawn("claude", claudeArgs, { env, stdio: "inherit" });
|
||||
child.on("error", (err) => {
|
||||
if (err && err.code === "ENOENT") {
|
||||
console.error(t("launch.notFound") || "The 'claude' CLI was not found in PATH.");
|
||||
@@ -222,20 +142,14 @@ export function registerLaunch(program) {
|
||||
)
|
||||
.option("--port <port>", t("serve.port") || "Proxy port", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute base URL (overrides --port and the active context)")
|
||||
.option(
|
||||
"--profile <name>",
|
||||
"Claude Code profile to use (CLAUDE_CONFIG_DIR ~/.claude/profiles/<name>)"
|
||||
)
|
||||
.option("--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("--api-key <key>", "Alias for --token (OmniRoute access token / API key)")
|
||||
.allowUnknownOption(true)
|
||||
.allowExcessArguments(true)
|
||||
.argument("[claudeArgs...]", "arguments passed through to the claude binary")
|
||||
.action(async (claudeArgs, opts) => {
|
||||
// process.exit() here aborted the process with a libuv assertion on
|
||||
// Windows (`!(handle->flags & UV_HANDLE_CLOSING)`, async.c:94): it tears
|
||||
// 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 ?? []);
|
||||
const exitCode = await runLaunchCommand(opts, claudeArgs ?? []);
|
||||
if (exitCode !== 0) process.exit(exitCode);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,19 +19,6 @@ import { randomUUID } from "node:crypto";
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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";
|
||||
@@ -67,7 +54,7 @@ function defaultStartServer(preferredPort) {
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(
|
||||
"<!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>" +
|
||||
"<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). */
|
||||
async function loadDeps() {
|
||||
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 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(
|
||||
"\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" +
|
||||
blob +
|
||||
"\n\n"
|
||||
@@ -259,8 +170,6 @@ async function runLoginAntigravity(opts) {
|
||||
browser: opts.browser,
|
||||
timeout: opts.timeout,
|
||||
port: opts.port,
|
||||
push: opts.push,
|
||||
context: opts.context,
|
||||
});
|
||||
} catch (err) {
|
||||
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("--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(
|
||||
"--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);
|
||||
}
|
||||
|
||||
@@ -6,31 +6,16 @@ import { t } from "../i18n.mjs";
|
||||
const PROVIDERS_WITH_OAUTH = [
|
||||
{ id: "gemini", name: "Google Gemini", 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: "zed", name: "Zed", flow: "import" },
|
||||
{ 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: "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 = [
|
||||
{ key: "id", header: "Provider ID", width: 16 },
|
||||
{ key: "name", header: "Name", width: 28 },
|
||||
@@ -72,107 +57,32 @@ async function pollStatus(endpoint, timeoutMs) {
|
||||
}
|
||||
|
||||
async function runBrowserFlow(def, opts) {
|
||||
// The user-facing id (`def.id`, e.g. "claude-code") must be translated to the
|
||||
// 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" });
|
||||
const startRes = await apiFetch(`/api/oauth/${def.id}/start`, { method: "POST" });
|
||||
if (!startRes.ok) {
|
||||
const detail = await safeErrorBody(startRes);
|
||||
process.stderr.write(`Failed to start OAuth for ${def.id}: ${startRes.status}${detail}\n`);
|
||||
process.stderr.write(`Failed to start OAuth for ${def.id}: ${startRes.status}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
const start = await startRes.json();
|
||||
const url = start.authUrl ?? 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;
|
||||
const url = start.authorizeUrl ?? start.url;
|
||||
|
||||
process.stdout.write(`\nOpen this URL to authorize:\n ${url}\n\n`);
|
||||
if (opts.browser !== false) await openBrowser(url);
|
||||
if (process.stdout.isTTY && opts.browser !== false) {
|
||||
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(
|
||||
"After authorizing, paste the callback URL (or the Authentication Code\n" +
|
||||
"shown on the confirmation page) here:\n"
|
||||
`Authorized: ${result.email ?? result.userId ?? result.account ?? "connected"}\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) {
|
||||
@@ -215,7 +125,7 @@ async function runSocialFlow(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" });
|
||||
if (!startRes.ok) {
|
||||
process.stderr.write(`Failed to start device flow: ${startRes.status}\n`);
|
||||
|
||||
@@ -10,25 +10,9 @@ const DEFAULT_IMAGE = "docker.io/redis:7-alpine";
|
||||
const DEFAULT_NAME = "omniroute-redis";
|
||||
const DEFAULT_PORT = "6379";
|
||||
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"];
|
||||
|
||||
/**
|
||||
* 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() {
|
||||
for (const candidate of RUNTIME_PREFERENCE) {
|
||||
try {
|
||||
@@ -43,14 +27,7 @@ async function detectRuntime() {
|
||||
|
||||
async function containerExists(runtime, name) {
|
||||
try {
|
||||
const { stdout } = await execFile(runtime, [
|
||||
"ps",
|
||||
"-a",
|
||||
"--filter",
|
||||
`name=^${name}$`,
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
]);
|
||||
const { stdout } = await execFile(runtime, ["ps", "-a", "--filter", `name=^${name}$`, "--format", "{{.Names}}"]);
|
||||
return stdout.trim() === name;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -59,13 +36,7 @@ async function containerExists(runtime, name) {
|
||||
|
||||
async function containerRunning(runtime, name) {
|
||||
try {
|
||||
const { stdout } = await execFile(runtime, [
|
||||
"ps",
|
||||
"--filter",
|
||||
`name=^${name}$`,
|
||||
"--format",
|
||||
"{{.Names}}",
|
||||
]);
|
||||
const { stdout } = await execFile(runtime, ["ps", "--filter", `name=^${name}$`, "--format", "{{.Names}}"]);
|
||||
return stdout.trim() === name;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -129,11 +100,6 @@ export function registerRedis(program) {
|
||||
.command("up")
|
||||
.description("Start the local Redis container")
|
||||
.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("-i, --image <image>", "Container image", DEFAULT_IMAGE)
|
||||
.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 port = opts.port || DEFAULT_PORT;
|
||||
const bind = opts.bind || DEFAULT_BIND;
|
||||
const image = opts.image || DEFAULT_IMAGE;
|
||||
|
||||
const exists = await containerExists(runtime, name);
|
||||
@@ -221,11 +186,7 @@ export async function runRedisUpCommand(opts = {}) {
|
||||
info(`Checking if image '${image}' is present locally…`);
|
||||
let present = false;
|
||||
try {
|
||||
const { stdout } = await execFile(runtime, [
|
||||
"images",
|
||||
"--format",
|
||||
"{{.Repository}}:{{.Tag}}",
|
||||
]);
|
||||
const { stdout } = await execFile(runtime, ["images", "--format", "{{.Repository}}:{{.Tag}}"]);
|
||||
present = stdout.split("\n").some((line) => line.trim() === image);
|
||||
} catch {
|
||||
// ignore — fall through to pull
|
||||
@@ -244,14 +205,10 @@ export async function runRedisUpCommand(opts = {}) {
|
||||
const args = [
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
name,
|
||||
"--restart",
|
||||
"unless-stopped",
|
||||
"-p",
|
||||
buildRedisPublishSpec(bind, port),
|
||||
"-v",
|
||||
`${DEFAULT_VOLUME}:/data`,
|
||||
"--name", name,
|
||||
"--restart", "unless-stopped",
|
||||
"-p", `${port}:6379`,
|
||||
"-v", `${DEFAULT_VOLUME}:/data`,
|
||||
];
|
||||
if (opts.password) {
|
||||
args.push("-e", `REDIS_PASSWORD=${opts.password}`);
|
||||
@@ -262,13 +219,8 @@ export async function runRedisUpCommand(opts = {}) {
|
||||
info(`Launching ${runtime} run ${args.join(" ")}`);
|
||||
try {
|
||||
await execFile(runtime, args);
|
||||
success(`Container '${name}' is now running on redis://${bind}:${port}`);
|
||||
info(`Set OMNIROUTE_REDIS_URL=redis://${bind}:${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>.`
|
||||
);
|
||||
}
|
||||
success(`Container '${name}' is now running on redis://127.0.0.1:${port}`);
|
||||
info(`Set OMNIROUTE_REDIS_URL=redis://127.0.0.1:${port} in your .env to wire OmniRoute to it.`);
|
||||
return 0;
|
||||
} catch (err) {
|
||||
fail(`Failed to launch container: ${err.message}`);
|
||||
@@ -315,13 +267,7 @@ export async function runRedisStatusCommand(opts = {}) {
|
||||
|
||||
const exists = await containerExists(runtime, name);
|
||||
if (!exists) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{ runtime, name, port, exists: false, running: false, reachable: false },
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
console.log(JSON.stringify({ runtime, name, port, exists: false, running: false, reachable: false }, null, 2));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -339,12 +285,10 @@ export async function runRedisStatusCommand(opts = {}) {
|
||||
console.log(` Running: ${running ? "yes" : "no"}`);
|
||||
console.log(` Reachable: ${reachable ? "yes" : "no"} (port ${port})`);
|
||||
if (running && !reachable) {
|
||||
warn(
|
||||
"Container is running but the port is not reachable. Is REDIS_PASSWORD set or another process bound?"
|
||||
);
|
||||
warn("Container is running but the port is not reachable. Is REDIS_PASSWORD set or another process bound?");
|
||||
}
|
||||
if (!running) {
|
||||
info(`Run 'omniroute redis up' to launch it.`);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,6 @@ import { registerProviders } from "./providers.mjs";
|
||||
import { registerProvider } from "./provider-cmd.mjs";
|
||||
import { registerConfig } from "./config.mjs";
|
||||
import { registerKeys } from "./keys.mjs";
|
||||
import { registerAuthExport } from "./auth-export.mjs";
|
||||
import { registerModels } from "./models.mjs";
|
||||
import { registerCombo } from "./combo.mjs";
|
||||
import { registerStatus } from "./status.mjs";
|
||||
@@ -70,8 +69,8 @@ import { registerSetupCursor } from "./setup-cursor.mjs";
|
||||
import { registerSetupRoo } from "./setup-roo.mjs";
|
||||
import { registerSetupCrush } from "./setup-crush.mjs";
|
||||
import { registerSetupGoose } from "./setup-goose.mjs";
|
||||
import { registerSetupAider } from "./setup-aider.mjs";
|
||||
import { registerSetupQwen } from "./setup-qwen.mjs";
|
||||
import { registerSetupAider } from "./setup-aider.mjs";
|
||||
import { registerConnect } from "./connect.mjs";
|
||||
import { registerContexts } from "./contexts.mjs";
|
||||
import { registerTokens } from "./tokens.mjs";
|
||||
@@ -119,7 +118,6 @@ export function registerCommands(program) {
|
||||
registerProvider(program);
|
||||
registerConfig(program);
|
||||
registerKeys(program);
|
||||
registerAuthExport(program);
|
||||
registerModels(program);
|
||||
registerCombo(program);
|
||||
registerStatus(program);
|
||||
@@ -153,8 +151,8 @@ export function registerCommands(program) {
|
||||
registerSetupRoo(program);
|
||||
registerSetupCrush(program);
|
||||
registerSetupGoose(program);
|
||||
registerSetupAider(program);
|
||||
registerSetupQwen(program);
|
||||
registerSetupAider(program);
|
||||
registerConnect(program);
|
||||
registerContexts(program);
|
||||
registerTokens(program);
|
||||
|
||||
@@ -34,14 +34,7 @@ async function runRepairAction(opts, cmd) {
|
||||
if (ok) {
|
||||
process.stdout.write("✓ better-sqlite3 repaired OK\n");
|
||||
} else {
|
||||
process.stderr.write("✗ Repair failed\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.stderr.write("✗ Repair failed — check npm availability\n");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,6 @@ import { t } from "../i18n.mjs";
|
||||
import { writePidFile, cleanupPidFile, waitForServer } from "../utils/pid.mjs";
|
||||
import { ServerSupervisor, detectMitmCrash } from "../runtime/processSupervisor.mjs";
|
||||
import { isTermux } from "../../../scripts/build/postinstallSupport.mjs";
|
||||
import {
|
||||
ensureAndroidCacheDir,
|
||||
isFatalInstrumentationHookFailure,
|
||||
formatAndroidInstrumentationFailureHint,
|
||||
} from "../utils/ensureAndroidCacheDir.mjs";
|
||||
import {
|
||||
resolveMaxOldSpaceMb,
|
||||
calibrateHeapFallbackMb,
|
||||
@@ -67,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 = {}) {
|
||||
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 } =
|
||||
await import("../../../scripts/build/native-binary-compat.mjs");
|
||||
const { getNodeRuntimeSupport, getNodeRuntimeWarning } =
|
||||
@@ -167,11 +134,7 @@ export async function runServe(opts = {}) {
|
||||
"Release",
|
||||
"better_sqlite3.node"
|
||||
);
|
||||
if (
|
||||
!process.versions.bun &&
|
||||
existsSync(sqliteBinary) &&
|
||||
!isNativeBinaryCompatible(sqliteBinary)
|
||||
) {
|
||||
if (existsSync(sqliteBinary) && !isNativeBinaryCompatible(sqliteBinary)) {
|
||||
console.error(
|
||||
"\x1b[31m✖ better-sqlite3 native module is incompatible with this platform.\x1b[0m"
|
||||
);
|
||||
@@ -267,21 +230,12 @@ export async function runServe(opts = {}) {
|
||||
function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) {
|
||||
// #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).
|
||||
const server = spawn(
|
||||
process.versions.bun ? process.execPath : "node",
|
||||
[
|
||||
...(process.versions.bun
|
||||
? ["--preload", join(APP_DIR, "open-sse/utils/setupPolyfill.ts")]
|
||||
: buildNodeHeapArgs(process.env, memoryLimit)),
|
||||
serverJs,
|
||||
],
|
||||
{
|
||||
cwd: APP_DIR,
|
||||
env,
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
}
|
||||
);
|
||||
const server = spawn("node", [...buildNodeHeapArgs(process.env, memoryLimit), serverJs], {
|
||||
cwd: APP_DIR,
|
||||
env,
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
});
|
||||
writePidFile("server", server.pid);
|
||||
server.unref();
|
||||
console.log(`\x1b[32m✔ OmniRoute started in background (PID: ${server.pid})\x1b[0m`);
|
||||
@@ -292,20 +246,11 @@ function runDaemon(serverJs, env, memoryLimit, dashboardPort, apiPort) {
|
||||
function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, noOpen, startedAt) {
|
||||
// #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).
|
||||
const server = spawn(
|
||||
process.versions.bun ? process.execPath : "node",
|
||||
[
|
||||
...(process.versions.bun
|
||||
? ["--preload", join(APP_DIR, "open-sse/utils/setupPolyfill.ts")]
|
||||
: buildNodeHeapArgs(process.env, memoryLimit)),
|
||||
serverJs,
|
||||
],
|
||||
{
|
||||
cwd: APP_DIR,
|
||||
env,
|
||||
stdio: "pipe",
|
||||
}
|
||||
);
|
||||
const server = spawn("node", [...buildNodeHeapArgs(process.env, memoryLimit), serverJs], {
|
||||
cwd: APP_DIR,
|
||||
env,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
writePidFile("server", server.pid);
|
||||
|
||||
@@ -314,7 +259,6 @@ function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort,
|
||||
server.stdout.on("data", (data) => {
|
||||
const text = data.toString();
|
||||
process.stdout.write(text);
|
||||
maybeReportInstrumentationHookFailure(text);
|
||||
if (
|
||||
!started &&
|
||||
(text.includes("Ready") || text.includes("started") || text.includes("listening"))
|
||||
@@ -324,11 +268,7 @@ function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort,
|
||||
}
|
||||
});
|
||||
|
||||
server.stderr.on("data", (data) => {
|
||||
const text = data.toString();
|
||||
process.stderr.write(text);
|
||||
maybeReportInstrumentationHookFailure(text);
|
||||
});
|
||||
server.stderr.on("data", (data) => process.stderr.write(data));
|
||||
|
||||
server.on("error", (err) => {
|
||||
console.error("\x1b[31m✖ Failed to start server:\x1b[0m", err.message);
|
||||
@@ -411,38 +351,11 @@ async function runWithSupervisor(
|
||||
if (up) {
|
||||
if (useTray) await maybeStartTray(dashboardPort, apiPort, supervisor);
|
||||
onReady(dashboardPort, apiPort, noOpen, startedAt);
|
||||
} else {
|
||||
reportReadinessTimeout(dashboardPort, supervisor);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// #6321: waitForServer resolving `false` used to fall through silently — the CLI
|
||||
// printed the banner + "⏳ Starting server..." and then produced ZERO further
|
||||
// output forever, even though the child process may well have crashed or be
|
||||
// stuck (issue reports show the server sometimes actually comes up later, or is
|
||||
// reachable directly while the CLI still looks hung). Surface a clear diagnostic
|
||||
// plus whatever stdout/stderr the child buffered instead of going silent.
|
||||
export function reportReadinessTimeout(dashboardPort, supervisor) {
|
||||
console.error(
|
||||
`\n\x1b[33m⚠ Server did not respond within 60s.\x1b[0m It may still be starting, or may` +
|
||||
` have failed silently.`
|
||||
);
|
||||
console.error(` Try: curl -I http://localhost:${dashboardPort}/api/monitoring/health`);
|
||||
console.error(` Or: rerun with \x1b[36m--log\x1b[0m to see live server output.\n`);
|
||||
|
||||
const recentLog = supervisor?.getRecentLog?.() ?? [];
|
||||
if (recentLog.length) {
|
||||
console.error("--- Recent server output ---");
|
||||
recentLog.forEach((l) => console.error(l));
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
||||
let _killTray = null;
|
||||
function killTrayIfActive() {
|
||||
if (_killTray) {
|
||||
|
||||
@@ -156,15 +156,7 @@ export async function runSetupClaudeCommand(opts = {}) {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
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);
|
||||
}
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
|
||||
const body = await res.json();
|
||||
models = body.data ?? body.models ?? [];
|
||||
} catch (err) {
|
||||
|
||||
@@ -218,56 +218,18 @@ function registerPluginInOpenCodeConfig({
|
||||
* a clear "could not run opencode" message instead of a hard import
|
||||
* failure.
|
||||
*/
|
||||
/**
|
||||
* Resolve the provider id used for `opencode auth login --provider <id>`.
|
||||
*
|
||||
* The bundled @omniroute/opencode-plugin registers its provider under
|
||||
* `opencode-<id>` (the `opencode-` prefix is required by OpenCode >=1.17.8's
|
||||
* native-adapter gate). The auth login command must use the prefixed form
|
||||
* 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);
|
||||
function runOpenCodeAuth(providerId) {
|
||||
const isWin = process.platform === "win32";
|
||||
const opencodeBin = isWin ? "opencode.cmd" : "opencode";
|
||||
const res = spawnSync(opencodeBin, ["auth", "login", "--provider", providerId], {
|
||||
stdio: "inherit",
|
||||
shell: false,
|
||||
});
|
||||
if (res.error) {
|
||||
// ENOENT = opencode is not on PATH
|
||||
if (res.error.code === "ENOENT") {
|
||||
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;
|
||||
}
|
||||
@@ -365,8 +327,7 @@ export async function runSetupOpenCodeCommand(opts = {}) {
|
||||
if (wantsAuth) {
|
||||
if (nonInteractive) {
|
||||
printInfo(`Skipping \`opencode auth login\` (non-interactive mode).`);
|
||||
const authProviderId = resolveOpenCodeAuthProviderId(providerId);
|
||||
printInfo(`Run manually: opencode auth login --provider ${authProviderId}`);
|
||||
printInfo(`Run manually: opencode auth login --provider ${providerId}`);
|
||||
} else {
|
||||
printHeading("Authenticating with OpenCode");
|
||||
const authExit = runOpenCodeAuth(providerId);
|
||||
@@ -375,9 +336,8 @@ export async function runSetupOpenCodeCommand(opts = {}) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const authProviderId = resolveOpenCodeAuthProviderId(providerId);
|
||||
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)`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,166 +1,156 @@
|
||||
/** Configure Qwen Code's OpenAI-compatible provider for OmniRoute. */
|
||||
/**
|
||||
* omniroute setup-qwen — configure Qwen Code (QwenLM/qwen-code) for OmniRoute.
|
||||
*
|
||||
* Qwen Code is a terminal AI agent with a file-based config at
|
||||
* ~/.qwen/settings.json. For a custom OpenAI-compatible endpoint it uses a
|
||||
* `modelProviders` entry with authType "openai", baseUrl WITH /v1, and an
|
||||
* `envKey` naming the env var holding the key (secret stays in the env, never the
|
||||
* file). Remote-aware; headless test via `qwen -p "..."`.
|
||||
*/
|
||||
|
||||
import {
|
||||
chmodSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
renameSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
mergeQwenCodeEnv,
|
||||
mergeQwenCodeSettings,
|
||||
normalizeQwenCodeBaseUrl,
|
||||
} from "../../../src/shared/services/qwenCodeConfig.ts";
|
||||
import { printHeading, printInfo, printSuccess, printError, createPrompt } from "../io.mjs";
|
||||
import { resolveActiveContext } from "../contexts.mjs";
|
||||
import { createPrompt, printError, printHeading, printInfo, printSuccess } from "../io.mjs";
|
||||
|
||||
/** Resolve base URL and key from flags, active context, then local defaults. */
|
||||
export function resolveQwenTarget(opts = {}) {
|
||||
let root = opts.remote ? String(opts.remote) : "";
|
||||
let context;
|
||||
|
||||
if (!root || !(opts.apiKey ?? opts["api-key"])) {
|
||||
try {
|
||||
context = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
} catch {
|
||||
// An active context is optional for local setup.
|
||||
}
|
||||
}
|
||||
|
||||
if (!root) root = context?.baseUrl || "";
|
||||
if (!root) {
|
||||
const port = Number(opts.port ?? process.env.PORT ?? 20128) || 20128;
|
||||
root = `http://localhost:${port}`;
|
||||
}
|
||||
|
||||
const apiKey =
|
||||
opts.apiKey ??
|
||||
opts["api-key"] ??
|
||||
context?.accessToken ??
|
||||
context?.apiKey ??
|
||||
process.env.OMNIROUTE_API_KEY ??
|
||||
"sk_omniroute";
|
||||
|
||||
return { baseUrl: normalizeQwenCodeBaseUrl(root), apiKey };
|
||||
function ensureV1(url) {
|
||||
const s = String(url || "").replace(/\/+$/, "");
|
||||
return s.endsWith("/v1") ? s : `${s}/v1`;
|
||||
}
|
||||
|
||||
const readSettings = (filePath) => {
|
||||
if (!existsSync(filePath)) return {};
|
||||
const parsed = JSON.parse(readFileSync(filePath, "utf8"));
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Qwen Code settings.json must contain a JSON object");
|
||||
}
|
||||
return parsed;
|
||||
};
|
||||
|
||||
const readText = (filePath) => (existsSync(filePath) ? readFileSync(filePath, "utf8") : "");
|
||||
|
||||
const writeAtomic = (filePath, content, mode) => {
|
||||
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
try {
|
||||
writeFileSync(tempPath, content, { encoding: "utf8", mode });
|
||||
if (mode !== undefined) chmodSync(tempPath, mode);
|
||||
renameSync(tempPath, filePath);
|
||||
} catch (error) {
|
||||
/** Resolve baseUrl (WITH /v1) + apiKey from flags → active context → localhost. */
|
||||
export function resolveQwenTarget(opts = {}) {
|
||||
let root;
|
||||
if (opts.remote) root = String(opts.remote).replace(/\/+$/, "");
|
||||
else {
|
||||
try {
|
||||
unlinkSync(tempPath);
|
||||
root = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT)?.baseUrl;
|
||||
} catch {
|
||||
// The temporary file may not have been created.
|
||||
/* none */
|
||||
}
|
||||
throw error;
|
||||
if (!root) root = `http://localhost:${Number(opts.port ?? process.env.PORT ?? 20128) || 20128}`;
|
||||
}
|
||||
};
|
||||
let apiKey = opts.apiKey ?? opts["api-key"];
|
||||
if (!apiKey) {
|
||||
try {
|
||||
const c = resolveActiveContext(opts.context ?? process.env.OMNIROUTE_CONTEXT);
|
||||
apiKey = c?.accessToken || c?.apiKey;
|
||||
} catch {
|
||||
/* none */
|
||||
}
|
||||
}
|
||||
if (!apiKey) apiKey = process.env.OMNIROUTE_API_KEY || "";
|
||||
return { baseUrl: ensureV1(root), apiKey };
|
||||
}
|
||||
|
||||
const fetchModelIds = async (baseUrl, apiKey) => {
|
||||
/** Merge the OmniRoute modelProvider into Qwen's settings.json (preserve rest). */
|
||||
export function buildQwenSettings(existing, { baseUrl, model }) {
|
||||
const s = existing && typeof existing === "object" ? { ...existing } : {};
|
||||
const providers = Array.isArray(s.modelProviders)
|
||||
? s.modelProviders.filter((p) => p?.id !== "omniroute")
|
||||
: [];
|
||||
providers.push({
|
||||
id: "omniroute",
|
||||
name: "OmniRoute",
|
||||
authType: "openai",
|
||||
baseUrl,
|
||||
envKey: "OMNIROUTE_API_KEY",
|
||||
});
|
||||
s.modelProviders = providers;
|
||||
if (model) {
|
||||
s.selectedProvider = "omniroute";
|
||||
s.model = model;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function readJson(path) {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/models`, {
|
||||
headers: { Authorization: `Bearer ${apiKey}` },
|
||||
if (existsSync(path)) return JSON.parse(readFileSync(path, "utf8"));
|
||||
} catch {
|
||||
/* corrupt/missing */
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
async function fetchModelIds(baseUrl, apiKey) {
|
||||
try {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (apiKey) headers["Authorization"] = `Bearer ${apiKey}`;
|
||||
const res = await fetch(`${baseUrl.replace(/\/v1$/, "")}/v1/models`, {
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8000),
|
||||
});
|
||||
if (!response.ok) return [];
|
||||
const body = await response.json();
|
||||
const models = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
|
||||
return models.map((entry) => (typeof entry === "string" ? entry : entry?.id)).filter(Boolean);
|
||||
if (!res.ok) return [];
|
||||
const body = await res.json();
|
||||
const list = Array.isArray(body) ? body : (body.data ?? body.models ?? []);
|
||||
return list.map((m) => (typeof m === "string" ? m : m?.id)).filter(Boolean);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function runSetupQwenCommand(opts = {}) {
|
||||
const { baseUrl, apiKey } = resolveQwenTarget(opts);
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const settingsPath =
|
||||
opts.configPath ?? opts["config-path"] ?? path.join(os.homedir(), ".qwen", "settings.json");
|
||||
const envPath = opts.envPath ?? opts["env-path"] ?? path.join(path.dirname(settingsPath), ".env");
|
||||
const configPath =
|
||||
opts.configPath ?? opts["config-path"] ?? join(os.homedir(), ".qwen", "settings.json");
|
||||
|
||||
printHeading("OmniRoute → Qwen Code (OpenAI-compatible)");
|
||||
printHeading("OmniRoute → Qwen Code (openai-compatible)");
|
||||
printInfo(`baseUrl: ${baseUrl}`);
|
||||
|
||||
let model = String(opts.model || "").trim();
|
||||
if (!model && !opts.yes) {
|
||||
const modelIds = await fetchModelIds(baseUrl, apiKey);
|
||||
if (modelIds.length > 0) {
|
||||
printInfo(`Examples: ${modelIds.slice(0, 20).join(", ")}${modelIds.length > 20 ? " …" : ""}`);
|
||||
}
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
model = String(await prompt.ask("Model id for Qwen Code")).trim();
|
||||
} finally {
|
||||
prompt.close();
|
||||
let model = opts.model;
|
||||
if (!model) {
|
||||
const ids = await fetchModelIds(baseUrl, apiKey);
|
||||
if (ids.length && !opts.yes) {
|
||||
printInfo(`Examples: ${ids.slice(0, 20).join(", ")}${ids.length > 20 ? " …" : ""}`);
|
||||
const prompt = createPrompt();
|
||||
try {
|
||||
model = await prompt.ask("Model id for Qwen");
|
||||
} finally {
|
||||
prompt.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
printError("A model is required. Pass --model <id>.");
|
||||
return 2;
|
||||
}
|
||||
|
||||
try {
|
||||
const settings = mergeQwenCodeSettings(readSettings(settingsPath), { baseUrl, model });
|
||||
const envText = mergeQwenCodeEnv(readText(envPath), apiKey);
|
||||
const settingsText = `${JSON.stringify(settings, null, 2)}\n`;
|
||||
const merged = buildQwenSettings(readJson(configPath), { baseUrl, model });
|
||||
const out = JSON.stringify(merged, null, 2) + "\n";
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n${settingsText}`);
|
||||
printInfo(`[dry-run] settings → ${settingsPath}`);
|
||||
printInfo(`[dry-run] credential → ${envPath} (OMNIROUTE_API_KEY)`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(settingsPath), { recursive: true, mode: 0o700 });
|
||||
mkdirSync(path.dirname(envPath), { recursive: true, mode: 0o700 });
|
||||
writeAtomic(settingsPath, settingsText);
|
||||
writeAtomic(envPath, envText, 0o600);
|
||||
printSuccess(`Wrote ${settingsPath}`);
|
||||
printSuccess(`Updated ${envPath} (OMNIROUTE_API_KEY only)`);
|
||||
printInfo('Run: qwen (or headless: qwen -p "reply OK")');
|
||||
return 0;
|
||||
} catch (error) {
|
||||
printError(`Failed to configure Qwen Code: ${error?.message || error}`);
|
||||
return 1;
|
||||
if (dryRun) {
|
||||
console.log("\n" + out);
|
||||
printInfo(`[dry-run] → ${configPath}`);
|
||||
} else {
|
||||
mkdirSync(join(configPath, ".."), { recursive: true });
|
||||
writeFileSync(configPath, out, "utf8");
|
||||
printSuccess(`Wrote ${configPath}`);
|
||||
}
|
||||
printInfo(
|
||||
"\nProvide the key (settings reference OMNIROUTE_API_KEY): export OMNIROUTE_API_KEY=..."
|
||||
);
|
||||
printInfo('Then run: qwen (or headless: qwen -p "reply OK")');
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function registerSetupQwen(program) {
|
||||
program
|
||||
.command("setup-qwen")
|
||||
.description("Configure Qwen Code's upstream V4 modelProviders format for OmniRoute")
|
||||
.description(
|
||||
"Configure Qwen Code for OmniRoute: write ~/.qwen/settings.json (openai modelProvider)"
|
||||
)
|
||||
.option("--port <port>", "Local OmniRoute port (ignored when --remote is set)", "20128")
|
||||
.option("--remote <url>", "Remote OmniRoute URL")
|
||||
.option("--api-key <key>", "OmniRoute API key")
|
||||
.option("--model <id>", "Model id for Qwen Code")
|
||||
.option("--config-path <path>", "Qwen Code settings.json path")
|
||||
.option("--env-path <path>", "Qwen Code .env path")
|
||||
.option("--yes", "Non-interactive; requires --model")
|
||||
.option("--dry-run", "Print settings without writing files or secrets")
|
||||
.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("--model <id>", "Model id for Qwen (required unless picked interactively)")
|
||||
.option("--config-path <path>", "settings.json path (default: ~/.qwen/settings.json)")
|
||||
.option("--yes", "Non-interactive: do not prompt (requires --model)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const code = await runSetupQwenCommand(opts);
|
||||
if (code !== 0) process.exitCode = code;
|
||||
if (code !== 0) process.exit(code);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ function wantsProviderSetup(opts) {
|
||||
|
||||
async function resolvePassword(opts, prompt, nonInteractive) {
|
||||
if (opts.password) return opts.password;
|
||||
if (process.env.INITIAL_PASSWORD) return process.env.INITIAL_PASSWORD;
|
||||
if (nonInteractive) return "";
|
||||
|
||||
const answer = await prompt.ask("Set an admin password now? [y/N]", "N");
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
sleep,
|
||||
} from "../utils/pid.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
import { stopProcessGracefully } from "../../../src/shared/platform/windowsProcess.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
@@ -24,35 +23,25 @@ export function registerStop(program) {
|
||||
|
||||
export async function runStopCommand(opts = {}) {
|
||||
const pid = readPidFile("server");
|
||||
// #9455: when the server was started with a supervisor (the default), killing only
|
||||
// the child lets the supervisor respawn it immediately. The supervisor's PID is
|
||||
// persisted separately by serve.mjs; SIGTERM it FIRST so its handler sets
|
||||
// isShuttingDown=true and stops the child cleanly without respawning.
|
||||
const supervisorPid = readPidFile("supervisor");
|
||||
|
||||
if (pid && isPidRunning(pid)) {
|
||||
console.log(t("stop.stopping", { pid }));
|
||||
try {
|
||||
if (supervisorPid && isPidRunning(supervisorPid)) {
|
||||
try {
|
||||
process.kill(supervisorPid, "SIGTERM");
|
||||
} catch {}
|
||||
// Give the supervisor a moment to cascade the shutdown to its child so we
|
||||
// don't race the child kill against the supervisor's own child stop.
|
||||
await sleep(300);
|
||||
process.kill(pid, "SIGTERM");
|
||||
|
||||
let waited = 0;
|
||||
while (waited < 5000 && isPidRunning(pid)) {
|
||||
await sleep(100);
|
||||
waited += 100;
|
||||
}
|
||||
|
||||
// #8045: on win32, process.kill(pid, "SIGTERM") unconditionally force-terminates
|
||||
// the target instead of delivering an interceptable signal, racing (and beating)
|
||||
// the server's own async graceful shutdown / WAL checkpoint. stopProcessGracefully
|
||||
// skips the immediate SIGTERM on win32 and just polls before escalating to SIGKILL.
|
||||
if (isPidRunning(pid)) {
|
||||
await stopProcessGracefully({ pid, timeoutMs: 5000, isPidRunning, sleep });
|
||||
process.kill(pid, "SIGKILL");
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
killAllSubprocesses();
|
||||
cleanupPidFile("server");
|
||||
cleanupPidFile("supervisor");
|
||||
console.log(t("stop.stopped"));
|
||||
return 0;
|
||||
} catch (err) {
|
||||
@@ -66,24 +55,10 @@ export async function runStopCommand(opts = {}) {
|
||||
const port = opts.port ? parseInt(String(opts.port), 10) : 20128;
|
||||
if (pid === null) {
|
||||
console.log(t("stop.portFallback"));
|
||||
// #9455: a stale supervisor PID file would let the port-fallback stop also
|
||||
// leave the supervisor running and respawning. Stop it first.
|
||||
if (supervisorPid && isPidRunning(supervisorPid)) {
|
||||
try {
|
||||
process.kill(supervisorPid, "SIGTERM");
|
||||
} catch {}
|
||||
}
|
||||
const portFreed = await killByPort(port);
|
||||
await killByPort(port);
|
||||
killAllSubprocesses();
|
||||
cleanupPidFile("server");
|
||||
cleanupPidFile("supervisor");
|
||||
// #9455: only report success when the port is actually free — previously stop
|
||||
// printed "Server stopped." even when killByPort was a no-op (win32).
|
||||
if (portFreed) {
|
||||
console.log(t("stop.stopped"));
|
||||
} else {
|
||||
console.log(t("stop.notRunning"));
|
||||
}
|
||||
console.log(t("stop.stopped"));
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -91,84 +66,31 @@ export async function runStopCommand(opts = {}) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill the process listening on `port`. Returns true once the port is free
|
||||
* (or no listener was found), false if it could not be freed.
|
||||
*
|
||||
* #9455: previously this was a no-op on win32 (`if (win32) return;`) yet the
|
||||
* caller still reported "Server stopped." — a lie. The win32 branch now uses
|
||||
* `netstat -ano` to find LISTENING PIDs and `process.kill()` (SIGTERM then
|
||||
* SIGKILL), mirroring the POSIX `lsof` path.
|
||||
*/
|
||||
export async function killByPort(port, deps = {}) {
|
||||
const exec = deps.execFileAsync || execFileAsync;
|
||||
const kill = deps.processKill || ((p, sig) => process.kill(p, sig));
|
||||
const running = deps.isPidRunning || isPidRunning;
|
||||
const wait = deps.sleep || sleep;
|
||||
const platform = deps.platform || process.platform;
|
||||
|
||||
if (platform === "win32") {
|
||||
return killByPortWin32(port, { exec, kill, running, wait });
|
||||
}
|
||||
return killByPortPosix(port, { exec, kill, running, wait });
|
||||
}
|
||||
|
||||
async function killByPortPosix(port, { exec, kill, running, wait }) {
|
||||
let pids = [];
|
||||
async function killByPort(port) {
|
||||
if (process.platform === "win32") return;
|
||||
try {
|
||||
const { stdout } = await exec("lsof", ["-ti", `:${port}`]);
|
||||
pids = stdout
|
||||
const { stdout } = await execFileAsync("lsof", ["-ti", `:${port}`]);
|
||||
const pids = stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((p) => parseInt(p, 10))
|
||||
.filter((p) => Number.isFinite(p) && p > 0);
|
||||
|
||||
for (const p of pids) {
|
||||
try {
|
||||
process.kill(p, "SIGTERM");
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (pids.length > 0) {
|
||||
await sleep(1000);
|
||||
for (const p of pids) {
|
||||
try {
|
||||
if (isPidRunning(p)) process.kill(p, "SIGKILL");
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// lsof not available or no process on port
|
||||
}
|
||||
return terminatePids(pids, { kill, running, wait });
|
||||
}
|
||||
|
||||
async function killByPortWin32(port, { exec, kill, running, wait }) {
|
||||
let pids = [];
|
||||
try {
|
||||
const { stdout } = await exec("netstat", ["-ano"]);
|
||||
pids = parseNetstatPids(stdout, port);
|
||||
} catch {
|
||||
// netstat not available or empty
|
||||
}
|
||||
return terminatePids(pids, { kill, running, wait });
|
||||
}
|
||||
|
||||
function parseNetstatPids(stdout, port) {
|
||||
const portCol = `:${port}`;
|
||||
const pids = [];
|
||||
for (const line of stdout.split(/\r?\n/)) {
|
||||
const cols = line.trim().split(/\s+/);
|
||||
// Expected columns: Proto LocalAddress ForeignAddress State PID
|
||||
if (cols.length < 5) continue;
|
||||
if (cols[0] !== "TCP" && cols[0] !== "TCPv6") continue;
|
||||
const local = cols[1] || "";
|
||||
if (!local.endsWith(portCol)) continue;
|
||||
if ((cols[cols.length - 2] || "").toUpperCase() !== "LISTENING") continue;
|
||||
const pid = parseInt(cols[cols.length - 1], 10);
|
||||
if (Number.isFinite(pid) && pid > 0 && !pids.includes(pid)) pids.push(pid);
|
||||
}
|
||||
return pids;
|
||||
}
|
||||
|
||||
async function terminatePids(pids, { kill, running, wait }) {
|
||||
if (pids.length === 0) return true;
|
||||
for (const p of pids) {
|
||||
try {
|
||||
kill(p, "SIGTERM");
|
||||
} catch {}
|
||||
}
|
||||
await wait(1000);
|
||||
for (const p of pids) {
|
||||
try {
|
||||
if (running(p)) kill(p, "SIGKILL");
|
||||
} catch {}
|
||||
}
|
||||
// Confirm the port is free: any PID still alive means we failed.
|
||||
return pids.every((p) => !running(p));
|
||||
}
|
||||
|
||||
@@ -181,28 +181,6 @@ export async function runUpdateCommand(opts = {}) {
|
||||
// --include=optional keeps the optionalDependencies (better-sqlite3, keytar,
|
||||
// tls-client, llmlingua SLM stack) on update so an omit=optional config can't drop them.
|
||||
execSync("npm install -g omniroute@latest --include=optional", { stdio: "inherit" });
|
||||
// Trust-but-verify: `npm install -g` exits 0 even when a shadowing local install
|
||||
// (e.g. ~/node_modules/omniroute ahead of the global prefix on PATH) means the
|
||||
// binary the user actually runs was not touched. Re-read the running binary's
|
||||
// version and warn instead of lying about success (#9475).
|
||||
const afterVersion = await getCurrentVersion();
|
||||
if (afterVersion && compareVersions(afterVersion, latest) < 0) {
|
||||
printError(
|
||||
`Global install updated to ${latest}, but the running binary still reports ${afterVersion}.`
|
||||
);
|
||||
console.log(
|
||||
" A local `node_modules/omniroute` is likely shadowing the global install on PATH."
|
||||
);
|
||||
console.log(" Diagnose with:");
|
||||
console.log(" which -a omniroute");
|
||||
console.log(" command -v omniroute");
|
||||
console.log(" npm prefix -g");
|
||||
console.log(
|
||||
" Then remove the shadowing local copy (e.g. `npm uninstall omniroute` from its directory)"
|
||||
);
|
||||
console.log(" or reorder PATH so the global bin comes first.");
|
||||
return 1;
|
||||
}
|
||||
printSuccess(`Updated to version ${latest}`);
|
||||
printInfo("Run `omniroute --version` to verify.");
|
||||
return 0;
|
||||
|
||||
@@ -133,20 +133,6 @@
|
||||
"listTitle": "Keys expiring within {days} days:"
|
||||
}
|
||||
},
|
||||
"authExport": {
|
||||
"description": "Export DECRYPTED provider credentials (local-only, plaintext output)",
|
||||
"idOpt": "Export only the connection matching this id/name/provider",
|
||||
"formatOpt": "Output format: json or env",
|
||||
"outOpt": "Write output to a file instead of stdout (written with 0600 permissions)",
|
||||
"forceOpt": "Confirm you understand this prints/writes plaintext secrets",
|
||||
"warning": "⚠ This prints/writes DECRYPTED plaintext API keys and OAuth tokens. Make sure your screen, shell history, and any output file stay private.",
|
||||
"confirmHeading": "⚠ WARNING: this exports DECRYPTED provider credentials in plaintext",
|
||||
"confirmBody": "This command decrypts and prints/writes apiKey, accessToken, refreshToken, and\nidToken for the selected connection(s). Treat the output as a secret.",
|
||||
"confirmFooter": "To confirm, run:\n omniroute auth export --force",
|
||||
"missingKey": "STORAGE_ENCRYPTION_KEY is required to export credentials.",
|
||||
"notFound": "Connection not found: {id}",
|
||||
"invalidFormat": "Invalid format: {format}. Use json or env."
|
||||
},
|
||||
"stream": {
|
||||
"description": "Stream a chat response with SSE inspection modes",
|
||||
"file": "Read prompt from file",
|
||||
|
||||
@@ -132,20 +132,6 @@
|
||||
"listTitle": "Chaves que expiram nos próximos {days} dias:"
|
||||
}
|
||||
},
|
||||
"authExport": {
|
||||
"description": "Exportar credenciais DESCRIPTOGRAFADAS de provedores (somente local, saída em texto puro)",
|
||||
"idOpt": "Exportar apenas a conexão correspondente a este id/nome/provedor",
|
||||
"formatOpt": "Formato de saída: json ou env",
|
||||
"outOpt": "Gravar a saída em um arquivo em vez do stdout (gravado com permissão 0600)",
|
||||
"forceOpt": "Confirma que você entende que isso imprime/grava segredos em texto puro",
|
||||
"warning": "⚠ Isso imprime/grava chaves de API e tokens OAuth DESCRIPTOGRAFADOS em texto puro. Garanta que sua tela, o histórico do shell e qualquer arquivo de saída permaneçam privados.",
|
||||
"confirmHeading": "⚠ AVISO: isso exporta credenciais de provedores DESCRIPTOGRAFADAS em texto puro",
|
||||
"confirmBody": "Este comando descriptografa e imprime/grava apiKey, accessToken, refreshToken e\nidToken da(s) conexão(ões) selecionada(s). Trate a saída como um segredo.",
|
||||
"confirmFooter": "Para confirmar, execute:\n omniroute auth export --force",
|
||||
"missingKey": "STORAGE_ENCRYPTION_KEY é obrigatório para exportar credenciais.",
|
||||
"notFound": "Conexão não encontrada: {id}",
|
||||
"invalidFormat": "Formato inválido: {format}. Use json ou env."
|
||||
},
|
||||
"stream": {
|
||||
"description": "Transmitir resposta de chat com modos de inspeção SSE",
|
||||
"file": "Ler prompt de arquivo",
|
||||
|
||||
@@ -29,16 +29,16 @@
|
||||
"setup": {
|
||||
"title": "OmniRoute 设置",
|
||||
"passwordPrompt": "管理员密码",
|
||||
"providerPrompt": "默认提供者(留空跳过)",
|
||||
"providerPrompt": "默认提供商(留空跳过)",
|
||||
"done": "设置完成",
|
||||
"passwordSet": "管理员密码已配置",
|
||||
"providerSet": "提供者已配置:{name}",
|
||||
"testingProvider": "正在测试提供者连接:{name}",
|
||||
"testPassed": "提供者测试通过",
|
||||
"testFailed": "提供者测试失败:{error}",
|
||||
"providerSet": "提供商已配置:{name}",
|
||||
"testingProvider": "正在测试提供商连接:{name}",
|
||||
"testPassed": "提供商测试通过",
|
||||
"testFailed": "提供商测试失败:{error}",
|
||||
"loginEnabled": "登录:已启用(密码已更新)",
|
||||
"loginDisabled": "登录:已禁用",
|
||||
"providerInfo": "提供者:{info}"
|
||||
"providerInfo": "提供商:{info}"
|
||||
},
|
||||
"doctor": {
|
||||
"title": "OmniRoute 诊断",
|
||||
@@ -52,29 +52,29 @@
|
||||
"warnings": "{count} 个警告 — 见上文。"
|
||||
},
|
||||
"providers": {
|
||||
"title": "提供者",
|
||||
"noProviders": "未配置提供者。运行:omniroute setup",
|
||||
"title": "提供商",
|
||||
"noProviders": "未配置提供商。运行:omniroute setup",
|
||||
"testing": "正在测试 {name}...",
|
||||
"available": "{count} 个提供者可用",
|
||||
"available": "{count} 个提供商可用",
|
||||
"connected": "已连接",
|
||||
"disconnected": "未连接",
|
||||
"validationFailed": "验证失败:{error}",
|
||||
"metrics": {
|
||||
"description": "显示提供者性能指标(延迟、成功率、成本)",
|
||||
"provider": "按提供者 ID 筛选",
|
||||
"description": "显示提供商性能指标(延迟、成功率、成本)",
|
||||
"provider": "按提供商 ID 筛选",
|
||||
"connection_id": "按连接 ID 筛选",
|
||||
"period": "时间范围:1h|6h|24h|7d|30d(默认:24h)",
|
||||
"metric": "关注特定指标字段",
|
||||
"sort": "按字段排序(降序)",
|
||||
"limit": "最大行数(默认:50)",
|
||||
"watch": "每 5 秒刷新(实时模式)",
|
||||
"compare": "逗号分隔的提供者 ID,并排比较"
|
||||
"compare": "逗号分隔的提供商 ID,并排比较"
|
||||
},
|
||||
"metric_single": {
|
||||
"description": "获取特定连接的单个指标值"
|
||||
},
|
||||
"rotate": {
|
||||
"description": "轮换提供者连接的上游 API 密钥",
|
||||
"description": "轮换提供商连接的上游 API 密钥",
|
||||
"newKeyOpt": "新 API 密钥值(避免使用:优先使用 --from-env)",
|
||||
"fromEnvOpt": "从环境变量 VAR 读取新密钥",
|
||||
"oauthOpt": "改为触发 OAuth 重新认证流程",
|
||||
@@ -89,18 +89,18 @@
|
||||
"testFailed": "轮换后测试失败:{error}"
|
||||
},
|
||||
"status": {
|
||||
"description": "显示所有提供者连接的密钥健康状态(期限、过期、冷却)",
|
||||
"providerOpt": "按提供者名称筛选",
|
||||
"header": "ID 提供者 名称 过期状态 测试状态 冷却至",
|
||||
"noData": "没有可用的提供者连接数据。",
|
||||
"description": "显示所有提供商连接的密钥健康状态(期限、过期、冷却)",
|
||||
"providerOpt": "按提供商名称筛选",
|
||||
"header": "ID 提供商 名称 过期状态 测试状态 冷却至",
|
||||
"noData": "没有可用的提供商连接数据。",
|
||||
"requiresServer": "providers status 需要 OmniRoute 服务器正在运行。"
|
||||
}
|
||||
},
|
||||
"keys": {
|
||||
"title": "API 密钥",
|
||||
"addDescription": "为提供者添加或更新 API 密钥",
|
||||
"addDescription": "为提供商添加或更新 API 密钥",
|
||||
"listDescription": "列出所有已配置的 API 密钥",
|
||||
"removeDescription": "移除提供者的 API 密钥",
|
||||
"removeDescription": "移除提供商的 API 密钥",
|
||||
"regenerateDescription": "重新生成 OmniRoute API 密钥",
|
||||
"revokeDescription": "撤销 OmniRoute API 密钥",
|
||||
"revealDescription": "显示未掩码的 API 密钥值",
|
||||
@@ -122,10 +122,10 @@
|
||||
"revoked": "密钥 {id} 已撤销。",
|
||||
"rotated": "密钥 {id} 已轮换。新密钥 ID:{newId}",
|
||||
"revealWarning": "⚠ 这将显示完整的未掩码密钥。请确保您的屏幕不被人看到。",
|
||||
"providerRequired": "需要提供者。",
|
||||
"providerRequired": "需要提供商。",
|
||||
"keyRequired": "需要 API 密钥。",
|
||||
"stdinEmpty": "未通过标准输入提供 API 密钥。",
|
||||
"unknownProvider": "未知提供者:{provider}",
|
||||
"unknownProvider": "未知提供商:{provider}",
|
||||
"policy": {
|
||||
"title": "密钥策略",
|
||||
"showDescription": "显示密钥的速率/成本策略",
|
||||
@@ -165,7 +165,7 @@
|
||||
"analytics": {
|
||||
"description": "显示汇总使用分析",
|
||||
"period": "时间范围:1d|7d|30d|90d|ytd|all(默认:30d)",
|
||||
"provider": "按提供者 ID 筛选"
|
||||
"provider": "按提供商 ID 筛选"
|
||||
},
|
||||
"budget": {
|
||||
"description": "管理成本预算",
|
||||
@@ -175,8 +175,8 @@
|
||||
}
|
||||
},
|
||||
"quota": {
|
||||
"description": "显示提供者配额使用情况",
|
||||
"provider": "按提供者 ID 筛选",
|
||||
"description": "显示提供商配额使用情况",
|
||||
"provider": "按提供商 ID 筛选",
|
||||
"check": "显示新请求是否有可用配额"
|
||||
},
|
||||
"logs": {
|
||||
@@ -201,7 +201,7 @@
|
||||
}
|
||||
},
|
||||
"cost": {
|
||||
"description": "按提供者、模型、组合或 API 密钥显示成本报告",
|
||||
"description": "按提供商、模型、组合或 API 密钥显示成本报告",
|
||||
"period": "时间范围:1d|7d|30d|90d|ytd|all(默认:30d)",
|
||||
"since": "开始日期(ISO 格式,例如 2026-01-01)— 覆盖 --period",
|
||||
"until": "结束日期(ISO 格式)",
|
||||
@@ -210,7 +210,7 @@
|
||||
"limit": "显示的最大行数(默认:100)"
|
||||
},
|
||||
"simulate": {
|
||||
"description": "模拟路由(空运行)— 显示将选择哪些提供者而不调用上游",
|
||||
"description": "模拟路由(空运行)— 显示将选择哪些提供商而不调用上游",
|
||||
"file": "从 JSON 文件加载完整请求体",
|
||||
"model": "模型 ID(默认:auto)",
|
||||
"combo": "强制指定组合名称",
|
||||
@@ -301,7 +301,7 @@
|
||||
"cost": "成本(24h):"
|
||||
},
|
||||
"quota": {
|
||||
"description": "显示提供者配额使用情况",
|
||||
"description": "显示提供商配额使用情况",
|
||||
"noServer": "服务器未运行。启动:omniroute serve",
|
||||
"noData": "没有可用的配额信息。"
|
||||
},
|
||||
@@ -315,12 +315,12 @@
|
||||
"description": "一键启动本地 Redis 容器(Podman 或 Docker),用于 OmniRoute 缓存和配额跟踪"
|
||||
},
|
||||
"test": {
|
||||
"description": "测试提供者连接",
|
||||
"description": "测试提供商连接",
|
||||
"noServer": "服务器未运行。启动:omniroute serve",
|
||||
"testing": "正在测试 {provider} / {model}...",
|
||||
"passed": "连接成功!",
|
||||
"failed": "连接失败:{error}",
|
||||
"allProvidersOpt": "测试所有已配置的提供者",
|
||||
"allProvidersOpt": "测试所有已配置的提供商",
|
||||
"latencyOpt": "显示延迟测量值(平均/最小/最大 毫秒)",
|
||||
"repeatOpt": "重复测试 N 次并汇总结果",
|
||||
"compareOpt": "逗号分隔的要比较的模型(例如 gpt-4o,claude-3-5-sonnet)",
|
||||
@@ -328,7 +328,7 @@
|
||||
"saved": "结果已保存至 {path}",
|
||||
"compareTitle": "模型比较",
|
||||
"compareMinTwo": "--compare 需要至少两个模型(逗号分隔)",
|
||||
"noProviders": "未配置提供者。添加:omniroute keys add"
|
||||
"noProviders": "未配置提供商。添加:omniroute keys add"
|
||||
},
|
||||
"update": {
|
||||
"checking": "正在检查更新...",
|
||||
@@ -509,7 +509,7 @@
|
||||
},
|
||||
"models": {
|
||||
"description": "列出可用模型(需要服务器)",
|
||||
"search": "按 ID、名称、提供者或描述筛选模型",
|
||||
"search": "按 ID、名称、提供商或描述筛选模型",
|
||||
"noServer": "服务器未运行。启动:omniroute serve",
|
||||
"noModels": "未找到模型。"
|
||||
},
|
||||
@@ -656,25 +656,25 @@
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"description": "管理 OAuth 提供者连接",
|
||||
"description": "管理 OAuth 提供商连接",
|
||||
"providers": {
|
||||
"description": "列出支持 OAuth 的提供者及其流程类型"
|
||||
"description": "列出支持 OAuth 的提供商及其流程类型"
|
||||
},
|
||||
"start": {
|
||||
"description": "为提供者启动 OAuth 授权流程",
|
||||
"provider": "提供者 ID(gemini, copilot, cursor, ...)",
|
||||
"description": "为提供商启动 OAuth 授权流程",
|
||||
"provider": "提供商 ID(gemini, copilot, cursor, ...)",
|
||||
"no_browser": "仅打印 URL — 不打开浏览器",
|
||||
"import_system": "从本地系统配置自动导入凭据",
|
||||
"social": "社交登录提供者(google|github)— kiro 需要",
|
||||
"social": "社交登录提供商(google|github)— kiro 需要",
|
||||
"timeout": "等待授权超时时间(毫秒,默认:300000)"
|
||||
},
|
||||
"status": {
|
||||
"description": "列出活动的 OAuth 连接",
|
||||
"provider": "按提供者 ID 筛选"
|
||||
"provider": "按提供商 ID 筛选"
|
||||
},
|
||||
"revoke": {
|
||||
"description": "撤销 OAuth 连接",
|
||||
"provider": "要撤销的提供者 ID",
|
||||
"provider": "要撤销的提供商 ID",
|
||||
"connection_id": "按 ID 撤销特定连接",
|
||||
"yes": "跳过确认提示"
|
||||
}
|
||||
@@ -884,11 +884,11 @@
|
||||
"description": "管理模型定价数据",
|
||||
"sync": {
|
||||
"description": "从上游同步价格",
|
||||
"provider": "按提供者筛选",
|
||||
"provider": "按提供商筛选",
|
||||
"force": "强制重新同步"
|
||||
},
|
||||
"list": {
|
||||
"provider": "按提供者筛选",
|
||||
"provider": "按提供商筛选",
|
||||
"model": "按模型筛选",
|
||||
"limit": "最大结果数"
|
||||
},
|
||||
@@ -907,25 +907,25 @@
|
||||
"resilience": {
|
||||
"description": "检查和管理弹性机制",
|
||||
"status": {
|
||||
"provider": "按提供者筛选"
|
||||
"provider": "按提供商筛选"
|
||||
},
|
||||
"breakers": {
|
||||
"provider": "按提供者筛选"
|
||||
"provider": "按提供商筛选"
|
||||
},
|
||||
"cooldowns": {
|
||||
"provider": "按提供者筛选",
|
||||
"provider": "按提供商筛选",
|
||||
"connectionId": "按连接 ID 筛选"
|
||||
},
|
||||
"lockouts": {
|
||||
"provider": "按提供者筛选",
|
||||
"provider": "按提供商筛选",
|
||||
"model": "按模型筛选"
|
||||
},
|
||||
"reset": {
|
||||
"description": "重置断路器/冷却状态",
|
||||
"provider": "要重置的提供者",
|
||||
"provider": "要重置的提供商",
|
||||
"connectionId": "要重置的连接 ID",
|
||||
"model": "要重置锁定状态的模型",
|
||||
"allCooldowns": "重置提供者的所有冷却",
|
||||
"allCooldowns": "重置提供商的所有冷却",
|
||||
"yes": "跳过确认"
|
||||
},
|
||||
"profile": {
|
||||
@@ -940,13 +940,13 @@
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"description": "管理提供者节点(端点)",
|
||||
"description": "管理提供商节点(端点)",
|
||||
"list": {
|
||||
"provider": "按提供者筛选",
|
||||
"provider": "按提供商筛选",
|
||||
"enabled": "仅显示已启用的节点"
|
||||
},
|
||||
"add": {
|
||||
"provider": "提供者名称",
|
||||
"provider": "提供商名称",
|
||||
"baseUrl": "节点的基础 URL",
|
||||
"name": "节点名称",
|
||||
"weight": "负载均衡权重",
|
||||
@@ -965,7 +965,7 @@
|
||||
},
|
||||
"validate": {
|
||||
"baseUrl": "要验证的 URL",
|
||||
"provider": "要验证的提供者"
|
||||
"provider": "要验证的提供商"
|
||||
},
|
||||
"test": {
|
||||
"description": "向节点发送测试请求"
|
||||
@@ -1091,7 +1091,7 @@
|
||||
"oneproxy": {
|
||||
"description": "管理 OneProxy 上游代理池",
|
||||
"stats": {
|
||||
"provider": "按提供者筛选",
|
||||
"provider": "按提供商筛选",
|
||||
"period": "时间范围(默认:24h)"
|
||||
},
|
||||
"fetch": {
|
||||
@@ -1101,14 +1101,14 @@
|
||||
},
|
||||
"rotate": {
|
||||
"description": "强制轮换代理",
|
||||
"provider": "要轮换的提供者",
|
||||
"provider": "要轮换的提供商",
|
||||
"connectionId": "要轮换的特定连接 ID"
|
||||
},
|
||||
"config": {
|
||||
"description": "显示或更新 OneProxy 配置",
|
||||
"enabled": "启用代理池(true|false)",
|
||||
"poolSize": "池大小",
|
||||
"providerSource": "代理提供者的 URL",
|
||||
"providerSource": "代理提供商的 URL",
|
||||
"rotationPolicy": "轮换策略:sticky|per-request|periodic"
|
||||
},
|
||||
"pool": {
|
||||
@@ -1201,7 +1201,7 @@
|
||||
"bash": "打印 bash 补全脚本",
|
||||
"fish": "打印 fish 补全脚本",
|
||||
"install": "为检测到的 Shell 全局安装补全脚本",
|
||||
"refresh": "刷新组合/提供者/模型缓存"
|
||||
"refresh": "刷新组合/提供商/模型缓存"
|
||||
},
|
||||
"logs": {
|
||||
"description": "流式传输或导出请求日志",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,6 @@ function inferSchema(sample) {
|
||||
function formatCell(v, col) {
|
||||
if (v == null) return "";
|
||||
if (col.formatter) return col.formatter(v);
|
||||
if (typeof v === "object") return JSON.stringify(v);
|
||||
return String(v);
|
||||
}
|
||||
|
||||
|
||||
@@ -52,31 +52,6 @@ export function hasModule(name) {
|
||||
return existsSync(join(runtimeModules(), name, "package.json"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Probe whether a native addon (.node) file can actually be dlopen'd by the Node runtime that
|
||||
* is going to load it. Runs in a throwaway subprocess so a real ABI mismatch (which can segfault
|
||||
* the process instead of throwing) never takes down the caller — only the probe subprocess.
|
||||
*/
|
||||
function probeNativeBinaryLoadable(binary) {
|
||||
try {
|
||||
const res = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"-e",
|
||||
"try { require(process.argv[1]); process.exit(0); } catch (e) { process.exit(1); }",
|
||||
binary,
|
||||
],
|
||||
{ timeout: 10_000, stdio: "ignore" }
|
||||
);
|
||||
// status === 0 means require() (and therefore dlopen) succeeded. Anything else — a thrown
|
||||
// ERR_DLOPEN_FAILED/NODE_MODULE_VERSION mismatch (status 1) or a crash (status null with a
|
||||
// signal, e.g. SIGSEGV) — means the binary is not safe to load.
|
||||
return res.status === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function isBetterSqliteBinaryValid() {
|
||||
const binary = join(
|
||||
runtimeModules(),
|
||||
@@ -93,20 +68,10 @@ export function isBetterSqliteBinaryValid() {
|
||||
closeSync(fd);
|
||||
const magic = buf.toString("hex");
|
||||
const os = platform();
|
||||
let formatOk;
|
||||
if (os === "linux")
|
||||
formatOk = magic.startsWith("7f454c46"); // ELF
|
||||
else if (os === "darwin")
|
||||
formatOk = magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O
|
||||
else if (os === "win32")
|
||||
formatOk = magic.startsWith("4d5a"); // PE/MZ
|
||||
else formatOk = true;
|
||||
if (!formatOk) return false;
|
||||
// File-format magic bytes alone do not guarantee the binary was built for the Node ABI
|
||||
// (NODE_MODULE_VERSION) that will load it — a stale/foreign-ABI binary passes the header
|
||||
// check and then crashes (segfault) on load instead of triggering a rebuild. Actually
|
||||
// attempt to load it, isolated in a subprocess.
|
||||
return probeNativeBinaryLoadable(binary);
|
||||
if (os === "linux") return magic.startsWith("7f454c46"); // ELF
|
||||
if (os === "darwin") return magic.startsWith("cffaedfe") || magic.startsWith("cefaedfe"); // Mach-O
|
||||
if (os === "win32") return magic.startsWith("4d5a"); // PE/MZ
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -154,18 +119,9 @@ export function ensureBetterSqliteRuntime({ silent = false, force = false } = {}
|
||||
if (!silent) process.stdout.write("[omniroute][runtime] better-sqlite3 OK\n");
|
||||
return { betterSqlite: true };
|
||||
}
|
||||
if (!silent) {
|
||||
process.stdout.write(
|
||||
`[omniroute][runtime] Installing better-sqlite3@${BETTER_SQLITE3_VERSION} into runtime...\n`
|
||||
);
|
||||
}
|
||||
const ok = npmInstallRuntime([`better-sqlite3@${BETTER_SQLITE3_VERSION}`], { silent });
|
||||
if (!ok && !silent) {
|
||||
process.stderr.write(
|
||||
"[omniroute][runtime] better-sqlite3 install failed.\n" +
|
||||
" This usually means npm install scripts are blocked.\n" +
|
||||
" Try: npm install-scripts approve better-sqlite3\n"
|
||||
);
|
||||
process.stderr.write("[omniroute][runtime] better-sqlite3 install failed\n");
|
||||
}
|
||||
return { betterSqlite: ok && hasModule("better-sqlite3") && isBetterSqliteBinaryValid() };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { dirname, join } from "node:path";
|
||||
import { writePidFile, cleanupPidFile, killAllSubprocesses, isPidRunning } from "../utils/pid.mjs";
|
||||
import { dirname } from "node:path";
|
||||
import { writePidFile, cleanupPidFile, killAllSubprocesses } from "../utils/pid.mjs";
|
||||
import {
|
||||
RESTART_RESET_MS,
|
||||
DEFAULT_MAX_RESTARTS,
|
||||
@@ -8,23 +8,12 @@ import {
|
||||
computeRestartDelayMs,
|
||||
waitUntilPortFree,
|
||||
} from "./supervisorPolicy.mjs";
|
||||
import { buildNodeRuntimeArgs } from "../../../scripts/build/runtime-env.mjs";
|
||||
import { stopProcessGracefully } from "../../../src/shared/platform/windowsProcess.ts";
|
||||
import {
|
||||
isFatalInstrumentationHookFailure,
|
||||
formatAndroidInstrumentationFailureHint,
|
||||
} from "../utils/ensureAndroidCacheDir.mjs";
|
||||
import { buildNodeHeapArgs } from "../../../scripts/build/runtime-env.mjs";
|
||||
|
||||
const CRASH_LOG_LINES = 50;
|
||||
|
||||
export class ServerSupervisor {
|
||||
constructor({
|
||||
serverPath,
|
||||
env,
|
||||
maxRestarts = DEFAULT_MAX_RESTARTS,
|
||||
memoryLimit = 512,
|
||||
onCrashCallback,
|
||||
}) {
|
||||
constructor({ serverPath, env, maxRestarts = DEFAULT_MAX_RESTARTS, memoryLimit = 512, onCrashCallback }) {
|
||||
this.serverPath = serverPath;
|
||||
this.env = env;
|
||||
this.maxRestarts = maxRestarts;
|
||||
@@ -35,68 +24,33 @@ export class ServerSupervisor {
|
||||
this.crashLog = [];
|
||||
this.child = null;
|
||||
this.isShuttingDown = false;
|
||||
this.instrumentationFailureHintPrinted = false;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.startedAt = Date.now();
|
||||
this.crashLog = [];
|
||||
this.instrumentationFailureHintPrinted = false;
|
||||
|
||||
const showLog = process.env.OMNIROUTE_SHOW_LOG === "1";
|
||||
// #6321: stdout used to be discarded (`"ignore"`) whenever `--log`/OMNIROUTE_SHOW_LOG
|
||||
// wasn't set (the default) — any debug/pino output written to stdout vanished
|
||||
// silently, so a boot that never becomes ready looked like a dead hang with zero
|
||||
// output even at APP_LOG_LEVEL=debug. Pipe stdout too and buffer it alongside
|
||||
// stderr so a readiness timeout can surface what the child actually printed.
|
||||
// #9156: always spawn via process.execPath (absolute path to the running
|
||||
// runtime — node or bun). Bare "node" is unresolvable under macOS launchd's
|
||||
// minimal PATH; #9761's Bun ternary accidentally regressed the Node branch.
|
||||
// Node args come from buildNodeRuntimeArgs (#9209 IPv4-first DNS + #5238
|
||||
// heap flag handling); the Bun branch keeps #9761's polyfill preload —
|
||||
// Bun does not accept the Node-only flags.
|
||||
this.child = spawn(
|
||||
process.execPath,
|
||||
process.versions.bun
|
||||
? [
|
||||
"--preload",
|
||||
join(dirname(this.serverPath), "open-sse/utils/setupPolyfill.ts"),
|
||||
this.serverPath,
|
||||
]
|
||||
: buildNodeRuntimeArgs(process.env, this.memoryLimit, this.serverPath),
|
||||
{
|
||||
cwd: dirname(this.serverPath),
|
||||
env: this.env,
|
||||
stdio: showLog ? "inherit" : ["ignore", "pipe", "pipe"],
|
||||
}
|
||||
);
|
||||
// #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). The
|
||||
// calibrated heap is already carried by env.NODE_OPTIONS either way.
|
||||
const heapArgs = buildNodeHeapArgs(process.env, this.memoryLimit);
|
||||
this.child = spawn("node", [...heapArgs, this.serverPath], {
|
||||
cwd: dirname(this.serverPath),
|
||||
env: this.env,
|
||||
stdio: showLog ? "inherit" : ["ignore", "ignore", "pipe"],
|
||||
});
|
||||
|
||||
writePidFile("server", this.child.pid);
|
||||
|
||||
const bufferOutput = (data) => {
|
||||
const text = data.toString();
|
||||
const lines = text.split("\n").filter(Boolean);
|
||||
this.crashLog.push(...lines);
|
||||
if (this.crashLog.length > CRASH_LOG_LINES) {
|
||||
this.crashLog = this.crashLog.slice(-CRASH_LOG_LINES);
|
||||
}
|
||||
// Surface Android/Termux instrumentation-hook failures even when --log is
|
||||
// off (output is only buffered otherwise).
|
||||
if (!this.instrumentationFailureHintPrinted && isFatalInstrumentationHookFailure(text)) {
|
||||
this.instrumentationFailureHintPrinted = true;
|
||||
process.stderr.write(
|
||||
formatAndroidInstrumentationFailureHint(
|
||||
this.env?.XDG_CACHE_HOME || process.env.XDG_CACHE_HOME
|
||||
)
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
if (this.child.stdout) {
|
||||
this.child.stdout.on("data", bufferOutput);
|
||||
}
|
||||
if (this.child.stderr) {
|
||||
this.child.stderr.on("data", bufferOutput);
|
||||
this.child.stderr.on("data", (data) => {
|
||||
const lines = data.toString().split("\n").filter(Boolean);
|
||||
this.crashLog.push(...lines);
|
||||
if (this.crashLog.length > CRASH_LOG_LINES) {
|
||||
this.crashLog = this.crashLog.slice(-CRASH_LOG_LINES);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.child.on("error", (err) => this.handleExit(-1, err));
|
||||
@@ -105,32 +59,12 @@ export class ServerSupervisor {
|
||||
return this.child;
|
||||
}
|
||||
|
||||
handleExit(code, err) {
|
||||
handleExit(code) {
|
||||
// Node.js v24+ requires process.exit() to receive a number. Spawn-error events
|
||||
// deliver err.code (a string like 'ENOENT') via the 'error' listener; normalise here.
|
||||
const exitCode = typeof code === "number" ? code : null;
|
||||
cleanupPidFile("server");
|
||||
|
||||
// #8091: the child's spawn 'error' listener passes `err` through as a second
|
||||
// argument, but it used to be silently dropped — the user only ever saw the
|
||||
// hardcoded "code=-1" with a permanently empty crash log, with no way to
|
||||
// diagnose why the child never started (ENOENT/EACCES/bad path/etc.). Surface
|
||||
// the real reason immediately, both on the console and in the crash-log buffer
|
||||
// so `dumpCrashLog()` shows it too.
|
||||
if (err) {
|
||||
const detail = [
|
||||
err.code && `code=${err.code}`,
|
||||
err.syscall && `syscall=${err.syscall}`,
|
||||
err.path && `path=${err.path}`,
|
||||
err.message,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
const line = `⚠ Spawn error: ${detail || String(err)}`;
|
||||
console.error(line);
|
||||
this.crashLog.push(line);
|
||||
}
|
||||
|
||||
// #4425: only exit on an intentional shutdown. A spontaneous code-0 exit (e.g. a
|
||||
// systemd MemoryMax cgroup kill, which reports the process exited cleanly) is anomalous
|
||||
// and must be restarted, not treated as a graceful stop that leaves the gateway dead.
|
||||
@@ -173,12 +107,6 @@ export class ServerSupervisor {
|
||||
}, delay);
|
||||
}
|
||||
|
||||
// #6321: exposes the buffered stdout+stderr lines so a caller (e.g. a readiness
|
||||
// timeout) can print what the child actually said instead of silence.
|
||||
getRecentLog() {
|
||||
return [...this.crashLog];
|
||||
}
|
||||
|
||||
dumpCrashLog() {
|
||||
console.error("\n--- Server crash log ---");
|
||||
this.crashLog.forEach((l) => console.error(l));
|
||||
@@ -188,13 +116,14 @@ export class ServerSupervisor {
|
||||
stop() {
|
||||
this.isShuttingDown = true;
|
||||
if (this.child?.pid) {
|
||||
// #8045: on win32, process.kill(pid, "SIGTERM") unconditionally force-terminates
|
||||
// the target — it is never a real, interceptable signal there. The child already
|
||||
// receives the real CTRL_C_EVENT/CTRL_CLOSE_EVENT independently (it shares the
|
||||
// console) and runs its own async graceful shutdown (WAL checkpoint). Sending
|
||||
// SIGTERM immediately on win32 races and beats that cleanup. Fire-and-forget:
|
||||
// stop() itself stays sync so callers keep their existing control flow.
|
||||
void stopProcessGracefully({ pid: this.child.pid, timeoutMs: 5000, isPidRunning });
|
||||
try {
|
||||
process.kill(this.child.pid, "SIGTERM");
|
||||
} catch {}
|
||||
setTimeout(() => {
|
||||
try {
|
||||
process.kill(this.child.pid, "SIGKILL");
|
||||
} catch {}
|
||||
}, 5000);
|
||||
}
|
||||
killAllSubprocesses();
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ export const SYSTRAY_VERSION = "2.1.4";
|
||||
const SYSTRAY_SPEC = `${SYSTRAY_PACKAGE}@${SYSTRAY_VERSION}`;
|
||||
|
||||
export function resolveSystrayBinName(platform: NodeJS.Platform): string | null {
|
||||
if (platform === "win32") return "tray_windows_release.exe";
|
||||
if (platform === "win32") return null;
|
||||
if (platform === "darwin") return "tray_darwin_release";
|
||||
return "tray_linux_release";
|
||||
}
|
||||
@@ -45,6 +45,7 @@ export function chmodSystrayBinAt(runtimeRoot: string, platform: NodeJS.Platform
|
||||
}
|
||||
|
||||
export async function loadSystray(): Promise<(new (...args: unknown[]) => unknown) | null> {
|
||||
if (process.platform === "win32") return null; // Windows uses tray.ps1 instead
|
||||
ensureRuntimeDir();
|
||||
if (!isInstalled()) {
|
||||
try {
|
||||
|
||||
@@ -3,85 +3,12 @@ import { resolveDataDir, resolveStoragePath } from "./data-dir.mjs";
|
||||
import { ensureProviderSchema } from "./provider-store.mjs";
|
||||
import { ensureSettingsSchema, hashManagementPassword, updateSettings } from "./settings-store.mjs";
|
||||
|
||||
async function loadSqlite() {
|
||||
if (process.versions.bun) {
|
||||
return { Database: (await import("bun:sqlite")).Database };
|
||||
}
|
||||
async function loadBetterSqlite() {
|
||||
try {
|
||||
return { Database: (await import("better-sqlite3")).default };
|
||||
} catch (error) {
|
||||
return { error };
|
||||
}
|
||||
}
|
||||
|
||||
// #7586: unlike the real server (src/lib/db/adapters/driverFactory.ts::tryOpenSync),
|
||||
// this CLI helper historically had NO fallback beyond better-sqlite3 — so on any
|
||||
// machine where better-sqlite3's native binary is unavailable (Windows without a
|
||||
// prebuilt addon, etc.), every `omniroute doctor` DB check reported a false FAIL
|
||||
// even when the actual server was healthy via its own (correct) driver cascade.
|
||||
// Reuse that same cascade here instead of re-deriving it.
|
||||
async function openWithSyncDriverFallback(dbPath, options, importError) {
|
||||
try {
|
||||
const { tryOpenSync } = await import("../../src/lib/db/adapters/driverFactory.ts");
|
||||
const adapter = tryOpenSync(dbPath, options);
|
||||
if (adapter) {
|
||||
return adapter;
|
||||
}
|
||||
return (await import("better-sqlite3")).default;
|
||||
} catch {
|
||||
// fall through to the original better-sqlite3 error below
|
||||
throw new Error("better-sqlite3 is not installed. Run npm install before using setup.");
|
||||
}
|
||||
throw createSqliteNativeError(importError);
|
||||
}
|
||||
|
||||
function openBunSqlite(Database, dbPath, options) {
|
||||
const raw = new Database(dbPath, options);
|
||||
const prepare = (sql) => {
|
||||
const statement = raw.query(sql);
|
||||
return {
|
||||
run: (...params) => statement.run(...normalizeBunSqliteParams(params)),
|
||||
get: (...params) => statement.get(...normalizeBunSqliteParams(params)),
|
||||
all: (...params) => statement.all(...normalizeBunSqliteParams(params)),
|
||||
};
|
||||
};
|
||||
return {
|
||||
prepare,
|
||||
query: (sql) => raw.query(sql),
|
||||
exec: (sql) => raw.exec(sql),
|
||||
transaction: (fn) => raw.transaction(fn),
|
||||
close: () => raw.close(),
|
||||
serialize: () => raw.serialize(),
|
||||
pragma: (pragmaStr, pragmaOptions) => {
|
||||
const statement = raw.query(`PRAGMA ${pragmaStr}`);
|
||||
if (pragmaOptions?.simple) {
|
||||
const row = statement.get();
|
||||
return row ? (Object.values(row)[0] ?? null) : null;
|
||||
}
|
||||
return statement.all();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeBunSqliteParams(params) {
|
||||
if (
|
||||
params.length !== 1 ||
|
||||
params[0] === null ||
|
||||
typeof params[0] !== "object" ||
|
||||
Array.isArray(params[0]) ||
|
||||
params[0] instanceof Uint8Array ||
|
||||
(typeof Buffer !== "undefined" && Buffer.isBuffer(params[0]))
|
||||
) {
|
||||
return params;
|
||||
}
|
||||
const expanded = {};
|
||||
for (const [key, value] of Object.entries(params[0])) {
|
||||
if (/^[:@$]/.test(key)) expanded[key] = value;
|
||||
else {
|
||||
expanded[`@${key}`] = value;
|
||||
expanded[`:${key}`] = value;
|
||||
expanded[`$${key}`] = value;
|
||||
}
|
||||
}
|
||||
return [expanded];
|
||||
}
|
||||
|
||||
export function createSqliteNativeError(error) {
|
||||
@@ -94,43 +21,15 @@ export function createSqliteNativeError(error) {
|
||||
"(rebuilds into a user-writable runtime; works without a C++ toolchain)."
|
||||
);
|
||||
}
|
||||
if (
|
||||
message.includes("Could not locate the bindings file") ||
|
||||
message.includes("MODULE_NOT_FOUND") ||
|
||||
message.includes("Cannot find module 'better-sqlite3'")
|
||||
) {
|
||||
return new Error(
|
||||
"better-sqlite3 native binding could not be found (no prebuilt addon for this platform). " +
|
||||
"This is common under `npx`, which runs a fresh, ephemeral install that never built the addon. " +
|
||||
"Run: omniroute runtime repair " +
|
||||
"(rebuilds into a user-writable runtime; works without a C++ toolchain)."
|
||||
);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
async function openSqliteDatabase(dbPath, options = {}) {
|
||||
const loaded = await loadSqlite();
|
||||
if (process.versions.bun) {
|
||||
if (options.fileMustExist && !fs.existsSync(dbPath)) {
|
||||
throw new Error(`SQLite file does not exist: ${dbPath}`);
|
||||
}
|
||||
const bunOptions = options.readonly
|
||||
? { readonly: true }
|
||||
: { readwrite: true, create: options.fileMustExist !== true };
|
||||
try {
|
||||
return openBunSqlite(loaded.Database, dbPath, bunOptions);
|
||||
} catch (error) {
|
||||
throw createSqliteNativeError(error);
|
||||
}
|
||||
}
|
||||
if (loaded.error) {
|
||||
return openWithSyncDriverFallback(dbPath, options, loaded.error);
|
||||
}
|
||||
const Database = await loadBetterSqlite();
|
||||
try {
|
||||
return new loaded.Database(dbPath, options);
|
||||
return new Database(dbPath, options);
|
||||
} catch (error) {
|
||||
return openWithSyncDriverFallback(dbPath, options, error);
|
||||
throw createSqliteNativeError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,16 +59,7 @@ export async function withReadonlySqlite(dbPath, callback) {
|
||||
export async function backupSqliteFile(sourcePath, destPath) {
|
||||
const db = await openSqliteDatabase(sourcePath, { readonly: true });
|
||||
try {
|
||||
if (typeof db.backup === "function") {
|
||||
await db.backup(destPath);
|
||||
} else if (sourcePath === ":memory:" && typeof db.serialize === "function") {
|
||||
fs.writeFileSync(destPath, Buffer.from(db.serialize()));
|
||||
} else {
|
||||
try {
|
||||
db.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
||||
} catch {}
|
||||
fs.copyFileSync(sourcePath, destPath);
|
||||
}
|
||||
await db.backup(destPath);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user