merge(release/v3.8.51): refresh onto 3b752f9d4c

Bring inherited Fast Quality Gates / ESLint / unit-test / docs-sync fixes from #11940/#11955/#11975.

Co-authored-by: diegosouzapw <8016841+diegosouzapw@users.noreply.github.com>
This commit is contained in:
dependabot[bot]
2026-08-29 04:05:55 -03:00
committed by diegosouzapw
1382 changed files with 9000 additions and 5651 deletions

View File

@@ -3023,3 +3023,11 @@ QUOTA_STORE_DRIVER=sqlite
# corpus-aware retrieval. Higher values keep more index entries hot.
# Used by: src/lib/localCorpus/configured.ts
# OMNIROUTE_CORPUS_CACHE_SIZE=5
# Service-worker cache-busting id for the PWA shell (#11779). NEXT_PUBLIC_SW_BUILD_ID is
# derived at build time from OMNIROUTE_SW_BUILD_ID, then SOURCE_VERSION (set by some PaaS
# builders), then the git SHA — override only when the build cannot see git. Used by:
# next.config.mjs, scripts/build/assembleStandalone.mjs, src/shared/components/PwaRegister.tsx.
#OMNIROUTE_SW_BUILD_ID=2026-08-28T12-00-00
#SOURCE_VERSION=abcdef0123456789
#NEXT_PUBLIC_SW_BUILD_ID=abcdef0123456789

View File

@@ -1,9 +1,16 @@
name: Build App
# Manual-only since #11946. The hosted 7 GB runner can no longer build this tree — 19 of
# the last 30 runs died with "The runner has received a shutdown signal" (VM out of
# memory) ~8 min into `next build`, release/v3.8.51 itself included, even with the 10 GB
# swapfile below. Triggered on `push: branches: ["**"]` it painted every branch and every
# PR red while producing an artefact nothing downloads. The bundle is validated where a
# build actually fits:
# - main: ci.yml `Build` (self-hosted omni-build pool) on every merge
# - release/**: nightly-release-green.yml (same pool, continuous)
# Dispatch this workflow by hand when a hosted build artefact is genuinely needed.
on:
workflow_dispatch:
push:
branches: ["**"]
permissions:
contents: read

View File

@@ -609,13 +609,24 @@ jobs:
# Dynamic runner: when the release captain flips the USE_VPS_RUNNER repo var to
# 'true' (scripts/vps/release-runner-up.sh does it after the self-hosted VM is
# online), the heavy jobs run on the dedicated 32-core VPS runners (label
# omni-release) instead of queueing on the 20-concurrent-job hosted pool.
# omni-build) instead of queueing on the 20-concurrent-job hosted pool.
# Safety: fork PRs NEVER reach the self-hosted runner — the expression falls
# back to ubuntu-latest unless the PR head repo is this repository (push /
# dispatch events are own-origin by definition). Any failure path (VM down,
# var unset/false) also falls back to ubuntu-latest.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-release"]') || 'ubuntu-latest' }}
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-build"]') || 'ubuntu-latest' }}
needs: changes
# The .113 pool runs ONE next-build with room to spare and two at the edge: the
# box has 31 GB and a single next-build peaks at 1416 GB RSS. On 2026-08-28
# 13:50Z the kernel OOM-killed main's build while a PR build ran beside it
# (five Build jobs had been queued by a burst of PRs). Two lanes: main keeps
# its own so a release is never queued behind PR traffic; PR builds serialize
# among themselves. GitHub keeps one running + one pending per group and
# CANCELS older pendings — a cancelled PR build is re-runnable; a dead main
# build costs the publish its artefact and a 40-minute rebuild that OOMs.
concurrency:
group: heavy-build-${{ github.ref == 'refs/heads/main' && 'main' || 'pr' }}
cancel-in-progress: false
if: ${{ github.event_name != 'pull_request' || (needs.changes.outputs.code == 'true' && github.event.pull_request.draft == false) }}
steps:
- uses: actions/checkout@v7
@@ -649,14 +660,14 @@ jobs:
# Keep standalone/node_modules intact: package/electron jobs consume the
# Next-traced standalone tree and must not replace it with root node_modules.
run: |
tar -czf /tmp/e2e-build.tar.gz \
tar -czf "$RUNNER_TEMP/e2e-build.tar.gz" \
--exclude='.build/next/cache' \
.build/next
- name: Upload Next.js build for downstream jobs
uses: actions/upload-artifact@v7
with:
name: next-build
path: /tmp/e2e-build.tar.gz
path: ${{ runner.temp }}/e2e-build.tar.gz
retention-days: 1
package-artifact:
@@ -679,10 +690,14 @@ jobs:
uses: actions/download-artifact@v8
with:
name: next-build
path: /tmp/
# Workspace-relative on purpose: the matrix below includes windows-latest, whose
# default shell is pwsh, where $RUNNER_TEMP is empty (it is $env:RUNNER_TEMP) —
# #11896's first cut broke the Electron smoke on exactly that. A relative path
# works in bash and pwsh alike; hosted workspaces are ephemeral.
path: next-build-artifact
- name: Extract Next.js build artifact
run: |
tar -xzf /tmp/e2e-build.tar.gz
tar -xzf next-build-artifact/e2e-build.tar.gz
# build:cli consumes the downloaded .build/next standalone artifact and assembles dist/;
# it only rebuilds if the downloaded standalone artifact is missing.
- run: npm run build:cli
@@ -770,10 +785,14 @@ jobs:
uses: actions/download-artifact@v8
with:
name: next-build
path: /tmp/
# Workspace-relative on purpose: the matrix below includes windows-latest, whose
# default shell is pwsh, where $RUNNER_TEMP is empty (it is $env:RUNNER_TEMP) —
# #11896's first cut broke the Electron smoke on exactly that. A relative path
# works in bash and pwsh alike; hosted workspaces are ephemeral.
path: next-build-artifact
- name: Extract Next.js build artifact
run: |
tar -xzf /tmp/e2e-build.tar.gz
tar -xzf next-build-artifact/e2e-build.tar.gz
- name: Install Electron dependencies
working-directory: electron
run: npm install --no-audit --no-fund
@@ -960,7 +979,11 @@ jobs:
# 10min was sized before #7114 added the lcov reporter (Codecov/Sonar need it);
# merging 8 shard JSONs + text+json+lcov now takes ~10-12min — three consecutive
# release-tip runs died at exactly 10m as job-timeout "cancelled" (2026-07-15/16).
timeout-minutes: 20
# 30, not 20 (2026-08-29): the informational Codecov upload below hung for the rest of
# the budget on two consecutive main runs (33207760653, 33215115341); the job ended
# `cancelled` and dragged the whole run's conclusion to `cancelled` although every
# blocking job was green. The upload step now has its own ceiling; this is headroom.
timeout-minutes: 30
needs: test-unit
if: ${{ !cancelled() && needs.test-unit.result == 'success' && !contains(github.event.pull_request.labels.*.name, 'hotfix') }}
env:
@@ -1039,6 +1062,10 @@ jobs:
# (if-no-files-found: warn) — Sonar consumes the same file.
- name: Upload coverage to Codecov (informational)
if: always()
# Informational means informational: its own ceiling and continue-on-error, so a
# stalled upload can neither eat the job's budget nor turn a green job cancelled.
timeout-minutes: 5
continue-on-error: true
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
files: coverage/lcov.info
@@ -1233,10 +1260,14 @@ jobs:
uses: actions/download-artifact@v8
with:
name: next-build
path: /tmp/
# Workspace-relative on purpose: the matrix below includes windows-latest, whose
# default shell is pwsh, where $RUNNER_TEMP is empty (it is $env:RUNNER_TEMP) —
# #11896's first cut broke the Electron smoke on exactly that. A relative path
# works in bash and pwsh alike; hosted workspaces are ephemeral.
path: next-build-artifact
- name: Extract Next.js build artifact
run: |
tar -xzf /tmp/e2e-build.tar.gz
tar -xzf next-build-artifact/e2e-build.tar.gz
# WS4.1: duration-balanced shards (LPT over config/quality/e2e-timings.json).
# Measured skew of plain --shard was 14× (24m47s vs 1m47s) — E2E was the CI
# critical path. The balancer self-verifies completeness and exits non-zero on

View File

@@ -1,7 +1,15 @@
name: DAST smoke (PR)
# PRs into main only since #11946. The job's "Build CLI bundle" step is a backend-only
# `next build`; on the hosted 7 GB runner it fits main's tree (~5.5 min) but dies on
# release/v3.8.51 (VM shutdown ~7 min in, before the server even starts), and because the
# job is continue-on-error the result was a permanently red advisory check on every
# release PR — noise, not signal. DAST coverage for release/** lives on the nightly rail
# (nightly-schemathesis.yml, nightly-llm-security.yml); dispatch this workflow by hand
# to smoke a release branch on demand.
on:
workflow_dispatch:
pull_request:
branches: ["main", "release/**"]
branches: ["main"]
# 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.

View File

@@ -10,6 +10,11 @@ on:
description: "Release version (e.g., v1.6.8)"
required: true
type: string
publish_npm:
description: "Also run the npm publish leg (turn off when re-attaching desktop assets to a release whose npm package already shipped)"
required: false
default: true
type: boolean
# Least-privilege default: read-only at the top level; each job grants the writes it
# needs (build/release upload assets, publish-npm forwards npm provenance / packages
@@ -76,6 +81,9 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
# workflow_dispatch: build the tag being (re)built, not the dispatching branch. On a
# tag push this resolves to the same commit.
ref: ${{ needs.validate.outputs.version }}
- name: Setup Node
uses: actions/setup-node@v7
with:
@@ -161,6 +169,9 @@ jobs:
- uses: actions/checkout@v7
with:
persist-credentials: false
# workflow_dispatch: build the tag being (re)built, not the dispatching branch. On a
# tag push this resolves to the same commit.
ref: ${{ needs.validate.outputs.version }}
- name: Setup Node
uses: actions/setup-node@v7
with:
@@ -347,6 +358,8 @@ jobs:
with:
persist-credentials: false
fetch-depth: 0
# Source archives + SBOM come from the tag being released, not the dispatching branch.
ref: ${{ needs.validate.outputs.version }}
# `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
@@ -462,11 +475,20 @@ jobs:
publish-npm:
name: Publish to npm
needs: [validate, release]
# A re-dispatch that only re-attaches desktop assets must not publish the npm package again.
if: ${{ github.event_name != 'workflow_dispatch' || inputs.publish_npm }}
permissions:
# Must be `write`, not `read`: this job calls the reusable npm-publish.yml whose
# `publish` job needs `contents: write` (gh release upload — attach the SBOM, #3874).
# A reusable workflow's job cannot request more permission than the caller grants,
# so a `read` here makes GitHub reject the run at startup (startup_failure).
#
# `actions: read` for the same reason: the called `publish` job downloads the next-build
# artefact and requests it. v3.8.50 (run 33005490476) died at startup with "The nested
# job 'publish' is requesting 'actions: read', but is only allowed 'actions: none'" — and
# because `release` lives in this same workflow, the tag shipped with ZERO assets. Keep
# this block a superset of every job's permissions in npm-publish.yml.
actions: read
contents: write
id-token: write # npm provenance (forwarded to the reusable workflow)
packages: write # publish to npm.pkg.github.com

View File

@@ -10,7 +10,10 @@ permissions:
jobs:
promptfoo-guard:
name: promptfoo — injection guard (block mode, no secret)
runs-on: ubuntu-latest
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
steps:
- uses: actions/checkout@v7
with:
@@ -46,7 +49,10 @@ jobs:
garak:
name: garak probes (skip without provider secret)
runs-on: ubuntu-latest
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
# NOTE: the `secrets` context is NOT available in a job-level `if:` — referencing
# it there makes GitHub reject the file on push (startup_failure on every push).
# Map the secret into a job-level env and gate each step on a presence check, so

View File

@@ -68,7 +68,7 @@ jobs:
# 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: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-build"]')) || 'ubuntu-latest' }}
env:
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-nightly-api-key-secret-long
@@ -217,7 +217,7 @@ jobs:
# 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' }}
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-build"]')) || 'ubuntu-latest' }}
env:
JWT_SECRET: ci-nightly-secret-with-sufficient-length-for-validation
API_KEY_SECRET: ci-nightly-api-key-secret-long

View File

@@ -78,7 +78,10 @@ jobs:
a11y:
name: A11y axe (nightly, freeze-and-alert)
runs-on: ubuntu-latest
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
# The Playwright webServer (`start` mode) builds Next via build-next-isolated.mjs and
# boots the standalone server itself (waits on /api/monitoring/health, 15min webServer
# timeout). Unlike the per-PR test-e2e job, this nightly job has no pre-built artifact,

View File

@@ -10,7 +10,10 @@ permissions:
jobs:
schemathesis:
name: Schemathesis — OpenAPI contract fuzz (advisory)
runs-on: ubuntu-latest
# #11965: this job runs a backend-only `next build`; the hosted 7 GB runner cannot build
# release/v3.8.51 (VM shutdown ~7 min in), so it targets the box's light pool (`omni-light`:
# two listeners, jobs ≤ ~6 GB). Falls back to hosted when USE_VPS_RUNNER is off.
runs-on: ${{ (vars.USE_VPS_RUNNER == 'true' && fromJSON('["self-hosted","omni-light"]')) || 'ubuntu-latest' }}
timeout-minutes: 30
steps:
- uses: actions/checkout@v7

View File

@@ -23,11 +23,12 @@ on:
- 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)"
description: "auto = publish through npm Trusted Publishing (OIDC, no token, no 2FA prompt — the default); staged = npm stage publish (owner approves with 2FA); direct = legacy token publish (emergency fallback only)"
required: false
default: "staged"
default: "auto"
type: choice
options:
- auto
- staged
- direct
workflow_call:
@@ -62,7 +63,7 @@ jobs:
# 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: ${{ (vars.USE_VPS_RUNNER == 'true' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)) && fromJSON('["self-hosted","omni-build"]') || 'ubuntu-latest' }}
outputs:
version: ${{ steps.resolve.outputs.version }}
tag: ${{ steps.resolve.outputs.tag }}
@@ -204,8 +205,11 @@ jobs:
exit 0
fi
RUN=""
# $RUNNER_TEMP, never /tmp: on the .113 pool /tmp is a 12 GB tmpfs (RAM). Parking
# this 1.3 GB artefact there took 2732 min of the 76-min publish job — the
# same bytes upload from disk in 2 min. RUNNER_TEMP is per-runner and on disk.
for candidate in $CANDIDATES; do
if gh run download "$candidate" --repo "$REPO" --name next-build --dir /tmp/next-build 2>/dev/null; then
if gh run download "$candidate" --repo "$REPO" --name next-build --dir "$RUNNER_TEMP/next-build" 2>/dev/null; then
RUN="$candidate"
break
fi
@@ -215,8 +219,8 @@ jobs:
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
tar -xzf "$RUNNER_TEMP/next-build/e2e-build.tar.gz" -C .
rm -rf "$RUNNER_TEMP/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
@@ -404,8 +408,34 @@ jobs:
fi
npm --version
# Trusted Publishing (OIDC): npm mints a short-lived credential for THIS run from
# GitHub's id-token — no NPM_TOKEN secret, no 2FA prompt, provenance included, and
# it is the bypass npm sanctions now that tokens which skip 2FA are being retired
# (gh.io/npm-gat-bypass2fa-deprecation). Requires the package's Trusted Publisher to
# be configured on npmjs.com (owner: diegosouzapw/OmniRoute, workflow
# npm-publish.yml) and a github-hosted runner — which is why this job exists.
# Without that configuration `npm publish` fails with ENEEDAUTH: re-dispatch with
# publish_mode=staged or direct. Automatic publishing was the flow up to v3.8.48;
# v3.8.49 moved to staged (WS1.3) to keep a leaked token from publishing alone —
# OIDC gives the same guarantee without the manual approve.
- name: Publish to npm (Trusted Publishing / OIDC — automatic)
if: github.event_name != 'workflow_dispatch' || inputs.publish_mode == 'auto'
env:
VERSION: ${{ needs.publish.outputs.version }}
TAG: ${{ needs.publish.outputs.tag }}
run: |
set -euo pipefail
TARBALL="omniroute-${VERSION}.tgz"
test -f "$TARBALL" || { echo "tarball $TARBALL did not arrive from the publish job" >&2; ls -la; exit 1; }
# Deliberately NO NODE_AUTH_TOKEN in this step: npm >= 11.5 detects the GitHub
# OIDC token itself. Always pass --tag explicitly (defense in depth: an older
# VERSION can never claim `@latest`).
npm publish "$TARBALL" --provenance --access public --tag "$TAG" --ignore-scripts
echo "✅ Published omniroute@$VERSION (dist-tag=$TAG) via Trusted Publishing"
- name: Publish to npm (staged — owner approves with 2FA)
if: github.event_name != 'workflow_dispatch' || inputs.publish_mode != 'direct'
# Only on an explicit request now: Trusted Publishing below is the default.
if: github.event_name == 'workflow_dispatch' && inputs.publish_mode == 'staged'
env:
VERSION: ${{ needs.publish.outputs.version }}
TAG: ${{ needs.publish.outputs.tag }}

View File

@@ -61,6 +61,9 @@ jobs:
name: Build (advisory)
needs: changes
# FORK PRs ONLY. build.yml's `Fast Production Build` triggers on `push: branches: ["**"]`
# (#11946, 2026-08-29: build.yml is now workflow_dispatch-only — the hosted runner cannot
# build this tree in any profile, 8/8 recent fork PRs included — so own-origin PRs rely on
# ci.yml `Build` after merge to main and on nightly-release-green for release/**.)
# and runs `build:release` — a superset of this job — so for an own-origin branch this job
# was building the same tree twice. A fork contributor pushes to THEIR repo, so that push
# never fires here, and this is the only pre-merge build signal they get. Measured
@@ -189,8 +192,11 @@ jobs:
.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 }}-
# No restore-keys fallback on purpose (#11600, P-II.1 of the v3.8.50 postmortem): a
# cache built under a different suppressions file / lint config / lockfile reports
# stale per-file verdicts, which is exactly how 215 pre-existing errors stayed
# invisible for a whole cycle. Exact key or a cold full lint (~13 min) — never a
# partial cache from another configuration.
# 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
@@ -460,6 +466,12 @@ jobs:
# 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
# A shard finishes in ~10 min. Without a ceiling a hung test process holds the PR for
# GitHub's 6 h default: on 2026-08-28 shard 1/4 sat 64 min without a line of output
# (twice, same spot — a timing race, gone on the third run) while the other three
# shards were long green. 30 min = 3x the normal wall-clock; a shard that needs more
# is a hang, not a slow run, and a fast red with a re-run beats a silent 6 h hold.
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
@@ -524,8 +536,11 @@ jobs:
.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 }}-
# No restore-keys fallback on purpose (#11600, P-II.1 of the v3.8.50 postmortem): a
# cache built under a different suppressions file / lint config / lockfile reports
# stale per-file verdicts, which is exactly how 215 pre-existing errors stayed
# invisible for a whole cycle. Exact key or a cold full lint (~13 min) — never a
# partial cache from another configuration.
- 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

View File

@@ -594,6 +594,18 @@ inside your feature branch (a base-red fix is its own freeze-gated `fix/release-
PR); and if you must open a PR anyway, add `⚠️ base-red inherited: #<issue>` to the PR body so
reviewers and CI babysitters do not chase ghosts.
### Sync-back landings are fast-forward, never squash
A `main → release/vX+1` sync-back (Phase 5 of `/generate-release`, or any later "bring main's
post-release commits over" PR) must reach the release branch as the merge commit it already is:
`git merge-base --is-ancestor origin/release/vX+1 <head>` then
`git push origin <head>:refs/heads/release/vX+1` (GitHub marks the PR merged). Squash-merging it
drops `main` from the release branch's ancestry and the next sync-back re-conflicts on every file
main touched (551 conflicts on the v3.8.50 → v3.8.51 sync before the two-step merge). After
landing, `git merge-base --is-ancestor origin/main origin/release/vX+1` must be true — and check
that `config/quality/eslint-suppressions.json` / `quality-baseline.json` carried main's freezes
(they merge as "ours" silently). Details: `.agents/skills/generate-release/phases/phase-5-next-cycle.md`.
---
## Upstream contributions

View File

@@ -0,0 +1,4 @@
- The npm publish is automatic again, through npm Trusted Publishing (OIDC): the hosted
`stage-npm` job publishes with a short-lived credential minted from GitHub's id-token —
no `NPM_TOKEN`, no 2FA prompt, provenance attached. `publish_mode=staged` (owner
approves with 2FA) and `direct` (token) remain available on `workflow_dispatch`.

View File

@@ -0,0 +1,5 @@
- Fixed the Alibaba free-tier allowlist test that went red on its own once the
shipped catalog's `validUntil` (2026-08-27) passed, leaving every PR and `main`
with a failing `Unit Tests (1/8)`. The test now builds its own packs with dates
it controls, and covers the expired-pack fallback that production has actually
been serving.

View File

@@ -0,0 +1 @@
- **fix(providers):** xAI `reasoning_effort: "xhigh"` now reaches grok-4.6+ instead of being silently clamped to `"high"` ([#11879](https://github.com/diegosouzapw/OmniRoute/pull/11879)) — thanks @NoxzRCW

View File

@@ -0,0 +1 @@
- **fix(dashboard):** the providers page no longer crashes into the error boundary when a provider id collides with an `Object.prototype` member (`constructor`, `__proto__`); icon lookups are own-property guarded ([#11880](https://github.com/diegosouzapw/OmniRoute/pull/11880)) — thanks @NoxzRCW

View File

@@ -0,0 +1 @@
- **fix(skills):** injected skill tools declared in shorthand (`{"content": "string"}`) now forward valid JSON Schema, unblocking providers that validate tool schemas strictly such as Zhipu GLM on the Console Go tier ([#11881](https://github.com/diegosouzapw/OmniRoute/pull/11881)) — thanks @NoxzRCW

View File

@@ -0,0 +1 @@
- **fix(api):** `POST /api/playground/simulate-route` now surfaces `combo-ref` and `provider-wildcard` persisted combo steps with a specific warning (naming the referenced combo, or the unresolved `provider/model` wildcard) instead of folding them into a generic "unsupported step" count; a `provider-wildcard` step is also now included as an unresolved target so the operator can see it is in the route (ported from [#11882](https://github.com/diegosouzapw/OmniRoute/pull/11882) — thanks @NoxzRCW).

View File

@@ -0,0 +1 @@
- **fix(translator):** the streaming OpenAI→Claude translator keeps upstream usage, including prompt-cache tokens, when it arrives on a trailing `choices: []` chunk (Fireworks and any upstream using `stream_options.include_usage`) ([#11883](https://github.com/diegosouzapw/OmniRoute/pull/11883)) — thanks @NoxzRCW

View File

@@ -0,0 +1 @@
- **fix(sse):** the OpenAI→Claude stream translator now defers the terminal `message_delta`/`message_stop` emission until the real usage block has arrived (or a genuine end-of-stream flush forces it) instead of emitting it immediately on `finish_reason` — previously, when the trailing usage-only chunk (`{"choices":[],"usage":{...}}`) arrived *after* the `finish_reason` chunk (the normal order for Fireworks/vLLM/Together and other `stream_options.include_usage` upstreams), the client-visible `message_delta` still carried stale/zero usage even though `state.usage` was internally corrected too late to matter (ported from [#11915](https://github.com/diegosouzapw/OmniRoute/pull/11915) — thanks @HouMinXi).

View File

@@ -0,0 +1 @@
- **fix(sse):** `stripStore()` now forces `store=false` for stateless OpenAI-compatible Responses-API targets (unless the connection has explicitly opted in via `providerSpecificData.openaiStoreEnabled`), instead of only handling the `openai`/`agentrouter` cases — a client-supplied `store` value previously passed through untouched to backends that don't actually persist responses server-side ([#11916](https://github.com/diegosouzapw/OmniRoute/pull/11916) — thanks @HouMinXi).

View File

@@ -0,0 +1 @@
- **fix(models):** custom provider-node models (synced, custom, and alias-backed) now appear under their configured prefix in the unified catalog when the operator's model-id prefix mode is canonical, instead of being dropped whenever alias-inclusion was otherwise disabled ([#11918](https://github.com/diegosouzapw/OmniRoute/pull/11918) — thanks @HouMinXi).

View File

@@ -0,0 +1 @@
- **fix(models):** the unified model catalog now suppresses stale static registry models (including effort-tier variants) for any provider whose active connection has an authoritative live synced catalog, not only providers already using exclusive-synced-listing — a connection with `providerUsesAuthoritativeLiveCatalog` previously kept serving both the live-synced models and the stale static rows side by side ([#11919](https://github.com/diegosouzapw/OmniRoute/pull/11919) — thanks @HouMinXi).

View File

@@ -0,0 +1 @@
- **fix(ui):** `ProviderIcon`'s three remaining unguarded lookups (`PROVIDER_ICON_ALIASES`, `LOCAL_SVG_ALIASES`, `THEMED_SVGS`) now use `Object.hasOwn()` own-property checks — a provider id such as `constructor` or `__proto__` previously resolved through the prototype chain instead of falling through to the unknown-provider CDN fallback (`getLobeProviderIcon()` itself was already guarded by [#11880](https://github.com/diegosouzapw/OmniRoute/pull/11880)); `ProviderPageHeader`'s `color` field is also now optional, matching the rest of the component's defensive typing (ported from [#11920](https://github.com/diegosouzapw/OmniRoute/pull/11920) — thanks @HouMinXi).

View File

@@ -0,0 +1 @@
- Electron release workflow: the `publish-npm` job now grants `actions: read` to the reusable `npm-publish.yml` it calls (its `publish` job requests it), which is what made GitHub refuse the whole v3.8.50 run at startup and ship the release with zero desktop assets; a `workflow_dispatch` now builds the requested tag instead of the dispatching branch and can skip the npm leg (`publish_npm=false`) when only re-attaching assets

View File

@@ -0,0 +1,7 @@
- Drained the reds every PR against `release/v3.8.51` was born with: documented the
three service-worker build-id variables, registered the six retirement/tunnel tests
with the mutation gate, approved `eslint-plugin-react-hooks` in the dependency
allowlist, added the six `combo.sort.*` strings to `vi` and `pt-BR`, pointed the
ChatGPT Web doc at the real migration-168 test, worded the g4f hint around the
member key, and realigned four tests to the retired-provider catalog and the
legacy-schema fixtures the retirement migrations touch.

View File

@@ -0,0 +1,4 @@
- Added a unit test that fails seven days before any dated pack under `config/`
(`validUntil` and sibling keys) lapses, naming the file and key. The Alibaba
free-tier pack expired on 2026-08-27 and turned every PR red the next morning
with no commit involved; renewal now happens on someone's terms, not the clock's.

View File

@@ -0,0 +1,3 @@
- `check:workflows` now fails (under `--strict`/`--ratchet`) when any job routed to a
self-hosted runner publishes with `--provenance` — npm rejects that with `422` at the
registry, which in v3.8.50 only surfaced after the tag and Docker images were public.

View File

@@ -0,0 +1,5 @@
- `scripts/ops/runner-janitor.sh` now proves a path is idle with one `lsof`
snapshot and removes stale leftovers itself (tmpfs after 3 h — it is RAM — disk
after 24 h), kills orphan `next-build` processes, prunes checkouts of stopped
runners, and alerts on memory pressure; `--dry-run` shows exactly what it would
do. `docs/ops/RUNNER_BOX.md` reconciled to the measured box (31 GB, 10 listeners).

View File

@@ -0,0 +1,5 @@
- The `next-build` artefact (1.3 GB) is now written and read under `$RUNNER_TEMP`
(per-runner, on disk) instead of `/tmp`, which on the self-hosted pool is a
12 GB tmpfs in RAM. Landing it there took 2732 of the publish job's 76 minutes,
and the fixed `/tmp/e2e-build.tar.gz` name let E2E jobs on different runners
overwrite each other's download.

View File

@@ -0,0 +1,3 @@
- The CI `build` job now runs in two concurrency lanes — `main` and pull requests —
so a release build is never queued behind (or OOM-killed beside) PR builds on the
self-hosted pool, which holds one `next-build` comfortably and two at the edge.

View File

@@ -0,0 +1 @@
- Re-freeze the ESLint suppressions on `release/v3.8.51` from a clean-room measurement (2 stale file entries pruned, 55 pre-existing `no-explicit-any` in six new files frozen under #11924) and drop the dead `GPT_SIZE_MAP` constant orphaned by the Adobe Firefly client split, so `No new ESLint warnings` stops failing every PR with exit 2 (Refs #11924)

View File

@@ -0,0 +1 @@
- Type the 55 `no-explicit-any` sites that had been frozen under #11924 — four redundant casts in `socksConnectorWithFamily.ts` (undici/socks types already accept them) and the mocks/fixtures of the socks-timeout and isFree suites — and drop their suppression entries; the ESLint ratchet shrinks from 5487 to 5432 (Closes #11924)

View File

@@ -0,0 +1 @@
- Take the two hosted-runner builds off the PR rail: `Build App` (`build.yml`) is `workflow_dispatch`-only and `DAST smoke (PR)` runs only for PRs into `main` — the 7 GB hosted VM cannot build `release/v3.8.51` in any profile (19/30 red, VM shutdown ~8 min into `next build`) and both checks had turned into permanent noise on every release PR; the bundle stays validated by `ci.yml` on `main` and by `nightly-release-green` on `release/**` (Closes #11946)

View File

@@ -0,0 +1 @@
- Move the four nightly jobs that build the backend (`nightly-schemathesis`, `nightly-llm-security` promptfoo + garak, `nightly-resilience` axe-a11y) off the hosted 7 GB runner — where they died on `release/v3.8.51` unseen — onto the box's new `omni-light` pool (two listeners), and document the reshaped fleet (4 active OmniRoute listeners: 2 `omni-build` + 2 `omni-light`, janitor ceiling 4) (Closes #11965)

View File

@@ -0,0 +1 @@
- `Coverage` job on `ci.yml`: the informational Codecov upload gets its own 5-minute ceiling and `continue-on-error`, and the job budget grows from 20 to 30 minutes (the 8-shard c8 merge alone takes ~10) — a stalled upload no longer ends the job `cancelled` and drags a fully green `main` run's conclusion down with it

View File

@@ -0,0 +1 @@
- CI hardening on the PR rail: the four `Unit Tests fast-path` shards get `timeout-minutes: 30` (a hung shard held a PR for 64 min instead of GitHub's 6 h default) and both ESLint file caches lose their `restore-keys` fallback, so a cache built under another suppressions file or lint config can no longer report stale verdicts (Refs #11600, #11924)

View File

@@ -0,0 +1,4 @@
- Every CI job that runs a `next build` (`build`, the npm `publish`, both release-green
validations) now targets the `omni-build` runner label, which only two of the eight
self-hosted runners carry. The box holds one build comfortably and two at the edge; a
third now queues on GitHub instead of being OOM-killed by the kernel.

View File

@@ -2,7 +2,8 @@
"_comment": "Allowlist anti-slopsquatting (check-deps.mjs). Toda dep nova exige adicao EXPLICITA aqui apos verificar que e legitima.",
"_justifications": {
"@testing-library/dom": "Peer dep obrigatoria de @testing-library/react v16 (adicionada no PR #11224); Refs #9985.",
"@testing-library/user-event": "Utilitario oficial do ecossistema testing-library para testes de UI (adicionada no PR #11224); Refs #9985."
"@testing-library/user-event": "Utilitario oficial do ecossistema testing-library para testes de UI (adicionada no PR #11224); Refs #9985.",
"eslint-plugin-react-hooks": "React Hooks lint rules (set-state-in-effect, immutability, refs, purity) pinned at 7.0.1 by the release/v3.8.51 cycle; the 224 findings it raised are tracked in #11924. Refs #11924."
},
"allowed": [
"@atjsh/llmlingua-2",
@@ -48,8 +49,8 @@
"clsx",
"commander",
"concurrently",
"cross-env",
"cron-parser",
"cross-env",
"csv-stringify",
"ctrf",
"dompurify",
@@ -60,6 +61,7 @@
"esbuild",
"eslint",
"eslint-config-next",
"eslint-plugin-react-hooks",
"eslint-plugin-sonarjs",
"express",
"fast-check",
@@ -102,9 +104,9 @@
"node-loader",
"node-machine-id",
"omniglyph",
"onnxruntime-node",
"open",
"opencode-ai",
"onnxruntime-node",
"ora",
"parse5",
"pino",
@@ -131,10 +133,10 @@
"tailwind-merge",
"tailwindcss",
"tls-client-node",
"turndown",
"turndown-plugin-gfm",
"tsup",
"tsx",
"turndown",
"turndown-plugin-gfm",
"type-coverage",
"typescript",
"typescript-eslint",

View File

@@ -401,11 +401,6 @@
"count": 3
}
},
"open-sse/services/adobeFireflyClient.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"open-sse/services/adobeFireflySession.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -2566,11 +2561,6 @@
"count": 1
}
},
"src/lib/providers/validation/webProvidersA.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 2
}
},
"src/lib/providers/validation/webProvidersB.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
@@ -6532,4 +6522,4 @@
"count": 2
}
}
}
}

View File

@@ -45,6 +45,7 @@
"_rebaseline_2026_07_03_v3844_ipfilter_release_green": "testFrozen bumps: models-catalog-route 1507->1600, perplexity-web 959->999, route-edge-coverage 1234->1241 (last is my #5975 comment +7). v3.8.44 cycle drift measured on release tip 32e4c906e during the #6131/#5975 release-green rebaseline. Inherited from the merge burst (Quality Ratchet does not run on PR->release fast-gates). route-edge-coverage +7 is my #5975 test comment; the rest is parallel-session drift. Tighten via --update next cycle.",
"_rebaseline_2026_07_03_v3844_residual_release_green": "Residual file-size drift on tip 716041223: providerLimits.ts 955->982 + accountFallback.ts 1790->1864 (production god-files grown by parallel-session merges e.g. #6128; ideally DECOMPOSE not rebaseline, tracked as debt) + sse-auth.test.ts 1553->1600. None mine.",
"_comment": "Catraca de tamanho (check-file-size.mjs). frozen so pode encolher; arquivos novos <= cap. --update ratcheta.",
"_rebaseline_2026_08_28_mergebatch_v3851_noxzrcw_skills_pipeline_drift": "/merge-batch 2026-08-28 (v3.8.51): tests/integration/skills-pipeline.test.ts already measured 1008->1009 (gate) on the pure release/v3.8.51 tip before boarding any PR in this batch (#11883/#11881/#11880/#11879 — none touch this file); pre-existing drift inherited from an earlier already-merged PR, rebaselined here so the gate stays green for this batch.",
"_rebaseline_2026_07_09_pr6647_winget_claude_detect": "PR #6647 (enjoyer-hub, /implement-prs sync): cliRuntime.ts 1100->1110 (split('\\n').length metric; +10, was already exactly at the 1100 frozen cap). Adds the WinGet-installed Claude Code fallback path (%LOCALAPPDATA%\\Microsoft\\WinGet\\Packages\\Anthropic.ClaudeCode_Microsoft.Winget.Source_8wekyb3d8bbwe\\claude.exe) to getKnownToolPaths() alongside the two sibling Claude Code paths, so WinGet installs are auto-detected without CLI_CLAUDE_BIN. The package folder name (62 chars) forces Prettier's 100-char width to break the path.join call across the full 10-line multi-arg form used elsewhere in this same function for long paths; irreducible without changing the shared getKnownToolPaths() structure. Covered by the PR's own regression test (tests/unit/cli-runtime-detection.test.ts, win32-gated).",
"_rebaseline_2026_07_03_review_prs_release_green": "Release-green unblock (2026-07-03, /review-prs): the quality.yml fast-gates job was base-red for EVERY PR->release from growth inherited via already-merged PRs on the release tip — no offending PR branch left to fix in-place. Prod frozen raised: ApiManagerPageClient.tsx 3017->3058, OAuthModal.tsx 969->989, cliRuntime.ts 1090->1100, webProvidersA.ts 805->809. Test frozen raised: deepseek-web.test.ts 1081->1092. Real sizes (check-file-size.mjs reported). These stay frozen (cannot grow further); structural shrink tracked under decomposition roadmap #3501; the release captain's rebaseline-at-release supersedes this note. Bundled with the #5695 quick-start test regex fix (multi-line <Link> tolerance) in the same release-green PR.",
"_rebaseline_2026_07_02_5798_release_green": "Release-green unblock #5798 / PR #5896 (2026-07-02): the quality.yml fast-gates job was base-red for EVERY PR->release (whole queue failing), from growth inherited via already-merged PRs — no offending PR branch left to fix. Prod frozen raised: AddApiKeyModal.tsx 869->905, providerPageHelpers.ts 996->1021, RequestLoggerV2.tsx 1316->1553, src/sse/services/auth.ts 2403->2405, antigravity.ts 1806->1813, base.ts 1502->1536 (1533 inherited + 3 lines from this PR's own typecheck:core fix in resolveBaseUrl), advancedTools.ts 1118->1120, accountFallback.ts 1783->1790, openai-to-kiro.ts 842->853, openai-responses.ts 1035->1092, stream.ts 2710->2727; new-above-cap frozen: webProvidersA.ts 805, tokenHealthCheck.ts 830. Test frozen raised: cc-compatible-provider 1179->1217, translator-openai-to-kiro 999->1088, web-cookie-providers-new 827->845; new-above-cap: response-sanitizer.test.ts 906. These files remain frozen (cannot grow further); the release captain's rebaseline-at-release supersedes this note.",
@@ -195,7 +196,7 @@
"_rebaseline_pr4613_compatible_provider_groups": "Reconcile #4613 already-merged growth: providers-page-utils.test.ts 1004->1052 (+48, buildCompatibleProviderGroups partition unit test). Fast-gate PR->release does not run check:file-size, so this surfaced post-merge.",
"tests/integration/chat-pipeline.test.ts": 2077,
"tests/integration/chatcore-compression-integration.test.ts": 1448,
"tests/integration/skills-pipeline.test.ts": 1006,
"tests/integration/skills-pipeline.test.ts": 1009,
"tests/unit/account-fallback-service.test.ts": 2032,
"tests/unit/adobe-firefly.test.ts": 1477,
"tests/unit/batch_api.test.ts": 1721,

View File

@@ -82,9 +82,10 @@
"tightenSlack": 10
},
"openapiCoverage.pct": {
"value": 38.4,
"value": 39,
"direction": "up",
"eps": 0.5,
"_tighten_2026_08_28_v3851_eslint_refreeze": "38.4 -> 39. Aperto EXIGIDO pelo step --require-tighten do job No new ESLint warnings na PR #11955 (release/v3.8.51): assim que o ESLint voltou a medir 0/0, o ratchet passou a cobrar o aperto. 39 = valor medido pelo collect-metrics do CI no run 33213844112 e reproduzido numa sala limpa da ponta 777d9d1629 (clone --depth 1 + npm ci do lockfile). A cobertura melhorou porque as rotas novas do ciclo entraram documentadas em docs/openapi.yaml; nenhuma rota tocada nesta PR. Aperto = gate mais ESTRITO, nunca mascaramento.",
"_rebaseline_2026_08_21_v3850_cycle_drift": "39.2 -> 38.4. Measured locally and in CI collect-metrics on release/v3.8.50 (260/677 implemented routes documented). Cycle added internal/dashboard routes faster than docs/openapi.yaml; documenting LOCAL_ONLY catch-all and service-management paths in the public spec would be gaming (same class as v3.8.34/v3.8.39/v3.8.47). This PR (#10988) adds 0 API routes.",
"_tighten_2026_08_06_v3850_sweepreds": "38.0 -> 39.2 (aperto EXIGIDO pelo step 'Require-tighten (blocking)', que estava vermelho em ~60 PRs abertas de release/v3.8.50 — base-red herdado, nao defeito das PRs). A cobertura melhorou no ciclo porque as rotas novas entraram documentadas. 39.2 = valor medido pelo CI Quality Ratchet no run 31088889488; o tip puro 2ddbbc61a6 mede 39.3 localmente (npm run check:openapi-coverage: 247/628 rotas), entao 39.2 e o valor conservador dos dois. Aperto = gate mais ESTRITO, nunca mascaramento.",
"_tighten_2026_07_04_v3844_release": "36.9 -> 39.3 (aperto exigido pelo --require-tighten no PR de release #5925). A cobertura OpenAPI melhorou no ciclo (9 rotas documentadas em 8fb020676 + as rotas novas de #5939/#5817/#6034/#5998 documentadas junto das features). 39.3 = valor medido pelo CI Quality Ratchet no run 28708141003 (tip 00c55afcb).",

View File

@@ -1,12 +1,12 @@
---
title: "Release Checklist"
version: 3.8.40
lastUpdated: 2026-06-28
version: 3.8.51
lastUpdated: 2026-08-28
---
# Release Checklist
> **Last updated:** 2026-06-28 — v3.8.40
> **Last updated:** 2026-08-28 — v3.8.51
> Streamlined release flow that leverages Claude Code skills for automation.
>
> **Keep the queue/branch green between releases:** see [RELEASE_GREEN.md](./RELEASE_GREEN.md)
@@ -37,7 +37,21 @@ npm run test:e2e # optional but recommended
/capture-release-evidences-cc
```
## npm Staged Publishing (default since v3.8.49 — WS1.3/D2)
## npm Trusted Publishing (default since v3.8.51) — staged on request, direct as fallback
`npm-publish.yml` publishes through **npm Trusted Publishing (OIDC)** by default: the
`stage-npm` job (github-hosted) exchanges GitHub's id-token for a short-lived npm
credential for that run — no long-lived npm token in the repository secrets, no 2FA prompt, provenance attached.
That is the bypass npm sanctions now that tokens which skip 2FA are being retired;
it restores the fully automatic flow the project had up to v3.8.48 while keeping the
WS1.3 guarantee (a leaked token cannot publish alone — there is no token).
**One-time setup (owner):** npmjs.com → package `omniroute` → Settings → *Trusted
Publisher* → GitHub: owner `diegosouzapw`, repo `OmniRoute`, workflow `npm-publish.yml`
(environment: none). Until that exists, the automatic step fails with `ENEEDAUTH`:
re-dispatch with `publish_mode=staged` (below) or `direct`.
### Staged publishing (on request — `publish_mode=staged`)
The npm-publish workflow no longer publishes directly: it boots the packed tarball
(`check:pack-boot`) and then runs `npm stage publish` — the exact bytes are parked on

View File

@@ -4,32 +4,75 @@ title: Self-Hosted Runner Box Operations
# Self-Hosted Runner Box Operations (.113 pool)
The self-hosted pool (`self-hosted, omni-release` labels) runs on the 16 GB box at
`192.168.0.113`. Two failure modes recurred on release days and were, until v3.8.49,
manual discipline; the **janitor script codifies them** (WS3.3 of the quality plan):
The self-hosted pool (`self-hosted, omni-release` on all eight runners; `omni-build` on two) runs on the **.113** box.
Measured 2026-08-28 (v3.8.50 postmortem, Parte III):
1. **Orphaned temp/work dirs** filling the disk → disk-full SQLite errors mid-job.
2. **>4 concurrent runners** → OOM-killed jobs (8-wide killed jobs twice on the
v3.8.47 release day; 4-wide is the proven ceiling).
| resource | value | what it means for scheduling |
| --------- | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| RAM / CPU | **31 GB / 32 cores** (was 16 GB when this doc was first written) | one `next-build` peaks at **~14 GB** → 2 concurrent heavy builds saturate the box, 3 take it down (2026-08-28 06:42Z: load 56, two jobs lost) |
| swap | 15 GB | it swapped its way through the v3.8.50 publish; pressure shows in `/proc/pressure/memory` |
| `/tmp` | **12 GB tmpfs = RAM** | anything parked there is memory; leftovers are swept after 3 h |
| disk | 188 GB | `_work` checkouts of 8 runners reach ~70 GB with no cap |
| runners | **6 listeners**: 4 OmniRoute (2 `omni-build` + 2 `omni-light`) + OmniHeuris + OmniMind | all share the memory above; `omniroute-113-3/-4/-7/-8` are disabled (`systemctl enable --now` brings one back) |
## Install the janitor (one-time, on the box)
```bash
sudo mkdir -p /opt/omniroute-ops
sudo cp scripts/ops/runner-janitor.sh /opt/omniroute-ops/
sudo chmod +x /opt/omniroute-ops/runner-janitor.sh
( sudo crontab -l 2>/dev/null; echo '*/30 * * * * /opt/omniroute-ops/runner-janitor.sh >> /var/log/runner-janitor.log 2>&1' ) | sudo crontab -
scp scripts/ops/runner-janitor.sh root@192.168.0.113:/opt/omniroute-ops/runner-janitor.sh
ssh root@192.168.0.113 'chmod +x /opt/omniroute-ops/runner-janitor.sh; apt-get install -y lsof'
# cron (root): every 30 min, log to /var/log/runner-janitor.log
*/30 * * * * MAX_ACTIVE_RUNNERS=4 /opt/omniroute-ops/runner-janitor.sh >> /var/log/runner-janitor.log 2>&1
```
What it does every 30min: sweeps runner temp leftovers older than 24h, alerts at
≥85% root-disk usage, and alerts when more than the runner ceiling (default 4, tunable
via the script's own environment) of `Runner.Listener` processes are up. Alerts land in `/var/log/runner-janitor.log`
with a non-zero exit (grep for `⚠`).
`lsof` is required: the janitor proves a path is idle with one snapshot of open
files before removing it, and without the tool it removes nothing and says so
(exit 1). Try any change with `--dry-run` first — it prints exactly what it would
do and touches nothing.
What it does every run: sweeps our own leftovers (`runner-*`, `omniroute-*`,
`next-build*`, `e2e-build.tar.gz`) after **3 h on tmpfs** and 24 h on disk
`_work/_temp`; kills a `next-build` older than 75 min (no job runs that long — on
2026-08-27 one ran 70 min after GitHub had declared its job lost); prunes 48 h-old
checkouts of runners whose unit is **stopped**; alerts on disk ≥ 85 %, memory PSI
`full/avg60` ≥ 10 %, and more listeners than `MAX_ACTIVE_RUNNERS` (with an
omniroute/other breakdown). Exit 1 = attention needed; read the log.
## Runner units: KillMode
The runner's default `KillMode=process` leaves `Runner.Worker → npm → next-build`
alive when a unit is stopped or restarted — an orphan build keeps eating RAM and
CPU with no job attached. Every OmniRoute unit carries a drop-in
(`/etc/systemd/system/actions.runner.diegosouzapw-OmniRoute.<name>.service.d/10-killmode.conf`)
with `KillMode=mixed`: SIGTERM to the listener first, SIGKILL to the whole cgroup at
`TimeoutStop`. It takes effect on the unit's next restart — restart **one runner at
a time, only when idle**, with the idle check and the restart in the same command.
## Operating rules
- **Ceiling: 4 runners** on the 16 GB box. Runners 58 stay STOPPED except for
explicit off-peak experiments — never during a release window.
- Stopping a runner mid-job cancels the job (observed live): `systemctl stop`
only when its runner is idle (`Runner.Listener` without a `Runner.Worker` child).
- **Heavy-build ceiling: 2 at a time — enforced by label.** Every job that runs a
`next build` (`ci.yml` `build`, `npm-publish.yml` `publish`, both `nightly-release-green`
validations) targets `[self-hosted, omni-build]`, and only **two** runners carry that
label (`omniroute-113-5`, `omniroute-113-6`, added through the runners API — no
re-registration). The other six keep `omni-release` and take nothing heavy; GitHub
queues a third build instead of the kernel killing one. Pair with the `heavy-build-*`
concurrency lanes in `ci.yml`. To add capacity, label another runner — never raise
the count past what 31 GB holds (one next-build ≈ 1416 GB).
- **Light pool: `omni-light` (2026-08-29, #11965).** `omniroute-113` and `omniroute-113-2` carry
`omni-light` for jobs that need a backend-only `next build` (~56 GB) but not a full one: the
nightly Schemathesis, promptfoo, garak and axe-a11y jobs. They ran on the hosted 7 GB runner and
died on `release/v3.8.51` with nobody watching. Worst case on the box is 2 heavy + 2 light ≈
30 + 12 GB — over 31 GB of RAM, inside the 16 GB of swap; the real fix for headroom is more RAM
on the Proxmox VM (`tomni-proxmox-113`), which turns the label ceilings into 3 heavy + 2 light.
- **Fewer listeners on purpose.** Four OmniRoute units were disabled on 2026-08-29 — with only
`ci.yml` `Build` and the nightlies using the box, 8 listeners were idle and each extra one is a
potential 14 GB tenant. The janitor ceiling is 4 (`MAX_ACTIVE_RUNNERS=4` in cron).
- **Never clean `/tmp` or `_work` by hand while any runner is busy.** A
check-then-delete with a gap between the two is how a live Build job lost its
`_work` on 2026-08-27. The janitor does the check and the removal in one step;
let it.
- Stopping a runner mid-job cancels the job (observed live): `systemctl stop` only
when its listener has no `Runner.Worker` child — and do it in one command.
- Workflows must not park artefacts in `/tmp` (it is RAM). Download to
`$RUNNER_TEMP` (on disk, per runner) — the 1.3 GB `next-build` artefact took 2732
minutes to land on the tmpfs and 2 minutes to upload from disk.
- The `.15` VPS is homologation-only — never runs CI runners.

View File

@@ -130,4 +130,4 @@ Retirement regression guards live in:
- `tests/unit/chatgpt-web-runtime-block.test.ts`
- `tests/unit/chatgpt-web-image-handler-retirement.test.ts`
- `tests/unit/chatgpt-web-source-retirement.test.ts`
- `tests/unit/migration-163-retire-chatgpt-web.test.ts`
- `tests/unit/migration-168-retire-chatgpt-web.test.ts`

View File

@@ -59,6 +59,9 @@ These **must** be set before the first run. Without them, the application will e
| `API_KEY_SECRET` | **Yes** | _(none)_ | `src/lib/db/apiKeys.ts` | AES encryption key for API key values at rest in SQLite. Generate with `openssl rand -hex 32`. |
| `INITIAL_PASSWORD` | **Yes** | `CHANGEME` | Bootstrap script | Sets the initial admin dashboard password (matches `.env.example` default — kept obviously insecure to force a change). **Change before first use.** After login, change via Dashboard → Settings → Security. |
| `OMNIROUTE_WS_BRIDGE_SECRET` | **Yes** (production) | _(unset)_ | `src/app/api/internal/codex-responses-ws/route.ts` | Shared secret for the internal Codex Responses WebSocket bridge. Authenticates bridge requests between the Electron/browser WS relay and OmniRoute. ⚠️ **REQUIRED in production — when unset, all WS bridge requests are rejected.** Generate with `openssl rand -base64 32`. |
| `OMNIROUTE_SW_BUILD_ID` | No | _(git SHA)_ | `next.config.mjs`, `scripts/build/assembleStandalone.mjs` | Explicit service-worker cache-busting id for the PWA shell (#11779); first in the resolution chain. |
| `SOURCE_VERSION` | No | _(unset)_ | `next.config.mjs`, `scripts/build/assembleStandalone.mjs` | Second in the chain — set by PaaS builders (e.g. Heroku-style) as the deployed commit. |
| `NEXT_PUBLIC_SW_BUILD_ID` | No | _(derived)_ | `src/shared/components/PwaRegister.tsx` | Build-time public value the client uses to register `/sw.js?v=…`; derived from the two above, then the git SHA. |
| `OMNIROUTE_PEER_STAMP_TOKEN` | No (auto) | _(auto per boot)_ | `src/server/authz/policies/management.ts` | Per-process secret proving the trusted peer-IP stamp came from OmniRoute's own HTTP server (`scripts/dev/peer-stamp.mjs`). The authz middleware trusts request locality (loopback/LAN gating of LOCAL_ONLY routes) only when the stamp carries this token. Auto-generated each boot — leave unset; only pin it for multi-process setups that must share the stamp. |
### Generation Commands

View File

@@ -265,7 +265,7 @@ export function orderHeaders(
* Apply a CLI fingerprint to headers and body.
* Returns { headers, bodyString } with the correct ordering.
*/
function stripInternalBodyFields(body: unknown): unknown {
export function stripInternalBodyFields(body: unknown): unknown {
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
const record = body as Record<string, unknown>;

View File

@@ -30,7 +30,7 @@ import {
addParamToBlocklist,
isAutoLearnGloballyEnabled,
} from "@/lib/db/paramFilters";
import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts";
import { applyFingerprint, isCliCompatEnabled, stripInternalBodyFields } from "../config/cliFingerprints.ts";
import { supportsClaudeMaxEffort, supportsXHighEffort } from "../config/providerModels.ts";
import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts";
import {
@@ -582,6 +582,8 @@ export class BaseExecutor {
if (cloned[key] === "") delete cloned[key];
}
stripInternalBodyFields(cloned);
return cloned;
}
@@ -1393,6 +1395,7 @@ export class BaseExecutor {
);
}
stripInternalBodyFields(transformedBody);
let bodyString = JSON.stringify(transformedBody);
const shouldFingerprint =

View File

@@ -2808,7 +2808,12 @@ export async function handleChatCore({
log?.debug?.("PARAMS", `Renamed max_completion_tokens to max_tokens for ${model}`);
}
stripStore(translatedBody, provider, targetFormat);
stripStore(
translatedBody,
provider,
targetFormat,
credentials?.providerSpecificData as Record<string, unknown> | null | undefined
);
// Chat clients may send stream_options.include_usage, but OpenAI Responses
// upstreams (including Azure AI Foundry /responses) reject stream_options.

View File

@@ -26,8 +26,21 @@ export function usesClaudeBridge(
export function stripStore(
body: Record<string, unknown>,
provider: string,
targetFormat: string
targetFormat: string,
providerSpecificData?: unknown
): void {
if (provider.startsWith("openai-compatible-") && targetFormat === FORMATS.OPENAI_RESPONSES) {
const psd =
providerSpecificData && typeof providerSpecificData === "object"
? (providerSpecificData as Record<string, unknown>)
: undefined;
if (psd?.openaiStoreEnabled === true) {
return;
}
body.store = false;
return;
}
const supportsStore =
provider === "openai" ||
(provider === "agentrouter" && targetFormat === FORMATS.OPENAI_RESPONSES);

View File

@@ -226,45 +226,6 @@ export const NANO_SIZE_MAP: Record<string, Record<string, { width: number; heigh
},
};
const GPT_SIZE_MAP: Record<string, Record<string, { width: number; height: number }>> = {
"1K": {
"1:1": { width: 1024, height: 1024 },
"5:4": { width: 1120, height: 896 },
"9:16": { width: 720, height: 1280 },
"21:9": { width: 1456, height: 624 },
"16:9": { width: 1280, height: 720 },
"4:3": { width: 1152, height: 864 },
"3:2": { width: 1248, height: 832 },
"4:5": { width: 896, height: 1120 },
"3:4": { width: 864, height: 1152 },
"2:3": { width: 832, height: 1248 },
},
"2K": {
"1:1": { width: 2048, height: 2048 },
"5:4": { width: 2240, height: 1792 },
"9:16": { width: 1440, height: 2560 },
"21:9": { width: 3024, height: 1296 },
"16:9": { width: 2560, height: 1440 },
"4:3": { width: 2304, height: 1728 },
"3:2": { width: 2496, height: 1664 },
"4:5": { width: 1792, height: 2240 },
"3:4": { width: 1728, height: 2304 },
"2:3": { width: 1664, height: 2496 },
},
"4K": {
"1:1": { width: 2880, height: 2880 },
"5:4": { width: 3200, height: 2560 },
"9:16": { width: 2160, height: 3840 },
"21:9": { width: 3696, height: 1584 },
"16:9": { width: 3840, height: 2160 },
"4:3": { width: 3264, height: 2448 },
"3:2": { width: 3504, height: 2336 },
"4:5": { width: 2560, height: 3200 },
"3:4": { width: 2448, height: 3264 },
"2:3": { width: 2336, height: 3504 },
},
};
export const PIXEL_SIZE_TO_RATIO: Record<string, string> = {
"1024x1024": "1:1",
"1536x1536": "1:1",

View File

@@ -173,26 +173,6 @@ const RAW_CONFIGS: TokenExtractionConfig[] = [
}
),
// ── Volcano Engine Ark Console ───────────────────────────
config(
"volcengine-console",
"Volcano Engine Ark Console",
"https://console.volcengine.com/ark/region:cn-beijing/subscription/coding-plan",
"https://console.volcengine.com",
[
{ type: "cookie", name: "digest", domain: ".volcengine.com" },
{ type: "cookie", name: "AccountID", domain: ".volcengine.com" },
{ type: "cookie", name: "csrfToken", domain: ".volcengine.com" },
{ type: "cookie", name: "userInfo", domain: ".volcengine.com" },
],
"Log in to the Volcano Engine Ark console. The console session is used to discover Agent/Coding Plan API keys and live quota usage.",
{
cookieDomain: ".volcengine.com",
successUrlPattern: /console\.volcengine\.com\/ark/i,
pollingConfig: { timeout: 300_000, minLoginTime: 3000 },
}
),
// ── Kimi Web ──────────────────────────────────────────────
config(
"kimi-web",

View File

@@ -194,50 +194,75 @@ function stopTextBlock(state, results) {
state.textBlockStarted = false;
}
// Convert OpenAI stream chunk to Claude format
export function openaiToClaudeResponse(chunk, state) {
if (!chunk || !chunk.choices?.[0]) return null;
// Harvest the upstream usage block from any chunk, including trailing
// usage-only chunks that carry `choices: []` (#11817).
function trackUsageFromChunk(chunk, state) {
if (!chunk.usage || typeof chunk.usage !== "object") return;
const promptTokens =
typeof chunk.usage.prompt_tokens === "number" ? chunk.usage.prompt_tokens : 0;
const outputTokens =
typeof chunk.usage.completion_tokens === "number" ? chunk.usage.completion_tokens : 0;
const results = [];
const choice = chunk.choices[0];
const delta = choice.delta;
// Extract cache tokens from prompt_tokens_details
const cachedTokens = chunk.usage.prompt_tokens_details?.cached_tokens;
const cacheCreationTokens = chunk.usage.prompt_tokens_details?.cache_creation_tokens;
const cacheReadTokens = typeof cachedTokens === "number" ? cachedTokens : 0;
const cacheCreateTokens = typeof cacheCreationTokens === "number" ? cacheCreationTokens : 0;
// Track usage from OpenAI chunk if available
if (chunk.usage && typeof chunk.usage === "object") {
const promptTokens =
typeof chunk.usage.prompt_tokens === "number" ? chunk.usage.prompt_tokens : 0;
const outputTokens =
typeof chunk.usage.completion_tokens === "number" ? chunk.usage.completion_tokens : 0;
// input_tokens = prompt_tokens - cached_tokens - cache_creation_tokens
// Because OpenAI's prompt_tokens includes all prompt-side tokens
const inputTokens = promptTokens - cacheReadTokens - cacheCreateTokens;
// Extract cache tokens from prompt_tokens_details
const cachedTokens = chunk.usage.prompt_tokens_details?.cached_tokens;
const cacheCreationTokens = chunk.usage.prompt_tokens_details?.cache_creation_tokens;
const cacheReadTokens = typeof cachedTokens === "number" ? cachedTokens : 0;
const cacheCreateTokens = typeof cacheCreationTokens === "number" ? cacheCreationTokens : 0;
state.usage = {
input_tokens: inputTokens,
output_tokens: outputTokens,
};
// input_tokens = prompt_tokens - cached_tokens - cache_creation_tokens
// Because OpenAI's prompt_tokens includes all prompt-side tokens
const inputTokens = promptTokens - cacheReadTokens - cacheCreateTokens;
state.usage = {
input_tokens: inputTokens,
output_tokens: outputTokens,
};
// Add cache_read_input_tokens if present
if (cacheReadTokens > 0) {
state.usage.cache_read_input_tokens = cacheReadTokens;
}
// Add cache_creation_input_tokens if present
if (cacheCreateTokens > 0) {
state.usage.cache_creation_input_tokens = cacheCreateTokens;
}
// Note: completion_tokens_details.reasoning_tokens is already included in output_tokens
// No need to add separately as Claude expects total output_tokens
// Add cache_read_input_tokens if present
if (cacheReadTokens > 0) {
state.usage.cache_read_input_tokens = cacheReadTokens;
}
// Add cache_creation_input_tokens if present
if (cacheCreateTokens > 0) {
state.usage.cache_creation_input_tokens = cacheCreateTokens;
}
// Note: completion_tokens_details.reasoning_tokens is already included in output_tokens
// No need to add separately as Claude expects total output_tokens
}
// Convert OpenAI stream chunk to Claude format
export function openaiToClaudeResponse(chunk, state) {
if (!chunk && !state.pendingClaudeFinishChoice) return null;
const results = [];
const chunkUsage = chunk?.usage;
const hasChunkUsage = chunkUsage && typeof chunkUsage === "object";
// Usage must be harvested BEFORE the choices guard: many OpenAI-compatible
// upstreams (Fireworks, vLLM, Together, …) deliver the authoritative usage
// block — including prompt_tokens_details.cached_tokens — on a trailing
// usage-only chunk shaped `{"choices":[],"usage":{...}}`. Returning early on
// that chunk discarded the real numbers and left downstream accounting on
// OmniRoute's own tokenizer estimate (#11817).
//
// Harvesting alone is not enough: if the finish_reason chunk arrives BEFORE
// this trailing usage chunk (the normal order for these upstreams), the
// finish block below fires immediately and emits message_delta with
// whatever state.usage held at that moment — zero/stale, since the real
// trailing chunk hasn't been seen yet. The finish deferral below
// (pendingClaudeFinishChoice) holds the terminal emission open until either
// real usage has arrived or a genuine flush forces it, so the message_delta
// actually sent to the client carries the correct numbers (#11817 follow-up).
if (chunk) trackUsageFromChunk(chunk, state);
const chunkChoice = chunk?.choices?.[0];
const flushingPendingFinish = !chunkChoice && Boolean(state.pendingClaudeFinishChoice);
const choice = chunkChoice || state.pendingClaudeFinishChoice;
if (!choice) return null;
if (flushingPendingFinish) state.pendingClaudeFinishChoice = null;
const delta = choice.delta;
// First chunk - ALWAYS send message_start first
if (!state.messageStartSent) {
state.messageStartSent = true;
@@ -489,6 +514,11 @@ export function openaiToClaudeResponse(chunk, state) {
// guard therefore misfired and silently dropped the terminal message_delta/message_stop
// for Responses→Claude streams (#5828 regression).
if (choice.finish_reason && !state.claudeFinishEmitted) {
if (!hasChunkUsage && !flushingPendingFinish) {
state.pendingClaudeFinishChoice = choice;
return results.length > 0 ? results : null;
}
state.claudeFinishEmitted = true;
stopThinkingBlock(state, results);
stopTextBlock(state, results);

View File

@@ -49,13 +49,15 @@ export function socksConnectorWithFamily(
const isDisabled = connectTimeout === 0;
// SOCKS lib: 0 throws (isValidTimeoutValue: value>0) and undefined → DEFAULT_TIMEOUT 30s;
// undici: 0 disables (core/util.js: if (!opts.timeout) return noop), undefined → 10s. Divergence intentional.
const handshakeTimeout = isDisabled ? undefined : (connectTimeout ?? resolveSocksHandshakeTimeoutMs());
const handshakeTimeout = isDisabled
? undefined
: (connectTimeout ?? resolveSocksHandshakeTimeoutMs());
const tlsTimeout = connectTimeout;
// Sequential budget: both phases bounded by the same connectTimeout → wall-time up to 60s for https
// (vs 30s direct). Shared-deadline alternative rejected as unjustified complexity.
const build = _buildConnectorForTest ?? buildConnector;
const undiciConnect = build(
tlsTimeout !== undefined ? ({ ...tlsOpts, timeout: tlsTimeout } as any) : tlsOpts
tlsTimeout !== undefined ? { ...tlsOpts, timeout: tlsTimeout } : tlsOpts
);
const socketOptions = buildSocksFamilySocketOptions(family);
return async (options, callback) => {
@@ -69,7 +71,7 @@ export function socksConnectorWithFamily(
const r = await SocksClient.createConnection({
command: "connect",
proxy,
timeout: handshakeTimeout as any,
timeout: handshakeTimeout,
destination: { host: hostname, port: resolvePort(protocol, port) },
existing_socket: httpSocket as never,
socket_options: socketOptions as never,
@@ -97,6 +99,6 @@ export function createSocksDispatcherWithFamily(
};
return new Agent({
...rest,
connect: socksConnectorWithFamily(proxy, family, connect as any, connectTimeout as any),
connect: socksConnectorWithFamily(proxy, family, connect, connectTimeout),
});
}

View File

@@ -0,0 +1,101 @@
#!/usr/bin/env node
/**
* One-shot codemod (#11966): give every recursive temp-dir removal in tests the retry
* options Node already supports, so a WAL/backup/worker still writing into the directory
* turns into a retried delete instead of a red shard:
*
* rmSync(dir, { recursive: true, force: true })
* → rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
*
* Applies to `rmSync(`, `fs.rmSync(`, `rm(` / `fs.rm(` / `fs.promises.rm(` (async) and
* `rmdirSync(` calls whose option object literal contains `recursive: true` and no
* `maxRetries`. Only the option object is touched — call sites, assertions and imports are
* left as they are. Usage: node scripts/ad-hoc/codemod-rm-maxretries.mjs [dir=tests]
*/
import fs from "node:fs";
import path from "node:path";
const root = process.argv[2] || "tests";
const CALL = /\b(?:fs\.promises\.|fsp\.|fs\.|promises\.)?(?:rmSync|rmdirSync|rm)\(/g;
let files = 0;
let sites = 0;
function walk(dir, out = []) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) {
if (e.name === "node_modules" || e.name === "fixtures") continue;
walk(p, out);
} else if (/\.(ts|tsx|mts|cts|js|mjs|cjs)$/.test(e.name)) out.push(p);
}
return out;
}
// Find the closing brace of the option object literal that starts at `open`.
function objectEnd(src, open) {
let depth = 0;
for (let i = open; i < src.length; i++) {
const c = src[i];
if (c === "{") depth++;
else if (c === "}") {
depth--;
if (depth === 0) return i;
} else if (c === '"' || c === "'" || c === "`") {
const q = c;
i++;
while (i < src.length && src[i] !== q) {
if (src[i] === "\\") i++;
i++;
}
}
}
return -1;
}
for (const file of walk(root)) {
const src = fs.readFileSync(file, "utf8");
let out = "";
let last = 0;
let touched = 0;
for (const m of src.matchAll(CALL)) {
const callStart = m.index + m[0].length;
// Locate the option object: the first `{` before the call's closing paren at depth 0.
let depth = 0;
let objOpen = -1;
for (let i = callStart; i < src.length; i++) {
const c = src[i];
if (c === "(" || c === "[") depth++;
else if (c === ")" || c === "]") {
if (depth === 0) break;
depth--;
} else if (c === "{" && depth === 0) {
objOpen = i;
break;
}
}
if (objOpen === -1) continue;
const objClose = objectEnd(src, objOpen);
if (objClose === -1) continue;
const obj = src.slice(objOpen, objClose + 1);
if (!/\brecursive:\s*true\b/.test(obj) || /\bmaxRetries\b/.test(obj)) continue;
// Insert before the closing brace, respecting an existing trailing comma / newline.
const inner = obj.slice(1, -1);
const trimmed = inner.replace(/\s+$/, "");
const trailing = inner.slice(trimmed.length);
const sep = trimmed.endsWith(",") ? " " : ", ";
const multiline = /\n/.test(trailing);
const insert = multiline
? `${trimmed}${trimmed.endsWith(",") ? "" : ","}\n${trailing.replace(/\n$/, "")} maxRetries: 5,\n retryDelay: 100,${trailing}`
: `${trimmed}${sep}maxRetries: 5, retryDelay: 100${trailing}`;
out += src.slice(last, objOpen + 1) + insert;
last = objClose;
touched++;
}
if (touched) {
out += src.slice(last);
fs.writeFileSync(file, out);
files++;
sites += touched;
}
}
console.log(`[codemod-rm-maxretries] ${sites} call site(s) in ${files} file(s) under ${root}`);

View File

@@ -173,7 +173,7 @@ function arg(name, fallback = "") {
}
function git(root, args) {
return execFileSync("git", args, { cwd: root, encoding: "utf8" });
return execFileSync("git", args, { cwd: root, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
}
function changedEntries(root, base) {

View File

@@ -42,6 +42,7 @@ import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { findProvenanceOnSelfHosted, formatProvenanceFinding } from "./lib/provenanceRunner.mjs";
const ROOT = process.cwd();
const WORKFLOWS_DIR = path.join(ROOT, ".github", "workflows");
@@ -275,6 +276,23 @@ export function runZizmor(workflowsDir) {
// Main
// ---------------------------------------------------------------------------
/**
* Hard rule (not a lint count): `--provenance` inside a job that runs on a
* self-hosted runner. npm answers 422 at the registry, and in v3.8.50 that
* answer only came after the tag, the GitHub Release and the Docker images were
* already out. Blocks under --strict AND --ratchet (the CI mode); plain mode
* reports it like everything else.
* @param {string[]} files absolute workflow paths
*/
export function runProvenanceRunnerCheck(files) {
const findings = [];
for (const file of files) {
const text = fs.readFileSync(file, "utf8");
findings.push(...findProvenanceOnSelfHosted(text, path.relative(ROOT, file)));
}
return findings;
}
function main() {
const hasActionlint = isBinaryAvailable("actionlint");
const hasZizmor = isBinaryAvailable("zizmor");
@@ -350,6 +368,16 @@ function main() {
}
}
const provenanceFindings = runProvenanceRunnerCheck(workflowFiles);
if (provenanceFindings.length > 0) {
console.error(
`[check-workflows] provenance×self-hosted: ${provenanceFindings.length} finding(s) — HARD RULE:`
);
provenanceFindings.forEach((f) => console.error(` ${formatProvenanceFinding(f)}`));
} else if (!QUIET) {
console.log("[check-workflows] provenance×self-hosted: OK (0 findings)");
}
const total = actionlintCount + zizmorCount;
process.stdout.write(`workflowFindings=${total}\n`);
process.stdout.write(`actionlintFindings=${actionlintCount}\n`);
@@ -357,6 +385,15 @@ function main() {
// Read this line with the count above: a finding total is only reproducible against the
// version that produced it. See zizmorVersion().
process.stdout.write(`zizmorVersion=${hasZizmor ? zizmorVersion() : "absent"}\n`);
process.stdout.write(`provenanceRunnerFindings=${provenanceFindings.length}\n`);
if ((STRICT || RATCHET) && provenanceFindings.length > 0) {
console.error(
`\n[check-workflows] FAIL — ${provenanceFindings.length} job(s) publish with --provenance from a self-hosted runner.\n` +
" npm rejects that with 422 at the registry. Move the upload step to a github-hosted job\n" +
" (see .github/workflows/npm-publish.yml `stage-npm` for the pattern)."
);
process.exit(1);
}
if (STRICT && total > 0) {
console.error(`\n[check-workflows] FAIL — ${total} workflow finding(s) total (--strict mode).`);

View File

@@ -0,0 +1,90 @@
/**
* scripts/check/lib/configExpiry.mjs
*
* Finds dated validity fields in JSON config packs so a test can fail BEFORE
* they lapse. Origin: config/alibaba-free-tier-allowlist.json carried
* `"validUntil": "2026-08-27"`; on 2026-08-28 the loader started (correctly)
* rejecting the pack and a test that asserted "the shipped pack loads" turned
* every PR and main red with no commit involved (#11866). A time bomb, not a
* regression — and the only kind of defect a diff review can never catch.
*
* Pure helpers; the repo-wide assertion lives in
* tests/unit/config-expiry-time-bomb.test.ts.
*/
import fs from "node:fs";
import path from "node:path";
export const EXPIRY_KEY =
/^(validUntil|valid_until|validTo|valid_to|expiresAt|expires_at|expiry|expires)$/;
const DAY_MS = 86_400_000;
/**
* Walks a parsed JSON value and returns every string-valued expiry field.
* @returns {{ file: string, keyPath: string, raw: string, expiresAt: number|null }[]}
*/
export function collectExpiryFields(value, file, keyPath = []) {
const out = [];
if (Array.isArray(value)) {
value.forEach((v, i) => out.push(...collectExpiryFields(v, file, [...keyPath, String(i)])));
return out;
}
if (!value || typeof value !== "object") return out;
for (const [key, v] of Object.entries(value)) {
const kp = [...keyPath, key];
if (EXPIRY_KEY.test(key) && typeof v === "string") {
const ms = Date.parse(v);
out.push({ file, keyPath: kp.join("."), raw: v, expiresAt: Number.isFinite(ms) ? ms : null });
} else if (v && typeof v === "object") {
out.push(...collectExpiryFields(v, file, kp));
}
}
return out;
}
/** @returns {"expired"|"expiring"|"ok"|"unparseable"} */
export function classifyExpiry(field, nowMs, warnDays = 7) {
if (field.expiresAt === null) return "unparseable";
if (field.expiresAt < nowMs) return "expired";
if (field.expiresAt < nowMs + warnDays * DAY_MS) return "expiring";
return "ok";
}
/** All *.json under dir, recursively, skipping node_modules. Sorted for stable output. */
export function walkJsonFiles(dir) {
const out = [];
const stack = [dir];
while (stack.length > 0) {
const current = stack.pop();
let entries;
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
continue;
}
for (const e of entries) {
const full = path.join(current, e.name);
if (e.isDirectory()) {
if (e.name !== "node_modules") stack.push(full);
} else if (e.isFile() && e.name.endsWith(".json")) {
out.push(full);
}
}
}
return out.sort();
}
/**
* Scans every JSON file under `dir`; `file` in the result is relative to `dir`
* with forward slashes, so allowlists can key on it portably.
*/
export function scanConfigExpiry(dir) {
return walkJsonFiles(dir).flatMap((f) => {
let parsed;
try {
parsed = JSON.parse(fs.readFileSync(f, "utf8"));
} catch {
return []; // not this scanner's job to validate JSON
}
return collectExpiryFields(parsed, path.relative(dir, f).split(path.sep).join("/"));
});
}

View File

@@ -0,0 +1,83 @@
/**
* scripts/check/lib/provenanceRunner.mjs
*
* npm refuses `--provenance` from a self-hosted runner:
*
* 422 Unprocessable Entity - Error verifying sigstore provenance bundle:
* Unsupported GitHub Actions runner environment: "self-hosted".
* Only "github-hosted" runners are supported when publishing with provenance.
*
* v3.8.50 hit this at the very end of a 76-minute publish job — after the tag,
* the GitHub Release and the Docker images were already public — because
* `USE_VPS_RUNNER` had been turned on (2026-08-02) with no release in between to
* surface it. The combination is greppable, so it must fail in CI the moment a
* workflow introduces it, not four weeks later at the registry.
*
* Pure: takes workflow YAML text, returns the offending (job, step) pairs.
*/
import { load as yamlLoad } from "js-yaml";
const SELF_HOSTED = /\bself-hosted\b/;
const EXPRESSION = /\$\{\{/;
// Lookahead, not \b: `--provenance-file=…` is a different flag (a pre-built
// bundle) and must not match — a word boundary sits between "e" and "-".
const PROVENANCE = /(^|\s)--provenance(?=\s|=|$)/m;
/**
* Classifies a job's `runs-on` value.
* @returns {"self-hosted"|"hosted"|"unknown"}
* "unknown" = an expression with no literal `self-hosted` in it (e.g.
* `${{ matrix.os }}`); the check does not guess, it skips.
*/
export function classifyRunsOn(runsOn) {
if (runsOn == null) return "unknown";
if (typeof runsOn === "string") {
if (SELF_HOSTED.test(runsOn)) return "self-hosted";
return EXPRESSION.test(runsOn) ? "unknown" : "hosted";
}
if (Array.isArray(runsOn)) {
return runsOn.some((v) => typeof v === "string" && SELF_HOSTED.test(v))
? "self-hosted"
: "hosted";
}
if (typeof runsOn === "object") {
// { group: ..., labels: ... } form
const labels = runsOn.labels;
return classifyRunsOn(Array.isArray(labels) ? labels : labels == null ? "" : String(labels));
}
return "unknown";
}
/**
* @param {string} yamlText
* @param {string} fileName used only for reporting
* @returns {{ file: string, job: string, step: string }[]}
*/
export function findProvenanceOnSelfHosted(yamlText, fileName = "<workflow>") {
let doc;
try {
doc = yamlLoad(yamlText);
} catch {
// actionlint owns syntax; an unparseable file is not this rule's finding.
return [];
}
const jobs =
doc && typeof doc === "object" && doc.jobs && typeof doc.jobs === "object" ? doc.jobs : {};
const findings = [];
for (const [jobName, job] of Object.entries(jobs)) {
if (!job || typeof job !== "object") continue;
if (classifyRunsOn(job["runs-on"]) !== "self-hosted") continue;
const steps = Array.isArray(job.steps) ? job.steps : [];
steps.forEach((step, i) => {
if (step && typeof step.run === "string" && PROVENANCE.test(step.run)) {
findings.push({ file: fileName, job: jobName, step: step.name || `#${i + 1}` });
}
});
}
return findings;
}
/** Human-readable line per finding, used by the CLI. */
export function formatProvenanceFinding(f) {
return `${f.file}: job "${f.job}", step "${f.step}" runs \`--provenance\` on a self-hosted runner — npm rejects that (422). Move the upload to a github-hosted job.`;
}

View File

@@ -1,53 +1,172 @@
#!/usr/bin/env bash
# runner-janitor — self-hosted runner box hygiene (WS3.3, v3.8.49 quality plan).
# runner-janitor — self-hosted runner box hygiene for the .113 pool.
#
# The .113 runner box has recurring failure modes that until now were manual
# discipline: orphaned tmpfs/work dirs filling the disk, and >4 concurrent
# runners OOM-killing jobs (16 GB box; incidents on the v3.8.47 release day).
# Install via cron on the box (see docs/ops/RUNNER_BOX.md):
# */30 * * * * /opt/omniroute-ops/runner-janitor.sh >> /var/log/runner-janitor.log 2>&1
# Runs from cron every 30 min (see docs/ops/RUNNER_BOX.md). It ACTS on what it
# can prove is safe and ALERTS on what needs an operator decision. Reads of
# "is this in use?" and the removal happen in the same command, never in two
# passes: a check-then-delete with a gap is how a live Build job lost its _work
# on 2026-08-27.
#
# Measured box (2026-08-28): 31 GB RAM, 32 cores, 15 GB swap, /tmp = 12 GB
# tmpfs (RAM!), 188 GB disk. A single `next-build` peaks at ~14 GB, so two
# concurrent heavy builds saturate the box and three take it down (06:42Z that
# day: load 56, two jobs lost). The v3.8.50 postmortem (Parte III) has the numbers.
#
# What it does, in order:
# 1) sweep stale artefacts our tooling leaves behind — tmpfs bases after 3 h
# (they hold RAM), disk _work/_temp bases after 24 h; only names we create,
# only when no process has them open
# 2) kill zombie builds: a `next-build` older than ZOMBIE_BUILD_MAX_MIN has no
# job attached (a real Build step measures ~26 min). On 2026-08-27 one ran
# 70 minutes after GitHub had already declared its job lost, eating 3.6 GB
# and a full core set. KillMode=mixed on the units covers systemctl
# stop/restart; this covers the lost-connection path.
# 3) prune 48 h-old checkouts under _work of runners whose unit is INACTIVE
# (stopped runners cannot be mid-job; active ones are never touched)
# 4) alert: root disk >= DISK_ALERT_PCT, memory PSI full/avg60 >= threshold,
# Runner.Listener count above the ceiling (with a per-project breakdown —
# the box also hosts OmniHeuris and OmniMind runners)
#
# Usage: runner-janitor.sh [--dry-run] [--help]
# Exit codes: 0 healthy · 1 attention needed (printed to stdout for the log).
set -euo pipefail
MAX_ACTIVE_RUNNERS="${MAX_ACTIVE_RUNNERS:-4}"
DISK_ALERT_PCT="${DISK_ALERT_PCT:-85}"
WORK_DIR_MAX_AGE_HOURS="${WORK_DIR_MAX_AGE_HOURS:-24}"
STATUS=0
echo "[janitor] $(date -u +%FT%TZ) start"
# 1) Sweep stale runner temp/work leftovers (>24h — no legitimate job runs that long).
# Hardened for a root cron on world-writable paths: never follow a symlinked base
# (a compromised runner could plant one), -P + -xdev so the sweep cannot traverse
# out of the filesystem, and patterns narrowed to names OUR tooling creates
# (no generic tmp* — unrelated system temp files are out of scope).
for base in /tmp /home/*/actions-runner*/_work/_temp; do
[ -d "$base" ] || continue
[ -L "$base" ] && { echo "[janitor] skip symlinked base: $base"; continue; }
find -P "$base" -xdev -maxdepth 1 \( -name 'runner-*' -o -name 'omniroute-*' \) \
! -type l -mmin +$((WORK_DIR_MAX_AGE_HOURS * 60)) -exec rm -rf {} + 2>/dev/null || true
DRY_RUN=0
for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=1 ;;
-h|--help)
sed -n '2,32p' "$0" | sed 's/^# \{0,1\}//'
exit 0 ;;
*) echo "unknown argument: $arg" >&2; exit 2 ;;
esac
done
echo "[janitor] stale temp sweep done"
# 2) Disk pressure — alert loudly before SQLITE_FULL kills jobs mid-run.
USAGE=$(df --output=pcent / | tail -1 | tr -dc '0-9')
if [ "$USAGE" -ge "$DISK_ALERT_PCT" ]; then
echo "[janitor] ⚠ ROOT DISK ${USAGE}% >= ${DISK_ALERT_PCT}% — clean before the next heavy run"
STATUS=1
MAX_ACTIVE_RUNNERS="${MAX_ACTIVE_RUNNERS:-8}"
DISK_ALERT_PCT="${DISK_ALERT_PCT:-85}"
TMPFS_MAX_AGE_HOURS="${TMPFS_MAX_AGE_HOURS:-3}"
WORK_TEMP_MAX_AGE_HOURS="${WORK_TEMP_MAX_AGE_HOURS:-24}"
WORK_CHECKOUT_MAX_AGE_HOURS="${WORK_CHECKOUT_MAX_AGE_HOURS:-48}"
ZOMBIE_BUILD_MAX_MIN="${ZOMBIE_BUILD_MAX_MIN:-75}"
ZOMBIE_BUILD_COMM="${ZOMBIE_BUILD_COMM:-next-build}"
PSI_FULL_AVG60_ALERT="${PSI_FULL_AVG60_ALERT:-10}"
# Overridable so the unit test can point everything at a fixture tree.
JANITOR_TMP_BASES="${JANITOR_TMP_BASES-/tmp}"
JANITOR_WORK_TEMP_BASES="${JANITOR_WORK_TEMP_BASES-/opt/actions-runner*/_work/_temp /home/*/actions-runner*/_work/_temp}"
JANITOR_RUNNER_DIRS="${JANITOR_RUNNER_DIRS-/opt/actions-runner*}"
JANITOR_PSI_FILE="${JANITOR_PSI_FILE:-/proc/pressure/memory}"
JANITOR_DF_PATH="${JANITOR_DF_PATH:-/}"
STATUS=0
say() { echo "[janitor] $*"; }
# "Is anything using this?" — ONE snapshot of every open path on the box
# (lsof -Fn), then a prefix match per candidate. `lsof +D <dir>` walks the whole
# tree instead and took minutes on a 5 GB leftover — unusable from cron. An
# absent lsof means "cannot prove idle": the sweep keeps the path and says so.
LSOF_BIN="${JANITOR_LSOF:-lsof}"
have_busy_tools() { command -v "$LSOF_BIN" >/dev/null 2>&1; }
SNAP=""
cleanup() { [ -n "$SNAP" ] && rm -f -- "$SNAP"; }
trap cleanup EXIT
# One lsof for the whole run (~13 s / 83k lines on the box), kept ONLY for the
# bases we sweep — 460 candidates grepping a re-printed 83k-line string was the
# slow part, not lsof itself.
snapshot_open_paths() {
have_busy_tools || return 0
SNAP=$(mktemp) || return 0
local prefixes="" b
for b in $JANITOR_TMP_BASES $JANITOR_WORK_TEMP_BASES; do [ -d "$b" ] && prefixes="$prefixes"$'\n'"$b/"; done
# -F n: one "n<path>" line per open file; -w: no warnings
"$LSOF_BIN" -w -Fn 2>/dev/null | sed -n 's/^n//p' | grep -F -f <(printf '%s' "$prefixes" | sed '/^$/d') > "$SNAP" 2>/dev/null || true
}
is_busy() {
local p="$1"
[ -n "$SNAP" ] && [ -s "$SNAP" ] || return 1
# exact path, or anything beneath it when it is a directory
grep -qxF -- "$p" "$SNAP" && return 0
[ -d "$p" ] && grep -qF -- "$p/" "$SNAP"
}
# sweep <base> <max-age-minutes>: only names our tooling creates, never through
# a symlinked base, never across a filesystem, and remove+check in one step.
sweep() {
local base="$1" max_min="$2" p
[ -d "$base" ] || return 0
[ -L "$base" ] && { say "skip symlinked base: $base"; return 0; }
while IFS= read -r -d '' p; do
if ! have_busy_tools; then say "cannot prove idle (lsof missing — apt install lsof), kept: $p"; STATUS=1; continue; fi
if is_busy "$p"; then say "busy, kept: $p"; continue; fi
if [ "$DRY_RUN" -eq 1 ]; then say "would remove ($(( max_min / 60 ))h+): $p"; else rm -rf -- "$p" && say "removed ($(( max_min / 60 ))h+): $p"; fi
done < <(find -P "$base" -xdev -mindepth 1 -maxdepth 1 \
\( -name 'runner-*' -o -name 'omniroute-*' -o -name 'next-build*' -o -name 'e2e-build.tar.gz' \) \
! -type l -mmin "+$max_min" -print0 2>/dev/null || true)
}
say "$(date -u +%FT%TZ) start${DRY_RUN:+ (dry-run=$DRY_RUN)} busy-tools=$(have_busy_tools && echo ok || echo MISSING)"
# 1) stale artefacts — tmpfs is RAM, so it gets the short fuse
snapshot_open_paths
for base in $JANITOR_TMP_BASES; do sweep "$base" $(( TMPFS_MAX_AGE_HOURS * 60 )); done
for base in $JANITOR_WORK_TEMP_BASES; do sweep "$base" $(( WORK_TEMP_MAX_AGE_HOURS * 60 )); done
say "stale temp sweep done"
# 2) zombie builds
ZOMBIES=0
while read -r pid etimes comm; do
[ -n "${pid:-}" ] || continue
if [ "$etimes" -gt $(( ZOMBIE_BUILD_MAX_MIN * 60 )) ]; then
say "⚠ zombie build pid=$pid comm=$comm age=$(( etimes / 60 ))min > ${ZOMBIE_BUILD_MAX_MIN}min — no job runs this long"
if [ "$DRY_RUN" -eq 1 ]; then say "[dry-run] would: kill -TERM $pid (then -KILL)"; else
kill -TERM "$pid" 2>/dev/null || true; sleep 10
kill -0 "$pid" 2>/dev/null && { kill -KILL "$pid" 2>/dev/null || true; say " needed SIGKILL"; }
fi
ZOMBIES=$(( ZOMBIES + 1 )); STATUS=1
fi
done < <(ps -eo pid=,etimes=,comm= 2>/dev/null | awk -v c="$ZOMBIE_BUILD_COMM" '$3 ~ ("^" c) {print $1, $2, $3}' || true)
say "zombie builds: $ZOMBIES"
# 3) old checkouts of STOPPED runners
for d in $JANITOR_RUNNER_DIRS; do
[ -d "$d" ] && [ -f "$d/.runner" ] || continue
agent=$(grep -o '"agentName": *"[^"]*"' "$d/.runner" 2>/dev/null | sed 's/.*"\([^"]*\)"$/\1/')
[ -n "$agent" ] || continue
unit=$(systemctl list-units --plain --no-legend "actions.runner.*.${agent}.service" 2>/dev/null | awk 'NR==1{print $1}')
[ -n "$unit" ] || continue
if systemctl is-active --quiet "$unit"; then continue; fi
while IFS= read -r -d '' co; do
if [ "$DRY_RUN" -eq 1 ]; then say "would prune checkout of stopped runner $agent: $co"; else rm -rf -- "$co" && say "pruned checkout of stopped runner $agent: $co"; fi
done < <(find -P "$d/_work" -xdev -mindepth 2 -maxdepth 2 -type d -mmin "+$(( WORK_CHECKOUT_MAX_AGE_HOURS * 60 ))" -print0 2>/dev/null || true)
done
# 4a) disk
USAGE=$(df --output=pcent "$JANITOR_DF_PATH" 2>/dev/null | tail -1 | tr -dc '0-9')
if [ "${USAGE:-0}" -ge "$DISK_ALERT_PCT" ]; then
say "⚠ ROOT DISK ${USAGE}% >= ${DISK_ALERT_PCT}% — clean before the next heavy run"; STATUS=1
else
echo "[janitor] disk ${USAGE}% OK"
say "disk ${USAGE:-?}% OK"
fi
# 3) Concurrency ceiling — 8-wide OOMed the 16 GB box twice on release day;
# 4 is the proven ceiling. This CODIFIES the rule that was manual discipline.
# 4b) memory pressure (PSI) — the box swapped its way through the v3.8.50 publish
if [ -r "$JANITOR_PSI_FILE" ]; then
FULL60=$(awk '/^full/ {for(i=1;i<=NF;i++) if ($i ~ /^avg60=/) {sub("avg60=","",$i); print $i}}' "$JANITOR_PSI_FILE" 2>/dev/null || echo "")
if [ -n "$FULL60" ] && awk -v v="$FULL60" -v t="$PSI_FULL_AVG60_ALERT" 'BEGIN{exit !(v+0 >= t+0)}'; then
say "⚠ MEMORY PRESSURE psi full/avg60=${FULL60}% >= ${PSI_FULL_AVG60_ALERT}% — too many heavy jobs at once"; STATUS=1
else
say "memory psi full/avg60=${FULL60:-n/a}% OK"
fi
fi
# 4c) concurrency ceiling — alert with a breakdown; the fix is fewer/labelled
# runners (an operator decision), not killing listeners from cron.
ACTIVE=$(pgrep -fc "Runner.Listener" || true)
OMNI=$(pgrep -fc "actions-runner-omniroute[^ ]*/bin[^ ]*/Runner.Listener" || true)
if [ "${ACTIVE:-0}" -gt "$MAX_ACTIVE_RUNNERS" ]; then
echo "[janitor] ${ACTIVE} Runner.Listener processes > ceiling ${MAX_ACTIVE_RUNNERS} — stop the extra runners (systemctl stop actions.runner.<name>)"
say "${ACTIVE} Runner.Listener processes (omniroute=${OMNI:-0}, other=$(( ${ACTIVE:-0} - ${OMNI:-0} ))) > ceiling ${MAX_ACTIVE_RUNNERS} — stop idle extras: systemctl stop <unit> only when it has no Runner.Worker child"
STATUS=1
else
echo "[janitor] runners active: ${ACTIVE:-0}/${MAX_ACTIVE_RUNNERS} OK"
say "runners active: ${ACTIVE:-0}/${MAX_ACTIVE_RUNNERS} (omniroute=${OMNI:-0}) OK"
fi
echo "[janitor] done status=$STATUS"
say "done status=$STATUS"
exit "$STATUS"

View File

@@ -2037,7 +2037,9 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
const [showAdvanced, setShowAdvanced] = useState(false);
const [config, setConfig] = useState(sanitizeComboRuntimeConfig(combo?.config));
// Validate persisted enum; ensure reset on combo change not just first mount.
const initialSortMethod = normalizeSortMethod(config.modelSort?.method);
const initialSortMethod = normalizeSortMethod(
(config.modelSort as { method?: unknown } | undefined)?.method
);
const [sortMethod, setSortMethod] = useState<SortMethod>(initialSortMethod);
useEffect(() => {
// Sync point: when the combo identity changes, re-derive sort method.
@@ -2733,13 +2735,15 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
const rankings = await fetchProviderRankings();
// Functional note: `next` is the post-batch snapshot. Concurrent single-add
// racing this batch is low-probability single-user; last write wins.
const sorted = await sortComboStepsByScore(next, rankings);
setModels(sorted);
const sorted = await sortComboStepsByScore(next as ComboStep[], rankings);
setModels(sorted as typeof next);
} catch {
setModels(next);
}
} else {
setModels(sortComboStepsSync(next, currentMethod as "provider" | "name"));
setModels(
sortComboStepsSync(next as ComboStep[], currentMethod as "provider" | "name") as typeof next
);
}
setBuilderError("");
};
@@ -3642,7 +3646,11 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
</div>
<div className="flex items-center justify-between gap-2 mb-2">
<ComboSortSelect value={sortMethod} onChange={handleSortChange} t={t} />
<ComboSortSelect
value={sortMethod}
onChange={handleSortChange}
t={(k, f) => getI18nOrFallback(t, k, f)}
/>
</div>
{models.length === 0 ? (

View File

@@ -12,7 +12,7 @@ interface ProviderInfo {
id: string;
name: string;
website?: string;
color: string;
color?: string;
apiType?: string;
/** Optional operator-supplied remote icon URL (#2166) for compatible provider nodes. */
iconUrl?: string;

View File

@@ -153,32 +153,60 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "Combo not found" }, { status: 404 });
}
const persistedSteps = Array.isArray(combo.models) ? combo.models : [];
const modelSteps = persistedSteps.filter(
(step: any) =>
let unsupportedStepCount = 0;
const targets = persistedSteps.flatMap((step: any) => {
if (
typeof step === "string" ||
((step.kind === undefined || step.kind === "model") && typeof step.model === "string")
);
const unsupportedStepCount = persistedSteps.length - modelSteps.length;
) {
const value = typeof step === "string" ? step : step.model;
const separator = value.indexOf("/");
const parsedProvider = separator === -1 ? undefined : value.slice(0, separator);
const parsedModel = separator === -1 ? value : value.slice(separator + 1);
return [
{
provider:
typeof step === "string"
? parsedProvider || "unknown"
: step.providerId || step.provider || parsedProvider || "unknown",
model: parsedModel,
weight: typeof step === "string" ? undefined : step.weight,
},
];
}
// #11822 follow-up (see #11882): surface combo-ref and provider-wildcard
// steps with a specific warning instead of folding them into a generic
// "unsupported step" count. Structural combo references and wildcard
// expansion are out of scope for this route-local simulator.
if (step?.kind === "combo-ref") {
warnings.push(
`Step references combo "${String(step.comboName)}" — nested combos are not expanded by the simulator.`
);
return [];
}
if (step?.kind === "provider-wildcard") {
warnings.push(
`Step "${String(step.providerId)}/${String(step.modelPattern)}" is a provider wildcard — expanded at runtime, shown here unresolved.`
);
return [
{
provider: String(step.providerId ?? "unknown"),
model: String(step.modelPattern ?? "*"),
weight: typeof step.weight === "number" ? step.weight : undefined,
},
];
}
unsupportedStepCount += 1;
return [];
});
if (unsupportedStepCount > 0) {
warnings.push(
`Skipped ${unsupportedStepCount} unsupported persisted combo ${unsupportedStepCount === 1 ? "step" : "steps"}.`
);
}
const targets = modelSteps.map((step: any) => {
const value = typeof step === "string" ? step : step.model;
const separator = value.indexOf("/");
const parsedProvider = separator === -1 ? undefined : value.slice(0, separator);
const parsedModel = separator === -1 ? value : value.slice(separator + 1);
return {
provider:
typeof step === "string"
? parsedProvider || "unknown"
: step.providerId || step.provider || parsedProvider || "unknown",
model: parsedModel,
weight: typeof step === "string" ? undefined : step.weight,
};
});
comboInfo = { name: combo.name, strategy: combo.strategy, targets };
} else if (body.combo) {
comboInfo = body.combo;

View File

@@ -31,6 +31,7 @@ import { getAllMusicModels } from "@omniroute/open-sse/config/musicRegistry";
import {
getRegistryModelThinkingEfforts,
getRegistryThinkingEfforts,
providerUsesAuthoritativeLiveCatalog,
REGISTRY,
} from "@omniroute/open-sse/config/providerRegistry";
import { CODEX_NATIVE_UNPREFIXED_MODELS } from "@omniroute/open-sse/services/model";
@@ -994,11 +995,13 @@ async function buildUnifiedModelsResponseCore(
// the fix, a provider with any synced model silently dropped ALL its
// static models.
//
// Cursor exclusive listing: when an active synced catalog exists, drop
// ALL static rows (including effort variants) so Test All / clients only
// see live AvailableModels + injected auto*.
// An authoritative active synced catalog replaces the static registry.
// Partial discovery providers still use exact-id coverage suppression so
// their intentionally omitted static routes remain available.
const syncedForProvider = syncedModelIdsByCanonicalProvider.get(canonicalProviderId);
const exclusiveListing = providerUsesExclusiveSyncedListing(canonicalProviderId);
const exclusiveListing =
providerUsesExclusiveSyncedListing(canonicalProviderId) ||
providerUsesAuthoritativeLiveCatalog(canonicalProviderId);
const providerHasSynced = syncedForProvider !== undefined && syncedForProvider.size > 0;
const coveredBySynced = shouldSuppressStaticModelForExclusiveListing({
exclusiveListing,
@@ -1227,7 +1230,7 @@ async function buildUnifiedModelsResponseCore(
continue;
}
if (includeAlias) {
if (includeAlias || Boolean(prefix)) {
models.push({
id: aliasId,
object: "model",
@@ -1239,7 +1242,7 @@ async function buildUnifiedModelsResponseCore(
...syncedFields,
});
}
if (includeAlias && modelType === "audio") {
if ((includeAlias || Boolean(prefix)) && modelType === "audio") {
models.push({
id: aliasId,
object: "model",
@@ -1652,7 +1655,7 @@ async function buildUnifiedModelsResponseCore(
? getCustomVisionCapabilityFields(model, aliasId, modelId)
: null;
if (includeAlias) {
if (includeAlias || Boolean(prefix)) {
models.push({
id: aliasId,
object: "model",
@@ -1770,7 +1773,7 @@ async function buildUnifiedModelsResponseCore(
const visionFields =
getVisionCapabilityFields(aliasId) || getVisionCapabilityFields(modelId);
if (includeAlias) {
if (includeAlias || Boolean(nodePrefix)) {
models.push({
id: aliasId,
object: "model",

View File

@@ -7679,7 +7679,8 @@
"backupCleanupSuccess": "Excluído(s) {backups} conjunto(s) de backup e {files} arquivo(s).",
"backupCleanupFailed": "Falha ao limpar backups do banco de dados",
"purgeQuotaSnapshotsSuccess": "{count} snapshots de cota removidos",
"purgeQuotaSnapshotsFailed": "Falha ao remover snapshots de cota", "purgeCallLogsSuccess": "{count} logs de chamadas removidos",
"purgeQuotaSnapshotsFailed": "Falha ao remover snapshots de cota",
"purgeCallLogsSuccess": "{count} logs de chamadas removidos",
"purgeCallLogsFailed": "Falha ao remover logs de chamadas",
"purgeDetailedLogsSuccess": "{count} logs detalhados removidos",
"purgeDetailedLogsFailed": "Falha ao remover logs detalhados",
@@ -7689,7 +7690,8 @@
"invalidJsonFileType": "Tipo de arquivo inválido. Apenas arquivos .json são permitidos.",
"legacyJsonImportSuccess": "JSON legado importado com sucesso!",
"jsonImportFailed": "Falha ao importar JSON",
"jsonImportError": "Erro durante a importação de JSON", "storagePurgeData": "Limpar dados",
"jsonImportError": "Erro durante a importação de JSON",
"storagePurgeData": "Limpar dados",
"storagePurgeDataDesc": "Excluir imediatamente todos os registros sem aplicar verificações de retenção. Use com cautela.",
"storageRetentionCleanup": "Configurações de Retenção",
"storageRetentionCleanupDesc": "Configure a retenção de registros operacionais e a limpeza de backup do banco de dados.",
@@ -13937,5 +13939,17 @@
"cta": "Obter uma chave de API",
"partnerLinkNote": "Link de parceiro",
"dismissAriaLabel": "Dispensar"
},
"combo": {
"sort": {
"label": "Ordenar por",
"method": {
"manual": "Manual",
"provider": "Provedor",
"score": "Pontuação (modelos gratuitos)",
"name": "Nome"
},
"scoreHint": "A ordenação por pontuação vale só para provedores gratuitos; os demais ficam onde estão."
}
}
}

View File

@@ -13939,5 +13939,17 @@
"cta": "Lấy khóa API",
"partnerLinkNote": "Liên kết đối tác",
"dismissAriaLabel": "Đóng"
},
"combo": {
"sort": {
"label": "Sắp xếp theo",
"method": {
"manual": "Thủ công",
"provider": "Nhà cung cấp",
"score": "Điểm (mô hình miễn phí)",
"name": "Tên"
},
"scoreHint": "Xếp hạng theo điểm chỉ áp dụng cho nhà cung cấp miễn phí; các nhà cung cấp khác giữ nguyên vị trí."
}
}
}

View File

@@ -12,14 +12,16 @@
* - honor explicit caller intent verbatim
*/
const VALID_EFFORTS = new Set(["minimal", "low", "medium", "high"]);
const VALID_EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh"]);
export type ReasoningEffort = "minimal" | "low" | "medium" | "high";
export type ReasoningEffort = "minimal" | "low" | "medium" | "high" | "xhigh";
export function normalizeXaiReasoningEffort(effort: unknown): ReasoningEffort | undefined {
if (typeof effort !== "string") return undefined;
const normalized = effort.toLowerCase();
if (normalized === "max" || normalized === "xhigh") return "high";
// "max" is not an xAI tier; "xhigh" is real on grok-4.6+ and xAI itself
// degrades it to "high" on older models, so passing it through is always safe.
if (normalized === "max") return "high";
return VALID_EFFORTS.has(normalized) ? (normalized as ReasoningEffort) : undefined;
}

View File

@@ -63,9 +63,17 @@ function normalizeInputSchema(input: Record<string, unknown>): Record<string, un
if (typeof input.type === "string") {
return input;
}
// Expand shorthand values: skills may declare `{ "content": "string" }`
// instead of `{ "content": { "type": "string" } }`. Forwarding the shorthand
// verbatim produces invalid JSON Schema, which strict-validating upstreams
// (Zhipu GLM behind Console Go) reject with a 400 for the entire request.
const properties: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
properties[key] = typeof value === "string" ? { type: value } : value;
}
return {
type: "object",
properties: input,
properties,
};
}

View File

@@ -13,12 +13,7 @@ import {
} from "@/lib/db/providerLimits";
import { syncToCloud } from "@/lib/cloudSync";
import { setQuotaCache } from "@/domain/quotaCache";
import {
buildClaudeExtraUsageConnectionUpdate,
CLAUDE_EXTRA_USAGE_ERROR_SOURCE,
isClaudeExtraUsageBlockEnabled,
isClaudeExtraUsageQueued,
} from "@/lib/providers/claudeExtraUsage";
import { buildClaudeExtraUsageConnectionUpdate } from "@/lib/providers/claudeExtraUsage";
import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation";
import { clearRecoveredProviderState } from "@/sse/services/auth";
import { getMachineId } from "@/shared/utils/machine";
@@ -526,48 +521,7 @@ export function shouldClearErrorStateOnValidProbe(
* — keeps the connection locked, matching the kimi-coding partial-refresh
* semantics.
*/
function windowStillExhaustedAfterRealReset(value: unknown, nowMs: number): boolean {
if (!isRecord(value)) return false;
if (value.unlimited === true) return false;
const remaining =
typeof value.remaining === "number"
? value.remaining
: typeof value.remainingPercentage === "number"
? value.remainingPercentage
: null;
if (remaining !== null && remaining > 0) return false;
if (value.resetAt == null) return true;
const resetMs = Date.parse(String(value.resetAt));
if (Number.isNaN(resetMs)) return true;
return resetMs > nowMs;
}
function isQuotaExhaustedCooldownReleasable(
connection: Pick<
ProviderConnectionLike,
"lastErrorType" | "lastErrorSource" | "provider" | "providerSpecificData"
>,
usage: JsonRecord
): boolean {
if (connection.lastErrorType !== "quota_exhausted") return false;
// An extra-usage block is a POLICY lock, not a quota window: the session and
// weekly windows genuinely look recovered in the very same fetch, so the
// window scan below would happily release it. It stays locked while the
// policy is on and upstream still reports extra usage queued.
if (
connection.lastErrorSource === CLAUDE_EXTRA_USAGE_ERROR_SOURCE &&
isClaudeExtraUsageBlockEnabled(connection.provider, connection.providerSpecificData) &&
isClaudeExtraUsageQueued(usage)
) {
return false;
}
const quotas = usage?.quotas;
if (!isRecord(quotas)) return false;
const values = Object.values(quotas);
if (values.length === 0) return false;
const nowMs = Date.now();
return !values.some((value) => windowStillExhaustedAfterRealReset(value, nowMs));
}
/**
* Is an explicit cooldown still in the future?
@@ -600,17 +554,17 @@ export async function maybeClearRecoveredQuotaState(
if (!hasUsableQuota(usage)) return connection;
if (isTerminalStatusForQuotaRecovery(connection.testStatus)) return connection;
if (hasActiveCooldown(connection)) {
// #11355 made an active rateLimitedUntil an unconditional stop, which is right
// for an upstream-derived cooldown but over-broad for the one case #10534 was
// built for: a Claude-subscription 429 persists a SYNTHETIC 1h cooldown because
// the upstream sent no parseable reset. When the later poll shows every window
// that governs this connection has really reset WITH quota available, holding
// that synthetic cooldown just deadlocks the connection for an hour.
//
// Narrow by design: only lastErrorType "quota_exhausted" (the synthetic-cooldown
// writer) is eligible, and a single still-exhausted or unknown-reset window keeps
// the lock. Every other reason keeps #11355/#11277 semantics untouched.
if (!isQuotaExhaustedCooldownReleasable(connection, usage)) return connection;
// A future rateLimitedUntil written from a real upstream signal is a hard
// statement no poller may overrule (#11277) — executor-sourced rate limits
// and extra-usage policy blocks included. Only a SYNTHETIC cooldown (a
// quota_exhausted lock persisted without an upstream reset, e.g. the
// Claude-subscription poller's 1h lockout) yields to positive live-window
// evidence that the real quota has already replenished past its reset.
const syntheticRecoveryOverride =
connection.lastErrorType === "quota_exhausted" &&
connection.lastErrorSource !== "extra_usage" &&
syntheticCooldownOutlivedByRealWindows(usage);
if (!syntheticRecoveryOverride) return connection;
}
const hasTransientState =

View File

@@ -335,11 +335,19 @@ const ProviderIcon = memo(function ProviderIcon({
fallbackColor,
}: ProviderIconProps) {
const { isDark } = useTheme();
const normalizedId = PROVIDER_ICON_ALIASES[providerId.toLowerCase()] || providerId.toLowerCase();
const localSvgId = LOCAL_SVG_ALIASES[normalizedId] || normalizedId;
// Own-property guards: a providerId such as "constructor" or "__proto__" otherwise
// resolves through Object.prototype, yielding a truthy-looking value that corrupts
// downstream lookups instead of falling through to the unknown-provider path (#11853).
const providerIdLower = providerId.toLowerCase();
const normalizedId = Object.hasOwn(PROVIDER_ICON_ALIASES, providerIdLower)
? PROVIDER_ICON_ALIASES[providerIdLower]
: providerIdLower;
const localSvgId = Object.hasOwn(LOCAL_SVG_ALIASES, normalizedId)
? LOCAL_SVG_ALIASES[normalizedId]
: normalizedId;
const usesGenericIcon =
GENERIC_PROVIDER_IDS.has(normalizedId) || GENERIC_PROVIDER_IDS.has(localSvgId);
const themedSvg = THEMED_SVGS[normalizedId];
const themedSvg = Object.hasOwn(THEMED_SVGS, normalizedId) ? THEMED_SVGS[normalizedId] : undefined;
const hasSvg = KNOWN_SVGS.has(localSvgId);
const [failedAssets, setFailedAssets] = useState<Record<string, true>>({});

View File

@@ -484,9 +484,17 @@ export function getLobeProviderIcon(
providerId: string,
type: "mono" | "color" = "color"
): LobeIconComponent | null {
const iconKey = LOBE_PROVIDER_ALIASES[providerId.toLowerCase()];
if (!iconKey) return null;
if (typeof providerId !== "string") return null;
const aliasKey = providerId.toLowerCase();
// Own-property guards: a providerId such as "constructor" or "__proto__"
// otherwise resolves through Object.prototype, yielding a truthy iconKey
// whose LOBE_ICON_COMPONENTS lookup is undefined -> `entry.color` throws and
// takes down the whole providers dashboard via the error boundary.
if (!Object.hasOwn(LOBE_PROVIDER_ALIASES, aliasKey)) return null;
const iconKey = LOBE_PROVIDER_ALIASES[aliasKey];
if (!iconKey || !Object.hasOwn(LOBE_ICON_COMPONENTS, iconKey)) return null;
const entry = LOBE_ICON_COMPONENTS[iconKey];
if (!entry) return null;
return type === "color" && entry.color ? entry.color : entry.mono;
}

View File

@@ -695,7 +695,7 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
"Anonymous access to Groq requires proof-of-work cake credits from g4f.dev/chat; alternatively, use a g4f.dev member API key. Limits vary.",
passthroughModels: true,
authHint:
"Bake anonymous cake credits at g4f.dev/chat, or create a member API key at g4f.dev/members.html.",
"Bake anonymous cake credits at g4f.dev/chat, or use a g4f.dev member key (create one at g4f.dev/members.html).",
notice: {
text: "Remote third-party gateway: prompts and request metadata leave OmniRoute and are handled by g4f.space. Its Terms and Privacy links were unavailable when last verified on 2026-08-27.",
apiKeyUrl: "https://g4f.dev/members.html",
@@ -714,7 +714,7 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
"Anonymous access to Gemini requires proof-of-work cake credits from g4f.dev/chat; alternatively, use a g4f.dev member API key. Limits vary.",
passthroughModels: true,
authHint:
"Bake anonymous cake credits at g4f.dev/chat, or create a member API key at g4f.dev/members.html.",
"Bake anonymous cake credits at g4f.dev/chat, or use a g4f.dev member key (create one at g4f.dev/members.html).",
notice: {
text: "Remote third-party gateway: prompts and request metadata leave OmniRoute and are handled by g4f.space. Its Terms and Privacy links were unavailable when last verified on 2026-08-27.",
apiKeyUrl: "https://g4f.dev/members.html",
@@ -733,7 +733,7 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
"Anonymous access to Pollinations requires proof-of-work cake credits from g4f.dev/chat; alternatively, use a g4f.dev member API key. Limits vary.",
passthroughModels: true,
authHint:
"Bake anonymous cake credits at g4f.dev/chat, or create a member API key at g4f.dev/members.html.",
"Bake anonymous cake credits at g4f.dev/chat, or use a g4f.dev member key (create one at g4f.dev/members.html).",
notice: {
text: "Remote third-party gateway: prompts and request metadata leave OmniRoute and are handled by g4f.space. Its Terms and Privacy links were unavailable when last verified on 2026-08-27.",
apiKeyUrl: "https://g4f.dev/members.html",
@@ -752,7 +752,7 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
"Anonymous access to hosted Ollama requires proof-of-work cake credits from g4f.dev/chat; alternatively, use a g4f.dev member API key. Limits vary.",
passthroughModels: true,
authHint:
"Bake anonymous cake credits at g4f.dev/chat, or create a member API key at g4f.dev/members.html.",
"Bake anonymous cake credits at g4f.dev/chat, or use a g4f.dev member key (create one at g4f.dev/members.html).",
notice: {
text: "Remote third-party gateway: prompts and request metadata leave OmniRoute and are handled by g4f.space. Its Terms and Privacy links were unavailable when last verified on 2026-08-27.",
apiKeyUrl: "https://g4f.dev/members.html",
@@ -771,7 +771,7 @@ export const APIKEY_PROVIDERS_GATEWAYS = {
"Anonymous access to NVIDIA NIM requires proof-of-work cake credits from g4f.dev/chat; alternatively, use a g4f.dev member API key. Limits vary.",
passthroughModels: true,
authHint:
"Bake anonymous cake credits at g4f.dev/chat, or create a member API key at g4f.dev/members.html.",
"Bake anonymous cake credits at g4f.dev/chat, or use a g4f.dev member key (create one at g4f.dev/members.html).",
notice: {
text: "Remote third-party gateway: prompts and request metadata leave OmniRoute and are handled by g4f.space. Its Terms and Privacy links were unavailable when last verified on 2026-08-27.",
apiKeyUrl: "https://g4f.dev/members.html",

View File

@@ -1,14 +1,14 @@
{
"$schema": "https://stryker-mutator.io/schemas/stryker-schema.json",
"_comment": [
"Mutation testing for the ~8 critical modules (Task 11 \u2014 Fase 7).",
"NIGHTLY ONLY \u2014 DO NOT run on every PR. Mutation testing is expensive:",
"Mutation testing for the ~8 critical modules (Task 11 Fase 7).",
"NIGHTLY ONLY DO NOT run on every PR. Mutation testing is expensive:",
" - Each mutant requires a full test suite execution.",
" - The 8 modules produce ~200\u2013500 mutants; est. 30\u201390 min per run.",
" - The 8 modules produce ~200500 mutants; est. 3090 min per run.",
" - Wired to the nightly CI workflow (.github/workflows/nightly-mutation.yml),",
" NOT to the 'lint' / 'quality-gate' PR jobs.",
"",
"TEST RUNNER \u2014 @stryker-mutator/tap-runner (NOT vitest):",
"TEST RUNNER @stryker-mutator/tap-runner (NOT vitest):",
" The 8 critical modules are covered by node:test files in tests/unit/",
" (run via `node --import tsx --test`), NOT by vitest. The vitest config",
" only includes a small set of .test.tsx + open-sse/**/__tests__ files, so",
@@ -22,24 +22,26 @@
" npm install --save-dev @stryker-mutator/core @stryker-mutator/tap-runner",
"",
"Run manually:",
" npm run test:mutation # full run (slow \u2014 nightly budget)",
" npm run test:mutation # full run (slow nightly budget)",
" npx stryker run --dryRunOnly # validate the baseline only (no mutants)",
" (single-module probe: temporarily narrow `mutate` + `tap.testFiles` in this file)",
"",
"VALIDATED 2026-06-15: `npx stryker run --dryRunOnly` exits 0 \u2014 all 129 covering",
"VALIDATED 2026-06-15: `npx stryker run --dryRunOnly` exits 0 all 129 covering",
"test files run green in the Stryker sandbox and the perTest coverage map builds for",
"all 8 instrumented modules (15k+ mutants). The baseline dry-run takes ~20 min with",
"concurrency=1; the full mutation phase runs on top (advisory, capped by the workflow",
"timeout). So the nightly produces REAL mutation scores for the 8 modules.",
"",
"Mutation score per module \u2192 quality-baseline.json key 'mutationScore.<module>'",
"Direction: up (score can only improve; ratchet blocks drops \u2014 wired in a later INT phase)."
"Mutation score per module quality-baseline.json key 'mutationScore.<module>'",
"Direction: up (score can only improve; ratchet blocks drops wired in a later INT phase)."
],
"packageManager": "npm",
"incremental": true,
"incrementalFile": "reports/mutation/stryker-incremental.json",
"testRunner": "tap",
"plugins": ["@stryker-mutator/tap-runner"],
"plugins": [
"@stryker-mutator/tap-runner"
],
"tap": {
"testFiles": [
"tests/unit/10085-compatible-generic-vs-uuid-credential.test.ts",
@@ -389,7 +391,13 @@
"tests/unit/vertex-passthrough-model-lockout.test.ts",
"tests/unit/video-bridge-drilldown-route.test.ts",
"tests/unit/video-bridge-route-security.test.ts",
"tests/unit/xai-agent-tools-passthrough.test.ts"
"tests/unit/xai-agent-tools-passthrough.test.ts",
"tests/unit/combo/connection-aware-expansion.test.ts",
"tests/unit/chatgpt-web-runtime-block.test.ts",
"tests/unit/felo-web-runtime-block.test.ts",
"tests/unit/microsoft-designer-web-runtime-block.test.ts",
"tests/unit/qwen-web-runtime-block.test.ts",
"tests/unit/tunnel-routes-error-sanitization.test.ts"
],
"nodeArgs": [
"--import",
@@ -405,7 +413,7 @@
]
},
"_mutate_godfiles_excluded_comment": [
"2026-06-18 (Onda 2 budget): chatCore.ts + combo.ts \u2014 the two god-files \u2014 were REMOVED",
"2026-06-18 (Onda 2 budget): chatCore.ts + combo.ts the two god-files were REMOVED",
"from `mutate`. They dominated ~2/3 of the ~15k mutants; the full 8-module run TIMED OUT",
"at the 180min nightly cap (run 27705123780: 16:47:33 -> killed 19:47:48 = exactly 180min;",
"the prior 120min scheduled run also timed out). #4078 made concurrency safe but the",
@@ -425,23 +433,23 @@
"comboContextCache/idempotency/passthroughHelpers/responseHeaders/sanitization/upstreamTimeouts).",
"A follow-up then added DEDICATED unit tests for 6 more leaves (tests/unit/chatcore-headers,",
"-log-truncation, -memory-extraction, -non-streaming-sse, -passthrough-tool-names,",
"-executor-helpers \u2014 wired into tap.testFiles above) and added those leaves as batch h",
"-executor-helpers wired into tap.testFiles above) and added those leaves as batch h",
"(headers/logTruncation/memoryExtraction/nonStreamingSse/passthroughToolNames/executorHelpers).",
"A later follow-up added dedicated tests (NO mock.module \u2014 unavailable under the tap-runner; used",
"A later follow-up added dedicated tests (NO mock.module unavailable under the tap-runner; used",
"fetch-override + crafted inputs + temp-DATA_DIR) for telemetryHelpers (both fns, all branches) and",
"memorySkillsInjection (getSkillsProviderForFormat fully + injectMemoryAndSkills guards/empty-DB",
"path) and added them as batch i.",
"",
"The FINAL chatCore leaf, semanticCache.ts, was added to batch i once its cache-HIT block had a",
"fixture: chatcore-semantic-cache now SEEDS the real cache via setCachedResponse (the in-memory",
"store getCachedResponse checks first \u2014 no mock.module needed) under the exact signature",
"store getCachedResponse checks first no mock.module needed) under the exact signature",
"checkSemanticCache rebuilds, so the HIT branch runs end-to-end (status 200 / 'semantic' / 'HIT' /",
"the stream + content-type ternaries / the cost fallback / the side-effect calls all get killed).",
"ALL 15 chatCore leaves are now mutated.",
"",
"STILL EXCLUDED (follow-ups, NOT in `mutate` yet):",
" - combo.ts + chatCore.ts barrels: their handleComboChat/handleChatCore CORES were not",
" split (out of scope \u2014 Fase 3 ChatCoreContext refactor). The barrels are now thin-ish",
" split (out of scope Fase 3 ChatCoreContext refactor). The barrels are now thin-ish",
" but still large; keep excluded until the cores are split.",
"See project memory: Quality Gate v2 / Fase 9 (project-combo-split)."
],
@@ -498,7 +506,11 @@
".worktrees",
".stryker-tmp"
],
"reporters": ["progress", "html", "json"],
"reporters": [
"progress",
"html",
"json"
],
"htmlReporter": {
"fileName": "reports/mutation/mutation.html"
},
@@ -525,11 +537,11 @@
"would break the required all-green baseline dry-run (e.g. body-timeout-integration,",
"heap-pressure, sse-heartbeat-integration, *-stream-readiness, chatcore-memory-pressure).",
"It is enumerated (not a broad glob) so the Stryker dry-run stays tractable for the",
"nightly budget \u2014 a glob over the full ~1300-file unit suite would make the per-test",
"nightly budget a glob over the full ~1300-file unit suite would make the per-test",
"dry-run take hours. coverageAnalysis:perTest then narrows which files run per mutant.",
"Regenerate the base union after adding/renaming covering tests, then re-prune flaky ones:",
" grep -rlE \"circuitBreaker|publicCreds|accountFallback|routeGuard|services/auth|chatCore|services/combo|utils/error|public-client|account-fallback|route-guard|circuit-breaker\" tests/unit --include=\"*.test.ts\" | sort -u"
],
"dryRunTimeoutMinutes": 30,
"_concurrency_comment": "concurrency=4 (was 1): the covering node:test files used to share SQLite/module state via the default DATA_DIR (~/.omniroute), so running them concurrently in the Stryker sandbox caused cross-file races that failed the all-green baseline. tap.nodeArgs now imports ./tests/_setup/isolateDataDir.ts, which gives each spawned test process its own temp DATA_DIR \u2014 eliminating the shared on-disk DB, so concurrency>1 is deterministic. A/B verified 2026-06-17: dry-run at concurrency=4 fails WITHOUT the isolation import (account-fallback-service tap exit 9) and passes WITH it. Raise further only if the runner has spare cores."
"_concurrency_comment": "concurrency=4 (was 1): the covering node:test files used to share SQLite/module state via the default DATA_DIR (~/.omniroute), so running them concurrently in the Stryker sandbox caused cross-file races that failed the all-green baseline. tap.nodeArgs now imports ./tests/_setup/isolateDataDir.ts, which gives each spawned test process its own temp DATA_DIR eliminating the shared on-disk DB, so concurrency>1 is deterministic. A/B verified 2026-06-17: dry-run at concurrency=4 fails WITHOUT the isolation import (account-fallback-service tap exit 9) and passes WITH it. Raise further only if the runner has spare cores."
}

View File

@@ -33,7 +33,7 @@ if (!process.env.DATA_DIR) {
// Best-effort cleanup so a long suite run does not leak hundreds of temp DBs.
process.on("exit", () => {
try {
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
// ignore — the OS reaps its temp dir eventually.
}

View File

@@ -366,7 +366,7 @@ test.after(async () => {
await serverA.stop();
await serverB.stop();
core.closeDbInstance();
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true });
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
test("primary healthy: request routes to Server A only", async () => {

View File

@@ -286,7 +286,7 @@ export async function createChatPipelineHarness(prefix) {
clearSkillState();
await new Promise((resolve) => setTimeout(resolve, 20));
core.resetDbInstance();
fs.rmSync(testDataDir, { recursive: true, force: true });
fs.rmSync(testDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(testDataDir, { recursive: true });
initTranslators();
}
@@ -300,7 +300,7 @@ export async function createChatPipelineHarness(prefix) {
clearSkillState();
resetAllCircuitBreakers();
core.resetDbInstance();
fs.rmSync(testDataDir, { recursive: true, force: true });
fs.rmSync(testDataDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
async function seedConnection(provider: string, overrides: SeedConnectionOverrides = {}) {

View File

@@ -24,7 +24,7 @@ const DEFAULT_PATTERNS = [".bank.", ".gov.", "okta.com", "auth0.com"];
function resetDb() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -34,7 +34,11 @@ test.beforeEach(() => {
});
test.after(() => {
try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch { /* noop */ }
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* noop */
}
});
// ── POST patterns ──────────────────────────────────────────────────────────
@@ -48,7 +52,10 @@ test("POST /bypass: stores user patterns", async () => {
})
);
assert.equal(res.status, 200);
const body = await res.json() as { ok: boolean; patterns: Array<{ pattern: string; source: string }> };
const body = (await res.json()) as {
ok: boolean;
patterns: Array<{ pattern: string; source: string }>;
};
assert.equal(body.ok, true);
assert.ok(Array.isArray(body.patterns));
const userPatterns = body.patterns.filter((p) => p.source === "user");
@@ -64,7 +71,7 @@ test("POST /bypass: invalid body returns 400", async () => {
})
);
assert.equal(res.status, 400);
const body = await res.json() as Record<string, unknown>;
const body = (await res.json()) as Record<string, unknown>;
const errMsg = (body.error as Record<string, unknown>)?.message as string;
assert.ok(!errMsg.includes("at /"), "stack trace leaked in 400 error");
});
@@ -83,7 +90,7 @@ test("GET /bypass: shows default + user patterns", async () => {
const res = await bypassRoute.GET();
assert.equal(res.status, 200);
const body = await res.json() as { patterns: Array<{ pattern: string; source: string }> };
const body = (await res.json()) as { patterns: Array<{ pattern: string; source: string }> };
assert.ok(Array.isArray(body.patterns));
const sources = new Set(body.patterns.map((p) => p.source));
@@ -119,7 +126,10 @@ test("DELETE /bypass?pattern=X: removes a user pattern", async () => {
})
);
assert.equal(deleteRes.status, 200);
const deleteBody = await deleteRes.json() as { ok: boolean; patterns: Array<{ pattern: string; source: string }> };
const deleteBody = (await deleteRes.json()) as {
ok: boolean;
patterns: Array<{ pattern: string; source: string }>;
};
assert.equal(deleteBody.ok, true);
// Verify it's gone
@@ -142,19 +152,18 @@ test("DELETE /bypass: missing pattern param returns 400", async () => {
})
);
assert.equal(res.status, 400);
const body = await res.json() as Record<string, unknown>;
const body = (await res.json()) as Record<string, unknown>;
const errMsg = (body.error as Record<string, unknown>)?.message as string;
assert.ok(!errMsg.includes("at /"), "stack trace leaked in DELETE 400");
});
test("DELETE /bypass?pattern=X: no-op when pattern not in user list", async () => {
const res = await bypassRoute.DELETE(
new Request(
"http://localhost/api/tools/agent-bridge/bypass?pattern=not-in-list.com",
{ method: "DELETE" }
)
new Request("http://localhost/api/tools/agent-bridge/bypass?pattern=not-in-list.com", {
method: "DELETE",
})
);
assert.equal(res.status, 200);
const body = await res.json() as { ok: boolean };
const body = (await res.json()) as { ok: boolean };
assert.equal(body.ok, true);
});

View File

@@ -19,7 +19,8 @@ process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
const certRoute = await import("../../src/app/api/tools/agent-bridge/cert/route.ts");
const downloadRoute = await import("../../src/app/api/tools/agent-bridge/cert/download/route.ts");
const regenerateRoute = await import("../../src/app/api/tools/agent-bridge/cert/regenerate/route.ts");
const regenerateRoute =
await import("../../src/app/api/tools/agent-bridge/cert/regenerate/route.ts");
function certDir() {
return path.join(TEST_DATA_DIR, "mitm");
@@ -30,7 +31,7 @@ function certFilePath() {
}
function resetCertDir() {
fs.rmSync(certDir(), { recursive: true, force: true });
fs.rmSync(certDir(), { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(certDir(), { recursive: true });
}
@@ -39,7 +40,11 @@ test.beforeEach(() => {
});
test.after(() => {
try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch { /* noop */ }
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* noop */
}
});
// ── GET /cert ─────────────────────────────────────────────────────────────
@@ -47,7 +52,7 @@ test.after(() => {
test("GET /cert: returns exists:false when no cert file", async () => {
const res = await certRoute.GET();
assert.equal(res.status, 200);
const body = await res.json() as Record<string, unknown>;
const body = (await res.json()) as Record<string, unknown>;
assert.equal(body.exists, false);
assert.equal(body.trusted, false);
assert.equal(body.path, null);
@@ -59,7 +64,7 @@ test("GET /cert: returns exists:true when cert file present", async () => {
const res = await certRoute.GET();
assert.equal(res.status, 200);
const body = await res.json() as Record<string, unknown>;
const body = (await res.json()) as Record<string, unknown>;
assert.equal(body.exists, true);
// trusted may be false in test env (no system store)
assert.ok(typeof body.trusted === "boolean");
@@ -83,7 +88,7 @@ test("POST /cert: returns 404 when no cert file", async () => {
})
);
assert.equal(res.status, 404);
const body = await res.json() as Record<string, unknown>;
const body = (await res.json()) as Record<string, unknown>;
const errMsg = (body.error as Record<string, unknown>)?.message as string;
assert.ok(!errMsg.includes("at /"), "stack trace leaked in 404 error message");
});
@@ -106,7 +111,7 @@ MIIBpDCCAQ2gAwIBAgIUFakeMITMCertForTestingOnlyXX==
// In test env: installCert may throw because the PEM is fake; we accept
// either 200 (mocked) or 500 (real OS failure) — NOT a 500 with stack trace
const body = await res.json() as Record<string, unknown>;
const body = (await res.json()) as Record<string, unknown>;
const errMsg = (body.error as Record<string, unknown>)?.message as string | undefined;
if (errMsg) {
assert.ok(!errMsg.includes("at /"), "stack trace leaked in POST /cert error");
@@ -118,7 +123,7 @@ MIIBpDCCAQ2gAwIBAgIUFakeMITMCertForTestingOnlyXX==
test("GET /cert/download: 404 when no cert file", async () => {
const res = await downloadRoute.GET();
assert.equal(res.status, 404);
const body = await res.json() as Record<string, unknown>;
const body = (await res.json()) as Record<string, unknown>;
const errMsg = (body.error as Record<string, unknown>)?.message as string;
assert.ok(!errMsg.includes("at /"), "stack trace leaked in download 404");
});

View File

@@ -18,13 +18,12 @@ process.env.DATA_DIR = TEST_DATA_DIR;
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
const core = await import("../../src/lib/db/core.ts");
const mappingsRoute = await import(
"../../src/app/api/tools/agent-bridge/agents/[id]/mappings/route.ts"
);
const mappingsRoute =
await import("../../src/app/api/tools/agent-bridge/agents/[id]/mappings/route.ts");
function resetDb() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -33,18 +32,21 @@ test.beforeEach(() => {
});
test.after(() => {
try { fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true }); } catch { /* noop */ }
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* noop */
}
});
// ── GET (empty) ────────────────────────────────────────────────────────────
test("GET /mappings: returns empty array for new agent", async () => {
const res = await mappingsRoute.GET(
new Request("http://localhost/"),
{ params: { id: "copilot" } }
);
const res = await mappingsRoute.GET(new Request("http://localhost/"), {
params: { id: "copilot" },
});
assert.equal(res.status, 200);
const body = await res.json() as { mappings: unknown[] };
const body = (await res.json()) as { mappings: unknown[] };
assert.ok(Array.isArray(body.mappings));
assert.equal(body.mappings.length, 0);
});
@@ -66,17 +68,21 @@ test("PUT → GET round-trip: stores and retrieves mappings", async () => {
{ params: { id: "copilot" } }
);
assert.equal(putRes.status, 200);
const putBody = await putRes.json() as { ok: boolean; mappings: Array<{ agent_id: string; source_model: string; target_model: string }> };
const putBody = (await putRes.json()) as {
ok: boolean;
mappings: Array<{ agent_id: string; source_model: string; target_model: string }>;
};
assert.equal(putBody.ok, true);
assert.equal(putBody.mappings.length, 2);
// GET reads back the same data
const getRes = await mappingsRoute.GET(
new Request("http://localhost/"),
{ params: { id: "copilot" } }
);
const getRes = await mappingsRoute.GET(new Request("http://localhost/"), {
params: { id: "copilot" },
});
assert.equal(getRes.status, 200);
const getBody = await getRes.json() as { mappings: Array<{ source_model: string; target_model: string }> };
const getBody = (await getRes.json()) as {
mappings: Array<{ source_model: string; target_model: string }>;
};
assert.equal(getBody.mappings.length, 2);
const sources = getBody.mappings.map((m) => m.source_model).sort();
@@ -108,11 +114,10 @@ test("PUT: replaces all previous mappings", async () => {
);
assert.equal(putRes.status, 200);
const getRes = await mappingsRoute.GET(
new Request("http://localhost/"),
{ params: { id: "cursor" } }
);
const body = await getRes.json() as { mappings: Array<{ source_model: string }> };
const getRes = await mappingsRoute.GET(new Request("http://localhost/"), {
params: { id: "cursor" },
});
const body = (await getRes.json()) as { mappings: Array<{ source_model: string }> };
assert.equal(body.mappings.length, 1);
assert.equal(body.mappings[0].source_model, "new-model");
});
@@ -136,7 +141,7 @@ test("PUT: empty mappings array clears all mappings", async () => {
{ params: { id: "zed" } }
);
assert.equal(putRes.status, 200);
const body = await putRes.json() as { mappings: unknown[] };
const body = (await putRes.json()) as { mappings: unknown[] };
assert.equal(body.mappings.length, 0);
});
@@ -152,7 +157,7 @@ test("PUT: invalid body (missing mappings) returns 400", async () => {
{ params: { id: "antigravity" } }
);
assert.equal(res.status, 400);
const body = await res.json() as Record<string, unknown>;
const body = (await res.json()) as Record<string, unknown>;
const errMsg = (body.error as Record<string, unknown>)?.message as string;
assert.ok(!errMsg.includes("at /"), "stack trace leaked in 400 error");
});
@@ -183,10 +188,9 @@ test("PUT: error responses do not leak stack traces", async () => {
});
test("GET: error responses do not leak stack traces", async () => {
const res = await mappingsRoute.GET(
new Request("http://localhost/"),
{ params: { id: "antigravity" } }
);
const res = await mappingsRoute.GET(new Request("http://localhost/"), {
params: { id: "antigravity" },
});
const text = await res.text();
assert.ok(!text.includes("at /"), "stack trace leaked in GET /mappings response");
});

View File

@@ -39,7 +39,7 @@ const routeGuard = await import("../../src/server/authz/routeGuard.ts");
function resetDb() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -49,7 +49,7 @@ test.beforeEach(() => {
test.after(() => {
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* noop */
}

View File

@@ -39,7 +39,7 @@ async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -55,7 +55,7 @@ test.beforeEach(async () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
// ── Auth tests ────────────────────────────────────────────────────────────────
@@ -288,6 +288,6 @@ test("grok-build status uses GROK_HOME and returns its managed endpoint", async
} finally {
if (original === undefined) delete process.env.GROK_HOME;
else process.env.GROK_HOME = original;
fs.rmSync(grokHome, { recursive: true, force: true });
fs.rmSync(grokHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});

View File

@@ -25,14 +25,13 @@ process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-8491-antigravit
const core = await import("../../src/lib/db/core.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const { AntigravityExecutor } = await import("../../open-sse/executors/antigravity.ts");
const { clearAntigravityProjectCache } = await import(
"../../open-sse/services/antigravityProjectBootstrap.ts"
);
const { clearAntigravityProjectCache } =
await import("../../open-sse/services/antigravityProjectBootstrap.ts");
test.after(() => {
core.resetDbInstance();
if (fs.existsSync(TEST_DATA_DIR)) {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -91,7 +90,11 @@ test("#8491 PART A: runtime-discovered projectId must be persisted to the connec
throw new Error(`Expected an envelope but got a ${result.status} Response`);
}
assert.equal(loadCodeAssistCalls, 1, "loadCodeAssist must be called to recover the project");
assert.equal(result.project, DISCOVERED_PROJECT_ID, "the in-flight request uses the discovered id");
assert.equal(
result.project,
DISCOVERED_PROJECT_ID,
"the in-flight request uses the discovered id"
);
const persisted = await providersDb.getProviderConnectionById(connection.id);
assert.equal(

View File

@@ -25,7 +25,7 @@ async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -63,7 +63,7 @@ test.beforeEach(async () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
test("API keys routes require management auth when login protection is enabled", async () => {

View File

@@ -24,7 +24,7 @@ async function resetStorage() {
delete process.env.ENABLE_SOCKS5_PROXY;
core.resetDbInstance();
apiKeysDb.resetApiKeyState();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -59,7 +59,7 @@ test.beforeEach(async () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
test("critical routes: v1 management proxies covers auth, lookup, where-used, patch, and delete branches", async () => {

View File

@@ -20,7 +20,7 @@ const auditRoute = await import("../../src/app/api/compliance/audit-log/route.ts
function resetDb() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -38,7 +38,7 @@ test.beforeEach(() => {
test.after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
/**

View File

@@ -268,7 +268,7 @@ async function stopProcess(child: ReturnType<typeof spawn>) {
async function removeDirWithRetry(dir: string) {
for (let attempt = 0; attempt < 5; attempt++) {
try {
fs.rmSync(dir, { recursive: true, force: true });
fs.rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
return;
} catch (error) {
if (attempt === 4) throw error;

View File

@@ -373,7 +373,7 @@ async function resetStorage() {
invalidateMemorySettingsCache();
await new Promise((resolve) => setTimeout(resolve, 20));
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
initTranslators();
}
@@ -512,7 +512,7 @@ test.after(async () => {
clearInflight();
resetAllCircuitBreakers();
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
test("chat pipeline handles OpenAI passthrough with valid API key auth", async () => {

View File

@@ -28,7 +28,7 @@ async function resetStorage() {
readCacheDb.invalidateDbCache();
await new Promise((resolve) => setTimeout(resolve, 20));
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -40,7 +40,7 @@ test.after(async () => {
globalThis.fetch = originalFetch;
core.closeDbInstance();
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {}
});

View File

@@ -21,7 +21,7 @@ test.after(async () => {
globalThis.fetch = originalFetch;
core.closeDbInstance();
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {}
});

View File

@@ -22,12 +22,13 @@ process.env.JWT_SECRET = "test-jwt-secret-codewhale";
const core = await import("../../src/lib/db/core.ts");
const localDb = await import("../../src/lib/localDb.ts");
const { GET, POST, DELETE } = await import("../../src/app/api/cli-tools/codewhale-settings/route.ts");
const { GET, POST, DELETE } =
await import("../../src/app/api/cli-tools/codewhale-settings/route.ts");
async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -126,7 +127,7 @@ test("codewhale-settings POST: writes primary ~/.codewhale/config.toml for a fre
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -173,7 +174,7 @@ test("codewhale-settings POST: syncs an existing legacy ~/.deepseek/config.toml"
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -201,7 +202,7 @@ test("codewhale-settings GET: falls back to legacy ~/.deepseek/config.toml when
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -238,7 +239,7 @@ test("codewhale-settings DELETE: removes primary and legacy config files", async
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -272,7 +273,7 @@ test("codewhale-settings route.ts: does not call exec() or spawn() directly", ()
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
delete process.env.DATA_DIR;
delete process.env.API_KEY_SECRET;
delete process.env.JWT_SECRET;

View File

@@ -8,9 +8,7 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(
path.join(os.tmpdir(), "omniroute-deepseek-tui-settings-")
);
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-deepseek-tui-settings-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = "test-api-key-secret-deepseek-tui";
process.env.JWT_SECRET = "test-jwt-secret-deepseek-tui";
@@ -18,14 +16,13 @@ process.env.JWT_SECRET = "test-jwt-secret-deepseek-tui";
const core = await import("../../src/lib/db/core.ts");
const localDb = await import("../../src/lib/localDb.ts");
const { GET, POST, DELETE } = await import(
"../../src/app/api/cli-tools/deepseek-tui-settings/route.ts"
);
const { GET, POST, DELETE } =
await import("../../src/app/api/cli-tools/deepseek-tui-settings/route.ts");
async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -103,10 +100,7 @@ test("deepseek-tui-settings POST: writes config.toml with valid body", async ()
}),
})
);
assert.ok(
[200, 403, 500].includes(res.status),
`Unexpected status ${res.status}`
);
assert.ok([200, 403, 500].includes(res.status), `Unexpected status ${res.status}`);
if (res.status === 200) {
const body = await res.json();
assert.equal(body.success, true);
@@ -120,7 +114,7 @@ test("deepseek-tui-settings POST: writes config.toml with valid body", async ()
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -136,23 +130,20 @@ test("deepseek-tui-settings DELETE: removes config file", async () => {
fs.mkdirSync(configDir, { recursive: true });
fs.writeFileSync(
path.join(configDir, "config.toml"),
"# managed by OmniRoute (plan 14)\n[openai]\nbase_url = \"http://localhost:20128\"\n"
'# managed by OmniRoute (plan 14)\n[openai]\nbase_url = "http://localhost:20128"\n'
);
const res = await DELETE(
new Request("http://localhost/api/cli-tools/deepseek-tui-settings", { method: "DELETE" })
);
assert.ok(
[200, 403, 500].includes(res.status),
`Expected 200/403/500, got ${res.status}`
);
assert.ok([200, 403, 500].includes(res.status), `Expected 200/403/500, got ${res.status}`);
if (res.status === 200) {
const body = await res.json();
assert.equal(body.success, true);
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -186,7 +177,7 @@ test("deepseek-tui-settings route.ts: does not call exec() or spawn() directly",
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
delete process.env.DATA_DIR;
delete process.env.API_KEY_SECRET;
delete process.env.JWT_SECRET;

View File

@@ -19,14 +19,12 @@ const core = await import("../../src/lib/db/core.ts");
const localDb = await import("../../src/lib/localDb.ts");
// Import route handlers
const { GET, POST, DELETE } = await import(
"../../src/app/api/cli-tools/forge-settings/route.ts"
);
const { GET, POST, DELETE } = await import("../../src/app/api/cli-tools/forge-settings/route.ts");
async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -107,10 +105,7 @@ test("forge-settings POST: writes config.toml with valid body", async () => {
);
// 200 = success; 403 = write guard active (test env); 500 = backup dir issue
assert.ok(
[200, 403, 500].includes(res.status),
`Unexpected status ${res.status}`
);
assert.ok([200, 403, 500].includes(res.status), `Unexpected status ${res.status}`);
if (res.status === 200) {
const body = await res.json();
@@ -126,7 +121,7 @@ test("forge-settings POST: writes config.toml with valid body", async () => {
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -143,16 +138,13 @@ test("forge-settings DELETE: removes config file when it exists", async () => {
fs.mkdirSync(forgeDir, { recursive: true });
fs.writeFileSync(
path.join(forgeDir, "config.toml"),
"# managed by OmniRoute (plan 14)\n[openai]\nbase_url = \"http://localhost:20128\"\n"
'# managed by OmniRoute (plan 14)\n[openai]\nbase_url = "http://localhost:20128"\n'
);
const res = await DELETE(
new Request("http://localhost/api/cli-tools/forge-settings", { method: "DELETE" })
);
assert.ok(
[200, 403, 500].includes(res.status),
`Expected 200/403/500, got ${res.status}`
);
assert.ok([200, 403, 500].includes(res.status), `Expected 200/403/500, got ${res.status}`);
if (res.status === 200) {
const body = await res.json();
@@ -160,7 +152,7 @@ test("forge-settings DELETE: removes config file when it exists", async () => {
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -194,7 +186,7 @@ test("forge-settings route.ts: does not call exec() or spawn() directly", () =>
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
delete process.env.DATA_DIR;
delete process.env.API_KEY_SECRET;
delete process.env.JWT_SECRET;

View File

@@ -39,7 +39,7 @@ const { GET, POST, DELETE } =
async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -163,7 +163,7 @@ test("grok-build-settings POST: writes [model.omniroute] section and preserves e
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -219,7 +219,7 @@ test("grok-build-settings DELETE: removes our section, preserves the rest, resto
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -237,7 +237,7 @@ test("grok-build-settings DELETE: no-op success when no config file exists", asy
assert.equal(body.success, true);
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -289,7 +289,7 @@ test("grok-build-settings: honors GROK_HOME and rejects a relative value", async
} finally {
if (original === undefined) delete process.env.GROK_HOME;
else process.env.GROK_HOME = original;
fs.rmSync(grokHome, { recursive: true, force: true });
fs.rmSync(grokHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -313,7 +313,7 @@ test("grok-build-settings POST: returns 409 for an unowned omniroute slot", asyn
} finally {
if (original === undefined) delete process.env.GROK_HOME;
else process.env.GROK_HOME = original;
fs.rmSync(grokHome, { recursive: true, force: true });
fs.rmSync(grokHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -342,7 +342,7 @@ test("grok-build-settings POST: resolves keyId to an unmasked key", async () =>
} finally {
if (original === undefined) delete process.env.GROK_HOME;
else process.env.GROK_HOME = original;
fs.rmSync(grokHome, { recursive: true, force: true });
fs.rmSync(grokHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -376,7 +376,7 @@ test("grok-build-settings route.ts: does not call exec() or spawn() directly", (
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
delete process.env.DATA_DIR;
delete process.env.API_KEY_SECRET;
delete process.env.JWT_SECRET;

View File

@@ -21,7 +21,7 @@ const { GET, POST, DELETE } = await import("../../src/app/api/cli-tools/jcode-se
async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -115,7 +115,7 @@ test("jcode-settings POST: writes [providers.omniroute] into config.toml", async
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -159,7 +159,7 @@ test("jcode-settings DELETE: removes only the OmniRoute-managed block", async ()
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -193,7 +193,7 @@ test("jcode-settings route.ts: does not call exec() or spawn() directly", () =>
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
delete process.env.DATA_DIR;
delete process.env.API_KEY_SECRET;
delete process.env.JWT_SECRET;

View File

@@ -22,9 +22,7 @@ process.env.JWT_SECRET = "test-jwt-secret-letta";
const core = await import("../../src/lib/db/core.ts");
const localDb = await import("../../src/lib/localDb.ts");
const { GET, POST, DELETE } = await import(
"../../src/app/api/cli-tools/letta-settings/route.ts"
);
const { GET, POST, DELETE } = await import("../../src/app/api/cli-tools/letta-settings/route.ts");
let tmpHome: string;
let origHome: string | undefined;
@@ -40,7 +38,7 @@ function req(init?: RequestInit) {
async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -58,7 +56,7 @@ test.beforeEach(async () => {
test.afterEach(() => {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
// ── Test 1: GET without auth → 401 ──────────────────────────────────────────
@@ -189,7 +187,7 @@ test("letta-settings: error responses do not leak stack traces", async () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
delete process.env.DATA_DIR;
delete process.env.API_KEY_SECRET;
delete process.env.JWT_SECRET;

View File

@@ -59,7 +59,7 @@ function seedOmpDb() {
async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -77,7 +77,7 @@ test.beforeEach(async () => {
test.afterEach(() => {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
});
// ── Test 1: GET without auth → 401 ──────────────────────────────────────────
@@ -190,7 +190,7 @@ test("omp-settings: error responses do not leak stack traces", async () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
delete process.env.DATA_DIR;
delete process.env.API_KEY_SECRET;
delete process.env.JWT_SECRET;

View File

@@ -16,14 +16,12 @@ process.env.JWT_SECRET = "test-jwt-secret-pi";
const core = await import("../../src/lib/db/core.ts");
const localDb = await import("../../src/lib/localDb.ts");
const { GET, POST, DELETE } = await import(
"../../src/app/api/cli-tools/pi-settings/route.ts"
);
const { GET, POST, DELETE } = await import("../../src/app/api/cli-tools/pi-settings/route.ts");
async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -101,10 +99,7 @@ test("pi-settings POST: writes config.json with valid body", async () => {
}),
})
);
assert.ok(
[200, 403, 500].includes(res.status),
`Unexpected status ${res.status}`
);
assert.ok([200, 403, 500].includes(res.status), `Unexpected status ${res.status}`);
if (res.status === 200) {
const body = await res.json();
assert.equal(body.success, true);
@@ -118,7 +113,7 @@ test("pi-settings POST: writes config.json with valid body", async () => {
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -145,17 +140,14 @@ test("pi-settings DELETE: removes OmniRoute fields from existing config", async
const res = await DELETE(
new Request("http://localhost/api/cli-tools/pi-settings", { method: "DELETE" })
);
assert.ok(
[200, 403, 500].includes(res.status),
`Expected 200/403/500, got ${res.status}`
);
assert.ok([200, 403, 500].includes(res.status), `Expected 200/403/500, got ${res.status}`);
if (res.status === 200) {
const body = await res.json();
assert.equal(body.success, true);
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -189,7 +181,7 @@ test("pi-settings route.ts: does not call exec() or spawn() directly", () => {
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
delete process.env.DATA_DIR;
delete process.env.API_KEY_SECRET;
delete process.env.JWT_SECRET;

View File

@@ -16,14 +16,12 @@ process.env.JWT_SECRET = "test-jwt-secret-smelt";
const core = await import("../../src/lib/db/core.ts");
const localDb = await import("../../src/lib/localDb.ts");
const { GET, POST, DELETE } = await import(
"../../src/app/api/cli-tools/smelt-settings/route.ts"
);
const { GET, POST, DELETE } = await import("../../src/app/api/cli-tools/smelt-settings/route.ts");
async function resetStorage() {
delete process.env.INITIAL_PASSWORD;
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
}
@@ -101,10 +99,7 @@ test("smelt-settings POST: writes config.json with valid body", async () => {
}),
})
);
assert.ok(
[200, 403, 500].includes(res.status),
`Unexpected status ${res.status}`
);
assert.ok([200, 403, 500].includes(res.status), `Unexpected status ${res.status}`);
if (res.status === 200) {
const body = await res.json();
assert.equal(body.success, true);
@@ -118,7 +113,7 @@ test("smelt-settings POST: writes config.json with valid body", async () => {
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -145,17 +140,14 @@ test("smelt-settings DELETE: removes OmniRoute fields from existing config", asy
const res = await DELETE(
new Request("http://localhost/api/cli-tools/smelt-settings", { method: "DELETE" })
);
assert.ok(
[200, 403, 500].includes(res.status),
`Expected 200/403/500, got ${res.status}`
);
assert.ok([200, 403, 500].includes(res.status), `Expected 200/403/500, got ${res.status}`);
if (res.status === 200) {
const body = await res.json();
assert.equal(body.success, true);
}
} finally {
process.env.HOME = origHome;
fs.rmSync(tmpHome, { recursive: true, force: true });
fs.rmSync(tmpHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
@@ -189,7 +181,7 @@ test("smelt-settings route.ts: does not call exec() or spawn() directly", () =>
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
delete process.env.DATA_DIR;
delete process.env.API_KEY_SECRET;
delete process.env.JWT_SECRET;

View File

@@ -44,6 +44,6 @@ test("Codex Spark cooldown survives a fresh process without creating child conne
assert.equal(after.connectionId, before.connectionId);
assert.deepEqual(after.upstreamModels, ["gpt-5.5"]);
} finally {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});

View File

@@ -305,6 +305,6 @@ test("chat completions streams Codex Responses reasoning through real route HTTP
globalThis.fetch = originalFetch;
if (routeServer) await closeServer(routeServer);
core.closeDbInstance({ checkpointMode: null });
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true });
await fsp.rm(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});

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