Compare commits

..

10 Commits

Author SHA1 Message Date
Xiangzhe
7b45e21ea5 fix(providers): unify learned and declared reasoning-effort clamps on nearest-tier semantics (#11295)
The reactively-learned reasoning-effort clamp (clampToLearned, populated from
upstream 4xx enum errors on custom/unregistered provider+model pairs) used
downgrade-only semantics — greatest accepted value <= demand. The statically-
declared clamp (registry supportedThinkingEfforts, sanitizeReasoningEffortForProvider)
already used nearest-tier semantics — smallest accepted value >= demand,
falling back to the ceiling when demand exceeds every accepted value (#11274's
DeepSeek native mapping is the precedent for that direction).

For the identical accepted set, e.g. {low, high, max}, the two paths disagreed:
a request for "medium" clamped to "low" via the learned path but "high" via the
declared path — same inputs, opposite outputs, depending only on whether the
model happened to have a static registry entry. The owner decided to unify on
nearest-tier for both.

This also fixes a standalone bug in the old learned clamp: a request BELOW the
learned floor (e.g. none/minimal on a model that only ever advertised
{low,high,max}) returned null (no clamp), so the too-low value reached the
upstream unchanged and 400'd again on every subsequent request without ever
learning a lower floor. Nearest-tier fixes this for free: the smallest accepted
value is always >= any demand below the floor.

Risk: the learned-cap Map is in-memory and resets on every deploy, but while
populated this changes user-visible clamped values for providers that have
already been reactively taught an accepted set — e.g. a provider previously
serving "low" for a "medium" request now serves "high" instead.

Realigned pre-existing tests in tests/unit/learned-reasoning-effort-caps.test.ts,
tests/unit/reasoning-effort-clamp-and-retry.test.ts, and
tests/unit/reasoning-effort-learned-capability.test.ts that asserted the old
downgrade-only direction (all from #11232 and earlier). Added
tests/unit/reasoning-effort-clamp-direction-consistency.test.ts asserting the
learned and declared paths now map the same accepted set identically, and that
a sub-floor demand clamps up instead of passing through unmapped.
2026-08-23 21:36:34 -03:00
Óscar Fonseca
a3c3117254 fix(translator): preserve omitted subagent session IDs (#11297)
Validated on a 3-PR combined board: openai-responses-opencode-subagent-sessionid within the board's 20/20, typecheck:core + check:open-sse-typecheck clean, gates within baseline. Uses null as the omission sentinel for OpenCode's optional default-less subagent.sessionID under Responses/Codex strict mode (which required every declared property) — models can now omit the field to spawn a new child instead of inventing filler IDs; the null is stripped before OpenCode sees the call, real child IDs pass through unchanged. Thank you @ofonseca-pyming!
2026-08-23 21:03:16 -03:00
Ravi Tharuma
d9a883ec53 docs(openapi): declare spec endpoint management auth (#11299)
Validated on a 3-PR combined board: openapi-security-tiers 1/1 within the board's 20/20, typecheck:core clean, gates within baseline. Declares GET /api/openapi/spec's real ManagementSessionAuth contract (conditional on requireLogin) — no runtime behavior change, just the doc catching up. Closes #10851. Thank you @RaviTharuma!
2026-08-23 21:03:11 -03:00
Ravi Tharuma
7913447bf0 docs(db): align cache guide with runtime settings (#11018) (#11294)
Validated on a 3-PR combined board: 11018-database-cache-docs 1/1 within the board's 20/20 focused suite, typecheck:core clean, gates within baseline. DATABASE_GUIDE.md now cites the real 64 MiB runtime default and the 1..1,000,000 KiB range, locked with a regression test. Thank you @RaviTharuma!
2026-08-23 21:03:07 -03:00
Diego Rodrigues de Sa e Souza
67fba53190 fix(tests): drain base-red cluster from 2026-08-23 merges (#9985) (#11280)
Validated on a 2-PR combined board: 158/158 across the drain's 17 focused test files (incl. integration qdrant-routes), typecheck:core + dashboard-typecheck clean, env-doc-sync, mutation-test-coverage, cli-i18n, file-size, changelog-integrity all green. Every red discriminated per-item as stale-test (contract intentionally moved, citing the origin PR) or real bug (fixed, never masked) across 3 base-advance waves (#9985): stryker registration, i18n gaps (health.* namespace mismatch + pt-BR parity), combo-token-window pins (872K), CLI catalog counts (prime-agent), the isComboStep single-target reasoning-transport-fallback regression, container-guard hermeticity, and 26 unsuppressed ESLint errors from the wave (typed qdrant adapter, import trim, justified disable with precedent). Drains the accumulated base-red cluster.
2026-08-23 20:32:46 -03:00
Diego Rodrigues de Sa e Souza
9cb91dee74 fix(security): close round-3 advisories (v7g9, x7vm, j7j4, jcm5) + exposure warning (wmgv) (#11261)
Validated on a 2-PR combined board: routeGuard 36/36 (within the 68/68 focused-file total), a2a-task-owner-idor 7/7, a2a-tasks-auth, search-baseurl-ssrf-guard, cli-serve-hostname, spawn-capable-prefixes-client-safe all green, typecheck:core + dashboard-typecheck clean, gates within baseline. Five real High-severity advisories fixed with TDD (each failing-then-passing): settings export/import-json ALWAYS_PROTECTED completion, MITM route LOCAL_ONLY+SPAWN_CAPABLE gating, search baseUrl SSRF/IMDS guard, A2A REST task auth+ownership (previously none at all), and the loud boot exposure warning. GHSA-cjv9 confirmed already closed on this base (verified). Round 3 of the advisory sweep.
2026-08-23 20:32:42 -03:00
ignamiranda
92f58603f9 Beginner UX: purpose-first Traffic Inspector header (#11283)
Validated on a 3-PR combined board: traffic-inspector-beginner-header suite green within the board's 30/30, typecheck:core + dashboard-typecheck clean, gates within baseline. Purpose-first orientation header for Traffic Inspector, existing inspection UI untouched. Thank you @ignamiranda!
2026-08-23 19:08:27 -03:00
ignamiranda
7cec8e32fd Beginner UX: Essentials sidebar preset (#11286)
Validated on a 3-PR combined board: sidebar-customization + sidebar-essentials-static 30/30 combined (across the board's 4 focused files), typecheck:core + dashboard-typecheck clean, gates within baseline. New Essentials sidebar preset gives first-time users a short beginner path while advanced tools stay reachable via Command Palette search. Thank you @ignamiranda!
2026-08-23 19:08:23 -03:00
engenhariaandrereis01-ai
d137368fb5 fix(oauth): stop overwriting Kiro connections that share a profile ARN (#10815) (#11287)
Validated on a 3-PR combined board: kiro-connection-identity 8/8 (written failing-first — 3 new cases red on the pristine release/v3.8.50 tip, green with this change), typecheck:core + dashboard-typecheck clean, all static gates within baseline. Root cause is exactly right: a CodeWhisperer profile ARN identifies the profile, not the account, and distinct Builder ID accounts via social login can share one — the ARN is now trusted only alongside a non-contradicting account-level identifier (email/clientId). Closes #10815. Thank you @engenhariaandrereis01-ai!
2026-08-23 19:08:18 -03:00
Diego Rodrigues de Sa e Souza
2904cf849d fix(security): clear new CodeQL code-scanning alerts (round 4) (#11293)
- open-sse/executors/github.ts: replace the Math.random() fallback in
  the Copilot correlation-id generators (x-request-id,
  x-interaction-id, x-client-session-id, x-agent-task-id) with a
  CSPRNG-backed randomIdFallback() (node:crypto randomBytes) — closes
  js/insecure-randomness with no behavior change (crypto.randomUUID
  stays the primary path).
- tests/unit/cli/_helpers/shellArgs.mjs: collapse the two sequential
  global .replace() unescape passes into a single left-to-right regex
  replace with alternation — closes js/double-escaping. The prior
  two-pass form let the first pass's output feed the second, which is
  exactly the double-(un)escaping bug pattern the query flags (e.g. an
  escaped-backslash-then-quote sequence could be misread depending on
  pass order).

Co-authored-by: Markus Hartung <mail@hartmark.se>
2026-08-23 19:06:53 -03:00
80 changed files with 1979 additions and 336 deletions

View File

@@ -65,6 +65,12 @@ INITIAL_PASSWORD=CHANGEME
# OMNIROUTE_RELEASE_REF=origin/main
# OMNIROUTE_ALLOW_CANARY_BUILD=1
# Build-phase signal (#10060). Set to 1 by scripts/build/build-next-isolated.mjs and
# inherited by every spawned build worker so the DB layer returns a no-op stub instead
# of loading the native better-sqlite3 addon (which aborts the worker on exit).
# Never set this for the running server. Used by: src/lib/buildPhase.ts, src/lib/db/core.ts
# OMNIROUTE_BUILDING=1
# Encryption key for SQLite database encryption at rest.
# Used by: src/lib/db/encryption.ts — encrypts the entire SQLite database.
# Generate: openssl rand -hex 32 | Leave empty to disable DB encryption.

View File

@@ -12,7 +12,7 @@ import {
isFatalInstrumentationHookFailure,
formatAndroidInstrumentationFailureHint,
} from "../utils/ensureAndroidCacheDir.mjs";
import { resolveServerHost } from "../utils/serverHost.mjs";
import { resolveServerHost, resolveExposureWarning } from "../utils/serverHost.mjs";
import {
resolveMaxOldSpaceMb,
calibrateHeapFallbackMb,
@@ -162,6 +162,15 @@ export async function runServe(opts = {}) {
`);
}
// GHSA-wmgv-ph3p-rv57: the default posture (all interfaces + no API key) is a
// deliberate local-first choice, but it must be loud at startup — an operator
// on an untrusted network learns the two escape hatches here, not after a
// surprise quota bill.
const exposureWarning = resolveExposureWarning();
if (exposureWarning) {
console.warn(`\x1b[33m ⚠ ${exposureWarning}\x1b[0m\n`);
}
const serverWsJs = join(APP_DIR, "server-ws.mjs");
const serverJs = existsSync(serverWsJs) ? serverWsJs : join(APP_DIR, "server.js");

View File

@@ -24,3 +24,34 @@ export function resolveServerHost(
}
return "0.0.0.0";
}
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
/**
* Boot-time exposure warning (GHSA-wmgv-ph3p-rv57): the shipped default binds
* all interfaces while the inference plane requires no credentials, so any
* LAN peer can spend the operator's quota. That local-first posture is a
* deliberate, documented default — but it must be LOUD at startup so an
* operator who never read the docs still learns the two escape hatches.
*
* Returns the warning text when the server will listen on a non-loopback
* interface with no API-key requirement, or null when the exposure is closed.
*
* @param {NodeJS.ProcessEnv} [env]
* @param {string} [host]
* @returns {string | null}
*/
export function resolveExposureWarning(env = process.env, host = resolveServerHost(env)) {
if (LOOPBACK_HOSTS.has(host)) return null;
const requireKey = String(env.REQUIRE_API_KEY || "")
.trim()
.toLowerCase();
if (requireKey === "true" || requireKey === "1" || requireKey === "yes") return null;
return (
`SECURITY: listening on ${host} with NO API-key requirement — the inference ` +
`plane (/v1/*) is reachable by ANY device that can route to this host, and ` +
`requests are billed to your configured providers. This local-first default ` +
`is intentional, but on an untrusted network either set REQUIRE_API_KEY=true ` +
`or bind loopback with OMNIROUTE_SERVER_HOST=127.0.0.1.`
);
}

View File

@@ -0,0 +1 @@
- **feat(dashboard):** lead Traffic Inspector with a purpose-first header that separates "what happened" from "how it happened", so beginners can read request outcomes without drowning in protocol detail ([#11283](https://github.com/diegosouzapw/OmniRoute/pull/11283))

View File

@@ -0,0 +1 @@
- **feat(dashboard):** add an Essentials sidebar preset that shows only the beginner core path (Home → Endpoints → API Keys → Providers → Health → Settings) while keeping Advanced tools reachable via Command Palette search ([#11286](https://github.com/diegosouzapw/OmniRoute/pull/11286))

View File

@@ -0,0 +1 @@
- fix(oauth): stop treating the Kiro profile ARN as an account identity in `findKiroConnectionByIdentity()`, so a second Google/GitHub social login creates a new connection instead of overwriting the first — distinct Builder ID accounts share the same CodeWhisperer profile ARN, and the social token is not a JWT, so no e-mail was available to disambiguate them (#10815)

View File

@@ -0,0 +1 @@
- Document the conditional management authentication and 401/403 responses for `GET /api/openapi/spec`.

View File

@@ -0,0 +1 @@
- **fix(translator):** preserve omitted OpenCode `subagent.sessionID` values — optional default-less plain strings now use the Responses `null = omit` sentinel and are stripped before the client sees the tool call, so Codex/Responses no longer invent filler session IDs ([#11297](https://github.com/diegosouzapw/OmniRoute/pull/11297)) — thanks @ofonseca-pyming

View File

@@ -0,0 +1 @@
- **docs(database):** align the SQLite cache guide with the 65,536 KiB runtime default, supported 11,000,000 KiB range, and live Settings application behavior ([#11018](https://github.com/diegosouzapw/OmniRoute/issues/11018))

View File

@@ -1,5 +1,9 @@
{
"_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."
},
"allowed": [
"@atjsh/llmlingua-2",
"@aws-sdk/client-bedrock-runtime",
@@ -20,8 +24,10 @@
"@stryker-mutator/tap-runner",
"@swc/helpers",
"@tailwindcss/postcss",
"@testing-library/dom",
"@testing-library/jest-dom",
"@testing-library/react",
"@testing-library/user-event",
"@toon-format/toon",
"@types/better-sqlite3",
"@types/bun",

View File

@@ -6866,7 +6866,11 @@ paths:
Returns a structured JSON catalog parsed from this `openapi.yaml`,
including info, servers, tags, schemas, and a flat list of endpoints
(method, path, tags, summary, security, parameters, responses).
Used by the in-app API explorer.
Used by the in-app API explorer. When `requireLogin` is enabled, this
management endpoint requires an authenticated dashboard session;
otherwise it is available without authentication.
security:
- ManagementSessionAuth: []
responses:
"200":
description: Parsed OpenAPI catalog
@@ -6920,6 +6924,10 @@ paths:
type: string
"404":
description: openapi.yaml file not found on disk
"401":
$ref: "#/components/responses/ManagementAuthenticationRequired"
"403":
$ref: "#/components/responses/ManagementInvalidToken"
"500":
description: Failed to parse OpenAPI spec

View File

@@ -1,7 +1,7 @@
---
title: "Database Schema & Operations Guide"
version: 3.8.40
lastUpdated: 2026-06-28
version: 3.8.50
lastUpdated: 2026-08-23
---
# Database Schema & Operations Guide
@@ -43,12 +43,17 @@ For **single-user, single-instance** deployments (the primary OmniRoute use case
db.pragma("journal_mode = WAL");
db.pragma("busy_timeout = 2000");
db.pragma("synchronous = NORMAL");
// Settings > System & Storage > Cache Size is applied as KiB.
db.pragma("cache_size = -16384");
db.pragma(`cache_size = -${DEFAULT_DATABASE_SETTINGS.optimization.cacheSize}`);
```
WAL allows **concurrent reads** during writes — important for the dashboard, which queries while requests are being recorded.
The default cache size is **65,536 KiB (64 MiB)**. SQLite interprets a negative
`cache_size` as an approximate upper bound in KiB and allocates pages on demand.
**Settings > System & Storage > Cache Size** accepts integer values from **1 to
1,000,000 KiB**; saving the setting applies it to the live database connection,
and OmniRoute restores the persisted value at startup.
---
## Database Location

View File

@@ -1,19 +1,19 @@
---
title: "CLI Tools — OmniRoute"
version: 3.8.50
lastUpdated: 2026-08-18
lastUpdated: 2026-08-23
---
# CLI Tools — OmniRoute
Last updated: 2026-08-18
Last updated: 2026-08-23
OmniRoute integrates with three categories of CLI tools spread across three dedicated dashboard pages:
| Page | Route | Concept | Count |
| -------------- | ----------------------- | ------------------------------------------------------------------------- | ------------ |
| **CLI Code's** | `/dashboard/cli-code` | Coding tools you point at OmniRoute (Client → CLI → OmniRoute → Provider) | 26 |
| **CLI Agents** | `/dashboard/cli-agents` | Autonomous agents you point at OmniRoute (same flow, broader scope) | 8 |
| **CLI Agents** | `/dashboard/cli-agents` | Autonomous agents you point at OmniRoute (same flow, broader scope) | 9 |
| **ACP Agents** | `/dashboard/acp-agents` | CLIs that OmniRoute spawns as backend via stdio/ACP (reverse flow) | see registry |
Legacy routes redirect via 308: `/dashboard/cli-tools``/dashboard/cli-code`, `/dashboard/agents``/dashboard/acp-agents`.

View File

@@ -88,6 +88,7 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
| `OMNIROUTE_RELEASE_REF` | `origin/main` | `scripts/build/buildProvenance.ts` | Ref the pack-artifact provenance gate checks the build SHA against (#10427). |
| `OMNIROUTE_ALLOW_CANARY_BUILD` | _(unset)_ | `scripts/build/buildProvenance.ts` | Set to `1` to allow packing a build whose SHA is not on the release line, recording it as a deliberate canary instead of failing the gate (#10427). |
| `OMNIROUTE_SMOKE_API_KEY` | _(unset)_ | `scripts/ops/deploy-canary.mjs` | API key for the canary-deploy smoke probe, sent as `Authorization: Bearer` on `/v1/chat/completions`. Only used by the deploy script (#10429), never by the server. Not related to the `OMNIROUTE_SMOKE_*` variables of the opt-in CLI smoke harness (`RUN_CLI_SMOKE=1`, `OMNIROUTE_SMOKE_BASE_URL/MODEL/API_KEY_ENV/TARGETS/TIMEOUT_MS` in `tests/integration/upstream-cli-smoke.int.test.ts`) — see [CLI Integrations → Real smoke sweep](../guides/CLI-INTEGRATIONS.md). |
| `OMNIROUTE_BUILDING` | _(unset)_ | `src/lib/buildPhase.ts` | Build-phase signal (#10060): set to `1` by `scripts/build/build-next-isolated.mjs` and inherited by every spawned build worker so the DB layer returns a no-op stub instead of loading the native better-sqlite3 addon (which aborts the worker on exit). Never set for the running server. |
| `OMNIROUTE_DATA_DIR` | _(unset)_ | `open-sse/executors/promptql/threadSticky.ts` | **Fallback alias** for `DATA_DIR`, checked only when `DATA_DIR` is unset. Used to locate the PromptQL executor's on-disk thread-sticky session cache (`<dir>/promptql-thread-sessions.json`); if neither var is set, the cache stays in-memory only (not persisted across restarts). |
| `STORAGE_ENCRYPTION_KEY` | _(empty = disabled)_ | `src/lib/db/encryption.ts` | AES key for full SQLite database encryption at rest. Generate with `openssl rand -hex 32`. |
| `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/build/bootstrap-env.mjs`, `electron/main.js` | Version label for the encryption key. Increment when performing key rotation to support decryption of old backups. |

View File

@@ -379,6 +379,7 @@ import { isCompactResponsesEndpoint } from "../executors/codex.ts";
import { persistCodexChildQuotaResponse } from "../services/codexAccount/index.ts";
import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts";
import { translateNonStreamingResponse } from "./responseTranslator.ts";
import { extractToolSchemaMap } from "../translator/response/openai-responses/toolSchemas.ts";
import { unwrapClineNonStreamingEnvelope } from "./chatCore/clineResponseEnvelope.ts";
import { extractUsageFromResponse } from "./usageExtractor.ts";
import {
@@ -1218,7 +1219,12 @@ export async function handleChatCore({
credentials?.providerSpecificData?.preserveEncryptedReasoning === true,
onIncompatibleReasoning: resolveIncompatibleReasoningAction({
reasoningTransportFallback,
isComboStep: Boolean(comboStepId || comboExecutionKey),
// #11178 regressed combo steps whose combo record carries no explicit
// stepId/executionKey (plain model-list combos): their explicit
// `reasoningTransportFallback: "skip"` config was silently degraded to
// "drop". `isCombo` is the combo marker; step ids are optional
// finer-grained metadata that plain combos never set.
isComboStep: Boolean(isCombo) || Boolean(comboStepId || comboExecutionKey),
headers: clientRawRequest?.headers ?? null,
}),
}
@@ -4905,12 +4911,14 @@ export async function handleChatCore({
// Translate response to client's expected format (usually OpenAI)
// Pass toolNameMap so Claude OAuth proxy_ prefix is stripped in tool_use blocks (#605)
const responseToolSchemas = extractToolSchemaMap(finalBody || translatedBody || body);
let translatedResponse = needsTranslation(responsePayloadFormat, clientResponseFormat)
? translateNonStreamingResponse(
responseBody,
responsePayloadFormat,
clientResponseFormat,
responseToolNameMap
responseToolNameMap,
responseToolSchemas
)
: responseBody;
const memoryExtractionResponse = translatedResponse;
@@ -4937,7 +4945,8 @@ export async function handleChatCore({
responseBody,
responsePayloadFormat,
FORMATS.OPENAI,
responseToolNameMap
responseToolNameMap,
responseToolSchemas
)
: responseBody;
const firstChoice = cacheResponse?.choices?.[0];
@@ -5460,7 +5469,8 @@ export async function handleChatCore({
streamBody,
clientResponseFormat,
FORMATS.OPENAI,
responseToolNameMap
responseToolNameMap,
extractToolSchemaMap(finalBody || translatedBody || body)
) as Record<string, unknown>)
: streamBody;
const choices = cacheStreamBody.choices as

View File

@@ -13,6 +13,7 @@ import {
import { restoreClaudeToolName } from "../services/claudeCodeToolRemapper.ts";
import { extractReplayableResponsesReasoningText } from "../services/reasoningInputPolicy.ts";
import { sanitizeToolId } from "../translator/helpers/schemaCoercion.ts";
import { stripEmptyOptionalToolArgs } from "../translator/response/openai-responses/pureHelpers.ts";
type JsonRecord = Record<string, unknown>;
@@ -135,24 +136,28 @@ function findBestMessageText(output: unknown[]): {
* Handles different provider response formats (Gemini, Claude, etc.)
*
* @param toolNameMap - Optional Map<prefixedName, originalName> for Claude OAuth tool name stripping
* @param toolSchemas - Optional Map<toolName, parametersSchema> for schema-aware optional-arg cleanup
*/
export function translateNonStreamingResponse(
responseBody: JsonRecord,
targetFormat: string,
sourceFormat: string,
toolNameMap?: Map<string, string> | null
toolNameMap?: Map<string, string> | null,
toolSchemas?: Map<string, JsonRecord> | null
): JsonRecord;
export function translateNonStreamingResponse(
responseBody: unknown,
targetFormat: string,
sourceFormat: string,
toolNameMap?: Map<string, string> | null
toolNameMap?: Map<string, string> | null,
toolSchemas?: Map<string, JsonRecord> | null
): unknown;
export function translateNonStreamingResponse(
responseBody: unknown,
targetFormat: string,
sourceFormat: string,
toolNameMap?: Map<string, string> | null
toolNameMap?: Map<string, string> | null,
toolSchemas?: Map<string, JsonRecord> | null
): unknown {
// If already in source format, return as-is
if (targetFormat === sourceFormat) {
@@ -219,6 +224,11 @@ export function translateNonStreamingResponse(
toString(itemObj.id) ||
`call_${Date.now()}_${toolCalls.length}`;
let argsToEmit = itemObj.arguments;
const rawName = toString(itemObj.name);
const toolSchema = toolSchemas?.get(rawName);
if (toolSchema) {
argsToEmit = stripEmptyOptionalToolArgs(argsToEmit, rawName, toolSchema);
}
if (argsToEmit != null && typeof argsToEmit === "object" && !Array.isArray(argsToEmit)) {
const cleaned: JsonRecord = { ...(argsToEmit as JsonRecord) };
for (const [k, v] of Object.entries(cleaned)) {
@@ -229,7 +239,6 @@ export function translateNonStreamingResponse(
const fnArgs =
typeof argsToEmit === "string" ? argsToEmit : JSON.stringify(argsToEmit || {});
const rawName = toString(itemObj.name);
// Strip Claude OAuth proxy_ prefix using toolNameMap
const resolvedName = caseInsensitiveToolNameLookup(rawName, toolNameMap) ?? rawName;
toolCalls.push({

View File

@@ -31,6 +31,7 @@ import * as xSearch from "./search/xSearch.ts";
import { freeWebSearch } from "../services/freeWebSearch.ts";
import { saveCallLog } from "@/lib/usageDb";
import { safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
import { parseAndValidateNonMetadataUrl } from "@/shared/network/outboundUrlGuard";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import { z } from "zod";
@@ -313,9 +314,23 @@ function getProviderSettingString(
return undefined;
}
function resolveSearchBaseUrl(config: SearchProviderConfig, params: SearchRequestParams): string {
export function resolveSearchBaseUrl(
config: SearchProviderConfig,
params: SearchRequestParams
): string {
const override = getProviderSettingString(params, "baseUrl");
return (override || config.baseUrl).replace(/\/+$/, "");
if (override) {
// GHSA-j7j4-g9qc-q69c: the override is client-controlled (provider_options /
// providerSpecificData) and flows into a plain fetch() sink — validate it
// before any builder uses it as the server-side fetch target. Mode is
// block-metadata (NOT public-only): the primary searxng use case is a
// self-hosted instance on loopback/LAN, so private hosts keep working,
// while cloud-metadata endpoints (IMDS credential theft) are rejected.
// The catalog's own config.baseUrl is operator config and stays untouched.
parseAndValidateNonMetadataUrl(override);
return override.replace(/\/+$/, "");
}
return config.baseUrl.replace(/\/+$/, "");
}
function toSearchPageNumber(offset: number | undefined, maxResults: number): number | undefined {

View File

@@ -12,7 +12,21 @@
* `sanitizeReasoningEffortForProvider` in `executors/base/reasoningEffort.ts`)
* so the 4xx→retry round-trip is paid at most once per process per provider+model.
*
* `clampToLearned` implements downgrade-only clamping: greatest accepted <= demand.
* `clampToLearned` implements nearest-tier clamping: smallest accepted >= demand,
* falling back to the greatest accepted when demand exceeds every accepted value.
* (#11295 — unified with the static "declared" clamp in
* `executors/base/reasoningEffort.ts`, which already used nearest-tier semantics.
* Before #11295, this learned clamp was downgrade-only — greatest accepted <=
* demand — so the SAME accepted set {low,high,max} produced medium→low here but
* medium→high via the declared path: identical inputs, opposite outputs,
* depending only on whether the model had a static registry entry. #11274's
* DeepSeek native mapping is the precedent for nearest-tier. This also fixes a
* standalone bug: a request BELOW the learned floor (e.g. none/minimal on a
* model that only ever advertised {low,high,max}) used to return null — no
* clamp — so the too-low value passed straight through to the upstream, which
* 400'd again on every subsequent request without ever learning a lower floor.
* Nearest-tier naturally fixes this too: the smallest accepted value is always
* >= any demand below the floor, so it is returned instead of null.
*
* In-memory only (same operator-accepted tradeoff as the thinking-budget cache):
* restart resets, the first request after a restart may re-learn at the cost of
@@ -132,25 +146,39 @@ export function recordLearnedReasoningEffort(
}
/**
* Return the greatest accepted value <= effortStr (downgrade only), or null
* if effortStr is already accepted, below the minimum, or not in ORDER.
* Return the nearest-tier accepted value for effortStr: the smallest accepted
* value with rank >= effortStr's rank, or — when effortStr's rank exceeds every
* accepted value (demand above the learned ceiling) — the greatest accepted
* value. Returns null only when effortStr is already accepted (no clamp
* needed), empty, or not a recognized member of REASONING_EFFORT_ORDER.
*
* Mirrors the declared-capability clamp in `executors/base/reasoningEffort.ts`
* (#11295): both now use nearest-tier semantics so the same accepted set
* produces the same mapping regardless of whether the model has a static
* registry entry or was only learned reactively from an upstream 4xx.
*/
export function clampToLearned(effortStr: string, accepted: Set<string>): string | null {
if (!effortStr || accepted.has(effortStr)) return null;
const rank = rankOf(effortStr);
if (rank === -1) return null;
const minRank = Math.min(...[...accepted].map((v) => rankOf(v)));
if (rank < minRank) return null;
let best: string | null = null;
let bestRank = -1;
let nearestAbove: string | null = null;
let nearestAboveRank = Infinity;
let highest: string | null = null;
let highestRank = -1;
for (const v of accepted) {
const r = rankOf(v);
if (r <= rank && r > bestRank) {
bestRank = r;
best = v;
if (r < 0) continue;
if (r >= rank && r < nearestAboveRank) {
nearestAboveRank = r;
nearestAbove = v;
}
if (r > highestRank) {
highestRank = r;
highest = v;
}
}
return best;
return nearestAbove ?? highest;
}
// Matches prose shapes: OVH's "@ai-sdk/openai-compatible" deserializer

View File

@@ -290,6 +290,31 @@ export function coerceToolSchemas(tools: unknown): unknown {
});
}
const NULL_OMISSION_NOTE = "null = omit this parameter";
function schemaTypeIncludes(type: unknown, wanted: string): boolean {
return type === wanted || (Array.isArray(type) && type.includes(wanted));
}
function isPlainStringType(type: unknown): boolean {
return type === "string" || (Array.isArray(type) && type.length === 1 && type[0] === "string");
}
function appendNullOmissionMarker(description: unknown): string {
if (typeof description === "string" && description.length > 0) {
return description.includes(NULL_OMISSION_NOTE)
? description
: `${description} (${NULL_OMISSION_NOTE})`;
}
return NULL_OMISSION_NOTE;
}
function widenTypeWithNull(type: unknown): unknown {
if (typeof type === "string") return [type, "null"];
if (Array.isArray(type) && !type.includes("null")) return [...type, "null"];
return type;
}
// #7023 — Responses API strict mode forces every "optional" tool property into
// `required`, so a model that intends to OMIT an optional enum property (no declared
// `default`) must still emit a concrete value (e.g. Agent.isolation:"remote"). Neither
@@ -299,7 +324,11 @@ export function coerceToolSchemas(tools: unknown): unknown {
// `null` (see pureHelpers.ts::isDroppableNullEntry). Scope: top-level
// `properties[key].enum` only — does not recurse into `items`/`anyOf`/`oneOf` branches
// (no real-world case beyond Agent.isolation is documented; extend with a concrete repro).
function shouldInjectNullOmission(key: string, propSchema: unknown, required: Set<string>): boolean {
function shouldInjectNullOmission(
key: string,
propSchema: unknown,
required: Set<string>
): boolean {
return (
isPlainObject(propSchema) &&
Array.isArray(propSchema.enum) &&
@@ -312,19 +341,38 @@ function widenPropertyForNullOmission(propSchema: JsonRecord): JsonRecord {
const widened: JsonRecord = { ...propSchema };
const enumValues = propSchema.enum as unknown[];
widened.enum = enumValues.includes(null) ? enumValues : [...enumValues, null];
if (typeof propSchema.type === "string") {
widened.type = [propSchema.type, "null"];
} else if (Array.isArray(propSchema.type) && !propSchema.type.includes("null")) {
widened.type = [...propSchema.type, "null"];
}
const note = "null = omit this parameter";
widened.description =
typeof propSchema.description === "string" && propSchema.description.length > 0
? `${propSchema.description} (${note})`
: note;
widened.type = widenTypeWithNull(propSchema.type);
widened.description = appendNullOmissionMarker(propSchema.description);
return widened;
}
// OpenCode `subagent.sessionID` (and any other optional default-less plain string) has
// the same strict-mode omission problem as #7023 enums, but no enum to widen. Inject
// the same nullable-union sentinel on top-level `properties[key]` only — do not recurse
// into `items`/`anyOf`/`$defs`, and do not touch enums (owned by the helper above).
function shouldInjectStringNullOmission(
key: string,
propSchema: unknown,
required: Set<string>
): boolean {
return (
isPlainObject(propSchema) &&
!Array.isArray(propSchema.enum) &&
isPlainStringType(propSchema.type) &&
!schemaTypeIncludes(propSchema.type, "null") &&
!required.has(key) &&
!hasOwn(propSchema, "default")
);
}
function widenStringPropertyForNullOmission(propSchema: JsonRecord): JsonRecord {
return {
...propSchema,
type: widenTypeWithNull(propSchema.type),
description: appendNullOmissionMarker(propSchema.description),
};
}
export function injectOptionalEnumOmissionSentinel(schema: unknown): unknown {
if (!isPlainObject(schema) || !isPlainObject(schema.properties)) return schema;
@@ -356,6 +404,43 @@ export function injectOptionalEnumOmissionForTools(tools: unknown): unknown {
});
}
export function injectOptionalStringOmissionSentinel(schema: unknown): unknown {
if (!isPlainObject(schema) || !isPlainObject(schema.properties)) return schema;
const required = new Set(Array.isArray(schema.required) ? schema.required : []);
let changed = false;
const nextProperties: JsonRecord = { ...schema.properties };
for (const [key, propSchema] of Object.entries(schema.properties)) {
if (!shouldInjectStringNullOmission(key, propSchema, required)) continue;
nextProperties[key] = widenStringPropertyForNullOmission(propSchema as JsonRecord);
changed = true;
}
if (!changed) return schema;
return { ...schema, properties: nextProperties };
}
export function injectOptionalStringOmissionForTools(tools: unknown): unknown {
if (!Array.isArray(tools)) return tools;
return tools.map((tool) => {
if (!isPlainObject(tool)) return tool;
const result: JsonRecord = { ...tool };
if (isPlainObject(result.function) && "parameters" in result.function) {
result.function = {
...result.function,
parameters: injectOptionalStringOmissionSentinel(result.function.parameters),
};
}
if ("parameters" in result && !isPlainObject(result.function)) {
result.parameters = injectOptionalStringOmissionSentinel(result.parameters);
}
return result;
});
}
export function sanitizeToolDescriptions(tools: unknown): unknown {
if (!Array.isArray(tools)) return tools;
return tools.map((tool) => sanitizeToolDescription(tool));

View File

@@ -18,6 +18,7 @@ import {
coerceToolSchemas,
injectEmptyReasoningContentForToolCalls,
injectOptionalEnumOmissionForTools,
injectOptionalStringOmissionForTools,
sanitizeToolDescriptions,
} from "./helpers/schemaCoercion.ts";
import { getRequestTranslator, getResponseTranslator } from "./registry.ts";
@@ -595,6 +596,12 @@ export function translateRequest(
}
if (result.tools !== undefined) {
// Plain-string omission must run before coerceToolSchemas() strips `default`,
// so defaulted optional strings stay unsentinelled. Enum injection stays after
// coercion to preserve the #7023 pipeline.
if (targetFormat === FORMATS.OPENAI_RESPONSES) {
result.tools = injectOptionalStringOmissionForTools(result.tools);
}
result.tools = coerceToolSchemas(result.tools);
result.tools = sanitizeToolDescriptions(result.tools);
if (targetFormat === FORMATS.OPENAI_RESPONSES) {

View File

@@ -866,13 +866,13 @@ export function openaiResponsesToOpenAIResponse(chunk, state) {
function openaiResponsesToOpenAIResponseStream(chunk, state) {
if (!chunk) {
// Iterate every still-open call needing schema-aware normalization, not just a
// single one — multiple parallel calls can each be pending here if the stream
// ends before their output_item.done arrives.
// Iterate every still-open call with a buffered argument payload — argument
// deltas are buffered for every tool, so an incomplete stream must flush every
// buffered call, not only the historical uppercase Agent path.
const pendingNormalized: Array<{ index: number; argsStr: string }> = [];
if (state.toolCallByCallId instanceof Map) {
for (const entry of state.toolCallByCallId.values()) {
if (entry.needsNormalization && entry.argsBuffer) {
if (entry.argsBuffer) {
const toolSchema = state.toolSchemas?.get(entry.name);
const argsToEmit = stripEmptyOptionalToolArgs(entry.argsBuffer, entry.name, toolSchema);
pendingNormalized.push({

View File

@@ -56,21 +56,35 @@ function isDroppableEmptyEntry(entry, propSchema, required, key, allowlisted) {
return allowlisted || (propSchema != null && !required.has(key));
}
// #7023 — the request-side counterpart (injectOptionalEnumOmissionSentinel) widens
// no-default optional enum properties to accept `null`, meaning "omitted" (OpenAI's own
// nullable-union idiom for Responses-API strict mode). Drop the key when the model
// follows that idiom for a non-required, schema-declared property.
function schemaTypeIncludes(type, wanted) {
return type === wanted || (Array.isArray(type) && type.includes(wanted));
}
function hasOmissionSentinel(propSchema) {
if (!propSchema || typeof propSchema !== "object") return false;
if (
typeof propSchema.description !== "string" ||
!propSchema.description.includes("null = omit this parameter")
) {
return false;
}
return (
schemaTypeIncludes(propSchema.type, "null") ||
(Array.isArray(propSchema.enum) && propSchema.enum.includes(null))
);
}
// #7023 — the request-side counterpart widens no-default optional properties to accept
// `null`, meaning "omitted" (OpenAI's own nullable-union idiom for Responses-API strict
// mode). Enums use injectOptionalEnumOmissionSentinel; plain strings use
// injectOptionalStringOmissionSentinel. Drop the key when the model follows that idiom
// for a non-required, schema-declared property, or when OmniRoute's marker is present
// even after an upstream strictifies the field into `required`.
function isDroppableNullEntry(entry, propSchema, required, key, toolName) {
if (entry !== null) return false;
if (toolName === "Agent") return true;
if (propSchema == null) return false;
const omissionSentinel =
typeof propSchema === "object" &&
Array.isArray(propSchema.enum) &&
propSchema.enum.includes(null) &&
typeof propSchema.description === "string" &&
propSchema.description.includes("null = omit this parameter");
return !required.has(key) || omissionSentinel;
return !required.has(key) || hasOmissionSentinel(propSchema);
}
function stripEmptyOptionalToolArgsObject(value, toolName, schema) {
@@ -110,7 +124,11 @@ export function stripEmptyOptionalToolArgs(value, toolName, schema) {
// supplied (schema-aware normalization is not restricted to the allowlist).
// "Agent" also passes without a schema: isDroppableNullEntry drops its null
// omission sentinels even when the strict schema snapshot is unavailable (#9423).
if (!hasUsableSchema(schema) && !STRIPPABLE_EMPTY_ARG_TOOLS.has(toolName) && toolName !== "Agent") {
if (
!hasUsableSchema(schema) &&
!STRIPPABLE_EMPTY_ARG_TOOLS.has(toolName) &&
toolName !== "Agent"
) {
return value;
}
try {

View File

@@ -75,76 +75,3 @@ omniroute mcp call <tool> [argsJson]
```bash
omniroute mcp scopes
```
### `mcp tools`
**Example:**
```bash
omniroute mcp tools
```
### `mcp list`
**Flags:**
- `--scope <s>`
**Example:**
```bash
omniroute mcp list
```
### `mcp info <name>`
**Example:**
```bash
omniroute mcp info <name>
```
### `mcp schema <name>`
**Flags:**
- `--io <kind>`
**Example:**
```bash
omniroute mcp schema <name>
```
### `mcp audit`
**Example:**
```bash
omniroute mcp audit
```
### `mcp tail`
**Flags:**
- `--follow`
- `--limit <n>`
**Example:**
```bash
omniroute mcp tail
```
### `mcp stats`
**Flags:**
- `--period <p>`
**Example:**
```bash
omniroute mcp stats
```

View File

@@ -290,6 +290,11 @@ export default function EditConnectionModal({
connection.providerSpecificData?.quotaPerUnit != null
? String(connection.providerSpecificData.quotaPerUnit)
: "";
// Modal-open form initialization from the loaded connection (sync with an
// external system on `isOpen`); remounting the 30+ field form per
// connection id is a behavior-risking restructure out of scope here
// (#11251 follow-up, #9985).
// eslint-disable-next-line react-hooks/set-state-in-effect
setFormData({
name: connection.name || "",
priority: connection.priority || 1,

View File

@@ -521,6 +521,7 @@ export default function SidebarTab() {
const presetLabels: Record<SidebarPresetId, string> = {
all: getSettingsLabel("presetAll", "All"),
essentials: getSettingsLabel("presetEssentials", "Essentials"),
minimal: getSettingsLabel("presetMinimal", "Minimal"),
developer: getSettingsLabel("presetDeveloper", "Developer"),
admin: getSettingsLabel("presetAdmin", "Admin"),
@@ -528,6 +529,10 @@ export default function SidebarTab() {
const presetDescriptions: Record<SidebarPresetId, string> = {
all: getSettingsLabel("presetAllDesc", "Show everything"),
essentials: getSettingsLabel(
"presetEssentialsDesc",
"Beginner path — Advanced tools stay searchable"
),
minimal: getSettingsLabel("presetMinimalDesc", "Core pages only"),
developer: getSettingsLabel("presetDeveloperDesc", "Dev & proxy tools"),
admin: getSettingsLabel("presetAdminDesc", "Monitoring & audit"),

View File

@@ -15,7 +15,15 @@ import { HistoricSessionBanner } from "./components/session/HistoricSessionBanne
const BUFFER_MAX = 1000;
export function TrafficInspectorPageClient() {
export function TrafficInspectorPageClient({
title,
subtitle,
purpose,
}: {
title?: string;
subtitle?: string;
purpose?: string;
} = {}) {
const [containerHeight, setContainerHeight] = useState(600);
const listContainerRef = useRef<HTMLDivElement | null>(null);
const [selectedRequest, setSelectedRequest] = useState<InterceptedRequest | null>(null);
@@ -91,6 +99,18 @@ export function TrafficInspectorPageClient() {
return (
<div className="flex flex-col h-full overflow-hidden">
{title && (
<div className="shrink-0 px-4 pt-4 pb-2">
<h1 className="text-2xl font-bold text-text-main">{title}</h1>
{subtitle && (
<p className="text-sm text-text-muted mt-1 max-w-2xl">{subtitle}</p>
)}
{purpose && (
<p className="text-xs text-text-muted mt-2 max-w-2xl italic">{purpose}</p>
)}
</div>
)}
{/* Capture modes toolbar */}
<div className="shrink-0 px-4 pt-4 pb-2">
<CaptureModesToolbar customHostCount={0} />

View File

@@ -9,6 +9,7 @@ export async function generateMetadata() {
};
}
export default function TrafficInspectorPage() {
return <TrafficInspectorPageClient />;
export default async function TrafficInspectorPage() {
const t = await getTranslations("sidebar");
return <TrafficInspectorPageClient title={t("trafficInspector")} subtitle={t("trafficInspectorSubtitle")} purpose={t("trafficInspectorPurpose")} />;
}

View File

@@ -10,15 +10,13 @@
* Auth: Bearer token via Authorization header
*/
import { timingSafeEqual } from "node:crypto";
import { NextRequest, NextResponse } from "next/server";
import { getTaskManager } from "@/lib/a2a/taskManager";
import { logRoutingDecision } from "@/lib/a2a/routingLogger";
import { createA2AStream, SSE_HEADERS } from "@/lib/a2a/streaming";
import { A2A_SKILL_HANDLERS, executeA2ATaskWithState } from "@/lib/a2a/taskExecution";
import { getSettings } from "@/lib/db/settings";
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { authenticateA2ARequest, resolveA2AOwner } from "@/lib/a2a/authenticate";
// ============ A2A v1.0 ↔ v0.3 compatibility layer ============
// A2A 1.0 renamed the JSON-RPC methods (message/send → SendMessage,
@@ -55,7 +53,7 @@ function buildV1Task(
? result.artifacts
.map((a) =>
a && typeof a === "object" && typeof (a as { content?: unknown }).content === "string"
? ((a as { content: string }).content)
? (a as { content: string }).content
: ""
)
.filter((s) => s.length > 0)
@@ -124,39 +122,13 @@ function toMessageArray(raw: unknown): A2AMessage[] | null {
// ============ Auth ============
/**
* Constant-time comparison of the presented bearer token against the configured
* key. A plain `===` short-circuits on the first differing byte, leaking the
* length of the shared prefix through response timing; `timingSafeEqual` does
* not. It requires equal-length buffers, so mismatched lengths are rejected up
* front (the length itself is not secret).
*/
function tokensMatch(provided: string, expected: string): boolean {
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
async function authenticate(req: NextRequest): Promise<boolean> {
// /a2a is outside the authz proxy matcher, so the REQUIRE_API_KEY posture the
// pipeline enforces for /v1 never ran here — the route accepted every caller
// whenever OMNIROUTE_API_KEY was unset, which is the shipped default
// (GHSA-v54m-6rm3-p565). Apply the same posture directly: when a client key is
// required, demand a valid OmniRoute key; otherwise honor the legacy explicit
// A2A key; otherwise stay keyless (the same local-first default as /v1).
const apiKey = extractApiKey(req);
if (isRequireApiKeyEnabled()) {
return apiKey ? await isValidApiKey(apiKey) : false;
}
const configuredKey = process.env.OMNIROUTE_API_KEY;
if (configuredKey) {
return apiKey ? tokensMatch(apiKey, configuredKey) : false;
}
// No API key required and none configured — allow (keyless local-first).
return true;
// (GHSA-v54m-6rm3-p565). The shared helper applies the same posture on both
// the JSON-RPC and the REST task surfaces (GHSA-jcm5-6wpp-wjj8).
return authenticateA2ARequest(req);
}
// ============ JSON-RPC Helpers ============
@@ -213,6 +185,9 @@ export async function POST(req: NextRequest) {
if (disabledResponse) return disabledResponse;
const tm = getTaskManager();
// GHSA-jcm5-6wpp-wjj8: scope every task read/mutation below to the caller's
// owner id (hashed API key; undefined under the keyless local-first posture).
const callerOwner = resolveA2AOwner(req);
// A2A 1.0 method-name compatibility (SendMessage → message/send, etc.)
const isV1Method = method in V1_METHOD_ALIASES;
@@ -236,7 +211,7 @@ export async function POST(req: NextRequest) {
return jsonRpcError(id, -32601, `Unknown skill: ${skill}`);
}
const task = tm.createTask({ skill, messages, metadata: params?.metadata });
const task = tm.createTask({ skill, messages, metadata: params?.metadata }, callerOwner);
try {
tm.updateTask(task.id, "working");
const result = await handler(task);
@@ -302,7 +277,7 @@ export async function POST(req: NextRequest) {
return jsonRpcError(id, -32601, `Unknown skill: ${skill}`);
}
const task = tm.createTask({ skill, messages, metadata: params?.metadata });
const task = tm.createTask({ skill, messages, metadata: params?.metadata }, callerOwner);
tm.updateTask(task.id, "working");
const stream = createA2AStream(
@@ -323,7 +298,7 @@ export async function POST(req: NextRequest) {
const taskId = params?.taskId || params?.id;
if (!taskId) return jsonRpcError(id, -32602, "Invalid params: taskId required");
const task = tm.getTask(taskId);
const task = tm.getTask(taskId, callerOwner);
if (!task) return jsonRpcError(id, -32601, `Task not found: ${taskId}`);
return jsonRpcResult(id, { task });
@@ -335,7 +310,7 @@ export async function POST(req: NextRequest) {
if (!taskId) return jsonRpcError(id, -32602, "Invalid params: taskId required");
try {
const task = tm.cancelTask(taskId);
const task = tm.cancelTask(taskId, callerOwner);
return jsonRpcResult(id, { task: { id: task.id, state: task.state } });
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);

51
src/app/api/a2a/_auth.ts Normal file
View File

@@ -0,0 +1,51 @@
/**
* Shared authorization for the REST A2A task routes (GHSA-jcm5-6wpp-wjj8).
*
* Dual audience: the dashboard calls these routes with a management session,
* A2A clients with an inference API key. Posture matrix:
*
* - REQUIRE_API_KEY=true: a valid OmniRoute key is mandatory (the same
* posture the /v1 inference plane enforces); a management session also
* passes (dashboard), via alwaysRequireAuth so requireLogin=false cannot
* bypass it.
* - otherwise + requireLogin=true: management session, or a valid key.
* - otherwise + requireLogin=false (local-first default): open, by design.
*
* Callers authenticated by key are owner-scoped — another principal's tasks
* answer as if they did not exist. Management/operator view sees all tasks.
*/
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
import { resolveA2AOwner } from "@/lib/a2a/authenticate";
export interface A2ARestAuth {
/** Owner scope for task reads/mutations; undefined = operator view (all tasks). */
owner: string | undefined;
}
/**
* NOTE: the failure branch is whatever requireManagementAuth returns — today a
* plain `Response` from createErrorResponse(), NOT a NextResponse. Callers must
* test with `instanceof Response` (NextResponse extends Response), never
* `instanceof NextResponse`, or the 401 silently falls through to the handler.
*/
export async function authorizeA2ATaskRoute(request: Request): Promise<A2ARestAuth | Response> {
const apiKey = extractApiKey(request);
if (isRequireApiKeyEnabled()) {
if (apiKey && (await isValidApiKey(apiKey))) return { owner: resolveA2AOwner(request) };
const managementError = await requireManagementAuth(request, {
invalidApiKeyStatus: 401,
alwaysRequireAuth: true,
});
if (managementError === null) return { owner: undefined };
return managementError;
}
const managementError = await requireManagementAuth(request, { invalidApiKeyStatus: 401 });
if (managementError === null) return { owner: undefined };
if (apiKey && (await isValidApiKey(apiKey))) return { owner: resolveA2AOwner(request) };
return managementError;
}

View File

@@ -1,14 +1,23 @@
import { NextResponse } from "next/server";
import { getTaskManager } from "@/lib/a2a/taskManager";
import { authorizeA2ATaskRoute } from "@/app/api/a2a/_auth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
// GHSA-jcm5-6wpp-wjj8: this route had no auth call at all. The owner check
// happens inside cancelTask: another principal's task throws the same
// "not found" a missing one would (no existence oracle).
const auth = await authorizeA2ATaskRoute(request);
if (auth instanceof Response) return auth;
try {
const { id } = await params;
const tm = getTaskManager();
const task = tm.cancelTask(id);
const task = tm.cancelTask(id, auth.owner);
return NextResponse.json({ task: { id: task.id, state: task.state } });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to cancel A2A task";
const message = sanitizeErrorMessage(
error instanceof Error ? error.message : "Failed to cancel A2A task"
);
const status = message.includes("not found") ? 404 : 400;
return NextResponse.json({ error: message }, { status });
}

View File

@@ -1,17 +1,30 @@
import { NextResponse } from "next/server";
import { getTaskManager } from "@/lib/a2a/taskManager";
import { authorizeA2ATaskRoute } from "@/app/api/a2a/_auth";
import { sanitizeErrorMessage } from "@omniroute/open-sse/utils/error";
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
export async function GET(request: Request, { params }: { params: Promise<{ id: string }> }) {
// GHSA-jcm5-6wpp-wjj8: this route had no auth call at all — open regardless
// of configuration. Another principal's task answers 404, same as a missing
// one, so an IDOR probe cannot tell the two apart.
const auth = await authorizeA2ATaskRoute(request);
if (auth instanceof Response) return auth;
try {
const { id } = await params;
const tm = getTaskManager();
const task = tm.getTask(id);
const task = tm.getTask(id, auth.owner);
if (!task) {
return NextResponse.json({ error: `Task not found: ${id}` }, { status: 404 });
}
return NextResponse.json({ task });
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to load A2A task";
return NextResponse.json({ error: message }, { status: 500 });
return NextResponse.json(
{
error: sanitizeErrorMessage(
error instanceof Error ? error.message : "Failed to load A2A task"
),
},
{ status: 500 }
);
}
}

View File

@@ -3,6 +3,7 @@ import { NextResponse } from "next/server";
import { z } from "zod";
import { getTaskManager, type TaskState } from "@/lib/a2a/taskManager";
import { authorizeA2ATaskRoute } from "@/app/api/a2a/_auth";
import { createConductorTask } from "@/lib/conductor/hubProxy";
import { getSettings } from "@/lib/db/settings";
@@ -22,6 +23,11 @@ function parseIntParam(value: string | null, fallback: number): number {
}
export async function GET(request: Request) {
// GHSA-jcm5-6wpp-wjj8: the list route had no auth call at all. Management
// (or the keyless posture) sees every task; a bare API key must be valid
// and is owner-scoped.
const auth = await authorizeA2ATaskRoute(request);
if (auth instanceof Response) return auth;
try {
const { searchParams } = new URL(request.url);
const stateParam = searchParams.get("state");
@@ -36,7 +42,7 @@ export async function GET(request: Request) {
const tm = getTaskManager();
const total = tm.countTasks({ state, skill });
const tasks = tm.listTasks({ state, skill, limit, offset });
const tasks = tm.listTasks({ state, skill, limit, offset }, auth.owner);
return NextResponse.json({
tasks,
@@ -104,7 +110,10 @@ export function authenticateA2A(request: Request): boolean {
*/
export async function POST(request: Request) {
if (!authenticateA2A(request)) {
return NextResponse.json({ error: "Unauthorized: missing or invalid API key" }, { status: 401 });
return NextResponse.json(
{ error: "Unauthorized: missing or invalid API key" },
{ status: 401 }
);
}
const settings = await getSettings();
if (settings.a2aEnabled !== true) {
@@ -122,12 +131,18 @@ export async function POST(request: Request) {
}
const parsed = delegationSchema.safeParse(raw);
if (!parsed.success) {
return NextResponse.json({ error: "Invalid A2A task: provide messages[] (and metadata.conductor)" }, { status: 400 });
return NextResponse.json(
{ error: "Invalid A2A task: provide messages[] (and metadata.conductor)" },
{ status: 400 }
);
}
const { skill, messages, metadata } = parsed.data;
if (skill !== "conductor" && !skill.startsWith("conductor-cli-")) {
return NextResponse.json(
{ error: "Only Conductor fleet skills are delegable here (conductor / conductor-cli-<profile>)" },
{
error:
"Only Conductor fleet skills are delegable here (conductor / conductor-cli-<profile>)",
},
{ status: 400 }
);
}
@@ -138,7 +153,9 @@ export async function POST(request: Request) {
{ status: 400 }
);
}
const prompt = [...messages].reverse().find((m) => m.role === "user")?.content ?? messages[messages.length - 1].content;
const prompt =
[...messages].reverse().find((m) => m.role === "user")?.content ??
messages[messages.length - 1].content;
const created = await createConductorTask({
repoUrl: conductor.repo.url,

View File

@@ -1221,12 +1221,6 @@
"consoleLogsSubtitle": "Console output",
"logsActivitySubtitle": "User activity log",
"healthSubtitle": "System health check",
"healthVerdictReady": "OmniRoute is ready",
"healthVerdictActionRequired": "Action required to restore full operation",
"healthVerdictCoolingDown": "Cooling down after recent changes",
"advancedDiagnosticsTitle": "Advanced diagnostics",
"hide": "Hide",
"show": "Show",
"costsPricingSubtitle": "Per-model pricing rules",
"costsBudgetSubtitle": "Budget limits",
"costsQuotaShareSubtitle": "Share provider quotas across keys",
@@ -1272,7 +1266,8 @@
"agentBridge": "Agent Bridge",
"agentBridgeSubtitle": "Intercept IDE agent traffic",
"trafficInspector": "Traffic Inspector",
"trafficInspectorSubtitle": "Monitor LLM calls + debug any HTTPS traffic",
"trafficInspectorSubtitle": "Inspect request and response traffic from your apps",
"trafficInspectorPurpose": "See exactly what your application sends to and receives from AI providers. Works with any OpenAI-compatible client.",
"cliCode": "CLI Code",
"cliCodeSubtitle": "Code tools pointing to OmniRoute",
"cliAgents": "CLI Agents",
@@ -2927,6 +2922,7 @@
"interpreter": "Open Interpreter autonomous coding agent CLI",
"omp": "Oh My Pi terminal coding agent",
"letta": "Letta CLI agent with persistent memory and tool use",
"prime-agent": "Prime Agent — self-improving RLM coding harness with OpenAI-compatible provider support",
"warp": "Warp AI terminal with custom provider support",
"agent-deck": "Agent Deck multi-agent orchestrator"
},
@@ -4636,6 +4632,13 @@
"retry": "Retry",
"allOperational": "All systems operational",
"issuesDetected": "System issues detected",
"healthVerdictReady": "OmniRoute is ready",
"healthVerdictActionRequired": "Action required to restore full operation",
"healthVerdictCoolingDown": "Cooling down after recent changes",
"healthSubtitle": "System health check",
"advancedDiagnosticsTitle": "Advanced diagnostics",
"hide": "Hide",
"show": "Show",
"updatedAt": "Updated {time}",
"latency": "Latency",
"latencyP50": "p50",
@@ -6707,6 +6710,18 @@
"sidebarVisibility": "Hide sidebar items",
"sidebarVisibilityDesc": "Hide any sidebar navigation entry to reduce visual clutter without disabling any features",
"sidebarVisibilityHint": "Any sidebar section is hidden automatically when all of its entries are hidden",
"presetAll": "All",
"presetAllDesc": "Show everything",
"presetEssentials": "Essentials",
"presetEssentialsDesc": "Beginner path - Advanced tools stay searchable",
"presetMinimal": "Minimal",
"presetMinimalDesc": "Core pages only",
"presetDeveloper": "Developer",
"presetDeveloperDesc": "Dev & proxy tools",
"presetAdmin": "Admin",
"presetAdminDesc": "Monitoring & audit",
"settingsSidebarTitle": "Sidebar Customization",
"settingsSidebarDesc": "Choose which sidebar items to show. Essentials keeps Advanced tools searchable.",
"hideHealthLogs": "Hide Health Check Logs",
"hideHealthLogsDesc": "When ON, suppress [HealthCheck] messages in server console",
"themeAccent": "Theme color",

View File

@@ -970,6 +970,13 @@
"batchTimelineCancelled": "Cancelado",
"batchTokenUsage": "Uso de Token",
"batchMetadata": "Metadados",
"batchHeaderSubtitle": "Execute muitas requisições como um único job",
"batchStep1": "1 · Enviar JSONL",
"batchStep1Desc": "Adicionar requisições",
"batchStep2": "2 · Criar lote",
"batchStep2Desc": "Executar job",
"batchStep3": "3 · Obter resultados",
"batchStep3Desc": "Baixar saída",
"batchFileContents": "Conteúdo do Arquivo",
"batchFileUsedByCount": "Usado por {count, plural, one {# lote} other {# lotes}}",
"batchFilePreview": "Prévia",
@@ -2905,6 +2912,7 @@
"interpreter": "CLI do agente de codificação autônomo Open Interpreter",
"omp": "Agente de codificação de terminal Oh My Pi",
"letta": "Agente CLI Letta com memória persistente e uso de ferramentas",
"prime-agent": "Prime Agent — harness de codificação RLM autoevolutivo com suporte a API compatível com OpenAI",
"warp": "Terminal de IA Warp com suporte a provedor personalizado",
"agent-deck": "Orquestrador multi-agente Agent Deck"
},
@@ -3831,6 +3839,9 @@
},
"endpoint": {
"title": "Endpoint da API",
"subtitle": "Use o endpoint compatível com OpenAI na maioria dos SDKs e ferramentas.",
"testEndpoint": "Testar endpoint →",
"advancedProtocols": "Protocolos avançados",
"available": "Endpoints Disponíveis",
"cloudProxy": "Proxy na Nuvem",
"disableConfirm": "Tem certeza que deseja desativar o proxy na nuvem?",
@@ -4611,6 +4622,13 @@
"retry": "Tentar Novamente",
"allOperational": "Todos os sistemas operacionais",
"issuesDetected": "Problemas detectados no sistema",
"healthVerdictReady": "O OmniRoute está pronto",
"healthVerdictActionRequired": "Ação necessária para restaurar a operação plena",
"healthVerdictCoolingDown": "Em resfriamento após mudanças recentes",
"healthSubtitle": "Verificação de saúde do sistema",
"advancedDiagnosticsTitle": "Diagnósticos avançados",
"hide": "Ocultar",
"show": "Mostrar",
"updatedAt": "Atualizado {time}",
"latency": "Latência",
"latencyP50": "p50",
@@ -12035,6 +12053,7 @@
"acp": {
"title": "ACP Agents",
"phrase": "CLIs que o OmniRoute spawna como backend de execução (fluxo reverso)",
"warning": "A maioria dos usuários pode ignorar isto — use apenas quando uma integração exigir.",
"flow": "Cliente → OmniRoute → spawn CLI (stdio/ACP) → resposta",
"seeOther": "Ver →"
}
@@ -13342,6 +13361,13 @@
},
"resilienceConnections": {
"title": "Resiliência de Conexão",
"reassuranceTitle": "Suas conexões se recuperam automaticamente",
"reassuranceDetail": "Normalmente nenhuma ação é necessária. O OmniRoute dá uma pausa temporária em uma conexão após falhas e depois a tenta novamente com segurança.",
"plainStates": {
"healthy": "Requisições podem ser enviadas",
"coolingDown": "Tentando novamente em breve",
"lockedOut": "Precisa da sua atenção"
},
"table": {
"status": "Status",
"provider": "Provedor",

View File

@@ -1300,13 +1300,7 @@
"open": "mở",
"close": "đóng"
},
"noResults": "Không có kết quả",
"healthVerdictReady": "OmniRoute đã sẵn sàng",
"healthVerdictActionRequired": "Cần hành động để khôi phục hoạt động đầy đủ",
"healthVerdictCoolingDown": "Đang nguội sau các thay đổi gần đây",
"advancedDiagnosticsTitle": "Chẩn đoán nâng cao",
"hide": "Ẩn",
"show": "Hiện"
"noResults": "Không có kết quả"
},
"webhooks": {
"title": "Webhook",
@@ -2918,6 +2912,7 @@
"interpreter": "Tác nhân lập trình tự trị Open Interpreter CLI",
"omp": "Tác nhân lập trình Oh My Pi trên terminal",
"letta": "Tác nhân Letta CLI có bộ nhớ lâu dài và khả năng dùng công cụ",
"prime-agent": "Prime Agent — bộ khung lập trình RLM tự cải tiến hỗ trợ API tương thích OpenAI",
"warp": "Terminal Warp AI hỗ trợ nhà cung cấp tùy chỉnh",
"agent-deck": "Trình điều phối đa tác nhân Agent Deck"
},
@@ -4627,6 +4622,13 @@
"retry": "Thử lại",
"allOperational": "Tất cả hệ thống đang hoạt động bình thường",
"issuesDetected": "Phát hiện sự cố hệ thống",
"healthVerdictReady": "OmniRoute đã sẵn sàng",
"healthVerdictActionRequired": "Cần hành động để khôi phục hoạt động đầy đủ",
"healthVerdictCoolingDown": "Đang nguội sau các thay đổi gần đây",
"healthSubtitle": "Kiểm tra tình trạng hệ thống",
"advancedDiagnosticsTitle": "Chẩn đoán nâng cao",
"hide": "Ẩn",
"show": "Hiện",
"updatedAt": "Đã cập nhật {time}",
"latency": "Độ trễ",
"latencyP50": "p50",

View File

@@ -0,0 +1,53 @@
/**
* Shared A2A authentication + caller-owner resolution (GHSA-jcm5-6wpp-wjj8).
*
* The JSON-RPC router (/a2a) grew its own authenticate() for GHSA-v54m, but
* the REST task routes under /api/a2a/tasks/ had no auth call at all. Both
* surfaces now share this single implementation so they cannot drift again:
* same REQUIRE_API_KEY posture as /v1, same keyless local-first default, and
* a stable owner id (hashed API key) used to scope task visibility.
*/
import { createHash, timingSafeEqual } from "crypto";
import type { NextRequest } from "next/server";
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
function tokensMatch(provided: string, expected: string): boolean {
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}
/**
* Whether the request may use the A2A surface at all. Mirrors the JSON-RPC
* posture: when a client key is required, demand a valid OmniRoute key;
* otherwise honor the legacy explicit A2A key; otherwise stay keyless (the
* same local-first default as /v1).
*/
export async function authenticateA2ARequest(req: NextRequest | Request): Promise<boolean> {
const apiKey = extractApiKey(req as NextRequest);
if (isRequireApiKeyEnabled()) {
return apiKey ? await isValidApiKey(apiKey) : false;
}
const configuredKey = process.env.OMNIROUTE_API_KEY;
if (configuredKey) {
return apiKey ? tokensMatch(apiKey, configuredKey) : false;
}
// No API key required and none configured — allow (keyless local-first).
return true;
}
/**
* Owner id for task scoping (GHSA-jcm5-6wpp-wjj8): a stable hash of the
* caller's API key, or `undefined` when the call carries no key (keyless
* posture — ownerless tasks stay visible to everyone, by design).
*/
export function resolveA2AOwner(req: NextRequest | Request): string | undefined {
const apiKey = extractApiKey(req as NextRequest);
if (!apiKey) return undefined;
return createHash("sha256").update(apiKey).digest("hex").slice(0, 32);
}

View File

@@ -45,6 +45,13 @@ export interface A2ATask {
createdAt: string;
updatedAt: string;
expiresAt: string;
/**
* GHSA-jcm5-6wpp-wjj8: principal that created the task (hashed API key).
* `undefined` = created under the keyless local-first posture — such tasks
* stay visible to every caller, matching the pre-owner behavior. Tasks WITH
* an owner are only returned/cancelled/listed for the same owner.
*/
owner?: string;
}
export interface TaskListFilter {
@@ -91,7 +98,7 @@ export class A2ATaskManager {
}
}
createTask(input: TaskInput): A2ATask {
createTask(input: TaskInput, owner?: string): A2ATask {
const now = new Date();
const task: A2ATask = {
id: randomUUID(),
@@ -104,19 +111,31 @@ export class A2ATaskManager {
createdAt: now.toISOString(),
updatedAt: now.toISOString(),
expiresAt: new Date(now.getTime() + this.ttlMs).toISOString(),
...(owner !== undefined ? { owner } : {}),
};
this.tasks.set(task.id, task);
return task;
}
getTask(taskId: string): A2ATask | undefined {
/**
* Owner scoping (GHSA-jcm5-6wpp-wjj8): a task carrying an owner is visible
* only to that owner. Ownerless tasks (keyless posture, or created before
* this field existed) stay visible to everyone — no behavior change there.
*/
private isVisibleTo(task: A2ATask, owner?: string): boolean {
return task.owner === undefined || task.owner === owner;
}
getTask(taskId: string, owner?: string): A2ATask | undefined {
const task = this.tasks.get(taskId);
if (task && new Date(task.expiresAt) < new Date()) {
if (task.state === "submitted" || task.state === "working") {
this.updateTask(taskId, "failed", undefined, "Task expired");
}
}
return this.tasks.get(taskId);
const current = this.tasks.get(taskId);
if (!current || !this.isVisibleTo(current, owner)) return undefined;
return current;
}
updateTask(
@@ -142,7 +161,15 @@ export class A2ATaskManager {
return task;
}
cancelTask(taskId: string): A2ATask {
cancelTask(taskId: string, owner?: string): A2ATask {
// Owner check BEFORE the mutation (GHSA-jcm5-6wpp-wjj8): a caller must not
// cancel another principal's task by id. Uses the same not-found error as
// a missing task so an IDOR probe cannot distinguish "exists but not
// yours" from "does not exist".
const task = this.tasks.get(taskId);
if (!task || !this.isVisibleTo(task, owner)) {
throw new Error(`Task ${taskId} not found`);
}
return this.updateTask(taskId, "cancelled", undefined, "Cancelled by client");
}
@@ -153,8 +180,11 @@ export class A2ATaskManager {
return tasks.length;
}
listTasks(filter?: TaskListFilter): A2ATask[] {
listTasks(filter?: TaskListFilter, owner?: string): A2ATask[] {
let tasks = [...this.tasks.values()];
// GHSA-jcm5-6wpp-wjj8: when an owner scope is supplied, owned tasks of
// other principals are hidden; ownerless tasks remain visible (posture).
if (owner !== undefined) tasks = tasks.filter((t) => this.isVisibleTo(t, owner));
if (filter?.state) tasks = tasks.filter((t) => t.state === filter.state);
if (filter?.skill) tasks = tasks.filter((t) => t.skill === filter.skill);
tasks.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());

View File

@@ -30,6 +30,27 @@ function providerData(connection: KiroConnectionLike): Record<string, unknown> {
: {};
}
/** True when the identity carries something that identifies the ACCOUNT (not the profile). */
function hasAccountIdentifier(identity: KiroConnectionIdentity): boolean {
return Boolean(folded(identity.email) || trimmed(identity.clientId));
}
/** True when a shared field is present on both sides and disagrees — different accounts. */
function contradictsAccount(
connection: KiroConnectionLike,
identity: KiroConnectionIdentity
): boolean {
const email = folded(identity.email);
const existingEmail = folded(connection.email);
if (email && existingEmail && email !== existingEmail) return true;
const clientId = trimmed(identity.clientId);
const existingClientId = trimmed(providerData(connection).clientId);
if (clientId && existingClientId && clientId !== existingClientId) return true;
return false;
}
/** Find an existing Kiro account without comparing OAuth tokens or API keys. */
export function findKiroConnectionByIdentity(
connections: KiroConnectionLike[],
@@ -45,7 +66,14 @@ export function findKiroConnectionByIdentity(
const match = candidates.find(
(connection) => trimmed(providerData(connection).profileArn) === profileArn
);
if (match) return match;
// A profile ARN identifies the CodeWhisperer PROFILE, not the account: distinct
// Builder ID accounts (Google/GitHub social login) share the same ARN. Accepting it
// as identity made a second social login overwrite the first connection (#10815).
// Only trust the ARN when the incoming identity carries an account-level identifier
// that does not contradict the stored one.
if (match && hasAccountIdentifier(identity) && !contradictsAccount(match, identity)) {
return match;
}
}
const clientId = trimmed(identity.clientId);

View File

@@ -43,6 +43,8 @@ export const LOCAL_ONLY_API_PREFIXES: ReadonlyArray<string> = [
"/dashboard/providers/services/", // T-07: reverse proxy to embedded service UIs
"/api/copilot/", // unauthenticated LLM driver — CLI-only by default; admins can opt-in to remote access via manage-scope bypass
"/api/tools/agent-bridge/", // AgentBridge: spawns MITM server + DNS edits (Hard Rules #15 + #17)
"/api/settings/mitm", // "Enable MITM" flow: installs a system-wide trusted root CA (security add-trusted-cert / certutil / update-ca-certificates) and writes /etc/hosts DNS overrides via src/mitm/* — host-level TLS interception. Was MANAGEMENT-only, so requireLogin=false left it remotely reachable (GHSA-x7vm-hp44-9p79, Hard Rules #15 + #17). Same tier as /api/tools/agent-bridge/.
"/api/cli-tools/antigravity-mitm", // Antigravity MITM enable flow: same privileged CA-trust + DNS surface as /api/settings/mitm (GHSA-x7vm-hp44-9p79, Hard Rules #15 + #17). Covers the /alias child route by prefix.
"/api/tools/traffic-inspector/", // Traffic Inspector: http-proxy listener + system proxy (Hard Rules #15 + #17)
"/api/issue-agent/", // Issue Agent: recorded/local triage executor surface; keep loopback/LAN until sandbox + audit hardening is complete
"/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17)
@@ -126,6 +128,12 @@ export const ALWAYS_PROTECTED_API_PATHS: ReadonlyArray<string> = [
// /api/settings/database already does. isAlwaysProtectedPath matches on a path
// boundary, so this covers export, exportAll and import. (GHSA-mghq-58h3-qcqj)
"/api/db-backups",
// Legacy siblings of /api/db-backups left out of the mghq fix: export-json
// dumps every stored credential and import-json irreversibly replaces
// settings/connections, and both handlers only gate on isAuthRequired() —
// which is false under requireLogin=false. (GHSA-v7g9-7f55-5g46)
"/api/settings/export-json",
"/api/settings/import-json",
];
export function isLoopbackHost(hostHeader: string | null): boolean {

View File

@@ -6,8 +6,11 @@ import { useTranslations } from "next-intl";
import {
SIDEBAR_SECTIONS,
HIDDEN_SIDEBAR_ITEMS_SETTING_KEY,
SIDEBAR_PRESET_KEY,
ESSENTIALS_ADVANCED_TOOL_IDS,
normalizeHiddenSidebarItems,
resolveRuntimeSidebarSections,
type HideableSidebarItemId,
type SidebarItemDefinition,
type SidebarSectionChild,
} from "@/shared/constants/sidebarVisibility";
@@ -61,6 +64,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
const [query, setQuery] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const [hiddenItems, setHiddenItems] = useState<Set<string>>(new Set());
const [activePreset, setActivePreset] = useState<string | null>(null);
const [radarAdminUrl, setRadarAdminUrl] = useState<unknown>(null);
useEffect(() => {
@@ -71,6 +75,9 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
setHiddenItems(
new Set(normalizeHiddenSidebarItems(data?.[HIDDEN_SIDEBAR_ITEMS_SETTING_KEY]))
);
setActivePreset(
typeof data?.[SIDEBAR_PRESET_KEY] === "string" ? data[SIDEBAR_PRESET_KEY] : null
);
setRadarAdminUrl(data?.radarAdminUrl ?? null);
})
.catch(() => {
@@ -104,7 +111,13 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
if (isSidebarGroup(child)) {
const subgroupLabel = safeTranslate(child.titleKey, child.titleFallback);
return child.items
.filter((item) => !hiddenItems.has(item.id))
.filter((item) => {
if (!hiddenItems.has(item.id)) return true;
return (
activePreset === "essentials" &&
ESSENTIALS_ADVANCED_TOOL_IDS.has(item.id as HideableSidebarItemId)
);
})
.map<PaletteItem>((item) => ({
id: item.id,
href: item.href,
@@ -121,7 +134,12 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
}));
}
const item = child as SidebarItemDefinition;
if (hiddenItems.has(item.id)) return [];
if (hiddenItems.has(item.id)) {
const keepForEssentials =
activePreset === "essentials" &&
ESSENTIALS_ADVANCED_TOOL_IDS.has(item.id as HideableSidebarItemId);
if (!keepForEssentials) return [];
}
return [
{
id: item.id,

View File

@@ -202,6 +202,36 @@ export const SIDEBAR_ITEM_ORDER_KEY = "sidebarItemOrder";
export const SIDEBAR_PRESET_KEY = "sidebarActivePreset";
export const SIDEBAR_SETTINGS_UPDATED_EVENT = "omniroute:settings-updated";
/** Beginner Essentials: core path only. Advanced tools stay reachable via search. */
const ESSENTIALS_SHOWN: ReadonlySet<HideableSidebarItemId> = new Set([
"home",
"endpoints",
"api-manager",
"providers",
"health",
"settings-general",
"settings-sidebar",
]);
/** Hidden in Essentials sidebar but kept searchable in Command Palette. */
export const ESSENTIALS_ADVANCED_TOOL_IDS: ReadonlySet<HideableSidebarItemId> = new Set([
"playground",
"logs",
"batch",
"translator",
"combos",
"quota",
"analytics",
"costs",
"cache",
"runtime",
"resilience-connections",
"mcp",
"a2a",
"memory",
"skills",
]);
const MINIMAL_SHOWN: ReadonlySet<HideableSidebarItemId> = new Set([
"home",
"endpoints",
@@ -297,6 +327,7 @@ function buildHiddenList(shown: ReadonlySet<HideableSidebarItemId>): HideableSid
export const SIDEBAR_PRESETS: readonly SidebarPresetDefinition[] = [
{ id: "all", icon: "select_all", hiddenItems: [] },
{ id: "essentials", icon: "star", hiddenItems: buildHiddenList(ESSENTIALS_SHOWN) },
{ id: "minimal", icon: "minimize", hiddenItems: buildHiddenList(MINIMAL_SHOWN) },
{ id: "developer", icon: "code", hiddenItems: buildHiddenList(DEVELOPER_SHOWN) },
{ id: "admin", icon: "admin_panel_settings", hiddenItems: buildHiddenList(ADMIN_SHOWN) },

View File

@@ -174,7 +174,7 @@ export interface SidebarSectionDefinition {
defaultPinned?: boolean;
}
export type SidebarPresetId = "all" | "minimal" | "developer" | "admin";
export type SidebarPresetId = "all" | "essentials" | "minimal" | "developer" | "admin";
export interface SidebarPresetDefinition {
id: SidebarPresetId;

View File

@@ -28,6 +28,8 @@ export const SPAWN_CAPABLE_PREFIXES: ReadonlyArray<string> = [
"/api/cli-tools/qwen-settings", // GET probes the Qwen Code binary; the route also mutates local ~/.qwen files
"/api/services/", // T-10: can run npm install + spawn node processes
"/api/tools/agent-bridge/", // start/stop MITM server + DNS edits (Hard Rules #15 + #17)
"/api/settings/mitm", // installs a system trusted root CA + /etc/hosts DNS overrides via src/mitm/* — must never be whitelistable via manage-scope bypass (GHSA-x7vm-hp44-9p79, Hard Rules #15 + #17)
"/api/cli-tools/antigravity-mitm", // same privileged CA-trust + DNS surface as /api/settings/mitm (GHSA-x7vm-hp44-9p79, Hard Rules #15 + #17)
"/api/tools/traffic-inspector/", // http-proxy listener + system proxy (Hard Rules #15 + #17)
"/api/plugins/", // plugins: load/execute via worker_threads + child_process (Hard Rules #15 + #17)
"/api/local/", // T-12: 1-click local service launchers (Redis today) — must never be whitelistable via manage-scope bypass (Hard Rules #15 + #17)

View File

@@ -67,4 +67,5 @@ export const EXPECTED_CODE_COUNT = 21;
// +2 (#6318): "omp" (Oh My Pi) and "letta" (Letta CLI) added as agent entries.
// Note: #6318 originally also shipped duplicate "pi"/"jcode"/"codewhale" entries —
// those tools were already delivered by a separate PR, so only omp+letta landed here.
export const EXPECTED_AGENT_COUNT = 8;
// +1 (#11166): "prime-agent" (PrimeIntellect-ai/prime-agent) added as an agent entry.
export const EXPECTED_AGENT_COUNT = 9;

View File

@@ -199,7 +199,10 @@ export const updateSettingsSchema = z.object({
.array(z.enum(SIDEBAR_SECTIONS.map((s) => s.id) as [string, ...string[]]))
.optional(),
sidebarItemOrder: z.record(z.string(), z.array(z.string().max(100))).optional(),
sidebarActivePreset: z.enum(["all", "minimal", "developer", "admin"]).nullable().optional(),
sidebarActivePreset: z
.enum(["all", "essentials", "minimal", "developer", "admin"])
.nullable()
.optional(),
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),
codexServiceTier: z
.object({

View File

@@ -89,7 +89,6 @@
"tests/unit/auth-terminal-status.test.ts",
"tests/unit/authz/discovery-routes-local-only.test.ts",
"tests/unit/authz/oauth-autoimport-local-only.test.ts",
"tests/unit/quota-exhaustion-cutoff-opencode.test.ts",
"tests/unit/authz/route-guard-local-prefix.test.ts",
"tests/unit/authz/route-guard-skills-collect.test.ts",
"tests/unit/authz/route-guard-version-get-exemption.test.ts",
@@ -308,6 +307,7 @@
"tests/unit/public-client-ids-3493.test.ts",
"tests/unit/publicCreds.test.ts",
"tests/unit/qoder-oauth-config.test.ts",
"tests/unit/quota-exhaustion-cutoff-opencode.test.ts",
"tests/unit/quota-groups-route.test.ts",
"tests/unit/quota-key-models-route.test.ts",
"tests/unit/quota-policy-generalization.test.ts",

View File

@@ -39,6 +39,10 @@ const qdrantEmbeddingModelsRoute =
// ── Helpers ──
// Route handlers are typed against NextRequest; the management-session helper
// returns the Fetch API Request, which is structurally sufficient at runtime.
const asNextRequest = (req: Request) => req as unknown as import("next/server").NextRequest;
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
@@ -91,7 +95,7 @@ test.after(async () => {
test("GET /api/settings/qdrant — returns settings with masked API key shape", async () => {
const req = await makeAuthRequest("GET", "http://localhost/api/settings/qdrant");
const res = await qdrantSettingsRoute.GET(req as any);
const res = await qdrantSettingsRoute.GET(asNextRequest(req));
assert.strictEqual(res.status, 200);
const body = await res.json();
@@ -110,7 +114,7 @@ test("GET /api/settings/qdrant — returns settings with masked API key shape",
test("GET /api/settings/qdrant — 401 without auth", async () => {
await setRequireLogin(true);
const req = makeUnauthRequest("GET", "http://localhost/api/settings/qdrant");
const res = await qdrantSettingsRoute.GET(req as any);
const res = await qdrantSettingsRoute.GET(asNextRequest(req));
assert.strictEqual(res.status, 401);
await setRequireLogin(false);
});
@@ -126,7 +130,7 @@ test("PUT /api/settings/qdrant — updates settings and returns new masked shape
embeddingModel: "openai/text-embedding-3-small",
});
const res = await qdrantSettingsRoute.PUT(req as any);
const res = await qdrantSettingsRoute.PUT(asNextRequest(req));
assert.strictEqual(res.status, 200);
const body = await res.json();
@@ -148,7 +152,7 @@ test("PUT enabled=true also activates Qdrant as the engine (memoryVectorStore=qd
host: "qdrant-server",
collection: "c",
});
const res = await qdrantSettingsRoute.PUT(req as any);
const res = await qdrantSettingsRoute.PUT(asNextRequest(req));
assert.strictEqual(res.status, 200);
const s = (await localDb.getSettings()) as Record<string, unknown>;
@@ -161,16 +165,20 @@ test("PUT enabled=true also activates Qdrant as the engine (memoryVectorStore=qd
test("PUT enabled=false resets the engine back to auto (sqlite-vec)", async () => {
await qdrantSettingsRoute.PUT(
(await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
enabled: true,
host: "qdrant-server",
collection: "c",
})) as any
asNextRequest(
await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
enabled: true,
host: "qdrant-server",
collection: "c",
})
)
);
await qdrantSettingsRoute.PUT(
(await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
enabled: false,
})) as any
asNextRequest(
await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
enabled: false,
})
)
);
const s = (await localDb.getSettings()) as Record<string, unknown>;
@@ -185,9 +193,11 @@ test("PUT without the enabled field must not change memoryVectorStore", async ()
// User already on qdrant; editing only the collection must not reset the engine.
await localDb.updateSettings({ memoryVectorStore: "qdrant", qdrantEnabled: true });
await qdrantSettingsRoute.PUT(
(await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
collection: "renamed",
})) as any
asNextRequest(
await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
collection: "renamed",
})
)
);
const s = (await localDb.getSettings()) as Record<string, unknown>;
@@ -211,11 +221,13 @@ test("PUT enabled=true invalidates the memory-settings cache (retrieval sees qdr
);
const res = await qdrantSettingsRoute.PUT(
(await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
enabled: true,
host: "qdrant-server",
collection: "c",
})) as any
asNextRequest(
await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
enabled: true,
host: "qdrant-server",
collection: "c",
})
)
);
assert.strictEqual(res.status, 200);
@@ -234,7 +246,7 @@ test("PUT /api/settings/qdrant — 400 invalid settings (invalid port type in st
port: "not-a-number",
});
const res = await qdrantSettingsRoute.PUT(req as any);
const res = await qdrantSettingsRoute.PUT(asNextRequest(req));
assert.strictEqual(res.status, 400);
const body = await res.json();
assert.ok(body.message || body.error, "should return error");
@@ -243,7 +255,7 @@ test("PUT /api/settings/qdrant — 400 invalid settings (invalid port type in st
test("PUT /api/settings/qdrant — 401 without auth", async () => {
await setRequireLogin(true);
const req = makeUnauthRequest("PUT", "http://localhost/api/settings/qdrant", { enabled: true });
const res = await qdrantSettingsRoute.PUT(req as any);
const res = await qdrantSettingsRoute.PUT(asNextRequest(req));
assert.strictEqual(res.status, 401);
await setRequireLogin(false);
});
@@ -257,7 +269,7 @@ test("GET /api/settings/qdrant/health — returns health result shape (qdrant di
headers: Object.fromEntries(headers.entries()),
});
const res = await qdrantHealthRoute.GET(req as any);
const res = await qdrantHealthRoute.GET(asNextRequest(req));
assert.strictEqual(res.status, 200);
const body = await res.json();
@@ -292,7 +304,7 @@ test("GET /api/settings/qdrant/health — reports named collection vector metada
try {
const req = await makeAuthRequest("GET", "http://localhost/api/settings/qdrant/health");
const res = await qdrantHealthRoute.GET(req as any);
const res = await qdrantHealthRoute.GET(asNextRequest(req));
const body = await res.json();
assert.strictEqual(res.status, 200);
@@ -309,7 +321,7 @@ test("GET /api/settings/qdrant/health — reports named collection vector metada
test("GET /api/settings/qdrant/health — 401 without auth", async () => {
await setRequireLogin(true);
const req = makeUnauthRequest("GET", "http://localhost/api/settings/qdrant/health");
const res = await qdrantHealthRoute.GET(req as any);
const res = await qdrantHealthRoute.GET(asNextRequest(req));
assert.strictEqual(res.status, 401);
await setRequireLogin(false);
});
@@ -322,7 +334,7 @@ test("POST /api/settings/qdrant/search — returns ok + results array", async ()
topK: 5,
});
const res = await qdrantSearchRoute.POST(req as any);
const res = await qdrantSearchRoute.POST(asNextRequest(req));
assert.strictEqual(res.status, 200);
const body = await res.json();
@@ -336,7 +348,7 @@ test("POST /api/settings/qdrant/search — 400 invalid body (empty query)", asyn
topK: 5,
});
const res = await qdrantSearchRoute.POST(req as any);
const res = await qdrantSearchRoute.POST(asNextRequest(req));
assert.strictEqual(res.status, 400);
const body = await res.json();
assert.ok(body.message || body.error, "should return error");
@@ -346,7 +358,7 @@ test("POST /api/settings/qdrant/search — 400 invalid body (empty query)", asyn
test("POST /api/settings/qdrant/cleanup — returns ok + deletedCount + retentionDays", async () => {
const req = await makeAuthRequest("POST", "http://localhost/api/settings/qdrant/cleanup");
const res = await qdrantCleanupRoute.POST(req as any);
const res = await qdrantCleanupRoute.POST(asNextRequest(req));
assert.strictEqual(res.status, 200);
const body = await res.json();
@@ -365,7 +377,7 @@ test("GET /api/settings/qdrant/embedding-models — returns models array", async
headers: Object.fromEntries(headers.entries()),
});
const res = await qdrantEmbeddingModelsRoute.GET(req as any);
const res = await qdrantEmbeddingModelsRoute.GET(asNextRequest(req));
// 200 expected; verify shape
assert.strictEqual(res.status, 200);
const body = await res.json();
@@ -387,7 +399,7 @@ test("GET /api/settings/qdrant/embedding-models — lists only configured provid
headers: Object.fromEntries(headers.entries()),
});
const res = await qdrantEmbeddingModelsRoute.GET(req as any);
const res = await qdrantEmbeddingModelsRoute.GET(asNextRequest(req));
assert.strictEqual(res.status, 200);
const body = await res.json();
assert.ok(body.models.length > 0, "should list models for configured provider");
@@ -400,7 +412,7 @@ test("GET /api/settings/qdrant/embedding-models — lists only configured provid
test("GET /api/settings/qdrant/embedding-models — 401 without auth", async () => {
await setRequireLogin(true);
const req = makeUnauthRequest("GET", "http://localhost/api/settings/qdrant/embedding-models");
const res = await qdrantEmbeddingModelsRoute.GET(req as any);
const res = await qdrantEmbeddingModelsRoute.GET(asNextRequest(req));
assert.strictEqual(res.status, 401);
await setRequireLogin(false);
});
@@ -416,7 +428,7 @@ test("Qdrant routes — error response has no stack trace in body", async () =>
body: "not-valid-json{{{",
});
const res = await qdrantSettingsRoute.PUT(req as any);
const res = await qdrantSettingsRoute.PUT(asNextRequest(req));
assert.ok(res.status >= 400, "should return error status");
const body = await res.json();

View File

@@ -0,0 +1,15 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { DEFAULT_DATABASE_SETTINGS } from "../../src/types/databaseSettings.ts";
const guide = readFileSync(new URL("../../docs/ops/DATABASE_GUIDE.md", import.meta.url), "utf8");
test("database guide keeps cache tuning aligned with runtime settings (#11018)", () => {
const defaultCacheSize = DEFAULT_DATABASE_SETTINGS.optimization.cacheSize;
assert.match(guide, new RegExp(`${defaultCacheSize.toLocaleString("en-US")} KiB`));
assert.match(guide, /1 to\s+1,000,000 KiB/);
assert.match(guide, /saving the setting applies it to the live database connection/);
assert.match(guide, /restores the persisted value at startup/);
});

View File

@@ -6,8 +6,8 @@ import { getRegistryEntry } from "../../open-sse/config/providerRegistry.ts";
const { getNextFamilyFallback } = await import("../../open-sse/services/modelFamilyFallback.ts");
// Regression for #8134 — GitHub Copilot ("github", alias "gh") T5 family fallback
// returned "claude-opus-4-6" verbatim even though the github registry catalog
// (Opus 4.8 / 4.8-fast / 4.7 / 4.5) has NO 4.6 tier under any dot/hyphen
// returned "claude-opus-4-6" verbatim even though the github registry catalog at
// the time (Opus 4.8 / 4.8-fast / 4.7 / 4.5) had NO 4.6 tier under any dot/hyphen
// notation. getNextFamilyFallback() resolved `supportedIds` from the provider's
// registry but only used it to try notation variants of a candidate, never to
// filter out a candidate that is provably absent from the catalog — so the
@@ -18,35 +18,46 @@ const { getNextFamilyFallback } = await import("../../open-sse/services/modelFam
// skips (continue) any family candidate that has no match in supportedIds
// under ANY notation (hyphen, dot, or a dated-snapshot id with the date
// suffix stripped) instead of returning it unfiltered.
//
// Fixture note: #10952 later added claude-opus-4.6 to the github registry, so
// the provably-absent tier used by the fixture moved to claude-opus-4-6-thinking
// (the ladder's first candidate after 4.6 — still absent from the catalog).
test("#8134: github claude-opus-4.8 fallback chain never returns an unsupported tier (claude-opus-4-6)", () => {
test("#8134: github claude-opus fallback chain never returns an unsupported tier (claude-opus-4-6-thinking)", () => {
const github = getRegistryEntry("github");
assert.ok(github, "expected the github registry entry to resolve");
const githubIds = new Set(github.models.map((m) => m.id));
// Fixture assumption: #10952 added claude-opus-4.6 to the github registry, so
// the original absent-tier role moved to the 4.6-thinking variant, which the
// catalog still does NOT carry under any notation.
assert.ok(
!githubIds.has("claude-opus-4-6") && !githubIds.has("claude-opus-4.6"),
"fixture assumption broken: github registry now has a 4.6 tier"
!githubIds.has("claude-opus-4-6-thinking") && !githubIds.has("claude-opus-4.6-thinking"),
"fixture assumption broken: github registry now has a 4.6-thinking tier"
);
// Ladder reality: 4.8 -> 4.7 -> 4.6 -> [4-6-thinking (absent), 4-5-20251101,
// sonnet-5]. The absent 4-6-thinking must be SKIPPED — the third hop resolves
// to the dated 4.5 snapshot's undated catalog entry, never to 4-6-thinking.
const tried = new Set(["github/claude-opus-4.8"]);
const first = getNextFamilyFallback("github/claude-opus-4.8", tried);
assert.ok(first, "expected a first fallback candidate");
const firstBareId = first.replace(/^github\//, "");
assert.ok(
githubIds.has(firstBareId),
`first fallback "${first}" is not in github's registered model catalog: ${[...githubIds].join(", ")}`
);
tried.add(first);
const second = getNextFamilyFallback(first, tried);
assert.ok(second, "expected a second fallback candidate (family must not be silently exhausted)");
const secondBareId = second.replace(/^github\//, "");
assert.ok(
githubIds.has(secondBareId),
`second fallback "${second}" is not in github's registered model catalog: ${[...githubIds].join(", ")}`
);
assert.notEqual(secondBareId, "claude-opus-4-6");
assert.notEqual(secondBareId, "claude-opus-4.6");
const hops: string[] = [];
let current = "github/claude-opus-4.8";
for (let hop = 0; hop < 3; hop++) {
const next = getNextFamilyFallback(current, tried);
assert.ok(next, `hop ${hop + 1}: family must not be silently exhausted`);
const bareId = next!.replace(/^github\//, "");
assert.ok(
githubIds.has(bareId),
`hop ${hop + 1}: "${next}" is not in github's registered model catalog: ${[...githubIds].join(", ")}`
);
assert.notEqual(bareId, "claude-opus-4-6-thinking");
assert.notEqual(bareId, "claude-opus-4.6-thinking");
tried.add(next!);
hops.push(next!);
current = next!;
}
// The skip specifically fired: the 4.6 -> next hop jumped past the absent
// 4-6-thinking tier straight to a catalogued model.
assert.equal(hops[2].replace(/^github\//, ""), "claude-opus-4.5");
});
test("#8134: getNextFamilyFallback never returns a candidate absent from the resolved provider's catalog", () => {

View File

@@ -0,0 +1,136 @@
/**
* GHSA-jcm5-6wpp-wjj8 — A2A task IDOR + unauthenticated REST task routes.
*
* Two gaps closed here:
* 1. The REST routes /api/a2a/tasks/[id] and /api/a2a/tasks/[id]/cancel had
* NO auth call at all — open regardless of configuration. They now share
* the JSON-RPC surface's authentication (REQUIRE_API_KEY posture).
* 2. Tasks lived in an owner-less Map: any caller could read/cancel any
* task by id. Tasks now bind to an owner (hashed API key) at creation and
* reads/cancels/lists are owner-scoped. Ownerless tasks (keyless
* local-first posture) stay visible to everyone — by design.
*
* Run with:
* node --import tsx/esm --test tests/unit/a2a-task-owner-idor.test.ts
*/
import { describe, it, after } from "node:test";
import assert from "node:assert/strict";
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(), "omni-a2a-idor-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "a2a-idor-test-secret";
process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE = "1";
const core = await import("../../src/lib/db/core.ts");
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
const { A2ATaskManager, getTaskManager } = await import("../../src/lib/a2a/taskManager.ts");
const { resolveA2AOwner } = await import("../../src/lib/a2a/authenticate.ts");
const restGet = await import("../../src/app/api/a2a/tasks/[id]/route.ts");
const ORIGINAL_REQUIRE = process.env.REQUIRE_API_KEY;
after(() => {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
if (ORIGINAL_REQUIRE === undefined) delete process.env.REQUIRE_API_KEY;
else process.env.REQUIRE_API_KEY = ORIGINAL_REQUIRE;
});
function makeManager() {
const tm = new A2ATaskManager(5);
// Prevent the per-instance cleanup interval from keeping the process alive.
clearInterval((tm as unknown as { cleanupInterval: NodeJS.Timeout }).cleanupInterval);
return tm;
}
describe("A2ATaskManager — owner scoping (GHSA-jcm5)", () => {
it("another principal cannot READ an owned task (same undefined as missing)", () => {
const tm = makeManager();
const task = tm.createTask({ skill: "smart-routing", messages: [] }, "owner-a");
assert.equal(tm.getTask(task.id, "owner-a")?.id, task.id, "the owner still reads it");
assert.equal(tm.getTask(task.id, "owner-b"), undefined, "another owner gets undefined");
});
it("another principal cannot CANCEL an owned task (not-found error, no existence oracle)", () => {
const tm = makeManager();
const task = tm.createTask({ skill: "smart-routing", messages: [] }, "owner-a");
assert.throws(() => tm.cancelTask(task.id, "owner-b"), /not found/);
assert.equal(tm.getTask(task.id, "owner-a")?.state, "submitted", "task untouched");
assert.equal(tm.cancelTask(task.id, "owner-a").state, "cancelled", "the owner can cancel");
});
it("owner-scoped listTasks hides other principals' owned tasks", () => {
const tm = makeManager();
tm.createTask({ skill: "s1", messages: [] }, "owner-a");
const mine = tm.createTask({ skill: "s1", messages: [] }, "owner-b");
const listed = tm.listTasks(undefined, "owner-b");
assert.deepEqual(
listed.map((t) => t.id),
[mine.id]
);
// No owner scope (management/dashboard path) still sees everything.
assert.equal(tm.listTasks(undefined).length, 2);
});
it("ownerless tasks stay visible to everyone (keyless local-first posture)", () => {
const tm = makeManager();
const task = tm.createTask({ skill: "smart-routing", messages: [] });
assert.equal(tm.getTask(task.id, "anyone")?.id, task.id);
assert.equal(tm.getTask(task.id)?.id, task.id);
assert.equal(tm.cancelTask(task.id, "anyone").state, "cancelled");
});
});
describe("REST /api/a2a/tasks/[id] — authentication (GHSA-jcm5)", () => {
it("rejects an unkeyed call when REQUIRE_API_KEY=true (was: no auth at all)", async () => {
process.env.REQUIRE_API_KEY = "true";
delete process.env.OMNIROUTE_API_KEY;
const res = await restGet.GET(new Request("http://localhost/api/a2a/tasks/abc") as never, {
params: Promise.resolve({ id: "abc" }),
});
assert.equal(res.status, 401);
});
it("serves a keyed call under REQUIRE_API_KEY=true", async () => {
process.env.REQUIRE_API_KEY = "true";
const key = await apiKeysDb.createApiKey("a2a-rest-client", "machine-rest", []);
const res = await restGet.GET(
new Request("http://localhost/api/a2a/tasks/definitely-missing", {
headers: { authorization: `Bearer ${key.key}` },
}) as never,
{ params: Promise.resolve({ id: "definitely-missing" }) }
);
// Authenticated — the 404 now comes from the task lookup, not the auth gate.
assert.equal(res.status, 404);
});
it("keyed caller gets 404 for another principal's task (route-level IDOR, GHSA-jcm5)", async () => {
process.env.REQUIRE_API_KEY = "true";
const tm = getTaskManager();
// A task owned by a DIFFERENT principal than the caller's key hash.
const foreign = tm.createTask({ skill: "smart-routing", messages: [] }, "some-other-owner");
const key = await apiKeysDb.createApiKey("a2a-rest-idor", "machine-idor", []);
const req = new Request(`http://localhost/api/a2a/tasks/${foreign.id}`, {
headers: { authorization: `Bearer ${key.key}` },
});
const res = await restGet.GET(req as never, { params: Promise.resolve({ id: foreign.id }) });
assert.equal(res.status, 404, "another principal's task is invisible");
// And the same task IS visible to its owner (owner hash derived from the key).
const owned = tm.createTask(
{ skill: "smart-routing", messages: [] },
resolveA2AOwner(req as never)
);
const res2 = await restGet.GET(
new Request(`http://localhost/api/a2a/tasks/${owned.id}`, {
headers: { authorization: `Bearer ${key.key}` },
}) as never,
{ params: Promise.resolve({ id: owned.id }) }
);
assert.equal(res2.status, 200, "the owner reads its own task");
});
});

View File

@@ -8,7 +8,9 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const TASKS_ROUTE = path.resolve(__dirname, "../../src/app/api/a2a/tasks/route.ts");
const A2A_ROUTE = path.resolve(__dirname, "../../src/app/a2a/route.ts");
// GHSA-jcm5-6wpp-wjj8: the constant-time token comparison moved out of
// src/app/a2a/route.ts into the shared helper both surfaces now use.
const A2A_AUTH_HELPER = path.resolve(__dirname, "../../src/lib/a2a/authenticate.ts");
const source = fs.readFileSync(TASKS_ROUTE, "utf-8");
@@ -21,11 +23,11 @@ function hasImport(src: string, name: string, from: string): boolean {
return pattern.test(src);
}
test("tasks route uses the same constant-time contract as src/app/a2a/route.ts", () => {
const a2aSource = fs.readFileSync(A2A_ROUTE, "utf-8");
test("tasks route uses the same constant-time contract as the shared A2A auth helper", () => {
const a2aSource = fs.readFileSync(A2A_AUTH_HELPER, "utf-8");
assert.ok(
hasImport(a2aSource, "timingSafeEqual", "node:crypto"),
"reference route imports timingSafeEqual"
hasImport(a2aSource, "timingSafeEqual", "crypto"),
"shared auth helper imports timingSafeEqual"
);
assert.ok(

View File

@@ -22,6 +22,22 @@ test("isLocalOnlyPath: /api/cli-tools/runtime/ is local-only", () => {
assert.equal(isLocalOnlyPath("/api/cli-tools/runtime/claude"), true);
});
test("isLocalOnlyPath: MITM management routes are local-only (GHSA-x7vm-hp44-9p79)", () => {
// The "Enable MITM" flow installs a system-wide trusted root CA and writes
// /etc/hosts DNS overrides (src/mitm/*) — host-level TLS interception. Both
// routes were MANAGEMENT-classified only, so requireLogin=false left them
// remotely reachable. They belong to the same loopback tier as
// /api/tools/agent-bridge/ (also MITM + DNS).
assert.equal(isLocalOnlyPath("/api/settings/mitm"), true);
assert.equal(isLocalOnlyPath("/api/cli-tools/antigravity-mitm"), true);
assert.equal(isLocalOnlyPath("/api/cli-tools/antigravity-mitm/alias"), true);
});
test("isLocalOnlyBypassableByManageScope: MITM routes are NOT bypassable (GHSA-x7vm-hp44-9p79)", () => {
assert.equal(isLocalOnlyBypassableByManageScope("/api/settings/mitm"), false);
assert.equal(isLocalOnlyBypassableByManageScope("/api/cli-tools/antigravity-mitm"), false);
});
test("isLocalOnlyPath: regular management routes are not local-only", () => {
assert.equal(isLocalOnlyPath("/api/settings"), false);
assert.equal(isLocalOnlyPath("/api/providers"), false);
@@ -89,6 +105,19 @@ test("isAlwaysProtectedPath: /api/db-backups is always protected (GHSA-mghq-58h3
assert.equal(isAlwaysProtectedPath("/api/db-backups/import"), true);
});
test("isAlwaysProtectedPath: legacy settings export/import-json are always protected (GHSA-v7g9-7f55-5g46)", () => {
// The mghq fix covered /api/db-backups but left the legacy sibling routes out:
// export-json dumps every credential and import-json irreversibly replaces
// settings/connections. Both handlers only check isAuthRequired(), which
// returns false under requireLogin=false — so they must sit in Tier 2 like
// /api/settings/database and /api/db-backups.
assert.equal(isAlwaysProtectedPath("/api/settings/export-json"), true);
assert.equal(isAlwaysProtectedPath("/api/settings/import-json"), true);
// The matcher is a plain startsWith (fail-closed: covers more, never less),
// so a hypothetical export-json2 sibling would also be protected — fine.
assert.equal(isAlwaysProtectedPath("/api/settings/proxy"), false);
});
test("isAlwaysProtectedPath: ordinary settings routes are not always protected", () => {
assert.equal(isAlwaysProtectedPath("/api/settings"), false);
assert.equal(isAlwaysProtectedPath("/api/settings/proxy"), false);

View File

@@ -82,11 +82,13 @@ test("SPAWN_CAPABLE_PREFIXES is defined in the server-free constants leaf with t
"/api/headroom/stop",
"/api/vnc-session",
"/api/modality-bridge/video/",
"/api/settings/mitm",
"/api/cli-tools/antigravity-mitm",
]) {
assert.ok(
SPAWN_CAPABLE_PREFIXES.includes(prefix),
`SPAWN_CAPABLE_PREFIXES lost the spawn-capable prefix "${prefix}" during extraction`
);
}
assert.equal(SPAWN_CAPABLE_PREFIXES.length, 12);
assert.equal(SPAWN_CAPABLE_PREFIXES.length, 14);
});

View File

@@ -61,6 +61,11 @@ function hasImporter(mod: string, roots: string[]): boolean {
new RegExp(`(?:import|require)\\s*\\(\\s*['""][^'"]+/db/${escaped}['"]`),
// dynamic template: import(`…/db/<mod>.ts`) — bin/cli/runtime.mjs uses template literals
new RegExp(`import\\s*\\(\`[^'"\`]+/db/${escaped}\\.ts\`\\)`),
// dynamic via file:// URL helper: import(projectFileUrl("…/db/<mod>.ts")) —
// bin/cli/runtime.mjs since #11238 (Windows-safe file:// dynamic imports).
new RegExp(
`import\\s*\\(\\s*projectFileUrl\\(\\s*['""][^'"]+/db/${escaped}\\.ts['"]\\s*\\)\\s*\\)`
),
// relative import within db/: from "./<mod>" or from "./<mod>"
new RegExp(`from\\s+['"]\\.\\.?/${escaped}['"]`),
];

View File

@@ -41,8 +41,8 @@ test("CLI_TOOLS total code entries (including none) equals 26 (21 visible + 5 no
assert.equal(codeAll.length, 26, `Expected 26 total code entries, got ${codeAll.length}`);
});
test("CLI_TOOLS total (code + agent) = 34", () => {
assert.equal(all.length, 34, `Expected 34 total entries, got ${all.length}`);
test("CLI_TOOLS total (code + agent) = 35", () => {
assert.equal(all.length, 35, `Expected 35 total entries, got ${all.length}`);
});
test("All code-none entries have configType mitm OR are legacy excluded entries", () => {
@@ -99,7 +99,7 @@ test("The 21 visible code entries include Qwen Code's rebuilt integration", () =
}
});
test("The 8 agent entries match D15 list exactly (+ omp + letta, #6318)", () => {
test("The 9 agent entries match D15 list exactly (+ omp + letta #6318, + prime-agent #11166)", () => {
const d15Agents = new Set([
"hermes-agent",
"openclaw",
@@ -109,6 +109,7 @@ test("The 8 agent entries match D15 list exactly (+ omp + letta, #6318)", () =>
"agent-deck",
"omp",
"letta",
"prime-agent",
]);
const agentIds = new Set(agentAll.map((t) => t.id));
for (const id of d15Agents) {

View File

@@ -1,6 +1,6 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveServerHost } from "../../bin/cli/utils/serverHost.mjs";
import { resolveServerHost, resolveExposureWarning } from "../../bin/cli/utils/serverHost.mjs";
test("serve hostname: Linux honors OMNIROUTE_SERVER_HOST when HOSTNAME is set", () => {
assert.equal(
@@ -55,3 +55,26 @@ test("serve hostname: Windows preserves an explicit legacy HOSTNAME", () => {
test("serve hostname: Windows ignores an auto-set HOSTNAME matching the machine", () => {
assert.equal(resolveServerHost({ HOSTNAME: "windows-pc" }, "win32", "windows-pc"), "0.0.0.0");
});
test("exposure warning: fires when bound to all interfaces with no API-key requirement (GHSA-wmgv-ph3p-rv57)", () => {
const warning = resolveExposureWarning({}, "0.0.0.0");
assert.ok(warning, "a warning must be returned for the shipped default posture");
assert.match(warning, /REQUIRE_API_KEY/);
assert.match(warning, /OMNIROUTE_SERVER_HOST/);
});
test("exposure warning: silent when REQUIRE_API_KEY is enabled", () => {
assert.equal(resolveExposureWarning({ REQUIRE_API_KEY: "true" }, "0.0.0.0"), null);
assert.equal(resolveExposureWarning({ REQUIRE_API_KEY: "1" }, "0.0.0.0"), null);
});
test("exposure warning: silent on loopback binds", () => {
assert.equal(resolveExposureWarning({}, "127.0.0.1"), null);
assert.equal(resolveExposureWarning({}, "localhost"), null);
assert.equal(resolveExposureWarning({}, "::1"), null);
});
test("exposure warning: fires for a LAN bind too (any non-loopback interface)", () => {
assert.ok(resolveExposureWarning({}, "192.168.0.17"));
assert.ok(resolveExposureWarning({}, "::"));
});

View File

@@ -74,6 +74,9 @@ describe("omniroute setup opencode", () => {
// Commander turns `--base-url` into `baseUrl` — the runner must accept it.
baseUrl: "http://10.0.0.5:20128",
nonInteractive: true,
// These tests exercise the plugin install/merge path, not the container
// guard (#10057) — keep them hermetic on container devboxes/CI.
allowContainerWrite: true,
});
assert.equal(r.exitCode, 0);
@@ -99,6 +102,7 @@ describe("omniroute setup opencode", () => {
configDir: CONFIG_DIR,
baseUrl: "http://10.0.0.9:20128",
nonInteractive: true,
allowContainerWrite: true,
});
assert.equal(r.exitCode, 0);
@@ -127,7 +131,11 @@ describe("omniroute setup opencode", () => {
})
);
const r = await runSetupOpenCodeCommand({ configDir: CONFIG_DIR, nonInteractive: true });
const r = await runSetupOpenCodeCommand({
configDir: CONFIG_DIR,
nonInteractive: true,
allowContainerWrite: true,
});
assert.equal(r.exitCode, 0);
const cfg = readConfig();
@@ -140,7 +148,11 @@ describe("omniroute setup opencode", () => {
it("fails with a clear error (exit 1) when the bundled plugin dist is missing", async () => {
fs.rmSync(path.join(FAKE_PLUGIN_DIR, "dist"), { recursive: true, force: true });
try {
const r = await runSetupOpenCodeCommand({ configDir: CONFIG_DIR, nonInteractive: true });
const r = await runSetupOpenCodeCommand({
configDir: CONFIG_DIR,
nonInteractive: true,
allowContainerWrite: true,
});
assert.equal(r.exitCode, 1);
} finally {
makeFakePluginDist();

View File

@@ -15,6 +15,10 @@ const originalFetch = globalThis.fetch;
const originalJwtSecret = process.env.JWT_SECRET;
const originalApiKeySecret = process.env.API_KEY_SECRET;
const originalXdg = process.env.XDG_CONFIG_HOME;
const originalAllowContainerWrite = process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE;
// This test exercises the apply/merge path, not the container guard (#10057) —
// keep it hermetic on container devboxes/CI.
process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE = "1";
const testRoots = new Set<string>();
async function createAuthCookie(): Promise<string> {
@@ -72,6 +76,9 @@ test.afterEach(async () => {
else process.env.API_KEY_SECRET = originalApiKeySecret;
if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME;
else process.env.XDG_CONFIG_HOME = originalXdg;
if (originalAllowContainerWrite === undefined)
delete process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE;
else process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE = originalAllowContainerWrite;
for (const root of testRoots) await fs.rm(root, { recursive: true, force: true });
testRoots.clear();
});

View File

@@ -11,6 +11,7 @@ test("CLI_TOOLS registry contains all expected tools including rebuilt Qwen Code
// (CodeWhale is the actively-maintained successor to DeepSeek TUI).
// omp + letta added by #6318 (agent-category CLI integrations).
// grok-build added — xAI Grok Build TUI coding agent (ported from upstream decolua/9router#2571).
// prime-agent added by #11166 (PrimeIntellect-ai/prime-agent, agent category).
const expected = [
"claude",
"codex",
@@ -46,6 +47,7 @@ test("CLI_TOOLS registry contains all expected tools including rebuilt Qwen Code
"grok-build",
"qwen",
"zcode",
"prime-agent",
];
for (const id of expected) {
assert.ok(id in CLI_TOOLS, `Missing tool: ${id}`);

View File

@@ -106,7 +106,9 @@ test("CLI fingerprint preserves Codex executor User-Agent and maps legacy Copilo
{ model: "gpt-4o", messages: [] }
);
assert.equal(copilot.headers["User-Agent"], "GitHubCopilotChat/0.54.0");
// #10952 bumped GITHUB_COPILOT_CLI_VERSION 0.54.0 -> 1.0.81-6; the fingerprint
// pin tracks the advertised upstream CLI version.
assert.equal(copilot.headers["User-Agent"], "GitHubCopilotChat/1.0.81-6");
});
test("CLI fingerprint keeps legacy Copilot settings functional without exposing duplicate UI toggles", () => {

View File

@@ -42,6 +42,9 @@ test("setup-qwen writes current V4 settings and only its dedicated env key", asy
configPath: settingsPath,
envPath,
yes: true,
// These tests exercise the merge/write logic, not the container guard
// (#10057) — keep them hermetic on container devboxes/CI.
allowContainerWrite: true,
});
assert.equal(code, 0);
@@ -76,6 +79,8 @@ test("setup-qwen does not overwrite an invalid settings file", async () => {
model: "model-id",
configPath: settingsPath,
yes: true,
// See above — hermetic regardless of container detection (#10057).
allowContainerWrite: true,
});
assert.equal(code, 1);
assert.equal(await fs.readFile(settingsPath, "utf8"), "{ invalid JSON");

View File

@@ -497,7 +497,13 @@ test(
}
);
test("build phase uses an in-memory database without creating sqlite files", serial, async () => {
test("build phase returns the no-op stub without creating sqlite files", serial, async () => {
// Contract changed by #10060 (via #10952): the build phase no longer opens a
// real in-memory SQLite with migrations — loading the native better-sqlite3
// addon aborts the Next.js build worker on exit (node::
// RemoveEnvironmentCleanupHook). getDbInstance() now returns a no-op stub
// (pinned by tests/unit/build/10060-build-sqlite-stub.test.ts); queries are
// harmless no-ops and no file is touched.
const dataDir = makeTempDir("omniroute-db-build-");
try {
@@ -510,13 +516,15 @@ test("build phase uses an in-memory database without creating sqlite files", ser
const core = await importFresh("src/lib/db/core.ts");
const db = core.getDbInstance();
assert.ok(
assert.notEqual(db.driver, "better-sqlite3");
assert.equal(
db
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get("provider_connections")
.get("provider_connections"),
undefined,
"the build stub must answer queries with no-ops, never a real table scan"
);
assert.equal(fs.existsSync(path.join(dataDir, "storage.sqlite")), false);
assert.equal(db.pragma("journal_mode", { simple: true }), "memory");
core.resetDbInstance();
}

View File

@@ -4,7 +4,7 @@
* response (/api/v1/models), including the learned-only variant entry.
* Never "fix" this test by injecting the same string on both sides.
*/
import { test, after, beforeEach } from "node:test";
import { test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";

View File

@@ -79,3 +79,49 @@ test("findKiroConnectionByIdentity never overwrites a different authentication t
null
);
});
// #10815 — a profile ARN identifies the CodeWhisperer profile, not the account: two
// distinct social (Google/GitHub) Builder ID accounts share the same ARN, so matching
// on it alone made the second login overwrite the first connection.
const SHARED_PROFILE_ARN = "arn:aws:codewhisperer:us-east-1:1:profile/SHARED";
const firstSocialAccount = {
id: "social-account-1",
authType: "oauth",
name: null,
email: null,
providerSpecificData: {
profileArn: SHARED_PROFILE_ARN,
authMethod: "imported",
provider: "Github",
},
};
test("findKiroConnectionByIdentity does not match a shared profile ARN without an account identifier", () => {
const match = findKiroConnectionByIdentity([firstSocialAccount], {
authType: "oauth",
profileArn: SHARED_PROFILE_ARN,
email: null,
});
assert.equal(match, null);
});
test("findKiroConnectionByIdentity treats diverging emails on a shared profile ARN as distinct accounts", () => {
const stored = { ...firstSocialAccount, id: "social-a", email: "a@example.com" };
const match = findKiroConnectionByIdentity([stored], {
authType: "oauth",
profileArn: SHARED_PROFILE_ARN,
email: "b@example.com",
});
assert.equal(match, null);
});
test("findKiroConnectionByIdentity still matches the same account on a shared profile ARN", () => {
const stored = { ...firstSocialAccount, id: "social-a", email: "a@example.com" };
const match = findKiroConnectionByIdentity([stored], {
authType: "oauth",
profileArn: SHARED_PROFILE_ARN,
email: "a@example.com",
});
assert.equal(match?.id, "social-a");
});

View File

@@ -130,13 +130,18 @@ test("a later, lower accepted-list does ratchet the cap down", () => {
assert.equal((getLearnedReasoningEffort("acme", "model-x") as unknown as Set<string>).size, 2);
});
test("clampToLearned medium→low when accepted is low,high,max", async () => {
// #11295: nearest-tier semantics (smallest accepted >= demand) — unified with
// the declared/static clamp. Was downgrade-only (greatest accepted <= demand,
// medium→low) before #11295.
test("clampToLearned medium→high when accepted is low,high,max (nearest-tier, #11295)", async () => {
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
assert.equal(clampToLearned("medium", new Set(["low", "high", "max"])), "low");
assert.equal(clampToLearned("medium", new Set(["low", "high", "max"])), "high");
});
test("clampToLearned xhigh→high when accepted is low,high,max", async () => {
// #11295: xhigh(rank 5) has no accepted tier >= it among {low,high,max}
// (max=6 IS >= 5, so nearest-tier picks max) — was downgrade-only high before.
test("clampToLearned xhigh→max when accepted is low,high,max (nearest-tier, #11295)", async () => {
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
assert.equal(clampToLearned("xhigh", new Set(["low", "high", "max"])), "high");
assert.equal(clampToLearned("xhigh", new Set(["low", "high", "max"])), "max");
});
test("clampToLearned ultra→max when accepted is low,high,max", async () => {
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
@@ -154,17 +159,25 @@ test("clampToLearned returns null when already accepted", async () => {
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
assert.equal(clampToLearned("low", new Set(["low", "high", "max"])), null);
});
test("clampToLearned returns null when effort < min (no upgrade)", async () => {
// #11295: a sub-floor demand (below every accepted value) now maps to the
// accepted floor instead of returning null. Pre-#11295 this returned null —
// no clamp — so the too-low value passed straight through to the upstream,
// which 400'd again on every subsequent request without ever learning a
// lower floor.
test("clampToLearned maps sub-floor demand to the accepted floor instead of null (#11295)", async () => {
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
assert.equal(clampToLearned("low", new Set(["high", "max"])), null);
assert.equal(clampToLearned("low", new Set(["high", "max"])), "high");
});
test("clampToLearned returns null for turbo (not in ORDER)", async () => {
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
assert.equal(clampToLearned("turbo", new Set(["low", "high", "max"])), null);
});
test("clampToLearned returns null when effort is none but accepted is low,high,max", async () => {
// #11295: none is below the learned floor {low,high,max} — nearest-tier maps
// it to the floor (low) instead of returning null (no clamp, upstream 400s
// again with no chance to ever learn a lower floor).
test("clampToLearned maps none to the floor (low) when accepted is low,high,max (#11295)", async () => {
const { clampToLearned } = await import("../../open-sse/services/learnedReasoningEffortCaps.ts");
assert.equal(clampToLearned("none", new Set(["low", "high", "max"])), null);
assert.equal(clampToLearned("none", new Set(["low", "high", "max"])), "low");
});
test("recordLearned stores Set and getLearned returns Set", () => {
const s = recordLearnedReasoningEffort("acme", "m1", ["low", "high", "max"]);

View File

@@ -98,7 +98,11 @@ test("single-target Codex combo advertises a larger model context override", asy
assert.equal(response.status, 200);
assert.equal(direct?.context_length, contextWindow);
assert.equal(combo?.context_length, contextWindow);
assert.equal(combo?.max_input_tokens, 272000);
// #11179 raised the static codex catalog cap to maxInputTokens=872000 (the real
// usable window; the old 272000 was just the first pricing tier). The input cap
// can never exceed the total window, so with the 500K override it clamps to it:
// min(872000, 500000) = 500000.
assert.equal(combo?.max_input_tokens, 500000);
} finally {
contextOverrides.removeModelContextOverride("codex", modelId);
}

View File

@@ -0,0 +1,515 @@
import test from "node:test";
import assert from "node:assert/strict";
// OpenCode `subagent.sessionID` is an optional plain string. Absence means "spawn a
// new child". Responses/Codex strict mode forces every declared property into
// `required`, so models invent fillers (`ses_`, `ses_new`, parent IDs) unless
// OmniRoute offers `null` as the omission sentinel and strips it before the client
// sees the tool call. This is the string counterpart of the #7023 enum sentinel.
const { injectOptionalStringOmissionSentinel, injectOptionalStringOmissionForTools } =
await import("../../open-sse/translator/helpers/schemaCoercion.ts");
const { stripEmptyOptionalToolArgs } =
await import("../../open-sse/translator/response/openai-responses/pureHelpers.ts");
const { openaiResponsesToOpenAIResponse } =
await import("../../open-sse/translator/response/openai-responses.ts");
const { translateRequest } = await import("../../open-sse/translator/index.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
const { translateNonStreamingResponse } =
await import("../../open-sse/handlers/responseTranslator.ts");
const { extractToolSchemaMap } =
await import("../../open-sse/translator/response/openai-responses/toolSchemas.ts");
const OMISSION_MARKER = "null = omit this parameter";
const OPENCODE_SUBAGENT_SCHEMA = {
type: "object",
additionalProperties: false,
properties: {
agent: { type: "string" },
description: { type: "string" },
prompt: { type: "string" },
sessionID: {
type: "string",
description: "Continue a specific previous subagent conversation",
},
background: { type: "boolean" },
},
required: ["agent", "description", "prompt"],
};
const SUBAGENT_TOOL_CHAT = {
type: "function",
function: {
name: "subagent",
parameters: structuredClone(OPENCODE_SUBAGENT_SCHEMA),
},
};
const SUBAGENT_TOOL_RESPONSES = {
type: "function",
name: "subagent",
parameters: structuredClone(OPENCODE_SUBAGENT_SCHEMA),
};
const NATIVE_CUSTOM_TOOL = {
type: "custom",
name: "apply_patch",
format: { type: "grammar", syntax: "lark", definition: "start: /.+/ " },
};
function findTool(tools, name) {
return tools.find((t) => t?.name === name || t?.function?.name === name);
}
function toolParameters(tool) {
return tool.parameters ?? tool.function?.parameters ?? tool.input_schema;
}
function sessionIdSchema(params) {
return params.properties.sessionID;
}
function assertOmissionSentinel(prop) {
assert.deepEqual(prop.type, ["string", "null"]);
assert.match(
prop.description,
new RegExp(OMISSION_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
);
assert.equal(Array.isArray(prop.enum), false);
}
function collectArgs(chunks) {
const list = Array.isArray(chunks) ? chunks : chunks ? [chunks] : [];
let raw = "";
let finishReason = null;
for (const chunk of list) {
const choice = chunk?.choices?.[0];
if (!choice) continue;
const args = choice.delta?.tool_calls?.[0]?.function?.arguments;
if (typeof args === "string") raw += args;
if (choice.finish_reason) finishReason = choice.finish_reason;
}
return { raw, finishReason, parsed: raw ? JSON.parse(raw) : null };
}
test("RED: translateRequest OpenAI→Responses widens optional default-less sessionID", () => {
const body = {
model: "gpt-5.1-codex",
messages: [{ role: "user", content: "hi" }],
tools: [structuredClone(SUBAGENT_TOOL_CHAT)],
};
const toResponses = translateRequest(
FORMATS.OPENAI,
FORMATS.OPENAI_RESPONSES,
"gpt-5.1-codex",
structuredClone(body)
);
const tool = findTool(toResponses.tools, "subagent");
const params = toolParameters(tool);
assertOmissionSentinel(sessionIdSchema(params));
assert.equal(params.properties.agent.type, "string");
assert.equal(params.properties.background.type, "boolean");
assert.deepEqual(params.required, ["agent", "description", "prompt"]);
});
test("RED: same-format Responses applies string omission without flattening native tools", () => {
const body = {
model: "gpt-5.1-codex",
input: [{ role: "user", content: "hi" }],
tools: [structuredClone(SUBAGENT_TOOL_RESPONSES), structuredClone(NATIVE_CUSTOM_TOOL)],
};
const sameFormat = translateRequest(
FORMATS.OPENAI_RESPONSES,
FORMATS.OPENAI_RESPONSES,
"gpt-5.1-codex",
structuredClone(body)
);
const functionTool = findTool(sameFormat.tools, "subagent");
assertOmissionSentinel(sessionIdSchema(toolParameters(functionTool)));
const custom = sameFormat.tools.find((t) => t.name === "apply_patch");
assert.equal(custom.type, "custom");
assert.deepEqual(custom.format, NATIVE_CUSTOM_TOOL.format);
assert.equal(custom.parameters, undefined);
});
test("characterization: non-Responses target leaves sessionID unchanged", () => {
const body = {
model: "claude-3-7-sonnet",
messages: [{ role: "user", content: "hi" }],
tools: [structuredClone(SUBAGENT_TOOL_CHAT)],
};
const toClaude = translateRequest(
FORMATS.OPENAI,
FORMATS.CLAUDE,
"claude-3-7-sonnet",
structuredClone(body)
);
const tool = toClaude.tools.find((t) => String(t.name).includes("subagent"));
const schema = toolParameters(tool);
assert.equal(schema.properties.sessionID.type, "string");
assert.equal(
String(schema.properties.sessionID.description || "").includes(OMISSION_MARKER),
false
);
});
test("characterization: required string stays non-nullable; unmarked required null is kept", () => {
const requiredOnly = injectOptionalStringOmissionSentinel({
type: "object",
properties: { sessionID: { type: "string" } },
required: ["sessionID"],
});
assert.equal(requiredOnly.properties.sessionID.type, "string");
const requiredNull = stripEmptyOptionalToolArgs(
{ sessionID: null, agent: "explore" },
"subagent",
{
type: "object",
properties: { sessionID: { type: "string" }, agent: { type: "string" } },
required: ["sessionID", "agent"],
}
);
assert.equal(Object.prototype.hasOwnProperty.call(requiredNull, "sessionID"), true);
assert.equal(requiredNull.sessionID, null);
});
test("characterization: optional string with default stays unsentinelled through translateRequest", () => {
const body = {
model: "gpt-5.1-codex",
messages: [{ role: "user", content: "hi" }],
tools: [
{
type: "function",
function: {
name: "subagent",
parameters: {
type: "object",
properties: {
agent: { type: "string" },
sessionID: { type: "string", default: "" },
},
required: ["agent"],
},
},
},
],
};
const toResponses = translateRequest(
FORMATS.OPENAI,
FORMATS.OPENAI_RESPONSES,
"gpt-5.1-codex",
structuredClone(body)
);
const params = toolParameters(findTool(toResponses.tools, "subagent"));
assert.equal(params.properties.sessionID.type, "string");
assert.equal(
String(params.properties.sessionID.description || "").includes(OMISSION_MARKER),
false
);
});
test("characterization: optional unmarked null is already stripped; real IDs are kept", () => {
const optionalSchema = structuredClone(OPENCODE_SUBAGENT_SCHEMA);
const stripped = stripEmptyOptionalToolArgs(
{
agent: "explore",
description: "spawn",
prompt: "do work",
sessionID: null,
},
"subagent",
optionalSchema
);
assert.equal(Object.prototype.hasOwnProperty.call(stripped, "sessionID"), false);
const kept = stripEmptyOptionalToolArgs(
{
agent: "explore",
description: "continue",
prompt: "do work",
sessionID: "ses_valid_child",
},
"subagent",
optionalSchema
);
assert.equal(kept.sessionID, "ses_valid_child");
});
test("RED: strictified required sessionID with OmniRoute marker still drops null", () => {
const strictified = {
type: "object",
additionalProperties: false,
properties: {
agent: { type: "string" },
description: { type: "string" },
prompt: { type: "string" },
sessionID: {
type: ["string", "null"],
description: `Continue a specific previous subagent conversation (${OMISSION_MARKER})`,
},
background: { type: "boolean" },
},
required: ["agent", "description", "prompt", "sessionID", "background"],
};
const stripped = stripEmptyOptionalToolArgs(
{
agent: "explore",
description: "spawn",
prompt: "do work",
sessionID: null,
},
"subagent",
strictified
);
assert.equal(Object.prototype.hasOwnProperty.call(stripped, "sessionID"), false);
assert.equal(stripped.agent, "explore");
});
test("characterization: empty sessionID is stripped; nested optional strings are not widened", () => {
const emptyStripped = stripEmptyOptionalToolArgs(
{
agent: "explore",
description: "spawn",
prompt: "do work",
sessionID: "",
},
"subagent",
OPENCODE_SUBAGENT_SCHEMA
);
assert.equal(Object.prototype.hasOwnProperty.call(emptyStripped, "sessionID"), false);
const nested = injectOptionalStringOmissionSentinel({
type: "object",
properties: {
items: {
type: "array",
items: {
type: "object",
properties: { sessionID: { type: "string" } },
required: [],
},
},
wrapper: {
anyOf: [{ type: "object", properties: { sessionID: { type: "string" } } }],
},
$defs: {
child: { type: "object", properties: { sessionID: { type: "string" } } },
},
},
required: [],
});
assert.equal(nested.properties.items.items.properties.sessionID.type, "string");
assert.equal(nested.properties.wrapper.anyOf[0].properties.sessionID.type, "string");
assert.equal(nested.properties.$defs.child.properties.sessionID.type, "string");
const mixedUnion = injectOptionalStringOmissionSentinel({
type: "object",
properties: { value: { type: ["string", "number"] } },
required: [],
});
assert.deepEqual(mixedUnion.properties.value.type, ["string", "number"]);
});
test("characterization: string omission injection is idempotent", () => {
const once = injectOptionalStringOmissionSentinel(structuredClone(OPENCODE_SUBAGENT_SCHEMA));
const twice = injectOptionalStringOmissionSentinel(once);
assertOmissionSentinel(sessionIdSchema(twice));
assert.equal(twice.properties.sessionID.description.split(OMISSION_MARKER).length - 1, 1);
const toolsOnce = injectOptionalStringOmissionForTools([
structuredClone(SUBAGENT_TOOL_RESPONSES),
]);
const toolsTwice = injectOptionalStringOmissionForTools(toolsOnce);
assertOmissionSentinel(toolParameters(toolsTwice[0]).properties.sessionID);
});
test("characterization: fragmented deltas + output_item.done emit cleaned lowercase subagent args", () => {
const schema = {
type: "object",
properties: {
agent: { type: "string" },
description: { type: "string" },
prompt: { type: "string" },
sessionID: {
type: ["string", "null"],
description: `Continue a specific previous subagent conversation (${OMISSION_MARKER})`,
},
},
required: ["agent", "description", "prompt"],
};
const state = { toolSchemas: new Map([["subagent", schema]]) };
openaiResponsesToOpenAIResponse(
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "subagent" },
},
state
);
const raw = JSON.stringify({
agent: "explore",
description: "spawn",
prompt: "do work",
sessionID: null,
});
const firstDelta = openaiResponsesToOpenAIResponse(
{ type: "response.function_call_arguments.delta", delta: raw.slice(0, 40) },
state
);
const secondDelta = openaiResponsesToOpenAIResponse(
{ type: "response.function_call_arguments.delta", delta: raw.slice(40) },
state
);
const done = openaiResponsesToOpenAIResponse(
{
type: "response.output_item.done",
item: { type: "function_call", call_id: "call_1", name: "subagent", arguments: raw },
},
state
);
assert.equal(firstDelta, null);
assert.equal(secondDelta, null);
const args = JSON.parse(done.choices[0].delta.tool_calls[0].function.arguments);
assert.equal(Object.prototype.hasOwnProperty.call(args, "sessionID"), false);
assert.equal(args.agent, "explore");
assert.equal(args.prompt, "do work");
});
test("RED: incomplete-stream flush emits cleaned lowercase subagent arguments", () => {
const schema = {
type: "object",
properties: {
agent: { type: "string" },
description: { type: "string" },
prompt: { type: "string" },
sessionID: {
type: ["string", "null"],
description: `Continue a specific previous subagent conversation (${OMISSION_MARKER})`,
},
},
required: ["agent", "description", "prompt"],
};
const state = { toolSchemas: new Map([["subagent", schema]]) };
openaiResponsesToOpenAIResponse(
{
type: "response.output_item.added",
item: { type: "function_call", call_id: "call_1", name: "subagent" },
},
state
);
const raw = JSON.stringify({
agent: "explore",
description: "spawn",
prompt: "do work",
sessionID: null,
});
openaiResponsesToOpenAIResponse(
{ type: "response.function_call_arguments.delta", delta: raw },
state
);
const flushed = openaiResponsesToOpenAIResponse(null, state);
const { parsed, finishReason } = collectArgs(flushed);
assert.ok(parsed);
assert.equal(Object.prototype.hasOwnProperty.call(parsed, "sessionID"), false);
assert.equal(parsed.agent, "explore");
assert.equal(finishReason, "tool_calls");
});
test("RED: non-streaming Responses translation drops sessionID null when given the schema", () => {
const schema = {
type: "object",
properties: {
agent: { type: "string" },
description: { type: "string" },
prompt: { type: "string" },
sessionID: {
type: ["string", "null"],
description: `Continue a specific previous subagent conversation (${OMISSION_MARKER})`,
},
},
required: ["agent", "description", "prompt", "sessionID"],
};
const responseBody = {
id: "resp_1",
object: "response",
output: [
{
type: "function_call",
call_id: "call_1",
name: "subagent",
arguments: JSON.stringify({
agent: "explore",
description: "spawn",
prompt: "do work",
sessionID: null,
}),
},
],
};
const translated = translateNonStreamingResponse(
responseBody,
FORMATS.OPENAI_RESPONSES,
FORMATS.OPENAI,
null,
new Map([["subagent", schema]])
);
const args = JSON.parse(translated.choices[0].message.tool_calls[0].function.arguments);
assert.equal(Object.prototype.hasOwnProperty.call(args, "sessionID"), false);
assert.equal(args.agent, "explore");
});
test("characterization: non-streaming keeps a real sessionID and legacy empty cleanup without schema", () => {
const withId = translateNonStreamingResponse(
{
id: "resp_2",
object: "response",
output: [
{
type: "function_call",
call_id: "call_2",
name: "subagent",
arguments: JSON.stringify({
agent: "explore",
description: "continue",
prompt: "do work",
sessionID: "ses_valid_child",
}),
},
],
},
FORMATS.OPENAI_RESPONSES,
FORMATS.OPENAI
);
const kept = JSON.parse(withId.choices[0].message.tool_calls[0].function.arguments);
assert.equal(kept.sessionID, "ses_valid_child");
const noSchema = translateNonStreamingResponse(
{
id: "resp_3",
object: "response",
output: [
{
type: "function_call",
call_id: "call_3",
name: "other",
arguments: { note: "", tags: [] },
},
],
},
FORMATS.OPENAI_RESPONSES,
FORMATS.OPENAI
);
const cleaned = JSON.parse(noSchema.choices[0].message.tool_calls[0].function.arguments);
assert.equal(Object.prototype.hasOwnProperty.call(cleaned, "note"), false);
assert.equal(Object.prototype.hasOwnProperty.call(cleaned, "tags"), false);
});
test("characterization: extractToolSchemaMap still keys OpenCode subagent by lowercase name", () => {
const map = extractToolSchemaMap({ tools: [structuredClone(SUBAGENT_TOOL_RESPONSES)] });
assert.ok(map?.has("subagent"));
assert.equal(map.get("subagent").properties.sessionID.type, "string");
});

View File

@@ -34,6 +34,21 @@ test("every x-loopback-only path matches a LOCAL_ONLY prefix in routeGuard.ts",
}
});
test("GET /api/openapi/spec documents its conditional management auth contract", () => {
const operation = paths["/api/openapi/spec"]?.get;
assert.deepEqual(operation?.security, [{ ManagementSessionAuth: [] }]);
assert.match(operation?.description ?? "", /When `requireLogin` is enabled/);
assert.equal(
operation?.responses?.["401"]?.$ref,
"#/components/responses/ManagementAuthenticationRequired"
);
assert.equal(
operation?.responses?.["403"]?.$ref,
"#/components/responses/ManagementInvalidToken"
);
});
test("every x-always-protected path matches ALWAYS_PROTECTED_API_PATHS in routeGuard.ts", () => {
for (const [pathStr, methods] of Object.entries(paths)) {
if (!methods || typeof methods !== "object") continue;

View File

@@ -181,10 +181,11 @@ test("provider models route merges live Codex models with the local catalog then
// merge conservatively — the smaller of live vs. pinned wins, never the
// larger, so a stale/inflated live number can never make OmniRoute promise
// more context than the account can actually serve (#7012). Here the pinned
// GPT-5.6 Codex contract (272000/128000, see GPT_5_6_CODEX_CAPABILITIES)
// GPT-5.6 Codex contract (872000/128000, see GPT_5_6_CODEX_CAPABILITIES — raised
// from the old 272K pricing tier to the real usable window by #11179)
// is smaller than the live payload's 999999/999999, so the pinned value wins.
assert.equal(liveModel?.name, "GPT 5.6 Sol Live");
assert.equal(liveModel?.inputTokenLimit, 272000);
assert.equal(liveModel?.inputTokenLimit, 872000);
assert.equal(liveModel?.outputTokenLimit, 128000);
assert.equal(liveModel?.apiFormat, "responses");
assert.deepEqual(liveModel?.supportedEndpoints, ["responses"]);

View File

@@ -108,7 +108,7 @@ test("a second request for the same provider+model sends the learned value on th
}
});
test("400 please use low, high, or max clamps and retries once", async () => {
test("400 please use low, high, or max clamps and retries once (nearest-tier: medium -> high, #11295)", async () => {
const executor = new SimpleExecutor();
const originalFetch = globalThis.fetch;
const capturedBodies: Record<string, unknown>[] = [];
@@ -140,7 +140,10 @@ test("400 please use low, high, or max clamps and retries once", async () => {
});
assert.equal(capturedBodies.length, 2);
assert.equal(capturedBodies[0].reasoning_effort, "medium");
assert.equal(capturedBodies[1].reasoning_effort, "low");
// #11295: nearest-tier — smallest accepted >= demand — maps medium(3) to
// high(4), the smallest accepted rank at or above it (was "low" under the
// old downgrade-only direction).
assert.equal(capturedBodies[1].reasoning_effort, "high");
const learned = getLearnedReasoningEffort("openai-compatible-chat-eaff6869", "x-preview-f-free") as unknown as Set<string>;
assert.ok(learned instanceof Set);
assert.ok(learned.has("low"));
@@ -190,7 +193,7 @@ test("400 please use low, medium with ultra retries to medium", async () => {
}
});
test("no-op clamp does not retry: learned {high,max} with low request stays single-fetch", async () => {
test("sub-floor clamp now retries: learned {high,max} with low request clamps up to high (#11295)", async () => {
const executor = new SimpleExecutor();
const originalFetch = globalThis.fetch;
const capturedBodies: Record<string, unknown>[] = [];
@@ -214,17 +217,20 @@ test("no-op clamp does not retry: learned {high,max} with low request stays sing
};
try {
// low is below the learned minimum {high,max}: downgrade-only passthrough,
// sanitizer leaves the body unchanged -> no identical-body retry.
// #11295: low is below the learned minimum {high,max}. Pre-#11295 this was
// a downgrade-only passthrough (no clamp, no retry, upstream stayed 400
// forever). Nearest-tier now clamps up to the accepted floor (high) and
// retries once, succeeding.
const result = await executor.execute({
model: "x-preview-f-free-3",
body: { reasoning_effort: "low" },
stream: false,
credentials: {},
});
assert.equal(capturedBodies.length, 1);
assert.equal(capturedBodies.length, 2);
assert.equal(capturedBodies[0].reasoning_effort, "low");
assert.equal(result.response.status, 400);
assert.equal(capturedBodies[1].reasoning_effort, "high");
assert.equal(result.response.status, 200);
} finally {
globalThis.fetch = originalFetch;
}

View File

@@ -0,0 +1,79 @@
// #11295 — the learned clamp (reactive, from upstream 4xx) and the declared
// clamp (static registry `supportedThinkingEfforts`) used to disagree on
// direction for the identical accepted set {low,high,max}: the learned path
// was downgrade-only (medium -> low) while the declared path was already
// nearest-tier (medium -> high). Same inputs, opposite outputs, depending only
// on whether the model happened to have a static registry entry. This test
// proves the two paths now agree, and that a request below the learned floor
// (previously silently passed through unmapped, returning null from
// clampToLearned) is now mapped up to the nearest accepted tier instead.
import { test, after, beforeEach } from "node:test";
import assert from "node:assert/strict";
import { clampToLearned } from "../../open-sse/services/learnedReasoningEffortCaps.ts";
import { sanitizeReasoningEffortForProvider } from "../../open-sse/executors/base/reasoningEffort.ts";
import {
recordLearnedReasoningEffort,
__test_resetLearnedReasoningEffortCaps,
} from "../../open-sse/services/learnedReasoningEffortCaps.ts";
beforeEach(() => {
__test_resetLearnedReasoningEffortCaps();
});
after(() => {
__test_resetLearnedReasoningEffortCaps();
});
test("clampToLearned: nearest-tier medium -> high when accepted is {low,high,max} (was low pre-#11295)", () => {
assert.equal(clampToLearned("medium", new Set(["low", "high", "max"])), "high");
});
test("sanitizeReasoningEffortForProvider maps medium identically for a LEARNED-only model and a DECLARED model with the same {low,high,max} accepted set", () => {
// Learned side: a custom OpenAI-compatible connection that has no static
// registry entry — the only source of truth is the reactively-learned set.
recordLearnedReasoningEffort("acme-oai-compatible", "custom-reasoner", [
"low",
"high",
"max",
]);
const learnedResult = sanitizeReasoningEffortForProvider(
{ reasoning_effort: "medium" },
"acme-oai-compatible",
"custom-reasoner"
) as Record<string, unknown>;
// Declared side: opencode-go/ox-alpha-free, whose registry entry declares
// supportedThinkingEfforts: ["low", "high", "max"] (see reasoningEffort.ts
// comment referencing the Console Go 400 case).
const declaredResult = sanitizeReasoningEffortForProvider(
{ reasoning_effort: "medium" },
"opencode-go",
"ox-alpha-free"
) as Record<string, unknown>;
assert.equal(learnedResult.reasoning_effort, "high");
assert.equal(declaredResult.reasoning_effort, "high");
assert.equal(learnedResult.reasoning_effort, declaredResult.reasoning_effort);
});
test("sub-floor request (none) on a learned-only model with floor {low,high,max} maps to low, not a pass-through null-clamp", () => {
recordLearnedReasoningEffort("acme-oai-compatible", "custom-reasoner-2", [
"low",
"high",
"max",
]);
const result = sanitizeReasoningEffortForProvider(
{ reasoning_effort: "none" },
"acme-oai-compatible",
"custom-reasoner-2"
) as Record<string, unknown>;
assert.equal(result.reasoning_effort, "low");
});
test("clampToLearned: sub-floor demand (none) below accepted {low,high,max} maps to the accepted floor (low), not null", () => {
assert.equal(clampToLearned("none", new Set(["low", "high", "max"])), "low");
});
test("clampToLearned: sub-floor demand (low) below accepted {high,max} maps to the accepted floor (high), not null", () => {
assert.equal(clampToLearned("low", new Set(["high", "max"])), "high");
});

View File

@@ -90,23 +90,25 @@ test("deepseek's non-ordinal max<->xhigh translation is untouched by the learned
assert.equal(result.reasoning_effort, "max");
});
test("proactive clamp: medium→low for learned {low,high,max}", () => {
// #11295: nearest-tier — smallest accepted >= demand — replaces the old
// downgrade-only (greatest accepted <= demand) direction.
test("proactive clamp: medium→high for learned {low,high,max} (nearest-tier, #11295)", () => {
recordLearnedReasoningEffort("opencode-zen-direct", "x-preview-f-free", ["low", "high", "max"]);
const out = sanitizeReasoningEffortForProvider(
{ reasoning_effort: "medium", model: "x-preview-f-free" },
"opencode-zen-direct",
"x-preview-f-free"
) as { reasoning_effort: string };
assert.equal(out.reasoning_effort, "low");
assert.equal(out.reasoning_effort, "high");
});
test("proactive clamp: xhigh→high for learned {low,high,max}", () => {
test("proactive clamp: xhigh→max for learned {low,high,max} (nearest-tier, #11295)", () => {
recordLearnedReasoningEffort("opencode-zen-direct", "x-preview-f-free-2", ["low", "high", "max"]);
const out = sanitizeReasoningEffortForProvider(
{ reasoning_effort: "xhigh", model: "x-preview-f-free-2" },
"opencode-zen-direct",
"x-preview-f-free-2"
) as { reasoning_effort: string };
assert.equal(out.reasoning_effort, "high");
assert.equal(out.reasoning_effort, "max");
});
test("proactive clamp: ultra→max for learned {low,high,max}", () => {
recordLearnedReasoningEffort("opencode-zen-direct", "x-preview-f-free-3", ["low", "high", "max"]);
@@ -135,14 +137,16 @@ test("proactive clamp: high→medium for learned {low,medium}", () => {
) as { reasoning_effort: string };
assert.equal(out.reasoning_effort, "medium");
});
test("no upgrade: low stays low for learned {high,max}", () => {
// #11295: sub-floor demand (low, below the learned floor {high,max}) now
// clamps up to the floor instead of passing through unchanged.
test("sub-floor clamp: low→high for learned {high,max} (#11295)", () => {
recordLearnedReasoningEffort("acme", "m3", ["high", "max"]);
const out = sanitizeReasoningEffortForProvider(
{ reasoning_effort: "low", model: "m3" },
"acme",
"m3"
) as { reasoning_effort: string };
assert.equal(out.reasoning_effort, "low");
assert.equal(out.reasoning_effort, "high");
});
test("custom model ultra→medium for learned {low,medium}", () => {
recordLearnedReasoningEffort("openai-compatible-chat-eaff6869", "qwen3-coder-30b-a3b-instruct-2", ["low", "medium"]);

View File

@@ -0,0 +1,81 @@
/**
* SSRF guard coverage for /v1/search's shared base-url resolution (GHSA-j7j4-g9qc-q69c).
*
* `provider_options.baseUrl` (and legacy `providerSpecificData.baseUrl`) is
* client-controlled and flowed verbatim through `resolveSearchBaseUrl()` into
* every search builder's server-side fetch target (searxng, ollama, …), with
* no SSRF validation — while the sink (`searchProxy.ts`) is a plain `fetch()`.
* The Firecrawl sibling was fixed in #10738; this shared resolver was missed.
*
* Guard mode is `block-metadata` (NOT public-only): the catalog's primary
* searxng use case is a self-hosted instance on loopback/LAN, so private
* hosts must keep working, while cloud-metadata endpoints (IMDS credential
* theft — the worst pivot) are rejected.
*
* Run with:
* node --import tsx/esm --test tests/unit/search-baseurl-ssrf-guard.test.ts
*/
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { resolveSearchBaseUrl } from "../../open-sse/handlers/search.ts";
import type { SearchProviderConfig } from "../../open-sse/config/searchRegistry.ts";
const config: SearchProviderConfig = {
id: "searxng-search",
name: "SearXNG",
baseUrl: "http://127.0.0.1:8888",
method: "GET",
authType: "none",
costPerQuery: 0,
} as SearchProviderConfig;
const base = {
query: "test",
searchType: "web",
maxResults: 5,
};
const METADATA_URLS = [
"http://169.254.169.254/latest/meta-data/iam/security-credentials/",
"http://169.254.169.254/latest/meta-data/?x=/search", // reporter's suffix-bypass shape
"http://metadata.google.internal/computeMetadata/v1/",
];
describe("resolveSearchBaseUrl — SSRF guard on client-controlled baseUrl (GHSA-j7j4)", () => {
for (const malicious of METADATA_URLS) {
it(`rejects providerOptions.baseUrl pointing at cloud metadata (${malicious})`, () => {
assert.throws(() => {
resolveSearchBaseUrl(config, { ...base, providerOptions: { baseUrl: malicious } });
});
});
it(`rejects providerSpecificData.baseUrl pointing at cloud metadata (${malicious})`, () => {
assert.throws(() => {
resolveSearchBaseUrl(config, { ...base, providerSpecificData: { baseUrl: malicious } });
});
});
}
it("still allows a self-hosted loopback/LAN override (block-metadata, not public-only)", () => {
assert.equal(
resolveSearchBaseUrl(config, {
...base,
providerOptions: { baseUrl: "http://127.0.0.1:9999" },
}),
"http://127.0.0.1:9999"
);
assert.equal(
resolveSearchBaseUrl(config, {
...base,
providerOptions: { baseUrl: "http://10.0.0.5:8080" },
}),
"http://10.0.0.5:8080"
);
});
it("leaves the catalog baseUrl untouched when no override is supplied", () => {
assert.equal(resolveSearchBaseUrl(config, base), "http://127.0.0.1:8888");
});
});

View File

@@ -420,25 +420,24 @@ test("v1 search POST preserves stored SearXNG baseUrl for authless providers", a
}
});
test("v1 search POST returns 400 when auto-select finds no configured provider (searxng-search is now fallbackOnly)", async () => {
test("v1 search POST falls back to duckduckgo-free when no provider is configured (#11097)", async () => {
// Contract changed by PR #11097 ("fix(search): fall back to duckduckgo-free when
// no search provider is configured"): zero-credential /v1/search no longer returns
// 400 — it promotes the fallback-only duckduckgo-free provider so out-of-the-box
// search works. This test pins the NEW contract.
const originalFetch = globalThis.fetch;
let capturedUrl = "";
// DuckDuckGo lite HTML shape: result link + snippet cell (see
// open-sse/services/freeWebSearch.ts parseDuckDuckGoLite).
const liteHtml = `<html><body>
<a href="https://example.com/auto-result" class='result-link'>Auto-selected DuckDuckGo result</a>
<td class='result-snippet'>Fallback free search snippet</td>
</body></html>`;
globalThis.fetch = async (url) => {
capturedUrl = String(url);
return new Response(
JSON.stringify({
results: [
{
title: "Auto-selected SearXNG result",
url: "https://searx.example/auto",
content: "Auto-selected self-hosted response",
engines: ["duckduckgo"],
},
],
}),
{ status: 200, headers: { "content-type": "application/json" } }
);
return new Response(liteHtml, { status: 200, headers: { "content-type": "text/html" } });
};
try {
@@ -454,14 +453,15 @@ test("v1 search POST returns 400 when auto-select finds no configured provider (
);
const body = (await response.json()) as any;
assert.equal(response.status, 400);
assert.equal(capturedUrl, "", "fallback-only SearXNG must not receive an upstream request");
assert.ok(body.error?.message || body.error);
assert.match(
String(body.error?.message ?? body.error),
/provider|configured/i,
"the response must explain that no provider was selected"
assert.equal(response.status, 200);
assert.equal(
capturedUrl,
"https://lite.duckduckgo.com/lite/",
"the fallback must call the DuckDuckGo lite endpoint"
);
assert.equal(body.provider, "duckduckgo-free");
assert.equal(body.results[0].title, "Auto-selected DuckDuckGo result");
assert.equal(body.results[0].url, "https://example.com/auto-result");
} finally {
globalThis.fetch = originalFetch;
}

View File

@@ -89,9 +89,10 @@ test("applyItemOrder ignores unknown IDs in order list", () => {
// ─── SIDEBAR_PRESETS ──────────────────────────────────────────────────────────
test("SIDEBAR_PRESETS contains all four preset IDs", () => {
test("SIDEBAR_PRESETS contains all five preset IDs", () => {
const ids = SIDEBAR_PRESETS.map((p) => p.id);
assert.ok(ids.includes("all"), "expected 'all' preset");
assert.ok(ids.includes("essentials"), "expected 'essentials' preset");
assert.ok(ids.includes("minimal"), "expected 'minimal' preset");
assert.ok(ids.includes("developer"), "expected 'developer' preset");
assert.ok(ids.includes("admin"), "expected 'admin' preset");
@@ -112,6 +113,30 @@ test("SIDEBAR_PRESETS 'all' preset has no hidden items", () => {
assert.deepEqual(allPreset.hiddenItems, []);
});
test("SIDEBAR_PRESETS includes essentials as the beginner path", () => {
assert.equal(SIDEBAR_PRESETS.length, 5);
assert.deepEqual(
SIDEBAR_PRESETS.map((p) => p.id),
["all", "essentials", "minimal", "developer", "admin"]
);
const essentials = SIDEBAR_PRESETS.find((p) => p.id === "essentials");
assert.ok(essentials, "expected 'essentials' preset to exist");
const hidden = new Set(essentials.hiddenItems);
for (const id of [
"home",
"endpoints",
"api-manager",
"providers",
"health",
"settings-general",
"settings-sidebar",
]) {
assert.equal(hidden.has(id as never), false, `${id} should stay visible in essentials`);
}
assert.equal(hidden.has("playground"), true);
assert.equal(hidden.has("logs"), true);
});
test("SIDEBAR_PRESETS non-all presets have at least one hidden item", () => {
for (const preset of SIDEBAR_PRESETS.filter((p) => p.id !== "all")) {
assert.ok(preset.hiddenItems.length > 0, `Preset '${preset.id}' should hide at least one item`);

View File

@@ -0,0 +1,45 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
test("essentials preset is registered in sidebar visibility types and presets", () => {
const types = fs.readFileSync(
path.join(repoRoot, "src/shared/constants/sidebarVisibility/types.ts"),
"utf8"
);
const visibility = fs.readFileSync(
path.join(repoRoot, "src/shared/constants/sidebarVisibility.ts"),
"utf8"
);
const schema = fs.readFileSync(
path.join(repoRoot, "src/shared/validation/settingsSchemas.ts"),
"utf8"
);
assert.match(types, /"essentials"/);
assert.match(visibility, /id:\s*"essentials"/);
assert.match(visibility, /ESSENTIALS_ADVANCED_TOOL_IDS/);
assert.match(schema, /"essentials"/);
});
test("command palette keeps essentials advanced tools searchable", () => {
const source = fs.readFileSync(
path.join(repoRoot, "src/shared/components/CommandPalette.tsx"),
"utf8"
);
assert.match(source, /ESSENTIALS_ADVANCED_TOOL_IDS/);
assert.match(source, /activePreset === "essentials"/);
});
test("essentials i18n keys exist in en.json", () => {
const en = JSON.parse(
fs.readFileSync(path.join(repoRoot, "src/i18n/messages/en.json"), "utf8")
) as { settings: Record<string, string> };
assert.equal(en.settings.presetEssentials, "Essentials");
assert.match(en.settings.presetEssentialsDesc, /Beginner path/i);
assert.match(en.settings.presetEssentialsDesc, /searchable/i);
});

View File

@@ -0,0 +1,44 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const pagePath = path.join(
repoRoot,
"src/app/(dashboard)/dashboard/tools/traffic-inspector/page.tsx"
);
const clientPath = path.join(
repoRoot,
"src/app/(dashboard)/dashboard/tools/traffic-inspector/TrafficInspectorPageClient.tsx"
);
const enPath = path.join(repoRoot, "src/i18n/messages/en.json");
test("Traffic Inspector page passes translated title, subtitle, and purpose", () => {
const pageSource = fs.readFileSync(pagePath, "utf8");
assert.match(pageSource, /title=\{t\("trafficInspector"\)\}/);
assert.match(pageSource, /subtitle=\{t\("trafficInspectorSubtitle"\)\}/);
assert.match(pageSource, /purpose=\{t\("trafficInspectorPurpose"\)\}/);
});
test("Traffic Inspector client renders purpose-first header when props are provided", () => {
const clientSource = fs.readFileSync(clientPath, "utf8");
assert.match(clientSource, /title\s*&&/);
assert.match(clientSource, /subtitle\s*&&/);
assert.match(clientSource, /purpose\s*&&/);
});
test("Traffic Inspector beginner i18n keys exist in en.json", () => {
const en = JSON.parse(fs.readFileSync(enPath, "utf8"));
assert.equal(en.sidebar.trafficInspector, "Traffic Inspector");
assert.equal(
en.sidebar.trafficInspectorSubtitle,
"Inspect request and response traffic from your apps"
);
assert.equal(
typeof en.sidebar.trafficInspectorPurpose,
"string"
);
assert.ok(en.sidebar.trafficInspectorPurpose.length > 20);
});

View File

@@ -72,10 +72,12 @@ test("usage service covers GitHub free-plan parsing, auth denial and unsupported
assert.equal(freeUsage.quotas.completions.used, 0);
assert.equal(freeUsage.quotas.completions.remainingPercentage, 100);
assert.equal(calls[0].headers.Authorization, "token gho-free");
assert.equal(calls[0].headers["User-Agent"], "GitHubCopilotChat/0.54.0");
assert.equal(calls[0].headers["Editor-Version"], "vscode/1.126.0");
assert.equal(calls[0].headers["Editor-Plugin-Version"], "copilot-chat/0.54.0");
assert.equal(calls[0].headers["X-GitHub-Api-Version"], "2026-06-01");
// #10952 re-based the Copilot wire identity on the live-captured CLI 1.0.81-6
// (copilot-developer-cli integration id; API version 2026-08-01).
assert.equal(calls[0].headers["User-Agent"], "GitHubCopilotChat/1.0.81-6");
assert.equal(calls[0].headers["Editor-Version"], "copilot/1.0.81-6");
assert.equal(calls[0].headers["Editor-Plugin-Version"], "copilot-chat/1.0.81-6");
assert.equal(calls[0].headers["X-GitHub-Api-Version"], "2026-08-01");
globalThis.fetch = async () => new Response("forbidden", { status: 403 });
const forbidden: any = await usageService.getUsageForProvider({

View File

@@ -128,9 +128,11 @@ test("vscode raw models route exposes native GPT-5.6 IDs and effort tiers", asyn
assert.equal(typeof defaultModel.created, "number");
assert.equal(defaultModel.owned_by, "codex");
assert.equal(defaultModel.name, "Codex GPT 5.6 Sol");
assert.equal(defaultModel.context_length, 272000);
// #11179: codex static catalog advertises the usable 872K window (max_context_window),
// not the old 272K pricing tier.
assert.equal(defaultModel.context_length, 872000);
assert.equal(defaultModel.max_output_tokens, 128000);
assert.equal(defaultModel.max_input_tokens, 272000);
assert.equal(defaultModel.max_input_tokens, 872000);
assert.deepEqual(defaultModel.capabilities, {
vision: true,
tool_calling: true,

View File

@@ -255,7 +255,9 @@ test("vscode combos route resolves combo names through Ollama api/show", async (
assert.equal(body.model, "show-combo");
assert.equal(body.modelfile, "FROM show-combo");
assert.equal(body.details.family, "show-combo");
assert.equal(body.model_info.context_length, 272000);
// #11179: codex static catalog advertises the usable 872K window (max_context_window),
// not the old 272K pricing tier.
assert.equal(body.model_info.context_length, 872000);
assert.deepEqual(body.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]);
assert.equal(body.model_info.capabilities.reasoning, true);
});
@@ -290,7 +292,8 @@ test("vscode tokenized combos root route exposes importable combo metadata", asy
assert.equal(response.status, 200);
assert.ok(combo, "expected balanced-load in combo root response");
assert.equal(combo.url.includes("/responses#models.ai.azure.com"), true);
assert.equal(combo.maxInputTokens, 272000);
// #11179: codex static catalog maxInputTokens is now the usable 872K window.
assert.equal(combo.maxInputTokens, 872000);
assert.equal(combo.toolCalling, true);
assert.deepEqual(combo.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]);
});
@@ -1073,7 +1076,9 @@ test("vscode tokenized api/show route exposes explicit reasoning effort metadata
assert.equal(body.configurationSchema?.properties?.reasoningEffort?.default, "low");
assert.equal(body.model_info["general.basename"], "Codex GPT 5.6 Sol (Default)");
assert.equal(body.model_info["general.architecture"], "codex");
assert.equal(body.model_info["codex.context_length"], 272000);
// #11179: codex static catalog advertises the usable 872K window (max_context_window),
// not the old 272K pricing tier.
assert.equal(body.model_info["codex.context_length"], 872000);
assert.deepEqual(body.model_info.supports_reasoning_effort, [
"low",
"medium",