mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-08 00:02:20 +03:00
* chore(release): open v3.8.31 development cycle * fix(mitm): exact host membership in MITM hosts test (CodeQL false positive) (#4386) getMitmToolHosts returns string[], so .includes(host) is Array.prototype.includes (exact membership). CodeQL's js/incomplete-url-substring-sanitization heuristic misreads it as a String.includes() URL-substring sanitization check and raises a HIGH alert. Switch to .some(h => h === host) — identical semantics, explicit intent, no flagged pattern. Surfaced post-v3.8.30 (#4325) once the test landed on main. Test-only change (no runtime behavior); the suite still passes and the CodeQL re-scan on merge clears the alert. * fix(codex): request reasoning summaries (#4359) Adds reasoning.summary=auto + reasoning.encrypted_content include for Codex. Thanks @xz-dev. * fix(embeddings): inject NVIDIA NIM input_type for asymmetric embed models (#4341) NVIDIA NIM asymmetric embedding models (e.g. nvidia/nv-embedqa-e5-v5) reject requests without an `input_type` ("query" | "passage") with 400 "'input_type' parameter is required". The embedding registry now carries a model-level default param for the asymmetric NVIDIA model, and the embeddings handler injects a model's default params into the upstream body only when the client omitted them, leaving a client-supplied value untouched. Reported-by: hydraromania (https://github.com/decolua/9router/issues/1378) Co-authored-by: hydraromania <252583922+hydraromania@users.noreply.github.com> * fix(api): migrate deprecated Codex [features].codex_hooks to [features].hooks (#4342) Codex renamed the `codex_hooks` feature flag to `hooks`; recent Codex CLI versions ignore the old key and warn. When OmniRoute rewrites an existing config.toml (configure/reset Codex provider) it now renames [features].codex_hooks -> [features].hooks, preserving the value and never clobbering an already-present `hooks`, then drops the deprecated key. The migration is a no-op when the flag is absent and runs on both the POST and DELETE config paths. Reported-by: Bian-Sh (https://github.com/decolua/9router/issues/1327) Co-authored-by: Bian-Sh <24520547+Bian-Sh@users.noreply.github.com> * fix(translator): drop the null flush on the same-format response path (#4344) The streaming response translator's same-format fast path returned `[chunk]` unconditionally, so the end-of-stream null/flush signal (chunk === null) propagated as a literal `[null]`. Downstream this surfaced as an empty `data: null` SSE event between chunks and crashed strict clients (e.g. Factory Droid BYOK on /v1/responses). The fast path now returns `[]` for the null flush while still passing real chunks through unchanged. Reported-by: thaitryhand (https://github.com/decolua/9router/issues/1052) Co-authored-by: thaitryhand <248103256+thaitryhand@users.noreply.github.com> * fix(translator): strip assistant echo fields on the OpenAI target path (Mistral 422) (#4350) Strict OpenAI-compatible upstreams (e.g. mistral/codestral-latest) reject client-only assistant echo fields sent back as input with 422 extra_forbidden (the report hit messages[].assistant.reasoning_content via Codex /responses). Only reasoning_content was stripped on the OpenAI target path; the sibling fields reasoning / refusal / annotations / cache_control leaked through. They are now all dropped on the non-reasoner OpenAI target path. `audio` is intentionally preserved (OpenAI audio models reference a prior assistant audio response by id; Mistral never emits audio). Reported-by: xxy9468615 (https://github.com/decolua/9router/issues/1649) Co-authored-by: xxy9468615 <63351664+xxy9468615@users.noreply.github.com> * fix(cli): honor TAILSCALE_AUTHKEY for non-interactive tailscale login (#4343) * fix(cli): honor TAILSCALE_AUTHKEY for non-interactive tailscale login (port from 9router#1263) startTailscaleLogin built `tailscale up` without ever reading process.env.TAILSCALE_AUTHKEY, so a pre-authenticated / headless daemon waited for an interactive auth URL and timed out (~15s). When TAILSCALE_AUTHKEY is set it is now passed via `--auth-key=` (an argv element to spawn(binary, args) — no shell interpolation, Hard Rule #13); when unset, behavior is unchanged. The arg builder is extracted into a pure exported `tailscaleUpArgs()` for testing. Reported-by: ipeterpetrus (https://github.com/decolua/9router/issues/1263) Co-authored-by: ipeterpetrus <93033698+ipeterpetrus@users.noreply.github.com> * chore(quality): rebaseline tailscaleTunnel.ts file-size to 1202 (#1263 +13) --------- Co-authored-by: ipeterpetrus <93033698+ipeterpetrus@users.noreply.github.com> * fix(dashboard): OAuth modal surfaces the real error on a non-JSON response (#4351) * fix(dashboard): OAuth modal surfaces real error on non-JSON responses (port from 9router#1318) The OAuth connect/reauth modal called `await res.json()` unconditionally, so a non-JSON error response (e.g. a plain-text 500 page from a build/OAuth endpoint) threw `Unexpected token 'I'...` and hid the real failure. New shared helpers parseResponseBody / getErrorMessage (src/shared/utils/api.ts) read the body safely (JSON when JSON, raw text otherwise) and produce a clean message either way; every modal fetch site now uses them. Reported-by: DNNYF (https://github.com/decolua/9router/issues/1318) Co-authored-by: DNNYF <74033321+DNNYF@users.noreply.github.com> * fix(dashboard): type OAuth modal response body as Record<string, unknown> (t11 any-budget) Switch the parseResponseBody casts from Record<string, any> to Record<string, unknown> so OAuthModal.tsx stays within its t11 explicit-any budget. getErrorMessage already takes unknown; the success paths typecheck clean under strict:false. No runtime change. --------- Co-authored-by: DNNYF <74033321+DNNYF@users.noreply.github.com> * fix(translator): accept AI SDK-style { type: image, image: data-URL } content parts (#4345) * fix(translator): accept AI SDK-style { type: image, image: "data:..." } parts (port from 9router#1330) Several OpenAI-input translators only recognized images shaped as `image_url.url` (or an object with `.source`/`.url`), so an AI SDK-style content part where `image` is a bare data-URL STRING was silently dropped before reaching a vision provider (OpenCode is one affected client; the gap is generic). The OpenAI->Claude, OpenAI->Kiro and OpenAI->Gemini/Antigravity translators now parse a string `image` data URL into each provider's native image shape (Claude base64 source, Kiro images[].source.bytes, Gemini inlineData). Reported-by: mugnimaestra (https://github.com/decolua/9router/issues/1330) Co-authored-by: mugnimaestra <13349159+mugnimaestra@users.noreply.github.com> * chore(quality): freeze openai-to-kiro.ts file-size at 807 (#1330 +9, over 800 cap) --------- Co-authored-by: mugnimaestra <13349159+mugnimaestra@users.noreply.github.com> * fix(dashboard): show a disabled connection's last error in the row (#4352) * fix(dashboard): show a disabled connection's last error in the row (port from 9router#1447) The provider card's error badge counts a disabled connection (isActive === false) that has an error — its effective status is still error/expired/unavailable — but the connection row hid the lastError text for disabled rows, so the operator saw the count without the cause. The row's error-visibility decision is extracted into shouldShowConnectionLastError() and now shows the error whenever there is one, regardless of the active toggle. Reported-by: ntdung6868 (https://github.com/decolua/9router/issues/1447) Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> * chore(quality): rebaseline ConnectionRow.tsx file-size to 942 (#1447 +1 import) --------- Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> * fix(providers): bound the OAuth connection-test probe with a timeout (#4347) * fix(providers): bound OAuth connection-test probe with a timeout (port from 9router#1449) The OAuth path of "Test Connection One-by-One" called bare fetch() with no AbortController/signal, so a provider probe that accepted the socket but never responded wedged the test queue forever. Both the initial probe and the post-refresh retry are now bounded with AbortSignal.timeout(30s) — matching the API-key path's existing budget — and a timed-out probe resolves as a failure with a clear "Test timed out after 30s" message in the route's normal error shape. Reported-by: ntdung6868 (https://github.com/decolua/9router/issues/1449) Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> * chore(quality): rebaseline providers test route file-size to 887 (#1449 + sibling #1444 growth) --------- Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> * fix(providers): label a deactivated account distinctly from a revoked token (#4353) A Codex connection whose OAuth refresh is fully healthy but whose ChatGPT account has been deactivated by the provider gets a 401 from the upstream API. The connection test labeled that the same as a bad credential ("Token invalid or revoked" -> upstream_auth_error), so an operator could not tell a deactivated account from a revoked token. The test now reads the 401/403 body and, when it indicates account deactivation, classifies it as account_deactivated (which the dashboard already renders as "Account Deactivated"); a plain auth 401 is unchanged. Reported-by: ntdung6868 (https://github.com/decolua/9router/issues/1444) Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> * fix(db): cascade-delete orphaned model aliases when a provider is removed (#4348) * fix(db): cascade-delete orphaned model aliases when a provider is removed (port from 9router#1409) Deleting a custom provider removed its connections and node but left the imported model-alias rows (key=<alias>, value="<providerId>/<model>") behind, so re-importing the same provider was blocked by stale "already exists" aliases. Add a deleteModelAliasesForProvider(providerId) DB helper that drops every alias whose stored value begins with "<providerId>/", and call it from the provider-node DELETE handler so a fresh import is unblocked. Reported-by: nguyenvanhuy0612 (https://github.com/decolua/9router/issues/1409) Co-authored-by: nguyenvanhuy0612 <57367674+nguyenvanhuy0612@users.noreply.github.com> * chore(quality): rebaseline models.ts file-size to 1221 (#1409 + sibling #1294 growth) --------- Co-authored-by: nguyenvanhuy0612 <57367674+nguyenvanhuy0612@users.noreply.github.com> * fix(api): persist max_input_tokens/max_output_tokens when adding a custom model (#4349) The POST /api/provider-models handler read the rest of the body but never the two token-limit fields, and addCustomModel() had no parameter for them, so the form values were dropped on write while the DB layer and /v1/models catalog already round-trip inputTokenLimit/outputTokenLimit. Accept the two optional limits in the schema, forward them through the handler, and persist them in addCustomModel(). TDD: failing-then-passing unit test. Reported-by: codename-zen (https://github.com/decolua/9router/issues/1294) Co-authored-by: codename-zen <263238141+codename-zen@users.noreply.github.com> * docs: feature-documentation catch-up (v3.8.20 → v3.8.30) (#4391) One-time reconciliation of the docs with every user-facing feature shipped since v3.8.20 (we had never done a dedicated pass, so debt had accumulated): - README: new '✨ What's New' section (curated v3.8.20→v3.8.30 highlights). - New guides: CLI-INTEGRATIONS (all setup-*/launch commands), MITM-TPROXY-DECRYPT (transparent-decrypt epic), CONTEXT_EDITING (delegated Anthropic clear_tool_uses). - Refreshed: AUTO-COMBO (auto/<category>:<tier> + Arena-ELO), API_REFERENCE (x-omniroute-no-memory), MEMORY (int8 quantization + off-by-default), RESILIENCE (model-lockout success-decay), RTK, AGENTBRIDGE, TRAFFIC_INSPECTOR, GUARDRAILS, CLOUD_AGENT, ENVIRONMENT, SETUP_GUIDE, CLI-TOOLS, MCP-SERVER. - Regenerated PROVIDER_REFERENCE (231 providers); synced the count in README/CLAUDE/AGENTS. - Allowlisted external-tool env vars (OPENAI_API_BASE, PROMPTFOO_PROVIDER_KEY) and the STREAM_RECOVERY config-object name in the docs-accuracy gates. All claims source-verified; check:docs-all (sync/counts/env/links/fabricated) passes. Going forward this runs every release via generate-release step 6b. * fix(executors): don't inject thinking when tool_choice forces a tool (native Claude) (#4389) Forced tool_choice now strips the adaptive thinking injection to avoid Anthropic 400. Thanks @NomenAK. * fix(translator): Gemini accepts HTTP/HTTPS image URLs (port from 9router#344) (#4373) OpenAI-style `image_url` parts with an `http://` or `https://` URL reached `convertOpenAIContentToParts` and were dropped with only a `console.warn`, because Gemini's `inlineData` requires base64 (the helper is synchronous and cannot fetch+encode upstream assets). Gemini's `Part` schema, however, natively accepts `fileData: { fileUri }` for remote URIs — the model fetches the asset itself. The helper now emits a `fileData` part (`mimeType: "image/*"`, inferred upstream on fetch) for HTTP/HTTPS URLs instead of silently dropping them. Vision requests that pass a URL — not a data: URI — now reach Gemini intact. No behavioral change for: - `data:` URIs → still emitted as `inlineData` with the parsed media type. - Unsupported schemes (e.g. `ftp:`) → still skipped (Gemini would reject them). The openai-to-claude side already passed HTTP/HTTPS URLs through as `source: { type: "url", url }` (lines 573–578) — the upstream PR's Claude-side change was already covered. Regression test: tests/unit/gemini-helper-http-image-url-port344.test.ts (4 cases: https URL, http URL, data: URI no-regression, unsupported-scheme guard). Inspired-by: https://github.com/decolua/9router/pull/344 Co-authored-by: Ibrahim Ryan <ryan@nuevanext.com> * fix(executors): strip stream_options for qwen non-streaming / thinking Claude Code requests (port from 9router#663) (#4374) Claude-Code-compatible providers force the executor-level `stream` flag on via `upstreamStream = stream || isClaudeCodeCompatible` (open-sse/handlers/chatCore.ts), but the outgoing body keeps the caller's original `stream: false`. The shared `stream && targetFormat === "openai"` branch in DefaultExecutor.transformRequest then injected `stream_options: { include_usage: true }` onto a body that still said `stream: false`, and qwen upstream rejected the request with `400 "'stream_options' only set this when you set stream: true"`. The same rejection surfaced when the body carried `thinking` / `enable_thinking`. The qwen branch now skips the injection (and strips any client-sent `stream_options`) when the body explicitly says `stream: false` or requests thinking, leaving regular qwen streaming requests with the include_usage injection intact. Other providers are unaffected. Adds a TDD regression with 4 cases covering both opt-out paths and the normal-streaming positive control. Inspired-by: https://github.com/decolua/9router/pull/663 Co-authored-by: anuragg-saxenaa <anuragg.saxenaa@gmail.com> * fix(security): scope OAuth callback postMessage to a trusted-origin allowlist (port from 9router#998) (#4372) The OAuth callback at `/callback` previously fell back to `window.opener.postMessage({ code, state, ... }, "*")` whenever the opener was cross-origin. The fallback was intended to support remote-OmniRoute + local-loopback callbacks (where opener and callback live on different origins), but the same code path also delivers the OAuth code/state to any hostile opener that pops the well-known callback URL — letting that attacker complete the OAuth flow as the user. Replace the wildcard fallback with iteration over a fixed allowlist: `window.location.origin` (same-origin parent — the popup-mode dashboard) plus Codex's fixed loopback helper (`http://localhost:1455` and the IPv4 literal `http://127.0.0.1:1455`). The browser drops `postMessage` to any opener whose actual origin is not in `targetOrigin`, so the message reaches only known parents and is silently dropped for any other. The same-origin fallback path is unchanged — methods 2 (`BroadcastChannel`) and 3 (`localStorage` storage event) still cover same-origin openers that COOP severed. The `openerSameOrigin` probe stays in place to drive the auto-close vs manual-copy UI decision (no behavior change for the success path). Adds a regression test (`tests/unit/ui/oauth-callback-postmessage-scope.test.tsx`) that mounts the page with a stubbed cross-origin opener and asserts no `postMessage` call ever uses `"*"` and every call lands on an allowlisted origin. The test failed against the pre-fix code (red), passes after the fix (green) — TDD per CLAUDE.md hard rule #18. Partial port of upstream decolua/9router#998: the upstream PR also re-enabled TLS verification on a DNS-bypass fetch in `open-sse/utils/proxyFetch.js`; that part is N/A here because OmniRoute's `proxyFetch.ts` never disabled TLS verification (no `rejectUnauthorized: false` anywhere in the file). Inspired-by: https://github.com/decolua/9router/pull/998 Co-authored-by: aeonframework <aeon@aeonframework.dev> * fix(sse): default combo per-target timeout to 120s for fast failover (#4365) Combo per-target timeout inherited the full FETCH_TIMEOUT_MS (600s) when a combo did not set its own targetTimeoutMs, so a single hung/slow target stalled the whole combo for up to 10 minutes before falling through to the next model. Introduce DEFAULT_COMBO_TARGET_TIMEOUT_MS (120s) as the unset-default in resolveComboTargetTimeoutMs (new 3rd arg) and wire it in phaseComboSetup. The upstream ceiling (600s) and per-combo opt-out (targetTimeoutMs, up to the ceiling) are preserved; single non-combo requests are unchanged. For streaming requests this only bounds time-to-first-headers, so token generation is not cut short. TDD: failing-then-passing unit test in tests/unit/combo-config.test.ts. * refactor(combo): de-dup exhausted-target skip predicate across both dispatchers (#4362) Primeiro incremento da de-dup dos 2 dispatchers de combo (handleComboChat + handleRoundRobinCombo). O bloco de pre-check #1731/#1731v2 (skip de target já exhausted no provider/connection) era BYTE-IDÊNTICO nos dois (mesmas condições, mesmas mensagens), diferindo só na tag de log e no control-flow. - comboPredicates.ts: getExhaustedTargetSkipReason(target, exhaustedProviders, exhaustedConnections) — predicate PURO que retorna a mensagem de skip (ou null); cada dispatcher mantém seu próprio log-tag + control-flow (return null / continue) + fallbackCount. No mutate do stryker (cobertura de mutação). - combo.ts: −20 linhas (os 2 blocos viram 1 chamada cada). - 7 testes de caracterização travam condições + strings exatas. Comportamento preservado: 376/376 testes combo (357 caracterização + 7 novos), integração sse-correctness 5/5, typecheck 0, complexity neutro (1895), file-size encolhe. Próximo incremento: de-dup do error-handling/exhausted-tracking (handleTargetError). * refactor(combo): de-dup upstream-error exhaustion classification across both dispatchers (#4366) Segundo incremento da de-dup dos 2 dispatchers (handleTargetError). Após cada erro de target, ambos rodavam um bloco quase-idêntico que marca o provider exhausted (#1731), a conexão connection-errored (#1731v2) ou o provider transiently rate-limited. - combo/targetExhaustion.ts: applyComboTargetExhaustion(target, opts) — atualiza os 3 Sets de exhaustion e retorna providerExhausted. As MUTAÇÕES de Set (que dirigem o skip de targets, lidas por getExhaustedTargetSkipReason) são BYTE-IDÊNTICAS nos dois; as diferenças reais viram parâmetros: tag, allAccountsRateLimited (termo extra do RR, false no handleComboChat), exhaustedLogLevel (info no handleComboChat, debug no RR). Connection-level extraído p/ markConnectionLevelExhaustion (privado, <15 complexity). - combo.ts: −73 linhas; 4 imports órfãos removidos. - 7 testes de caracterização travam as mutações + o return. ÚNICA mudança de comportamento: o WORDING das mensagens de log do RR ganha o sufixo 'on remaining targets' (cosmético; mesmo #code, mesmas mutações, mesmos níveis de log). 376/376 combo (caracterização preservada), integração sse 5/5, typecheck 0, complexity neutro (1895), file-size encolhe. * refactor(chatCore): extract checkHeapPressureGuard leaf (god-file decomposition start) (#4371) Primeiro incremento da decomposição do chatCore.ts (5127 LOC, hot-path mais quente). O guard de memória do topo do handleChatCore (rejeita 503 quando o heap V8 passa o threshold de shed) vira um leaf testável, co-locado com o threshold em heapPressure.ts. - heapPressure.ts: checkHeapPressureGuard(heapUsedMb, thresholdMb) — retorna o result 503 pronto ou null. Byte-idêntico ao guard inline (mesmo check, mesma 503, mesmo warn). A figura de heap fica em telemetria INTERNA, nunca no response do cliente (Hard Rule #12). - chatCore.ts: o bloco inline (~22 ln) vira 3 linhas; import órfão de HEAP_PRESSURE_THRESHOLD_MB trocado por checkHeapPressureGuard. - 3 testes novos (incl. assert Rule #12: o MB medido não vaza no payload). complexity-baseline 1895->1896: drift de base pós-#4338 (medido com minhas mudanças stashed = 1896); esta mudança é complexity-NEUTRA (helper complexity 2, handleChatCore só perde código). 190/190 chatcore tests, typecheck 0, file-size encolhe. * Localize CLI and stabilize fetch, memory, and coverage handling (#4383) en-only i18n, fetch-start-timeout hardening, EngineConfigPage icon fix, CI build-artifact-reuse overhaul. Memory production hunk dropped as a no-op (tests kept). Thanks @JxnLexn. * test(combo): reset circuit breakers between stream-readiness cases (restore green) (#4396) The combo-dispatch cases in combo-stream-readiness-fallback.test.ts deliberately fail `glm` (zombie streams / repeated 504s), which legitimately trips the per-provider circuit breaker. That OPEN state is a module-level singleton, so it leaked into the next test and combo.ts then SKIPPED `glm/*` targets entirely ("Skipping … circuit breaker OPEN"). That made "combo does not retry stream readiness timeouts on the same model" never attempt glm/zombie — expected ['glm/zombie','openai/gpt-5.4-mini'] but got ['openai/gpt-5.4-mini']. This was a pre-existing red on release/v3.8.31 (present at the cycle-open tip), order-dependent: the test passes in isolation, fails after the preceding cases. Add a test.beforeEach(resetAllCircuitBreakers) so each scenario starts from a clean breaker slate. Test-isolation only — the breaker behavior is correct and no production code or assertion changes. Full combo suite: 390/390 green. * fix(cli): decline confirm() cleanly on non-interactive stdin + document contexts workflow The `contexts remove` command already has `--yes` to skip confirmation, but when run without it under a non-interactive stdin (pipe, CI, EOF) the [y/N] prompt could never be answered — the readline question stayed pending and Node warned about an "unsettled top-level await" at exit. confirm() now detects `!process.stdin.isTTY` and declines cleanly (returns false), pointing at `--yes` for non-interactive use. Exported confirm() for a regression test. Docs: REMOTE-MODE.md gains a full "Managing contexts" section (list/current/use to switch between remote and local, add/show/rename, remove with --yes, export/import), fixes a `context current` -> `contexts current` typo, and the README remote-mode snippet now shows switching back to local. Verified against the live CLI: command signatures, --yes, and the non-TTY decline path all behave as documented. Tests: cli-contexts.test.ts asserts confirm() declines on non-TTY stdin (RED before, GREEN after). All docs gates (fabricated/links/symbols) pass. * ci(t11): bump any-budget for executors/base.ts (2 false-positive "any" strings) Unblocks the Fast Quality Gates on release/v3.8.31: `check:any-budget:t11` was red on `open-sse/executors/base.ts` for ALL PRs (pre-existing base drift, unrelated to this branch). The checker counts `\bany\b` after stripping comments but NOT strings, and the native-Claude tool_choice logic uses the API value `"any"` in two string literals (`tb.tool_choice === "any"`, `.type === "any"`). There are zero actual TypeScript `any` types in the file — budget set to the matched count, mirroring the existing cursor.ts false-positive entry right below it. * perf: combos UI split + next config + 1-click redis + bifrost sidecar (#3932) (#4381) Combos UI split + next.config perf + 1-click local Redis launcher + bifrost relay. Review fixes (co-author): --rm/--restart conflict, error sanitization, UI/route names, dead-guard re-doc, bifrost Zod, IPv6 + CLI test fixes. Thanks @KooshaPari. * fix(plugin): prefix OC static-catalog combo+raw keys with providerId (#4384) OC parses model ids on '/'; combo keys now carry 'omniroute/' (was 'combo/'). Live-validated against the VPS via OpenCode. Thanks @herjarsa. * docs(env): document TAILSCALE_AUTHKEY (env/docs contract drift on .31) Second pre-existing base-drift fix needed to get Fast Quality Gates green: the `repository contract is in sync` test (check:env-doc-sync) was red on ALL .31 PRs. PR #4343 added a code reference to `process.env.TAILSCALE_AUTHKEY` (src/lib/tailscaleTunnel.ts) for non-interactive `tailscale up`, but never added the var to .env.example / docs/reference/ENVIRONMENT.md — the contract requires code vars to appear in both. Add the (commented) entry to .env.example next to TAILSCALE_BIN and a row to ENVIRONMENT.md. Verified: `node scripts/check/check-env-doc-sync.mjs` → in sync. Unrelated to this branch's confirm()/docs change; surfaced because touching a quality script triggers the TIA fail-safe full unit suite. * chore(release): v3.8.31 — 2026-06-20 Finalize the v3.8.31 release: reconcile the CHANGELOG (full commit-to-bullet coverage, 26 bullets across Features/Fixed/Security/Maintenance), refresh the README What's New section, back-fill the .30/.31 sections into the 41 i18n CHANGELOG mirrors, and add TAILSCALE_AUTHKEY to the env contract (.env.example + ENVIRONMENT.md). * chore(release): align mitm-hosts test comment with main to clear merge conflict The same CodeQL false-positive fix landed twice — #4386 on release/v3.8.31 and #4387 directly on main — with only the explanatory comment differing (the assertion is byte-identical). Adopt main's comment wording on the release branch so the release PR merges without a comment-only conflict. * test(translator): align stale openai->gemini remote-URL tests with #4373 Third pre-existing base-drift fix to get Fast Quality Gates green on .31. PR #4373 ('Gemini accepts HTTP/HTTPS image URLs', port of 9router#344) intentionally changed convertOpenAIContentToParts so remote http(s) image URLs pass through as a native `fileData: { fileUri }` part instead of the old #2807 drop+warn — and added its own test (gemini-helper-http-image-url-port344.test.ts) for the new behavior. But it left three tests in translator-openai-to-gemini.test.ts asserting the OLD drop+warn contract, so they fail deterministically (verified: the file fails in isolation on clean .31). These only surface under the TIA fail-safe FULL suite, which a quality- script touch triggers. Align the three stale tests to the real, intended behavior (captured by running the function): remote URLs -> fileData.fileUri (mimeType image/*), still never inlineData (the sync path cannot fetch+encode). This is test-vs-code alignment to a deliberate, separately-tested change — not a weakened assertion. 40/40 in the file; 94/94 across the translator + env-doc combo that previously failed. * chore(quality): reconcile complexity baseline 1896->1900 (/review-prs v3.8.31 batch) (#4410) * fix(release): reconcile full-CI drift for v3.8.31 (gemini tests #4373, any-budget #4389, masking allowlist #4384) The release PR's full CI surfaced cumulative cycle drift the per-PR fast gates skip: - tests/unit/translator-openai-to-gemini.test.ts: realign 3 cases to #4373's HTTP/HTTPS-URL fileData pass-through (they asserted the old warn-and-drop). - scripts/check/check-t11-any-budget.mjs: base.ts budget 0→2 — #4389 compares tool_choice against the string literal "any" (not a TS any type). - config/quality/test-masking-allowlist.json: allowlist #4384's opencode combos net-assert reduction (obsolete combo/ namespace removed). No production behavior change. * chore(release): re-trigger full CI for v3.8.31 finalization Force a fresh pull_request CI run on the head carrying the cycle-drift fixes (gemini #4373 tests, any-budget #4389, masking allowlist #4384) — the prior synchronize event did not spawn a ci.yml run. * chore: re-trigger CI (no Actions runs registered for prior push) --------- Co-authored-by: Xiangzhe <32761048+xz-dev@users.noreply.github.com> Co-authored-by: hydraromania <252583922+hydraromania@users.noreply.github.com> Co-authored-by: Bian-Sh <24520547+Bian-Sh@users.noreply.github.com> Co-authored-by: thaitryhand <248103256+thaitryhand@users.noreply.github.com> Co-authored-by: xxy9468615 <63351664+xxy9468615@users.noreply.github.com> Co-authored-by: ipeterpetrus <93033698+ipeterpetrus@users.noreply.github.com> Co-authored-by: DNNYF <74033321+DNNYF@users.noreply.github.com> Co-authored-by: mugnimaestra <13349159+mugnimaestra@users.noreply.github.com> Co-authored-by: ntdung6868 <103993527+ntdung6868@users.noreply.github.com> Co-authored-by: nguyenvanhuy0612 <57367674+nguyenvanhuy0612@users.noreply.github.com> Co-authored-by: codename-zen <263238141+codename-zen@users.noreply.github.com> Co-authored-by: Anton <39598727+NomenAK@users.noreply.github.com> Co-authored-by: Ibrahim Ryan <ryan@nuevanext.com> Co-authored-by: anuragg-saxenaa <anuragg.saxenaa@gmail.com> Co-authored-by: aeonframework <aeon@aeonframework.dev> Co-authored-by: Jan Leon <Jan.gaschler@gmail.com> Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Co-authored-by: Hernan Javier Ardila Sanchez <hjasgr@gmail.com>
271 lines
9.2 KiB
JavaScript
271 lines
9.2 KiB
JavaScript
import { t } from "../i18n.mjs";
|
|
import { emit } from "../output.mjs";
|
|
import { loadContexts, saveContexts, resolveActiveContext } from "../contexts.mjs";
|
|
|
|
/** Auth label for a context: prefers the scoped accessToken over the legacy apiKey. */
|
|
function authLabel(c) {
|
|
if (c?.accessToken) return "token";
|
|
if (c?.apiKey) return "key";
|
|
return "✗";
|
|
}
|
|
|
|
export async function confirm(msg) {
|
|
// Non-interactive stdin (pipe, CI, EOF) cannot answer a [y/N] prompt. Asking
|
|
// anyway leaves the readline question pending forever — Node then warns about an
|
|
// "unsettled top-level await" at exit. Decline cleanly instead and point at the
|
|
// non-interactive escape hatch so scripted callers fail safe rather than hang.
|
|
if (!process.stdin.isTTY) {
|
|
process.stderr.write(`${msg} [y/N] (non-interactive stdin — declined; pass --yes to confirm)\n`);
|
|
return false;
|
|
}
|
|
const readline = await import("node:readline");
|
|
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
const answer = await new Promise((r) => rl.question(`${msg} [y/N] `, r));
|
|
rl.close();
|
|
return /^y(es)?$/i.test(answer);
|
|
}
|
|
|
|
function maskKey(k) {
|
|
if (!k) return null;
|
|
if (k.length <= 8) return "***";
|
|
return `${k.slice(0, 6)}***${k.slice(-4)}`;
|
|
}
|
|
|
|
export function registerContexts(program) {
|
|
const ctx = program
|
|
.command("contexts")
|
|
.description(t("config.contexts.description") || "Manage server contexts/profiles");
|
|
|
|
ctx
|
|
.command("list")
|
|
.description("List all contexts")
|
|
.action(async (opts, cmd) => {
|
|
const globalOpts = cmd.optsWithGlobals();
|
|
const cfg = loadContexts();
|
|
const rows = Object.entries(cfg.contexts || {}).map(([name, c]) => ({
|
|
active: name === (cfg.currentContext || "default") ? "●" : "",
|
|
name,
|
|
baseUrl: c.baseUrl || "",
|
|
auth: authLabel(c),
|
|
scope: c.scope || "",
|
|
description: c.description || "",
|
|
}));
|
|
emit(rows, globalOpts, [
|
|
{ key: "active", header: "" },
|
|
{ key: "name", header: "Name" },
|
|
{ key: "baseUrl", header: "Base URL" },
|
|
{ key: "auth", header: "Auth" },
|
|
{ key: "scope", header: "Scope" },
|
|
{ key: "description", header: "Description" },
|
|
]);
|
|
});
|
|
|
|
ctx
|
|
.command("add <name>")
|
|
.description("Add a new context")
|
|
.requiredOption("--url <u>", "Base URL")
|
|
.option("--api-key <k>", "Legacy inference API key")
|
|
.option("--api-key-stdin", "Read API key from stdin")
|
|
.option("--access-token <t>", "Scoped CLI access token (preferred over --api-key)")
|
|
.option("--access-token-stdin", "Read access token from stdin")
|
|
.option("--scope <s>", "Token scope hint for display (read|write|admin)")
|
|
.option("--description <d>", "Context description")
|
|
.action(async (name, opts) => {
|
|
const cfg = loadContexts();
|
|
if (cfg.contexts?.[name]) {
|
|
process.stderr.write(`Context '${name}' already exists. Remove or rename first.\n`);
|
|
process.exit(2);
|
|
}
|
|
let apiKey = opts.apiKey || null;
|
|
let accessToken = opts.accessToken || null;
|
|
if (opts.apiKeyStdin || opts.accessTokenStdin) {
|
|
const chunks = [];
|
|
for await (const c of process.stdin) chunks.push(c);
|
|
const value = chunks.join("").trim() || null;
|
|
if (opts.accessTokenStdin) accessToken = value;
|
|
else apiKey = value;
|
|
}
|
|
cfg.contexts = cfg.contexts || {};
|
|
cfg.contexts[name] = {
|
|
baseUrl: opts.url,
|
|
accessToken: accessToken || undefined,
|
|
apiKey,
|
|
scope: opts.scope || undefined,
|
|
description: opts.description || undefined,
|
|
};
|
|
saveContexts(cfg);
|
|
process.stdout.write(`Added context '${name}'\n`);
|
|
});
|
|
|
|
ctx
|
|
.command("use <name>")
|
|
.description("Switch active context")
|
|
.action((name) => {
|
|
const cfg = loadContexts();
|
|
if (!cfg.contexts?.[name]) {
|
|
process.stderr.write(`No such context: ${name}\n`);
|
|
process.exit(2);
|
|
}
|
|
cfg.currentContext = name;
|
|
saveContexts(cfg);
|
|
process.stdout.write(`Active context: ${name}\n`);
|
|
});
|
|
|
|
ctx
|
|
.command("current")
|
|
.description("Show the active context (server, auth, scope)")
|
|
.option("--name-only", "Print just the context name (legacy behavior)")
|
|
.action((opts, cmd) => {
|
|
const globalOpts = cmd.optsWithGlobals();
|
|
const cfg = loadContexts();
|
|
const name = cfg.currentContext || cfg.activeProfile || "default";
|
|
if (opts.nameOnly) {
|
|
process.stdout.write(`${name}\n`);
|
|
return;
|
|
}
|
|
const c = resolveActiveContext(name);
|
|
emit(
|
|
{
|
|
name,
|
|
baseUrl: c.baseUrl || "",
|
|
auth: authLabel(c),
|
|
scope: c.scope || "",
|
|
description: c.description || "",
|
|
},
|
|
globalOpts
|
|
);
|
|
});
|
|
|
|
ctx
|
|
.command("show <name>")
|
|
.description("Show context details")
|
|
.action((name, opts, cmd) => {
|
|
const globalOpts = cmd.optsWithGlobals();
|
|
const cfg = loadContexts();
|
|
const c = cfg.contexts?.[name];
|
|
if (!c) {
|
|
process.stderr.write(`No such context: ${name}\n`);
|
|
process.exit(2);
|
|
}
|
|
const display = {
|
|
name,
|
|
baseUrl: c.baseUrl,
|
|
accessToken: maskKey(c.accessToken),
|
|
apiKey: maskKey(c.apiKey),
|
|
scope: c.scope,
|
|
description: c.description,
|
|
};
|
|
emit(display, globalOpts);
|
|
});
|
|
|
|
ctx
|
|
.command("remove <name>")
|
|
.description("Remove a context")
|
|
.option("--yes", "Skip confirmation")
|
|
.action(async (name, opts) => {
|
|
if (!opts.yes) {
|
|
const ok = await confirm(`Remove context '${name}'?`);
|
|
if (!ok) {
|
|
process.stdout.write("Cancelled.\n");
|
|
return;
|
|
}
|
|
}
|
|
const cfg = loadContexts();
|
|
if (!cfg.contexts?.[name]) {
|
|
process.stderr.write(`No such context: ${name}\n`);
|
|
process.exit(2);
|
|
}
|
|
if (name === "default") {
|
|
process.stderr.write("Cannot remove default context.\n");
|
|
process.exit(2);
|
|
}
|
|
delete cfg.contexts[name];
|
|
if (cfg.currentContext === name) cfg.currentContext = "default";
|
|
saveContexts(cfg);
|
|
process.stdout.write(`Removed context '${name}'\n`);
|
|
});
|
|
|
|
ctx
|
|
.command("rename <old> <new>")
|
|
.description("Rename a context")
|
|
.action((oldName, newName) => {
|
|
const cfg = loadContexts();
|
|
if (!cfg.contexts?.[oldName]) {
|
|
process.stderr.write(`No such context: ${oldName}\n`);
|
|
process.exit(2);
|
|
}
|
|
if (cfg.contexts[newName]) {
|
|
process.stderr.write(`Context '${newName}' already exists.\n`);
|
|
process.exit(2);
|
|
}
|
|
cfg.contexts[newName] = cfg.contexts[oldName];
|
|
delete cfg.contexts[oldName];
|
|
if (cfg.currentContext === oldName) cfg.currentContext = newName;
|
|
saveContexts(cfg);
|
|
process.stdout.write(`Renamed '${oldName}' → '${newName}'\n`);
|
|
});
|
|
|
|
ctx
|
|
.command("export")
|
|
.description("Export contexts to JSON")
|
|
.option("--out <path>", "Output file path (default: stdout)")
|
|
.option("--no-secrets", "Omit API keys from export")
|
|
.action(async (opts, cmd) => {
|
|
const cfg = loadContexts();
|
|
const out = JSON.parse(JSON.stringify(cfg));
|
|
if (opts.noSecrets) {
|
|
for (const c of Object.values(out.contexts || {})) {
|
|
c.apiKey = null;
|
|
delete c.accessToken;
|
|
}
|
|
}
|
|
const json = JSON.stringify(out, null, 2);
|
|
if (opts.out) {
|
|
const { writeFileSync } = await import("node:fs");
|
|
writeFileSync(opts.out, json);
|
|
process.stdout.write(`Exported to ${opts.out}\n`);
|
|
} else {
|
|
process.stdout.write(json + "\n");
|
|
}
|
|
});
|
|
|
|
ctx
|
|
.command("import <file>")
|
|
.description("Import contexts from a JSON file")
|
|
.option("--merge", "Merge with existing contexts (default: overwrite)")
|
|
.action(async (file, opts) => {
|
|
const { readFileSync } = await import("node:fs");
|
|
let imported;
|
|
try {
|
|
imported = JSON.parse(readFileSync(file, "utf8"));
|
|
} catch (e) {
|
|
process.stderr.write(
|
|
`Cannot read ${file}: ${e instanceof Error ? e.message : String(e)}\n`
|
|
);
|
|
process.exit(1);
|
|
}
|
|
const cfg = opts.merge
|
|
? loadContexts()
|
|
: { version: 1, currentContext: "default", contexts: {} };
|
|
const incoming = imported.contexts || {};
|
|
let count = 0;
|
|
for (const [name, raw] of Object.entries(incoming)) {
|
|
if (typeof name !== "string" || !name) continue;
|
|
const c = raw && typeof raw === "object" ? /** @type {Record<string,unknown>} */ (raw) : {};
|
|
cfg.contexts[name] = {
|
|
baseUrl: typeof c.baseUrl === "string" ? c.baseUrl : "http://localhost:20128",
|
|
accessToken: typeof c.accessToken === "string" ? c.accessToken : undefined,
|
|
apiKey: typeof c.apiKey === "string" ? c.apiKey : null,
|
|
scope: typeof c.scope === "string" ? c.scope : undefined,
|
|
description: typeof c.description === "string" ? c.description : undefined,
|
|
};
|
|
count++;
|
|
}
|
|
if (!opts.merge && typeof imported.currentContext === "string") {
|
|
cfg.currentContext = imported.currentContext;
|
|
}
|
|
saveContexts(cfg);
|
|
process.stdout.write(`Imported ${count} context(s)\n`);
|
|
});
|
|
}
|