mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-09 00:32:13 +03:00
Compare commits
62 Commits
green/8728
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecb17e6b50 | ||
|
|
165c823f5a | ||
|
|
7ccd1b2342 | ||
|
|
004e5f5bfe | ||
|
|
d5b03504c8 | ||
|
|
d4c54f600b | ||
|
|
dbe750f2ae | ||
|
|
601e2a80cd | ||
|
|
223e67a4fb | ||
|
|
c0f1ee2200 | ||
|
|
1a096c4b3a | ||
|
|
401a7b4430 | ||
|
|
b14fb89fab | ||
|
|
5ba5bcd6d4 | ||
|
|
4008bdc1e8 | ||
|
|
12de25b6f6 | ||
|
|
21c1f540b3 | ||
|
|
fa94f38de5 | ||
|
|
c8833fd3e0 | ||
|
|
43da1bb98c | ||
|
|
a71157a1b8 | ||
|
|
72f8bf21f1 | ||
|
|
9a89158414 | ||
|
|
f5445a2e5e | ||
|
|
f862074209 | ||
|
|
9b0adabb17 | ||
|
|
57f81f60d5 | ||
|
|
1fd19c45ec | ||
|
|
5ac4637816 | ||
|
|
898aac5771 | ||
|
|
9dc31c7e08 | ||
|
|
bc00e32b15 | ||
|
|
ba38e31ff4 | ||
|
|
99b17f13b6 | ||
|
|
35f373e10f | ||
|
|
598bebae26 | ||
|
|
2b0c129ab6 | ||
|
|
bce5d17e3c | ||
|
|
2513934877 | ||
|
|
8adb40e8d1 | ||
|
|
04ae5a72f7 | ||
|
|
29321452a7 | ||
|
|
c779be6532 | ||
|
|
b87b9abe75 | ||
|
|
fa7f81cf0b | ||
|
|
503b1205c6 | ||
|
|
cf8c161a8d | ||
|
|
dbe44bb417 | ||
|
|
4c4dcbd6db | ||
|
|
462bca68b2 | ||
|
|
0568c37eee | ||
|
|
d8a392a477 | ||
|
|
e8d13ec17b | ||
|
|
6f150cfd2c | ||
|
|
6143ffc021 | ||
|
|
3ee53cd561 | ||
|
|
3ab9723a7b | ||
|
|
5f4cd53b6e | ||
|
|
7854b1eef1 | ||
|
|
051285a660 | ||
|
|
8da9e6ad3f | ||
|
|
c4280433c5 |
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
|
||||
@@ -43,17 +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
|
||||
.eslintcache-complexity
|
||||
|
||||
# Documentation
|
||||
# Issue #2348: The Dashboard Docs viewer reads markdown from `/app/docs` at
|
||||
@@ -66,10 +49,6 @@ reports/mutation
|
||||
# (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
|
||||
@@ -146,4 +125,3 @@ app.__qa_backup/
|
||||
.worktrees
|
||||
.next-playwright/
|
||||
cloud/
|
||||
electron/dist-electron
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
|
||||
[*.sh]
|
||||
indent_size = 4
|
||||
708
.env.example
708
.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
|
||||
@@ -1,4 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
if [ "$1" = "view" ]; then echo "3.8.99"; exit 0; fi
|
||||
if [ "$1" = "install" ]; then echo "added 1 package"; exit 0; fi
|
||||
exit 0
|
||||
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/dev/CONTRIBUTION_GOLDEN_PATH.md). The full unit suite,
|
||||
Vitest, the 60% coverage gate, and the production build all run in CI on this PR (#8329):
|
||||
|
||||
- [ ] 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
|
||||
732
.github/workflows/ci.yml
vendored
732
.github/workflows/ci.yml
vendored
File diff suppressed because it is too large
Load Diff
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@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
- uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
with:
|
||||
languages: javascript-typescript
|
||||
queries: security-extended
|
||||
- uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
- uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||
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.5.2
|
||||
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.5.2
|
||||
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.5.2
|
||||
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.5.2
|
||||
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.3
|
||||
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:
|
||||
|
||||
100
.github/workflows/electron-release.yml
vendored
100
.github/workflows/electron-release.yml
vendored
@@ -88,13 +88,13 @@ jobs:
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v7
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
|
||||
- name: Cache node_modules
|
||||
uses: actions/cache@v6.1.0
|
||||
uses: actions/cache@v6.0.0
|
||||
with:
|
||||
path: node_modules
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
|
||||
@@ -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
|
||||
|
||||
139
.github/workflows/nightly-compat.yml
vendored
139
.github/workflows/nightly-compat.yml
vendored
@@ -1,139 +0,0 @@
|
||||
name: Nightly Node Compat
|
||||
|
||||
# Plano mestre testes+CI (Eixo D2, aprovado 2026-07-04): as matrizes de compatibilidade
|
||||
# Node 24/26 custavam ~28% de CADA run do CI pesado (2 execuções completas da suíte por
|
||||
# sync da release-PR) para pegar uma classe de quebra que raramente nasce num PR típico.
|
||||
# Elas rodam aqui 1×/dia contra o tip da release ativa (mesmo alvo do nightly-release-green)
|
||||
# e continuam obrigatórias no gate de release via workflow_dispatch do ci.yml se preciso.
|
||||
# fail-fast desligado: numa quebra queremos saber TODAS as versões afetadas de uma vez.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "47 6 * * *" # 06:47 UTC diário — slot distinto dos demais nightlies
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
branch:
|
||||
description: "Branch to validate (default: highest release/vX.Y.Z)"
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: nightly-compat
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
resolve-branch:
|
||||
name: Resolve active release branch
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
target: ${{ steps.branch.outputs.target }}
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- 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
|
||||
case "$TARGET" in
|
||||
release/v[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "Refusing non-canonical branch name: $TARGET"; exit 1 ;;
|
||||
esac
|
||||
echo "target=$TARGET" >> "$GITHUB_OUTPUT"
|
||||
|
||||
compat-build-26:
|
||||
name: Node 26 Compatibility Build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
needs: resolve-branch
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.resolve-branch.outputs.target }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
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)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
needs: resolve-branch
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node: [24, 26]
|
||||
shard: [1, 2, 3, 4]
|
||||
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"
|
||||
TEST_SHARD: ${{ matrix.shard }}/4
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
ref: ${{ needs.resolve-branch.outputs.target }}
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: ${{ matrix.node }}
|
||||
cache: npm
|
||||
- uses: ./.github/actions/npm-ci-retry
|
||||
- run: npm run check:node-runtime
|
||||
- run: npm run test:unit:ci:shard
|
||||
|
||||
report:
|
||||
name: Open / update tracking issue on failure
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !cancelled() && (needs.compat-tests.result == 'failure' || needs.compat-build-26.result == 'failure') }}
|
||||
needs: [resolve-branch, compat-build-26, compat-tests]
|
||||
permissions:
|
||||
issues: write
|
||||
steps:
|
||||
- name: Open or update issue
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TARGET: ${{ needs.resolve-branch.outputs.target }}
|
||||
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TITLE="🌙 nightly-compat: Node 24/26 failures on $TARGET"
|
||||
EXISTING=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open --search "$TITLE in:title" --json number --jq '.[0].number')
|
||||
BODY="Nightly Node-compat run failed on \`$TARGET\`: $RUN_URL — triage which Node version/shard broke (fail-fast off, all versions reported)."
|
||||
if [ -n "$EXISTING" ]; then
|
||||
gh issue comment "$EXISTING" --repo "$GITHUB_REPOSITORY" --body "$BODY"
|
||||
else
|
||||
gh issue create --repo "$GITHUB_REPOSITORY" --title "$TITLE" --body "$BODY"
|
||||
fi
|
||||
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
|
||||
|
||||
8
.github/workflows/nightly-mutation.yml
vendored
8
.github/workflows/nightly-mutation.yml
vendored
@@ -107,13 +107,13 @@ 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: Restore Stryker incremental cache
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
|
||||
with:
|
||||
path: reports/mutation/stryker-incremental.json
|
||||
key: stryker-incremental-${{ matrix.batch.name }}-${{ github.run_id }}
|
||||
@@ -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
|
||||
|
||||
334
.github/workflows/nightly-release-green.yml
vendored
334
.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,25 +28,13 @@ 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:
|
||||
OMNIROUTE_SKIP_SYSTEM_TRUST: "1"
|
||||
|
||||
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.
|
||||
# Nightly cron normally finds the var false (VM off) and falls back to hosted.
|
||||
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-release"]')) || 'ubuntu-latest' }}
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-nightly-api-key-secret-long
|
||||
@@ -83,15 +49,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 +77,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 +86,9 @@ 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 \
|
||||
1> release-green.json 2> release-green.log
|
||||
echo "exit=$?" >> "$GITHUB_OUTPUT"
|
||||
echo "------- report -------"
|
||||
@@ -157,28 +100,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 +123,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
|
||||
|
||||
11
.github/workflows/nightly-resilience.yml
vendored
11
.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,13 +94,13 @@ 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: Cache Playwright browsers
|
||||
uses: actions/cache@v6.1.0
|
||||
uses: actions/cache@v6.0.0
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: playwright-chromium-${{ runner.os }}-${{ hashFiles('package-lock.json') }}
|
||||
|
||||
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
|
||||
|
||||
200
.github/workflows/npm-publish.yml
vendored
200
.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'
|
||||
@@ -348,56 +205,13 @@ jobs:
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
# 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
|
||||
|
||||
- name: Auto-bump plugin version if plugin changed since last release
|
||||
id: bump
|
||||
working-directory: "@omniroute/opencode-plugin"
|
||||
env:
|
||||
CURRENT_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
PKG_VERSION=$(node -p "require('./package.json').version")
|
||||
PKG_NAME=$(node -p "require('./package.json').name")
|
||||
|
||||
# 1) Skip if current version is not yet published (no bump needed)
|
||||
PUBLISHED="$(npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null || true)"
|
||||
if [ "$PUBLISHED" != "$PKG_VERSION" ]; then
|
||||
echo "✅ ${PKG_NAME}@${PKG_VERSION} is new — no bump needed."
|
||||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 2) Find the previous release tag (exclude the current one)
|
||||
PREV_TAG=$(git tag -l 'v*' --sort=-version:refname \
|
||||
| grep -v "^${CURRENT_TAG}$" | head -1 || echo "")
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
echo "No previous tag to compare — skipping bump."
|
||||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 3) Check if plugin dir actually changed since that tag
|
||||
if git diff --quiet "$PREV_TAG" -- "@omniroute/opencode-plugin/"; then
|
||||
echo "⏭️ No plugin changes since $PREV_TAG — nothing to publish."
|
||||
echo "bumped=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 4) Auto-bump patch version
|
||||
npm version patch --no-git-tag-version --allow-same-version
|
||||
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||
echo "bumped=true" >> "$GITHUB_OUTPUT"
|
||||
echo "📦 Auto-bumped ${PKG_NAME} from ${PKG_VERSION} to ${NEW_VERSION}"
|
||||
|
||||
- name: Install plugin dependencies
|
||||
working-directory: "@omniroute/opencode-plugin"
|
||||
run: npm install --no-audit --no-fund
|
||||
|
||||
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
|
||||
|
||||
464
.github/workflows/quality.yml
vendored
464
.github/workflows/quality.yml
vendored
@@ -14,117 +14,11 @@ permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# CI must never mutate the runner's OS trust store (2026-07-05: a cert-flow
|
||||
# test installed a fake PEM on a persistent self-hosted runner and broke all
|
||||
# system TLS). Belt-and-suspenders with tests/_setup/isolateDataDir.ts.
|
||||
OMNIROUTE_SKIP_SYSTEM_TRUST: "1"
|
||||
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,36 +31,19 @@ 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 }}-
|
||||
- run: npm run check:provider-consistency
|
||||
- run: npm run check:fetch-targets
|
||||
# docs-all / openapi-routes / docs-symbols live in docs-gates (path-filtered).
|
||||
- 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
|
||||
# #8522: --base-ref mode for PR events — compare against max(frozen, base) so
|
||||
# inherited drift (base already over frozen cap) doesn't red an innocent PR.
|
||||
# workflow_dispatch (no PR base) falls back to absolute comparison.
|
||||
- name: File-size ratchet (base-relative on PR)
|
||||
env:
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
if [ -n "$PR_BASE_SHA" ]; then
|
||||
npm run check:file-size -- --base-ref "$PR_BASE_SHA"
|
||||
else
|
||||
npm run check:file-size
|
||||
fi
|
||||
- run: npm run check:file-size
|
||||
- run: npm run check:error-helper
|
||||
- run: npm run check:migration-numbering
|
||||
- run: npm run check:public-creds
|
||||
@@ -175,10 +52,6 @@ jobs:
|
||||
- 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.
|
||||
@@ -187,184 +60,46 @@ jobs:
|
||||
# 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: ONE ESLint walk (both baselines still
|
||||
# enforced separately by ruleId). Avoids two cold tree walks on fast-path.
|
||||
- run: npm run check:complexity-ratchets
|
||||
# ── G0 (trilho .50): gates do trilho A que faltavam no trilho B ──────────────
|
||||
# The god-file refactor happens in PRs→release/**; without these, the release
|
||||
# rail never sees a new import cycle, dead code, duplication or a security
|
||||
# regression until the release PR to main. Deliberately NOT brought here:
|
||||
# bundle-size (self-skips without a build — this rail's build job is advisory
|
||||
# and uploads nothing, so it would be dead configuration) and the coverage
|
||||
# run (fast-unit already runs the full suite; the coverage ratchet stays on
|
||||
# the main rail via --allow-missing in lint-guard).
|
||||
- run: npm run check:cycles
|
||||
- run: npm run check:lockfile
|
||||
- name: Duplication ratchet
|
||||
run: npm run check:duplication
|
||||
- name: Dead-code ratchet (knip)
|
||||
run: npm run check:dead-code
|
||||
- name: Type coverage ratchet
|
||||
run: npm run check:type-coverage
|
||||
- name: Compression budget ratchet
|
||||
run: npm run check:compression-budget
|
||||
# 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: Secret scan (gitleaks, ratchet, blocking)
|
||||
run: npm run check:secrets -- --ratchet
|
||||
- name: Vulnerability ratchet (osv-scanner, ratchet, blocking)
|
||||
run: npm run check:vuln-ratchet -- --ratchet
|
||||
- name: Workflow lint (actionlint+zizmor, ratchet, blocking)
|
||||
run: npm run check:workflows -- --ratchet
|
||||
# BASE_REF is read by the script from the env (never interpolated into a
|
||||
# shell body) — workflow-injection-safe. actions/checkout fetches remote
|
||||
# refs, not a local branch named github.base_ref, so prefix origin/ or this
|
||||
# gate self-skips every PR with reason=base-unresolved.
|
||||
- name: OpenAPI breaking-change (oasdiff, ratchet, blocking)
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref && format('origin/{0}', github.base_ref) || '' }}
|
||||
run: npm run check:openapi-breaking -- --ratchet
|
||||
# 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
|
||||
# #7033: dashboard-scoped typecheck gate — src/app/(dashboard) TSX is not
|
||||
# covered by typecheck:core's curated allowlist. See check-dashboard-typecheck.mjs.
|
||||
- name: Typecheck (dashboard)
|
||||
run: npm run check:dashboard-typecheck
|
||||
# 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.
|
||||
# Promote to the blocking gate after ~1 week of parity with the step above.
|
||||
- name: Typecheck (core) — TS7 native shadow (advisory)
|
||||
continue-on-error: true
|
||||
run: |
|
||||
RC=0
|
||||
START=$(date +%s)
|
||||
npx -y -p typescript@7 tsc --pretty false -p tsconfig.typecheck-core.json || RC=$?
|
||||
echo "[ts7-shadow] exit=$RC elapsed=$(( $(date +%s) - START ))s — the 6.x step above stays authoritative"
|
||||
exit $RC
|
||||
# TIA: build the impact map at runtime (gitignored, ~21MB) and run only the
|
||||
# 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
|
||||
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 --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
|
||||
@@ -374,44 +109,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
|
||||
@@ -420,119 +131,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
|
||||
- run: npm ci
|
||||
# QW-d: fonte única — o mesmo npm script do CI pesado/local. Fecha dois drifts do
|
||||
# comando inline antigo: os dirs `memory` e `usage` estavam FORA do glob (testes
|
||||
# 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
|
||||
|
||||
# ── Pacote 4 (plano mestre testes+CI, aprovado 2026-07-04) ─────────────────────────
|
||||
# No-new-warnings por PR via ESLint bulk suppressions nativo (>=9.24). O baseline
|
||||
# config/quality/eslint-suppressions.json congela as violações EXISTENTES por
|
||||
# arquivo+regra; qualquer warning NOVO aparece e o --max-warnings 0 falha o job — o
|
||||
# drift de +41/+88 warnings por ciclo passa a morrer no PR que o introduz, em vez de
|
||||
# ser rebaselinado às cegas na release. Aperto do baseline (na reconciliação da
|
||||
# release): npx eslint . --prune-suppressions --suppressions-location config/quality/eslint-suppressions.json
|
||||
#
|
||||
# Princípio Zero: bloqueante SÓ para branches internas (as campanhas/sessões são a
|
||||
# origem do drift). PR de FORK roda em modo report (continue-on-error → o job fica
|
||||
# verde com anotação; a campanha /green-prs aplica o fix via co-autoria — o
|
||||
# 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
|
||||
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
|
||||
|
||||
# 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
|
||||
# bullets vizinhos/seções inteiras (incidente #6193, 2026-07-05: 212 linhas /
|
||||
# 130 bullets); o checkout de PR é refs/pull/N/merge, então comparar contra a
|
||||
# base detecta o eat ANTES do merge. (2) SKILL.md gerado stale vs o catálogo de
|
||||
# agent-skills (#6186 mergeou um id de catálogo sem rodar o gerador → 8 reds de
|
||||
# integration invisíveis até a release).
|
||||
#
|
||||
# Princípio Zero: bloqueante SÓ para branches internas; PR de FORK roda em modo
|
||||
# report (continue-on-error) — a campanha corrige via co-autoria, o contribuidor
|
||||
# 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:
|
||||
JWT_SECRET: ci-lint-secret-with-sufficient-length-for-validation
|
||||
API_KEY_SECRET: ci-lint-api-key-secret-long
|
||||
DISABLE_SQLITE_AUTO_BACKUP: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v6
|
||||
with:
|
||||
node-version: ${{ env.CI_NODE_VERSION }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: CHANGELOG integrity (nenhum bullet da base pode sumir no merge-result)
|
||||
run: npm run check:changelog-integrity
|
||||
- name: Agent-skills generator sync (SKILL.md gerado ≡ catálogo)
|
||||
run: npm run check:agent-skills-sync
|
||||
- run: >
|
||||
node --max-old-space-size=4096 --import tsx
|
||||
--import ./tests/_setup/isolateDataDir.ts
|
||||
--test --test-force-exit --test-concurrency=4 --test-shard=${{ matrix.shard }}/2
|
||||
tests/unit/*.test.ts
|
||||
"tests/unit/{api,auth,authz,build,cli,cli-helper,combo,compression,correctness,cors,dashboard,db,db-adapters,docs,gamification,guardrails,lib,mcp,runtime,security,services,settings,shared,ui}/**/*.test.ts"
|
||||
|
||||
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"
|
||||
|
||||
|
||||
35
.gitignore
vendored
35
.gitignore
vendored
@@ -72,7 +72,6 @@ yarn-error.log*
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
!.env.homolog.example
|
||||
# Provider API keys (never commit)
|
||||
*.api-key
|
||||
.nvidia-api-key
|
||||
@@ -171,6 +170,7 @@ config/quality/test-impact-map.json
|
||||
# GitNexus local index
|
||||
.gitnexus
|
||||
.worktrees
|
||||
bin/omniroute.mjs
|
||||
|
||||
# Consistent with .dockerignore / .npmignore
|
||||
.omc/
|
||||
@@ -200,10 +200,7 @@ scripts/i18n/_pending-keys.json
|
||||
.codegraph/
|
||||
|
||||
# Fumadocs generated source
|
||||
/.source/
|
||||
|
||||
# Temporary local worktrees used to build unpublished npm tarballs
|
||||
/.deploy-build-*/
|
||||
.source/
|
||||
|
||||
# AI agent local settings and configs
|
||||
.agents/
|
||||
@@ -232,31 +229,3 @@ docs/prompts/AGENT-OWNERSHIP-PROTOCOL.md
|
||||
docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute-mim.md
|
||||
docs/prompts/AGENT-OWNERSHIP-PROTOCOL.omniroute-mid.md
|
||||
omniroute.md
|
||||
|
||||
# mise configuration
|
||||
mise.toml
|
||||
# release-green artifacts (.gitignore has no inline comments — a trailing
|
||||
# `# ...` becomes part of the pattern, so it must sit on its own line).
|
||||
# Already covered by /_*/ above; kept explicit for discoverability.
|
||||
_artifacts/
|
||||
.claude-flow/
|
||||
|
||||
# ESLint file cache (npm run lint --cache / complexity ratchets)
|
||||
.eslintcache
|
||||
.eslintcache-complexity
|
||||
|
||||
|
||||
# CI/local quality artifacts (eslint-results.json, quality-ratchet.md, etc.)
|
||||
.artifacts/
|
||||
|
||||
# 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
|
||||
.playwright-cli/
|
||||
# Playwright screenshot/log output. Today every artifact happens to land inside
|
||||
# output/**/.playwright-cli/ (covered above), but anything written directly to
|
||||
# output/ would otherwise show up as untracked.
|
||||
/output/
|
||||
|
||||
@@ -74,22 +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), 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-v1''',
|
||||
'''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
|
||||
15
.npmignore
15
.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,6 +93,10 @@ vscode-extension/
|
||||
|
||||
# Root-level underscore-prefixed directories (private/draft — never publish)
|
||||
/_*/
|
||||
app/_*/
|
||||
app/coverage/
|
||||
app/logs/
|
||||
app/tests/
|
||||
|
||||
# Consistent with .gitignore and .dockerignore
|
||||
.DS_Store
|
||||
|
||||
10
.npmrc
10
.npmrc
@@ -2,13 +2,3 @@
|
||||
# Keeping peer auto-install disabled prevents npm from pulling @lobehub/ui/mermaid
|
||||
# back into the tree and reopening npm audit findings for unused packages.
|
||||
legacy-peer-deps=true
|
||||
|
||||
# Network resilience: enlarge npm's fetch retry budget so a transient registry
|
||||
# socket reset (ECONNRESET) mid-download retries instead of failing the job.
|
||||
# npm defaults to only 2 retries with short timeouts; `npm ci` in
|
||||
# electron-release.yml hit ECONNRESET during v3.8.41 publish. Applies to every
|
||||
# CI workflow (electron / docker / unit) and local installs.
|
||||
fetch-retries=5
|
||||
fetch-retry-factor=4
|
||||
fetch-retry-mintimeout=20000
|
||||
fetch-retry-maxtimeout=120000
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
],
|
||||
@@ -92,27 +88,6 @@ opencode auth login --provider 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.
|
||||
@@ -190,7 +165,7 @@ 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 |
|
||||
@@ -204,33 +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. |
|
||||
| `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
|
||||
|
||||
@@ -242,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,
|
||||
|
||||
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.0",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@omniroute/opencode-plugin",
|
||||
"version": "0.2.0",
|
||||
"version": "0.1.0",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@omniroute/opencode-plugin",
|
||||
"version": "0.2.1",
|
||||
"version": "0.1.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",
|
||||
"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,
|
||||
|
||||
@@ -248,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["omniroute/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");
|
||||
@@ -474,7 +474,7 @@ test("config: combos fetcher throws → emit models-only catalog (no combos in m
|
||||
"opencode-omniroute/claude-sonnet-4-6",
|
||||
"opencode-omniroute/gemini-3-flash",
|
||||
]);
|
||||
assert.equal(entry.models["omniroute/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"
|
||||
@@ -748,7 +748,7 @@ test("buildStaticProviderEntry: hidden combos are excluded", () => {
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
assert.equal(block.models["omniroute/claude-tier"], undefined);
|
||||
assert.equal(block.models["opencode-omniroute/claude-tier"], undefined);
|
||||
assert.ok(block.models["opencode-omniroute/claude-sonnet-4-6"]);
|
||||
});
|
||||
|
||||
@@ -858,7 +858,7 @@ test("buildStaticProviderEntry: combo modalities = intersection of members (LCD)
|
||||
"https://or.example/v1",
|
||||
"sk-test"
|
||||
);
|
||||
const combo = block.models["omniroute/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"]);
|
||||
@@ -970,7 +970,7 @@ test("config: enrichment fetched + name overlaid on raw-model entries", async ()
|
||||
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["omniroute/claude-tier"].name, "Claude Tier");
|
||||
assert.equal(entry.models["opencode-omniroute/claude-tier"].name, "Claude Tier");
|
||||
assert.equal(enrichmentFetcher.callCount(), 1);
|
||||
});
|
||||
|
||||
@@ -1337,7 +1337,7 @@ test("config: providerTag (default-on) prepends '<provider> - ' to enriched raw-
|
||||
);
|
||||
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["omniroute/claude-tier"].name, "Claude Tier");
|
||||
assert.equal(entry.models["opencode-omniroute/claude-tier"].name, "Claude Tier");
|
||||
});
|
||||
|
||||
test("config: providerTag=false suppresses the suffix", async () => {
|
||||
@@ -1516,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["omniroute/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,141 +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").
|
||||
test("#7976: buildStaticProviderEntry keys bare-slug combo ids with the unprefixed omnirouteProviderId (no double OC-gate prefix)", () => {
|
||||
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), ["omniroute/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");
|
||||
|
||||
3178
CHANGELOG.md
3178
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
570
CLAUDE.md
570
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 22 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 (94 files, 106 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,149 @@ 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
|
||||
- **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`.
|
||||
|
||||
---
|
||||
|
||||
## 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/dev/CONTRIBUTION_GOLDEN_PATH.md). It maps provider, routing,
|
||||
UI/UX, i18n, CLI, database, and build/deploy changes to their contracts, focused tests, CI
|
||||
coverage, and reconciliation steps.
|
||||
|
||||
---
|
||||
|
||||
## Development Setup
|
||||
@@ -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/dev/CONTRIBUTION_GOLDEN_PATH.md) to run the focused loop for
|
||||
what you changed. The full unit suite (4 CI shards), Vitest, the **60%+** coverage gate, and
|
||||
the production build are CI's responsibility — running them locally adds no signal the PR
|
||||
checks will not already give you, and on smaller machines it can saturate the host (#8084):
|
||||
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,7 +247,7 @@ src/ # TypeScript (.ts / .tsx)
|
||||
├── mitm/ # MITM proxy (cert, DNS, target routing)
|
||||
├── shared/
|
||||
│ ├── components/ # React components (.tsx)
|
||||
│ ├── constants/ # Provider definitions (290), 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
|
||||
@@ -301,7 +255,7 @@ src/ # TypeScript (.ts / .tsx)
|
||||
open-sse/ # @omniroute/open-sse workspace
|
||||
├── executors/ # 14 provider-specific request executors
|
||||
├── handlers/ # 11 request handlers (chat, responses, embeddings, images, etc.)
|
||||
├── mcp-server/ # MCP server (104 tools, 3 transports, 31 scopes)
|
||||
├── 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
|
||||
@@ -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)
|
||||
|
||||
254
DESING.md
Normal file
254
DESING.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# OmniRoute — Design System & Visual Identity
|
||||
|
||||
> **Status:** analysis + standardization plan (no code applied yet — this doc is the spec to approve before implementation).
|
||||
> **Date:** 2026-06-16 · **Scope:** unify the OmniRoute dashboard (`src/`) with the marketing site (`_mono_repo/omnirouteSite/`) into **one visual identity** — same graph-paper grid background, same color tokens, standardized components.
|
||||
|
||||
---
|
||||
|
||||
## 1. Purpose
|
||||
|
||||
The marketing site (`viral.omniroute.online`, `why.omniroute.online`, `omniroute.online`) and the product dashboard should look like **one product**. The site already borrowed its palette from the dashboard — its `css/tokens.css` even says _"Palette mirrors the OmniRoute dashboard (src/app/globals.css)"_. So the two are already ~80% aligned at the color level. What's missing on the dashboard:
|
||||
|
||||
1. The **graph-paper grid wallpaper** the site uses on every page.
|
||||
2. A handful of **shared design tokens** the site has but the dashboard lacks (radius scale, brand gradient, `surface-2`, mono font).
|
||||
3. **Component-level consistency** — a number of dashboard components bypass the theme tokens with hardcoded hex/rgba.
|
||||
|
||||
This document is the analysis and the plan. **Nothing is changed until approved.**
|
||||
|
||||
---
|
||||
|
||||
## 2. Principles
|
||||
|
||||
- **Single source of truth = `src/app/globals.css`.** The site mirrors the dashboard, never the other way around. New tokens land in `globals.css` first.
|
||||
- **Tokens, never literals.** Components consume semantic tokens (`bg-surface`, `text-primary`, `border-border`), never raw `#hex`.
|
||||
- **Subtle, not loud.** The grid is a faint wallpaper that sits behind content — it must never reduce text contrast or fight the UI.
|
||||
- **Theme-aware.** Everything works in both `.dark` (default-ish, the product's signature look) and light.
|
||||
- **Surgical rollout.** Ship the grid + tokens first (low risk, high visibility), then component cleanups in waves.
|
||||
|
||||
---
|
||||
|
||||
## 3. Current state — what's already aligned vs. what's not
|
||||
|
||||
### 3.1 Colors — already unified ✅
|
||||
|
||||
Every brand color and surface already matches the site **by value** (only the names differ — dashboard prefixes with `--color-`). Verified in `src/app/globals.css:30-128`:
|
||||
|
||||
| Concept | Site token (`tokens.css`) | Dashboard token (`globals.css`) | Match |
|
||||
| -------------------------- | ------------------------------------------- | ------------------------------- | ------------ |
|
||||
| primary | `--primary #e54d5e` | `--color-primary #e54d5e` | ✅ |
|
||||
| primary-hover | `--primary-hover #c93d4e` | `--color-primary-hover #c93d4e` | ✅ |
|
||||
| accent | `--accent #6366f1` | `--color-accent #6366f1` | ✅ |
|
||||
| accent-2 | `--accent-2 #8b5cf6` | `--color-accent-hover #8b5cf6` | ✅ (renamed) |
|
||||
| accent-3 | `--accent-3 #a855f7` | `--color-accent-light #a855f7` | ✅ (renamed) |
|
||||
| success / warning / error | `#22c55e / #f59e0b / #ef4444` | identical | ✅ |
|
||||
| traffic lights | `#ff5f56 / #ffbd2e / #27c93f` | identical | ✅ |
|
||||
| dark bg / surface / border | `#0b0e14 / #161b22 / rgba(255,255,255,.08)` | identical | ✅ |
|
||||
| light bg / surface / text | `#f9f9fb / #fff / #1a1a2e` | identical | ✅ |
|
||||
|
||||
**Conclusion:** there is no color migration to do. The identity is already shared; we are _finishing_ it, not rebuilding it.
|
||||
|
||||
### 3.2 Gaps — what the dashboard is missing
|
||||
|
||||
| Gap | Site has | Dashboard | Action |
|
||||
| ----------------------- | ------------------------------------------------------------------------------ | ---------------------------------------------------------- | ---------------------- |
|
||||
| **Grid wallpaper** | `body::before` graph-paper, `--grid-line`, `--grid-size 46px`, `--section-alt` | none (flat `--color-bg`) | **Part A** |
|
||||
| **Radius scale** | `--radius 14px`, `--radius-sm 9px` | none — primitives use ad-hoc `rounded-md/lg/xl` (6/8/12px) | **Part B** |
|
||||
| **Brand gradient** | `--grad-brand 135deg primary→accent-3` | none — only a one-off `.bg-hero-gradient` | **Part B** |
|
||||
| **Nested surface** | `--surface-2 #1c2230` | none | **Part B** |
|
||||
| **Mono font** | `--font-mono` (ui-monospace stack) | none (code/terminal areas have no token) | **Part B** |
|
||||
| **`text-muted` (dark)** | `#8b8b9e` | `#a1a1aa` (zinc-400) | reconcile — **Part B** |
|
||||
|
||||
### 3.3 Theming mechanics (so we don't break anything)
|
||||
|
||||
- **Tailwind v4, CSS-first** (no `tailwind.config.*`). Tokens are defined in `:root`/`.dark` and exposed to utilities via `@theme inline` (`globals.css:130-179`).
|
||||
- **Dark via `.dark` class** on `<html>` (`@custom-variant dark` at `globals.css:22`), toggled by a custom Zustand store (`src/store/themeStore.ts`), default theme = `system` (`src/shared/constants/appConfig.ts:11`). The site uses `html[data-theme="light"]` instead — **the mechanisms differ but never meet** (separate origins), so no conflict. We keep the dashboard's `.dark` mechanism.
|
||||
- **Runtime primary override** exists (`themeStore.ts:85-97`, presets in `COLOR_THEMES`) — users can swap `--color-primary`. Any new token (gradient, etc.) that references `--color-primary` will inherit those overrides for free. ✅
|
||||
|
||||
---
|
||||
|
||||
## 4. Part A — The graph-paper grid background (headline ask)
|
||||
|
||||
### 4.1 What it is
|
||||
|
||||
The exact recipe from the site (`_mono_repo/omnirouteSite/css/base.css`): a **fixed, full-viewport pseudo-element** painting two 1px line gradients, sitting at `z-index:-1` behind all content.
|
||||
|
||||
```css
|
||||
body::before {
|
||||
content: "";
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
background-image:
|
||||
linear-gradient(to right, var(--grid-line) 1px, transparent 1px),
|
||||
linear-gradient(to bottom, var(--grid-line) 1px, transparent 1px);
|
||||
background-size: var(--grid-size) var(--grid-size);
|
||||
}
|
||||
```
|
||||
|
||||
**Why this works even though `body` has an opaque `background-color`:** a `::before` with `z-index:-1` paints _above_ the element's own background but _below_ its in-flow content. So `--color-bg` is the base fill, the grid is layered on top of it, and the app renders above the grid.
|
||||
|
||||
### 4.2 Precedent already in the codebase
|
||||
|
||||
`src/app/landing/page.tsx:16-26` **already implements this same grid per-page** — but with **red** lines (`#E54D5E`, opacity `0.06`) at **50px**, plus animated orbs. So the pattern is proven in the product; we are promoting it to a **global, theme-aware** wallpaper and (optionally) retiring the duplicate.
|
||||
|
||||
### 4.3 Tokens to add (in `globals.css`)
|
||||
|
||||
```css
|
||||
:root {
|
||||
/* light */
|
||||
--grid-line: rgba(0, 0, 0, 0.045);
|
||||
--grid-size: 46px;
|
||||
--section-alt: rgba(0, 0, 0, 0.022);
|
||||
}
|
||||
.dark {
|
||||
/* dark */
|
||||
--grid-line: rgba(255, 255, 255, 0.035);
|
||||
--section-alt: rgba(255, 255, 255, 0.018);
|
||||
}
|
||||
```
|
||||
|
||||
### 4.4 The single blocker
|
||||
|
||||
The grid is global by construction (it covers the panel, `auth`/`login`, error pages — every route — at once). Exactly **one** element hides it inside the panel:
|
||||
|
||||
- `src/shared/components/layouts/DashboardLayout.tsx:62` — the outer wrapper paints an opaque `bg-bg`:
|
||||
|
||||
```jsx
|
||||
<div className="flex h-dvh min-h-0 w-full overflow-hidden bg-bg">
|
||||
```
|
||||
|
||||
Everything below it is already transparent — `<main>` (`:93`), the scroll container (`:102`), the `max-w-7xl` inner (`:103`). So **removing `bg-bg` from this one line** lets the body grid show through the entire content area (the body's `--color-bg` remains the base fill underneath the grid).
|
||||
|
||||
```diff
|
||||
- <div className="flex h-dvh min-h-0 w-full overflow-hidden bg-bg">
|
||||
+ <div className="flex h-dvh min-h-0 w-full overflow-hidden">
|
||||
```
|
||||
|
||||
### 4.5 Chrome interaction (sidebar / header)
|
||||
|
||||
- `Header` (`src/shared/components/Header.tsx:207`, `bg-bg`) and `Sidebar` (`src/shared/components/Sidebar.tsx:430`, `bg-sidebar`) stay **opaque** → the grid shows in the **content area only**, with solid chrome framing it. This is the recommended, calm default and matches how the site separates chrome from canvas.
|
||||
- _Optional vibrancy variant:_ make the header translucent (`bg-bg/80 backdrop-blur`) so the grid runs behind it. A `.bg-vibrancy` helper already exists (`globals.css:370`). **Decision D3 below.**
|
||||
|
||||
### 4.6 Login / auth / error pages
|
||||
|
||||
These render directly under `<body>` (no panel chrome) and their page wrappers are mostly transparent — the global grid appears behind them automatically. One exception: `src/app/login/page.tsx:124,139` uses opaque `bg-bg` wrappers; soften the same way if we want the grid there too (minor, **D4**).
|
||||
|
||||
### 4.7 Landing page
|
||||
|
||||
`landing/page.tsx` keeps its richer animated background (orbs + vignette). Options: (a) leave it as-is (its own splash identity), or (b) align its grid to the global tokens (46px, neutral lines) for consistency. **Recommend (a)** — it's a marketing splash, not a panel screen. **Decision D5.**
|
||||
|
||||
---
|
||||
|
||||
## 5. Part B — Token unification
|
||||
|
||||
Add to `globals.css` (`:root` + `@theme inline`) so the dashboard gains the site's missing tokens. None of these change existing colors; they add the _missing_ primitives.
|
||||
|
||||
```css
|
||||
:root {
|
||||
--surface-2: #f5f5fa; /* light: nested panels */
|
||||
--radius: 14px;
|
||||
--radius-sm: 9px;
|
||||
--grad-brand: linear-gradient(135deg, var(--color-primary), var(--color-accent-light));
|
||||
--font-mono: ui-monospace, "JetBrains Mono", "Fira Code", "SF Mono", monospace;
|
||||
}
|
||||
.dark {
|
||||
--surface-2: #1c2230;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-surface-2: var(--surface-2); /* enables bg-surface-2 */
|
||||
--radius-lg: var(--radius); /* enables rounded-lg = 14px */
|
||||
--radius-md: var(--radius-sm); /* enables rounded-md = 9px */
|
||||
--font-mono: var(--font-mono); /* enables font-mono */
|
||||
}
|
||||
```
|
||||
|
||||
| Token | Why | Consumers |
|
||||
| -------------------------- | --------------------------------------------------------------- | ----------------------------------------------------------- |
|
||||
| `--radius` / `--radius-sm` | One radius scale (14/9) instead of 6/8/12 ad-hoc | Button, Card, Modal, Input, Select |
|
||||
| `--grad-brand` | Brand gradient for primary CTAs (red→violet), matching the site | Button `primary`, hero/CTA surfaces |
|
||||
| `--surface-2` | Nested panels / table headers / inset rows | Card.Section, DataTable header, inputs |
|
||||
| `--font-mono` | Code blocks, terminal, IDs, endpoints | ConsoleLogViewer, code snippets, `localhost:20128/v1` chips |
|
||||
| `--text-muted` reconcile | Pick one value site↔panel | global |
|
||||
|
||||
**Decision D2 (text-muted):** site `#8b8b9e` vs dashboard `#a1a1aa`. Recommend keeping the **dashboard's `#a1a1aa`** (it's the live product, slightly higher contrast) and updating the _site_ to match. Low priority, cosmetic.
|
||||
|
||||
---
|
||||
|
||||
## 6. Part C — Component standardization
|
||||
|
||||
The component layer is **custom** (no shadcn/Radix), Tailwind v4, semantic tokens **mostly** adopted (`bg-surface`, `border-white/10`, `ring-primary`) — good adoption (195 files import the shared barrel). The work is removing the **bypasses**. Home: `src/shared/components/`.
|
||||
|
||||
Ranked by impact × reach:
|
||||
|
||||
| # | Item | File(s) | Problem → Target |
|
||||
| --- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| C1 | **Radius alignment** | `Button.tsx:14-18`, `Card.tsx:39`, `Modal.tsx`, `Input.tsx`, `Select.tsx` | mixed 6/8/12px → repoint to `--radius`/`--radius-sm` (14/9) |
|
||||
| C2 | **Button gradient + `accent` variant** | `Button.tsx:5-12` | primary is flat red→red (`from-primary to-primary-hover`); align to `--grad-brand` (red→violet) and add the missing `accent` variant (indigo `#6366f1` is unused by buttons) — **highest visibility, ~195 importers**. **Decision D1.** |
|
||||
| C3 | **Tables** | `DataTable.tsx:122-176`, `logTableStyles.ts`, `globals.css:405-414` (Ant remnants) | `DataTable` is 100% inline hardcoded rgba + references non-existent vars (`--text-secondary`, `--bg-table-header`); migrate to tokens, retire the 2 divergent table styles. Tables are everywhere (providers/connections/logs) — worst offender. |
|
||||
| C4 | **Centralize status colors** | `flow/edgeStyles.ts:7-12`, `TokenHealthBadge.tsx:14-19`, `DegradationBadge.tsx`, `ProviderCascadeNode.tsx`, `Badge.tsx`, +5 `statusColor` helpers | 6+ copies of the same `#22c55e/#f59e0b/#ef4444` hex; create one `statusColors` module driven off `--color-success/warning/error`. Critical for circuit-breaker / cooldown / lockout badges to read consistently. |
|
||||
| C5 | **Card border** | `Card.tsx:39` | uses `border-white/5`; brand border is `/8` → align |
|
||||
| C6 | **Focus ring reconcile** | `globals.css:183` vs component `ring-primary/30` | global `:focus-visible` is indigo (`--color-accent`), components are red (`ring-primary`) — pick one (recommend **accent/indigo** globally, it reads as the "interactive" color) |
|
||||
| C7 | **Add `Checkbox` + `Textarea` primitives** | currently raw `<input>`/`<textarea>` with inline `accentColor:#6366f1` (e.g. `ColumnToggle.tsx:91`) | create token-driven primitives |
|
||||
| C8 | **Hardcoded-hex sweep** | `ConsoleLogViewer.tsx:240` (`#161b22`/`#30363d`), `ComboLiveStudio.tsx:306` (`#6366f120`), Modal traffic dots `Modal.tsx:149-159`, ~14 chart/component files with literal `#6366f1`/`#a855f7` | replace literals with `bg-surface`/`border-border`/`text-accent` etc. |
|
||||
| C9 | **`cn()` → clsx + tailwind-merge** | `src/shared/utils/cn.ts` | current `cn` just joins; conflicting classes stack (a `className="rounded-2xl"` override won't replace a primitive's `rounded-lg`). Needed for C1 overrides to behave. |
|
||||
|
||||
**Already on-brand (token-driven, only need radius):** `Badge`, `Toggle`, `SegmentedControl`, `Input`, `Select`.
|
||||
|
||||
---
|
||||
|
||||
## 7. Rollout plan (phased, each phase shippable + testable)
|
||||
|
||||
- **Phase 1 — Grid + tokens (low risk, high visibility).**
|
||||
1. Add grid + identity tokens to `globals.css` (Part A §4.3, Part B §5).
|
||||
2. Add `body::before` grid.
|
||||
3. Remove `bg-bg` from `DashboardLayout.tsx:62`.
|
||||
4. Verify across themes + key screens (dashboard, providers, logs, login, an error page). Confirm contrast unchanged.
|
||||
→ _Delivers the headline ask. Reversible in one commit._
|
||||
|
||||
- **Phase 2 — Primitives radius + Button (C1, C2, C5, C9).** The visible "feel" pass. `cn()` upgrade first so overrides behave.
|
||||
|
||||
- **Phase 3 — Tables + status colors (C3, C4).** The largest consistency win; touch the data-heavy screens.
|
||||
|
||||
- **Phase 4 — Cleanup (C6, C7, C8).** Focus ring, new primitives, hardcoded-hex sweep.
|
||||
|
||||
Each phase: `npm run lint` + `npm run typecheck:core` + a visual pass. Per repo rule, production-code changes ship with tests where applicable (token/CSS changes are visual — validated by screenshots; component API changes get unit coverage).
|
||||
|
||||
---
|
||||
|
||||
## 8. Open decisions (need your call before/while implementing)
|
||||
|
||||
- **D1 — Button primary look.** Keep the current **red→red** gradient, or switch the product's primary buttons to the **red→violet `--grad-brand`** (matches the site CTAs)? _(Affects every primary button.)_ Recommend: **red→violet**, with `--grad-brand`.
|
||||
- **D2 — Grid line color.** **Neutral** lines (site style: faint white/black, `rgba(255,255,255,0.035)`) — calm, content-first — **or** the landing's **brand-red** lines? Recommend: **neutral** (matches the site's interior pages; red is louder and can tint readability). Size **46px** (site) to retire the landing's 50px drift.
|
||||
- **D3 — Chrome vibrancy.** Sidebar/header stay **solid** (grid in content area only), or go **translucent** so the grid runs behind them? Recommend: **solid** (calmer; less risk).
|
||||
- **D4 — Auth/login grid.** Soften `login/page.tsx` wrappers so the grid shows there too? Recommend: **yes** (cheap, more cohesive).
|
||||
- **D5 — Landing page.** Leave its animated splash bg as-is, or align it to the global grid? Recommend: **leave as-is**.
|
||||
- **D6 — Radius value.** Adopt **14/9** everywhere (bigger, softer, site-matching) — confirm you want this product-wide shift. Recommend: **yes**, it's the single biggest "one identity" signal.
|
||||
- **D7 — Scope of first PR.** Ship **Phase 1 only** first (grid + tokens), then iterate? Recommend: **yes** — validate the wallpaper live before the component waves.
|
||||
|
||||
---
|
||||
|
||||
## 9. Out of scope / risks
|
||||
|
||||
- **No palette change** — colors already match; we only add missing tokens. Zero risk of recoloring the product.
|
||||
- **No theme-engine change** — keep `.dark` + Zustand store; don't migrate to `next-themes` or to the site's `data-theme`.
|
||||
- **Radius shift is broad** (D6) — it touches every card/button/input; that's the point, but it's the one change worth eyeballing on busy screens (tables, modals) before merge.
|
||||
- **Tables (C3)** carry the most hardcoded styling and the highest regression surface — isolate in its own PR with before/after screenshots.
|
||||
- **Worktree isolation (repo hard-rule #19):** implementation runs in a dedicated worktree on a branch cut from the confirmed base (likely `release/v3.8.28`), never on the shared checkout. This doc is the only artifact written to the working tree so far.
|
||||
|
||||
---
|
||||
|
||||
## 10. Reference index
|
||||
|
||||
| Area | Path |
|
||||
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Dashboard tokens | `src/app/globals.css:30-179` (`:root`, `.dark`, `@theme inline`), `body` `:206` |
|
||||
| Theme store | `src/store/themeStore.ts`, `src/shared/components/ThemeProvider.tsx`, `src/shared/constants/appConfig.ts:9-11` |
|
||||
| Panel shell (grid blocker) | `src/shared/components/layouts/DashboardLayout.tsx:62` |
|
||||
| Chrome | `src/shared/components/Header.tsx:207`, `src/shared/components/Sidebar.tsx:430` |
|
||||
| Grid precedent | `src/app/landing/page.tsx:16-26` |
|
||||
| Primitives | `src/shared/components/{Button,Card,Input,Select,Badge,Modal,Toggle,SegmentedControl,Loading,Tooltip,DataTable}.tsx`, barrel `index.tsx` |
|
||||
| Status-color sources | `flow/edgeStyles.ts`, `TokenHealthBadge.tsx`, `DegradationBadge.tsx`, `logTableStyles.ts` |
|
||||
| `cn` util | `src/shared/utils/cn.ts` |
|
||||
| Site reference | `_mono_repo/omnirouteSite/css/tokens.css`, `css/base.css` (grid `body::before`) |
|
||||
110
Dockerfile
110
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,8 +8,8 @@ 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,target=/var/cache/apt,sharing=shared \
|
||||
--mount=type=cache,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 \
|
||||
@@ -29,8 +29,8 @@ 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,target=/var/cache/apt,sharing=shared \
|
||||
--mount=type=cache,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/*
|
||||
@@ -39,7 +39,7 @@ COPY package*.json ./
|
||||
# Workspace package manifests MUST be present before `npm ci` so npm materializes
|
||||
# the workspace and installs its *workspace-only* deps (e.g. safe-regex,
|
||||
# @toon-format/toon — declared in open-sse/package.json, not hoisted to root).
|
||||
# Without this, `npm ci` skips them and the application build fails with "Module not
|
||||
# Without this, `npm ci` skips them and `npm run build` fails with "Module not
|
||||
# found" (root cause of the v3.8.39 Docker build break). workspaces = ["open-sse"].
|
||||
COPY open-sse/package.json ./open-sse/package.json
|
||||
COPY scripts/build/postinstall.mjs ./scripts/build/postinstall.mjs
|
||||
@@ -55,73 +55,33 @@ 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))
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
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
|
||||
# root" in ImportTracer::get_traces) no longer reproduces on Next 16.2.9 — validated
|
||||
# 2026-07-05 with clean amd64 (12min14s, image smoke-tested: /api/monitoring/health
|
||||
# 200) and arm64 (qemu, exit 0, zero panic strings) builds. Turbopack cut the bare
|
||||
# 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.
|
||||
# Build with webpack (stable). Turbopack hit a non-recoverable internal panic on this
|
||||
# Next.js version during the v3.8.27 release build — TurbopackInternalError "entered
|
||||
# unreachable code: there must be a path to a root" in ImportTracer::get_traces, on both
|
||||
# linux/amd64 and linux/arm64. Webpack is the proven engine (build:release / VPS / CI Build
|
||||
# all green). Re-enable Turbopack (=1) once the upstream tracer bug is fixed.
|
||||
# See docs/ops/QUALITY_GATE_PLAYBOOK.md Parte 6.
|
||||
ENV OMNIROUTE_USE_TURBOPACK=1
|
||||
|
||||
# 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=0
|
||||
|
||||
# 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
|
||||
# memory-constrained Docker build otherwise dies with "FATAL ERROR: ... JavaScript
|
||||
# heap out of memory" during the builder stage (#4076). Turbopack's compile is
|
||||
# native (Rust) and less V8-heap-bound, but the prerender/export phase still runs
|
||||
# on V8, so keep the ceiling. NODE_OPTIONS propagates to the spawned `next build`
|
||||
# child (build-next-isolated.mjs → resolveNextBuildEnv spreads process.env).
|
||||
# Build-only; the runtime heap is set separately on the runner stage
|
||||
# (OMNIROUTE_MEMORY_MB). Override: `--build-arg OMNIROUTE_BUILD_MEMORY_MB=6144`.
|
||||
# pass (forced above since Turbopack panics) needs more than V8's default ceiling
|
||||
# (~2 GB) for a codebase this size; a memory-constrained Docker build otherwise
|
||||
# dies with "FATAL ERROR: ... JavaScript heap out of memory" during the builder
|
||||
# stage (#4076). NODE_OPTIONS propagates to the spawned `next build` child
|
||||
# (build-next-isolated.mjs → resolveNextBuildEnv spreads process.env). Build-only;
|
||||
# the runtime heap is set separately on the runner stage (OMNIROUTE_MEMORY_MB).
|
||||
# Override for hosts with more/less RAM: `--build-arg OMNIROUTE_BUILD_MEMORY_MB=6144`.
|
||||
ARG OMNIROUTE_BUILD_MEMORY_MB=4096
|
||||
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);"
|
||||
RUN --mount=type=cache,target=/app/.build/next/cache \
|
||||
mkdir -p /app/data && npm run build
|
||||
|
||||
# ── Runner base ────────────────────────────────────────────────────────────
|
||||
FROM base AS runner-base
|
||||
@@ -135,12 +95,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}"
|
||||
|
||||
@@ -181,8 +135,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"]
|
||||
@@ -222,8 +176,8 @@ COPY --from=builder /app/node_modules/playwright ./node_modules/playwright
|
||||
# browsers land under /home/node which persists across image layers and is
|
||||
# accessible to the non-root runtime user.
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright
|
||||
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,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
|
||||
apt-get update \
|
||||
&& node node_modules/playwright/cli.js install chromium --with-deps \
|
||||
&& chown -R node:node /home/node/.cache \
|
||||
@@ -239,15 +193,15 @@ FROM runner-base AS runner-cli
|
||||
USER root
|
||||
|
||||
# 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 \
|
||||
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
||||
--mount=type=cache,target=/var/lib/apt/lists,sharing=locked \
|
||||
apt-get update \
|
||||
&& apt-get install -y --no-install-recommends git ca-certificates docker.io docker-compose \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& git config --system url."https://github.com/".insteadOf "ssh://git@github.com/"
|
||||
|
||||
# Install CLI tools globally. Separate layer from apt for better cache reuse.
|
||||
RUN --mount=type=cache,id=npm-cache,target=/root/.npm \
|
||||
RUN --mount=type=cache,target=/root/.npm \
|
||||
npm install -g --no-audit --no-fund @openai/codex @anthropic-ai/claude-code droid openclaw@latest
|
||||
|
||||
USER node
|
||||
|
||||
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 22 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.
|
||||
|
||||
24
SECURITY.md
24
SECURITY.md
@@ -42,7 +42,7 @@ 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** | 13 providers (Claude, Codex, GitHub, Cursor, Antigravity, Gemini, Kimi Coding, Kilo Code, Cline, Kiro, Qoder, Windsurf, GitLab Duo) |
|
||||
| **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` |
|
||||
@@ -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,296 +0,0 @@
|
||||
# Relatorio de pesquisa: repositorios de CLI integraveis com OmniRoute
|
||||
|
||||
> **Status final (2026-08-03):** este documento preserva o inventário inicial. A pesquisa foi concluída para `104/104` casos. Para resultados por projeto, use `04-tracker-integracoes-clis.md`; para o fechamento executivo e a estratégia de publicação, use `06-relatorio-final-104-clis-e-estrategia-prs.md`.
|
||||
|
||||
**Data da pesquisa:** 2026-08-01
|
||||
**Escopo:** agentes de codigo de terminal, CLIs de LLM, runtimes de agentes e harnesses que possam consumir um endpoint HTTP compativel com OpenAI, Anthropic ou Gemini, ou que possam ser adaptados por provider/plugin/ACP/MITM.
|
||||
**Fonte local principal:** `_tasks/hands-off/2026-08-01_release-v3.8.50_v3.8.50_sess-e1846bc2/handoff.md`
|
||||
**Fontes externas principais:** GitHub Search/API, READMEs dos repositorios e a lista publica `bradAGI/awesome-cli-coding-agents` (atualizada em 2026-07-29).
|
||||
|
||||
## 1. Resumo executivo
|
||||
|
||||
O OmniRoute ja possui uma integracao funcional com o jcode e um catalogo local de ferramentas CLI. O proximo ganho de maior valor e transformar o OmniRoute em um endpoint reconhecido pelos principais agentes de terminal, priorizando configuracao nativa e PR upstream quando o projeto aceitar contribuicoes.
|
||||
|
||||
A pesquisa encontrou:
|
||||
|
||||
- **33 entradas de ferramentas no registro local `CLI_TOOLS`**, contando o registro extraido de Grok Build em `src/shared/constants/cliToolsGrokBuild.ts`, incluindo Claude Code, Codex CLI, Cline, Kilo, Continue, OpenCode, Aider, jcode, Smelt, Pi, Crush, Goose, Open Interpreter, OpenClaw, Hermes Agent, Letta CLI e outros.
|
||||
- **Mais de 90 projetos publicos** no inventario externo consultado, entre agentes de codigo, CLIs generalistas, forks, runtimes e orquestradores.
|
||||
- **Candidatos com evidencia forte de endpoint customizavel:** Gemini CLI, Claw Code, Plandex, MiMo Code, Trae Agent, Kimi CLI, Every Code, Open Codex, VT Code, OpenHands CLI, gptme, Nanocoder, RA.Aid, CoreCoder, Grok CLI, Gitlawb Zero, DeepSeek Reasonix, KlaatCode, CodeMini, DvalinCode, Coro Code, Mini-Kode, Late CLI, Agentty, Aizen, Minacode, YottaCode, aichat, ShellGPT, Mistral Vibe, OpenSquilla, Kode CLI e outros.
|
||||
- **Candidatos que exigem pesquisa confirmatoria:** projetos com README generico, configuracao recente, repositorio ambiguo, binario fechado ou sem evidencia textual suficiente de `base_url`/provider.
|
||||
- **Candidatos que podem ser integrados por outros caminhos:** ACP, MCP, wrapper/launcher, provider adapter, proxy MITM ou apenas documentacao; eles nao devem ser classificados automaticamente como OpenAI-compatible.
|
||||
|
||||
Conclusao: devemos pesquisar e tentar todos os candidatos tecnicamente viaveis, mas separar claramente `suporte no catalogo OmniRoute`, `configuracao generica`, `adaptacao upstream publicada` e `PR/issue aceita`. O tracker acompanha essas dimensoes separadamente.
|
||||
|
||||
## 2. Metodo e limites
|
||||
|
||||
### 2.1 Como a busca foi feita
|
||||
|
||||
1. Leitura integral do handoff do caso jcode para capturar o padrao de integracao, validacao, publicacao e as restricoes de worktree.
|
||||
2. Inspecao do catalogo local em `src/shared/constants/cliTools.ts`, da documentacao de CLI e do fluxo de setup em `docs/guides/CLI-INTEGRATIONS.md`.
|
||||
3. Consulta do GitHub Search/API para resolver o repositorio canonico de cada nome, evitando homonimos.
|
||||
4. Leitura de README/raw quando disponivel, procurando sinais como `base_url`, `baseURL`, `OPENAI_BASE_URL`, `OPENAI_API_BASE`, `LLM_BASE_URL`, `provider`, `gateway`, `model provider`, `Anthropic` e `Gemini`.
|
||||
5. Consulta da lista `https://github.com/bradAGI/awesome-cli-coding-agents`, que serve como descoberta ampla, nao como prova de compatibilidade.
|
||||
6. Classificacao por adocao, manutencao, licenca, evidencia de endpoint, maturidade, potencial de PR e utilidade para o ecossistema OmniRoute.
|
||||
|
||||
### 2.2 O que ainda nao foi afirmado
|
||||
|
||||
- Nao foi feita implementacao ou abertura de PR/issue para os candidatos abaixo; o unico caso publicado nesta sessao anterior e o jcode.
|
||||
- A presenca da palavra `provider` no README nao prova que uma URL arbitraria funciona em runtime.
|
||||
- Estrelas e datas sao snapshots aproximados obtidos em 2026-08-01 e podem mudar.
|
||||
- Repositorios fechados ou com EULA entram no inventario para avaliacao de configuracao, mas nao implicam possibilidade de fork ou PR.
|
||||
- Cada task de integracao precisa repetir a pesquisa no upstream antes de editar codigo.
|
||||
|
||||
## 3. Baseline do OmniRoute
|
||||
|
||||
### 3.1 Superficie que o OmniRoute oferece
|
||||
|
||||
- Endpoint OpenAI em `/v1`.
|
||||
- Superficie Anthropic na raiz, usada por clientes que esperam `/v1/messages` a partir do `ANTHROPIC_BASE_URL`.
|
||||
- Superficie Gemini em `/v1beta`.
|
||||
- Catalogo de modelos consultavel pelos comandos de setup quando o cliente suporta descoberta.
|
||||
- Chave via `OMNIROUTE_API_KEY` ou chave selecionada no dashboard.
|
||||
- Traducao entre formatos, streaming SSE, tool calling, fallback, combos, custos e politicas de autenticacao.
|
||||
- Modos de consumo: configuracao de ambiente, arquivo nativo do cliente, provider customizado, ACP/MCP e MITM.
|
||||
|
||||
### 3.2 Catalogo local ja registrado
|
||||
|
||||
Fonte: `src/shared/constants/cliTools.ts` e `src/shared/constants/cliToolsGrokBuild.ts`.
|
||||
|
||||
**Codigo/CLI:** Claude Code, OpenAI Codex CLI, Factory Droid, OpenClaw, Cursor, Cline, Kilo Code, Continue, Antigravity, GitHub Copilot CLI, OpenCode, Kiro, Qwen Code, Aider, ForgeCode, Cursor Agent CLI, Roo Code, jcode, DeepSeek TUI, CodeWhale, Smelt, Pi, Crush.
|
||||
|
||||
**Agentes:** Hermes, Hermes Agent, Goose, Open Interpreter, Oh My Pi, Letta CLI, Warp AI, Agent Deck.
|
||||
|
||||
Os documentos do catalogo tambem mantem um backlog MITM para ferramentas sem base URL, como Windsurf, Amp, Amazon Q/Kiro CLI e Cowork. Esses casos devem permanecer separados de uma integracao direta.
|
||||
|
||||
### 3.3 Caso jcode (referencia validada)
|
||||
|
||||
- Upstream: `https://github.com/1jehuang/jcode`
|
||||
- Mecanismo: perfil OpenAI-compatible dirigido por metadados; nao foi criado um plugin de runtime.
|
||||
- Branch: `feat/omniroute-provider`
|
||||
- Commit: `ee4f904e6`
|
||||
- PR no fork: `https://github.com/diegosouzapw/jcode/pull/1`
|
||||
- Issue no upstream: `https://github.com/1jehuang/jcode/issues/704`
|
||||
- Diff: 6 arquivos, `+56/-3`.
|
||||
- Validacao: `cargo check --workspace` limpo; 205 testes passaram e uma falha foi preexistente/ambiental.
|
||||
- Estado: aguardando mantenedor; o upstream nao aceita PR de forks externos, por isso a issue e o artefato oficial.
|
||||
- Pendencia prometida: adicionar no README do OmniRoute a secao "Tools & repositories that work with OmniRoute".
|
||||
|
||||
Licao: o trabalho deve comecar descobrindo o mecanismo real de providers do upstream. Nem todos os clientes precisam de mudanca no OmniRoute; alguns precisam somente de um perfil local, e outros exigirao um adaptador especifico.
|
||||
|
||||
## 4. Candidatos prioritarios com evidencia concreta
|
||||
|
||||
As evidencias abaixo sao sinais de README/configuracao observados na pesquisa inicial. A task individual deve abrir o arquivo exato, confirmar a versao atual e executar um smoke test.
|
||||
|
||||
| Projeto | Repositorio | Evidencia inicial | Rota provavel |
|
||||
|---|---|---|---|
|
||||
| Gemini CLI | `google-gemini/gemini-cli` | `GOOGLE_GEMINI_BASE_URL` | configuracao direta; possivel PR/documentacao |
|
||||
| Claw Code | `ultraworkers/claw-code` | `OPENAI_BASE_URL`, provider compativel | configuracao direta ou provider |
|
||||
| Plandex | `plandex-ai/plandex` | providers customizados com `baseUrl` | provider/preset |
|
||||
| MiMo Code | `XiaomiMiMo/MiMo-Code` | `@ai-sdk/openai-compatible` e `baseURL` | provider customizado |
|
||||
| Trae Agent | `bytedance/trae-agent` | `model_providers` e `base_url` | provider/config |
|
||||
| Kimi CLI | `MoonshotAI/kimi-cli` | modos `openai_legacy`, `openai_responses`, `anthropic` e `base_url` | provider nativo/config |
|
||||
| Every Code | `just-every/code` | fork Codex com providers OpenAI/Claude/Gemini | perfil/provider |
|
||||
| Open Codex | `ymichael/open-codex` | multi-provider e OpenAI-compatible | fork/provider |
|
||||
| VT Code | `vinhnx/vtcode` | `custom_providers[].base_url`, failover | provider customizado |
|
||||
| OpenHands CLI | `OpenHands/OpenHands-CLI` | `LLM_BASE_URL` | configuracao direta |
|
||||
| gptme | `gptme/gptme` | `OPENAI_BASE_URL` e providers | configuracao direta |
|
||||
| Nanocoder | `Nano-Collective/nanocoder` | qualquer API OpenAI-compatible | configuracao direta |
|
||||
| RA.Aid | `ai-christianson/RA.Aid` | `OPENAI_API_BASE` | configuracao direta |
|
||||
| CoreCoder | `he-yufeng/CoreCoder` | `OPENAI_BASE_URL` | configuracao direta |
|
||||
| Grok CLI | `superagent-ai/grok-cli` | `GROK_BASE_URL`/`baseURL` | configuracao direta |
|
||||
| Gitlawb Zero | `Gitlawb/zero` | provider `custom-openai-compatible`, `--base-url` | provider/flag |
|
||||
| DeepSeek Reasonix | `esengine/DeepSeek-Reasonix` | provider compativel e endpoint | confirmar configuracao |
|
||||
| KlaatCode | `KlaatAI/klaatcode` | `customModels` OpenAI-compatible | configuracao JSON |
|
||||
| CodeMini CLI | `havingautism/Codemini-CLI` | `gateway.base_url` | gateway/config |
|
||||
| Zot | `patriceckhart/zot` | `--base-url` e provider custom em `models.json` | flag/config |
|
||||
| Pool | `poolsideai/pool` | `POOLSIDE_STANDALONE_BASE_URL`; licenca proprietaria | configuracao, sem PR assumido |
|
||||
| Octomind | `Muvon/octomind` | `<PROVIDER>_API_URL`/`LOCAL_API_URL` | provider/env |
|
||||
| Coro Code | `Blushyes/coro-code` | `OPENAI_BASE_URL` | configuracao direta |
|
||||
| Mini-Kode | `minmaxflow/mini-kode` | `MINIKODE_BASE_URL` | configuracao direta |
|
||||
| Late CLI | `mlhher/late-cli` | `OPENAI_BASE_URL`/`api-url` | env/flag |
|
||||
| Agentty | `1ay1/agentty` | modelo agnostico e endpoints compativeis | confirmar arquivo de config |
|
||||
| Aizen | `aizen-stack/aizen` | CLI Rust OpenAI-compatible; `AIZEN_BASE_URL` | configuracao direta |
|
||||
| Clif-Code | `DLhugly/Clif-Code` | OpenRouter/OpenAI/Anthropic/Ollama | provider/config |
|
||||
| Minacode | `hit9/minacode` | provider e compatibilidade no README | confirmar URL |
|
||||
| YottaCode | `yottadynamics/yottacode` | modelo escolhido, gateway/provider | confirmar config |
|
||||
| aichat | `sigoden/aichat` | providers OpenAI/Claude/Gemini e compatibilidade | `models.yaml`/provider |
|
||||
| ShellGPT | `TheR1D/shell_gpt` | `API_BASE_URL` | env/config |
|
||||
| Mistral Vibe | `mistralai/mistral-vibe` | `base_url`, API base e provider | config/env |
|
||||
| OpenSquilla | `opensquilla/opensquilla` | 20+ providers e gateway | provider/config |
|
||||
| Kode CLI | `shareAI-lab/Kode-cli` | provider, endpoint e Anthropic/OpenAI/Gemini | config |
|
||||
| Crush | `charmbracelet/crush` | `base_url`, provider compativel | ja catalogado no OmniRoute; validar upstream |
|
||||
| Hermes Agent | `NousResearch/hermes-agent` | endpoint/gateway e 300+ modelos | ja catalogado; validar modo de endpoint |
|
||||
| OpenClaw | `openclaw/openclaw` | providers, gateway e endpoints | ja catalogado; validar configuracao atual |
|
||||
|
||||
## 5. Inventario amplo localizado
|
||||
|
||||
### 5.1 Agentes de terminal e coding CLIs
|
||||
|
||||
Os projetos desta tabela foram encontrados na lista curada ou no GitHub Search. `Pesquisa` indica o proximo gate; nao significa que a integracao ja esta pronta.
|
||||
|
||||
| Projeto | Repositorio | Licenca/sinal publico | Situacao inicial |
|
||||
|---|---|---|---|
|
||||
| OpenCode | `anomalyco/opencode` | multi-provider, 75+ providers | ja suportado; acompanhar provider/plugin |
|
||||
| Codex CLI | `openai/codex` | Apache-2.0, provider configuravel | ja suportado |
|
||||
| OpenHands principal | `All-Hands-AI/OpenHands` | OSS, CLI e web | pesquisar CLI e `LLM_BASE_URL` |
|
||||
| Pi | `badlogic/pi-mono` | harness multi-provider | ja suportado; confirmar repo atual |
|
||||
| Open Interpreter | `OpenInterpreter/open-interpreter` | Apache-2.0, `--api_base` | ja suportado |
|
||||
| Cline | `cline/cline` | Apache-2.0, base URL/gateway | ja suportado |
|
||||
| Goose | `aaif-goose/goose` | Apache-2.0, providers | ja suportado |
|
||||
| Aider | `Aider-AI/aider` | Apache-2.0, Anthropic/OpenAI | ja suportado |
|
||||
| Continue | `continuedev/continue` | Apache-2.0, multi-model | ja suportado |
|
||||
| Deep Agents Code | `langchain-ai/deepagents` | MIT, tool-calling LLM | pesquisar pacote `deepagents-code` |
|
||||
| Crush | `charmbracelet/crush` | provider/base URL | ja suportado |
|
||||
| Kilo Code | `Kilo-Org/kilocode` | MIT, providers | ja suportado |
|
||||
| Qwen Code | `QwenLM/qwen-code` | Apache-2.0, providers | ja suportado |
|
||||
| Roo Code | `RooCodeInc/Roo-Code` | Apache-2.0 | ja catalogado; validar CLI |
|
||||
| Grok Build | `xai-org/grok-build` | Apache-2.0, provider | ja suportado |
|
||||
| Oh My Pi | `can1357/oh-my-pi` | provider custom em YAML | ja suportado |
|
||||
| SWE-agent | `SWE-agent/SWE-agent` | MIT | pesquisar backend e base URL |
|
||||
| Smol Developer | `smol-ai/developer` | embeddable agent | adapter/SDK, nao necessariamente CLI |
|
||||
| Claude Engineer | `Doriandarko/claude-engineer` | CLI Claude | pesquisar provider |
|
||||
| Claurst | `Kuberwastaken/claurst` | GPL-3.0, provider | confirmar endpoint e politica de fork |
|
||||
| Free Code | `paoloanzn/free-code` | fork de Claude Code | pesquisar licenca e endpoint |
|
||||
| Codebuff | `CodebuffAI/codebuff` | multi-agent CLI | pesquisar provider |
|
||||
| ForgeCode | `antinomyhq/forge` | 300+ modelos | ja suportado |
|
||||
| OpenSquilla | `opensquilla/opensquilla` | Apache-2.0, gateway | candidato forte |
|
||||
| Kode CLI | `shareAI-lab/Kode-cli` | Apache-2.0, endpoint | candidato forte |
|
||||
| Devon | `entropy-research/Devon` | pair programmer TUI | pesquisar backend |
|
||||
| AutoCodeRover | `AutoCodeRoverSG/auto-code-rover` | agente de issues | pesquisar configuracao de modelos |
|
||||
| Letta Code | `letta-ai/letta-code` | Apache-2.0, model-agnostic | pesquisar API base |
|
||||
| CodeMachine CLI | `moazbuilds/CodeMachine-CLI` | multi-agent local | pesquisar provider |
|
||||
| Codel | `semanser/codel` | AGPL-3.0, Docker/web UI | confirmar servidor OpenAI e restricoes AGPL |
|
||||
| Agentless | `OpenAutoCoder/Agentless` | workflow sem loop persistente | pesquisar entrada de modelo |
|
||||
| Amazon Q Developer CLI | `aws/amazon-q-developer-cli` | Apache-2.0 | provavelmente auth/ecossistema AWS; pesquisar |
|
||||
| Neovate Code | `neovateai/neovate-code` | MIT, plugin/multi-provider | candidato forte |
|
||||
| Groq Code CLI | `build-with-groq/groq-code-cli` | multi-model | pesquisar endpoint |
|
||||
| Dexto | `truffle-ai/dexto` | CLI/web/API, subagentes | pesquisar provider |
|
||||
| claw-code-agent | `HarnessLab/claw-code-agent` | Python, sem dependencias | confirmar endpoint |
|
||||
| g3 | `dhanji/g3` | Rust, provider abstraction | confirmar licenca e URL |
|
||||
| Coro Code | `Blushyes/coro-code` | base URL/OpenAI | candidato |
|
||||
| Mini-Kode | `minmaxflow/mini-kode` | MIT, referencia educacional | candidato |
|
||||
| zot | `patriceckhart/zot` | MIT, TUI/JSON/RPC | candidato |
|
||||
| agentty | `1ay1/agentty` | MIT, ACP e multi-provider | candidato |
|
||||
| nori-cli | `tilework-tech/nori-cli` | multi-provider sobre Codex | pesquisar base URL |
|
||||
| cursor-agent clone | `civai-technologies/cursor-agent` | OpenAI/Claude/Ollama | pesquisar maturidade e licenca |
|
||||
| DvalinCode | `arthurpanhku/dvalincode` | MIT, OpenAI-compatible | candidato |
|
||||
| OpenHarness | `zhijiewong/openharness` | Apache-2.0, any LLM | candidato |
|
||||
| Octomind | `Muvon/octomind` | Apache-2.0, 13+ providers | candidato |
|
||||
| Codex Infinity | `lee101/codex-infinity` | fork Codex | pesquisar endpoint |
|
||||
| San | `genai-io/san` | Apache-2.0, provider-neutral | pesquisar endpoint |
|
||||
| Waveloom | `Menfre01/waveloom` | Apache-2.0, DeepSeek-focused | pesquisar provider |
|
||||
| picocode | `jondot/picocode` | Rust, multi-LLM | pesquisar provider |
|
||||
| QQCode | `qnguyen3/qqcode` | Rust, skills | pesquisar provider |
|
||||
| Keen Code | `mochow13/keen-code` | MIT, 9+ providers | pesquisar provider |
|
||||
| Smelt | `leonardcser/smelt` | MIT, OpenAI-compatible | ja suportado |
|
||||
| Grinta | `josephsenior/Grinta-Coding-Agent` | MIT, Python | pesquisar provider |
|
||||
| Zap | `zap-coding-agent/zap-coding-agent` | MIT, MCP, local/OpenAI | pesquisar endpoint |
|
||||
| Binharic | `CogitatorTech/binharic-cli` | multi-provider | pesquisar endpoint |
|
||||
| Darce | `AmerSarhan/darce-cli` | MIT, multi-model | pesquisar endpoint |
|
||||
| CLAII | `agencyswarm/CLAII` | multi-agent/MCP | pesquisar endpoint |
|
||||
|
||||
### 5.2 Agentes generalistas e ecossistema OpenClaw
|
||||
|
||||
Estes podem consumir OmniRoute como backend, mas a task deve confirmar se a interface de configuracao e realmente uma CLI de codigo ou apenas um gateway de agente.
|
||||
|
||||
| Projeto | Repositorio | Possivel caminho |
|
||||
|---|---|---|
|
||||
| OpenClaw | `openclaw/openclaw` | provider/gateway; ja catalogado |
|
||||
| nanobot | `HKUDS/nanobot` | provider OpenAI-compatible |
|
||||
| ZeroClaw | `zeroclaw-labs/zeroclaw` | trait de provider |
|
||||
| NanoClaw | `gavrielc/nanoclaw` | Anthropic SDK; pesquisar base |
|
||||
| PicoClaw | `sipeed/picoclaw` | provider/config |
|
||||
| IronClaw | `nearai/ironclaw` | provider Rust |
|
||||
| NullClaw | `nullclaw/nullclaw` | 23+ providers |
|
||||
| Clawith | `dataelement/Clawith` | gateway/teams |
|
||||
| claw0 | `shareAI-lab/claw0` | tutorial/runtime; pesquisa de viabilidade |
|
||||
| Moltis | `moltis-org/moltis` | provider Rust |
|
||||
| GitClaw | `open-gitagent/gitclaw` | agente Git-native; pesquisar |
|
||||
| LionClaw | `moshthepitt/lionclaw` | CLI local; pesquisar |
|
||||
| Aizen | `aizen-stack/aizen` | OpenAI-compatible |
|
||||
| aichat | `sigoden/aichat` | provider/model YAML |
|
||||
| ShellGPT | `TheR1D/shell_gpt` | `API_BASE_URL` |
|
||||
| gptme | `gptme/gptme` | `OPENAI_BASE_URL` |
|
||||
|
||||
### 5.3 Orquestradores, wrappers e ferramentas adjacentes
|
||||
|
||||
Nao sao todos alvos de um provider OmniRoute. Devem ser avaliados para launcher, ACP, MCP, observabilidade ou configuracao de seus agentes filhos.
|
||||
|
||||
| Projeto | Repositorio | Tipo de integracao a investigar |
|
||||
|---|---|---|
|
||||
| Agent Deck | `asheshgoplani/agent-deck` | config dos CLIs filhos; ja catalogado |
|
||||
| VibePod | `VibePod/vibepod-cli` | wrapper Docker e metricas |
|
||||
| zeroshot | `the-open-engine/zeroshot` | launcher/worktrees |
|
||||
| Fractal | `plasma-ai/fractal` | orquestrador de CLIs |
|
||||
| Bernstein | `chernistry/bernstein` | orquestrador/verificador |
|
||||
| Traycer | `traycerai/traycer` | CLI custom e agentes filhos |
|
||||
| h5i | `h5i-dev/h5i` | execucao paralela |
|
||||
| OMK | `dmae97/open-multi-agent-kit` | control plane/provider-neutral |
|
||||
| kodo | `ikamensh/kodo` | orquestrador |
|
||||
| ORCH | `oxgeneral/ORCH` | fila de tarefas |
|
||||
| LoopTroop | `LoopTroop-ai/LoopTroop` | orchestration sobre OpenCode |
|
||||
| Galley | `shinpr/galley` | worktree/PR handoff |
|
||||
| Relay | `jcast90/relay` | MCP/orquestracao |
|
||||
| sage | `youwangd/SageCLI` | runtime-agnostic |
|
||||
| 5dive | `5dive-ai/5dive` | agentes em servidor |
|
||||
| agx | `ramarlina/agx` | checkpoints e agentes |
|
||||
| claude-code-router | `musistudio/claude-code-router` | proxy/roteamento; possivel upstream consumidor |
|
||||
| cc-router | `finch-xu/cc-router` | proxy Anthropic multi-provider |
|
||||
| OneCLI | `onecli/onecli` | broker de credenciais, nao agente |
|
||||
| agent-browser | `vercel-labs/agent-browser` | ferramenta MCP/plugin |
|
||||
| OpenWork | `different-ai/openwork` | desktop sobre OpenCode |
|
||||
| Mistral Vibe | `mistralai/mistral-vibe` | provider/base URL |
|
||||
| Junie CLI | `junie.jetbrains.com` | fechado; configuracao BYOK a confirmar |
|
||||
| Pool | `poolsideai/pool` | binario/EULA; sem PR presumido |
|
||||
|
||||
## 6. Evidencias tecnicas e mapeamento para OmniRoute
|
||||
|
||||
### 6.1 Padroes de endpoint encontrados
|
||||
|
||||
| Padrao observado | Exemplos | Acao OmniRoute |
|
||||
|---|---|---|
|
||||
| `OPENAI_BASE_URL`/`OPENAI_API_BASE` | Claw Code, RA.Aid, CoreCoder, Coro Code | fornecer root ou `/v1` conforme o cliente; testar append de path |
|
||||
| `base_url`/`baseURL` em provider | Plandex, MiMo Code, Trae Agent, VT Code, KlaatCode | gerar bloco de provider e modelo |
|
||||
| `LLM_BASE_URL` | OpenHands CLI | configurar surface OpenAI e validar streaming/tool calling |
|
||||
| `GOOGLE_GEMINI_BASE_URL` | Gemini CLI | usar superficie `/v1beta`/Gemini; confirmar formato esperado |
|
||||
| `GROK_BASE_URL` | Grok CLI | decidir se o cliente fala xAI ou OpenAI; testar traducoes |
|
||||
| `--base-url` | Gitlawb Zero, Zot, jcode | launcher ou perfil persistido |
|
||||
| `API_BASE_URL` | ShellGPT | config/env direta |
|
||||
| `<PROVIDER>_API_URL`/gateway | Octomind, Pool, OpenSquilla | provider selecionavel; testar cada preset |
|
||||
| ACP/MCP sem URL direta | Agentty, Kimi CLI, Goose, OpenCode | avaliar se OmniRoute deve ser provider ou backend ACP |
|
||||
| endpoint nao customizavel | Cursor desktop, Antigravity, Kiro, Windsurf, Amp | somente MITM/guide; nao prometer integracao direta |
|
||||
|
||||
### 6.2 Superficies e riscos de protocolo
|
||||
|
||||
- **`/v1` duplicado:** alguns clientes recebem a raiz e acrescentam `/v1/chat/completions`; outros exigem a URL final com `/v1`. Cada task deve registrar o resultado real.
|
||||
- **Chat Completions vs Responses:** forks do Codex e clientes modernos podem usar Responses; testar ambas quando o cliente permitir.
|
||||
- **Anthropic:** clientes que mandam `/v1/messages` esperam `ANTHROPIC_BASE_URL` sem `/v1` no valor. A traducao Anthropic do OmniRoute deve ser validada com streaming e tool use.
|
||||
- **Gemini:** Gemini CLI pode esperar uma base Gemini nativa, nao somente OpenAI-compatible; validar `generateContent`, streaming e headers.
|
||||
- **Tool calling:** o agente pode exigir nomes/ids de ferramenta estaveis, JSON estrito, `tool_choice` ou blocos de pensamento especificos.
|
||||
- **Descoberta de modelos:** `/v1/models` pode ser obrigatorio, opcional ou inexistente. O setup precisa aceitar `--model` fixo quando a descoberta nao for suportada.
|
||||
- **Autenticacao:** alguns projetos leem somente env, outros gravam tokens em arquivo/keyring e alguns usam OAuth proprietario. Nunca reutilizar credenciais de um upstream sem verificar escopo.
|
||||
- **Streaming e retry:** SSE, timeouts, abort signals e re-tentativas podem divergir do cliente. Validar uma chamada longa e uma falha de provider.
|
||||
- **Licenca:** GPL/AGPL, EULA e repositorios sem SPDX exigem decisao de distribuicao antes de enviar patch.
|
||||
|
||||
## 7. Riscos de pesquisa e integracao
|
||||
|
||||
1. **Homonomimos e clones:** usar sempre URL canonica, organizacao, release e README do repositorio correto.
|
||||
2. **Repositorios que mudam rapidamente:** congelar commit/versao no relatorio da task e repetir a consulta no dia da implementacao.
|
||||
3. **README divergente do codigo:** procurar schema, parser de config, testes e comando de execucao; README sozinho e evidencia Tier 1.
|
||||
4. **Clientes fechados:** registrar como `needs-mitm` ou `config-only`, nunca como PR upstream.
|
||||
5. **Forks com historia de origem controversa:** avaliar politica, licenca e aceite de contribuicoes antes de reproduzir componentes.
|
||||
6. **Segredos no ambiente:** limpar `OMNIROUTE_API_KEY` e chaves de teste quando a suite assume ambiente sem credencial, como ocorreu no jcode.
|
||||
7. **Mudancas no checkout:** usar worktree em `.claude/worktrees/` por projeto; nao editar o checkout compartilhado do OmniRoute nem usar `git stash`.
|
||||
|
||||
## 8. Recomendacao
|
||||
|
||||
Executar primeiro os lotes P0/P1 do documento de prioridade. Cada lote pode ter ate tres subagentes, um repositorio por worktree. O agente principal deve revisar a pesquisa, o smoke test e a licenca antes de permitir implementacao. O resultado de cada caso deve atualizar o tracker com commit, PR/issue, validacao e status upstream, sem preencher campos externos por suposicao.
|
||||
|
||||
## 9. Referencias
|
||||
|
||||
- OmniRoute CLI catalogo: `src/shared/constants/cliTools.ts`
|
||||
- OmniRoute CLI reference: `docs/reference/CLI-TOOLS.md`
|
||||
- OmniRoute setup guide: `docs/guides/CLI-INTEGRATIONS.md`
|
||||
- Handoff jcode: `_tasks/hands-off/2026-08-01_release-v3.8.50_v3.8.50_sess-e1846bc2/handoff.md`
|
||||
- Inventario curado: `https://github.com/bradAGI/awesome-cli-coding-agents`
|
||||
- GitHub Search API: `https://api.github.com/search/repositories`
|
||||
@@ -1,167 +0,0 @@
|
||||
# Prioridade de integracoes de CLIs com OmniRoute
|
||||
|
||||
> **Status final (2026-08-03):** esta é a priorização inicial que orientou a execução. Todos os `104/104` casos já foram pesquisados. A classificação final está no tracker `04`; a estratégia revisada de contribuição está no relatório `06`.
|
||||
|
||||
**Snapshot:** 2026-08-01
|
||||
**Objetivo:** ordenar do melhor para o pior todos os projetos tecnicamente candidatos a consumir OmniRoute, sem remover projetos pequenos. A ordem e uma fila de pesquisa/execucao; ela nao e promessa de que todo upstream aceitara um PR.
|
||||
|
||||
## Como ler a prioridade
|
||||
|
||||
- **P0:** ja esta no catalogo OmniRoute ou tem evidencia muito forte de endpoint customizavel; executar/consolidar primeiro.
|
||||
- **P1:** forte candidato novo, com provider/base URL evidente e bom retorno para o ecossistema.
|
||||
- **P2:** tecnicamente promissor, mas requer confirmacao de protocolo, config, maturidade ou licenca.
|
||||
- **P3:** possivel via ACP/MCP/wrapper/launcher, ou com menor adocao; pesquisar depois dos P0-P2.
|
||||
- **P4:** cliente fechado, EULA, MITM ou pesquisa exploratoria; manter no inventario, mas nao bloquear os demais.
|
||||
|
||||
Os fatores usados foram: evidencia de endpoint arbitrario, adocao/atividade, facilidade de teste, compatibilidade OpenAI/Anthropic/Gemini, maturidade, licenca, chance de PR upstream, valor para usuarios OmniRoute e risco de protocolo.
|
||||
|
||||
## A. Catalogo OmniRoute ja existente
|
||||
|
||||
Estas entradas ja aparecem no registro local. A prioridade aqui significa consolidar documentacao, smoke tests, detector/configurador e eventual upstream nominal; nao significa recriar uma integracao que ja existe.
|
||||
|
||||
| Ordem | Projeto | Repositorio/documentacao | Estado local | Proximo foco |
|
||||
|---:|---|---|---|---|
|
||||
| A1 | Claude Code | `anthropics/claude-code` | catalogado; Anthropic base URL | manter compatibilidade Anthropic, streaming e tools |
|
||||
| A2 | Codex CLI | `openai/codex` | catalogado; OpenAI-compatible | Responses, profiles e `/v1` |
|
||||
| A3 | OpenCode | `anomalyco/opencode` | catalogado; provider | provider nativo/plugin e model discovery |
|
||||
| A4 | Cline | `cline/cline` | catalogado; base URL | validar CLI/extension e append de `/v1` |
|
||||
| A5 | Goose | `aaif-goose/goose` | catalogado; `OPENAI_HOST` | validar schema atual e ACP |
|
||||
| A6 | Aider | `Aider-AI/aider` | catalogado; `OPENAI_API_BASE` | LiteLLM path, tools e custo |
|
||||
| A7 | Continue | `continuedev/continue` | catalogado; provider OpenAI | CLI e config YAML atual |
|
||||
| A8 | Kilo Code | `Kilo-Org/kilocode` | catalogado; custom URL | CLI, extension e auth |
|
||||
| A9 | Roo Code | `RooCodeInc/Roo-Code` | catalogado; custom URL | CLI/headless e provider |
|
||||
| A10 | Qwen Code | `QwenLM/qwen-code` | catalogado; `modelProviders` | V4 schema, Responses e env |
|
||||
| A11 | Open Interpreter | `OpenInterpreter/open-interpreter` | catalogado; `--api_base` | streaming e tool execution |
|
||||
| A12 | OpenClaw | `openclaw/openclaw` | catalogado; gateway/provider | config atual e segurança |
|
||||
| A13 | Hermes Agent | `NousResearch/hermes-agent` | catalogado; provider/gateway | endpoint custom e modelos |
|
||||
| A14 | Hermes | `NousResearch/hermes-agent` | catalogado/dual entry | distinguir CLI e agente |
|
||||
| A15 | Oh My Pi | `can1357/oh-my-pi` | catalogado; YAML provider | auto-discovery e tool calling |
|
||||
| A16 | Pi | `badlogic/pi-mono` | catalogado; provider | confirmar repositorio/CLI atual |
|
||||
| A17 | Crush | `charmbracelet/crush` | catalogado; `base_url` | config TOML/JSON atual |
|
||||
| A18 | Smelt | `leonardcser/smelt` | catalogado; OpenAI-compatible | headless e subagents |
|
||||
| A19 | ForgeCode | `antinomyhq/forge` | catalogado; multi-provider | base URL e custom agents |
|
||||
| A20 | jcode | `1jehuang/jcode` | integrado e proposto upstream | aguardar issue #704; manter README OmniRoute |
|
||||
| A21 | DeepSeek TUI | `hunterbown/deepseek-tui` | catalogado legado | confirmar sucessor CodeWhale |
|
||||
| A22 | CodeWhale | `Hmbown/CodeWhale` | catalogado | config primaria e legado |
|
||||
| A23 | Grok Build | `xai-org/grok-build` | catalogado; `~/.grok/config.toml` | provider OmniRoute e modelos |
|
||||
| A24 | Cursor Agent CLI | `cursor.com/cli` | catalogado parcial | confirmar limites de endpoint |
|
||||
| A25 | Factory Droid | `Factory-AI/factory` | catalogado parcial | BYOK e endpoint suportado |
|
||||
| A26 | GitHub Copilot CLI | `github/copilot-cli` | catalogado | provider base URL atual |
|
||||
| A27 | Letta CLI | `letta-ai/letta-code` | catalogado | config pi-ai/local mode |
|
||||
| A28 | Warp AI | `warpdotdev/Warp` | catalogado parcial | somente BYOK/desktop |
|
||||
| A29 | Agent Deck | `asheshgoplani/agent-deck` | catalogado | agentes filhos e ACP |
|
||||
| A30 | Antigravity | produto Google | MITM backlog | nao tratar como endpoint direto |
|
||||
| A31 | Kiro AI | produto AWS | MITM backlog | auth/SSO e MITM |
|
||||
| A32 | Cursor desktop | produto Anysphere | cloud/MITM | manter separado do Cursor CLI |
|
||||
|
||||
## B. Novos candidatos em ordem de execucao
|
||||
|
||||
| Ordem | Prioridade | Projeto | Repositorio | Evidencia inicial | Rota esperada |
|
||||
|---:|:---:|---|---|---|---|
|
||||
| 1 | P0 | Gemini CLI | `google-gemini/gemini-cli` | `GOOGLE_GEMINI_BASE_URL` | config direta/Gemini |
|
||||
| 2 | P0 | Claw Code | `ultraworkers/claw-code` | `OPENAI_BASE_URL`, provider | OpenAI-compatible |
|
||||
| 3 | P0 | Plandex | `plandex-ai/plandex` | provider com `baseUrl` | preset/provider |
|
||||
| 4 | P0 | MiMo Code | `XiaomiMiMo/MiMo-Code` | `@ai-sdk/openai-compatible`, `baseURL` | provider |
|
||||
| 5 | P0 | Trae Agent | `bytedance/trae-agent` | `model_providers`, `base_url` | provider/config |
|
||||
| 6 | P0 | Kimi CLI | `MoonshotAI/kimi-cli` | OpenAI legacy/Responses/Anthropic, `base_url` | provider nativo |
|
||||
| 7 | P0 | Every Code | `just-every/code` | fork Codex, OpenAI/Claude/Gemini | profile/provider |
|
||||
| 8 | P0 | Open Codex | `ymichael/open-codex` | OpenAI/Gemini/OpenRouter/Ollama | profile/provider |
|
||||
| 9 | P0 | VT Code | `vinhnx/vtcode` | `custom_providers[].base_url` | provider/failover |
|
||||
| 10 | P0 | OpenHands CLI | `OpenHands/OpenHands-CLI` | `LLM_BASE_URL` | config direta |
|
||||
| 11 | P0 | gptme | `gptme/gptme` | `OPENAI_BASE_URL` | config direta |
|
||||
| 12 | P0 | Nanocoder | `Nano-Collective/nanocoder` | qualquer OpenAI-compatible | config direta |
|
||||
| 13 | P0 | RA.Aid | `ai-christianson/RA.Aid` | `OPENAI_API_BASE` | config direta |
|
||||
| 14 | P0 | CoreCoder | `he-yufeng/CoreCoder` | `OPENAI_BASE_URL` | config direta |
|
||||
| 15 | P1 | Grok CLI | `superagent-ai/grok-cli` | `GROK_BASE_URL`/`baseURL` | config direta |
|
||||
| 16 | P1 | Gitlawb Zero | `Gitlawb/zero` | `custom-openai-compatible`, `--base-url` | provider/flag |
|
||||
| 17 | P1 | DeepSeek Reasonix | `esengine/DeepSeek-Reasonix` | endpoint/provider compativel | provider |
|
||||
| 18 | P1 | KlaatCode | `KlaatAI/klaatcode` | `customModels` OpenAI-compatible | config |
|
||||
| 19 | P1 | CodeMini CLI | `havingautism/Codemini-CLI` | `gateway.base_url` | gateway |
|
||||
| 20 | P1 | Zot | `patriceckhart/zot` | `--base-url`, `models.json` | flag/config |
|
||||
| 21 | P1 | Octomind | `Muvon/octomind` | provider URL envs | provider/env |
|
||||
| 22 | P1 | DvalinCode | `arthurpanhku/dvalincode` | qualquer OpenAI-compatible | config direta |
|
||||
| 23 | P1 | Coro Code | `Blushyes/coro-code` | `OPENAI_BASE_URL` | env |
|
||||
| 24 | P1 | Mini-Kode | `minmaxflow/mini-kode` | `MINIKODE_BASE_URL` | env |
|
||||
| 25 | P1 | Late CLI | `mlhher/late-cli` | `OPENAI_BASE_URL`, `api-url` | env/flag |
|
||||
| 26 | P1 | Agentty | `1ay1/agentty` | provider-agnostic, ACP | config/ACP |
|
||||
| 27 | P1 | Aizen | `aizen-stack/aizen` | Rust OpenAI-compatible, `AIZEN_BASE_URL` | config |
|
||||
| 28 | P1 | Clif-Code | `DLhugly/Clif-Code` | OpenAI/Anthropic/Ollama | provider |
|
||||
| 29 | P1 | Minacode | `hit9/minacode` | provider/compatibilidade | confirmar URL |
|
||||
| 30 | P1 | YottaCode | `yottadynamics/yottacode` | modelo escolhido/gateway | provider |
|
||||
| 31 | P1 | aichat | `sigoden/aichat` | OpenAI/Claude/Gemini | models YAML |
|
||||
| 32 | P1 | ShellGPT | `TheR1D/shell_gpt` | `API_BASE_URL` | env |
|
||||
| 33 | P1 | Mistral Vibe | `mistralai/mistral-vibe` | `base_url`, API base | config |
|
||||
| 34 | P1 | OpenSquilla | `opensquilla/opensquilla` | gateway, 20+ providers | provider |
|
||||
| 35 | P1 | Kode CLI | `shareAI-lab/Kode-cli` | endpoint/Anthropic/OpenAI/Gemini | config |
|
||||
| 36 | P1 | Neovate Code | `neovateai/neovate-code` | plugin/multi-provider | plugin/provider |
|
||||
| 37 | P1 | Deep Agents Code | `langchain-ai/deepagents` | qualquer tool-calling LLM | provider SDK |
|
||||
| 38 | P1 | Kode fork/variants | `shareAI-lab/Kode-cli` | multi-provider | confirmar upstream |
|
||||
| 39 | P1 | OpenHands principal | `All-Hands-AI/OpenHands` | CLI/web; pesquisar LLM base | config/CLI |
|
||||
| 40 | P1 | SWE-agent | `SWE-agent/SWE-agent` | agente de issues | backend/provider |
|
||||
| 41 | P1 | AutoCodeRover | `AutoCodeRoverSG/auto-code-rover` | agente de patches | backend/provider |
|
||||
| 42 | P2 | Claurst | `Kuberwastaken/claurst` | provider/Anthropic | config; licenca GPL |
|
||||
| 43 | P2 | Codebuff | `CodebuffAI/codebuff` | multi-agent CLI | provider |
|
||||
| 44 | P2 | Devon | `entropy-research/Devon` | TUI pair programmer | backend |
|
||||
| 45 | P2 | Letta Code | `letta-ai/letta-code` | model-agnostic | provider |
|
||||
| 46 | P2 | CodeMachine CLI | `moazbuilds/CodeMachine-CLI` | multi-agent local | provider |
|
||||
| 47 | P2 | Groq Code CLI | `build-with-groq/groq-code-cli` | multi-model | endpoint |
|
||||
| 48 | P2 | Dexto | `truffle-ai/dexto` | CLI/web/API | provider |
|
||||
| 49 | P2 | claw-code-agent | `HarnessLab/claw-code-agent` | endpoint/gateway | provider |
|
||||
| 50 | P2 | g3 | `dhanji/g3` | Rust provider abstraction | provider |
|
||||
| 51 | P2 | San | `genai-io/san` | provider-neutral | provider |
|
||||
| 52 | P2 | Waveloom | `Menfre01/waveloom` | DeepSeek/provider | endpoint |
|
||||
| 53 | P2 | picocode | `jondot/picocode` | multi-LLM | config |
|
||||
| 54 | P2 | QQCode | `qnguyen3/qqcode` | skills, Rust | config |
|
||||
| 55 | P2 | Keen Code | `mochow13/keen-code` | 9+ providers | config |
|
||||
| 56 | P2 | Grinta | `josephsenior/Grinta-Coding-Agent` | provider-agnostic | config |
|
||||
| 57 | P2 | Zap | `zap-coding-agent/zap-coding-agent` | Claude/Gemini/OpenAI/LM Studio | provider |
|
||||
| 58 | P2 | Binharic | `CogitatorTech/binharic-cli` | multi-provider | config |
|
||||
| 59 | P2 | Darce | `AmerSarhan/darce-cli` | multi-model/streaming | config |
|
||||
| 60 | P2 | CLAII | `agencyswarm/CLAII` | multi-agent/MCP | provider |
|
||||
| 61 | P2 | nori-cli | `tilework-tech/nori-cli` | multi-provider sobre Codex | config |
|
||||
| 62 | P2 | cursor-agent clone | `civai-technologies/cursor-agent` | Claude/OpenAI/Ollama | provider |
|
||||
| 63 | P2 | Free Code | `paoloanzn/free-code` | fork Claude Code | licenca/config |
|
||||
| 64 | P2 | Claude Engineer | `Doriandarko/claude-engineer` | CLI Claude | provider |
|
||||
| 65 | P2 | Smol Developer | `smol-ai/developer` | agent embutivel | SDK/adaptador |
|
||||
| 66 | P2 | Agentless | `OpenAutoCoder/Agentless` | workflow sem loop | entrada de modelo |
|
||||
| 67 | P2 | Amazon Q Developer CLI | `aws/amazon-q-developer-cli` | CLI AWS | auth/provider |
|
||||
| 68 | P2 | nanobot | `HKUDS/nanobot` | OpenClaw rewrite | provider |
|
||||
| 69 | P2 | ZeroClaw | `zeroclaw-labs/zeroclaw` | providers pluggable | provider |
|
||||
| 70 | P2 | NanoClaw | `gavrielc/nanoclaw` | Anthropic SDK | base URL |
|
||||
| 71 | P2 | PicoClaw | `sipeed/picoclaw` | provider/config | provider |
|
||||
| 72 | P2 | IronClaw | `nearai/ironclaw` | provider Rust | provider |
|
||||
| 73 | P2 | NullClaw | `nullclaw/nullclaw` | 23+ providers | provider |
|
||||
| 74 | P2 | Moltis | `moltis-org/moltis` | Rust agent | provider |
|
||||
| 75 | P2 | GitClaw | `open-gitagent/gitclaw` | Git-native agent | provider |
|
||||
| 76 | P2 | LionClaw | `moshthepitt/lionclaw` | CLI local | provider |
|
||||
| 77 | P3 | VibePod | `VibePod/vibepod-cli` | wrapper Docker | launcher |
|
||||
| 78 | P3 | zeroshot | `the-open-engine/zeroshot` | worktrees/orchestration | launcher |
|
||||
| 79 | P3 | Fractal | `plasma-ai/fractal` | orquestra CLIs | launcher |
|
||||
| 80 | P3 | Bernstein | `chernistry/bernstein` | executa/verifica agentes | launcher |
|
||||
| 81 | P3 | Traycer | `traycerai/traycer` | agentes paralelos | launcher |
|
||||
| 82 | P3 | h5i | `h5i-dev/h5i` | sandbox e peer review | launcher |
|
||||
| 83 | P3 | OMK | `dmae97/open-multi-agent-kit` | control plane | ACP/MCP |
|
||||
| 84 | P3 | kodo | `ikamensh/kodo` | orquestrador | launcher |
|
||||
| 85 | P3 | ORCH | `oxgeneral/ORCH` | fila de tarefas | launcher |
|
||||
| 86 | P3 | LoopTroop | `LoopTroop-ai/LoopTroop` | orquestrador OpenCode | launcher |
|
||||
| 87 | P3 | Galley | `shinpr/galley` | worktree/PR | launcher |
|
||||
| 88 | P3 | Relay | `jcast90/relay` | MCP/orquestracao | MCP |
|
||||
| 89 | P3 | SageCLI | `youwangd/SageCLI` | runtime-agnostic | launcher/ACP |
|
||||
| 90 | P3 | 5dive | `5dive-ai/5dive` | agentes em servidor | launcher |
|
||||
| 91 | P3 | agx | `ramarlina/agx` | checkpoints | launcher |
|
||||
| 92 | P3 | claude-code-router | `musistudio/claude-code-router` | proxy multi-provider | integrar como consumidor/proxy |
|
||||
| 93 | P3 | cc-router | `finch-xu/cc-router` | proxy Anthropic | interoperabilidade |
|
||||
| 94 | P3 | OneCLI | `onecli/onecli` | broker de credenciais | seguranca/integ. adjacente |
|
||||
| 95 | P3 | agent-browser | `vercel-labs/agent-browser` | ferramenta para agentes | MCP/plugin |
|
||||
| 96 | P3 | OpenWork | `different-ai/openwork` | desktop sobre OpenCode | config do agente filho |
|
||||
| 97 | P4 | Pool | `poolsideai/pool` | `POOLSIDE_STANDALONE_BASE_URL`; EULA | config sem PR presumido |
|
||||
| 98 | P4 | Junie CLI | `junie.jetbrains.com` | fechado/EAP | BYOK/endpoint a confirmar |
|
||||
| 99 | P4 | Cursor desktop | `Anysphere` | cloud endpoint | MITM/guide |
|
||||
| 100 | P4 | Windsurf | produto Codeium | sem base URL geral | MITM |
|
||||
| 101 | P4 | Amp | `sourcegraph.com/amp` | fechado | MITM/sem PR |
|
||||
| 102 | P4 | Amazon Q/Kiro CLI | AWS | SSO/ecossistema AWS | MITM/adapter |
|
||||
| 103 | P4 | Cowork | produto Anthropic | endpoint opaco | MITM |
|
||||
|
||||
## C. Regra de promocao/rebaixamento
|
||||
|
||||
Um projeto sobe de prioridade quando a pesquisa individual confirma: configuracao documentada, teste local com OmniRoute, licenca permissiva e contribuicao aceita. Desce quando: a URL e fixa, o endpoint e somente SaaS, o README nao corresponde ao codigo, a autenticacao e inseparavel do provedor, ou a licenca/EULA impede redistribuicao. Nenhum projeto e marcado como impossivel sem registrar a evidencia no tracker.
|
||||
@@ -1,314 +0,0 @@
|
||||
# Plano executavel de integracao de CLIs
|
||||
|
||||
> **Status final (2026-08-03):** a fase de pesquisa foi concluída em lotes de até três worktrees/agentes, cobrindo `104/104` casos. Este documento continua válido como processo operacional para implementação/publicação. Consulte `06-relatorio-final-104-clis-e-estrategia-prs.md` para o resultado final.
|
||||
|
||||
**Data:** 2026-08-01
|
||||
**Objetivo:** pesquisar, integrar, validar e publicar suporte ao OmniRoute em todos os projetos tecnicamente possiveis, mantendo uma fila que permite ate tres subagentes simultaneos.
|
||||
|
||||
O ciclo especifico de preparacao, revisao, envio e acompanhamento das contribuicoes upstream esta
|
||||
em `05-plano-publicacao-prs-upstream.md`.
|
||||
|
||||
## 1. Principios operacionais
|
||||
|
||||
- Um repositorio por subagente e por worktree.
|
||||
- No maximo tres tasks de repositorios em execucao ao mesmo tempo.
|
||||
- Cada task pesquisa o upstream novamente antes de editar; o relatorio inicial e somente contexto.
|
||||
- O agente principal revisa licenca, arquitetura, smoke test e diff antes do proximo lote.
|
||||
- Nao usar checkout compartilhado para desenvolvimento e nao usar `git stash`/`git pop`.
|
||||
- Usar worktrees em `.claude/worktrees/` e branches especificas.
|
||||
- Nao inventar PR, issue, commit ou aceite de mantenedor.
|
||||
- Nao adicionar trailers ou rodapes de IA em commits/PRs.
|
||||
|
||||
## 2. Fases obrigatorias por projeto
|
||||
|
||||
### Fase 0 - Preparacao da task
|
||||
|
||||
Criar uma task com nome do projeto, URL canonica, prioridade, evidencia inicial, estado no catalogo OmniRoute e objetivo de integrar. Definir a worktree e o agente responsavel.
|
||||
|
||||
### Fase 1 - Pesquisa individual fresca
|
||||
|
||||
O agente deve verificar no upstream atual:
|
||||
|
||||
- arquitetura de providers e ponto de entrada do CLI;
|
||||
- arquivo/schema de configuracao e suporte a `base_url`, `baseURL`, `OPENAI_BASE_URL`, `OPENAI_API_BASE`, `LLM_BASE_URL` ou equivalente;
|
||||
- protocolo real (Chat Completions, Responses, Anthropic Messages, Gemini, ACP, MCP ou outro);
|
||||
- descoberta de modelos e necessidade de `/v1/models`;
|
||||
- autenticacao, keyring, OAuth e variaveis de ambiente;
|
||||
- streaming, tool calling, reasoning e limites conhecidos;
|
||||
- politica de contribuicao, licenca e se PR de fork externo e aceito;
|
||||
- atividade, releases, issues/PRs sobre providers customizados ou endpoints locais;
|
||||
- comandos de build, lint, teste e smoke test;
|
||||
- possibilidade de fork/PR, issue de proposta, documentacao ou apenas wrapper/MITM.
|
||||
|
||||
Registrar commit/release pesquisado e links de evidencia.
|
||||
|
||||
### Fase 2 - Gate de viabilidade
|
||||
|
||||
Classificar exatamente um caminho inicial:
|
||||
|
||||
`viable-direct` (somente configuracao), `viable-upstream` (mudanca no upstream), `viable-acp`, `viable-mcp`, `needs-wrapper`, `needs-mitm`, `config-only`, `blocked` ou `research-more`.
|
||||
|
||||
Nao implementar antes de haver uma conclusao de viabilidade e uma razao verificavel.
|
||||
|
||||
### Fase 3 - Baseline e TDD
|
||||
|
||||
- Executar a suite recomendada pelo upstream antes das mudancas.
|
||||
- Registrar falhas preexistentes, dependencias ausentes e comandos exatos.
|
||||
- Limpar `OMNIROUTE_API_KEY` e demais credenciais quando os testes pressupuserem ambiente sem chaves.
|
||||
- Adicionar primeiro um teste de configuracao, endpoint e selecao de modelo que falhe sem a integracao.
|
||||
|
||||
### Fase 4 - Implementacao minima
|
||||
|
||||
Implementar apenas o necessario para o caso pesquisado:
|
||||
|
||||
- perfil/preset `omniroute` ou provider custom;
|
||||
- base URL correta (raiz, `/v1` ou `/v1beta` conforme o cliente);
|
||||
- chave via ambiente ou mecanismo seguro do cliente;
|
||||
- modelo fixo ou descoberta de modelos;
|
||||
- selecao/login/report se o CLI tiver esses fluxos;
|
||||
- documentacao de uso e limites;
|
||||
- testes de config e chamada.
|
||||
|
||||
Se o upstream nao aceitar mudanca, preparar wrapper/launcher ou documentacao local e registrar a limitacao.
|
||||
|
||||
### Fase 5 - Validacao funcional
|
||||
|
||||
Executar, conforme o protocolo:
|
||||
|
||||
- build, lint, typecheck e testes do upstream;
|
||||
- smoke request com OmniRoute;
|
||||
- streaming SSE e encerramento por abort;
|
||||
- tool calling e JSON de argumentos;
|
||||
- `/v1/models` ou equivalente;
|
||||
- Chat Completions, Responses, Anthropic Messages e Gemini `generateContent` quando aplicavel;
|
||||
- fallback/erro, timeout, retry e modelo inexistente;
|
||||
- teste com chave limpa e teste com `OMNIROUTE_API_KEY` real fora dos logs.
|
||||
|
||||
### Fase 6 - Publicacao upstream
|
||||
|
||||
- Criar fork somente quando permitido e branch especifica.
|
||||
- Abrir PR upstream se contribuicoes externas forem aceitas.
|
||||
- Se PR externo for bloqueado, abrir issue com proposta, patch/referencia e smoke test.
|
||||
- Se o projeto for fechado/EULA, registrar config manual ou issue de produto; nao criar PR ficticio.
|
||||
- Atualizar o tracker com URL, commit, estado e resposta do mantenedor.
|
||||
|
||||
### Fase 7 - Catalogo e integracao OmniRoute
|
||||
|
||||
Quando houver valor para usuarios OmniRoute:
|
||||
|
||||
- criar worktree propria do OmniRoute;
|
||||
- atualizar `src/shared/constants/cliTools.ts` ou `src/shared/constants/cliToolsGrokBuild.ts`;
|
||||
- atualizar detector em `src/lib/cli-helper/tool-detector.ts` se necessario;
|
||||
- adicionar gerador/configurador e rota de settings somente se o caso exigir;
|
||||
- adicionar testes do catalogo, detector, settings, `baseUrlSupport` e `/v1`;
|
||||
- atualizar `docs/reference/CLI-TOOLS.md`, `docs/guides/CLI-INTEGRATIONS.md` e README quando apropriado;
|
||||
- atualizar o tracker com a integracao local e evidencias.
|
||||
|
||||
### Fase 8 - Fechamento
|
||||
|
||||
Registrar commit, branch, PR/issue, testes, limitacoes, status do upstream, status do catalogo OmniRoute e proximo passo. O agente principal faz uma revisao final de seguranca, licenca e factualidade.
|
||||
|
||||
## 3. Lotes de ate tres subagentes
|
||||
|
||||
O lote e uma unidade operacional. A fila abaixo e ordenada pelo documento `02-prioridade-integracoes-clis.md`; cada linha representa uma task individual.
|
||||
|
||||
### Lote 0 - consolidacao do caso de referencia
|
||||
|
||||
- `CLI-000` - jcode - manter a issue #704, validar resposta do mantenedor e concluir a secao do README OmniRoute.
|
||||
|
||||
### Lote P0.1
|
||||
|
||||
- `CLI-001` - Gemini CLI - integrar provider/base URL Gemini.
|
||||
- `CLI-002` - Claw Code - integrar `OPENAI_BASE_URL`/provider OmniRoute.
|
||||
- `CLI-003` - Plandex - integrar provider custom com `baseUrl`.
|
||||
|
||||
### Lote P0.2
|
||||
|
||||
- `CLI-004` - MiMo Code - integrar provider OpenAI-compatible.
|
||||
- `CLI-005` - Trae Agent - integrar `model_providers` e `base_url`.
|
||||
- `CLI-006` - Kimi CLI - integrar modos OpenAI/Responses/Anthropic.
|
||||
|
||||
### Lote P0.3
|
||||
|
||||
- `CLI-007` - Every Code - integrar perfil derivado do Codex.
|
||||
- `CLI-008` - Open Codex - integrar provider multi-modelo.
|
||||
- `CLI-009` - VT Code - integrar `custom_providers` e failover.
|
||||
|
||||
### Lote P0.4
|
||||
|
||||
- `CLI-010` - OpenHands CLI - integrar `LLM_BASE_URL`.
|
||||
- `CLI-011` - gptme - integrar `OPENAI_BASE_URL`.
|
||||
- `CLI-012` - Nanocoder - integrar API OpenAI-compatible.
|
||||
|
||||
### Lote P0.5
|
||||
|
||||
- `CLI-013` - RA.Aid - integrar `OPENAI_API_BASE`.
|
||||
- `CLI-014` - CoreCoder - integrar `OPENAI_BASE_URL`.
|
||||
- `CLI-015` - Grok CLI - integrar `GROK_BASE_URL`.
|
||||
|
||||
### Lote P1.1
|
||||
|
||||
- `CLI-016` - Gitlawb Zero - integrar provider custom e `--base-url`.
|
||||
- `CLI-017` - DeepSeek Reasonix - confirmar e integrar endpoint.
|
||||
- `CLI-018` - KlaatCode - integrar `customModels`.
|
||||
|
||||
### Lote P1.2
|
||||
|
||||
- `CLI-019` - CodeMini CLI - integrar `gateway.base_url`.
|
||||
- `CLI-020` - Zot - integrar flag/config `--base-url`.
|
||||
- `CLI-021` - Octomind - integrar provider URL envs.
|
||||
|
||||
### Lote P1.3
|
||||
|
||||
- `CLI-022` - DvalinCode - integrar OpenAI-compatible.
|
||||
- `CLI-023` - Coro Code - integrar `OPENAI_BASE_URL`.
|
||||
- `CLI-024` - Mini-Kode - integrar `MINIKODE_BASE_URL`.
|
||||
|
||||
### Lote P1.4
|
||||
|
||||
- `CLI-025` - Late CLI - integrar `OPENAI_BASE_URL`/`api-url`.
|
||||
- `CLI-026` - Agentty - integrar provider e/ou ACP.
|
||||
- `CLI-027` - Aizen - integrar `AIZEN_BASE_URL`.
|
||||
|
||||
### Lote P1.5
|
||||
|
||||
- `CLI-028` - Clif-Code - integrar providers OpenAI/Anthropic/Ollama.
|
||||
- `CLI-029` - Minacode - confirmar provider e integrar URL.
|
||||
- `CLI-030` - YottaCode - integrar gateway/provider.
|
||||
|
||||
### Lote P1.6
|
||||
|
||||
- `CLI-031` - aichat - integrar models YAML/provider.
|
||||
- `CLI-032` - ShellGPT - integrar `API_BASE_URL`.
|
||||
- `CLI-033` - Mistral Vibe - integrar base URL/provider.
|
||||
|
||||
### Lote P1.7
|
||||
|
||||
- `CLI-034` - OpenSquilla - integrar gateway/provider.
|
||||
- `CLI-035` - Kode CLI - integrar endpoint multi-provider.
|
||||
- `CLI-036` - Neovate Code - integrar plugin/provider.
|
||||
|
||||
### Lote P1.8
|
||||
|
||||
- `CLI-037` - Deep Agents Code - integrar provider do pacote CLI.
|
||||
- `CLI-038` - OpenHands principal - integrar CLI/config.
|
||||
- `CLI-039` - SWE-agent - integrar backend/provider.
|
||||
|
||||
### Lote P1.9
|
||||
|
||||
- `CLI-040` - AutoCodeRover - integrar backend/provider.
|
||||
- `CLI-041` - Claurst - integrar provider, respeitando GPL.
|
||||
- `CLI-042` - Codebuff - integrar provider.
|
||||
|
||||
### Lote P2.1
|
||||
|
||||
- `CLI-043` - Devon - integrar backend.
|
||||
- `CLI-044` - Letta Code - integrar provider.
|
||||
- `CLI-045` - CodeMachine CLI - integrar provider.
|
||||
|
||||
### Lote P2.2
|
||||
|
||||
- `CLI-046` - Groq Code CLI - integrar endpoint.
|
||||
- `CLI-047` - Dexto - integrar provider.
|
||||
- `CLI-048` - claw-code-agent - integrar endpoint.
|
||||
|
||||
### Lote P2.3
|
||||
|
||||
- `CLI-049` - g3 - integrar provider Rust.
|
||||
- `CLI-050` - San - integrar provider-neutral.
|
||||
- `CLI-051` - Waveloom - integrar provider/endpoint.
|
||||
|
||||
### Lote P2.4
|
||||
|
||||
- `CLI-052` - picocode - integrar multi-LLM.
|
||||
- `CLI-053` - QQCode - integrar config.
|
||||
- `CLI-054` - Keen Code - integrar provider.
|
||||
|
||||
### Lote P2.5
|
||||
|
||||
- `CLI-055` - Grinta - integrar provider.
|
||||
- `CLI-056` - Zap - integrar Claude/Gemini/OpenAI.
|
||||
- `CLI-057` - Binharic - integrar multi-provider.
|
||||
|
||||
### Lote P2.6
|
||||
|
||||
- `CLI-058` - Darce - integrar multi-modelo.
|
||||
- `CLI-059` - CLAII - integrar provider/MCP.
|
||||
- `CLI-060` - nori-cli - integrar provider baseado em Codex.
|
||||
|
||||
### Lote P2.7
|
||||
|
||||
- `CLI-061` - cursor-agent clone - integrar provider.
|
||||
- `CLI-062` - Free Code - pesquisar licenca e integrar se viavel.
|
||||
- `CLI-063` - Claude Engineer - integrar provider.
|
||||
|
||||
### Lote P2.8
|
||||
|
||||
- `CLI-064` - Smol Developer - integrar SDK/adaptador.
|
||||
- `CLI-065` - Agentless - integrar entrada de modelo.
|
||||
- `CLI-066` - Amazon Q Developer CLI - pesquisar auth/provider.
|
||||
|
||||
### Lote P2.9
|
||||
|
||||
- `CLI-067` - nanobot - integrar provider OpenClaw-compatible.
|
||||
- `CLI-068` - ZeroClaw - integrar trait de provider.
|
||||
- `CLI-069` - NanoClaw - confirmar base Anthropic.
|
||||
|
||||
### Lote P2.10
|
||||
|
||||
- `CLI-070` - PicoClaw - integrar provider/config.
|
||||
- `CLI-071` - IronClaw - integrar provider Rust.
|
||||
- `CLI-072` - NullClaw - integrar provider.
|
||||
|
||||
### Lote P2.11
|
||||
|
||||
- `CLI-073` - Moltis - integrar provider Rust.
|
||||
- `CLI-074` - GitClaw - integrar provider Git-native.
|
||||
- `CLI-075` - LionClaw - integrar provider CLI.
|
||||
|
||||
### Lote P3.1 - wrappers e orquestradores
|
||||
|
||||
- `CLI-076` - VibePod; `CLI-077` - zeroshot; `CLI-078` - Fractal.
|
||||
|
||||
### Lote P3.2
|
||||
|
||||
- `CLI-079` - Bernstein; `CLI-080` - Traycer; `CLI-081` - h5i.
|
||||
|
||||
### Lote P3.3
|
||||
|
||||
- `CLI-082` - OMK; `CLI-083` - kodo; `CLI-084` - ORCH.
|
||||
|
||||
### Lote P3.4
|
||||
|
||||
- `CLI-085` - LoopTroop; `CLI-086` - Galley; `CLI-087` - Relay.
|
||||
|
||||
### Lote P3.5
|
||||
|
||||
- `CLI-088` - SageCLI; `CLI-089` - 5dive; `CLI-090` - agx.
|
||||
|
||||
### Lote P3.6
|
||||
|
||||
- `CLI-091` - claude-code-router; `CLI-092` - cc-router; `CLI-093` - OneCLI.
|
||||
|
||||
### Lote P3.7
|
||||
|
||||
- `CLI-094` - agent-browser; `CLI-095` - OpenWork; `CLI-096` - Agent Deck (revisao de agente filho).
|
||||
|
||||
### Lote P4 - fechados/MITM
|
||||
|
||||
- `CLI-097` - Pool; `CLI-098` - Junie CLI; `CLI-099` - Cursor desktop.
|
||||
- `CLI-100` - Windsurf; `CLI-101` - Amp; `CLI-102` - Amazon Q/Kiro CLI; `CLI-103` - Cowork.
|
||||
|
||||
## 4. Criterio para iniciar o lote seguinte
|
||||
|
||||
O lote seguinte pode iniciar quando os tres agentes do lote atual tiverem: pesquisa upstream anexada, gate de viabilidade preenchido, baseline registrado, resultado de smoke test ou bloqueio reproduzivel, e tracker atualizado. Uma falha de um agente nao deve paralisar os outros dois; o agente principal deve marcar `blocked` ou `research-more` com evidencia e seguir a fila.
|
||||
|
||||
## 5. Entregaveis de cada task
|
||||
|
||||
1. Nota de pesquisa fresca com commit/release e links.
|
||||
2. Classificacao de viabilidade.
|
||||
3. Diff minimo ou conclusao documentada de que nao ha diff necessario.
|
||||
4. Testes e comandos executados, incluindo falhas preexistentes.
|
||||
5. PR/issue upstream ou justificativa de config-only/MITM.
|
||||
6. Entrada no catalogo OmniRoute quando aplicavel.
|
||||
7. Atualizacao do tracker `04-tracker-integracoes-clis.md`.
|
||||
@@ -1,144 +0,0 @@
|
||||
# Tracker de integracoes de CLIs com OmniRoute
|
||||
|
||||
**Status final da pesquisa:** `104/104` concluídos (`100%`), `0` casos `not-started`. Este é o registro individual autoritativo. O relatório executivo está em `06-relatorio-final-104-clis-e-estrategia-prs.md`.
|
||||
|
||||
**Snapshot inicial:** 2026-08-01
|
||||
**Legenda de status:** `not-started`, `researching`, `research-more`, `viable-direct`, `viable-upstream`, `viable-acp`, `viable-mcp`, `needs-wrapper`, `needs-mitm`, `blocked`, `implementing`, `validating`, `published-pr`, `published-issue`, `awaiting-maintainer`, `accepted`, `rejected`, `integrated`.
|
||||
|
||||
Os campos externos (`branch`, `commit`, `PR`, `issue`) ficam como `—` ate haver evidencia real. “Catalogo OmniRoute” significa entrada local, nao necessariamente suporte upstream publicado.
|
||||
|
||||
| ID | Prio | Projeto | Repositorio | Pesquisa | Tipo | Upstream | Branch | Commit | PR | Issue | Catalogo OmniRoute | Observacoes/proximo passo |
|
||||
|---|:---:|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| CLI-000 | P0 | jcode | `1jehuang/jcode` | concluida | `viable-upstream` | `awaiting-maintainer` | `feat/omniroute-provider` | `ee4f904e6` | [fork PR](https://github.com/diegosouzapw/jcode/pull/1) | [upstream #704](https://github.com/1jehuang/jcode/issues/704) | integrated | acompanhar mantenedor e concluir secao do README |
|
||||
|
||||
## Caso publicado: jcode
|
||||
|
||||
| Campo | Valor |
|
||||
|---|---|
|
||||
| Projeto | jcode |
|
||||
| Repositorio | `https://github.com/1jehuang/jcode` |
|
||||
| Status geral | `awaiting-maintainer` |
|
||||
| Tipo | `viable-upstream`; perfil OpenAI-compatible dirigido por metadados |
|
||||
| Branch | `feat/omniroute-provider` |
|
||||
| Commit | `ee4f904e6` |
|
||||
| PR | `https://github.com/diegosouzapw/jcode/pull/1` (fork de referencia) |
|
||||
| Issue | `https://github.com/1jehuang/jcode/issues/704` |
|
||||
| Catalogo OmniRoute | `integrated` / entrada existente |
|
||||
| Validacao | `cargo check --workspace` limpo; 205 testes passaram; 1 falha preexistente/ambiental |
|
||||
| Diff | 6 arquivos, `+56/-3` |
|
||||
| Proximo passo | acompanhar issue #704 e criar secao de README do OmniRoute |
|
||||
|
||||
## Tabela principal
|
||||
|
||||
| ID | Prio | Projeto | Repositorio | Pesquisa | Tipo | Upstream | Branch | Commit | PR | Issue | Catalogo OmniRoute | Observacoes/proximo passo |
|
||||
|---|:---:|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| CLI-001 | P0 | Gemini CLI | `google-gemini/gemini-cli` | concluida | `pr-generic` | `published-issue` | `fix/omniroute-gateway-auth` | `8138105c38cc1637fe9e8a9bd520eb835f1620e6` | — | [upstream #27550](https://github.com/google-gemini/gemini-cli/issues/27550#issuecomment-5152312278) | not-in-catalog | regression `AuthType.GATEWAY`; patch +26; auth 10/10, non-interactive 17/17, content generator 55/55, Gemini `/v1beta` stream/tools smoke verde; aguardar `help wanted` antes de terceira PR |
|
||||
| CLI-002 | P0 | Claw Code | `ultraworkers/claw-code` | concluida | `pr-docs` | `published-issue` | `docs/omniroute-setup` | `de857038b2f9ff9b319132e2241549e86215c351` | — | [upstream #3283](https://github.com/ultraworkers/claw-code/issues/3283) | not-in-catalog | generic OpenAI Chat Completions; docs +37; 1.415 testes, fmt, docs/release checks e clippy oficial verdes; fork bloqueado pelo GitHub, issue-first; smoke OmniRoute parcial/timeout; chave do smoke deve ser rotacionada |
|
||||
| CLI-003 | P0 | Plandex | `plandex-ai/plandex` | concluida | `pr-docs` | `published-pr` | `feat/omniroute-provider-docs` | `f8f0694bdf7d1cb6e65a1f1c5bc39f84921a4507` | [upstream #359](https://github.com/plandex-ai/plandex/pull/359) | — | not-in-catalog | custom provider OpenAI-compatible ja existia; docs com `/v1`, `OMNIROUTE_API_KEY`, Docker reachability e model mapping; Go indisponivel; Docusaurus build verde; acompanhar mantenedor |
|
||||
| CLI-004 | P0 | MiMo Code | `XiaomiMiMo/MiMo-Code` | concluida | `config-only` | not-applicable | `research/omniroute-mimo-code` | — | — | — | not-in-catalog | SHA `ce124cb`; provider customizado `@ai-sdk/openai-compatible` já suporta `baseURL`, `apiKey` e modelo; 116 testes focados + typecheck verdes; smoke CLI inconclusivo por travamento ambiental; sem PR artificial |
|
||||
| CLI-005 | P0 | Trae Agent | `bytedance/trae-agent` | concluida | `pr-docs` | `published-pr` | `research/omniroute-trae-agent` | `4801e48b69d7583300eb86ec5c69235506d7f205` | [upstream #449](https://github.com/bytedance/trae-agent/pull/449) | — | not-in-catalog | README +39; `provider: openai` + mapping `base_url=/v1`; `/v1/responses`, `/v1/models`, Bearer, tools e limitação sem streaming; 62 testes/17 skips, pre-commit e mocks verdes; CLA pendente |
|
||||
| CLI-006 | P0 | Kimi CLI | `MoonshotAI/kimi-cli` | concluida | `pr-docs` | `published-issue` | `research/omniroute-kimi-cli` | `a2f62bf6108a6954e798db992411aa06670e224f` | — | [upstream #2576](https://github.com/MoonshotAI/kimi-cli/issues/2576) | not-in-catalog | docs EN/ZH +63; `openai_legacy` `/v1`, chave via `OPENAI_API_KEY`, modelo manual; Responses/Anthropic alternativos; 47 testes e VitePress verdes; aguardar direção do mantenedor antes da PR |
|
||||
| CLI-007 | P0 | Every Code | `just-every/code` | concluida | `pr-docs` / `config-only` | `published-pr` | `feat/omniroute-integration` | `8fbc8dab5fb76bf05535055801af0c3ccfea6f3b` | [upstream #614](https://github.com/just-every/code/pull/614) | — | not-in-catalog | PR documental aberta e mergeable; release `v0.6.162`; `./build-fast.sh` baseline/pós-patch verdes; smoke mock Responses/SSE/tools verde; acompanhar CI/mantenedor |
|
||||
| CLI-008 | P0 | Open Codex | `ymichael/open-codex` | concluida | `pr-generic` / `issue-first` | `published-issue` | `feat/omniroute-integration` | `f25de99f991c0e4d9d6ae2811d307cdbff92f869` | — | [upstream #4](https://github.com/ymichael/open-codex/issues/4#issuecomment-5152804104) | not-in-catalog | patch genérico pronto localmente; issue-first por firewall de container e PR #19 fechada; 132 testes, typecheck/build/format verdes; lint bloqueado por ambiente; aguardar mantenedor antes de PR |
|
||||
| CLI-009 | P0 | VT Code | `vinhnx/vtcode` | concluida | `pr-docs` / `config-only` | `published-pr` | `feat/omniroute-integration` | `256682d10c72f3e6e145d852b6d9d53f5c471988` | [upstream #717](https://github.com/vinhnx/VTCode/pull/717) | — | not-in-catalog | PR documental aberta e mergeable; release `0.141.10`; custom provider `/v1`, Bearer, `auto`, discovery manual, streaming/tools; 10 testes config verdes; nextest/docs checks bloqueados por ambiente; acompanhar CI/mantenedor |
|
||||
| CLI-010 | P0 | OpenHands CLI | `OpenHands/OpenHands-CLI` | concluida | `config-only` | not-applicable | `feat/omniroute-openhands-cli-integration` | — | — | — | not-in-catalog | SHA `2df8a283`; `LLM_BASE_URL=/v1`, `LLM_API_KEY`, modelo obrigatório `openai/auto`, Chat Completions/SSE/tools; 63 testes focados e mock verdes; sem PR artificial |
|
||||
| CLI-011 | P0 | gptme | `gptme/gptme` | concluida | `config-only` | not-applicable | `feat/omniroute-gptme-integration` | — | — | — | not-in-catalog | SHA `7fe250529`; provider TOML nomeado, `/v1/chat/completions`, `/v1/models`, Bearer, streaming/tools; compileall verde, pytest bloqueado por deps; docs genericas ja cobrem |
|
||||
| CLI-012 | P0 | Nanocoder | `Nano-Collective/nanocoder` | concluida | `config-only` | not-applicable | `feat/omniroute-nanocoder-integration` | — | — | — | not-in-catalog | SHA `becae998`; `createOpenAICompatible`, `/v1/models`, streaming/native tools + XML/JSON fallback; types/format/lint/build verdes; suite ampla com falhas preexistentes; sem PR artificial |
|
||||
| CLI-013 | P0 | RA.Aid | `ai-christianson/RA.Aid` | concluida | `config-only` | not-applicable | `feat/omniroute-ra-aid-integration` | — | — | — | not-in-catalog | SHA `e71bb83`; provider `openai-compatible`, `/v1/chat/completions`, Bearer, modelo explicito/`auto`, function tools; 762 testes + 62 focados e smoke verdes; sem Responses/stream HTTP garantido; Aider exige config separada; sem PR artificial |
|
||||
| CLI-014 | P0 | CoreCoder | `he-yufeng/CoreCoder` | concluida | `pr-docs` / `config-only` | `published-pr` | `feat/omniroute-integration` | `f4d2851649e5dda20738c313a8a94337b24eeb9d` | [upstream #20](https://github.com/he-yufeng/CoreCoder/pull/20) | — | not-in-catalog | PR documental aberta, nao draft e mergeable; `/v1/chat/completions`, Bearer, `auto`, streaming/native tools; 86 testes, compileall, build, twine e smoke verdes; Ruff mantem 41 falhas preexistentes; acompanhar CI/mantenedor |
|
||||
| CLI-015 | P1 | Grok CLI | `superagent-ai/grok-cli` | concluida | `config-only` | not-applicable | `feat/omniroute-grok-cli-integration` | — | — | — | not-in-catalog | SHA `fb97af8`; `GROK_BASE_URL`/`--base-url`, Chat Completions/SSE, Bearer, `auto` e tools confirmados; 47/48 suites e 246 testes no gate isolado, 6 arquivos/39 testes focados verdes; Node não carrega `bun:sqlite`; Responses/search/STT/Batch/midia não garantidos; monitorar PRs #290/#349 |
|
||||
| CLI-016 | P1 | Gitlawb Zero | `Gitlawb/zero` | concluida | `config-only` | not-applicable | `feat/omniroute-gitlawb-zero-integration` | — | — | — | not-in-catalog | SHA `8e266797`; release `v0.6.0`; provider custom `/v1`, Bearer, `auto`, Chat/SSE/tools, usage e `/v1/models` confirmados; Go test/vet/fmt e smoke verdes; release build bloqueado por falta de espaco; politica exige issue aprovada; sem contribuicao nominal artificial |
|
||||
| CLI-017 | P1 | DeepSeek Reasonix | `esengine/DeepSeek-Reasonix` | concluida | `config-only` | not-applicable | `feat/omniroute-deepseek-reasonix-integration` | — | — | — | not-in-catalog | SHA `1c62489d`; release `v1.19.1`; `kind=openai`, `/v1/chat/completions`, Bearer, `auto`, SSE/tools, `/v1/models` e reasoning confirmados; suite completa, vet, fmt, build e smoke verdes apos remover env SSH do runner; sem PR/issue redundante |
|
||||
| CLI-018 | P1 | KlaatCode | `KlaatAI/klaatcode` | concluida | `config-only` | not-applicable | `feat/omniroute-klaatcode-integration` | — | — | — | not-in-catalog | SHA `0d20f24a`; release `V2.4.0`; `customModels` com `/v1`, Bearer, `auto`, Chat/SSE/tools confirmados; 316 testes, 33 fixtures e build verdes; typecheck local divergiu do CI verde; custom endpoint e apenas TUI; divergencia de metadata de licenca registrada; sem contribuicao nominal artificial |
|
||||
| CLI-019 | P1 | CodeMini CLI | `havingautism/Codemini-CLI` | concluida | `config-only` | not-applicable | `feat/omniroute-codemini-cli-integration` | — | — | — | not-in-catalog | SHA `a3764b21`; package `0.8.3`; gateway `/v1`, Bearer persistido, `auto`, Chat/SSE/usage/tools e tool round trip confirmados; `/models` e probe, nao picker; 122/123 testes, 10 focados e pack-imports verdes; sem PR nominal redundante |
|
||||
| CLI-020 | P1 | Zot | `patriceckhart/zot` | concluida | `config-only` | not-applicable | `feat/omniroute-zot-integration` | — | — | — | not-in-catalog | SHA `f3d8eb66`; release `v0.3.29`; custom provider `omniroute` em `models.json`, `/v1`, Bearer, `auto`, Chat/SSE/tools/reasoning opt-in e cache usage confirmados; `--base-url` e so override; PR #36 ja cita OmniRoute; race suite/build/vet/fmt verdes |
|
||||
| CLI-021 | P1 | Octomind | `Muvon/octomind` | concluida | `config-only` | not-applicable | `feat/omniroute-octomind-integration` | — | — | — | not-in-catalog | SHA `65ab1db1`; release `0.39.0`; provider `local:auto` usa endpoint completo `/v1/chat/completions`, Bearer opcional, Chat JSON buffered, tools/reasoning/usage; sem SSE/Responses/discovery; fmt/fetch e smokes com/sem auth verdes; suite ampla nao executada por disco/contencao |
|
||||
| CLI-022 | P1 | DvalinCode | `arthurpanhku/dvalincode` | concluida | `config-only` | not-applicable | `feat/omniroute-dvalincode-integration` | — | — | — | not-in-catalog | SHA `7d42664a`; release `v0.14.1`; provider OpenAI-compatible custom com `/v1`, Bearer via env, `auto`, Chat/SSE/usage/tools e tool round trip confirmados; `provider test` bloqueado por trusted presets; issues #109/#118/#135 ja cobrem melhorias genericas; sem PR nominal |
|
||||
| CLI-023 | P1 | Coro Code | `Blushyes/coro-code` | concluida | `config-only` | not-applicable | `feat/omniroute-coro-code-integration` | — | — | — | not-in-catalog | SHA `679c57af`; release `v0.0.8`; `OPENAI_BASE_URL=/v1`, Bearer, `auto`, Chat JSON e function tools/tool loop confirmados; streaming existe mas nao e usado pelo agente; sem Responses/discovery; `cargo check`/fmt bloqueados por drift preexistente; risco de LICENSE ausente; sem PR nominal |
|
||||
| CLI-024 | P1 | Mini-Kode | `minmaxflow/mini-kode` | concluida | `config-only` | not-applicable | `feat/omniroute-mini-kode-integration` | — | — | — | not-in-catalog | SHA `4e7f9767`; release/tag npm `0.2.3`; provider custom por `MINIKODE_BASE_URL=/v1`, Bearer, `auto`, Chat/SSE e tools/tool loop confirmados; sem Responses/discovery/reasoning dedicado; sem PR nominal redundante |
|
||||
| CLI-025 | P1 | Late CLI | `mlhher/late-cli` | concluida | `config-only` | not-applicable | `feat/omniroute-late-cli-integration` | — | — | — | not-in-catalog | SHA `26814e62`; release `v1.4.2`; `OPENAI_BASE_URL=/v1`, Bearer, `auto`, Chat/SSE/usage/reasoning_content/tools e tool round trip confirmados; probes `/props`/`/v1/models` nao sao picker; BSL 1.1/CLA; sem PR nominal |
|
||||
| CLI-026 | P1 | Agentty | `1ay1/agentty` | concluida | `config-only` | not-applicable | `feat/omniroute-agentty-integration` | — | — | — | not-in-catalog | SHA `e947b26c`; release `v0.2.10`; custom host `127.0.0.1:20128`, Bearer, Chat/SSE/tools e `/v1/models` confirmados; Responses/reasoning/tool round trip dinamico nao confirmados; MIT; sem PR nominal |
|
||||
| CLI-027 | P1 | Aizen | `aizen-stack/aizen` | concluida | `config-only` | not-applicable | `feat/omniroute-aizen-integration` | — | — | — | not-in-catalog | SHA `3d8ae0f6`; release `v0.5.4`; `AIZEN_BASE_URL=/v1`, Bearer, `auto`/modelo literal, Chat/SSE/reasoning_content e `/v1/models`; tools confirmadas estaticamente, sem smoke dinamico; PolyForm Noncommercial/CLA; sem PR nominal |
|
||||
| CLI-028 | P1 | Clif-Code | `DLhugly/Clif-Code` | concluida | `config-only` | not-applicable | `feat/omniroute-clif-code-integration` | — | — | — | not-in-catalog | SHA `282a787a`; release `v1.72.0`; `CLIFCODE_API_URL=/v1`, Bearer, `auto`, Chat/SSE/usage/tools e tool loop confirmados por fonte; smoke bloqueado por binario ausente; sem Responses/reasoning; licença proprietária conflitante com FSL declarada exige revisão jurídica; sem PR nominal |
|
||||
| CLI-029 | P1 | Minacode | `hit9/minacode` | concluida | `config-only` | not-applicable | `feat/omniroute-minacode-integration` | — | — | — | not-in-catalog | SHA `d4ea4a97`; release `v0.18.1`; TOML custom `/v1`, key obrigatória, `auto`, Chat/Responses/Anthropic, SSE/tools/reasoning/discovery confirmados; smoke de protocolo Chat+Responses+models e compileall verdes; CI remoto verde; sem PR nominal |
|
||||
| CLI-030 | P1 | YottaCode | `yottadynamics/yottacode` | concluida | `config-only` | not-applicable | `feat/omniroute-yottacode-integration` | — | — | — | not-in-catalog | SHA `039f61ce`; release `v0.3.1`; provider `openai-compatible`, `/v1`, Bearer, `/v1/models`, Chat/SSE/tools/reasoning parsing confirmados; smoke oficial com mock passou; Go 1.26 nao instalado e gates completos nao executados por espaco; sem PR nominal |
|
||||
| CLI-031 | P1 | aichat | `sigoden/aichat` | concluida | `config-only` | not-applicable | `feat/omniroute-aichat-integration` | — | — | — | not-in-catalog | SHA `82976d3`; package/release `v0.30.0`; provider `openai-compatible` com base `/v1`, Bearer opcional e modelo `auto`; Chat stream/JSON, reasoning e tool round-trip confirmados; Responses ausente (#1431); limites de tool SSE ja cobertos por #1454/#1495 e PR #1496; sem publicacao nominal |
|
||||
| CLI-032 | P1 | ShellGPT | `TheR1D/shell_gpt` | concluida | `config-only` | not-applicable | `feat/omniroute-shellgpt-integration` | — | — | — | not-in-catalog | SHA `a082bd53`; release `1.5.1`; `API_BASE_URL=/v1`, `OPENAI_API_KEY`, `DEFAULT_MODEL=auto` e `USE_LITELLM=false`; smoke real confirmou env e `.sgptrc`, Chat/SSE e Bearer; issue #718 nao reproduz no HEAD; CI baseline vermelho por temperatura default independente; sem publicacao nominal |
|
||||
| CLI-033 | P1 | Mistral Vibe | `mistralai/mistral-vibe` | concluida | `config-only` | not-applicable | `feat/omniroute-mistral-vibe-integration` | — | — | — | not-in-catalog | SHA/release `99a6efa9` / `v2.23.2`; `GenericBackend` custom com base `/v1`, Bearer, Chat/SSE, usage, tools e reasoning; smoke do binario oficial verde; #790 cobre somente discovery `/v1/models`; upstream nao aceita contribuicoes de codigo no momento; sem publicacao |
|
||||
| CLI-034 | P1 | OpenSquilla | `opensquilla/opensquilla` | concluida | `config-only` | not-applicable | `feat/omniroute-opensquilla-integration` | — | — | — | not-in-catalog | `custom` com `/v1`, Bearer opcional, Chat/SSE, tools, reasoning recebido, usage e `/v1/models`; smoke provider-level verde; monitorar issue #912 do probe custom; sem publicacao nominal |
|
||||
| CLI-035 | P1 | Kode CLI | `shareAI-lab/Kode-cli` | concluida | `config-only` | not-applicable | `feat/omniroute-kode-cli-integration` | — | — | — | not-in-catalog | `custom-openai` com `/v1`, discovery `/v1/models`, fallback manual, Bearer, Chat/SSE, tools/tool round-trip e persistencia; smoke runtime bloqueado por Bun/artefato ausente; CI baseline vermelho por formatacao; sem publicacao nominal |
|
||||
| CLI-036 | P1 | Neovate Code | `neovateai/neovate-code` | concluida | `config-only` | not-applicable | `feat/omniroute-neovate-code-integration` | — | — | — | not-in-catalog | provider JSON custom normalizado para OpenAI-compatible, `/v1`, Bearer, Chat/SSE, tools/tool round-trip; model catalog declarado (sem discovery); smoke do pacote publicado verde; sem publicacao nominal |
|
||||
| CLI-037 | P1 | Deep Agents Code | `langchain-ai/deepagents` | concluida | `config-only` | not-applicable | `feat/omniroute-deepagents-code-integration` | — | — | — | not-in-catalog | SHA `46ee772b4`; `deepagents-code==0.1.51`; provider `openai`, base OmniRoute `/v1`, model `openai:auto`; Responses e default, Chat usa `use_responses_api=false`; smoke de config verde, sem HTTP/runtime por deps e disco; #3973/#3287 ja cobrem os pontos genericos; sem publicacao nominal |
|
||||
| CLI-038 | P1 | OpenHands principal | `OpenHands/OpenHands` | concluida | `config-only` | not-applicable | `feat/omniroute-openhands-main-integration` | — | — | — | not-in-catalog | SHA `1708efc44`; Agent Canvas `1.8.0`; `openai/auto` + base `/v1` + API key + `api_mode=chat`; LiteLLM envia `model=auto`, Chat/SSE/tools estruturais; sem discovery generico `/v1/models`; PRs OmniRoute [#15189](https://github.com/OpenHands/OpenHands/pull/15189)/[#15211](https://github.com/OpenHands/OpenHands/pull/15211) fechadas sem merge; sem nova publicacao |
|
||||
| CLI-039 | P1 | SWE-agent | `SWE-agent/SWE-agent` | concluida | `config-only` | not-applicable | `feat/omniroute-swe-agent-integration` | — | — | — | not-in-catalog | SHA `3ea751c08`; release `v1.1.0`; LiteLLM com `openai/<model-id>`, `api_base=/v1` e chave por env; Chat/tools/tool round-trip e batch confirmados por fonte; reasoning parcial; smoke HTTP bloqueado por deps ausentes; sem publicacao nominal |
|
||||
| CLI-040 | P1 | AutoCodeRover | `AutoCodeRoverSG/auto-code-rover` | concluida | `pr-generic` | `validating` | `feat/omniroute-auto-code-rover-integration` | — | — | — | not-in-catalog | SHA `585d3e639`; patch local sem commit em 4 arquivos corrige `litellm-generic-openai/auto`, base `/v1`, precedencia da chave e pricing desconhecido; 9 testes focados com stubs, tracer source-only, compileall e diff-check verdes; sem HTTP real; licenca SONAR Source-Available exige gate juridico antes de publicar |
|
||||
| CLI-041 | P2 | Claurst | `Kuberwastaken/claurst` | concluida | `config-only` | not-applicable | `feat/omniroute-claurst-integration` | — | — | — | not-in-catalog | SHA `595b0ebe3`; `custom-openai` com settings persistidos, base `/v1`, `CUSTOM_OPENAI_API_KEY`, modelo `auto`, Chat/SSE/tools e `/v1/models`; CI upstream verde; sem build/smoke local e sem publicacao nominal; monitorar PR #365 sem duplicar |
|
||||
| CLI-042 | P2 | Codebuff | `CodebuffAI/codebuff` | concluida | `blocked` / `issue-first` | `blocked` | `feat/omniroute-codebuff-integration` | — | — | — | not-in-catalog | SHA `195b9bef6`; main nao expoe base/chave/provider custom na CLI/SDK; PR upstream existente [#693](https://github.com/CodebuffAI/codebuff/pull/693) cobre a lacuna, observada OPEN/CONFLICTING/DIRTY; nao criar patch concorrente; acompanhar #693 e validar apos merge/port |
|
||||
| CLI-043 | P2 | Devon | `entropy-research/Devon` | concluida | `pr-generic` | validating | `feat/omniroute-devon-integration` | — | — | [upstream #100](https://github.com/entropy-research/Devon/issues/100) | not-in-catalog | SHA `8f68f1d74`; diff local genérico em 5 arquivos, sem commit; reprodução literal DeepSeek/OpenRouter e resume corrigidos; 9 testes focados, compileall e diff-check verdes; Standards/Spec aprovados; aguardar autorização antes de fork/push/PR |
|
||||
| CLI-044 | P2 | Letta Code | `letta-ai/letta-code` | concluida | `config-only` | not-applicable | `feat/omniroute-letta-code-integration` | — | — | — | integrated | SHA `09aff1bb4`; já coberta pelo provider local `lmstudio` (`lmstudio_openai`), discovery `/api/v0/models`→`/v1/models`, Chat/SSE/tools; 8 testes OmniRoute verdes; sem PR nominal |
|
||||
| CLI-045 | P2 | CodeMachine CLI | `moazbuilds/CodeMachine-CLI` | concluida | `config-only` | not-applicable | `feat/omniroute-codemachine-cli-integration` | — | — | — | not-in-catalog | SHA `572def63e`; integração indireta por OpenCode custom `@ai-sdk/openai-compatible`, base `/v1`, chave por env e `omniroute/auto`; provider/model reconhecidos no smoke de config; alternativa Claude Code; sem PR nominal |
|
||||
| CLI-046 | P2 | Groq Code CLI | `build-with-groq/groq-code-cli` | concluida | `pr-generic` | `awaiting-maintainer` | `feat/omniroute-groq-code-cli-integration` | — | — | — | not-in-catalog | SHA `a303eb4be`; `groq-sdk@0.27.0` fixa `/openai/v1/chat/completions`, logo não há config-only para OmniRoute; mock confirmou path/Bearer; PR existente [#7](https://github.com/build-with-groq/groq-code-cli/pull/7) é a duplicata natural, mas precisa distinguir Groq-compatible de OpenAI-compatible; 17 testes oficiais + 5 testes de contexto, build e mock verdes; clone limpo, sem patch/publicação |
|
||||
| CLI-047 | P2 | Dexto | `truffle-ai/dexto` | concluida | `config-only` | `not-applicable` | `feat/omniroute-dexto-integration` | — | — | — | not-in-catalog | SHA `4108a9c73`; provider `openai-compatible` nativo exige `baseURL`, aceita modelo arbitrário, Bearer opcional, Chat/SSE/tools e reasoning effort; receita `/v1` + `auto`; 175 testes focados e builds llm/core verdes; TS2741 em chatgpt-oauth é baseline; ELv2; sem PR/issue nominal |
|
||||
| CLI-048 | P2 | claw-code-agent | `HarnessLab/claw-code-agent` | concluida | `config-only` | `not-applicable` | `feat/omniroute-claw-code-agent-integration` | — | — | — | not-in-catalog | SHA `167571da8`; `OPENAI_BASE_URL=http://127.0.0.1:20128/v1`, Bearer, model manual/`auto`, Chat/SSE/tools/usage confirmados; smoke `MOCK_SMOKE_OK`, 80 testes focados; sem discovery/Responses API; licença não identificada (`license: null`); sem PR/issue |
|
||||
| CLI-049 | P2 | g3 | `dhanji/g3` | concluida | `pr-generic` | `validating` | `feat/omniroute-g3-integration` | — | — | [upstream #70](https://github.com/dhanji/g3/issues/70) | not-in-catalog | SHA `0ddb052d2`; diff local provider-neutral em `provider_registration.rs`, 1 arquivo `+25/-1`, corrige registro `custom`→`custom.default`; `cargo check -p g3-config`, 6 testes config e diff-check verdes; teste focal escrito mas build bloqueado em `x11.pc`; manifesto declara MIT sem arquivo LICENSE; Standards/Spec centrais aprovados; sem publicação |
|
||||
| CLI-050 | P2 | San | `genai-io/san` | concluida | `config-only` | `not-applicable` | `feat/omniroute-san-integration` | — | — | — | not-in-catalog | SHA `e45ec0ef7`; Apache-2.0/release v1.22.1; provider Custom com base `/v1`, Bearer, `/models`, Chat/SSE/tools/tool result e reasoning best-effort; smoke HTTP de dois turnos e gates Go focados verdes; sem provider nominal ou publicação |
|
||||
| CLI-051 | P2 | Waveloom | `Menfre01/waveloom` | concluida | `config-only` | `not-applicable` | `feat/omniroute-waveloom-integration` | — | — | — | not-in-catalog | SHA `293d5cd11`; Apache-2.0/release v0.5.1; adapter OpenAI com `/v1`, Bearer, `/models`, SSE, 14 tools, tool-result round-trip e sessões; smoke do binário oficial verde e CI remoto do HEAD verde; reasoning/cache avançados não são projetados; sem publicação |
|
||||
| CLI-052 | P2 | picocode | `jondot/picocode` | concluida | `config-only` | `not-applicable` | `feat/omniroute-picocode-integration` | — | — | — | not-in-catalog | SHA `064a2a6ea`; MIT/release v0.6.0; Rig 0.28 lê `OPENAI_BASE_URL` e usa Responses `/v1/responses`; smoke confirmou Bearer, `auto`, 11 tools e function_call_output; 7 testes/doc-tests verdes; fmt/clippy só baseline; sem PR/issue |
|
||||
| CLI-053 | P2 | QQCode | `qnguyen3/qqcode` | concluida | `config-only` | `not-applicable` | `feat/omniroute-qqcode-integration` | — | — | — | not-in-catalog | SHA `be6a96ce7`; Apache-2.0/release v1.2.0; provider arbitrário + `GENERIC`/OpenAI com base `/v1`; smoke confirmou JSON/SSE, Bearer, extra_body, reasoning e tool-result; backend 20/20, ACP 13+1 skip, observer 11/11, compileall/helps verdes; sem PR/issue |
|
||||
| CLI-054 | P2 | Keen Code | `mochow13/keen-code` | concluida | `config-only` | `not-applicable` | `feat/omniroute-keen-code-integration` | — | — | — | not-in-catalog | SHA `ee2eaf0f4`; MIT/release v0.40.0; receita manual `openai-compatible` + `/v1` + Bearer + model arbitrário; smoke oficial confirmou Chat/SSE, tools/tool-result, usage e reasoning replay; provider oculto apenas no picker; CI remoto verde; sem PR/issue |
|
||||
| CLI-055 | P2 | Grinta | `josephsenior/Grinta-Coding-Agent` | concluida | `config-only` | `not-applicable` | `feat/omniroute-grinta-integration` | — | — | — | not-in-catalog | SHA `df7437524`; provider OpenAI-compatible com `LLM_API_KEY`, model `auto`, base `/v1`; smoke Chat/SSE/tools/tool-result/reasoning/usage/cache verde; 183 testes focados, compileall e Ruff verdes; sem PR/issue nominal |
|
||||
| CLI-056 | P2 | Zap | `zap-coding-agent/zap-coding-agent` | concluida | `config-only` | `not-applicable` | `feat/omniroute-zap-integration` | — | — | — | not-in-catalog | SHA `f0203f872`; provider arbitrário `kind=openai`, base `/v1`, Bearer, discovery `/models`, Chat JSON/SSE, tools/tool-result, reasoning e usage confirmados; cargo check + 16 testes/gates focados verdes; issue #2 confirma arquitetura; sem PR nominal |
|
||||
| CLI-057 | P2 | Binharic | `CogitatorTech/binharic-cli` | concluida | `pr-generic` | `validating` | `feat/omniroute-binharic-integration` | — | — | — | not-in-catalog | SHA `52ccca70b`; patch sem commit em `provider.ts` + teste: aplica `baseURL` ao OpenAI/Anthropic e usa Chat Completions para base customizada; RED→GREEN, 14 focal, 88 arquivos/774 testes, typecheck/build e smoke wire verdes; lint upstream bloqueado; sem publicação |
|
||||
| CLI-058 | P2 | Darce | `AmerSarhan/darce-cli` | concluida | `config-only` | `not-applicable` | `feat/omniroute-darce-integration` | — | — | — | not-in-catalog | SHA `1b90c379a`; MIT declarada no package/npm sem arquivo LICENSE; `DARCE_API_BASE` raiz sem `/v1`, `DARCE_API_KEY`, `DARCE_MODEL=auto`; smoke PTY do binário confirmou 2 Chat/SSE, 7 tools, tool-result e Bearer; 106 testes/build verdes; sem MCP/ACP/A2A; sem PR/issue |
|
||||
| CLI-059 | P2 | CLAII | `agencyswarm/CLAII` | concluida | `pr-generic` | `blocked` | `feat/omniroute-claii-integration` | — | — | — | not-in-catalog | SHA `89d42311b`; patch sem commit em README/config/providers/test: `CLAII_API_KEY`, `CLAII_BASE_URL` origem sem `/v1beta`, model runtime e reject explícito; 4 wire/loop + 10 calculator + pip install + smoke CLI verdes; unittest discover falha só baseline `calculator`/`pkg`; sem MCP/ACP/A2A; **All Rights Reserved**, não publicar sem autorização jurídica |
|
||||
| CLI-060 | P2 | nori-cli | `tilework-tech/nori-cli` | concluida | `config-only` | `not-applicable` | `feat/omniroute-nori-cli-integration` | — | — | — | not-in-catalog | SHA `829ecf3fd`; Apache-2.0/v0.24.0; Nori custom ACP → OpenCode `opencode-ai@1.18.11` → OmniRoute `/v1`; MCP separado por `/api/mcp/stream` ou stdio; 5 testes focados, cargo build nori e smoke ACP Nori→OpenCode verdes; sem patch/publicação |
|
||||
| CLI-061 | P2 | cursor-agent clone | `civai-technologies/cursor-agent` | concluida | `config-only` | `not-applicable` | `feat/omniroute-cursor-agent-clone-integration` | — | — | — | not-in-catalog | SHA `d21a8f3d4`; MIT/v0.1.39; SDK OpenAI usa base `/v1`, Anthropic usa raiz; smokes de 2 turnos/tools verdes; factory rejeita `auto` puro; 23 testes, mypy/build verdes; sem patch/publicação |
|
||||
| CLI-062 | P2 | Free Code | `freecodexyz/free-code` | concluida | `config-only` | `blocked` | `feat/omniroute-free-code-integration` | — | — | [upstream #20](https://github.com/freecodexyz/free-code/issues/20) | not-in-catalog | SHA `6b25ab68b`; URL antiga `paoloanzn/free-code` redireciona; base Anthropic raiz, `model=auto`, stream/tools/MCP; build verde; sem LICENSE/campo license e código atribuído à Anthropic, não publicar |
|
||||
| CLI-063 | P2 | Claude Engineer | `Doriandarko/claude-engineer` | concluida | `config-only` / `pr-generic` | `blocked` | `feat/omniroute-claude-engineer-integration` | — | [upstream #250](https://github.com/Doriandarko/claude-engineer/pull/250) | [upstream #116](https://github.com/Doriandarko/claude-engineer/issues/116) | not-in-catalog | SHA `0a9e4b309`; v3 funciona por base Anthropic raiz com modelo fixo; #250 já adiciona `ANTHROPIC_MODEL`; arquivo LICENSE ausente apesar de declaração MIT; sem patch concorrente/publicação |
|
||||
| CLI-064 | P2 | Smol Developer | `smol-ai/developer` | concluida | `config-only` | `not-applicable` | `feat/omniroute-smol-developer-integration` | — | — | — | not-in-catalog | SHA `a6747d1a6`; `OPENAI_API_BASE=/v1`, `auto`, 3 Chat calls, SSE/function calling e Agent Protocol validados; gates de runtime verdes, build metadata preexistente; sem patch/publicação |
|
||||
| CLI-065 | P2 | Agentless | `OpenAutoCoder/Agentless` | concluida | `config-only` | `not-applicable` | `feat/omniroute-agentless-integration` | — | — | — | not-in-catalog | SHA `5ce5888b9`; OpenAI chat + embeddings funcionam com bases distintas; Anthropic normal/cache histórico validados; DeepSeek fixa host; pre-commit/compileall verdes; sem patch/publicação |
|
||||
| CLI-066 | P2 | Amazon Q Developer CLI | `aws/amazon-q-developer-cli` | concluida | `viable-mcp` / `needs-wrapper` | `not-applicable` | `feat/omniroute-amazon-q-developer-cli-integration` | — | — | — | not-in-catalog | SHA `15cc8f3cd`; modelo usa AWS JSON/EventStream Bearer/SigV4 e não `/v1`; MCP stdio imediato, HTTP legado com ressalva; upstream issue-first/manutenção crítica; sem patch/publicação |
|
||||
| CLI-067 | P2 | nanobot | `HKUDS/nanobot` | concluida | `config-only` | `not-applicable` | `feat/omniroute-nanobot-integration` | — | — | — | not-in-catalog | HEAD `44b7e1bf4`; provider dinâmico OpenAI-compatible com base `/api/v1` e modelo `omniroute/auto`; Chat/SSE/tools/reasoning/usage/images/discovery e retry validados; 424 testes + Ruff; sem PR nominal |
|
||||
| CLI-068 | P2 | ZeroClaw | `zeroclaw-labs/zeroclaw` | concluida | `config-only` | `not-applicable` | `feat/omniroute-zeroclaw-integration` | — | — | — | not-in-catalog | HEAD `4770420ab`; `custom.omniroute`, base `/v1`, Bearer, `auto`, Chat/Responses e tools nativas opt-in; 1.173 unit + 1 integração, fmt/config/smoke verdes; sem PR nominal |
|
||||
| CLI-069 | P2 | NanoClaw | `gavrielc/nanoclaw` | concluida | `config-only` | `not-applicable` | `feat/omniroute-nanoclaw-integration` | — | — | — | not-in-catalog | HEAD `dfac7e0af`; provider Claude existente aponta para raiz Anthropic OmniRoute e OneCLI guarda a chave; baseline e 49 testes OmniRoute verdes; Codex #3155/#1984 e OpenCode #2985 ficam como follow-ups; sem PR |
|
||||
| CLI-070 | P2 | PicoClaw | `sipeed/picoclaw` | concluida | `config-only` | `not-applicable` | `feat/omniroute-picoclaw-integration` | — | — | — | not-in-catalog | HEAD `49183d7`, `/api/v1`, `openai/auto` → `auto`; Chat/SSE/tools/usage/images/discovery; Go ausente, testes locais não executados; issue router #3298; sem publicação |
|
||||
| CLI-071 | P2 | IronClaw | `nearai/ironclaw` | concluida | `config-only` | `not-applicable` | `feat/omniroute-ironclaw-integration` | — | — | — | not-in-catalog | HEAD `4b71aaae`; `openai_compatible` `/api/v1`, Chat/SSE/tools/images/discovery; 889+5 testes e fmt verdes; reasoning #3673; sem publicação |
|
||||
| CLI-072 | P2 | NullClaw | `nullclaw/nullclaw` | concluida | `config-only` | `not-applicable` | `feat/omniroute-nullclaw-integration` | — | — | — | not-in-catalog | HEAD `d8a802fd`; custom `/api/v1`, Chat/Responses/Anthropic, tools/streaming/usage/images; Zig ausente, CI run 30788444193 verde; sem publicação |
|
||||
| CLI-073 | P2 | Moltis | `moltis-org/moltis` | concluida | `config-only` | `not-applicable` | `feat/omniroute-moltis-integration` | — | — | — | not-in-catalog | HEAD `678d407`; `custom-omniroute`, `/api/v1`, `auto`, Chat/SSE/tools/reasoning/usage/images; 401 testes + fmt verdes; MCP/ACP separados; sem publicação |
|
||||
| CLI-074 | P2 | GitClaw | `open-gitagent/gitclaw` | concluida | `config-only` | `not-applicable` | `feat/omniroute-gitclaw-integration` | — | — | — | not-in-catalog | GitAgent HEAD `d3e25d7`; base `/api/v1`, `omniroute:auto`, Chat/SSE/tools/images; build + 65 testes + smoke verdes; reasoning=false no descriptor; sem publicação |
|
||||
| CLI-075 | P2 | LionClaw | `moshthepitt/lionclaw` | concluida | `patch-required` / `issue-first` | `awaiting-maintainer` | `feat/omniroute-lionclaw-integration` | — | — | — | not-in-catalog | HEAD `cb59b23d`; Codex app-server não projeta config.toml/secret para runtime confinado; patch seguro necessário, alinhado à #157; gates locais bloqueados por uv/podman; CI verde; sem publicação |
|
||||
| CLI-076 | P3 | VibePod | `VibePod/vibepod-cli` | concluida | `config-only` | `not-applicable` | `feat/omniroute-vibepod-integration` | — | — | — | not-in-catalog | Claude Code via `/api`, container usa `host.docker.internal`; Codex não injeta chave; compileall verde, pytest bloqueado por typer; sem publicação |
|
||||
| CLI-077 | P3 | zeroshot | `the-open-engine/zeroshot` | concluida | `config-only` | `not-applicable` | `feat/omniroute-zeroshot-integration` | — | — | — | not-in-catalog | Gateway OpenAI `/api/v1`, `auto`, tools fail-closed; 22 testes + build verdes; sem streaming JSON/reasoning/MCP no gateway; sem publicação |
|
||||
| CLI-078 | P3 | Fractal | `plasma-ai/fractal` | concluida | `config-only` / `needs-wrapper` | `awaiting-maintainer` | `feat/omniroute-fractal-integration` | — | — | — | not-in-catalog | Codex Responses por node `CODEX_HOME`; caveat tmux quente não encaminha `OMNIROUTE_API_KEY`; fix genérico recomendado, sem PR |
|
||||
| CLI-079 | P3 | Bernstein | `chernistry/bernstein` | concluida | `config-only` | `not-applicable` | `feat/omniroute-bernstein-integration` | — | — | — | not-in-catalog | Canonical `sipyourdrink-ltd/bernstein`; openai_agents `/api/v1`, auto, api_key_env allowlisted; testes bloqueados por openai ausente; sem publicação |
|
||||
| CLI-080 | P3 | Traycer | `traycerai/traycer` | concluida | `config-only` indireto | `not-applicable` | `feat/omniroute-traycer-integration` | — | — | — | not-in-catalog | Harness OpenCode + provider `@ai-sdk/openai-compatible`, `/api/v1`, `omniroute/auto`; host central fechado; sem publicação |
|
||||
| CLI-081 | P3 | h5i | `h5i-dev/h5i` | concluida | `patch-required` | `awaiting-maintainer` | `feat/omniroute-h5i-integration` | — | — | — | not-in-catalog | Auth proxy/egress Codex fixos em OpenAI anulam base custom; patch seguro/policy-pinned necessário; CI externa verde; sem publicação |
|
||||
| CLI-082 | P3 | OMK | `dmae97/open-multi-agent-kit` | concluida | `viable-mcp` | `not-applicable` | `feat/omniroute-omk-integration` | — | — | — | not-in-catalog | pesquisa concluída neste lote; controle multiagente, MCP é caminho primário; sem provider nominal |
|
||||
| CLI-083 | P3 | kodo | `ikamensh/kodo` | concluida | `config-only` indireto | `not-applicable` | `feat/omniroute-kodo-integration` | — | — | — | not-in-catalog | pesquisa concluída neste lote; orquestrador/agent child, propagar env/base/model ao agente filho |
|
||||
| CLI-084 | P3 | ORCH | `oxgeneral/ORCH` | concluida | `needs-wrapper` | `awaiting-maintainer` | `feat/omniroute-orch-integration` | — | — | — | not-in-catalog | pesquisa concluída neste lote; fila/controle sem provider LLM direto, wrapper/adaptador necessário |
|
||||
| CLI-085 | P3 | LoopTroop | `LoopTroop-ai/LoopTroop` | concluida | `config-only` indireto | `not-applicable` | `feat/omniroute-looptroop-integration` | — | — | — | not-in-catalog | HEAD `cbfc81c5`; OpenCode recebe provider `@ai-sdk/openai-compatible`, `/api/v1`, `omniroute/auto`; 16 testes verdes; sem publicação |
|
||||
| CLI-086 | P3 | Galley | `shinpr/galley` | concluida | `patch-required` | `awaiting-maintainer` | `feat/omniroute-galley-integration` | — | — | — | not-in-catalog | HEAD `6bcc593d`; registry/transports fechados, requer transport OpenAI-compatible para executor e supervisor; Go ausente; sem publicação |
|
||||
| CLI-087 | P3 | Relay | `jcast90/relay` | concluida | `config-only` | `not-applicable` | `feat/omniroute-relay-integration` | — | — | — | not-in-catalog | HEAD `7bd5a2f6`; provider profile Codex com `OPENAI_BASE_URL`, key ref e modelo; smoke Responses obrigatório; MCP separado; sem publicação |
|
||||
| CLI-088 | P3 | SageCLI | `youwangd/SageCLI` | concluida | `config-only` indireto | `not-applicable` | `feat/omniroute-sagecli-integration` | — | — | — | not-in-catalog | HEAD `c167712d`; Codex runtime, base/key configuradas fora do Sage; env plaintext caveat; 45 testes verdes; sem publicação |
|
||||
| CLI-089 | P3 | 5dive | `5dive-ai/5dive` | concluida | `patch-required` | `awaiting-maintainer` | `feat/omniroute-5dive-integration` | — | — | — | not-in-catalog | HEAD `b64b6dac`; provider/base maps fechados; patch OpenAI-compatible genérico; 50 testes focados verdes; sem publicação |
|
||||
| CLI-090 | P3 | agx | `ramarlina/agx` | concluida | `config-only` indireto | `not-applicable` | `feat/omniroute-agx-integration` | — | — | — | not-in-catalog | HEAD `e674cec1`; Codex herda base/key/model; smoke Responses e governança `--full-auto`; Jest ausente; sem publicação |
|
||||
| CLI-091 | P3 | claude-code-router | `musistudio/claude-code-router` | concluida | `config-only` | `not-applicable` | `feat/omniroute-claude-code-router-integration` | — | — | — | not-in-catalog | HEAD `bc8a8e62`; provider custom OpenAI/Anthropic/Gemini, Chat/Responses; smoke por protocolo; sem publicação |
|
||||
| CLI-092 | P3 | cc-router | `finch-xu/cc-router` | concluida | `config-only` | `not-applicable` | `feat/omniroute-cc-router-integration` | — | — | — | not-in-catalog | HEAD `c4c7579`; custom Responses/Chat com base/path/header, SSE/tools/reasoning; cargo bloqueado por glib; sem publicação |
|
||||
| CLI-093 | P3 | OneCLI | `onecli/onecli` | concluida | `config-only` | `not-applicable` | `feat/omniroute-onecli-integration` | — | — | — | not-in-catalog | HEAD `84ccaf74`; MITM credential gateway, generic host injection; MCP separado; sem publicação |
|
||||
| CLI-094 | P3 | agent-browser | `vercel-labs/agent-browser` | concluida | `config-only` | `not-applicable` | `feat/omniroute-agent-browser-integration` | — | — | — | not-in-catalog | HEAD `01c1147d`; chat usa gateway Chat/SSE/tools com env key/model; base precisa validar sufixo `/v1` para não duplicar path; cargo test exit 0; sem publicação |
|
||||
| CLI-095 | P3 | OpenWork | `different-ai/openwork` | concluida | `config-only` indireto | `not-applicable` | `feat/omniroute-openwork-integration` | — | — | — | not-in-catalog | HEAD `ecb7a5f0`; OpenCode custom provider `/api/v1`, auth gerenciada; sem testes/deps; sem publicação |
|
||||
| CLI-096 | P3 | Agent Deck review | `asheshgoplani/agent-deck` | concluida | `config-only` indireto | `not-applicable` | `feat/omniroute-agent-deck-review` | — | — | — | integrated | HEAD `46300807`; env/model propagados a Codex/OpenCode; Go ausente; sem publicação |
|
||||
| CLI-097 | P4 | Pool | `poolsideai/pool` | concluida | `config-only` | `not-applicable` | `feat/omniroute-pool-integration` | — | — | — | not-in-catalog | HEAD `a6fe0ca1`; `pool exec --api-url` OpenAI-compatible, sandbox required, MCP/ACP separado; EULA; sem publicação |
|
||||
| CLI-098 | P4 | Junie CLI | `junie.jetbrains.com` | concluida | `config-only` | `not-applicable` | `feat/omniroute-junie-integration` | — | — | — | not-in-catalog | HEAD `d2701be6`; custom profile OpenAICompletion/Responses com baseUrl full e env ref; runtime proprietário/EAP; sem publicação |
|
||||
| CLI-099 | P4 | Cursor desktop | Anysphere | concluida | `config-only` limitado | `awaiting-maintainer` | `feat/omniroute-cursor-desktop-integration` | — | — | — | integrated | disclosure-only; BYO key/chat panel; Composer/Tab nativos; privado/MITM proibido; sem publicação |
|
||||
| CLI-100 | P4 | Windsurf | Codeium | concluida | `blocked-closed` / MCP-only | `awaiting-maintainer` | `feat/omniroute-windsurf-integration` | — | — | — | not-in-catalog | sem upstream/base custom; BYOK Anthropic específico; MCP separado; MITM proibido; sem publicação |
|
||||
| CLI-101 | P4 | Amp | Sourcegraph | concluida | `config-only` parcial / Enterprise-gated | `awaiting-maintainer` | `feat/omniroute-amp-integration` | — | — | — | not-in-catalog | CLI fechada/Amp Server; confirmar provider custom com suporte; MCP viável; sem publicação |
|
||||
| CLI-102 | P4 | Amazon Q/Kiro CLI | AWS | concluida | `patch-required` legado / `blocked-closed` Kiro | `awaiting-maintainer` | `feat/omniroute-amazon-q-integration` | — | — | — | integrated | Q usa AWS EventStream/SigV4; Kiro fechado sem base custom; MCP-only seguro; sem publicação |
|
||||
| CLI-103 | P4 | Cowork | Anthropic | concluida | `blocked-closed` / MCP-only | `not-applicable` | — | — | — | — | not-in-catalog | inferência gerida pela Anthropic sem BYOK/base custom; Custom Connector MCP remoto; MITM proibido; sem publicação |
|
||||
|
||||
## Como atualizar
|
||||
|
||||
Ao terminar uma fase, alterar somente os campos comprovados e deixar os demais como `—`. Para uma integracao concluida, registrar: versao/commit pesquisado, mecanismo, arquivos modificados, testes, branch, commit, URL de PR/issue e resposta do mantenedor. Se o caso for apenas configuracao, registrar o comando/config real e marcar `config-only` ou `viable-direct`, sem criar uma PR artificial.
|
||||
|
||||
Antes de publicar uma contribuicao, aplicar o gate e o checklist de
|
||||
`05-plano-publicacao-prs-upstream.md`.
|
||||
@@ -1,659 +0,0 @@
|
||||
# Plano de publicacao de integracoes OmniRoute nos repositorios upstream
|
||||
|
||||
> **Status da campanha de pesquisa:** `104/104` casos concluídos. Este plano continua sendo o procedimento de execução e publicação. A matriz final, inclusive os casos em que PR é inadequada ou impossível, está em `06-relatorio-final-104-clis-e-estrategia-prs.md`.
|
||||
|
||||
**Data:** 2026-08-01
|
||||
**Escopo:** transformar a fila `CLI-000` a `CLI-103` em contribuicoes upstream verificadas,
|
||||
publicando PR, issue, guia de configuracao, adaptador ou conclusao de bloqueio conforme o mecanismo
|
||||
real de cada projeto.
|
||||
**Documentos-base:** `01-relatorio-pesquisa-clis-omniroute.md`,
|
||||
`02-prioridade-integracoes-clis.md`, `03-plano-integracao-em-lotes.md` e
|
||||
`04-tracker-integracoes-clis.md`.
|
||||
|
||||
## 1. Resultado esperado
|
||||
|
||||
Para cada repositorio pesquisado, a campanha deve produzir exatamente um resultado principal:
|
||||
|
||||
1. **PR upstream de integracao nominal:** adiciona provider/preset `omniroute`, configuracao,
|
||||
documentacao e testes quando isso combina com a arquitetura do projeto.
|
||||
2. **PR upstream de compatibilidade generica:** melhora suporte a endpoint customizado sem acoplar
|
||||
o projeto ao nome OmniRoute, acompanhado de documentacao comprovando o uso com OmniRoute.
|
||||
3. **PR somente de documentacao:** registra uma configuracao funcional quando o codigo ja suporta
|
||||
OmniRoute e o upstream aceita guias de terceiros.
|
||||
4. **Issue-first:** solicita decisao de arquitetura ou permissao antes do patch quando a politica do
|
||||
repositorio, o desenho de providers ou o tamanho da mudanca exigirem alinhamento.
|
||||
5. **Configuracao sem PR:** documenta no OmniRoute um fluxo que ja funciona e para o qual uma mudanca
|
||||
upstream seria redundante ou rejeitada pela politica do projeto.
|
||||
6. **Adaptador ACP/MCP/wrapper:** contribui no ponto de extensao correto quando o projeto nao consome
|
||||
diretamente APIs de modelos.
|
||||
7. **MITM, produto fechado ou bloqueado:** registra evidencia e nao fabrica uma contribuicao que o
|
||||
upstream nao pode receber.
|
||||
|
||||
O objetivo e tentar integrar todos os casos tecnicamente possiveis. O objetivo nao e abrir uma PR em
|
||||
todo repositorio independentemente da arquitetura, licenca ou politica de contribuicao.
|
||||
|
||||
## 2. Regras da campanha
|
||||
|
||||
- Trabalhar em lotes de no maximo tres repositorios, com um subagente por repositorio.
|
||||
- Usar uma worktree isolada por repositorio dentro de `.claude/worktrees/`.
|
||||
- Nao editar implementacoes no checkout compartilhado.
|
||||
- Nao usar `git stash` ou `git pop`.
|
||||
- Fazer pesquisa fresca no commit atual do upstream antes de criar branch ou editar arquivos.
|
||||
- Ler `README`, `CONTRIBUTING`, templates de issue/PR, `SECURITY`, licenca e instrucoes locais de
|
||||
agentes antes da implementacao.
|
||||
- Procurar issues e PRs abertas/fechadas sobre custom provider, base URL, OpenAI-compatible,
|
||||
Anthropic-compatible, Gemini endpoint, proxy, gateway e OmniRoute antes de propor uma mudanca.
|
||||
- Registrar a base pesquisada por commit SHA ou release. Nao usar apenas `main` como evidencia.
|
||||
- Executar baseline antes da mudanca e distinguir falhas preexistentes de regressao.
|
||||
- Nunca expor `OMNIROUTE_API_KEY` ou qualquer outra credencial em comandos publicados, fixtures,
|
||||
logs, commits, screenshots, PRs ou issues.
|
||||
- Nao inserir trailers, assinaturas ou rodapes de IA em commits, PRs ou issues.
|
||||
- Nao afirmar que uma integracao funciona sem um teste reproduzivel ou uma limitacao explicitamente
|
||||
registrada.
|
||||
- Nao inventar fork, branch, commit, PR, issue, CI ou resposta de mantenedor.
|
||||
- Atualizar `04-tracker-integracoes-clis.md` ao concluir cada fase material.
|
||||
|
||||
## 3. Unidade de trabalho por repositorio
|
||||
|
||||
Cada item `CLI-NNN` deve possuir uma task individual. A task e o pacote de contexto entregue ao
|
||||
subagente e o registro que permite retomar o trabalho sem repetir ou perder evidencias.
|
||||
|
||||
### 3.1 Cabecalho obrigatorio da task
|
||||
|
||||
```md
|
||||
# CLI-NNN - <projeto> - integracao OmniRoute upstream
|
||||
|
||||
- Repositorio canonico: <URL>
|
||||
- Prioridade/lote: <P0-P4 / lote>
|
||||
- Estado no catalogo OmniRoute: <integrated/not-in-catalog/parcial>
|
||||
- Evidencia inicial: <resumo vindo do relatorio; ainda nao confirmado>
|
||||
- Worktree: <caminho isolado>
|
||||
- Branch planejada: <definir somente depois de ler as regras upstream>
|
||||
- Commit/release pesquisado: —
|
||||
- Responsavel: <agente>
|
||||
- Estado: researching
|
||||
```
|
||||
|
||||
### 3.2 Pesquisa obrigatoria dentro da task
|
||||
|
||||
O subagente deve responder, com links e caminhos de codigo:
|
||||
|
||||
1. Qual e o repositorio canonico, commit/release atual, licenca e nivel de atividade?
|
||||
2. Contribuicoes de forks externos sao aceitas? Ha CLA, DCO, sign-off ou issue previa obrigatoria?
|
||||
3. Qual e a arquitetura de providers e qual e o menor ponto de extensao?
|
||||
4. O cliente usa Chat Completions, Responses, Anthropic Messages, Gemini, ACP, MCP ou protocolo
|
||||
proprietario?
|
||||
5. A base URL esperada e raiz, `/v1`, `/v1beta` ou uma URL completa por operacao?
|
||||
6. O cliente acrescenta algum sufixo automaticamente? Pode duplicar `/v1` ou `/v1beta`?
|
||||
7. Como a autenticacao e resolvida: variavel de ambiente, arquivo, keyring, OAuth ou header custom?
|
||||
8. Como os modelos sao definidos ou descobertos? O cliente chama um endpoint de modelos?
|
||||
9. Streaming, tool calling, reasoning, imagens e cancelamento funcionam pelo caminho escolhido?
|
||||
10. Ja existe issue, PR, discussao ou documentacao para endpoints customizados ou OmniRoute?
|
||||
11. Quais comandos oficiais executam install, format, lint, typecheck, build e testes?
|
||||
12. Qual contribuicao agrega valor real: codigo nominal, compatibilidade generica, docs, issue,
|
||||
wrapper, MCP/ACP, somente configuracao ou nenhum patch?
|
||||
|
||||
### 3.3 Gate de contribuicao
|
||||
|
||||
Antes de editar, preencher uma decisao:
|
||||
|
||||
| Decisao | Quando usar | Saida esperada |
|
||||
|---|---|---|
|
||||
| `pr-provider` | O upstream possui catalogo/presets de providers | Provider/preset OmniRoute, docs e testes |
|
||||
| `pr-generic` | Falta uma capacidade generica necessaria, como base URL customizavel | Patch generico, docs e teste com OmniRoute |
|
||||
| `pr-docs` | O codigo ja funciona e o upstream aceita guias de integracao | Guia minimo e validado |
|
||||
| `issue-first` | Mudanca arquitetural, politica incerta ou mantenedor exige proposta | Issue com evidencia e desenho do patch |
|
||||
| `config-only` | Tudo funciona por configuracao e um PR seria redundante | Guia no OmniRoute e smoke test |
|
||||
| `adapter-acp` | ACP e o ponto real de integracao | Adaptador/registro ACP e testes |
|
||||
| `adapter-mcp` | MCP e o ponto real de integracao | Config/servidor MCP e testes |
|
||||
| `wrapper` | O projeto apenas lanca outro agente | Wrapper/env forwarding e teste do filho |
|
||||
| `needs-mitm` | Endpoint fechado ou fixo | Pesquisa/guia MITM separado; sem PR artificial |
|
||||
| `blocked` | Licenca, politica, build ou protocolo impedem progresso | Evidencia reproduzivel e proximo desbloqueio |
|
||||
|
||||
O gate deve incluir a alternativa rejeitada. Exemplo: `pr-provider` escolhido porque o repositorio
|
||||
mantem presets nomeados; `pr-docs` rejeitado porque a configuracao exigiria cinco campos internos e
|
||||
nao seria uma experiencia suportada.
|
||||
|
||||
## 4. Ciclo completo da PR
|
||||
|
||||
### Fase PR-0 - Preparar o contexto
|
||||
|
||||
- Reservar o item no tracker e marcar pesquisa em andamento.
|
||||
- Confirmar que nenhum outro agente esta trabalhando no mesmo repositorio.
|
||||
- Resolver o repositorio canonico, fork existente e permissao de contribuicao.
|
||||
- Criar a task individual com a evidencia inicial marcada como hipotese.
|
||||
- Criar a worktree isolada somente depois de confirmar o upstream correto.
|
||||
|
||||
### Fase PR-1 - Pesquisar upstream e contribuicoes existentes
|
||||
|
||||
- Ler integralmente as regras do repositorio aplicaveis aos arquivos que podem mudar.
|
||||
- Mapear provider registry, configuracao, transporte HTTP, auth, modelo, streaming e ferramentas.
|
||||
- Pesquisar issues/PRs por termos de compatibilidade e pelo nome OmniRoute.
|
||||
- Registrar commit/release, caminhos e links de evidencia na task.
|
||||
- Escolher o gate de contribuicao da secao 3.3.
|
||||
|
||||
### Fase PR-2 - Baseline reproduzivel
|
||||
|
||||
- Instalar dependencias de acordo com o upstream.
|
||||
- Rodar format check, lint, typecheck/build e testes relevantes antes do patch.
|
||||
- Rodar um smoke test do caminho existente, mesmo que ele falhe por falta da integracao.
|
||||
- Limpar chaves do ambiente nos testes que validem o comportamento sem credenciais.
|
||||
- Registrar comando, codigo de saida, testes aprovados e falhas preexistentes.
|
||||
- Se o projeto nao puder ser construido, tentar o ambiente documentado e registrar o bloqueio; nao
|
||||
declarar regressao nem compatibilidade com base apenas na leitura do README.
|
||||
|
||||
### Fase PR-3 - Desenhar o menor patch aceitavel
|
||||
|
||||
A ordem de preferencia e:
|
||||
|
||||
1. Reusar a abstracao de provider ja existente.
|
||||
2. Adicionar metadados/preset antes de criar codigo especial.
|
||||
3. Reusar cliente OpenAI/Anthropic/Gemini ja presente.
|
||||
4. Adicionar capacidade generica quando ela beneficiar outros gateways e for coerente com o projeto.
|
||||
5. Criar executor/adapter dedicado somente quando o protocolo realmente divergir.
|
||||
|
||||
O patch normalmente deve cobrir:
|
||||
|
||||
- identificador e nome de exibicao `omniroute`, se presets nomeados forem aceitos;
|
||||
- base URL correta e sem dupla concatenacao de versao;
|
||||
- chave obtida de ambiente ou storage seguro;
|
||||
- configuracao/descoberta de modelo;
|
||||
- headers estritamente necessarios;
|
||||
- streaming e tool calling preservados;
|
||||
- mensagens de erro sem expor segredo;
|
||||
- documentacao curta e executavel;
|
||||
- testes unitarios/integracao alinhados ao padrao upstream.
|
||||
|
||||
Nao adicionar telemetria, dependencia, fluxo de login ou codigo de rede novo quando o provider
|
||||
generico existente ja resolve o caso.
|
||||
|
||||
### Fase PR-4 - Implementar com teste primeiro
|
||||
|
||||
- Criar teste que demonstre a ausencia do preset, config ou comportamento requerido.
|
||||
- Confirmar a falha pelo motivo esperado.
|
||||
- Implementar o menor patch.
|
||||
- Fazer o teste passar e executar testes adjacentes.
|
||||
- Refatorar apenas o necessario para manter o padrao do upstream.
|
||||
- Formatar somente os arquivos tocados, salvo exigencia contraria do repositorio.
|
||||
|
||||
Para PR somente de documentacao, substituir o teste vermelho por uma validacao real dos comandos e
|
||||
do arquivo de configuracao documentado. Nao sintetizar exemplos que nao foram executados.
|
||||
|
||||
### Fase PR-5 - Validar contra OmniRoute
|
||||
|
||||
Escolher a matriz compativel com o cliente:
|
||||
|
||||
| Superficie | Base inicial esperada | Validacoes minimas |
|
||||
|---|---|---|
|
||||
| OpenAI Chat Completions | confirmar se o cliente espera raiz ou `/v1` | chamada simples, stream, tool call, erro de modelo |
|
||||
| OpenAI Responses | confirmar regra de concatenacao do cliente | resposta simples, stream/eventos, tool call |
|
||||
| Anthropic Messages | normalmente base antes de `/v1/messages`; confirmar no codigo | mensagem, stream, tools, headers de versao |
|
||||
| Gemini | normalmente base antes das operacoes `v1beta`; confirmar no codigo | generateContent, streamGenerateContent, tools |
|
||||
| ACP | endpoint/transport definido pelo protocolo | discovery, sessao, request e cancelamento |
|
||||
| MCP | stdio, SSE ou Streamable HTTP conforme suporte | inicializacao, listagem e invocacao de ferramenta |
|
||||
|
||||
Registrar no resultado quais linhas da matriz foram executadas, omitidas ou bloqueadas. Um smoke
|
||||
test simples nao deve ser apresentado como prova de tool calling ou streaming.
|
||||
|
||||
### Fase PR-6 - Revisar o diff antes de publicar
|
||||
|
||||
O agente responsavel faz uma auto-revisao e o agente principal verifica:
|
||||
|
||||
- aderencia a `CONTRIBUTING` e instrucoes locais;
|
||||
- escopo minimo e ausencia de refactor oportunista;
|
||||
- testes cobrindo config, URL, auth sem segredo e modelo;
|
||||
- documentacao consistente com o codigo executado;
|
||||
- ausencia de arquivos gerados, caches, logs ou credenciais;
|
||||
- licenca e atribuicao preservadas;
|
||||
- branch baseada no upstream atual;
|
||||
- commits pequenos e com mensagem no estilo do projeto;
|
||||
- ausencia de trailers ou texto de IA;
|
||||
- `git diff --check` e gates oficiais limpos, ou falhas preexistentes documentadas.
|
||||
|
||||
Uma PR nao deve ser publicada enquanto houver alteracao sem explicacao, teste essencial faltando ou
|
||||
duvida material sobre a politica do upstream.
|
||||
|
||||
### Fase PR-7 - Preparar a publicacao
|
||||
|
||||
- Confirmar fork e remotes sem sobrescrever branches existentes.
|
||||
- Atualizar a branch sobre o ponto exigido pelo upstream usando operacao nao destrutiva.
|
||||
- Enviar a branch ao fork somente depois da revisao.
|
||||
- Criar PR contra a branch correta do repositorio canonico.
|
||||
- Se a contribuicao externa estiver bloqueada, abrir issue-first e anexar o commit/patch de
|
||||
referencia somente quando isso for permitido.
|
||||
- Registrar URLs reais no tracker imediatamente apos a publicacao.
|
||||
|
||||
Convencoes de branch sugeridas, sujeitas ao padrao de cada upstream:
|
||||
|
||||
- `feat/omniroute-provider` para provider/preset nominal;
|
||||
- `feat/custom-base-url` para capacidade generica;
|
||||
- `docs/omniroute-setup` para documentacao validada;
|
||||
- `fix/custom-endpoint-versioning` para correcao de raiz versus `/v1`/`/v1beta`.
|
||||
|
||||
### Fase PR-8 - Corpo da PR
|
||||
|
||||
Usar o template oficial do repositorio quando existir. Na ausencia de template, adaptar:
|
||||
|
||||
```md
|
||||
## Why
|
||||
|
||||
Explain the user problem and the existing extension point. Avoid marketing claims.
|
||||
|
||||
## What changed
|
||||
|
||||
- Add or enable the smallest provider/configuration path required.
|
||||
- Document the verified setup.
|
||||
- Cover URL, authentication and model selection behavior with tests.
|
||||
|
||||
## Verification
|
||||
|
||||
- `<official upstream command>`
|
||||
- `<focused test command>`
|
||||
- `<sanitized OmniRoute smoke test and result>`
|
||||
|
||||
## Compatibility notes
|
||||
|
||||
- API surface: `<Chat Completions/Responses/Anthropic/Gemini/ACP/MCP>`
|
||||
- Base URL rule: `<root, /v1, /v1beta or full operation URL>`
|
||||
- Streaming: `<verified/not applicable/not verified>`
|
||||
- Tool calling: `<verified/not applicable/not verified>`
|
||||
|
||||
## Scope
|
||||
|
||||
No unrelated refactors or credential changes.
|
||||
```
|
||||
|
||||
O titulo deve descrever a mudanca, nao a campanha. Exemplos de formato, sujeitos ao estilo do
|
||||
upstream: `Add OmniRoute provider preset`, `Support configurable OpenAI-compatible base URLs` ou
|
||||
`Document OmniRoute as a custom endpoint`.
|
||||
|
||||
### Fase PR-9 - Issue-first ou fallback
|
||||
|
||||
Quando uma PR direta nao for apropriada, a issue deve conter:
|
||||
|
||||
- problema reproduzivel e publico afetado;
|
||||
- ponto de extensao encontrado no codigo;
|
||||
- proposta minima;
|
||||
- compatibilidade esperada e protocolo;
|
||||
- evidencia de teste ou prototipo;
|
||||
- pergunta objetiva ao mantenedor;
|
||||
- link para patch de referencia apenas se permitido.
|
||||
|
||||
Nao abrir simultaneamente issue e PR sem necessidade. Se o template exigir issue previa, esperar a
|
||||
decisao ou seguir a politica declarada.
|
||||
|
||||
### Fase PR-10 - Acompanhar ate a decisao
|
||||
|
||||
Depois da publicacao:
|
||||
|
||||
- observar CI e checks obrigatorios;
|
||||
- responder perguntas tecnicas com evidencia;
|
||||
- corrigir somente o escopo da contribuicao ou pedidos claros do mantenedor;
|
||||
- reexecutar testes depois de cada mudanca;
|
||||
- registrar novos commits, revisoes e estado no tracker;
|
||||
- marcar `accepted` somente depois de merge/aceite comprovado;
|
||||
- marcar `rejected` com o motivo fornecido pelo upstream;
|
||||
- se a PR ficar inativa, registrar `awaiting-maintainer`, sem declarar abandono prematuramente;
|
||||
- manter o guia/catalogo OmniRoute coerente com o estado real do upstream.
|
||||
|
||||
O acompanhamento pode usar a skill `babysit` individualmente para uma PR aberta. Como essa skill
|
||||
acompanha uma unica PR, nunca agrupar tres PRs em uma mesma execucao dela.
|
||||
|
||||
### Fase PR-11 - Fechar a task
|
||||
|
||||
Uma task individual termina com:
|
||||
|
||||
- pesquisa fresca e gate registrados;
|
||||
- diff, configuracao ou bloqueio documentado;
|
||||
- baseline e validacao final comparados;
|
||||
- branch/commit reais, quando criados;
|
||||
- PR/issue reais, quando publicados;
|
||||
- status no catalogo OmniRoute;
|
||||
- limitacoes e proximo passo;
|
||||
- linha correspondente no tracker atualizada.
|
||||
|
||||
## 5. Estrategia de paralelizacao
|
||||
|
||||
### 5.1 Papeis por lote
|
||||
|
||||
- **Subagente A:** primeiro repositorio do lote; dono exclusivo da worktree e do diff upstream.
|
||||
- **Subagente B:** segundo repositorio do lote; dono exclusivo da worktree e do diff upstream.
|
||||
- **Subagente C:** terceiro repositorio do lote; dono exclusivo da worktree e do diff upstream.
|
||||
- **Agente principal:** coordena o tracker, revisa gates/diffs, impede duplicacao e autoriza a
|
||||
publicacao depois das evidencias.
|
||||
|
||||
Todos os agentes devem ser avisados de que nao estao sozinhos no workspace e nao podem reverter ou
|
||||
sobrescrever mudancas de outros agentes.
|
||||
|
||||
### 5.2 Barreira do lote
|
||||
|
||||
O lote seguinte pode comecar quando os tres itens atuais tiverem, no minimo:
|
||||
|
||||
1. commit/release upstream pesquisado;
|
||||
2. gate de contribuicao definido;
|
||||
3. baseline registrado;
|
||||
4. patch validado, configuracao comprovada ou bloqueio reproduzivel;
|
||||
5. decisao de publicacao tomada;
|
||||
6. tracker atualizado.
|
||||
|
||||
A espera por resposta de mantenedor nao bloqueia o lote seguinte. Depois de uma PR/issue publicada,
|
||||
o item passa para acompanhamento e libera o slot de implementacao.
|
||||
|
||||
### 5.3 Limite de trabalho em progresso
|
||||
|
||||
- No maximo tres pesquisas/implementacoes ativas.
|
||||
- Publicacoes aguardando mantenedor nao contam como slot de implementacao, mas ficam no tracker.
|
||||
- No maximo uma task ativa por repositorio, inclusive forks ou variantes do mesmo upstream.
|
||||
- Se dois itens resolverem o mesmo repositorio, consolidar a pesquisa e decidir se ha uma ou duas
|
||||
contribuicoes antes de abrir branches.
|
||||
|
||||
## 6. Fila de publicacao
|
||||
|
||||
A ordem detalhada continua sendo a do `03-plano-integracao-em-lotes.md`. Esta secao define o objetivo
|
||||
de publicacao de cada onda; a pesquisa individual pode promover, rebaixar ou mudar o tipo de
|
||||
contribuicao.
|
||||
|
||||
### Onda 0 - referencia e infraestrutura da campanha
|
||||
|
||||
- `CLI-000` jcode: acompanhar issue upstream e PR de referencia; concluir a secao prometida no
|
||||
README do OmniRoute.
|
||||
- Preparar o modelo de task individual e aplicar o mesmo tracker a todos os novos repositorios.
|
||||
|
||||
### Onda 1 - P0.1 a P0.5
|
||||
|
||||
- `CLI-001` Gemini CLI: confirmar se o endpoint Gemini customizado pede apenas docs/config ou um
|
||||
preset nominal.
|
||||
- `CLI-002` Claw Code: confirmar provider OpenAI-compatible e propor preset/docs minimos.
|
||||
- `CLI-003` Plandex: confirmar o registro de providers customizados e propor provider/preset.
|
||||
- `CLI-004` MiMo Code: confirmar o adapter OpenAI-compatible e propor configuracao/provider.
|
||||
- `CLI-005` Trae Agent: confirmar `model_providers` e propor entrada OmniRoute/documentacao.
|
||||
- `CLI-006` Kimi CLI: escolher uma superficie suportada e evitar um patch que misture tres
|
||||
protocolos sem testes.
|
||||
- `CLI-007` Every Code: reutilizar a arquitetura herdada do Codex quando ainda aplicavel.
|
||||
- `CLI-008` Open Codex: confirmar upstream canonico e propor provider multi-modelo.
|
||||
- `CLI-009` VT Code: validar provider customizado, modelo e failover.
|
||||
- `CLI-010` OpenHands CLI: verificar se `LLM_BASE_URL` torna o caso docs/config-only.
|
||||
- `CLI-011` gptme: verificar se `OPENAI_BASE_URL` torna o caso docs/config-only.
|
||||
- `CLI-012` Nanocoder: confirmar compatibilidade de tool calling e decidir preset versus docs.
|
||||
- `CLI-013` RA.Aid: verificar se `OPENAI_API_BASE` torna o caso docs/config-only.
|
||||
- `CLI-014` CoreCoder: verificar se `OPENAI_BASE_URL` torna o caso docs/config-only.
|
||||
- `CLI-015` Grok CLI: confirmar se o endpoint e genericamente configuravel ou preso ao protocolo
|
||||
Grok antes de propor patch.
|
||||
|
||||
### Onda 2 - P1.1 a P1.9
|
||||
|
||||
- `CLI-016` Gitlawb Zero: provider custom/flag; preferir docs ou preset pequeno.
|
||||
- `CLI-017` DeepSeek Reasonix: confirmar repositorio, atividade e endpoint antes de qualquer PR.
|
||||
- `CLI-018` KlaatCode: integrar via `customModels` ou preset se o catalogo aceitar nomes.
|
||||
- `CLI-019` CodeMini CLI: validar `gateway.base_url` e sua regra de versao.
|
||||
- `CLI-020` Zot: validar `--base-url` e `models.json`; docs-first se ja suficiente.
|
||||
- `CLI-021` Octomind: confirmar variaveis de URL por provider e propor configuracao minima.
|
||||
- `CLI-022` DvalinCode: confirmar o cliente OpenAI-compatible e testes disponiveis.
|
||||
- `CLI-023` Coro Code: confirmar `OPENAI_BASE_URL`; docs-first se nao houver lacuna de codigo.
|
||||
- `CLI-024` Mini-Kode: confirmar `MINIKODE_BASE_URL`; docs-first se nao houver lacuna de codigo.
|
||||
- `CLI-025` Late CLI: testar ambiente e flag `api-url`; corrigir precedencia apenas se necessario.
|
||||
- `CLI-026` Agentty: escolher entre provider direto e ACP conforme a arquitetura atual.
|
||||
- `CLI-027` Aizen: validar `AIZEN_BASE_URL` e propor docs/preset.
|
||||
- `CLI-028` Clif-Code: selecionar um unico protocolo principal para a primeira contribuicao.
|
||||
- `CLI-029` Minacode: pesquisa confirmatoria antes de definir o tipo de PR.
|
||||
- `CLI-030` YottaCode: confirmar gateway/provider e selecao de modelo.
|
||||
- `CLI-031` aichat: integrar via configuracao de modelos ou provider nominal, conforme a politica.
|
||||
- `CLI-032` ShellGPT: validar `API_BASE_URL` e decidir docs/config-only.
|
||||
- `CLI-033` Mistral Vibe: confirmar base URL customizada e separar suporte generico de marca.
|
||||
- `CLI-034` OpenSquilla: localizar o registro de gateways e propor provider/preset.
|
||||
- `CLI-035` Kode CLI: escolher OpenAI, Anthropic ou Gemini com base na implementacao mais nativa.
|
||||
- `CLI-036` Neovate Code: preferir plugin/provider oficial ao patch no core, se existir.
|
||||
- `CLI-037` Deep Agents Code: contribuir no pacote CLI/provider correto, nao apenas no SDK generico.
|
||||
- `CLI-038` OpenHands principal: evitar duplicar `CLI-010`; consolidar se ambos apontarem para o
|
||||
mesmo mecanismo e upstream.
|
||||
- `CLI-039` SWE-agent: confirmar backend de modelos e interface publica suportada.
|
||||
- `CLI-040` AutoCodeRover: confirmar backend e propor config/provider minimo.
|
||||
- `CLI-041` Claurst: revisar GPL e politica antes de redistribuir qualquer adaptacao.
|
||||
- `CLI-042` Codebuff: confirmar se o provider e extensivel e se contribuicoes externas sao aceitas.
|
||||
|
||||
### Onda 3 - P2.1 a P2.11
|
||||
|
||||
- `CLI-043` Devon, `CLI-044` Letta Code e `CLI-045` CodeMachine CLI: pesquisar backend real;
|
||||
revisar a entrada local ja existente de Letta antes de nova PR.
|
||||
- `CLI-046` Groq Code CLI, `CLI-047` Dexto e `CLI-048` claw-code-agent: confirmar endpoints,
|
||||
protocolos e maturidade antes do patch.
|
||||
- `CLI-049` g3, `CLI-050` San e `CLI-051` Waveloom: localizar a abstracao de provider e preferir
|
||||
implementacao generica.
|
||||
- `CLI-052` picocode, `CLI-053` QQCode e `CLI-054` Keen Code: validar configuracao multi-modelo e
|
||||
documentar o caminho minimo.
|
||||
- `CLI-055` Grinta, `CLI-056` Zap e `CLI-057` Binharic: escolher o provider compativel com melhor
|
||||
cobertura de streaming/tools.
|
||||
- `CLI-058` Darce, `CLI-059` CLAII e `CLI-060` nori-cli: separar integracao de modelo de MCP e de
|
||||
codigo herdado do Codex.
|
||||
|
||||
Resultado P2.6:
|
||||
|
||||
- `CLI-058` Darce: `config-only`, sem PR necessária; usar `DARCE_API_BASE` na raiz e `DARCE_MODEL`.
|
||||
- `CLI-059` CLAII: patch genérico local validado, mas publicação bloqueada pela declaração upstream
|
||||
`All Rights Reserved`/ausência de licença OSS; só reconsiderar com autorização jurídica explícita.
|
||||
- `CLI-060` nori-cli: `config-only` via agente ACP customizado OpenCode; não alterar backend Codex;
|
||||
MCP deve ser configurado uma vez, em Nori ou OpenCode, para evitar duplicação de tools.
|
||||
- `CLI-061` cursor-agent clone, `CLI-062` Free Code e `CLI-063` Claude Engineer: revisar origem,
|
||||
licenca e politica do fork antes de publicar.
|
||||
|
||||
Lote P2.7 reservado em 2026-08-02, na branch-base local `release/v3.8.50` em
|
||||
`35405be6020696a7c66158ea7a25f06d61ff88ff`. Os três upstreams foram clonados em worktrees
|
||||
separadas, indexados e delegados. Nenhuma publicação está autorizada; patches só podem surgir após
|
||||
prova RED→GREEN e permanecem sem commit até revisão central.
|
||||
|
||||
Resultado P2.7:
|
||||
|
||||
- `CLI-061` cursor-agent clone: `config-only`; OpenAI usa base com `/v1`, Anthropic usa raiz sem
|
||||
`/v1`; tools/tool-result foram comprovados nos dois protocolos. O factory rejeita `auto` puro,
|
||||
mas isso não impede uso com modelos reconhecíveis ou classes diretas. Sem PR.
|
||||
- `CLI-062` Free Code: `config-only` com `ANTHROPIC_BASE_URL` na raiz e `model=auto`; stream,
|
||||
tools/tool-result e MCP nativo foram comprovados. O repo canônico agora é `freecodexyz/free-code`,
|
||||
mas não há licença e o README atribui o código à Anthropic; publicação bloqueada.
|
||||
- `CLI-063` Claude Engineer: endpoint/chave funcionam como `config-only` com modelo fixo. A lacuna
|
||||
de `ANTHROPIC_MODEL` já está coberta pela PR #250; não criar patch concorrente. Arquivo de licença
|
||||
segue ausente apesar da issue #116, portanto publicação permanece bloqueada.
|
||||
- `CLI-064` Smol Developer, `CLI-065` Agentless e `CLI-066` Amazon Q Developer CLI: decidir entre
|
||||
SDK/adaptador, config de modelo ou bloqueio por autenticacao.
|
||||
|
||||
Lote P2.8 iniciado em 2026-08-02 na branch-base local `release/v3.8.50`, SHA
|
||||
`35405be6020696a7c66158ea7a25f06d61ff88ff`, com clones limpos e separados. Smol Developer será
|
||||
testado primeiro como integração do SDK OpenAI legado; Agentless será avaliado por backend
|
||||
OpenAI/Anthropic/DeepSeek; Amazon Q Developer CLI será tratado como protocolo AWS próprio, com MCP
|
||||
avaliado separadamente. Não criar adaptador grande para Amazon Q nem qualquer publicação antes de
|
||||
issue-first/coordenação exigida por `CONTRIBUTING.md`. Estado inicial: nenhum commit, fork, push,
|
||||
PR, issue ou Discussion.
|
||||
|
||||
Resultado P2.8:
|
||||
|
||||
- `CLI-064` Smol Developer: `config-only`; `OPENAI_API_BASE` com `/v1` e `model=auto` passaram no
|
||||
CLI, biblioteca e Agent Protocol histórico. Não há lacuna provider-specific e a PR #134 já cobre
|
||||
uma expansão LiteLLM. Sem publicação.
|
||||
- `CLI-065` Agentless: `config-only` pelo backend OpenAI, incluindo embeddings. Anthropic normal
|
||||
também funciona; cache/tools exige SDK histórico e DeepSeek possui host fixo, mas essas melhorias
|
||||
não são necessárias para integrar o projeto e propostas LiteLLM anteriores foram fechadas. Sem
|
||||
publicação.
|
||||
- `CLI-066` Amazon Q Developer CLI: MCP stdio é a integração direta; o backend de modelo fala AWS
|
||||
JSON/EventStream e precisa de wrapper/backend novo. O upstream está em manutenção crítica e exige
|
||||
issue-first; não preparar PR nominal ou adaptador surpresa. Sem publicação.
|
||||
|
||||
Estado final P2.8: commits `0`, pushes `0`, forks `0`, PRs `0`, issues `0`, Discussions `0`.
|
||||
Próxima fila: P2.9 (`CLI-067` nanobot, `CLI-068` ZeroClaw, `CLI-069` NanoClaw), usando no máximo
|
||||
três worktrees/agentes e repetindo a pesquisa individual antes de qualquer patch.
|
||||
|
||||
Lote P2.9 iniciado em 2026-08-03 sobre a branch-base local `release/v3.8.50`, SHA
|
||||
`84b1e5e12f238269e698f400766230f985f4a07b`. O checkout principal já continha uma alteração do
|
||||
operador em `CLAUDE.md`, preservada fora do escopo. As worktrees foram recriadas e os upstreams
|
||||
foram clonados nos HEADs `44b7e1bf4` (nanobot), `4770420ab` (ZeroClaw) e `dfac7e0af` (NanoClaw).
|
||||
Os três índices Codebase Memory moderate estão ready, sem skipped, e a pesquisa foi delegada a um
|
||||
agente por repositório. Nenhuma publicação está autorizada; o estado inicial continua: commits `0`,
|
||||
pushes `0`, forks `0`, PRs `0`, issues `0`, Discussions `0`.
|
||||
|
||||
- `CLI-067` nanobot, `CLI-068` ZeroClaw e `CLI-069` NanoClaw: validar providers OpenClaw/Anthropic
|
||||
e evitar assumir que todos aceitam a mesma base URL.
|
||||
|
||||
Resultado P2.9:
|
||||
|
||||
- `CLI-067` nanobot: `config-only` pelo provider dinâmico OpenAI-compatible. A base correta inclui
|
||||
`/api/v1`; `omniroute/auto` seleciona o provider custom e envia `auto` no wire. Chat, SSE, tools,
|
||||
reasoning, usage, imagens, discovery e retry foram validados. Sem publicação upstream.
|
||||
- `CLI-068` ZeroClaw: `config-only` pela família `custom`, com `uri=/v1`, modelo `auto`, wire Chat e
|
||||
`native_tools=true`. Responses é opt-in. Suite de provider, config, fmt e smoke HTTP passaram.
|
||||
Sem provider nominal ou publicação upstream.
|
||||
- `CLI-069` NanoClaw: `config-only` pelo provider Claude existente, apontando a raiz Anthropic do
|
||||
OmniRoute sem `/v1/messages` e usando OneCLI para a credencial. Codex e OpenCode têm bloqueios
|
||||
upstream reproduzidos (#3155/#1984/#2985) e ficam fora do caminho de produção atual.
|
||||
|
||||
Estado final P2.9: commits `0`, pushes `0`, forks `0`, PRs `0`, issues `0`, Discussions `0`.
|
||||
Progresso da pesquisa: `70/104` (`67,3%`); pendentes: `34/104` (`32,7%`). Próxima fila: P2.10
|
||||
(`CLI-070` PicoClaw, `CLI-071` IronClaw, `CLI-072` NullClaw).
|
||||
- `CLI-070` PicoClaw, `CLI-071` IronClaw e `CLI-072` NullClaw: localizar traits/registries e propor
|
||||
um provider pequeno com testes.
|
||||
- `CLI-073` Moltis, `CLI-074` GitClaw e `CLI-075` LionClaw: confirmar atividade, provider e comandos
|
||||
de validacao antes da publicacao.
|
||||
|
||||
### Onda 4 - P3, integracoes indiretas
|
||||
|
||||
- `CLI-076`, `CLI-077`, `CLI-078`, `CLI-079`, `CLI-080` e `CLI-081`: pesquisar forwarding de
|
||||
ambiente/configuracao para os agentes filhos;
|
||||
publicar wrapper ou docs somente quando houver um ponto de extensao real.
|
||||
- `CLI-082`, `CLI-083`, `CLI-084`, `CLI-085`, `CLI-086`, `CLI-087`, `CLI-088`, `CLI-089` e
|
||||
`CLI-090`: escolher ACP, MCP, launcher ou integracao do agente filho; nao apresentar uma
|
||||
integracao de orquestrador como provider de modelo.
|
||||
- `CLI-091` e `CLI-092`: tratar como interoperabilidade entre proxies; documentar loops, headers,
|
||||
auth e riscos antes de propor codigo.
|
||||
- `CLI-093` e `CLI-094`: integrar como broker/ferramenta MCP somente se isso estiver no escopo dos
|
||||
projetos.
|
||||
- `CLI-095` e `CLI-096`: configurar o agente filho e revisar a entrada existente de Agent Deck.
|
||||
|
||||
### Onda 5 - P4, fechados, EULA e MITM
|
||||
|
||||
- `CLI-097` Pool: confirmar o que a EULA permite; priorizar configuracao local e nao presumir PR.
|
||||
- `CLI-098` Junie CLI: pesquisar canal oficial de feedback; sem repositorio publico confirmado, nao
|
||||
existe fila de PR.
|
||||
- `CLI-099` Cursor desktop, `CLI-100` Windsurf, `CLI-101` Amp, `CLI-102` Amazon Q/Kiro CLI e
|
||||
`CLI-103` Cowork: tratar como MITM, configuracao de produto ou pedido oficial de feature. So mover
|
||||
para PR se um repositorio publico e uma politica de contribuicao forem comprovados.
|
||||
|
||||
## 7. Prompt operacional para cada subagente
|
||||
|
||||
O agente principal deve adaptar e enviar este prompt para cada item:
|
||||
|
||||
```text
|
||||
Voce e responsavel exclusivamente por CLI-NNN - <projeto> no repositorio <URL>.
|
||||
Voce nao esta sozinho no workspace: nao reverta, sobrescreva ou reorganize mudancas de outros
|
||||
agentes. Trabalhe somente na worktree isolada atribuida dentro de .claude/worktrees/ e nunca use
|
||||
git stash/pop.
|
||||
|
||||
Primeiro pesquise o upstream atual. Leia README, CONTRIBUTING, licenca, templates e instrucoes locais.
|
||||
Registre commit/release, arquitetura de providers, config/base URL, protocolo, auth, modelos,
|
||||
streaming, tool calling, issues/PRs existentes e comandos oficiais de build/test. A evidencia inicial
|
||||
do relatorio e uma hipotese, nao uma conclusao.
|
||||
|
||||
Antes de editar, classifique o caso como pr-provider, pr-generic, pr-docs, issue-first, config-only,
|
||||
adapter-acp, adapter-mcp, wrapper, needs-mitm ou blocked, com justificativa. Execute o baseline e
|
||||
registre falhas preexistentes. Se houver patch, trabalhe com teste primeiro e implemente somente a
|
||||
menor integracao coerente com o upstream. Confirme raiz versus /v1 versus /v1beta, autenticacao,
|
||||
modelo, streaming e tool calling conforme aplicavel.
|
||||
|
||||
Nao publique nada antes da revisao do agente principal. Entregue: pesquisa com links/caminhos,
|
||||
gate, baseline, diff, testes, smoke test sanitizado, riscos, branch/commit local se criados e a
|
||||
atualizacao proposta para 04-tracker-integracoes-clis.md. Nao invente dados e nao exponha chaves.
|
||||
```
|
||||
|
||||
## 8. Checklist de autorizacao para enviar uma PR
|
||||
|
||||
O agente principal somente autoriza a publicacao quando todas as respostas forem `sim` ou houver
|
||||
uma excecao registrada:
|
||||
|
||||
- [ ] O repositorio canonico e a branch-alvo foram confirmados.
|
||||
- [ ] A politica aceita o tipo de contribuicao planejado.
|
||||
- [ ] Issues/PRs duplicadas foram pesquisadas.
|
||||
- [ ] O commit/release de base esta registrado.
|
||||
- [ ] O gate de contribuicao esta justificado.
|
||||
- [ ] O baseline foi executado e falhas preexistentes estao separadas.
|
||||
- [ ] O patch e o menor necessario e segue a arquitetura upstream.
|
||||
- [ ] A base URL e sua regra de versao foram verificadas no codigo e em runtime.
|
||||
- [ ] Auth/modelos foram testados sem vazar segredo.
|
||||
- [ ] Streaming/tool calling foram testados ou marcados explicitamente como nao aplicaveis.
|
||||
- [ ] Testes, lint, format, typecheck/build relevantes foram executados.
|
||||
- [ ] A documentacao foi executada e corresponde ao codigo.
|
||||
- [ ] O diff nao contem caches, builds, logs, credenciais ou refactors sem relacao.
|
||||
- [ ] O titulo e o corpo seguem o template upstream e nao contêm marketing ou texto de IA.
|
||||
- [ ] O tracker esta pronto para receber branch, commit e URL reais.
|
||||
|
||||
## 9. Campos adicionais recomendados no tracker
|
||||
|
||||
O tracker atual deve continuar como fonte principal. Durante a execucao, registrar nas observacoes ou
|
||||
em uma nota individual:
|
||||
|
||||
- commit/release pesquisado;
|
||||
- decisao `pr-provider`, `pr-generic`, `pr-docs`, `issue-first`, `config-only`, adapter, wrapper,
|
||||
MITM ou bloqueio;
|
||||
- protocolo e regra da base URL;
|
||||
- comandos de baseline e resultado;
|
||||
- comandos finais e resultado;
|
||||
- smoke tests realizados;
|
||||
- arquivos modificados;
|
||||
- fork, branch e commit;
|
||||
- PR/issue e estado de CI/review;
|
||||
- limitacoes e proximo passo.
|
||||
|
||||
Campos ainda nao comprovados permanecem `—`.
|
||||
|
||||
## 10. Inicio recomendado
|
||||
|
||||
O primeiro ciclo de publicacao deve usar o lote P0.1:
|
||||
|
||||
1. `CLI-001` - Gemini CLI (`google-gemini/gemini-cli`)
|
||||
2. `CLI-002` - Claw Code (`ultraworkers/claw-code`)
|
||||
3. `CLI-003` - Plandex (`plandex-ai/plandex`)
|
||||
|
||||
Os tres subagentes fazem pesquisa fresca e implementacao em paralelo, mas nenhuma PR e enviada antes
|
||||
da revisao individual do agente principal. Ao publicar ou concluir config-only/bloqueio, atualizar o
|
||||
tracker e liberar os mesmos tres slots para o lote P0.2.
|
||||
|
||||
## Lote P2.10 iniciado em 2026-08-03
|
||||
|
||||
Base local: `release/v3.8.50` em `84b1e5e12f238269e698f400766230f985f4a07b`. Worktrees isoladas e um agente por upstream foram criadas para `CLI-070` PicoClaw, `CLI-071` IronClaw e `CLI-072` NullClaw. Nenhuma publicação está autorizada; os agentes devem pesquisar o HEAD atual, provar `config-only` ou RED→GREEN e registrar governança, gates, smoke e estado limpo.
|
||||
|
||||
Resultado P2.10:
|
||||
|
||||
- `CLI-070` PicoClaw: `config-only`, `openai/auto` com base `/api/v1`; Chat/SSE/tools/usage/images/discovery. Go ausente impediu execução local; monitorar #3298, sem PR.
|
||||
- `CLI-071` IronClaw: `config-only`, `openai_compatible` com `/api/v1` e `auto`; 889 testes do crate LLM, 5 de resolução e fmt passaram. Sem PR; reasoning proprietário segue limitado por #3673.
|
||||
- `CLI-072` NullClaw: `config-only`, provider custom com Chat Completions recomendado e Responses/Anthropic como alternativas. Zig ausente; CI do mesmo HEAD verde. Sem PR.
|
||||
|
||||
Estado final P2.10: commits `0`, pushes `0`, forks `0`, PRs `0`, issues `0`, Discussions `0`.
|
||||
Pesquisa acumulada: `73/104` (`70,2%`); pendentes: `31/104` (`29,8%`). Próxima fila: P2.11 (`CLI-073` Moltis, `CLI-074` GitClaw, `CLI-075` LionClaw).
|
||||
|
||||
Resultado P3.1:
|
||||
|
||||
- `CLI-076` VibePod: `config-only` pelo agente Claude Code com raiz Anthropic `/api`; wrapper injeta env no container. Codex sem chave automática permanece não comprovado.
|
||||
- `CLI-077` zeroshot: `config-only` pelo gateway OpenAI `/api/v1`; 22 testes focados verdes; limitações de streaming JSON, reasoning e MCP registradas.
|
||||
- `CLI-078` Fractal: `config-only` por Codex Responses em `CODEX_HOME` por node; servidores tmux quentes podem perder `OMNIROUTE_API_KEY`, recomendando fix genérico upstream.
|
||||
|
||||
Estado final P3.1: commits `0`, pushes `0`, forks `0`, PRs `0`, issues `0`, Discussions `0`. Pesquisa acumulada: `79/104` (`76,0%`); pendentes: `25/104` (`24,0%`).
|
||||
|
||||
Resultado P3.2: Bernstein `config-only` por openai_agents; Traycer `config-only` indireto pelo harness OpenCode; h5i `patch-required` porque auth proxy/egress são fixados em OpenAI. Nenhuma publicação externa. Pesquisa acumulada `82/104` (`78,8%`), pendentes `22/104` (`21,2%`).
|
||||
|
||||
Resultado P2.11:
|
||||
|
||||
- `CLI-073` Moltis: `config-only`, provider `custom-omniroute`, `/api/v1`, `auto`, Chat/SSE/tools e capacidades multimodais. 401 testes e fmt passaram. Sem publicação.
|
||||
- `CLI-074` GitClaw/GitAgent: `config-only`, loader OpenAI-compatible com `GITAGENT_MODEL_BASE_URL`, `OPENAI_API_KEY` e `omniroute:auto`. Build, 65 testes e smoke passaram. Sem publicação.
|
||||
- `CLI-075` LionClaw: `patch-required`/`issue-first`. O runtime Codex confinado não recebe `config.toml`/provider secret; preparar proposta genérica alinhada à [#157](https://github.com/moshthepitt/lionclaw/issues/157), sem PR até revisão do mantenedor.
|
||||
|
||||
Estado final P2.11: commits `0`, pushes `0`, forks `0`, PRs `0`, issues `0`, Discussions `0`. Pesquisa acumulada: `76/104` (`73,1%`); pendentes: `28/104` (`26,9%`).
|
||||
Resultado P3.3: OMK `viable-mcp`; kodo `config-only` indireto; ORCH `needs-wrapper`. Pesquisa acumulada `85/104` (`81,7%`), pendentes `19/104` (`18,3%`). Nenhuma publicação externa.
|
||||
|
||||
Resultado P3.4: LoopTroop `config-only` indireto via provider OpenCode; Galley `patch-required` por não possuir transport OpenAI-compatible configurável; Relay `config-only` via provider profile/Codex, condicionado a smoke da Responses API e controles sobre ferramentas nativas. Nenhuma publicação externa. Pesquisa acumulada `88/104` (`84,6%`), pendentes `16/104` (`15,4%`).
|
||||
|
||||
Resultado P3.5: SageCLI `config-only` indireto via Codex, com caveat de env plaintext; 5dive `patch-required` por mapas fechados de provider/base; agx `config-only` indireto via Codex e com gates de Responses/sandbox. Pesquisa acumulada `91/104` (`87,5%`), pendentes `13/104` (`12,5%`). Nenhuma publicação externa.
|
||||
|
||||
Resultado P3.6: claude-code-router, cc-router e OneCLI são config-only; os dois primeiros oferecem endpoints custom OpenAI-compatible e OneCLI injeta credenciais por proxy MITM. Pesquisa acumulada `94/104` (`90,4%`), pendentes `10/104` (`9,6%`). Nenhuma publicação externa.
|
||||
|
||||
Resultado P3.7: agent-browser `config-only` direto por Chat Completions; OpenWork `config-only` via OpenCode custom; Agent Deck `config-only` via CLIs filhos. Pesquisa acumulada `97/104` (`93,3%`), pendentes `7/104` (`6,7%`). Nenhuma publicação externa.
|
||||
|
||||
Resultado P4.1: Pool e Junie são `config-only` OpenAI-compatible; Cursor é `config-only` limitado ao BYO chat panel, sem MITM/protocolo privado. Pesquisa acumulada `100/104` (`96,2%`), pendentes `4/104` (`3,8%`). Nenhuma publicação externa.
|
||||
|
||||
Resultado P4.2: Windsurf está bloqueado para inferência e permite apenas MCP; Amp depende de confirmação Enterprise; Amazon Q legado requer patch substancial e Kiro atual é MCP-only seguro. Pesquisa acumulada `103/104` (`99,0%`), pendente `1/104` (`1,0%`). Nenhuma publicação externa.
|
||||
|
||||
Resultado P4.3: Cowork não permite substituir oficialmente a inferência; Custom Connector MCP remoto é o único caminho suportado e permanece separado do modelo. Pesquisa concluída `104/104` (`100%`), pendentes `0/104` (`0%`). Nenhuma publicação externa nesta fase de pesquisa.
|
||||
@@ -1,131 +0,0 @@
|
||||
# Relatório final — campanha de 104 integrações CLI OmniRoute
|
||||
|
||||
**Data de fechamento:** 2026-08-03
|
||||
**Escopo:** `CLI-000` a `CLI-103`
|
||||
**Resultado:** `104/104` pesquisados (`100%`), `0` pendentes de pesquisa.
|
||||
|
||||
## Como consultar o resultado individual
|
||||
|
||||
O documento autoritativo, com uma linha para cada caso, é o [tracker completo](./04-tracker-integracoes-clis.md). Ele contém para cada ID:
|
||||
|
||||
- prioridade;
|
||||
- projeto e repositório;
|
||||
- classificação de integração;
|
||||
- estado de contribuição upstream;
|
||||
- branch e commit quando existentes;
|
||||
- URL de PR e/ou issue quando publicados;
|
||||
- estado no catálogo OmniRoute;
|
||||
- observações, limitações, testes e próximo passo.
|
||||
|
||||
Além do tracker, existem fichas técnicas individuais em `_tasks/cli-integrations/`. A cobertura foi auditada e agora há uma ficha para cada ID `CLI-000`–`CLI-103`; o caso `CLI-000` jcode foi adicionado como ficha de referência nesta revisão.
|
||||
|
||||
## Resumo quantitativo
|
||||
|
||||
| Grupo operacional | Quantidade | Tratamento |
|
||||
|---|---:|---|
|
||||
| Configuração direta ou indireta | 76 | Documentar receita, validar smoke e só abrir PR se houver melhoria upstream real |
|
||||
| Contribuição upstream (PR/issue/docs/patch) | 17 | Preparar diff mínimo, validar, revisar e publicar conforme política do repositório |
|
||||
| Patch obrigatório | 4 | Implementar genericamente, com RED→GREEN/TDD e revisão do mantenedor |
|
||||
| Bloqueados/fechados | 4 | Registrar bloqueio; usar apenas MCP ou canal oficial, sem MITM |
|
||||
| MCP/wrapper/ACP como caminho principal | 2 | Integrar a camada de ferramentas/orquestração, sem falsificar provider de inferência |
|
||||
| Outros casos híbridos | 1 | Seguir a combinação específica descrita no tracker |
|
||||
|
||||
Os números são derivados do campo `Tipo` do tracker; categorias podem se sobrepor em casos híbridos. Atualmente há **7 PRs reais** e **9 issues reais** registrados no tracker, além de cinco entradas locais marcadas como integradas ao catálogo OmniRoute. Nenhum link foi inventado para os 97 casos sem publicação externa.
|
||||
|
||||
## O que foi feito na campanha
|
||||
|
||||
1. Inventário inicial e busca extensa de CLIs, runtimes, harnesses e control-planes.
|
||||
2. Priorização P0–P4 considerando compatibilidade de protocolo, adoção, licença, maturidade e risco.
|
||||
3. Pesquisa fresca, uma a uma, em worktrees isoladas, em lotes de no máximo três agentes.
|
||||
4. Uso de Codebase Memory para índices upstream e verificação de cobertura; faixas parciais foram lidas diretamente quando aplicável.
|
||||
5. Classificação por configuração, patch, PR documental, issue-first, MCP, wrapper ou bloqueio.
|
||||
6. Registro de comandos, base URL, autenticação, modelos, streaming, tools, reasoning, imagens, MCP/ACP/A2A, testes e limitações.
|
||||
7. Consolidação de cada lote com commit separado no OmniRoute e no repositório `_tasks`.
|
||||
8. Atualização final do tracker, plano de integração, plano de publicação e handoff.
|
||||
9. Nenhuma credencial real, publicação externa ou técnica de interceptação não autorizada foi utilizada.
|
||||
|
||||
## Estratégia para abrir PRs em 100% dos casos
|
||||
|
||||
“Abrir PR para 100%” deve ser interpretado como **dar um destino upstream apropriado a 100% dos casos**, e não criar 104 PRs artificiais. Há quatro trilhas:
|
||||
|
||||
### Trilha A — PR de código ou documentação
|
||||
|
||||
Aplicar aos casos `viable-upstream`, `pr-generic`, `pr-docs`, `patch-required` e híbridos que tenham superfície pública e política de contribuição compatível.
|
||||
|
||||
Processo por caso:
|
||||
|
||||
1. Reconfirmar HEAD, licença, branch default, política de contribuição e duplicatas.
|
||||
2. Criar worktree/branch baseada na versão local vigente.
|
||||
3. Executar baseline upstream e registrar falhas preexistentes.
|
||||
4. Escrever teste RED que demonstre a lacuna.
|
||||
5. Implementar o menor patch genérico possível — preferir `openai-compatible`, `base_url` ou provider abstrato a um provider nominal OmniRoute.
|
||||
6. Executar GREEN: testes focados, suite upstream, lint, format, typecheck/build e smoke com fake server ou OmniRoute local usando placeholder.
|
||||
7. Revisar segurança: nenhuma chave em argv, logs, fixtures, URL ou artefato; erros sanitizados; streaming/tools/cancelamento cobertos.
|
||||
8. Abrir PR somente se contribuições externas forem aceitas. O corpo deve explicar problema, solução genérica, compatibilidade, testes, limitações e não conter marketing/texto de IA.
|
||||
9. Se o repositório bloquear fork/PR ou pedir discussão prévia, abrir issue de proposta com o mesmo patch/reprodução, sem enviar PR prematuramente.
|
||||
10. Atualizar tracker com branch, commit, URL, CI, revisão e resposta do mantenedor; acompanhar até `accepted`, `merged`, `rejected` ou `awaiting-maintainer`.
|
||||
|
||||
### Trilha B — Issue-first, discussão ou suporte ao mantenedor
|
||||
|
||||
Aplicar quando a arquitetura é adequada, mas há bloqueio de governança, firewall, CLA, fork fechado, dúvida de protocolo ou necessidade de decisão do autor. A issue deve conter:
|
||||
|
||||
- caso de uso OmniRoute;
|
||||
- configuração atualmente possível;
|
||||
- lacuna reproduzível;
|
||||
- proposta genérica;
|
||||
- impacto de segurança;
|
||||
- testes/fake server;
|
||||
- disposição para enviar PR após aprovação.
|
||||
|
||||
Não abrir uma PR paralela enquanto a política exigir issue-first.
|
||||
|
||||
### Trilha C — Config-only documentado
|
||||
|
||||
Aplicar aos casos em que o upstream já suporta a integração e uma mudança de código seria redundante. O entregável é:
|
||||
|
||||
- ficha individual;
|
||||
- receita validada;
|
||||
- smoke test e limitações;
|
||||
- eventual documentação externa/local do OmniRoute;
|
||||
- issue somente se houver pedido de documentação ou descoberta de bug real.
|
||||
|
||||
Não criar provider nominal ou PR apenas para adicionar a palavra “OmniRoute”.
|
||||
|
||||
### Trilha D — MCP, wrapper ou bloqueio seguro
|
||||
|
||||
Aplicar a control-planes, produtos fechados e CLIs sem rota de inferência substituível. O resultado pode ser:
|
||||
|
||||
- MCP remoto/stdio do OmniRoute;
|
||||
- wrapper local claramente identificado como wrapper;
|
||||
- solicitação oficial de custom provider;
|
||||
- registro de bloqueio e gate legal/ToS.
|
||||
|
||||
Nunca mascarar OmniRoute como Claude/Codex, falsificar executável, interceptar TLS ou reutilizar tokens privados para fabricar uma PR upstream.
|
||||
|
||||
## Ordem recomendada de execução
|
||||
|
||||
1. **Primeiro:** PRs e issues já preparadas ou com alto retorno e baixo risco — jcode, Gemini CLI, Claw Code, Plandex, Trae Agent, Every Code, VT Code e CoreCoder.
|
||||
2. **Segundo:** patches genéricos com boa superfície OSS — AutoCodeRover, Galley, 5dive e demais casos `pr-generic`/`patch-required`.
|
||||
3. **Terceiro:** issues aguardando decisão — Open Codex, Kimi CLI, Devon, g3, Free Code, Claude Engineer e casos com `awaiting-maintainer`.
|
||||
4. **Quarto:** documentação e receitas config-only agrupadas por ecossistema — OpenCode, Codex, LiteLLM, AI SDK, OpenAI-compatible e Anthropic-compatible.
|
||||
5. **Quinto:** MCP/plugins para produtos fechados — Windsurf, Amp, Kiro, Cowork e Cursor, sempre pela superfície oficial.
|
||||
|
||||
Cada rodada deve manter no máximo três agentes ativos. O agente principal revisa o resultado do trio antes de liberar o próximo.
|
||||
|
||||
## Critério de encerramento por caso
|
||||
|
||||
Um caso só pode ser marcado como finalizado quando possui: pesquisa, classificação, evidência de protocolo, baseline ou limitação reproduzível, receita/patch/bloqueio, validação proporcional, estado de publicação e próximo passo. Para produtos fechados, `blocked-closed` ou `MCP-only` é um resultado válido e preferível a uma PR não autorizada.
|
||||
|
||||
## Estado de publicação atual
|
||||
|
||||
Os únicos links de publicação comprovados devem continuar sendo os registrados no tracker. O fato de existir uma branch local de pesquisa não significa que exista PR upstream. A matriz de verdade é:
|
||||
|
||||
- PR/issue preenchida: publicação real;
|
||||
- campo `—`: nenhuma publicação externa comprovada;
|
||||
- `not-applicable`: configuração ou bloqueio sem contribuição upstream;
|
||||
- `awaiting-maintainer`: contato feito, aguardando decisão;
|
||||
- `published-pr`/`published-issue`: URL real presente no tracker.
|
||||
|
||||
## Próxima fase
|
||||
|
||||
A pesquisa está encerrada. A próxima fase é execução controlada da Trilha A/B/C/D, começando pelos casos com maior retorno e menor risco, com revisão central antes de qualquer push, PR, issue ou contato externo.
|
||||
@@ -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,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());
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { createDecipheriv, scryptSync } from "node:crypto";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolveDataDir, resolveStoragePath } from "../data-dir.mjs";
|
||||
import { printHeading } from "../io.mjs";
|
||||
import { t } from "../i18n.mjs";
|
||||
@@ -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));
|
||||
@@ -389,74 +380,24 @@ function resolveLivenessUrl(options = {}) {
|
||||
return `http://${formatHostForUrl(host || "127.0.0.1")}:${dashboardPort}/api/health/degradation`;
|
||||
}
|
||||
|
||||
async function probeUrl(url) {
|
||||
try {
|
||||
const response = await fetchWithTimeout(url);
|
||||
return { ok: response.ok, status: response.status };
|
||||
} catch {
|
||||
return { ok: false, status: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
async function checkServerLiveness(options = {}) {
|
||||
const url = resolveLivenessUrl(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,
|
||||
});
|
||||
}
|
||||
|
||||
// #6162: /api/health and /api/health/degradation require a management token.
|
||||
// When unauthenticated, fall back to probing a publicly served static asset
|
||||
// (favicon.ico) to confirm the Next.js server is alive and reachable.
|
||||
// Derive the fallback URL from the primary URL (preserving protocol/host/port)
|
||||
// so custom liveness URL configurations are honored. Fall back to defaults
|
||||
// only if the primary URL can't be parsed.
|
||||
let fallbackUrl;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
parsed.pathname = "/favicon.ico";
|
||||
parsed.search = "";
|
||||
parsed.hash = "";
|
||||
fallbackUrl = parsed.toString();
|
||||
const response = await fetchWithTimeout(url);
|
||||
if (!response.ok) {
|
||||
return warn("Server liveness", `Server responded with HTTP ${response.status}`, { url });
|
||||
}
|
||||
return ok("Server liveness", "Server health endpoint is reachable", { url });
|
||||
} catch {
|
||||
const port = parsePort(process.env.PORT || "20128", 20128);
|
||||
const dashboardPort = parsePort(process.env.DASHBOARD_PORT || String(port), port);
|
||||
const host = String(options.livenessHost || process.env.OMNIROUTE_DOCTOR_HOST || "127.0.0.1")
|
||||
.trim()
|
||||
.replace(/^https?:\/\//, "")
|
||||
.replace(/\/.*$/, "");
|
||||
fallbackUrl = `http://${formatHostForUrl(host || "127.0.0.1")}:${dashboardPort}/favicon.ico`;
|
||||
return warn("Server liveness", "Server health endpoint is not reachable", { url });
|
||||
}
|
||||
const fallback = await probeUrl(fallbackUrl);
|
||||
|
||||
if (fallback.ok) {
|
||||
return ok(
|
||||
"Server liveness",
|
||||
`Server reachable (health endpoint returned ${primary.status}, likely requires MANAGEMENT_TOKEN)`,
|
||||
{
|
||||
primaryUrl: url,
|
||||
primaryStatus: primary.status,
|
||||
fallbackUrl,
|
||||
fallbackStatus: fallback.status,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return warn(
|
||||
"Server liveness",
|
||||
`Server health endpoint returned HTTP ${primary.status || "no-response"} and fallback probe failed`,
|
||||
{ primaryUrl: url, primaryStatus: primary.status, fallbackUrl, fallbackStatus: fallback.status }
|
||||
);
|
||||
}
|
||||
|
||||
export async function collectDoctorChecks(context = {}, options = {}) {
|
||||
const rootDir =
|
||||
context.rootDir || path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
|
||||
context.rootDir ||
|
||||
path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", "..", "..");
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -7,31 +7,15 @@ 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 },
|
||||
@@ -73,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) {
|
||||
@@ -216,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +1,18 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { existsSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { platform, totalmem, hostname as osHostname } from "node:os";
|
||||
import { platform, totalmem } from "node:os";
|
||||
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,
|
||||
buildServerNodeOptions,
|
||||
buildNodeRuntimeArgs,
|
||||
} from "../../../scripts/build/runtime-env.mjs";
|
||||
import { resolveTlsOptions } from "../../../scripts/dev/tls-options.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "..", "..", "..", "package.json"), "utf8"));
|
||||
|
||||
// URL scheme for the "OmniRoute is running" banner — flipped to https when
|
||||
// opt-in TLS (#5242) is active. Process-scoped: one `serve` run = one scheme.
|
||||
let urlScheme = "http";
|
||||
const ROOT = join(__dirname, "..", "..", "..");
|
||||
// The standalone bundle ships in `dist/` (since the build-output-isolation
|
||||
// refactor). Fall back to the legacy `app/` location so an upgrade over a
|
||||
@@ -52,52 +39,12 @@ export function registerServe(program) {
|
||||
.option("--max-restarts <n>", t("serve.max_restarts"), parseInt, 2)
|
||||
.option("--tray", t("serve.tray") || "Show system tray icon (desktop only)")
|
||||
.option("--no-tray", t("serve.no_tray") || "Disable system tray icon")
|
||||
.option(
|
||||
"--tls-cert <path>",
|
||||
t("serve.tls_cert") ||
|
||||
"Path to a TLS certificate (PEM) to serve HTTPS (also OMNIROUTE_TLS_CERT)"
|
||||
)
|
||||
.option(
|
||||
"--tls-key <path>",
|
||||
t("serve.tls_key") ||
|
||||
"Path to the TLS private key (PEM) to serve HTTPS (also OMNIROUTE_TLS_KEY)"
|
||||
)
|
||||
.action(async (opts) => {
|
||||
await runServe(opts);
|
||||
});
|
||||
}
|
||||
|
||||
/** 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 } =
|
||||
@@ -116,7 +63,6 @@ export async function runServe(opts = {}) {
|
||||
| |__| | | | | | | | | | | | \\ \\ (_) | |_| | || __/
|
||||
\\____/|_| |_| |_|_| |_|_|_| \\_\\___/ \\__,_|\\__\\___|
|
||||
\x1b[0m`);
|
||||
console.log(`\x1b[2m v${_pkg.version}\x1b[0m\n`);
|
||||
|
||||
const nodeSupport = getNodeRuntimeSupport();
|
||||
if (!nodeSupport.nodeCompatible) {
|
||||
@@ -167,11 +113,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"
|
||||
);
|
||||
@@ -196,41 +138,17 @@ export async function runServe(opts = {}) {
|
||||
calibrateHeapFallbackMb(totalmem())
|
||||
);
|
||||
|
||||
// #5242: opt-in native HTTPS. CLI flags take precedence over env; the child
|
||||
// server (server-ws.mjs) reads these and terminates TLS on the same listener.
|
||||
const tlsCert = opts.tlsCert ?? process.env.OMNIROUTE_TLS_CERT;
|
||||
const tlsKey = opts.tlsKey ?? process.env.OMNIROUTE_TLS_KEY;
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
OMNIROUTE_PORT: String(port),
|
||||
PORT: String(dashboardPort),
|
||||
DASHBOARD_PORT: String(dashboardPort),
|
||||
API_PORT: String(apiPort),
|
||||
// #6194: POSIX shells (bash/zsh) auto-set HOSTNAME to the machine name — the
|
||||
// .env loader (first-wins) can never override it. Ignore HOSTNAME when it
|
||||
// matches the OS-reported hostname (the auto-set signature). OMNIROUTE_SERVER_HOST
|
||||
// takes precedence; legacy HOSTNAME values that don't match os.hostname() are
|
||||
// still honoured for backward compatibility (e.g. Windows CMD/PowerShell users
|
||||
// who set HOSTNAME in .env where it is NOT auto-set).
|
||||
HOSTNAME:
|
||||
process.env.OMNIROUTE_SERVER_HOST ||
|
||||
(process.env.HOSTNAME !== osHostname() ? process.env.HOSTNAME : undefined) ||
|
||||
"0.0.0.0",
|
||||
HOSTNAME: process.env.HOSTNAME || "0.0.0.0",
|
||||
NODE_ENV: "production",
|
||||
// #5238: preserve a user-set NODE_OPTIONS (incl. their own
|
||||
// `--max-old-space-size=…`) instead of clobbering it with the calibrated
|
||||
// default — mirror the Electron/standalone launchers.
|
||||
NODE_OPTIONS: buildServerNodeOptions(process.env, memoryLimit),
|
||||
...(tlsCert ? { OMNIROUTE_TLS_CERT: tlsCert } : {}),
|
||||
...(tlsKey ? { OMNIROUTE_TLS_KEY: tlsKey } : {}),
|
||||
NODE_OPTIONS: `--max-old-space-size=${memoryLimit}`,
|
||||
};
|
||||
|
||||
// Validate the TLS pair up front so the operator sees a clear warning in the
|
||||
// CLI (the child re-validates authoritatively). Drives the banner scheme;
|
||||
// when null we fall through to identical plain-HTTP behavior as before.
|
||||
urlScheme = resolveTlsOptions(env) ? "https" : "http";
|
||||
|
||||
const isDaemon = opts.daemon === true;
|
||||
const useTray = opts.tray === true;
|
||||
|
||||
@@ -239,15 +157,7 @@ export async function runServe(opts = {}) {
|
||||
}
|
||||
|
||||
if (opts.noRecovery) {
|
||||
return runWithoutRecovery(
|
||||
serverJs,
|
||||
env,
|
||||
memoryLimit,
|
||||
dashboardPort,
|
||||
apiPort,
|
||||
noOpen,
|
||||
startedAt
|
||||
);
|
||||
return runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, noOpen);
|
||||
}
|
||||
|
||||
return runWithSupervisor(
|
||||
@@ -259,43 +169,30 @@ export async function runServe(opts = {}) {
|
||||
noOpen,
|
||||
opts.log === true,
|
||||
opts.maxRestarts ?? 2,
|
||||
startedAt,
|
||||
useTray
|
||||
);
|
||||
}
|
||||
|
||||
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 ? [serverJs] : buildNodeRuntimeArgs(process.env, memoryLimit, serverJs),
|
||||
{
|
||||
cwd: APP_DIR,
|
||||
env,
|
||||
stdio: "ignore",
|
||||
detached: true,
|
||||
}
|
||||
);
|
||||
const server = spawn("node", [`--max-old-space-size=${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`);
|
||||
console.log(` \x1b[1mDashboard:\x1b[0m ${urlScheme}://localhost:${dashboardPort}`);
|
||||
console.log(` \x1b[1mAPI Base:\x1b[0m ${urlScheme}://localhost:${apiPort}/v1`);
|
||||
console.log(` \x1b[1mDashboard:\x1b[0m http://localhost:${dashboardPort}`);
|
||||
console.log(` \x1b[1mAPI Base:\x1b[0m http://localhost:${apiPort}/v1`);
|
||||
}
|
||||
|
||||
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 ? [serverJs] : buildNodeRuntimeArgs(process.env, memoryLimit, serverJs),
|
||||
{
|
||||
cwd: APP_DIR,
|
||||
env,
|
||||
stdio: "pipe",
|
||||
}
|
||||
);
|
||||
function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort, noOpen) {
|
||||
const server = spawn("node", [`--max-old-space-size=${memoryLimit}`, serverJs], {
|
||||
cwd: APP_DIR,
|
||||
env,
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
writePidFile("server", server.pid);
|
||||
|
||||
@@ -304,21 +201,16 @@ 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"))
|
||||
) {
|
||||
started = true;
|
||||
onReady(dashboardPort, apiPort, noOpen, startedAt);
|
||||
onReady(dashboardPort, apiPort, noOpen);
|
||||
}
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -348,7 +240,7 @@ function runWithoutRecovery(serverJs, env, memoryLimit, dashboardPort, apiPort,
|
||||
setTimeout(() => {
|
||||
if (!started) {
|
||||
started = true;
|
||||
onReady(dashboardPort, apiPort, noOpen, startedAt);
|
||||
onReady(dashboardPort, apiPort, noOpen);
|
||||
}
|
||||
}, 15000);
|
||||
}
|
||||
@@ -362,7 +254,6 @@ async function runWithSupervisor(
|
||||
noOpen,
|
||||
showLog,
|
||||
maxRestarts,
|
||||
startedAt,
|
||||
useTray = false
|
||||
) {
|
||||
if (showLog) process.env.OMNIROUTE_SHOW_LOG = "1";
|
||||
@@ -387,19 +278,12 @@ async function runWithSupervisor(
|
||||
|
||||
supervisor.start();
|
||||
|
||||
// #9455: persist the supervisor's own PID so `omniroute stop` can SIGTERM it
|
||||
// before the child — the supervisor's SIGTERM handler sets isShuttingDown=true,
|
||||
// kills the child, and exits cleanly, so the child is never respawned after stop.
|
||||
writePidFile("supervisor", process.pid);
|
||||
|
||||
process.on("SIGINT", () => {
|
||||
killTrayIfActive();
|
||||
cleanupPidFile("supervisor");
|
||||
supervisor.stop();
|
||||
});
|
||||
process.on("SIGTERM", () => {
|
||||
killTrayIfActive();
|
||||
cleanupPidFile("supervisor");
|
||||
supervisor.stop();
|
||||
});
|
||||
|
||||
@@ -407,39 +291,12 @@ async function runWithSupervisor(
|
||||
waitForServer(dashboardPort, 60000).then(async (up) => {
|
||||
if (up) {
|
||||
if (useTray) await maybeStartTray(dashboardPort, apiPort, supervisor);
|
||||
onReady(dashboardPort, apiPort, noOpen, startedAt);
|
||||
} else {
|
||||
reportReadinessTimeout(dashboardPort, supervisor);
|
||||
onReady(dashboardPort, apiPort, noOpen);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// #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) {
|
||||
@@ -455,7 +312,7 @@ async function maybeStartTray(port, apiPort, supervisor) {
|
||||
const { initTray, isTraySupported } = await import("../tray/index.mjs");
|
||||
if (!isTraySupported()) return;
|
||||
const { default: open } = await import("open").catch(() => ({ default: null }));
|
||||
const dashboardUrl = `${urlScheme}://localhost:${port}`;
|
||||
const dashboardUrl = `http://localhost:${port}`;
|
||||
const tray = await initTray({
|
||||
port,
|
||||
onQuit: () => {
|
||||
@@ -475,20 +332,18 @@ async function maybeStartTray(port, apiPort, supervisor) {
|
||||
} catch (err) {
|
||||
// tray is optional — do not fail the server, but surface why it failed so
|
||||
// "--tray shows nothing" is diagnosable instead of silent (#4605).
|
||||
process.stderr.write(`[omniroute][tray] failed to start: ${err?.message ?? String(err)}\n`);
|
||||
process.stderr.write(
|
||||
`[omniroute][tray] failed to start: ${err?.message ?? String(err)}\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function onReady(dashboardPort, apiPort, noOpen, startedAt) {
|
||||
const dashboardUrl = `${urlScheme}://localhost:${dashboardPort}`;
|
||||
const apiUrl = `${urlScheme}://localhost:${apiPort}`;
|
||||
const elapsed =
|
||||
typeof startedAt === "number" && Number.isFinite(startedAt)
|
||||
? ((performance.now() - startedAt) / 1000).toFixed(1)
|
||||
: "0.0";
|
||||
async function onReady(dashboardPort, apiPort, noOpen) {
|
||||
const dashboardUrl = `http://localhost:${dashboardPort}`;
|
||||
const apiUrl = `http://localhost:${apiPort}`;
|
||||
|
||||
console.log(`
|
||||
\x1b[32m✔ OmniRoute is running!\x1b[0m \x1b[2m(started in ${elapsed}s)\x1b[0m
|
||||
\x1b[32m✔ OmniRoute is running!\x1b[0m
|
||||
|
||||
\x1b[1m Dashboard:\x1b[0m ${dashboardUrl}
|
||||
\x1b[1m API Base:\x1b[0m ${apiUrl}/v1
|
||||
|
||||
@@ -20,11 +20,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import os from "node:os";
|
||||
import { printHeading, printInfo, printSuccess, printError } from "../io.mjs";
|
||||
import {
|
||||
categoriseModel,
|
||||
isCodexCompatibleTextModel,
|
||||
profileNameFromModelId,
|
||||
} from "./setup-codex.mjs";
|
||||
import { categoriseModel } from "./setup-codex.mjs";
|
||||
|
||||
/** Map a Codex-style effort to a Claude Code settings.json effortLevel. */
|
||||
function effortLevelFor(cfg) {
|
||||
@@ -33,17 +29,6 @@ function effortLevelFor(cfg) {
|
||||
return cfg.effort || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic profile for a live-catalog model that `categoriseModel()` doesn't
|
||||
* recognize (e.g. any provider added after the hardcoded glm/kimi/mimo/…
|
||||
* pattern list was written). Mirrors setup-codex.mjs's fallbackCodexProfile()
|
||||
* so setup-claude never silently produces zero profiles for a fresh catalog.
|
||||
*/
|
||||
export function fallbackClaudeProfile(modelId, model) {
|
||||
if (!isCodexCompatibleTextModel(model)) return null;
|
||||
return { name: profileNameFromModelId(modelId) };
|
||||
}
|
||||
|
||||
/** Build the settings.json content for one Claude Code profile. */
|
||||
export function buildProfileSettings(modelId, baseUrl, cfg) {
|
||||
const env = {
|
||||
@@ -65,84 +50,18 @@ export function buildProfileSettings(modelId, baseUrl, cfg) {
|
||||
return JSON.stringify(settings, null, 2) + "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Claude Code profile files for a live model catalog. Shared by the
|
||||
* `setup-claude` CLI command and the post-model-sync auto-sync so both stay
|
||||
* behaviorally identical. Writes `<claudeHome>/profiles/<name>/settings.json`
|
||||
* (directory-per-profile); never touches the active/default Claude config.
|
||||
* @param {Array} models
|
||||
* @param {{claudeHome?:string, baseUrl:string, dryRun?:boolean, only?:string, log?:(line:string)=>void}} opts
|
||||
* @returns {Promise<{written:number, skipped:number, profiles:Array<{name:string, model:string, filePath:string}>}>}
|
||||
*/
|
||||
export async function syncClaudeProfilesFromModels(models, opts = {}) {
|
||||
const claudeHome = opts.claudeHome || join(os.homedir(), ".claude");
|
||||
const profilesRoot = join(claudeHome, "profiles");
|
||||
const baseUrl = opts.baseUrl;
|
||||
const dryRun = Boolean(opts.dryRun);
|
||||
// Injectable dry-run printer (#5959): under the node:test runner, a child
|
||||
// process writing multi-byte UTF-8 (the "──" box-drawing heading) to stdout
|
||||
// corrupts the runner's V8-serialized event stream ~50% of the time
|
||||
// ("Unable to deserialize cloned data due to invalid or unsupported
|
||||
// version"). Tests inject a collector; the CLI default stays console.log.
|
||||
const log = opts.log ?? console.log;
|
||||
const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null;
|
||||
|
||||
if (!dryRun && !existsSync(profilesRoot)) {
|
||||
mkdirSync(profilesRoot, { recursive: true });
|
||||
}
|
||||
|
||||
let written = 0;
|
||||
let skipped = 0;
|
||||
const profiles = [];
|
||||
|
||||
for (const m of models) {
|
||||
const id = typeof m === "string" ? m : (m.id ?? "");
|
||||
if (!id) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const cfg = categoriseModel(id) ?? fallbackClaudeProfile(id, m);
|
||||
if (!cfg) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const dir = join(profilesRoot, cfg.name);
|
||||
const filePath = join(dir, "settings.json");
|
||||
const content = buildProfileSettings(id, baseUrl, cfg);
|
||||
|
||||
if (dryRun) {
|
||||
log(`\n── [dry-run] ${filePath} ──`);
|
||||
log(content);
|
||||
} else {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
}
|
||||
profiles.push({ name: cfg.name, model: id, filePath });
|
||||
written++;
|
||||
}
|
||||
|
||||
return { written, skipped, profiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{remote?:string, port?:string, apiKey?:string, claudeHome?:string, dryRun?:boolean, only?:string}} opts
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function runSetupClaudeCommand(opts = {}) {
|
||||
const port = Number(opts.port ?? process.env.PORT ?? 20128) || 20128;
|
||||
const baseUrl = (opts.remote ?? `http://localhost:${port}`)
|
||||
.replace(/\/+$/, "")
|
||||
.replace(/\/v1$/, "");
|
||||
const baseUrl = (opts.remote ?? `http://localhost:${port}`).replace(/\/+$/, "").replace(/\/v1$/, "");
|
||||
const apiKey = opts.apiKey ?? opts["api-key"] ?? process.env.OMNIROUTE_API_KEY ?? "";
|
||||
const claudeHome = opts.claudeHome ?? opts["claude-home"] ?? join(os.homedir(), ".claude");
|
||||
const profilesRoot = join(claudeHome, "profiles");
|
||||
const dryRun = Boolean(opts.dryRun ?? opts["dry-run"]);
|
||||
const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null;
|
||||
|
||||
printHeading("OmniRoute → Claude Code profile generator");
|
||||
printInfo(`Connecting to ${baseUrl} …`);
|
||||
@@ -156,15 +75,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) {
|
||||
@@ -178,25 +89,42 @@ export async function runSetupClaudeCommand(opts = {}) {
|
||||
|
||||
printInfo(`Received ${models.length} models from ${baseUrl}`);
|
||||
|
||||
const { written, skipped, profiles } = await syncClaudeProfilesFromModels(models, {
|
||||
claudeHome,
|
||||
baseUrl,
|
||||
dryRun,
|
||||
only: opts.only,
|
||||
});
|
||||
if (!dryRun && !existsSync(profilesRoot)) {
|
||||
mkdirSync(profilesRoot, { recursive: true });
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
for (const profile of profiles) {
|
||||
printSuccess(` ✓ profiles/${profile.name}/settings.json (${profile.model})`);
|
||||
let written = 0;
|
||||
for (const m of models) {
|
||||
const id = typeof m === "string" ? m : m.id ?? "";
|
||||
if (!id) continue;
|
||||
if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) continue;
|
||||
|
||||
const cfg = categoriseModel(id);
|
||||
if (!cfg) continue;
|
||||
|
||||
const dir = join(profilesRoot, cfg.name);
|
||||
const filePath = join(dir, "settings.json");
|
||||
const content = buildProfileSettings(id, baseUrl, cfg);
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${filePath} ──`);
|
||||
console.log(content);
|
||||
} else {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
printSuccess(` ✓ profiles/${cfg.name}/settings.json (${id})`);
|
||||
}
|
||||
written++;
|
||||
}
|
||||
|
||||
const skipped = models.length - written;
|
||||
if (!dryRun) {
|
||||
console.log("");
|
||||
printSuccess(`${written} Claude Code profiles written to ${profilesRoot}`);
|
||||
if (skipped > 0) printInfo(`${skipped} models skipped (no matching profile pattern)`);
|
||||
console.log("\nTo use a profile:");
|
||||
console.log(" omniroute launch --profile <name> # e.g. omniroute launch --profile glm52");
|
||||
console.log(
|
||||
" # or: CLAUDE_CONFIG_DIR=~/.claude/profiles/<name> claude (export ANTHROPIC_AUTH_TOKEN first)"
|
||||
);
|
||||
console.log(" # or: CLAUDE_CONFIG_DIR=~/.claude/profiles/<name> claude (export ANTHROPIC_AUTH_TOKEN first)");
|
||||
} else {
|
||||
console.log(`\n[dry-run] ${written} profiles would be written (${skipped} skipped)`);
|
||||
}
|
||||
@@ -215,10 +143,7 @@ export function registerSetupClaude(program) {
|
||||
.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("--claude-home <dir>", "Claude home dir (default: ~/.claude)")
|
||||
.option(
|
||||
"--only <patterns>",
|
||||
"Comma-separated substrings — only matching model IDs (e.g. glm,kimi)"
|
||||
)
|
||||
.option("--only <patterns>", "Comma-separated substrings — only matching model IDs (e.g. glm,kimi)")
|
||||
.option("--dry-run", "Print what would be written without touching the filesystem")
|
||||
.action(async (opts) => {
|
||||
const exitCode = await runSetupClaudeCommand(opts);
|
||||
|
||||
@@ -39,83 +39,29 @@ export function categoriseModel(modelId) {
|
||||
{ re: /kmc\/kimi-k2\.6/, name: "kimi-k26", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{ re: /glm\/glm-5\.2-max/, name: "glm52max", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{ re: /glm\/glm-5\.2$/, name: "glm52", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{
|
||||
re: /opencode-go\/mimo-v2\.5-pro/,
|
||||
name: "mimo-pro",
|
||||
ctx: 131072,
|
||||
compact: 112000,
|
||||
toolLimit: 32768,
|
||||
},
|
||||
{
|
||||
re: /opencode-go\/qwen3\.7-plus/,
|
||||
name: "qwen37plus",
|
||||
ctx: 32768,
|
||||
compact: 28000,
|
||||
toolLimit: 16384,
|
||||
},
|
||||
{ re: /opencode-go\/mimo-v2\.5-pro/, name: "mimo-pro", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{ re: /opencode-go\/qwen3\.7-plus/, name: "qwen37plus", ctx: 32768, compact: 28000, toolLimit: 16384 },
|
||||
];
|
||||
|
||||
// ── Good models (high effort) ─────────────────────────────────────────────
|
||||
const goodPatterns = [
|
||||
{
|
||||
re: /ollamacloud\/deepseek-v4-pro/,
|
||||
name: "deepseek-pro",
|
||||
ctx: 131072,
|
||||
compact: 112000,
|
||||
toolLimit: 32768,
|
||||
},
|
||||
{
|
||||
re: /opencode-go\/mimo-v2\.5$/,
|
||||
name: "mimo",
|
||||
ctx: 131072,
|
||||
compact: 112000,
|
||||
toolLimit: 32768,
|
||||
},
|
||||
{ re: /ollamacloud\/deepseek-v4-pro/, name: "deepseek-pro", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
{ re: /opencode-go\/mimo-v2\.5$/, name: "mimo", ctx: 131072, compact: 112000, toolLimit: 32768 },
|
||||
];
|
||||
|
||||
// ── Simple models (no effort) ─────────────────────────────────────────────
|
||||
const simplePatterns = [
|
||||
{ re: /ollamacloud\/gemma4:31b/, name: "gemma4", ctx: 32768, compact: 28000, toolLimit: 16384 },
|
||||
{
|
||||
re: /ollamacloud\/nemotron-3-super/,
|
||||
name: "nemotron",
|
||||
ctx: 32768,
|
||||
compact: 28000,
|
||||
toolLimit: 16384,
|
||||
},
|
||||
{
|
||||
re: /ollamacloud\/gpt-oss:20b/,
|
||||
name: "gptoss",
|
||||
ctx: 32768,
|
||||
compact: 28000,
|
||||
toolLimit: 16384,
|
||||
},
|
||||
{ re: /ollamacloud\/nemotron-3-super/, name: "nemotron", ctx: 32768, compact: 28000, toolLimit: 16384 },
|
||||
{ re: /ollamacloud\/gpt-oss:20b/, name: "gptoss", ctx: 32768, compact: 28000, toolLimit: 16384 },
|
||||
];
|
||||
|
||||
// ── Fast models (low effort) ──────────────────────────────────────────────
|
||||
const fastPatterns = [
|
||||
{
|
||||
re: /ollamacloud\/deepseek-v4-flash/,
|
||||
name: "deepseek-flash",
|
||||
ctx: 65536,
|
||||
compact: 56000,
|
||||
toolLimit: 16384,
|
||||
},
|
||||
{
|
||||
re: /ollamacloud\/gemini-3-flash/,
|
||||
name: "gemini-flash",
|
||||
ctx: 1000000,
|
||||
compact: 850000,
|
||||
toolLimit: 32768,
|
||||
},
|
||||
{ re: /ollamacloud\/deepseek-v4-flash/, name: "deepseek-flash", ctx: 65536, compact: 56000, toolLimit: 16384 },
|
||||
{ re: /ollamacloud\/gemini-3-flash/, name: "gemini-flash", ctx: 1000000, compact: 850000, toolLimit: 32768 },
|
||||
{ re: /glm\/glm-5-turbo/, name: "glm5turbo", ctx: 131072, compact: 112000, toolLimit: 16384 },
|
||||
{
|
||||
re: /glm\/glm-4\.7-flash/,
|
||||
name: "glm47flash",
|
||||
ctx: 131072,
|
||||
compact: 112000,
|
||||
toolLimit: 16384,
|
||||
},
|
||||
{ re: /glm\/glm-4\.7-flash/, name: "glm47flash", ctx: 131072, compact: 112000, toolLimit: 16384 },
|
||||
];
|
||||
|
||||
for (const p of thinkingPatterns) {
|
||||
@@ -134,92 +80,6 @@ export function categoriseModel(modelId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstPositiveNumber(...values) {
|
||||
for (const value of values) {
|
||||
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function shortHash(value) {
|
||||
let hash = 5381;
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
hash = ((hash << 5) + hash) ^ value.charCodeAt(i);
|
||||
}
|
||||
return (hash >>> 0).toString(36);
|
||||
}
|
||||
|
||||
export function profileNameFromModelId(modelId) {
|
||||
const normalized = String(modelId)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "");
|
||||
const base = normalized || "model";
|
||||
if (base.length <= 96) return base;
|
||||
return `${base.slice(0, 84).replace(/-+$/g, "")}-${shortHash(base)}`;
|
||||
}
|
||||
|
||||
function hasAnyValue(values, patterns) {
|
||||
return values.some((value) => patterns.some((pattern) => pattern.test(value)));
|
||||
}
|
||||
|
||||
export function isCodexCompatibleTextModel(model) {
|
||||
if (typeof model === "string") return true;
|
||||
|
||||
const id = String(model?.id ?? "").toLowerCase();
|
||||
const type = String(model?.type ?? "").toLowerCase();
|
||||
const outputModalities = Array.isArray(model?.output_modalities)
|
||||
? model.output_modalities.map((value) => String(value).toLowerCase())
|
||||
: [];
|
||||
|
||||
if (type && !["chat", "text", "language", "llm", "model"].includes(type)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const unsupportedPatterns = [
|
||||
/(^|[/_-])(image|img|video|veo|seedance|audio|speech|voice|tts|stt|whisper)([/_-]|$)/,
|
||||
/(^|[/_-])(embedding|embeddings|embed|rerank|moderation|transcription)([/_-]|$)/,
|
||||
];
|
||||
if (hasAnyValue([id, type], unsupportedPatterns)) return false;
|
||||
|
||||
const nonTextModalities = [/^(image|video|audio)$/];
|
||||
if (hasAnyValue(outputModalities, nonTextModalities)) return false;
|
||||
|
||||
if (outputModalities.length > 0 && !outputModalities.includes("text")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function fallbackCodexProfile(modelId, model) {
|
||||
if (!isCodexCompatibleTextModel(model)) return null;
|
||||
|
||||
const ctx =
|
||||
typeof model === "string"
|
||||
? 128000
|
||||
: (firstPositiveNumber(
|
||||
model.context_length,
|
||||
model.max_context_window_tokens,
|
||||
model.max_input_tokens
|
||||
) ?? 128000);
|
||||
const maxOutput =
|
||||
typeof model === "string"
|
||||
? null
|
||||
: firstPositiveNumber(model.max_output_tokens, model.output_token_limit);
|
||||
const toolLimit = Math.min(Math.max(maxOutput ?? 16384, 8192), 32768);
|
||||
|
||||
return {
|
||||
name: profileNameFromModelId(modelId),
|
||||
ctx,
|
||||
compact: Math.floor(ctx * 0.85),
|
||||
summary: false,
|
||||
toolLimit,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build the TOML content for a single profile. */
|
||||
function buildProfileToml(modelId, cfg) {
|
||||
const lines = [
|
||||
@@ -245,52 +105,6 @@ function buildProfileToml(modelId, cfg) {
|
||||
return lines.join("\n") + "\n";
|
||||
}
|
||||
|
||||
export async function syncCodexProfilesFromModels(models, opts = {}) {
|
||||
const codexHome = opts.codexHome || join(os.homedir(), ".codex");
|
||||
const dryRun = Boolean(opts.dryRun);
|
||||
const onlyFilter = opts.only ? opts.only.split(",").map((s) => s.trim()) : null;
|
||||
|
||||
if (!dryRun && !existsSync(codexHome)) {
|
||||
mkdirSync(codexHome, { recursive: true });
|
||||
}
|
||||
|
||||
let written = 0;
|
||||
let skipped = 0;
|
||||
const profiles = [];
|
||||
|
||||
for (const m of models) {
|
||||
const id = typeof m === "string" ? m : (m.id ?? "");
|
||||
if (!id) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const cfg = categoriseModel(id) ?? fallbackCodexProfile(id, m);
|
||||
if (!cfg) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const filePath = join(codexHome, `${cfg.name}.config.toml`);
|
||||
const content = buildProfileToml(id, cfg);
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${filePath} ──`);
|
||||
console.log(content);
|
||||
} else {
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
}
|
||||
profiles.push({ name: cfg.name, model: id, filePath });
|
||||
written++;
|
||||
}
|
||||
|
||||
return { written, skipped, profiles };
|
||||
}
|
||||
|
||||
// ── Command ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -332,17 +146,39 @@ export async function runSetupCodexCommand(opts = {}) {
|
||||
|
||||
printInfo(`Received ${models.length} models from ${baseUrl}`);
|
||||
|
||||
// ── Ensure codex home exists ──────────────────────────────────────────────
|
||||
if (!dryRun && !existsSync(codexHome)) {
|
||||
mkdirSync(codexHome, { recursive: true });
|
||||
}
|
||||
|
||||
// ── Generate profiles ─────────────────────────────────────────────────────
|
||||
const { written, skipped, profiles } = await syncCodexProfilesFromModels(models, {
|
||||
codexHome,
|
||||
dryRun,
|
||||
only: opts.only,
|
||||
});
|
||||
let written = 0;
|
||||
let skipped = 0;
|
||||
|
||||
for (const m of models) {
|
||||
const id = typeof m === "string" ? m : (m.id ?? "");
|
||||
if (!id) continue;
|
||||
if (onlyFilter && !onlyFilter.some((f) => id.includes(f))) continue;
|
||||
|
||||
const cfg = categoriseModel(id);
|
||||
if (!cfg) continue;
|
||||
|
||||
const filePath = join(codexHome, `${cfg.name}.config.toml`);
|
||||
const content = buildProfileToml(id, cfg);
|
||||
|
||||
if (dryRun) {
|
||||
console.log(`\n── [dry-run] ${filePath} ──`);
|
||||
console.log(content);
|
||||
} else {
|
||||
writeFileSync(filePath, content, "utf8");
|
||||
printSuccess(` ✓ ${cfg.name}.config.toml (${id})`);
|
||||
}
|
||||
written++;
|
||||
}
|
||||
|
||||
skipped = models.length - written;
|
||||
|
||||
if (!dryRun) {
|
||||
for (const profile of profiles) {
|
||||
printSuccess(` ✓ ${profile.name}.config.toml (${profile.model})`);
|
||||
}
|
||||
console.log("");
|
||||
printSuccess(`${written} profiles written to ${codexHome}`);
|
||||
if (skipped > 0) {
|
||||
@@ -374,7 +210,10 @@ export function registerSetupCodex(program) {
|
||||
"--api-key <key>",
|
||||
"OmniRoute API key for the remote instance (defaults to OMNIROUTE_API_KEY env var)"
|
||||
)
|
||||
.option("--codex-home <dir>", "Directory where profile files are written (default: ~/.codex)")
|
||||
.option(
|
||||
"--codex-home <dir>",
|
||||
"Directory where profile files are written (default: ~/.codex)"
|
||||
)
|
||||
.option(
|
||||
"--only <patterns>",
|
||||
"Comma-separated substrings — only generate profiles for matching model IDs (e.g. glm,kimi)"
|
||||
|
||||
@@ -218,29 +218,13 @@ function registerPluginInOpenCodeConfig({
|
||||
* a clear "could not run opencode" message instead of a hard import
|
||||
* failure.
|
||||
*/
|
||||
/**
|
||||
* 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";
|
||||
return {
|
||||
command: isWin ? "opencode.cmd" : "opencode",
|
||||
args: ["auth", "login", "--provider", providerId],
|
||||
options: { stdio: "inherit", shell: isWin },
|
||||
};
|
||||
}
|
||||
|
||||
export function runOpenCodeAuth(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") {
|
||||
|
||||
@@ -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",
|
||||
@@ -255,9 +241,7 @@
|
||||
"no_recovery": "Disable auto-restart on crash (debugging mode)",
|
||||
"max_restarts": "Max crash restarts within 30s before giving up (default: 2)",
|
||||
"tray": "Show system tray icon (desktop only, opt-in)",
|
||||
"no_tray": "Disable system tray icon",
|
||||
"tls_cert": "Path to a TLS certificate (PEM) to serve HTTPS (also OMNIROUTE_TLS_CERT)",
|
||||
"tls_key": "Path to the TLS private key (PEM) to serve HTTPS (also OMNIROUTE_TLS_KEY)"
|
||||
"no_tray": "Disable system tray icon"
|
||||
},
|
||||
"backup": {
|
||||
"title": "Backup",
|
||||
|
||||
@@ -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 } from "node:path";
|
||||
import { writePidFile, cleanupPidFile, killAllSubprocesses, isPidRunning } from "../utils/pid.mjs";
|
||||
import { writePidFile, cleanupPidFile, killAllSubprocesses } from "../utils/pid.mjs";
|
||||
import {
|
||||
RESTART_RESET_MS,
|
||||
DEFAULT_MAX_RESTARTS,
|
||||
@@ -8,23 +8,11 @@ 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";
|
||||
|
||||
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,61 +23,29 @@ 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";
|
||||
// #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.
|
||||
// #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.
|
||||
this.child = spawn(
|
||||
process.versions.bun ? process.execPath : "node",
|
||||
process.versions.bun
|
||||
? [this.serverPath]
|
||||
: buildNodeRuntimeArgs(process.env, this.memoryLimit, this.serverPath),
|
||||
{
|
||||
cwd: dirname(this.serverPath),
|
||||
env: this.env,
|
||||
stdio: showLog ? "inherit" : ["ignore", "pipe", "pipe"],
|
||||
}
|
||||
);
|
||||
this.child = spawn("node", [`--max-old-space-size=${this.memoryLimit}`, 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));
|
||||
@@ -98,32 +54,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.
|
||||
@@ -166,12 +102,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));
|
||||
@@ -181,13 +111,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();
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user