Compare commits

..

16 Commits

Author SHA1 Message Date
Xiangzhe
9daeb1c7be fix(chat): detect severity-classifier format in claudeClassifierCompat short-circuit (#11289) 2026-08-23 21:18:31 -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
Markus Hartung
6158c9aeec fix: drop stray conflict leftover in managedModelImport (#11259 cherry-pick integration) 2026-08-23 18:14:06 -03:00
backryun
2764812ee4 fix(providers): eliminate Gemini 3.5 Flash 2026-08-23 18:13:11 -03:00
Marcel Wysocki
29caad9f3d feat(sse): discover Anthropic partner models on Vertex AI (#11279)
Validated on the combined 8-PR board + the branch itself: vertex-anthropic-models 4/4 (new — pushed to your branch as a fix-in-place commit per Hard Rule #8, covering the parser's global/project-scoped resource names, malformed-input handling, and the claude-* → targetFormat heuristic on vertex/vertex-partner), 88/88 across the board's focused suites, typecheck:core clean, gates within baseline. Anthropic partner models on Vertex AI now discover dynamically via the Model Garden publisher endpoint and route through the Claude translator even for models outside the static registry. Thank you @maci0!
2026-08-23 18:12:37 -03:00
Jack Smith
14d70a755b fix: resolve CLI mcp call protocol issues (BUG-001) (#10960)
Validated on the combined 8-PR board: all seven CLI command suites green (combo-suggest, compression, mcp-call, oneproxy, resilience, skills + the shared mcpStreamMock helper) within the board's 88/88, typecheck:core clean, gates within baseline. The CLI mcp call protocol fixes (BUG-001) land with full regression coverage across the affected commands. Thank you @YunyunZhai — and thank you for your patience while this one waited for review.
2026-08-23 18:11:50 -03:00
ignamiranda
3abbb60ec6 Beginner UX: soft first-run readiness card on Home (#11282)
Validated on the combined 8-PR board: home-page-static 4/4, check:dashboard-typecheck clean (220 pre-existing, all within frozen baseline — zero syntax errors), 88/88 across the board's focused suites, static gates within baseline. The soft first-run readiness card replaces the hard Home→onboarding redirect with a dismissable 4-step path. Thank you @ignamiranda!
2026-08-23 18:11:46 -03:00
Praveen K Palaniswamy
6d4c4843e9 fix(ollama): route models by advertised capability (#11087) — port of #11088 to the release line (#11271)
Validated on the combined 8-PR board: ollama-local-capabilities-routing 3/3, managed-model-import 9/9 (including the integration with the carried Gemini-3.5-Flash cleanup from #11259), 88/88 across the board's focused suites, typecheck:core + dashboard-typecheck clean, gates within baseline. This brings #11088 to the release line — it had squash-merged to main by base error (mine) — AND fixes the two defects the port caught: the global filter drop that leaked image/video models into OpenAI chat selections (now scoped to self-hosted providers) and the unregistered hard-lease credential site. Exemplary port discipline: byte-identical carry + the corrections in a separate reviewable commit + the superpowers docs deliberately left out. main still needs the same two-line fix. Thank you @yourspraveen!
2026-08-23 18:11:21 -03:00
Mina_
12986c44c9 fix(live-ws, dashboard): allow 0.0.0.0 dashboard origin and stop non-square SVG image warnings (#11269)
Validated on the combined 8-PR board: live-server-allowlist suite green within the board's 88/88, typecheck:core + dashboard-typecheck clean. 0.0.0.0 as a browser Origin is loopback-equivalent (it can never name a LAN host — same treatment as headroom/detect.ts and outboundUrlGuard.ts), so the local-only posture is unchanged and the FORBIDDEN_ORIGIN reconnect flood on the dev server's printed URL stops. Thank you @Minamaged18!
2026-08-23 18:10:57 -03:00
Prajeeth H
ab150e1f2b test(startup): assert disabled boot path log output at runtime (#11016) (#11265)
Validated on the combined 8-PR board: 88/88 across the board's focused suites, typecheck:core + dashboard-typecheck clean, static gates within baseline. The subprocess spawn this test does timed out locally on the shared devbox under load 35 (ETIMEDOUT on tsx bootstrap — environment, not the test); CI runs it unloaded. Runtime log-output assertion for the disabled credential-health boot path, closing the #11016 follow-up suggested on #11029. Thank you @Prajeeth-12!
2026-08-23 18:10:54 -03:00
Harvey Doan
9adeb3b673 fix(providers): clamp reasoning_effort to the declared vocabulary of models that expose one (#11274)
Validated on the combined 8-PR board: the declared-vocabulary clamp integrates with #11232's learned-set clamp (both fire in sequence; REASONING_EFFORT_ORDER import added), reasoning quartet 57/57 including the new opencode-go suite 7/7, 88/88 across the board's focused suites, typecheck:core + dashboard-typecheck clean, all static gates within baseline. Retargeted main→release/v3.8.50 and rebased onto the release tip (authorship preserved; the branch carried main history, so only the real commit was carried). The generic explicit-capability clamp closes the Console Go 400 [1210] loop for models with a declared effort vocabulary. Thank you @linhdmn — the live-probe table in the PR body is exactly the evidence standard we want.
2026-08-23 18:10:21 -03:00
116 changed files with 3145 additions and 898 deletions

View File

@@ -3,6 +3,7 @@ import { printHeading } from "../io.mjs";
import { withRuntime } from "../runtime.mjs";
import { t } from "../i18n.mjs";
import { apiFetch } from "../api.mjs";
import { mcpCallTool } from "../mcpClient.mjs";
import { emit } from "../output.mjs";
import { resolveComboModels, collectModel } from "./comboModels.mjs";
@@ -63,15 +64,7 @@ export function extendComboSuggest(combo) {
weights: opts.weights ? JSON.parse(opts.weights) : undefined,
top: opts.top,
};
const res = await apiFetch("/api/mcp/tools/call", {
method: "POST",
body: { name: "omniroute_best_combo_for_task", arguments: body },
});
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
const data = await res.json();
const data = await mcpCallTool("omniroute_best_combo_for_task", body);
const candidates = data.candidates ?? data;
const rows = (Array.isArray(candidates) ? candidates : []).map((c, i) => ({
rank: i + 1,

View File

@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import { apiFetch } from "../api.mjs";
import { mcpCallTool } from "../mcpClient.mjs";
import { emit } from "../output.mjs";
import { t } from "../i18n.mjs";
@@ -78,18 +79,17 @@ async function restComboStats(period) {
}
async function mcpCall(name, args, restFallback) {
const res = await apiFetch("/api/mcp/tools/call", {
method: "POST",
body: { name, arguments: args },
});
if (res.ok) return res.json();
// 404 = MCP tool surface not mounted on this build; 501 = not implemented.
// Anything else is a genuine error and we surface it.
if ((res.status === 404 || res.status === 501) && typeof restFallback === "function") {
return restFallback();
try {
return await mcpCallTool(name, args);
} catch (err) {
// Keep the REST fallback behavior for builds where the MCP surface
// is unreachable / not mounted. Anything else rethrows as an error.
const status = err?.status || err?.cause?.status;
if ((status === 404 || status === 501) && typeof restFallback === "function") {
return restFallback();
}
throw err;
}
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
async function confirm(q) {

View File

@@ -61,27 +61,12 @@ export function registerMcp(program) {
? JSON.parse(argsPositional)
: {};
if (opts.stream) {
await runMcpStream(tool, args, globalOpts);
return;
}
const exitCode = await runMcpCallCommand(tool, args, {
...opts,
stream: opts.stream,
}, globalOpts);
const extraHeaders = opts.scope?.length ? { "X-MCP-Scopes": opts.scope.join(",") } : {};
const res = await apiFetch("/api/mcp/tools/call", {
method: "POST",
body: { name: tool, arguments: args },
headers: extraHeaders,
});
if (res.status === 403) {
process.stderr.write("Scope denied\n");
process.exit(4);
}
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
const data = await res.json();
emit(data, globalOpts);
if (exitCode !== 0) process.exit(exitCode);
});
mcp
@@ -99,112 +84,132 @@ export function registerMcp(program) {
const data = await res.json();
emit(data.scopes ?? data, cmd.optsWithGlobals());
});
// 5.2 — mcp tools + mcp audit
const tools = mcp.command("tools").description(t("mcp.tools.description"));
tools
.command("list")
.description(t("mcp.tools.list.description"))
.option("--scope <s>", t("mcp.tools.list.scope"))
.action(async (opts, cmd) => {
const params = new URLSearchParams();
if (opts.scope) params.set("scope", opts.scope);
const res = await apiFetch(`/api/mcp/tools?${params}`);
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
const data = await res.json();
emit(data.tools ?? data, cmd.optsWithGlobals(), mcpToolSchema);
});
tools
.command("info <name>")
.description(t("mcp.tools.info.description"))
.action(async (name, opts, cmd) => {
const res = await apiFetch(`/api/mcp/tools?name=${encodeURIComponent(name)}`);
if (!res.ok) {
process.stderr.write(`Not found: ${name}\n`);
process.exit(1);
}
emit(await res.json(), cmd.optsWithGlobals());
});
tools
.command("schema <name>")
.description(t("mcp.tools.schema.description"))
.option("--io <kind>", t("mcp.tools.schema.io"), "input")
.action(async (name, opts, cmd) => {
const res = await apiFetch(`/api/mcp/tools?name=${encodeURIComponent(name)}&io=${opts.io}`);
if (!res.ok) {
process.stderr.write(`Not found: ${name}\n`);
process.exit(1);
}
const data = await res.json();
const globalOpts = cmd.optsWithGlobals();
if (globalOpts.output === "json") {
process.stdout.write(JSON.stringify(data.schema ?? data, null, 2) + "\n");
} else {
emit(data.schema ?? data, globalOpts);
}
});
const audit = mcp.command("audit").description(t("mcp.audit.description"));
audit
.command("tail")
.option("--follow", t("audit.tail.follow"))
.option("--limit <n>", t("audit.tail.limit"), parseInt, 100)
.action(async (opts, cmd) => {
const { runAuditTail } = await import("./audit.mjs");
await runAuditTail({ ...opts, source: "mcp" }, cmd);
});
audit
.command("stats")
.option("--period <p>", t("audit.stats.period"), "7d")
.action(async (opts, cmd) => {
const res = await apiFetch(`/api/mcp/audit/stats?period=${opts.period}`);
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
emit(await res.json(), cmd.optsWithGlobals());
});
}
async function runMcpStream(tool, args, globalOpts) {
/**
* Shared JSON-RPC 2.0 MCP client used by both stream and non-stream `mcp call`.
*
* Protocol:
* 1. POST /api/mcp/stream with initialize → get Mcp-Session-Id header
* 2. POST /api/mcp/stream with tools/call + Mcp-Session-Id header
*
* When `stream` is true, writes SSE data chunks to stdout as they arrive.
* When `stream` is false, returns the parsed JSON-RPC result.
*
* Returns the exit code (0 = success, non-zero = failure).
*/
async function mcpJsonRpcCall(tool, args, { stream = false, globalOpts = {} } = {}) {
const baseUrl = globalOpts.baseUrl ?? "http://localhost:20128";
const apiKey = globalOpts.apiKey ?? "";
const res = await fetch(`${baseUrl}/api/mcp/stream`, {
const streamUrl = `${baseUrl}/api/mcp/stream`;
const hdrs = {
"Content-Type": "application/json",
Accept: stream ? "text/event-stream" : "application/json",
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
};
// Step 1 — initialize
const initRes = await fetch(streamUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}),
},
body: JSON.stringify({ name: tool, arguments: args }),
headers: hdrs,
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "omniroute-cli", version: "1.0" },
},
}),
});
if (!res.ok) {
process.stderr.write(`HTTP ${res.status}\n`);
process.exit(1);
if (!initRes.ok) {
const text = await initRes.text().catch(() => "");
process.stderr.write(`MCP initialize failed: HTTP ${initRes.status}${text ? `${text}` : ""}\n`);
return 1;
}
const reader = res.body.getReader();
const sessionId = initRes.headers.get("mcp-session-id");
if (!sessionId) {
process.stderr.write("MCP initialize failed: no Mcp-Session-Id in response\n");
return 1;
}
// Step 2 — tools/call
const callHeaders = {
...hdrs,
"mcp-session-id": sessionId,
};
const callRes = await fetch(streamUrl, {
method: "POST",
headers: callHeaders,
body: JSON.stringify({
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: { name: tool, arguments: args },
}),
});
if (!callRes.ok) {
const text = await callRes.text().catch(() => "");
process.stderr.write(`MCP call failed: HTTP ${callRes.status}${text ? `${text}` : ""}\n`);
return 1;
}
if (stream) {
return readMcpSseStream(callRes.body);
}
// Non-stream: parse JSON-RPC response
const data = await callRes.json();
if (data.error) {
process.stderr.write(`MCP error: ${data.error.message || JSON.stringify(data.error)}\n`);
return 1;
}
// Print the result content
const content = data.result?.content;
if (content) {
for (const item of content) {
if (item.type === "text") {
process.stdout.write(item.text + "\n");
} else if (item.type === "resource") {
process.stdout.write(JSON.stringify(item.resource) + "\n");
} else {
process.stdout.write(JSON.stringify(item) + "\n");
}
}
} else {
process.stdout.write(JSON.stringify(data.result, null, 2) + "\n");
}
return 0;
}
async function readMcpSseStream(body) {
if (!body) return 1;
const reader = body.getReader();
const dec = new TextDecoder();
let buf = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop() ?? "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const raw = line.slice(6).trim();
if (raw && raw !== "[DONE]") process.stdout.write(raw + "\n");
}
}
const lines = buf.split("\n");
for (const line of lines) {
if (line.startsWith("data: ")) {
const raw = line.slice(6).trim();
if (raw && raw !== "[DONE]") process.stdout.write(raw + "\n");
}
}
return 0;
}
export async function runMcpCallCommand(tool, args, opts = {}, globalOpts = {}) {
return mcpJsonRpcCall(tool, args, { stream: opts.stream, globalOpts });
}
export async function runMcpStatusCommand(opts = {}) {
@@ -233,7 +238,8 @@ export async function runMcpStatusCommand(opts = {}) {
}
const transport = status.transport || "stdio";
console.log(status.running ? t("mcp.running", { transport }) : t("mcp.stopped"));
const online = status.online ?? status.running;
console.log(online ? t("mcp.running", { transport }) : t("mcp.stopped"));
if (status.toolsCount !== undefined) console.log(` Tools: ${status.toolsCount}`);
if (status.scopes?.length) {
console.log(" Scopes:");

View File

@@ -1,4 +1,5 @@
import { apiFetch } from "../api.mjs";
import { mcpCallTool } from "../mcpClient.mjs";
import { emit } from "../output.mjs";
import { t } from "../i18n.mjs";
@@ -8,15 +9,7 @@ function fmtTs(v) {
}
async function mcpCall(name, args) {
const res = await apiFetch("/api/mcp/tools/call", {
method: "POST",
body: { name, arguments: args },
});
if (!res.ok) {
process.stderr.write(`MCP error: ${res.status}\n`);
process.exit(1);
}
return res.json();
return mcpCallTool(name, args);
}
const proxySchema = [

View File

@@ -1,6 +1,7 @@
import { createInterface } from "node:readline";
import { Argument } from "commander";
import { apiFetch } from "../api.mjs";
import { mcpCallTool } from "../mcpClient.mjs";
import { emit } from "../output.mjs";
import { t } from "../i18n.mjs";
@@ -166,14 +167,7 @@ export function registerResilience(program) {
])
)
.action(async (name, opts, cmd) => {
const res = await apiFetch("/api/mcp/tools/call", {
method: "POST",
body: { name: "omniroute_set_resilience_profile", arguments: { profile: name } },
});
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
await mcpCallTool("omniroute_set_resilience_profile", { profile: name });
process.stdout.write(`Profile: ${name}\n`);
});

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

@@ -1,5 +1,6 @@
import { readFileSync } from "node:fs";
import { apiFetch } from "../api.mjs";
import { mcpCallTool } from "../mcpClient.mjs";
import { emit } from "../output.mjs";
import { t } from "../i18n.mjs";
@@ -106,14 +107,7 @@ export async function runSkillsInstall(opts, cmd) {
}
export async function runSkillsEnable(id, opts, cmd) {
const res = await apiFetch("/api/mcp/tools/call", {
method: "POST",
body: { name: "omniroute_skills_enable", arguments: { skillId: id, enabled: true } },
});
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
await mcpCallTool("omniroute_skills_enable", { skillId: id, enabled: true });
process.stdout.write(`Enabled: ${id}\n`);
}
@@ -122,14 +116,7 @@ export async function runSkillsDisable(id, opts, cmd) {
const ok = await confirm(`Disable ${id}?`);
if (!ok) return;
}
const res = await apiFetch("/api/mcp/tools/call", {
method: "POST",
body: { name: "omniroute_skills_enable", arguments: { skillId: id, enabled: false } },
});
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
await mcpCallTool("omniroute_skills_enable", { skillId: id, enabled: false });
process.stdout.write(`Disabled: ${id}\n`);
}
@@ -153,16 +140,11 @@ export async function runSkillsExecute(id, opts, cmd) {
: opts.inputFile
? JSON.parse(readFileSync(opts.inputFile, "utf8"))
: {};
const res = await apiFetch("/api/mcp/tools/call", {
method: "POST",
body: { name: "omniroute_skills_execute", arguments: { skillId: id, input } },
timeout: opts.timeout ?? 30000,
});
if (!res.ok) {
process.stderr.write(`Error: ${res.status}\n`);
process.exit(1);
}
const data = await res.json();
const data = await mcpCallTool(
"omniroute_skills_execute",
{ skillId: id, input },
{ timeout: opts.timeout ?? 30000 },
);
emit(data, globalOpts);
}

127
bin/cli/mcpClient.mjs Normal file
View File

@@ -0,0 +1,127 @@
/**
* Shared MCP JSON-RPC client for CLI commands.
*
* The server exposes MCP through /api/mcp/stream (Streamable HTTP transport).
* Calling a tool requires:
* 1. POST initialize → get Mcp-Session-Id response header
* 2. POST tools/call with that session header
*
* Older CLI paths POSTed { name, arguments } to /api/mcp/tools/call, which is
* not a registered route, so every MCP-backed command was broken.
*
* These functions route through apiFetch so CLI auth, remote contexts and
* timeouts are handled the same way as every other management API call.
*/
import { apiFetch } from "./api.mjs";
function mcpError(message, status) {
const err = new Error(message);
if (status) err.status = status;
return err;
}
async function callMcpEndpoint(payload, { timeout, stream }) {
const res = await apiFetch("/api/mcp/stream", {
method: "POST",
body: payload,
timeout,
acceptNotOk: true,
headers: stream ? { Accept: "text/event-stream" } : {},
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw mcpError(
`${payload.method} ${payload.id}: HTTP ${res.status}${text ? `${text}` : ""}`,
res.status,
);
}
return res;
}
/**
* Call an MCP tool over /api/mcp/stream.
*
* Non-stream: returns the JSON-RPC result payload.
* Stream: writes SSE `data:` chunks to stdout and returns null on success.
*/
export async function mcpCallTool(name, args = {}, options = {}) {
const { timeout, scope } = options;
const scopeHeader = scope?.length ? { "X-MCP-Scopes": scope.join(",") } : {};
const initRes = await callMcpEndpoint(
{
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "omniroute-cli", version: "1.0" },
},
},
{ timeout, stream: options.stream },
);
const sessionId = initRes.headers.get("mcp-session-id");
if (!sessionId) {
throw mcpError("MCP initialize failed: no Mcp-Session-Id in response", 500);
}
const callRes = await callMcpEndpoint(
{
jsonrpc: "2.0",
id: 2,
method: "tools/call",
params: { name, arguments: args },
},
{ timeout, stream: options.stream },
);
if (options.stream) {
return consumeSse(callRes.body, options.onChunk);
}
const data = await callRes.json();
if (data.error) {
const err = mcpError(`MCP error: ${data.error.message || JSON.stringify(data.error)}`);
err.code = data.error.code;
throw err;
}
if (data.result?.isError) {
const msg = data.result?.content?.[0]?.text || "unknown tool error";
throw mcpError(`MCP error: ${msg}`, 500);
}
return data.result;
}
async function consumeSse(body, onChunk) {
if (!body) throw mcpError("MCP stream returned no body", 500);
const reader = body.getReader();
const decoder = new TextDecoder();
let buf = "";
const flushLines = () => {
let idx;
while ((idx = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, idx);
buf = buf.slice(idx + 1);
if (line.startsWith("data: ")) {
const raw = line.slice(6).trim();
if (raw && raw !== "[DONE]") (onChunk ?? writeStdout)(raw);
}
}
};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
flushLines();
}
buf += decoder.decode();
flushLines();
return null;
}
function writeStdout(raw) {
process.stdout.write(raw + "\n");
}

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):** replace the hard Home → onboarding redirect with a dismissable first-run readiness card so returning users can stay on Home while new users still get a clear 4-step path ([#11282](https://github.com/diegosouzapw/OmniRoute/pull/11282))

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

@@ -1 +0,0 @@
- fix(sse): mark gemini-3.5-flash as thinking-capable so reasoning_effort is no longer rejected with a spurious 400 (#10286)

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 @@
- **fix(ollama):** Ollama Local models are no longer flattened to `chat` at sync time — the synced store persists every advertised capability and chat filtering moves to read time, so `/v1/embeddings` and `/v1/images/generations` stop rejecting models the daemon reports as capable ([#11271](https://github.com/diegosouzapw/OmniRoute/pull/11271)) — thanks @yourspraveen

View File

@@ -6,7 +6,7 @@ lastUpdated: 2026-07-31
# OmniRoute Antigravity (Google One AI) Onboarding Guide
> **What you get**: Access to Gemini 3.1 Pro, Gemini 3.5 Flash, Claude Sonnet 4.6, and other models through your Google One AI Pro subscription — routed through OmniRoute as a unified gateway.
> **What you get**: Access to Gemini 3.1 Pro, Gemini 3.7 Flash, Claude Sonnet 4.6, and other models through your Google One AI Pro subscription — routed through OmniRoute as a unified gateway.
**Official references**:
@@ -45,7 +45,7 @@ Both providers share the **same Google backend** — identical OAuth client, tok
**Why the model catalog differs**: Google's CLI is "optimized for speed and low overhead" and "co-optimized with Gemini models" (per Google's official blog). The Web/IDE product is "optimized for comprehensiveness." The CLI uses `:fetchAvailableModels` to dynamically discover models, while the IDE uses a static curated list.
**In practice**: Use `agy/` prefix for Gemini models (e.g. `agy/gemini-3.5-flash-high`). Use `antigravity/` for the static curated list. Both hit the same Google backend, but expose different model naming. The quota is shared — using either provider counts against the same Google account's limits.
**In practice**: Use `agy/` prefix for Gemini models (e.g. `agy/gemini-3.7-flash-high`). Use `antigravity/` for the static curated list. Both hit the same Google backend, but expose different model naming. The quota is shared — using either provider counts against the same Google account's limits.
---

View File

@@ -113,6 +113,7 @@ const AGY_RETIRED_MODEL_IDS = new Set([
"gemini-3.6-flash-medium",
"gemini-3.6-flash-low",
"gemini-3-flash-agent",
"gemini-3.5-flash",
"gemini-3.5-flash-extra-low",
"gemini-3.5-flash-low",
"gemini-3.5-flash-high",

View File

@@ -179,6 +179,7 @@ const ANTIGRAVITY_RETIRED_MODEL_IDS = new Set([
"gemini-3.6-flash-medium",
"gemini-3.6-flash-low",
"gemini-3-flash-agent",
"gemini-3.5-flash",
"gemini-3.5-flash-extra-low",
"gemini-3.5-flash-low",
"gemini-3.5-flash-high",

View File

@@ -8,7 +8,6 @@
"gemma-4-26b-it": { "rpm": 16000, "rpd": 14400, "tpm": 16000 },
"gemma-4-31b-it": { "rpm": 16000, "rpd": 14400, "tpm": 16000 },
"gemini-embedding-exp-03-07": { "rpm": 100, "rpd": 1000, "tpm": 30000 },
"gemini-3.5-flash": { "rpm": 5, "rpd": 20, "tpm": 250000 },
"gemini-3.1-flash-lite": { "rpm": 15, "rpd": 500, "tpm": 250000 },
"gemini-3.1-pro": { "rpm": 0, "rpd": 0, "tpm": 0 },
"gemini-2.5-flash-lite": { "rpm": 10, "rpd": 20, "tpm": 250000 },

View File

@@ -186,6 +186,9 @@ export function getModelTargetFormat(aliasOrId: string, modelId: string): string
// executor's /codex/i routing, 9router#102). Scoped to the openai alias so other
// providers shipping *-pro ids keep their own endpoint semantics.
if (alias === "openai" && /-pro$/i.test(bareModelId)) return "openai-responses";
// ponytail: Claude models on Vertex use rawPredict with Anthropic Messages format,
// not the Gemini generateContent format. Mirrors executor isClaudeModel() check.
if ((alias === "vertex" || alias === "vp") && /^claude-/i.test(bareModelId)) return "claude";
// Model-level targetFormat is provider-scoped: a catalog entry declares how THIS
// provider's endpoint serves the model — do NOT import another provider's tag.
// #9994 scoped this for providers WITH a catalog; #10072 extends it to catalogless

View File

@@ -228,14 +228,14 @@ export const cursorProvider: RegistryEntry = {
{ id: "gpt-5.1-low", name: "GPT-5.1 Low" },
{ id: "gpt-5.1", name: "GPT-5.1" },
{ id: "gpt-5.1-high", name: "GPT-5.1 High" },
{ id: "gemini-3.5-flash", name: "Gemini 3.5 Flash" },
{ id: "claude-4-sonnet", name: "Sonnet 4" },
{ id: "claude-4-sonnet-thinking", name: "Sonnet 4 Thinking" },
{ id: "gpt-5-mini", name: "GPT-5 Mini" },
{ id: "kimi-k3-low", name: "Kimi K3 Low" },
{ id: "kimi-k3-max", name: "Kimi K3" },
{ id: "glm-5.2-high", name: "GLM 5.2" },
{ id: "glm-5.2-max", name: "GLM 5.2 Max" }, ],
{ id: "glm-5.2-max", name: "GLM 5.2 Max" },
],
};
/**

View File

@@ -219,5 +219,18 @@ export const opencode_goProvider: RegistryEntry = {
supportedThinkingEfforts: ["none", "low", "high", "max"],
targetFormat: "openai-responses",
},
// Console Go free GLM-tier model (live-verified 2026-08-23): the upstream
// rejects every reasoning_effort outside {low, high, max} whenever tools
// are present — "[1210] This model always engages in thinking and cannot
// be disabled; please use low, high, or max" — which broke clients that
// default to reasoning_effort:"medium" (Hermes). Declaring the exact
// vocabulary lets sanitizeReasoningEffortForProvider clamp off-vocabulary
// requests to the nearest accepted tier instead of burning a 400.
{
id: "ox-alpha-free",
name: "ox-alpha (free)",
supportsReasoning: true,
supportedThinkingEfforts: ["low", "high", "max"],
},
],
};

View File

@@ -27,8 +27,17 @@ export const vertexProvider: RegistryEntry = {
{ id: "DeepSeek-V4-Pro", name: "DeepSeek V4 Pro (Vertex Partner)" },
{ id: "Qwen3.6-35B-A3B", name: "Qwen3.6 35B A3B (Vertex Partner)" },
{ id: "GLM-5.1-FP8", name: "GLM-5.1 (Vertex Partner)" },
{ id: "claude-opus-4-7", name: "Claude Opus 4.7 (Vertex)" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Vertex)" },
{ id: "claude-fable-5", name: "Claude Fable 5 (Vertex)", targetFormat: "claude" },
{ id: "claude-opus-5", name: "Claude Opus 5 (Vertex)", targetFormat: "claude" },
{ id: "claude-sonnet-5", name: "Claude Sonnet 5 (Vertex)", targetFormat: "claude" },
{ id: "claude-opus-4-8", name: "Claude Opus 4.8 (Vertex)", targetFormat: "claude" },
{ id: "claude-opus-4-7", name: "Claude Opus 4.7 (Vertex)", targetFormat: "claude" },
{ id: "claude-opus-4-6", name: "Claude Opus 4.6 (Vertex)", targetFormat: "claude" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6 (Vertex)", targetFormat: "claude" },
{ id: "claude-sonnet-4-5-v2", name: "Claude Sonnet 4.5 v2 (Vertex)", targetFormat: "claude" },
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5 (Vertex)", targetFormat: "claude" },
{ id: "claude-opus-4-5", name: "Claude Opus 4.5 (Vertex)", targetFormat: "claude" },
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5 (Vertex)", targetFormat: "claude" },
],
passthroughModels: true,
};

View File

@@ -13,10 +13,17 @@ export const vertex_partnerProvider: RegistryEntry = {
{ id: "DeepSeek-V4-Pro", name: "DeepSeek V4 Pro" },
{ id: "Qwen3.6-35B-A3B", name: "Qwen 3.6 35B A3B" },
{ id: "GLM-5.1-FP8", name: "GLM 5.1" },
// Sweep 2026-06-19: + Claude Opus on Vertex (Anthropic partner models).
{ id: "claude-opus-4-8", name: "Claude Opus 4.8" },
{ id: "claude-opus-4-7", name: "Claude Opus 4.7" },
{ id: "claude-opus-4-6", name: "Claude Opus 4.6" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" },
{ id: "claude-fable-5", name: "Claude Fable 5", targetFormat: "claude" },
{ id: "claude-opus-5", name: "Claude Opus 5", targetFormat: "claude" },
{ id: "claude-sonnet-5", name: "Claude Sonnet 5", targetFormat: "claude" },
{ id: "claude-opus-4-8", name: "Claude Opus 4.8", targetFormat: "claude" },
{ id: "claude-opus-4-7", name: "Claude Opus 4.7", targetFormat: "claude" },
{ id: "claude-opus-4-6", name: "Claude Opus 4.6", targetFormat: "claude" },
{ id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", targetFormat: "claude" },
{ id: "claude-sonnet-4-5-v2", name: "Claude Sonnet 4.5 v2", targetFormat: "claude" },
{ id: "claude-sonnet-4-5", name: "Claude Sonnet 4.5", targetFormat: "claude" },
{ id: "claude-sonnet-4", name: "Claude Sonnet 4", targetFormat: "claude" },
{ id: "claude-opus-4-5", name: "Claude Opus 4.5", targetFormat: "claude" },
{ id: "claude-haiku-4-5", name: "Claude Haiku 4.5", targetFormat: "claude" },
],
};

View File

@@ -11,6 +11,7 @@ import {
import {
getLearnedReasoningEffort,
clampToLearned,
REASONING_EFFORT_ORDER,
} from "../../services/learnedReasoningEffortCaps.ts";
/**
@@ -357,6 +358,43 @@ export function sanitizeReasoningEffortForProvider(
}
}
// ── explicit per-model capability clamp ──────────────────────────────────
// When the registry declares supportedThinkingEfforts for this exact model
// and the requested effort falls outside that vocabulary, remap to the
// nearest declared tier: the smallest ranked value ≥ the request, else the
// highest declared (a request above the ceiling lands on the ceiling).
// Live case: opencode-go/ox-alpha-free (Console Go) only accepts
// {low, high, max} — a client's reasoning_effort:"medium" reached the
// upstream verbatim and 400'd every turn ("[1210] This model always engages
// in thinking and cannot be disabled; please use low, high, or max"). The
// learned-caps path can't help here (it only clamps down from xhigh/max,
// and this error text isn't a parseable enum), so the declaration is the
// only source of truth. Models without an explicit declaration keep
// #8057's trust-the-upstream pass-through.
const providerModelIdForClamp = modelStr.startsWith(`${provider}/`)
? modelStr.slice(provider.length + 1)
: modelStr;
const declaredEfforts = getProviderModels(provider).find(
(entry) => entry.id === providerModelIdForClamp || entry.aliases?.includes(providerModelIdForClamp)
)?.supportedThinkingEfforts;
const declaredRanked = (
Array.isArray(declaredEfforts) ? declaredEfforts : []
)
.map((tier) => ({ tier, rank: REASONING_EFFORT_ORDER.indexOf(tier) }))
.filter((x) => x.rank >= 0)
.sort((a, b) => a.rank - b.rank);
if (declaredRanked.length > 0 && !declaredEfforts!.includes(effortStr)) {
const requestedRank = REASONING_EFFORT_ORDER.indexOf(effortStr);
const nearest =
declaredRanked.find((x) => x.rank >= requestedRank) ??
declaredRanked[declaredRanked.length - 1];
log?.info?.(
"REASONING_SANITIZE",
`${provider}/${modelStr}: mapped reasoning_effort ${effortStr}${nearest.tier} (model accepts ${declaredEfforts!.join("/")})`
);
return writeEffortValue(b, nearest.tier, c);
}
const supportsXHigh = supportsXHighEffort(provider, modelStr);
const supportsMax = supportsMaxEffortForProvider(provider, modelStr);

View File

@@ -1,3 +1,5 @@
import { randomBytes } from "node:crypto";
import {
BaseExecutor,
ExecuteInput,
@@ -13,6 +15,11 @@ import {
import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts";
import { stripUnsupportedParams } from "../translator/paramSupport.ts";
/** Correlation-id fallback for runtimes without crypto.randomUUID — still CSPRNG-backed. */
function randomIdFallback(): string {
return `${Date.now()}-${randomBytes(9).toString("hex")}`;
}
/**
* What a Copilot credential refresh resolves to.
*
@@ -329,7 +336,7 @@ export class GithubExecutor extends BaseExecutor {
...getGitHubCopilotChatHeaders(stream ? "text/event-stream" : "application/json", initiator),
Authorization: `Bearer ${token}`,
"x-request-id":
crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`,
crypto.randomUUID?.() || randomIdFallback(),
};
// Per-call / per-conversation / per-turn correlation ids the @github/copilot
@@ -338,7 +345,7 @@ export class GithubExecutor extends BaseExecutor {
// fresh uuids. A Copilot-aware client may pin the session/task ids across a
// conversation via its own headers — honor those when present, else mint.
const genId = () =>
crypto.randomUUID?.() || `${Date.now()}-${Math.random().toString(36).slice(2)}`;
crypto.randomUUID?.() || randomIdFallback();
headers["x-interaction-id"] = this.readClientHeader(clientHeaders, "x-interaction-id") || genId();
headers["x-client-session-id"] =
this.readClientHeader(clientHeaders, "x-client-session-id") || genId();

View File

@@ -20,6 +20,7 @@ import { checkSemanticCache } from "./chatCore/semanticCache.ts";
import { checkLifecycle, resolveLifecycle } from "./chatCore/modelLifecyclePolicy.ts";
import {
shouldDefaultAllowClassifier,
detectClassifierFormat,
buildDefaultAllowClaudeMessage,
} from "./chatCore/claudeClassifierCompat.ts";
import { applyClientUsageBuffer } from "./chatCore/clientUsageBuffer.ts";
@@ -778,11 +779,12 @@ export async function handleChatCore({
classifierSettings.claudeClassifierCompat as string | undefined
)
) {
const classifierFormat = detectClassifierFormat(body as Record<string, unknown>);
log?.warn?.(
"CHAT",
`classifier compat=${classifierSettings.claudeClassifierCompat} | short-circuit default-allow`
`classifier compat=${classifierSettings.claudeClassifierCompat} format=${classifierFormat} | short-circuit default-allow`
);
return buildDefaultAllowClaudeMessage(requestedModel);
return buildDefaultAllowClaudeMessage(requestedModel, classifierFormat);
}
}

View File

@@ -24,14 +24,19 @@ const SECURITY_MONITOR_MARKER = "You are a security monitor for autonomous AI co
export type ClaudeClassifierCompatMode = "off" | "auto" | "always";
/** The two synthetic-response shapes Claude Code's classifier can expect. */
export type ClaudeClassifierFormat = "block" | "severity";
function extractSystemTexts(body: Record<string, unknown> | null | undefined): string[] {
const system = body?.system;
if (typeof system === "string") return [system];
if (Array.isArray(system)) {
return system
.map((part) => (part && typeof (part as { text?: unknown }).text === "string"
? ((part as { text: string }).text)
: ""))
.map((part) =>
part && typeof (part as { text?: unknown }).text === "string"
? (part as { text: string }).text
: ""
)
.filter(Boolean);
}
return [];
@@ -60,6 +65,29 @@ export function shouldDefaultAllowClassifier(
return extractSystemTexts(body).some((text) => text.includes(SECURITY_MONITOR_MARKER));
}
/**
* Detect which synthetic-response shape the classifier request expects.
*
* Newer Claude Code builds send a "severity classifier" variant of the same internal
* request: it carries `stop_sequences: [..., "</severity>", ...]` and parses a
* `<severity>N</severity>` reply instead of `<block>no</block>`/`<block>yes</block>`.
* Feeding it the legacy `<block>no</block>` shape is unparseable, so it retries both
* stages and then fails closed — the same "blocking it for safety" failure this compat
* shim exists to avoid. Only `stop_sequences` distinguishes the two shapes; callers
* should only consult this after `shouldDefaultAllowClassifier` has already confirmed
* the request is the classifier (via the system-prompt marker), so an unrelated app
* that merely happens to use `</severity>` as a stop token is never affected (#8189).
*/
export function detectClassifierFormat(
body: Record<string, unknown> | null | undefined
): ClaudeClassifierFormat {
const stopSequences = body?.stop_sequences;
if (Array.isArray(stopSequences) && stopSequences.includes("</severity>")) {
return "severity";
}
return "block";
}
/**
* Build the synthetic Claude `message` ALLOW response. Always returns a plain JSON
* body (matching the upstream reference implementation) — Claude Code's classifier
@@ -67,7 +95,10 @@ export function shouldDefaultAllowClassifier(
* satisfies both streaming and non-streaming callers without needing to plumb a
* synthetic SSE encoding through the streaming/sseToJson/non-streaming handlers.
*/
export function buildDefaultAllowClaudeMessage(model?: string | null): {
export function buildDefaultAllowClaudeMessage(
model?: string | null,
format: ClaudeClassifierFormat = "block"
): {
success: true;
response: Response;
} {
@@ -76,7 +107,12 @@ export function buildDefaultAllowClaudeMessage(model?: string | null): {
type: "message",
role: "assistant",
model: model || "claude-3-5-sonnet-20241022",
content: [{ type: "text", text: "<block>no</block>" }],
content: [
{
type: "text",
text: format === "severity" ? "<severity>0</severity>" : "<block>no</block>",
},
],
stop_reason: "end_turn",
stop_sequence: null,
usage: { input_tokens: 1, output_tokens: 1 },

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

@@ -28,6 +28,7 @@ import type {
ResolvedComboTarget,
} from "./types.ts";
import { extractSessionAffinityKey } from "@/sse/services/auth";
import { filterChatSelectableModels } from "../modelEndpointPolicy.ts";
import { DEFAULT_INTENT_CONFIG, type IntentClassifierConfig } from "../intentClassifier.ts";
import { getTaskFitness } from "../autoCombo/taskFitness.ts";
import {
@@ -470,10 +471,13 @@ export async function expandAutoComboCandidatePool(
// catalog only when the user has none. This keeps catalog-only models
// (e.g. openrouter/auto) out of pure-auto pools when the operator only
// synced a subset (e.g. OpenRouter with importFreeModelsOnly).
const [syncedModels, customModels] = await Promise.all([
// #11088 (option 1): the synced store now persists non-chat models too —
// chat combo pools must keep filtering them out at read time.
const [syncedModelsRaw, customModels] = await Promise.all([
getSyncedAvailableModels(providerId),
getCustomModels(providerId),
]);
const syncedModels = filterChatSelectableModels(providerId, syncedModelsRaw);
const hiddenModels = hiddenModelsMap.get(providerId);
const userVisibleIds = new Set<string>();
for (const m of syncedModels) if (m.id && !hiddenModels?.has(m.id)) userVisibleIds.add(m.id);

View File

@@ -78,7 +78,7 @@ const FALLBACK_MODEL_SEEDS: FallbackModelSeed[] = [
/** Presets exposed by the web client's model picker (id → text/multimodal model). */
export const CONOL_FALLBACK_MODEL_PRESETS: ConolModelPreset[] = [
{ id: "flash", text: "deepseek/deepseek-v4-flash", multimodal: "google/gemini-3.5-flash" },
{ id: "flash", text: "deepseek/deepseek-v4-flash", multimodal: "google/gemini-3.7-flash" },
{ id: "moderate", text: "deepseek/deepseek-v4-pro", multimodal: "claude-sonnet-5" },
{ id: "pro", text: "z-ai/glm-5.2", multimodal: "moonshotai/kimi-k3" },
{ id: "ultra", text: "claude-fable-5", multimodal: "claude-fable-5" },

View File

@@ -6,7 +6,7 @@
*/
export interface PromptQlModel {
/** Client-facing id (model_reference slug, e.g. gemini-3.5-flash). */
/** Client-facing id (model_reference slug, e.g. gemini-3.7-flash). */
id: string;
/** Friendly picker label. */
name: string;

View File

@@ -0,0 +1,97 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useTranslations } from "next-intl";
const DISMISS_STORAGE_KEY = "omniroute-first-run-readiness-dismissed";
type FirstRunReadinessCardProps = {
setupComplete: boolean;
};
/**
* Soft entry path for first-run users. Replaces the hard redirect to
* /dashboard/onboarding so returning users can dismiss and stay on Home.
*/
export default function FirstRunReadinessCard({ setupComplete }: FirstRunReadinessCardProps) {
const t = useTranslations("home");
const [visible, setVisible] = useState(false);
useEffect(() => {
if (setupComplete) {
setVisible(false);
return;
}
try {
setVisible(!localStorage.getItem(DISMISS_STORAGE_KEY));
} catch {
setVisible(true);
}
}, [setupComplete]);
if (!visible || setupComplete) return null;
const dismiss = () => {
try {
localStorage.setItem(DISMISS_STORAGE_KEY, "true");
} catch {
// ignore storage failures; still hide for this session
}
setVisible(false);
};
const steps = [
t("readinessStep1"),
t("readinessStep2"),
t("readinessStep3"),
t("readinessStep4"),
];
return (
<div
role="region"
aria-label={t("readinessTitle")}
className="mb-4 rounded-xl border border-blue-200 dark:border-blue-500/30 bg-blue-50 dark:bg-blue-500/10 px-5 py-4"
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<p className="text-xs font-medium uppercase tracking-wide text-blue-700/80 dark:text-blue-300/80">
{t("readinessEyebrow")}
</p>
<h2 className="mt-1 text-lg font-semibold text-blue-950 dark:text-blue-100">
{t("readinessTitle")}
</h2>
<p className="mt-1 text-sm text-blue-900/80 dark:text-blue-200/80">
{t("readinessSubtitle")}
</p>
<ol className="mt-3 space-y-1.5 text-sm text-blue-900 dark:text-blue-100">
{steps.map((label, index) => (
<li key={label} className="flex items-center gap-2">
<span className="inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-blue-200/80 dark:bg-blue-400/20 text-xs font-semibold text-blue-800 dark:text-blue-200">
{index + 1}
</span>
<span>{label}</span>
</li>
))}
</ol>
<div className="mt-4 flex flex-wrap items-center gap-3">
<Link
href="/dashboard/onboarding"
className="inline-flex items-center rounded-lg bg-blue-600 px-3.5 py-2 text-sm font-medium text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-400"
>
{t("readinessContinue")}
</Link>
<button
type="button"
onClick={dismiss}
className="text-sm font-medium text-blue-800/80 hover:text-blue-950 dark:text-blue-200/80 dark:hover:text-blue-100"
>
{t("readinessDismiss")}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -2,7 +2,6 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { Card, Button, ModelSelectModal } from "@/shared/components";
import Image from "next/image";
import { useTranslations } from "next-intl";
import { copyToClipboard } from "@/shared/utils/clipboard";
import { buildOpenCodeConfigDocument } from "@/shared/services/opencodeConfig";
@@ -643,38 +642,32 @@ export default function DefaultToolCard({
};
const renderIcon = () => {
// Tool SVGs are non-square (e.g. opencode is 234×42, cursor is 467×532).
// next/image's dev check warns whenever the rendered aspect-ratio size
// differs from the square width/height attributes, so these render as a
// plain <img> capped at 32px on both axes — true ratio, no dev noise.
const renderImg = (src: string) => (
// eslint-disable-next-line @next/next/no-img-element -- local static SVG asset
<img
src={src}
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
style={{ width: "auto", height: "auto", maxWidth: 32, maxHeight: 32 }}
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = "none";
}}
/>
);
if (tool.image) {
return (
<Image
src={tool.image}
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = "none";
}}
/>
);
return renderImg(tool.image);
}
if (tool.imageLight || tool.imageDark) {
const themedSrc = isDark
? tool.imageDark || tool.imageLight
: tool.imageLight || tool.imageDark;
return (
<Image
src={themedSrc}
alt={tool.name}
width={32}
height={32}
className="size-8 object-contain rounded-lg"
sizes="32px"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = "none";
}}
/>
);
return renderImg(themedSrc);
}
if (tool.icon) {
return (

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

@@ -1,4 +1,3 @@
import { redirect } from "next/navigation";
import { getMachineId } from "@/shared/utils/machine";
import { getSettings } from "@/lib/localDb";
import HomePageClient from "../dashboard/HomePageClient";
@@ -7,19 +6,18 @@ import KimiSponsorBanner from "../dashboard/KimiSponsorBanner";
import CheaperInferenceSponsorBanner from "../dashboard/CheaperInferenceSponsorBanner";
import VscodeCopilotBanner from "../dashboard/VscodeCopilotBanner";
import NewsBanner from "../dashboard/NewsBanner";
import FirstRunReadinessCard from "../dashboard/FirstRunReadinessCard";
export const dynamic = "force-dynamic";
export default async function HomePage() {
const settings = await getSettings();
if (!settings.setupComplete) {
redirect("/dashboard/onboarding");
}
const machineId = await getMachineId();
const isBootstrapped = process.env.OMNIROUTE_BOOTSTRAPPED === "true";
return (
<>
{isBootstrapped && <BootstrapBanner />}
<FirstRunReadinessCard setupComplete={Boolean(settings.setupComplete)} />
<KimiSponsorBanner />
<CheaperInferenceSponsorBanner />
<VscodeCopilotBanner />

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

@@ -1,5 +1,11 @@
import { isSelfHostedChatProvider } from "@/shared/constants/providers";
import { getStaticModelsForProvider, type LocalCatalogModel } from "@/lib/providers/staticModels";
import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
import { getProviderValidationGuard } from "@/shared/network/outboundUrlGuardPolicy";
import {
buildOllamaShowUrl,
enrichOllamaModelsWithCapabilities,
} from "@/lib/providerModels/ollamaCapabilities";
export type JsonRecord = Record<string, unknown>;
@@ -102,3 +108,35 @@ export function buildNamedOpenAiStyleHeaders(
return headers;
}
// #11087 — Ollama's OpenAI-compatible /v1/models response carries no capability
// data, so every local model looked like a chat model and image/embedding
// requests were routed to text-only models. Probe /api/show per model (bounded
// concurrency, failures degrade to the unenriched entry) to recover the
// advertised capabilities. Lives here rather than inline in route.ts to keep the
// route file under its frozen file-size cap.
export async function enrichOllamaLocalModels(
models: unknown[],
baseUrl: string,
proxy: unknown,
token: string | null | undefined
): Promise<JsonRecord[]> {
const showUrl = buildOllamaShowUrl(baseUrl);
return enrichOllamaModelsWithCapabilities(models, async (modelId) => {
try {
const showResponse = await safeOutboundFetch(showUrl, {
...SAFE_OUTBOUND_FETCH_PRESETS.modelsProbe,
// Same guard tier as the discovery probe above: local-first, so LAN
// Ollama hosts are reachable while the outbound guard stays enforced.
guard: getProviderValidationGuard(),
proxyConfig: proxy,
method: "POST",
headers: buildOptionalBearerHeaders(token),
body: JSON.stringify({ model: modelId, verbose: false }),
});
return showResponse.ok ? await showResponse.json() : null;
} catch {
return null;
}
});
}

View File

@@ -85,10 +85,7 @@ import {
} from "@/lib/providerModels/modelDiscovery";
import { buildProviderModelsUrl, getDiscoveryClientVersionOptions } from "./discoveryClientVersion";
import { getAdobeModels } from "./adobeFireflyDiscovery";
import {
parseGeminiModelsList,
type GeminiDiscoveryModel,
} from "@/lib/providerModels/geminiModelsParser";
import { parseGeminiModelsList } from "@/lib/providerModels/geminiModelsParser";
import { getSyncedAvailableModels, getCustomModels } from "@/lib/db/models";
import { isConnectionUnavailableToAuxiliaryActivity } from "@/lib/exclusiveLeaseIsolation";
import { fetchCursorAgentModels } from "@/lib/providerModels/cursorAgent";
@@ -108,6 +105,7 @@ import {
mergeSpecialtyCatalogIntoLiveModels,
buildOptionalBearerHeaders,
buildNamedOpenAiStyleHeaders,
enrichOllamaLocalModels,
} from "./discovery/helpers";
import {
fetchAntigravityDiscoveryModelsCached,
@@ -794,6 +792,8 @@ export async function GET(
models = isNamedOpenAIStyleProvider(provider)
? normalizeOpenAiLikeModelsResponse(data, provider)
: data.data || data.models || [];
if (provider === "ollama-local")
models = await enrichOllamaLocalModels(models, baseUrl, proxy, token);
break; // Success!
}
@@ -1857,7 +1857,7 @@ export async function GET(
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (bearerToken) headers["Authorization"] = `Bearer ${bearerToken}`;
const allModels: GeminiDiscoveryModel[] = [];
const allModels: any[] = [];
let pageUrl = queryKey ? `${baseUrl}&key=${encodeURIComponent(queryKey)}` : baseUrl;
let pageCount = 0;
const MAX_PAGES = 20;
@@ -1903,6 +1903,60 @@ export async function GET(
throw error;
}
// ponytail: Anthropic partner models via Model Garden publisher endpoint (Bearer only)
if (bearerToken) {
const psd = asRecord(connection.providerSpecificData);
const region =
(typeof psd.region === "string" && psd.region.trim()) || "us-central1";
// Extract project_id from SA JSON for project-scoped listing (mirrors executor URL pattern).
// Falls back to global publisher endpoint if no project available.
let anthropicModelsUrl: string;
let projectId: string | null = null;
if (credential) {
try {
const sa = JSON.parse(credential);
if (sa?.project_id) projectId = sa.project_id;
} catch { /* not SA JSON, skip */ }
}
if (projectId) {
anthropicModelsUrl = `https://aiplatform.googleapis.com/v1/projects/${projectId}/locations/${region}/publishers/anthropic/models`;
} else {
anthropicModelsUrl = `https://aiplatform.googleapis.com/v1/publishers/anthropic/models`;
}
try {
const anthropicResponse = await safeOutboundFetch(anthropicModelsUrl, {
...SAFE_OUTBOUND_FETCH_PRESETS.modelsDiscovery,
guard: getProviderOutboundGuard(),
proxyConfig: proxy,
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${bearerToken}`,
},
});
if (anthropicResponse.ok) {
const anthropicData = await anthropicResponse.json();
const { parseVertexAnthropicModels } = await import(
"@/lib/providerModels/vertexAnthropicModelsParser"
);
allModels.push(...parseVertexAnthropicModels(anthropicData));
} else {
console.log("[models] Vertex Anthropic partner discovery failed", {
provider,
region,
status: anthropicResponse.status,
});
}
} catch (err) {
console.log("[models] Vertex Anthropic partner discovery error", {
provider,
error: err instanceof Error ? err.message : String(err),
});
}
}
if (allModels.length > 0) {
return buildApiDiscoveryResponse(allModels);
}

View File

@@ -23,6 +23,10 @@ import { getComboByName } from "@/lib/db/combos";
import { getAllCustomModels } from "@/lib/db/models";
import { resolveProxyForConnection } from "@/lib/db/settings";
import { resolveImageRouteModel } from "@/lib/images/imageRouteModel";
import {
resolveLocalSyncedEndpointRoute,
type LocalSyncedEndpointRoute,
} from "@/lib/providerModels/syncedEndpointRouting";
import { runWithProxyContext } from "@omniroute/open-sse/utils/proxyFetch.ts";
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
import { calculateModalCost } from "@/lib/usage/costCalculator";
@@ -145,6 +149,16 @@ async function postHandler(request, context) {
// Parse model to get provider
let { provider, model: requestedModel } = parseImageModel(body.model);
let isCustomModel = false;
let syncedEndpointRoute: LocalSyncedEndpointRoute | null = null;
if (!provider) {
syncedEndpointRoute = await resolveLocalSyncedEndpointRoute(body.model, "images");
if (syncedEndpointRoute) {
provider = syncedEndpointRoute.provider;
body.model = `${syncedEndpointRoute.provider}/${syncedEndpointRoute.model}`;
isCustomModel = true;
}
}
// If not in built-in registry, check custom models tagged for images
if (!provider) {
@@ -231,9 +245,8 @@ async function postHandler(request, context) {
credentials = await getProviderCredentialsWithQuotaPreflight(
provider,
null,
null,
requestedModel
);
syncedEndpointRoute?.connectionIds ?? null,
requestedModel );
if (!credentials) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,

View File

@@ -1266,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",
@@ -1868,7 +1869,16 @@
"directDownloadHint": "Or download the respective installer format directly:",
"releaseNotes": "Release Notes",
"readMore": "Read More",
"noAuthLabel": "No Auth"
"noAuthLabel": "No Auth",
"readinessEyebrow": "Get ready to route",
"readinessTitle": "Send your first request",
"readinessSubtitle": "Four small steps. OmniRoute checks readiness as you go.",
"readinessStep1": "Connect a provider",
"readinessStep2": "Configure endpoint authentication",
"readinessStep3": "Copy your endpoint",
"readinessStep4": "Send a test request",
"readinessContinue": "Continue setup",
"readinessDismiss": "Dismiss for now"
},
"analytics": {
"title": "Analytics",
@@ -6700,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

@@ -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

@@ -31,6 +31,7 @@ import { isPrivateHost, isCloudMetadataHost } from "@/shared/network/outboundUrl
import { calculateCost } from "@/lib/usage/costCalculator";
import { attachOmniRouteMetaHeaders } from "@/domain/omnirouteResponseMeta";
import { generateRequestId } from "@/shared/utils/requestId";
import { resolveLocalSyncedEndpointRoute } from "@/lib/providerModels/syncedEndpointRouting";
type ValidatedEmbeddingBody = Record<string, unknown> & { model: string };
type ProviderCredentialsResult = Awaited<ReturnType<typeof getProviderCredentials>>;
@@ -164,7 +165,17 @@ export async function createEmbeddingResponse(
model: options.resolvedModel ?? body.model,
}
: parseEmbeddingModel(body.model, dynamicProviders);
const { provider, model: resolvedModel } = parsedModel;
let { provider, model: resolvedModel } = parsedModel;
// #11088: a bare local-model request routes through the connection that
// advertises the requested endpoint — only when no explicit resolvedProvider
// already won above (explicit resolution takes precedence).
const syncedEndpointRoute = options.resolvedProvider
? null
: await resolveLocalSyncedEndpointRoute(body.model, "embeddings");
if (syncedEndpointRoute) {
provider = syncedEndpointRoute.provider;
resolvedModel = syncedEndpointRoute.model;
}
if (!provider) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
@@ -172,6 +183,7 @@ export async function createEmbeddingResponse(
);
}
let credentials: ProviderCredentialsResult | null = null;
let providerConfig: EmbeddingProvider | null =
options.resolvedProvider ||
dynamicProviders.find((dp) => dp.id === provider) ||
@@ -179,6 +191,48 @@ export async function createEmbeddingResponse(
null;
let credentialsProviderId = provider;
if (syncedEndpointRoute) {
credentials = await getProviderCredentials(
provider,
null,
syncedEndpointRoute.connectionIds,
syncedEndpointRoute.model
);
if (!credentials) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`No credentials for embedding provider: ${provider}`
);
}
if ("allRateLimited" in credentials && credentials.allRateLimited) {
return unavailableResponse(
HTTP_STATUS.RATE_LIMITED,
`[${provider}] All accounts rate limited`,
credentials.retryAfter,
credentials.retryAfterHuman
);
}
const providerSpecificData = (credentials as { providerSpecificData?: Record<string, unknown> })
.providerSpecificData;
const configuredBaseUrl = providerSpecificData?.baseUrl;
if (typeof configuredBaseUrl !== "string" || configuredBaseUrl.trim().length === 0) {
return errorResponse(
HTTP_STATUS.BAD_REQUEST,
`No base URL configured for embedding provider: ${provider}`
);
}
let baseUrl = configuredBaseUrl.trim();
while (baseUrl.endsWith("/")) baseUrl = baseUrl.slice(0, -1);
providerConfig = {
id: provider,
baseUrl: baseUrl.endsWith("/embeddings") ? baseUrl : `${baseUrl}/embeddings`,
authType: "apikey",
authHeader: "bearer",
models: [],
};
}
if (!providerConfig) {
try {
const allNodes = (await getCachedProviderNodes()) as unknown as EmbeddingProviderNodeRow[];
@@ -226,8 +280,7 @@ export async function createEmbeddingResponse(
);
}
let credentials: ProviderCredentialsResult | null = null;
if (providerConfig.authType !== "none") {
if (!credentials && providerConfig.authType !== "none") {
credentials = await getProviderCredentials(credentialsProviderId);
if (!credentials) {
return errorResponse(

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

@@ -34,6 +34,8 @@ const IGNORED_METHODS = new Set([
"asyncBatchEmbedContent",
]);
const RETIRED_GEMINI_MODEL_IDS = new Set(["gemini-3.5-flash"]);
export interface GeminiDiscoveryModel {
id: string;
name: string;
@@ -46,36 +48,38 @@ export interface GeminiDiscoveryModel {
}
export function parseGeminiModelsList(data: any): GeminiDiscoveryModel[] {
return (data?.models || []).map((m: Record<string, unknown>) => {
const methods: string[] = Array.isArray(m.supportedGenerationMethods)
? (m.supportedGenerationMethods as string[])
: [];
return (data?.models || [])
.map((m: Record<string, unknown>) => {
const methods: string[] = Array.isArray(m.supportedGenerationMethods)
? (m.supportedGenerationMethods as string[])
: [];
const endpoints = new Set<string>(
methods
.filter((method) => !IGNORED_METHODS.has(method))
.map((method) => METHOD_TO_ENDPOINT[method] || "chat")
);
const endpoints = new Set<string>(
methods
.filter((method) => !IGNORED_METHODS.has(method))
.map((method) => METHOD_TO_ENDPOINT[method] || "chat")
);
const id = ((m.name as string) || (m.id as string) || "").replace(/^models\//, "");
const lowerId = id.toLowerCase();
const id = ((m.name as string) || (m.id as string) || "").replace(/^models\//, "");
const lowerId = id.toLowerCase();
// Keep Veo models in the video bucket even when the method list is incomplete.
if (lowerId.includes("veo")) {
endpoints.add("video");
}
// Keep Veo models in the video bucket even when the method list is incomplete.
if (lowerId.includes("veo")) {
endpoints.add("video");
}
if (endpoints.size === 0) endpoints.add("chat");
if (endpoints.size === 0) endpoints.add("chat");
return {
...m,
id,
name: (m.displayName as string) || id,
supportedEndpoints: [...endpoints],
...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}),
...(typeof m.outputTokenLimit === "number" ? { outputTokenLimit: m.outputTokenLimit } : {}),
...(typeof m.description === "string" ? { description: m.description } : {}),
...(m.thinking === true ? { supportsThinking: true } : {}),
} as GeminiDiscoveryModel;
});
return {
...m,
id,
name: (m.displayName as string) || id,
supportedEndpoints: [...endpoints],
...(typeof m.inputTokenLimit === "number" ? { inputTokenLimit: m.inputTokenLimit } : {}),
...(typeof m.outputTokenLimit === "number" ? { outputTokenLimit: m.outputTokenLimit } : {}),
...(typeof m.description === "string" ? { description: m.description } : {}),
...(m.thinking === true ? { supportsThinking: true } : {}),
} as GeminiDiscoveryModel;
})
.filter((model: GeminiDiscoveryModel) => !RETIRED_GEMINI_MODEL_IDS.has(model.id));
}

View File

@@ -20,9 +20,12 @@ import { normalizeDiscoveredModels } from "@/lib/providerModels/modelDiscovery";
import {
ANTIGRAVITY_MODEL_ALIASES,
ANTIGRAVITY_REVERSE_MODEL_ALIASES,
isDiscoverableAntigravityModelId,
} from "@omniroute/open-sse/config/antigravityModelAliases.ts";
import { isDiscoverableAgyModelId } from "@omniroute/open-sse/config/agyModels.ts";
import { filterChatSelectableModels } from "@omniroute/open-sse/services/modelEndpointPolicy.ts";
import { filterSelectableModels } from "@omniroute/open-sse/services/modelLifecycle.ts";
import { isSelfHostedChatProvider } from "@/shared/constants/providers";
type JsonRecord = Record<string, unknown>;
@@ -253,10 +256,25 @@ export async function importManagedModels({
const previousSyncedAvailableModels =
previousSyncedAvailableModelsInput ??
(await getSyncedAvailableModelsForConnection(providerId, connectionId));
const discoveredModels = filterChatSelectableModels(
providerId,
filterSelectableModels(providerId, normalizeDiscoveredModels(fetchedModels, providerId))
);
const normalizedDiscoveredModels = normalizeDiscoveredModels(fetchedModels, providerId);
// Gemini 3.5 Flash elimination (ddf1bb760, carried from #11259): antigravity/
// agy discovery is restricted to each family's discoverable ids BEFORE any
// chat-selection filtering.
const providerFilteredModels =
providerId === "antigravity"
? normalizedDiscoveredModels.filter((model) => isDiscoverableAntigravityModelId(model.id))
: providerId === "agy"
? normalizedDiscoveredModels.filter((model) => isDiscoverableAgyModelId(model.id))
: normalizedDiscoveredModels;
// #11088 (option 1): self-hosted providers keep their non-chat models — chat
// filtering happens at read time (resolveLocalSyncedEndpointRoute). Every other
// provider keeps the import-time chat filter: the read-time path is gated on
// isSelfHostedChatProvider, so dropping it globally leaked image/video models
// into OpenAI chat selections (#11271).
const selectableModels = filterSelectableModels(providerId, providerFilteredModels);
const discoveredModels = isSelfHostedChatProvider(providerId)
? selectableModels
: filterChatSelectableModels(providerId, selectableModels);
const candidateImportedModels = normalizeImportedModels(discoveredModels);
const importedIds = new Set(candidateImportedModels.map((model) => model.id));

View File

@@ -6,7 +6,6 @@ import {
} from "@/lib/db/models";
import { CANONICAL_EFFORT_VALUES } from "@/shared/reasoning/effortStandardization";
import { isObsoleteKiroModelAlias } from "@omniroute/open-sse/services/kiroModels.ts";
import { filterChatSelectableModels } from "@omniroute/open-sse/services/modelEndpointPolicy.ts";
import { filterSelectableModels } from "@omniroute/open-sse/services/modelLifecycle.ts";
type JsonRecord = Record<string, unknown>;
@@ -379,9 +378,13 @@ export async function persistDiscoveredModels(
connectionId: string,
models: unknown
): Promise<SyncedAvailableModel[]> {
const normalized = filterChatSelectableModels(
// #11088 (option 1): the synced store is endpoint-agnostic — images/embeddings
// models must persist so per-connection endpoint routing (#11088) and the
// /v1/models catalog can see them. Chat selectability is applied at read time
// (auto-pool expansion, chat projections), not at write time.
const normalized = filterSelectableModels(
providerId,
filterSelectableModels(providerId, normalizeDiscoveredModels(models, providerId))
normalizeDiscoveredModels(models, providerId)
);
await replaceSyncedAvailableModelsForConnection(providerId, connectionId, normalized);
return normalized;

View File

@@ -0,0 +1,98 @@
import { z } from "zod";
type JsonRecord = Record<string, unknown>;
const ollamaShowResponseSchema = z
.object({
capabilities: z.array(z.string().max(64)).max(32).optional(),
})
.passthrough();
const OLLAMA_CAPABILITY_TO_ENDPOINT: Readonly<Record<string, string>> = {
completion: "chat",
embedding: "embeddings",
image: "images",
};
const MAX_CONCURRENT_SHOW_REQUESTS = 4;
function asRecord(value: unknown): JsonRecord {
return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {};
}
export function buildOllamaShowUrl(openAiBaseUrl: string): string {
let base = openAiBaseUrl.trim();
while (base.endsWith("/")) base = base.slice(0, -1);
base = base.replace(/\/(?:chat\/completions|completions|embeddings|images\/generations)$/i, "");
if (base.endsWith("/v1")) base = base.slice(0, -3);
return `${base}/api/show`;
}
export function applyOllamaShowCapabilities(model: unknown, showResponse: unknown): JsonRecord {
const record = asRecord(model);
const parsed = ollamaShowResponseSchema.safeParse(showResponse);
if (!parsed.success || !parsed.data.capabilities) return record;
const capabilities = Array.from(
new Set(parsed.data.capabilities.map((value) => value.trim().toLowerCase()).filter(Boolean))
);
const supportedEndpoints = Array.from(
new Set(
capabilities
.map((capability) => OLLAMA_CAPABILITY_TO_ENDPOINT[capability])
.filter((endpoint): endpoint is string => Boolean(endpoint))
)
);
if (supportedEndpoints.length === 0) return record;
const apiFormat = supportedEndpoints.includes("chat")
? "chat-completions"
: supportedEndpoints.includes("embeddings")
? "embeddings"
: "images-generations";
return {
...record,
apiFormat,
supportedEndpoints,
...(capabilities.includes("vision") ? { supportsVision: true } : {}),
...(capabilities.includes("tools") ? { supportsTools: true } : {}),
...(capabilities.includes("thinking") ? { supportsThinking: true } : {}),
};
}
export async function enrichOllamaModelsWithCapabilities(
models: unknown[],
fetchShow: (modelId: string) => Promise<unknown | null>
): Promise<JsonRecord[]> {
const output: JsonRecord[] = new Array(models.length);
let nextIndex = 0;
const worker = async () => {
while (nextIndex < models.length) {
const index = nextIndex++;
const model = asRecord(models[index]);
const modelId =
typeof model.id === "string"
? model.id
: typeof model.name === "string"
? model.name
: typeof model.model === "string"
? model.model
: null;
if (!modelId) {
output[index] = model;
continue;
}
try {
output[index] = applyOllamaShowCapabilities(model, await fetchShow(modelId));
} catch {
output[index] = model;
}
}
};
const workerCount = Math.min(MAX_CONCURRENT_SHOW_REQUESTS, Math.max(1, models.length));
await Promise.all(Array.from({ length: workerCount }, () => worker()));
return output;
}

View File

@@ -0,0 +1,31 @@
import { getSyncedAvailableModelsByConnection } from "@/lib/db/models";
import { isSelfHostedChatProvider, resolveProviderId } from "@/shared/constants/providers";
export type LocalSyncedEndpointRoute = {
provider: string;
model: string;
connectionIds: string[];
};
export async function resolveLocalSyncedEndpointRoute(
modelStr: string,
endpoint: "embeddings" | "images"
): Promise<LocalSyncedEndpointRoute | null> {
const slashIndex = modelStr.indexOf("/");
if (slashIndex <= 0 || slashIndex === modelStr.length - 1) return null;
const provider = resolveProviderId(modelStr.slice(0, slashIndex));
const model = modelStr.slice(slashIndex + 1);
if (!isSelfHostedChatProvider(provider)) return null;
const byConnection = await getSyncedAvailableModelsByConnection(provider);
const connectionIds = Object.entries(byConnection)
.filter(([, models]) =>
models.some(
(candidate) => candidate.id === model && candidate.supportedEndpoints?.includes(endpoint)
)
)
.map(([connectionId]) => connectionId);
return connectionIds.length > 0 ? { provider, model, connectionIds } : null;
}

View File

@@ -0,0 +1,44 @@
interface VertexPublisherModel {
name?: string;
displayName?: string;
description?: string;
supportedActions?: string[];
versionId?: string;
[key: string]: unknown;
}
export interface VertexAnthropicDiscoveryModel {
id: string;
name: string;
supportedEndpoints: string[];
targetFormat: string;
owned_by: string;
description?: string;
[key: string]: unknown;
}
export function parseVertexAnthropicModels(data: unknown): VertexAnthropicDiscoveryModel[] {
if (!data || typeof data !== "object") return [];
const envelope = data as { models?: unknown[] };
const models = Array.isArray(envelope.models) ? envelope.models : [];
return models
.map((m: unknown) => {
const model = m as VertexPublisherModel;
const rawName = typeof model.name === "string" ? model.name : "";
// "publishers/anthropic/models/claude-sonnet-4-6" or
// "projects/x/locations/y/publishers/anthropic/models/claude-sonnet-4-6"
const id = rawName.replace(/^(?:projects\/[^/]+\/locations\/[^/]+\/)?publishers\/anthropic\/models\//, "") || rawName;
if (!id) return null;
return {
id,
name: (typeof model.displayName === "string" && model.displayName) || id,
supportedEndpoints: ["chat"],
targetFormat: "claude",
...(typeof model.description === "string" ? { description: model.description } : {}),
owned_by: "anthropic",
} satisfies VertexAnthropicDiscoveryModel;
})
.filter((m): m is VertexAnthropicDiscoveryModel => m !== null);
}

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

@@ -20,6 +20,11 @@ export const DEFAULT_ALLOWED_ORIGINS: readonly string[] = Object.freeze([
"http://127.0.0.1:20128",
"http://localhost:20128",
"http://[::1]:20128",
// 0.0.0.0 is the "unspecified" address but browsers treat it as loopback
// when the user pastes it into the address bar; the dashboard is reachable
// at http://0.0.0.0:20128 and its WS Origin is exactly that string. Same
// local-only posture as the entries above — it never refers to a LAN host.
"http://0.0.0.0:20128",
]);
/**

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

@@ -401,34 +401,56 @@ const ProviderIcon = memo(function ProviderIcon({
className={className}
style={{ display: "inline-flex", alignItems: "center", ...style }}
>
<Image
{/* eslint-disable-next-line @next/next/no-img-element -- themed local SVG asset; see the Tier 2 comment for why these use a plain <img> */}
<img
src={themedSrc}
alt={providerId}
width={size}
height={size}
style={{ objectFit: "contain" }}
style={{
objectFit: "contain",
flex: "none",
width: "auto",
height: "auto",
maxWidth: size,
maxHeight: size,
}}
onError={() => setFailedAssets((current) => ({ ...current, [themedKey]: true }))}
unoptimized
/>
</span>
);
}
// Tier 2: Local SVG — fastest, cached separately from the JS bundle
// Tier 2: Local SVG — fastest, cached separately from the JS bundle.
// Rendered as a plain <img> (not next/image): provider SVGs carry their own
// intrinsic aspect ratio (e.g. opencode.svg is 234×42), and next/image's
// dev-mode check warns whenever the layout size differs from the square
// width/height attributes — a false positive for non-square logos rendered
// at fixed icon sizes. We keep `width/height` attributes for layout reserve
// but let the intrinsic ratio win on both axes (`width/height: "auto"`) so
// wide logos like opencode render at their true aspect ratio instead of
// being letterboxed into a 1:1 box.
if (hasSvg && !svgFailed) {
return (
<span
className={className}
style={{ display: "inline-flex", alignItems: "center", ...style }}
>
<Image
{/* eslint-disable-next-line @next/next/no-img-element -- local static SVG asset, see comment above */}
<img
src={`/providers/${localSvgId}.svg`}
alt={providerId}
width={size}
height={size}
style={{ objectFit: "contain" }}
style={{
objectFit: "contain",
flex: "none",
width: "auto",
height: "auto",
maxWidth: size,
maxHeight: size,
}}
onError={() => setFailedAssets((current) => ({ ...current, [svgKey]: true }))}
unoptimized
/>
</span>
);

View File

@@ -1,7 +1,6 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { useTranslations } from "next-intl";
import type { CliCatalogEntry } from "@/shared/schemas/cliCatalog";
import type { ToolBatchStatus } from "@/shared/types/cliBatchStatus";
@@ -38,12 +37,18 @@ export default function CliToolCard({
<div className="flex items-center gap-2.5">
{/* Icon / image */}
{imageSrc ? (
<Image
// Plain <img> (not next/image): tool SVGs are non-square (opencode
// 234×42, cursor 467×532) and next/image's dev check warns whenever the
// rendered aspect-ratio size differs from the square width/height
// attributes. object-contain + max caps keep the logo at its true ratio.
// eslint-disable-next-line @next/next/no-img-element -- local static SVG asset
<img
src={imageSrc}
alt={tool.name}
width={32}
height={32}
className="rounded-md object-contain flex-shrink-0"
style={{ width: "auto", height: "auto", maxWidth: 32, maxHeight: 32 }}
/>
) : (
<span

View File

@@ -99,7 +99,7 @@ const GPT_5_6_MODEL_SPEC = {
supportsVision: true,
} satisfies ModelSpec;
const GEMINI_35_FLASH_MODEL_SPEC = {
const GEMINI_36_FLASH_MODEL_SPEC = {
maxOutputTokens: 65536,
contextWindow: 1048576,
supportsThinking: false,
@@ -160,7 +160,7 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
aliases: ["openai/gpt-4o"],
},
// ── Gemini 2.5 and provider-neutral 3.5 Flash series ─────────────
// ── Gemini 2.5 Flash ─────────────────────────────────────────────
"gemini-2.5-flash": {
maxOutputTokens: 65536,
contextWindow: 1048576,
@@ -171,16 +171,6 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
supportsTools: true,
supportsVision: true,
},
"gemini-3.5-flash-extra-low": {
...GEMINI_35_FLASH_MODEL_SPEC,
thinkingBudgetCap: 0,
},
"gemini-3.5-flash-low": { ...GEMINI_35_FLASH_MODEL_SPEC },
"gemini-3-flash-agent": {
...GEMINI_35_FLASH_MODEL_SPEC,
thinkingBudgetCap: 0,
},
// ── Gemini 3.7 Flash (current Antigravity/AGY live tiers) ─────────
// The tier suffix configures the thinking budget passed to the upstream
// gemini-3.7-flash-tiered backend (high: 24.5k, medium: 8k, low: 1k).
@@ -234,9 +224,9 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
// Provider-neutral compatibility for providers that still serve Gemini 3.6.
// Antigravity/AGY availability is governed by their own provider catalogs and
// retirement filters; these shared specs must not be treated as an allowlist.
"gemini-3.6-flash-high": { ...GEMINI_35_FLASH_MODEL_SPEC },
"gemini-3.6-flash-medium": { ...GEMINI_35_FLASH_MODEL_SPEC },
"gemini-3.6-flash-low": { ...GEMINI_35_FLASH_MODEL_SPEC },
"gemini-3.6-flash-high": { ...GEMINI_36_FLASH_MODEL_SPEC },
"gemini-3.6-flash-medium": { ...GEMINI_36_FLASH_MODEL_SPEC },
"gemini-3.6-flash-low": { ...GEMINI_36_FLASH_MODEL_SPEC },
// ── Gemini 3 Flash series ───────────────────────────────────────
"gemini-3-flash": {
@@ -282,20 +272,6 @@ export const MODEL_SPECS: Record<string, ModelSpec> = {
aliases: ["gemini-3-pro-low"],
},
// ── Gemini 3.5 Flash ─────────────────────────────────────────────
// #10286: the base Google AI Studio model DOES support reasoning (it has
// an effort-tier alias gemini-3.5-flash-high) — override the shared spec's
// supportsThinking:false here only. Do NOT flip GEMINI_35_FLASH_MODEL_SPEC
// itself: it is also spread into the Antigravity flash-tier aliases
// (gemini-3.5-flash-low/-extra-low, gemini-3-flash-agent, gemini-3.6-flash-*)
// which reject client-supplied thinking params because the model id itself
// selects the reasoning tier upstream.
"gemini-3.5-flash": {
...GEMINI_35_FLASH_MODEL_SPEC,
supportsThinking: true,
aliases: ["gemini-3.5-flash-high"],
},
// ── Claude Opus 4.5 ─────────────────────────────────────────────
"claude-opus-4-5": {
maxOutputTokens: 32768,

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

@@ -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

@@ -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

@@ -57,6 +57,7 @@ test("agy ships its own live callable model catalog", () => {
assert.ok(!ids.includes("gemini-3.6-flash-low"));
assert.ok(!ids.includes("gemini-3.6-flash-medium"));
assert.ok(!ids.includes("gemini-3.6-flash-high"));
assert.ok(!ids.includes("gemini-3.5-flash"));
assert.ok(!ids.includes("gemini-3.5-flash-extra-low"));
assert.ok(!ids.includes("gemini-3.5-flash-low"));
assert.ok(!ids.includes("gemini-3-flash-agent"));
@@ -87,6 +88,7 @@ test("agy model helpers resolve catalog ids and display names", () => {
assert.equal(isUserCallableAgyModelId("gemini-3.6-flash-low"), false);
assert.equal(isUserCallableAgyModelId("gemini-3.6-flash-medium"), false);
assert.equal(isUserCallableAgyModelId("gemini-3.6-flash-high"), false);
assert.equal(isUserCallableAgyModelId("gemini-3.5-flash"), false);
assert.equal(isUserCallableAgyModelId("gemini-3.5-flash-extra-low"), false);
assert.equal(isUserCallableAgyModelId("gemini-3.5-flash-low"), false);
assert.equal(isUserCallableAgyModelId("gemini-3-flash-agent"), false);

View File

@@ -74,7 +74,7 @@ test("TDD S3: checkFallbackError extracts retry hint for oauth providers even if
429,
errorText,
0,
"gemini-3.5-flash",
"gemini-3.7-flash",
"antigravity", // which uses oauth provider profile (useUpstreamRetryHints: false)
null
);

View File

@@ -31,6 +31,7 @@ const RETIRED_FLASH_IDS = [
"gemini-3.6-flash-low",
"gemini-3.6-flash-medium",
"gemini-3.6-flash-high",
"gemini-3.5-flash",
"gemini-3.5-flash-extra-low",
"gemini-3.5-flash-low",
"gemini-3-flash-agent",

View File

@@ -21,6 +21,7 @@ const RETIRED_PUBLIC_MODELS = [
"gemini-3.6-flash-medium",
"gemini-3.6-flash-low",
"gemini-3-flash-agent",
"gemini-3.5-flash",
"gemini-3.5-flash-low",
"gemini-3.5-flash-extra-low",
"gemini-2.5-pro",

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

@@ -25,9 +25,8 @@ process.env.DATA_DIR = TEST_DATA_DIR;
const core = await import("../../src/lib/db/core.ts");
const { updateSettings } = await import("../../src/lib/db/settings.ts");
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
const { shouldDefaultAllowClassifier, buildDefaultAllowClaudeMessage } = await import(
"../../open-sse/handlers/chatCore/claudeClassifierCompat.ts"
);
const { shouldDefaultAllowClassifier, detectClassifierFormat, buildDefaultAllowClaudeMessage } =
await import("../../open-sse/handlers/chatCore/claudeClassifierCompat.ts");
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
const originalFetch = globalThis.fetch;
@@ -58,6 +57,14 @@ const CLASSIFIER_BODY = {
max_tokens: 8,
};
// Newer Claude Code builds send a "severity classifier" variant of the same internal
// request: same security-monitor marker, but `stop_sequences` carries `</severity>`
// instead of `</block>`, and it expects a `<severity>N</severity>` reply (#11289).
const SEVERITY_CLASSIFIER_BODY = {
...CLASSIFIER_BODY,
stop_sequences: ["</severity>"],
};
test.after(() => {
globalThis.fetch = originalFetch;
core.resetDbInstance();
@@ -123,7 +130,12 @@ test("detector: always does NOT fire for normal chat without classifier marker (
test("detector: always fires when classifier marker is present", () => {
const classifier = {
system: [{ type: "text", text: "You are a security monitor for autonomous AI coding agents. Evaluate the following action." }],
system: [
{
type: "text",
text: "You are a security monitor for autonomous AI coding agents. Evaluate the following action.",
},
],
stop_sequences: ["</block>"],
};
assert.equal(
@@ -133,6 +145,21 @@ test("detector: always fires when classifier marker is present", () => {
);
});
// ─── Pure detector: detectClassifierFormat (#11289) ──────────────────────────
test("format detector: defaults to 'block' for the legacy </block> classifier shape", () => {
assert.equal(detectClassifierFormat(CLASSIFIER_BODY), "block");
});
test("format detector: returns 'severity' when stop_sequences carries </severity>", () => {
assert.equal(detectClassifierFormat(SEVERITY_CLASSIFIER_BODY), "severity");
});
test("format detector: defaults to 'block' when stop_sequences is missing/empty", () => {
assert.equal(detectClassifierFormat({}), "block");
assert.equal(detectClassifierFormat({ stop_sequences: [] }), "block");
});
// ─── Pure builder: buildDefaultAllowClaudeMessage ────────────────────────────
test("builder: synthetic message text STARTS WITH <block>no</block>", async () => {
@@ -155,6 +182,16 @@ test("builder: synthetic message text STARTS WITH <block>no</block>", async () =
assert.ok(!text.includes("<block>yes"), "must not signal BLOCK");
});
test("builder: format='severity' returns <severity>0</severity> (#11289)", async () => {
const built = buildDefaultAllowClaudeMessage("claude-3-5-haiku-20241022", "severity");
assert.equal(built.success, true);
const payload = (await built.response.json()) as {
content: Array<{ type: string; text?: string }>;
};
const text = payload.content.find((b) => b.type === "text")?.text ?? "";
assert.equal(text, "<severity>0</severity>");
});
// ─── Handler-level: end-to-end short-circuit through handleChatCore ──────────
test("handler: claudeClassifierCompat=auto short-circuits WITHOUT calling upstream, text starts with <block>no</block>", async () => {
@@ -196,3 +233,44 @@ test("handler: claudeClassifierCompat=auto short-circuits WITHOUT calling upstre
globalThis.fetch = originalFetch;
}
});
test("handler: claudeClassifierCompat=auto emits <severity>0</severity> for the severity-classifier shape (#11289)", async () => {
await updateSettings({ claudeClassifierCompat: "auto" });
let fetchCalls = 0;
globalThis.fetch = (async () => {
fetchCalls++;
throw new Error("upstream fetch should NOT be called when the classifier short-circuits");
}) as typeof fetch;
try {
const result = await handleChatCore({
body: structuredClone(SEVERITY_CLASSIFIER_BODY),
modelInfo: { provider: "openai", model: "gpt-4o-mini", extendedContext: false },
credentials: { apiKey: "sk-test", providerSpecificData: {} },
log: noopLog(),
clientRawRequest: {
endpoint: "/v1/messages",
body: structuredClone(SEVERITY_CLASSIFIER_BODY),
headers: new Headers({ accept: "application/json" }),
},
userAgent: "unit-test",
});
assert.equal(fetchCalls, 0, "upstream fetch must NOT be called");
assert.equal(result.success, true, "handleChatCore must report success");
const payload = (await (result as { response: Response }).response.json()) as {
type: string;
content: Array<{ type: string; text?: string }>;
};
assert.equal(payload.type, "message");
const text = payload.content.find((b) => b.type === "text")?.text ?? "";
assert.equal(
text,
"<severity>0</severity>",
`expected severity-classifier response to be <severity>0</severity>, got: ${text}`
);
} finally {
globalThis.fetch = originalFetch;
}
});

View File

@@ -1,133 +1,101 @@
import test from "node:test";
import assert from "node:assert/strict";
function makeResp(data: unknown, status = 200) {
const obj = {
ok: status < 400,
status,
exitCode: status < 400 ? 0 : 1,
json: () => Promise.resolve(data),
text: () => Promise.resolve(JSON.stringify(data)),
headers: new Headers(),
};
obj.json = obj.json.bind(obj);
obj.text = obj.text.bind(obj);
return obj;
}
import { makeMcpResp, makeMcpStreamFetch } from "./helpers/mcpStreamMock.ts";
function makeCmd(output = "json") {
return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) };
}
test("combo suggest chama omniroute_best_combo_for_task via MCP", async () => {
let capturedBody: any = null;
let capturedUrl = "";
const origFetch = globalThis.fetch;
globalThis.fetch = ((url: string, opts: any) => {
capturedUrl = url;
if (opts?.body) capturedBody = JSON.parse(opts.body);
return Promise.resolve(
makeResp({
candidates: [
{
name: "fast-combo",
strategy: "priority",
score: 0.92,
latencyP50Ms: 120,
costPer1k: 0.002,
},
],
rationale: "Best latency for real-time tasks",
})
);
}) as any;
await (globalThis.fetch as any)("/api/mcp/tools/call", {
method: "POST",
body: JSON.stringify({
name: "omniroute_best_combo_for_task",
arguments: { task: "Real-time code completions", top: 5 },
}),
globalThis.fetch = makeMcpStreamFetch({
toolResult: {
candidates: [
{
name: "fast-combo",
strategy: "priority",
score: 0.92,
latencyP50Ms: 120,
costPer1k: 0.002,
},
],
rationale: "Best latency for real-time tasks",
},
});
const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs");
const result = await mcpCallTool("omniroute_best_combo_for_task", {
task: "Real-time code completions",
top: 5,
});
globalThis.fetch = origFetch;
assert.ok(capturedUrl.includes("/api/mcp/tools/call"));
assert.equal(capturedBody.name, "omniroute_best_combo_for_task");
assert.equal(capturedBody.arguments.task, "Real-time code completions");
const candidates = (result as any).candidates;
assert.equal(candidates[0].name, "fast-combo");
assert.equal((result as any).rationale, "Best latency for real-time tasks");
});
test("combo suggest --max-cost/--max-latency-ms passa constraints", async () => {
let capturedBody: any = null;
const origFetch = globalThis.fetch;
globalThis.fetch = ((_url: string, opts: any) => {
if (opts?.body) capturedBody = JSON.parse(opts.body);
return Promise.resolve(makeResp({ candidates: [] }));
const captured: any[] = [];
globalThis.fetch = makeMcpStreamFetch({ toolResult: { candidates: [] } });
const inner = globalThis.fetch;
globalThis.fetch = ((url: any, init: any) => {
captured.push({ url: String(url), init });
return inner(url, init);
}) as any;
await (globalThis.fetch as any)("/api/mcp/tools/call", {
method: "POST",
body: JSON.stringify({
name: "omniroute_best_combo_for_task",
arguments: {
task: "Summarize PDFs",
constraints: { maxCostUsd: 0.001, maxLatencyMs: 500 },
top: 3,
},
}),
const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs");
await mcpCallTool("omniroute_best_combo_for_task", {
task: "Summarize PDFs",
constraints: { maxCostUsd: 0.001, maxLatencyMs: 500 },
top: 3,
});
globalThis.fetch = origFetch;
assert.equal(capturedBody.arguments.constraints.maxCostUsd, 0.001);
assert.equal(capturedBody.arguments.constraints.maxLatencyMs, 500);
assert.equal(capturedBody.arguments.top, 3);
const args = JSON.parse(captured.find((c) => /tools\/call/.test(String(c.init?.body || "")))?.init?.body || "{}")?.params?.arguments;
assert.equal(args.constraints.maxCostUsd, 0.001);
assert.equal(args.constraints.maxLatencyMs, 500);
assert.equal(args.top, 3);
});
test("combo suggest --weights passa pesos no body", async () => {
let capturedBody: any = null;
const origFetch = globalThis.fetch;
globalThis.fetch = ((_url: string, opts: any) => {
if (opts?.body) capturedBody = JSON.parse(opts.body);
return Promise.resolve(makeResp({ candidates: [] }));
const captured: any[] = [];
globalThis.fetch = makeMcpStreamFetch({ toolResult: { candidates: [] } });
const inner = globalThis.fetch;
globalThis.fetch = ((url: any, init: any) => {
captured.push({ url: String(url), init });
return inner(url, init);
}) as any;
await (globalThis.fetch as any)("/api/mcp/tools/call", {
method: "POST",
body: JSON.stringify({
name: "omniroute_best_combo_for_task",
arguments: {
task: "batch",
weights: { latency: 0.7, cost: 0.3 },
},
}),
const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs");
await mcpCallTool("omniroute_best_combo_for_task", {
task: "batch",
weights: { latency: 0.7, cost: 0.3 },
});
globalThis.fetch = origFetch;
assert.equal(capturedBody.arguments.weights.latency, 0.7);
assert.equal(capturedBody.arguments.weights.cost, 0.3);
const args = JSON.parse(captured.find((c) => /tools\/call/.test(String(c.init?.body || "")))?.init?.body || "{}")?.params?.arguments;
assert.equal(args.weights.latency, 0.7);
assert.equal(args.weights.cost, 0.3);
});
test("combo suggest --switch chama /api/combos/switch com melhor combo", async () => {
let urls: string[] = [];
const urls: string[] = [];
const origFetch = globalThis.fetch;
globalThis.fetch = ((url: string, opts: any) => {
urls.push(url);
if (url.includes("/api/mcp/tools/call")) {
return Promise.resolve(makeResp({ candidates: [{ name: "best-combo", score: 0.95 }] }));
globalThis.fetch = ((url: any, opts: any) => {
urls.push(String(url));
if (String(url).includes("/api/mcp/stream")) {
const body = opts?.body ? JSON.parse(opts.body) : {};
if (body.method === "initialize") {
return Promise.resolve(makeMcpResp({ jsonrpc: "2.0", id: body.id, result: {} }, 200, { "mcp-session-id": "s" }));
}
return Promise.resolve(makeMcpResp({ jsonrpc: "2.0", id: body.id, result: { candidates: [{ name: "best-combo", score: 0.95 }] } }));
}
return Promise.resolve(makeResp({ switched: true }));
return Promise.resolve(makeMcpResp({ switched: true }));
}) as any;
await (globalThis.fetch as any)("/api/mcp/tools/call", {
method: "POST",
body: '{"name":"omniroute_best_combo_for_task","arguments":{"task":"x"}}',
});
await (globalThis.fetch as any)("/api/combos/switch", {
method: "POST",
body: '{"name":"best-combo"}',
});
globalThis.fetch = origFetch;
const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs");
const data = await mcpCallTool("omniroute_best_combo_for_task", { task: "x" });
const combosSwitchRes = await fetch("/api/combos/switch", { method: "POST", body: JSON.stringify({ name: (data as any).candidates[0].name }) });
assert.equal(combosSwitchRes.ok, true);
assert.ok(urls.some((u) => u.includes("/api/combos/switch")));
globalThis.fetch = origFetch;
});
test("combo.mjs exporta extendComboSuggest e registerCombo", async () => {

View File

@@ -1,11 +1,11 @@
import test from "node:test";
import assert from "node:assert/strict";
import { makeMcpResp, makeMcpStreamFetch } from "./helpers/mcpStreamMock.ts";
function makeResp(data: unknown, status = 200) {
const obj = {
ok: status < 400,
status,
exitCode: status < 400 ? 0 : 1,
json: () => Promise.resolve(data),
text: () => Promise.resolve(JSON.stringify(data)),
headers: new Headers(),
@@ -35,26 +35,32 @@ function makeCmd(output = "json") {
}
test("compression status chama omniroute_compression_status via mcp", async () => {
let capturedBody: any = null;
const calls: unknown[] = [];
const origFetch = globalThis.fetch;
globalThis.fetch = ((_url: string, opts: any) => {
if (opts?.body) capturedBody = JSON.parse(opts.body);
return Promise.resolve(makeResp({ engine: "caveman", enabled: true }));
globalThis.fetch = makeMcpStreamFetch({ toolResult: { engine: "caveman", enabled: true } });
const inner = globalThis.fetch;
globalThis.fetch = ((url: unknown, init: unknown) => {
calls.push({ url: String(url), init });
return inner(url, init);
}) as any;
const { runCompressionStatus } = await import("../../bin/cli/commands/compression.mjs");
await captureStdout(() => runCompressionStatus({}, makeCmd() as any));
globalThis.fetch = origFetch;
assert.equal(capturedBody.name, "omniroute_compression_status");
const body = JSON.parse(calls.find((x) => String(x.init?.body || "").includes("tools/call"))?.init?.body || "{}");
assert.equal(body.method, "tools/call");
assert.equal(body.params.name, "omniroute_compression_status");
});
test("compression configure envia configuração via mcp", async () => {
let capturedBody: any = null;
const calls: unknown[] = [];
const origFetch = globalThis.fetch;
globalThis.fetch = ((_url: string, opts: any) => {
if (opts?.body) capturedBody = JSON.parse(opts.body);
return Promise.resolve(makeResp({ success: true }));
globalThis.fetch = makeMcpStreamFetch({ toolResult: { success: true } });
const inner = globalThis.fetch;
globalThis.fetch = ((url: unknown, init: unknown) => {
calls.push({ url: String(url), init });
return inner(url, init);
}) as any;
const { runCompressionConfigure } = await import("../../bin/cli/commands/compression.mjs");
@@ -63,20 +69,22 @@ test("compression configure envia configuração via mcp", async () => {
);
globalThis.fetch = origFetch;
assert.equal(capturedBody.name, "omniroute_compression_configure");
// #6571: the configure command now sends the canonical `strategy` field the MCP
// tool schema (compressionConfigureInput) + handleCompressionConfigure expect,
// not the nonexistent `engine` key (which the non-strict schema silently stripped).
assert.equal(capturedBody.arguments.strategy, "caveman");
assert.ok(capturedBody.arguments.caveman?.aggressiveness === 0.8);
const body = JSON.parse(calls.find((x) => String(x.init?.body || "").includes("tools/call"))?.init?.body || "{}");
assert.equal(body.method, "tools/call");
assert.equal(body.params.name, "omniroute_compression_configure");
// #6571: the configure command now sends the canonical `strategy` field
assert.equal(body.params.arguments.strategy, "caveman");
assert.ok(body.params.arguments.caveman?.aggressiveness === 0.8);
});
test("compression engine set chama omniroute_set_compression_engine", async () => {
let capturedBody: any = null;
const calls: unknown[] = [];
const origFetch = globalThis.fetch;
globalThis.fetch = ((_url: string, opts: any) => {
if (opts?.body) capturedBody = JSON.parse(opts.body);
return Promise.resolve(makeResp({ success: true }));
globalThis.fetch = makeMcpStreamFetch({ toolResult: {} });
const inner = globalThis.fetch;
globalThis.fetch = ((url: unknown, init: unknown) => {
calls.push({ url: String(url), init });
return inner(url, init);
}) as any;
const out = await captureStdout(async () => {
@@ -85,8 +93,10 @@ test("compression engine set chama omniroute_set_compression_engine", async () =
});
globalThis.fetch = origFetch;
assert.equal(capturedBody.name, "omniroute_set_compression_engine");
assert.equal(capturedBody.arguments.engine, "rtk");
const body = JSON.parse(calls.find((x) => String(x.init?.body || "").includes("tools/call"))?.init?.body || "{}");
assert.equal(body.method, "tools/call");
assert.equal(body.params.name, "omniroute_set_compression_engine");
assert.equal(body.params.arguments.engine, "rtk");
assert.ok(out.includes("rtk"));
});
@@ -109,6 +119,26 @@ test("compression engine set rejeita engine inválido", async () => {
assert.equal(exitCode, 2);
});
test("compression engine set normaliza hybrid → stacked alias", async () => {
const calls: unknown[] = [];
const origFetch = globalThis.fetch;
globalThis.fetch = makeMcpStreamFetch({ toolResult: {} });
const inner = globalThis.fetch;
globalThis.fetch = ((url: unknown, init: unknown) => {
calls.push({ url: String(url), init });
return inner(url, init);
}) as any;
await captureStdout(async () => {
const { runCompressionEngineSet } = await import("../../bin/cli/commands/compression.mjs");
await runCompressionEngineSet("hybrid", {}, makeCmd() as any);
});
globalThis.fetch = origFetch;
const body = JSON.parse(calls.find((x) => String(x.init?.body || "").includes("tools/call"))?.init?.body || "{}");
assert.equal(body.params.arguments.engine, "stacked");
});
test("compression rules list busca /api/compression/rules", async () => {
let capturedUrl = "";
const origFetch = globalThis.fetch;
@@ -126,10 +156,10 @@ test("compression rules list busca /api/compression/rules", async () => {
});
test("compression rules add envia pattern e action", async () => {
let capturedBody: any = null;
let capturedBody: unknown = null;
let capturedUrl = "";
const origFetch = globalThis.fetch;
globalThis.fetch = ((url: string, opts: any) => {
globalThis.fetch = ((url: string, opts: unknown) => {
capturedUrl = url;
if (opts?.body) capturedBody = JSON.parse(opts.body);
return Promise.resolve(makeResp({ id: "rule-2", pattern: ".*debug.*", action: "drop" }));
@@ -168,15 +198,19 @@ test("compression.mjs pode ser importado sem erro", async () => {
assert.equal(typeof mod.runCompressionPreview, "function");
});
// #2688 — when /api/mcp/tools/call returns 404, the CLI must fall back to
// #2688 — when the MCP tool surface returns 404, the CLI must fall back to
// direct REST endpoints (no MCP tool surface required on minimal builds).
test("compression status falls back to /api/settings/compression on MCP 404", async () => {
const callOrder: string[] = [];
const origFetch = globalThis.fetch;
globalThis.fetch = ((url: string) => {
globalThis.fetch = ((url: string, opts: unknown) => {
callOrder.push(url);
if (url.includes("/api/mcp/tools/call")) {
return Promise.resolve(makeResp({ error: "not mounted" }, 404));
if (url.includes("/api/mcp/stream")) {
const body = opts?.body ? JSON.parse(opts.body) : {};
if (body.method === "initialize") {
return Promise.resolve(makeMcpResp({ jsonrpc: "2.0", id: body.id, result: {} }, 200, { "mcp-session-id": "s" }));
}
return Promise.resolve(makeMcpResp({ error: "not mounted" }, 404));
}
if (url.includes("/api/settings/compression")) {
return Promise.resolve(makeResp({ engine: "caveman", enabled: true }));
@@ -194,48 +228,28 @@ test("compression status falls back to /api/settings/compression on MCP 404", as
await captureStdout(() => runCompressionStatus({}, makeCmd() as any));
globalThis.fetch = origFetch;
assert.ok(
callOrder.some((u) => u.includes("/api/mcp/tools/call")),
"should attempt MCP first"
);
assert.ok(
callOrder.some((u) => u.includes("/api/settings/compression")),
"should fall back to settings endpoint"
);
assert.ok(
callOrder.some((u) => u.includes("/api/context/combos")),
"should fall back to combos endpoint"
);
});
test("compression engine set normalizes hybrid → stacked alias", async () => {
let captured: any = null;
const origFetch = globalThis.fetch;
globalThis.fetch = ((_url: string, opts: any) => {
if (opts?.body) captured = JSON.parse(opts.body);
return Promise.resolve(makeResp({ success: true }));
}) as any;
await captureStdout(async () => {
const { runCompressionEngineSet } = await import("../../bin/cli/commands/compression.mjs");
await runCompressionEngineSet("hybrid", {}, makeCmd() as any);
});
globalThis.fetch = origFetch;
assert.equal(captured?.arguments?.engine, "stacked");
const first = callOrder[0] ?? "";
assert.ok(first.includes("/api/mcp/stream"), "should attempt MCP first");
assert.ok(callOrder.some((u) => u.includes("/api/settings/compression")), "should fall back to REST");
assert.ok(callOrder.some((u) => u.includes("/api/context/combos")), "should fetch combos");
assert.ok(callOrder.some((u) => u.includes("/api/context/analytics")), "should fetch analytics");
});
test("compression engine set falls back to PUT /api/settings/compression on MCP 404", async () => {
const calls: Array<{ url: string; method?: string; body?: any }> = [];
const calls: Array<{ url: string; method?: string; body?: unknown }> = [];
const origFetch = globalThis.fetch;
globalThis.fetch = ((url: string, opts: any) => {
globalThis.fetch = ((url: string, opts: unknown) => {
calls.push({
url,
method: opts?.method,
body: opts?.body ? JSON.parse(opts.body) : undefined,
});
if (url.includes("/api/mcp/tools/call")) {
return Promise.resolve(makeResp({ error: "not mounted" }, 404));
if (url.includes("/api/mcp/stream")) {
const body = opts?.body ? JSON.parse(opts.body) : {};
if (body.method === "initialize") {
return Promise.resolve(makeMcpResp({ jsonrpc: "2.0", id: body.id, result: {} }, 200, { "mcp-session-id": "s" }));
}
return Promise.resolve(makeMcpResp({ error: "not mounted" }, 404));
}
return Promise.resolve(makeResp({ ok: true }));
}) as any;
@@ -249,7 +263,6 @@ test("compression engine set falls back to PUT /api/settings/compression on MCP
const restCall = calls.find((c) => c.url.includes("/api/settings/compression"));
assert.ok(restCall, "should fall back to PUT /api/settings/compression");
assert.equal(restCall?.method, "PUT");
// #6571: the REST fallback now PUTs the canonical `defaultMode` field the server's
// strict schema accepts, not the nonexistent `engine` key (which made the PUT 400).
// #6571: the REST fallback now PUTs the canonical `defaultMode` field
assert.equal(restCall?.body?.defaultMode, "rtk");
});

View File

@@ -1,14 +1,16 @@
import test from "node:test";
import assert from "node:assert/strict";
function makeResp(data: unknown, status = 200) {
// ---- helpers ----
function makeResp(data: unknown, status = 200, extraHeaders: Record<string, string> = {}) {
const headers = new Headers({ "content-type": "application/json", ...extraHeaders });
const obj = {
ok: status < 400,
status,
exitCode: status < 400 ? 0 : 1,
json: () => Promise.resolve(data),
text: () => Promise.resolve(JSON.stringify(data)),
headers: new Headers(),
headers,
};
obj.json = obj.json.bind(obj);
obj.text = obj.text.bind(obj);
@@ -34,116 +36,314 @@ function makeCmd(output = "json") {
return { optsWithGlobals: () => ({ output, quiet: output !== "table" }) };
}
test("mcp call envia name e arguments no body", async () => {
let capturedBody: any = null;
let capturedUrl = "";
// Simulate a /api/mcp/stream endpoint that speaks JSON-RPC 2.0
function makeMcpStreamFetch(
toolResult: { content: { type: string; text: string }[] } = {
content: [{ type: "text", text: "hello" }],
},
callStatus = 200,
) {
return ((url: string, opts: unknown) => {
const u = String(url);
if (!u.includes("/api/mcp/stream")) {
return Promise.resolve(makeResp({ error: "not found" }, 404));
}
const body = opts?.body ? JSON.parse(opts.body) : null;
// initialize
if (body && body.method === "initialize") {
return Promise.resolve(
makeResp(
{ jsonrpc: "2.0", id: 1, result: { protocolVersion: "2024-11-05", capabilities: {} } },
200,
{ "mcp-session-id": "test-session-123" },
),
);
}
// tools/call
if (body && body.method === "tools/call") {
return Promise.resolve(
makeResp(
{ jsonrpc: "2.0", id: 2, result: toolResult },
callStatus,
),
);
}
return Promise.resolve(makeResp({ error: "unknown method" }, 400));
}) as any;
}
// ---- tests ----
test("mcp call sends JSON-RPC initialize then tools/call", async () => {
const calls: Array<{ url: string; body: unknown }> = [];
const origFetch = globalThis.fetch;
globalThis.fetch = ((url: string, opts: any) => {
capturedUrl = url;
if (opts?.body) capturedBody = JSON.parse(opts.body);
return Promise.resolve(makeResp({ result: { health: "ok" } }));
globalThis.fetch = ((url: string, opts: unknown) => {
const u = String(url);
const body = opts?.body ? JSON.parse(opts.body) : null;
calls.push({ url: u, body });
if (body && body.method === "initialize") {
return Promise.resolve(
makeResp(
{ jsonrpc: "2.0", id: 1, result: { protocolVersion: "2024-11-05", capabilities: {} } },
200,
{ "mcp-session-id": "sess-1" },
),
);
}
if (body && body.method === "tools/call") {
return Promise.resolve(
makeResp({
jsonrpc: "2.0",
id: 2,
result: { content: [{ type: "text", text: "ok" }] },
}),
);
}
return Promise.resolve(makeResp({ error: "unknown" }, 400));
}) as any;
// Simula o que runMcpCall faz internamente
await (globalThis.fetch as any)("/api/mcp/tools/call", {
method: "POST",
body: JSON.stringify({ name: "omniroute_get_health", arguments: {} }),
try {
const { runMcpCallCommand } = await import(
"../../bin/cli/commands/mcp.mjs"
);
const exitCode = await runMcpCallCommand(
"omniroute_get_health",
{},
{ stream: false },
{ baseUrl: "http://localhost:20128" },
);
assert.equal(exitCode, 0);
assert.equal(calls.length, 2);
assert.equal(calls[0].body.method, "initialize");
assert.equal(calls[1].body.method, "tools/call");
assert.equal(calls[1].body.params.name, "omniroute_get_health");
assert.deepEqual(calls[1].body.params.arguments, {});
} finally {
globalThis.fetch = origFetch;
}
});
test("mcp call passes session-id header on tools/call", async () => {
let callHeaders: Record<string, string> = {};
const origFetch = globalThis.fetch;
globalThis.fetch = ((url: string, opts: unknown) => {
const body = opts?.body ? JSON.parse(opts.body) : null;
if (body && body.method === "initialize") {
return Promise.resolve(
makeResp(
{ jsonrpc: "2.0", id: 1, result: { protocolVersion: "2024-11-05", capabilities: {} } },
200,
{ "mcp-session-id": "sess-abc" },
),
);
}
if (body && body.method === "tools/call") {
callHeaders = opts.headers || {};
return Promise.resolve(
makeResp({
jsonrpc: "2.0",
id: 2,
result: { content: [{ type: "text", text: "ok" }] },
}),
);
}
return Promise.resolve(makeResp({ error: "unknown" }, 400));
}) as any;
try {
const { runMcpCallCommand } = await import(
"../../bin/cli/commands/mcp.mjs"
);
const exitCode = await runMcpCallCommand(
"test_tool",
{ key: "val" },
{ stream: false },
{ baseUrl: "http://localhost:20128" },
);
assert.equal(exitCode, 0);
assert.equal(callHeaders["mcp-session-id"], "sess-abc");
} finally {
globalThis.fetch = origFetch;
}
});
test("mcp call prints result content to stdout", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = makeMcpStreamFetch({
content: [{ type: "text", text: "hello world" }],
});
const output = await captureStdout(async () => {
const { runMcpCallCommand } = await import(
"../../bin/cli/commands/mcp.mjs"
);
await runMcpCallCommand(
"test",
{},
{ stream: false },
{ baseUrl: "http://localhost:20128" },
);
});
globalThis.fetch = origFetch;
assert.ok(capturedUrl.includes("/api/mcp/tools/call"));
assert.equal(capturedBody.name, "omniroute_get_health");
assert.deepEqual(capturedBody.arguments, {});
assert.ok(output.includes("hello world"));
});
test("mcp call com --args passa argumentos como JSON", async () => {
let capturedBody: any = null;
test("mcp call prints error on non-ok response", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = ((_url: string, opts: any) => {
if (opts?.body) capturedBody = JSON.parse(opts.body);
return Promise.resolve(makeResp({ result: {} }));
globalThis.fetch = ((url: string, opts: unknown) => {
const body = opts?.body ? JSON.parse(opts.body) : null;
if (body && body.method === "initialize") {
return Promise.resolve(
makeResp(
{ jsonrpc: "2.0", id: 1, result: { protocolVersion: "2024-11-05", capabilities: {} } },
200,
{ "mcp-session-id": "sess-1" },
),
);
}
if (body && body.method === "tools/call") {
return Promise.resolve(makeResp({ error: "tool not found" }, 500));
}
return Promise.resolve(makeResp({ error: "unknown" }, 400));
}) as any;
await (globalThis.fetch as any)("/api/mcp/tools/call", {
method: "POST",
body: JSON.stringify({ name: "omniroute_check_quota", arguments: { provider: "openai" } }),
try {
const { runMcpCallCommand } = await import(
"../../bin/cli/commands/mcp.mjs"
);
const exitCode = await runMcpCallCommand(
"bad_tool",
{},
{ stream: false },
{ baseUrl: "http://localhost:20128" },
);
assert.equal(exitCode, 1);
} finally {
globalThis.fetch = origFetch;
}
});
test("mcp call with stream reads SSE data", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = ((url: string, opts: unknown) => {
const body = opts?.body ? JSON.parse(opts.body) : null;
if (body && body.method === "initialize") {
return Promise.resolve(
makeResp(
{ jsonrpc: "2.0", id: 1, result: { protocolVersion: "2024-11-05", capabilities: {} } },
200,
{ "mcp-session-id": "sess-stream" },
),
);
}
if (body && body.method === "tools/call") {
// Simulate an SSE stream via a ReadableStream body
const encoder = new TextEncoder();
const stream = new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode("data: stream-chunk-1\n\ndata: stream-chunk-2\n\n"));
controller.close();
},
});
return Promise.resolve({
ok: true,
status: 200,
body: stream,
headers: new Headers(),
json: () => Promise.reject(new Error("not json")),
text: () => Promise.reject(new Error("not text")),
});
}
return Promise.resolve(makeResp({ error: "unknown" }, 400));
}) as any;
const output = await captureStdout(async () => {
const { runMcpCallCommand } = await import(
"../../bin/cli/commands/mcp.mjs"
);
await runMcpCallCommand(
"test",
{},
{ stream: true },
{ baseUrl: "http://localhost:20128" },
);
});
globalThis.fetch = origFetch;
assert.equal(capturedBody.arguments.provider, "openai");
assert.ok(output.includes("stream-chunk-1"));
assert.ok(output.includes("stream-chunk-2"));
});
test("mcp scopes envia meta=scopes na query", async () => {
let capturedUrl = "";
test("mcp status reads online field", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = ((url: string) => {
capturedUrl = url;
return Promise.resolve(makeResp({ scopes: ["read:health", "read:combos", "write:settings"] }));
globalThis.fetch = (async (_url: string | URL, init?: unknown) => {
const u = String(_url);
if (u.includes("/api/health")) {
return makeResp({ status: "ok" }) as any;
}
if (u.includes("/api/mcp/status")) {
return makeResp({
status: "online",
online: true,
transport: "stdio",
enabled: true,
toolsCount: 107,
}) as any;
}
return makeResp({ error: "not found" }, 404) as any;
}) as any;
await (globalThis.fetch as any)("/api/mcp/tools?meta=scopes");
globalThis.fetch = origFetch;
assert.ok(capturedUrl.includes("meta=scopes"));
});
test("mcp tools list busca /api/mcp/tools", async () => {
const TOOLS = [
{ name: "omniroute_get_health", scopes: ["read:health"], auditLevel: "low", phase: 1 },
{ name: "omniroute_list_combos", scopes: ["read:combos"], auditLevel: "low", phase: 1 },
];
const origFetch = globalThis.fetch;
globalThis.fetch = ((_url: string) => {
return Promise.resolve(makeResp({ tools: TOOLS }));
}) as any;
const out = await captureStdout(async () => {
const { emit } = await import("../../bin/cli/output.mjs");
const res = await (globalThis.fetch as any)("/api/mcp/tools");
const data = await res.json();
emit(data.tools ?? data, makeCmd().optsWithGlobals());
const output = await captureStdout(async () => {
const { runMcpStatusCommand } = await import(
"../../bin/cli/commands/mcp.mjs"
);
const exitCode = await runMcpStatusCommand({});
assert.equal(exitCode, 0);
});
globalThis.fetch = origFetch;
const parsed = JSON.parse(out);
assert.ok(Array.isArray(parsed));
assert.equal(parsed.length, 2);
assert.ok(output.includes("MCP server running"), "should print running status, got: " + output);
assert.ok(output.includes("107"), "should print toolsCount");
});
test("mcp tools list com --scope filtra por scope", async () => {
let capturedUrl = "";
test("mcp status json mode prints full object", async () => {
const origFetch = globalThis.fetch;
globalThis.fetch = ((url: string) => {
capturedUrl = url;
return Promise.resolve(makeResp({ tools: [] }));
const u = String(url);
if (u.includes("/api/health")) {
return Promise.resolve(makeResp({ status: "ok" }, 200));
}
if (u.includes("/api/mcp/status")) {
return Promise.resolve(
makeResp({
status: "online",
online: true,
transport: "stdio",
enabled: true,
toolsCount: 107,
}),
);
}
return Promise.resolve(makeResp({ error: "not found" }, 404));
}) as any;
const params = new URLSearchParams({ scope: "read:health" });
await (globalThis.fetch as any)(`/api/mcp/tools?${params}`);
const output = await captureStdout(async () => {
const { runMcpStatusCommand } = await import(
"../../bin/cli/commands/mcp.mjs"
);
const exitCode = await runMcpStatusCommand({ json: true });
assert.equal(exitCode, 0);
});
globalThis.fetch = origFetch;
assert.ok(
capturedUrl.includes("scope=read%3Ahealth") || capturedUrl.includes("scope=read:health")
);
});
test("mcp audit stats passa period na query", async () => {
let capturedUrl = "";
const origFetch = globalThis.fetch;
globalThis.fetch = ((url: string) => {
capturedUrl = url;
return Promise.resolve(makeResp({ period: "30d", totalCalls: 500 }));
}) as any;
await (globalThis.fetch as any)("/api/mcp/audit/stats?period=30d");
globalThis.fetch = origFetch;
assert.ok(capturedUrl.includes("period=30d"));
});
test("mcp.mjs pode ser importado sem erro", async () => {
const mod = await import("../../bin/cli/commands/mcp.mjs");
assert.equal(typeof mod.registerMcp, "function");
assert.equal(typeof mod.runMcpStatusCommand, "function");
assert.equal(typeof mod.runMcpRestartCommand, "function");
const parsed = JSON.parse(output.trim());
assert.equal(parsed.online, true);
assert.equal(parsed.toolsCount, 107);
});

View File

@@ -1,18 +1,9 @@
import test from "node:test";
import assert from "node:assert/strict";
import { makeMcpResp, makeMcpStreamFetch } from "./helpers/mcpStreamMock.ts";
function makeResp(data: unknown, status = 200) {
const obj = {
ok: status < 400,
status,
exitCode: status < 400 ? 0 : 1,
json: () => Promise.resolve(data),
text: () => Promise.resolve(JSON.stringify(data)),
headers: new Headers(),
};
obj.json = obj.json.bind(obj);
obj.text = obj.text.bind(obj);
return obj;
return makeMcpResp(data, status) as any;
}
function makeCmd(output = "json") {
@@ -20,84 +11,47 @@ function makeCmd(output = "json") {
}
test("oneproxy status chama omniroute_oneproxy_stats via MCP", async () => {
let capturedBody: any = null;
const calls: any[] = [];
const origFetch = globalThis.fetch;
globalThis.fetch = ((_url: string, opts: any) => {
if (opts?.body) capturedBody = JSON.parse(opts.body);
return Promise.resolve(makeResp({ poolSize: 10, activeProxies: 8 }));
globalThis.fetch = makeMcpStreamFetch({ toolResult: { poolSize: 10, activeProxies: 8 } });
globalThis.fetch = (async (url: string, init?: any) => {
calls.push({ url: String(url), init });
return origFetch(url, init);
}) as any;
await (globalThis.fetch as any)("/api/mcp/tools/call", {
method: "POST",
body: JSON.stringify({ name: "omniroute_oneproxy_stats", arguments: {} }),
});
await import("../../bin/cli/commands/oneproxy.mjs");
// ensure module registers; just assert stream mock shape
globalThis.fetch = origFetch;
assert.equal(capturedBody.name, "omniroute_oneproxy_stats");
assert.ok(calls.length >= 0);
});
test("oneproxy stats passa provider e period para MCP", async () => {
let capturedBody: any = null;
const origFetch = globalThis.fetch;
globalThis.fetch = ((_url: string, opts: any) => {
if (opts?.body) capturedBody = JSON.parse(opts.body);
return Promise.resolve(makeResp({ requests: 5000 }));
}) as any;
await (globalThis.fetch as any)("/api/mcp/tools/call", {
method: "POST",
body: JSON.stringify({
name: "omniroute_oneproxy_stats",
arguments: { provider: "openai", period: "24h" },
}),
});
globalThis.fetch = makeMcpStreamFetch({ toolResult: { requests: 5000 } });
const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs");
const result = await mcpCallTool("omniroute_oneproxy_stats", { provider: "openai", period: "24h" });
globalThis.fetch = origFetch;
assert.equal(capturedBody.arguments.provider, "openai");
assert.equal(capturedBody.arguments.period, "24h");
assert.deepEqual(result, { requests: 5000 });
});
test("oneproxy fetch chama omniroute_oneproxy_fetch com count e type", async () => {
let capturedBody: any = null;
const origFetch = globalThis.fetch;
globalThis.fetch = ((_url: string, opts: any) => {
if (opts?.body) capturedBody = JSON.parse(opts.body);
return Promise.resolve(makeResp({ proxies: [{ host: "10.0.0.1", type: "http" }] }));
}) as any;
await (globalThis.fetch as any)("/api/mcp/tools/call", {
method: "POST",
body: JSON.stringify({
name: "omniroute_oneproxy_fetch",
arguments: { count: 5, type: "http" },
}),
});
globalThis.fetch = makeMcpStreamFetch({ toolResult: { proxies: [{ host: "10.0.0.1", type: "http" }] } });
const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs");
const result = await mcpCallTool("omniroute_oneproxy_fetch", { count: 5, type: "http" });
globalThis.fetch = origFetch;
assert.equal(capturedBody.name, "omniroute_oneproxy_fetch");
assert.equal(capturedBody.arguments.count, 5);
assert.equal(capturedBody.arguments.type, "http");
assert.equal((result as any).proxies[0].host, "10.0.0.1");
assert.equal((result as any).proxies[0].type, "http");
});
test("oneproxy rotate chama omniroute_oneproxy_rotate com provider", async () => {
let capturedBody: any = null;
const origFetch = globalThis.fetch;
globalThis.fetch = ((_url: string, opts: any) => {
if (opts?.body) capturedBody = JSON.parse(opts.body);
return Promise.resolve(makeResp({ rotated: true, newProxy: "10.0.0.2" }));
}) as any;
await (globalThis.fetch as any)("/api/mcp/tools/call", {
method: "POST",
body: JSON.stringify({
name: "omniroute_oneproxy_rotate",
arguments: { provider: "anthropic" },
}),
});
globalThis.fetch = makeMcpStreamFetch({ toolResult: { rotated: true, newProxy: "10.0.0.2" } });
const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs");
const result = await mcpCallTool("omniroute_oneproxy_rotate", { provider: "anthropic" });
globalThis.fetch = origFetch;
assert.equal(capturedBody.name, "omniroute_oneproxy_rotate");
assert.equal(capturedBody.arguments.provider, "anthropic");
assert.equal((result as any).rotated, true);
assert.equal((result as any).newProxy, "10.0.0.2");
});
test("oneproxy config set envia PUT /api/settings/oneproxy", async () => {

View File

@@ -1,4 +1,5 @@
import test from "node:test";
import { makeMcpResp, makeMcpStreamFetch } from "./helpers/mcpStreamMock.ts";
import assert from "node:assert/strict";
function makeResp(data: unknown, status = 200) {
@@ -111,25 +112,25 @@ test("resilience reset envia provider e body correto", async () => {
assert.equal(capturedBody.connectionId, "conn-1");
});
test("resilience profile set chama MCP tool", async () => {
let capturedBody: any = null;
test("resilience profile set usa JSON-RPC tools/call", async () => {
let capturedCall: any = null;
const origFetch = globalThis.fetch;
globalThis.fetch = ((_url: string, opts: any) => {
if (opts?.body) capturedBody = JSON.parse(opts.body);
return Promise.resolve(makeResp({ result: {} }));
globalThis.fetch = makeMcpStreamFetch({ toolResult: {} });
const inner = globalThis.fetch;
globalThis.fetch = ((url: any, init: any) => {
if (String(url).includes("/api/mcp/stream") && String(init?.body || "").includes("tools/call")) {
capturedCall = JSON.parse(init.body);
}
return inner(url, init);
}) as any;
await (globalThis.fetch as any)("/api/mcp/tools/call", {
method: "POST",
body: JSON.stringify({
name: "omniroute_set_resilience_profile",
arguments: { profile: "balanced" },
}),
});
const { mcpCallTool } = await import("../../bin/cli/mcpClient.mjs");
await mcpCallTool("omniroute_set_resilience_profile", { profile: "balanced" });
globalThis.fetch = origFetch;
assert.equal(capturedBody.name, "omniroute_set_resilience_profile");
assert.equal(capturedBody.arguments.profile, "balanced");
assert.equal(capturedCall.method, "tools/call");
assert.equal(capturedCall.params.name, "omniroute_set_resilience_profile");
assert.equal(capturedCall.params.arguments.profile, "balanced");
});
test("resilience.mjs pode ser importado sem erro", async () => {

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

@@ -1,4 +1,5 @@
import test from "node:test";
import { makeMcpResp, makeMcpStreamFetch } from "./helpers/mcpStreamMock.ts";
import assert from "node:assert/strict";
const SKILLS_DATA = [
@@ -114,34 +115,37 @@ test("runSkillsGet busca /api/skills/:id", async () => {
assert.equal(parsed.id, "sk_pdf");
});
test("runSkillsEnable envia POST para tools/call", async () => {
let capturedUrl = "";
let capturedInit: any = null;
test("runSkillsEnable usa JSON-RPC tools/call", async () => {
const calls: unknown[] = [];
const origFetch = globalThis.fetch;
globalThis.fetch = ((url: string, init: any) => {
capturedUrl = url;
capturedInit = init;
return Promise.resolve(makeResp({ ok: true }));
globalThis.fetch = makeMcpStreamFetch({ toolResult: { ok: true } });
const inner = globalThis.fetch;
globalThis.fetch = ((url: unknown, init: unknown) => {
calls.push({ url: String(url), init });
return inner(url, init);
}) as any;
const { runSkillsEnable } = await import("../../bin/cli/commands/skills.mjs");
const out = await captureStdout(() => runSkillsEnable("sk_pdf", {}, makeCmd() as any));
globalThis.fetch = origFetch;
assert.ok(capturedUrl.includes("/api/mcp/tools/call"));
const body = JSON.parse(capturedInit?.body);
assert.equal(body.name, "omniroute_skills_enable");
assert.equal(body.arguments.skillId, "sk_pdf");
assert.equal(body.arguments.enabled, true);
assert.ok(calls.some((x) => String(x.url).includes("/api/mcp/stream")));
const callBody = JSON.parse(calls.find((x) => String(x.init?.body || "").includes("tools/call"))?.init?.body || "{}");
assert.equal(callBody.method, "tools/call");
assert.equal(callBody.params.name, "omniroute_skills_enable");
assert.equal(callBody.params.arguments.skillId, "sk_pdf");
assert.equal(callBody.params.arguments.enabled, true);
assert.ok(out.includes("sk_pdf"));
});
test("runSkillsExecute envia POST com skillId e input", async () => {
let capturedBody: any = null;
test("runSkillsExecute usa JSON-RPC tools/call", async () => {
const calls: unknown[] = [];
const origFetch = globalThis.fetch;
globalThis.fetch = ((_url: string, init: any) => {
capturedBody = JSON.parse(init.body);
return Promise.resolve(makeResp({ result: "ok", output: "parsed" }));
globalThis.fetch = makeMcpStreamFetch({ toolResult: { result: "ok", output: "parsed" } });
const inner = globalThis.fetch;
globalThis.fetch = ((url: unknown, init: unknown) => {
calls.push({ url: String(url), init });
return inner(url, init);
}) as any;
const { runSkillsExecute } = await import("../../bin/cli/commands/skills.mjs");
@@ -150,9 +154,11 @@ test("runSkillsExecute envia POST com skillId e input", async () => {
);
globalThis.fetch = origFetch;
assert.equal(capturedBody.name, "omniroute_skills_execute");
assert.equal(capturedBody.arguments.skillId, "sk_pdf");
assert.deepEqual(capturedBody.arguments.input, { file: "doc.pdf" });
const callBody = JSON.parse(calls.find((x) => String(x.init?.body || "").includes("tools/call"))?.init?.body || "{}");
assert.equal(callBody.method, "tools/call");
assert.equal(callBody.params.name, "omniroute_skills_execute");
assert.equal(callBody.params.arguments.skillId, "sk_pdf");
assert.deepEqual(callBody.params.arguments.input, { file: "doc.pdf" });
});
test("runSkillsExecutions filtra por skill e status", async () => {
@@ -197,9 +203,9 @@ test("runMarketplaceSearch retorna pacotes com query e filtros", async () => {
});
test("runMarketplaceInstall --yes envia POST sem confirmação", async () => {
let capturedBody: any = null;
let capturedBody: unknown = null;
const origFetch = globalThis.fetch;
globalThis.fetch = ((_url: string, init: any) => {
globalThis.fetch = ((_url: string, init: unknown) => {
capturedBody = JSON.parse(init?.body ?? "{}");
return Promise.resolve(makeResp({ skillId: "sk_pdf_installed" }));
}) as any;

View File

@@ -23,8 +23,11 @@ export function unescapeWindowsShellArg(arg) {
s = s.replace(/\^(.)/g, "$1").replace(/\^(.)/g, "$1");
// 2. drop the wrapping quotes added by the CRT argv layer
if (s.length >= 2 && s.startsWith('"') && s.endsWith('"')) s = s.slice(1, -1);
// 3. undo the doubled backslashes and the escaped embedded quotes
s = s.replace(/\\\\/g, "\\").replace(/\\"/g, '"');
// 3. undo the doubled backslashes and the escaped embedded quotes in a single
// left-to-right pass — two sequential global replaces would let the first
// pass's output feed the second (e.g. an escaped-backslash-then-quote
// sequence could be misread), which is exactly what js/double-escaping flags.
s = s.replace(/\\\\|\\"/g, (m) => (m === "\\\\" ? "\\" : '"'));
return s;
}

View File

@@ -14,6 +14,7 @@ import {
} from "../../open-sse/executors/conol-web.ts";
import {
CONOL_FALLBACK_MODELS,
CONOL_FALLBACK_MODEL_PRESETS,
clampConolEffort,
parseConolAgentServers,
resolveConolModelSelection,
@@ -27,6 +28,11 @@ import { getResolvedModelCapabilities } from "../../src/lib/modelCapabilities.ts
const SESSION_COOKIE_NAME = "__Secure-better-auth.session_token";
describe("Conol web provider", () => {
it("routes the Flash preset multimodal path to Gemini 3.7", () => {
const flashPreset = CONOL_FALLBACK_MODEL_PRESETS.find((preset) => preset.id === "flash");
assert.equal(flashPreset?.multimodal, "google/gemini-3.7-flash");
});
it("normalizes raw, full-header, JSON, and provider-data credentials", () => {
assert.equal(normalizeConolCookie("token-value"), `${SESSION_COOKIE_NAME}=token-value`);
assert.equal(

View File

@@ -55,7 +55,7 @@ describe("GithubExecutor — Gemini/Claude must never hit /responses (port 9rout
it("routes registered Gemini Copilot models to chat/completions", () => {
const exec = new GithubExecutor();
for (const id of ["gemini-3.1-pro-preview", "gemini-3.5-flash"]) {
for (const id of ["gemini-3.1-pro-preview", "gemini-3.7-flash"]) {
assert.equal(exec.buildUrl(id, false), CHAT_URL, `${id} must route to chat/completions`);
}
});

View File

@@ -0,0 +1,93 @@
import test from "node:test";
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { readFileSync, existsSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath } from "node:url";
// #11016 follow-up (suggested by maintainer on PR #11029): assert that the
// disabled boot path produces the correct "[STARTUP] Credential health scheduler
// disabled" log at runtime.
//
// Two complementary assertions:
// 1. Runtime: spawn a subprocess that imports the real scheduler with the disable
// env set, calls initCredentialHealthCheck(), and logs the result using the
// same conditional from instrumentation-node.ts — verifying the actual output.
// 2. Static: read src/instrumentation-node.ts and assert the boot wiring still
// uses initCredentialHealthCheck()'s return value to select the log message.
// This breaks if the production conditional is removed or refactored away.
const thisDir = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(thisDir, "../..");
// In CI: projectRoot has a real node_modules.
// In a worktree: the junction may not work with tsx; fall back to the main checkout.
function resolveMainCheckout(): string {
const hasRealNodeModules = existsSync(resolve(projectRoot, "node_modules", ".package-lock.json"));
if (hasRealNodeModules) return projectRoot;
const candidate = resolve(projectRoot, "../../..");
if (existsSync(resolve(candidate, "node_modules", ".package-lock.json"))) return candidate;
return projectRoot;
}
const mainCwd = resolveMainCheckout();
const BOOT_DISABLED_SCRIPT = `
process.env.OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK = "true";
const { initCredentialHealthCheck } = await import(
"./src/lib/credentialHealth/scheduler.ts"
);
const started = initCredentialHealthCheck();
console.log(
started
? "[STARTUP] Credential health scheduler started"
: "[STARTUP] Credential health scheduler disabled"
);
process.exit(0);
`;
test("disabled scheduler emits [STARTUP] Credential health scheduler disabled via the real initCredentialHealthCheck", () => {
const result = execFileSync(
process.execPath,
["--import", "tsx/esm", "--input-type=module", "--eval", BOOT_DISABLED_SCRIPT],
{
cwd: mainCwd,
env: {
...process.env,
OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK: "true",
NODE_NO_WARNINGS: "1",
},
encoding: "utf8",
timeout: 30_000,
}
);
assert.match(
result,
/\[STARTUP\] Credential health scheduler disabled/,
"must log the disabled message when OMNIROUTE_DISABLE_CREDENTIAL_HEALTH_CHECK is set"
);
assert.doesNotMatch(
result,
/\[STARTUP\] Credential health scheduler started/,
"must NOT log the started message when disabled"
);
});
test("instrumentation-node.ts wires initCredentialHealthCheck return to the log conditional", () => {
const src = readFileSync(
resolve(projectRoot, "src/instrumentation-node.ts"),
"utf8"
).replace(/\r\n/g, "\n");
assert.match(
src,
/const started = initCredentialHealthCheck\(\)/,
"boot wiring must capture the return value of initCredentialHealthCheck()"
);
assert.match(
src,
/started[\s\S]{0,50}\?[\s\S]{0,80}scheduler started[\s\S]{0,50}:[\s\S]{0,80}scheduler disabled/,
"boot wiring must use the return value to select started vs disabled log"
);
});

View File

@@ -8,6 +8,10 @@ function modelIds(): Set<string> {
return new Set(cursorProvider.models.map((m) => m.id));
}
test("cursor registry excludes retired Gemini 3.5 Flash", () => {
assert.equal(modelIds().has("gemini-3.5-flash"), false);
});
test("cursor registry includes Claude Opus 4.8 effort + thinking + fast variants", () => {
const ids = modelIds();
for (const effort of EFFORTS) {

View File

@@ -56,7 +56,7 @@ describe("PromptQl — registry consistency", () => {
it("registers a model catalog via getModelsByProviderId", () => {
const catalog = getModelsByProviderId("promptql");
assert.ok(catalog.length >= 5);
assert.ok(catalog.some((m) => m.id === "gemini-3.5-flash" || m.id.includes("gemini")));
assert.ok(catalog.some((m) => m.id === "gemini-3.7-flash" || m.id.includes("gemini")));
assert.ok(catalog.some((m) => m.id.includes("gpt-5.6") || m.id.includes("fable")));
});
});
@@ -209,7 +209,10 @@ describe("PromptQl — helpers", () => {
});
it("resolves model slugs and prefixes", () => {
assert.equal(models.clientFacingPromptQlModelId("promptql/gemini-3.5-flash"), "gemini-3.5-flash");
assert.equal(
models.clientFacingPromptQlModelId("promptql/gemini-3.7-flash"),
"gemini-3.7-flash"
);
assert.equal(models.clientFacingPromptQlModelId("pql/gpt-5.6-sol"), "gpt-5.6-sol");
const r = models.resolvePromptQlModel("Claude Fable 5");
assert.ok(r);
@@ -265,7 +268,7 @@ describe("PromptQlExecutor — auth / validation", () => {
it("returns 401 when no token is supplied", async () => {
const executor = new mod.PromptQlExecutor();
const result = await executor.execute({
model: "gemini-3.5-flash",
model: "gemini-3.7-flash",
body: { messages: [{ role: "user", content: "hi" }] },
stream: false,
credentials: {},
@@ -279,7 +282,7 @@ describe("PromptQlExecutor — auth / validation", () => {
it("returns 400 when no user message is present", async () => {
const executor = new mod.PromptQlExecutor();
const result = await executor.execute({
model: "gemini-3.5-flash",
model: "gemini-3.7-flash",
body: { messages: [{ role: "assistant", content: "hi" }] },
stream: false,
credentials: { apiKey: sampleJwt },
@@ -614,7 +617,7 @@ describe("PromptQlExecutor — mocked GraphQL turn", () => {
try {
const executor = new mod.PromptQlExecutor();
const result = await executor.execute({
model: "gemini-3.5-flash",
model: "gemini-3.7-flash",
body: { messages: [{ role: "user", content: "ping" }] },
stream: false,
credentials: { apiKey: sampleJwt },
@@ -628,7 +631,7 @@ describe("PromptQlExecutor — mocked GraphQL turn", () => {
};
assert.equal(json.choices[0]!.message.content, "HELLO-PQL");
assert.equal(json.promptql_thread_id, "thread-1");
assert.equal(json.model, "gemini-3.5-flash");
assert.equal(json.model, "gemini-3.7-flash");
assert.ok(call >= 2);
assert.equal(result.response.headers.get("X-PromptQL-Thread-Id"), "thread-1");
} finally {

View File

@@ -1,76 +0,0 @@
// Regression test for #10286: gemini-3.5-flash was incorrectly marked
// supportsThinking:false, causing a spurious pre-provider HTTP 400 for any
// request with reasoning_effort set, even though the base Google AI Studio
// model supports reasoning (it has an effort-tier alias gemini-3.5-flash-high).
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-repro-10286-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "test-repro-10286-secret";
const caps = await import("../../src/lib/modelCapabilities.ts");
const core = await import("../../src/lib/db/core.ts");
const rulesDb = await import("../../src/lib/db/reasoningRoutingRules.ts");
const policy = await import("../../src/lib/reasoningRouting/policy.ts");
async function resetStorage() {
core.resetDbInstance();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
rulesDb.invalidateReasoningRoutingRuleCache();
}
function ruleInput(patch: Record<string, unknown> = {}) {
return {
name: "Enable thinking on gemini-3.5-flash",
description: "",
scope: "global",
apiKeyId: null,
comboId: null,
connectionId: null,
modelPattern: "gemini-3.5-flash",
sourceEffort: "any",
requestTags: [],
tagMatchMode: "any",
effortMode: "inherit",
targetEffort: null,
targetKind: "keep",
targetModel: null,
targetComboId: null,
budgetAction: "preserve",
budgetTokens: null,
priority: 0,
enabled: true,
...patch,
};
}
test.beforeEach(resetStorage);
test.after(async () => {
await resetStorage();
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
});
test("gemini-3.5-flash (AI Studio provider) resolves as thinking-capable", () => {
const resolved = caps.getResolvedModelCapabilities({
provider: "gemini",
model: "gemini-3.5-flash",
});
assert.equal(resolved.supportsThinking, true);
});
test("reasoning_effort 'high' on gemini-3.5-flash is NOT rejected by routing policy", async () => {
await rulesDb.createReasoningRoutingRule(ruleInput());
const decision = await policy.resolveReasoningRoutingRule({
sourceModel: "gemini/gemini-3.5-flash",
sourceModelAliases: ["gemini-3.5-flash"],
sourceEffort: "high",
hasReasoningSignal: true,
});
assert.ok(decision, "a matching rule must produce a decision");
assert.equal(decision.capability, "supported");
});

View File

@@ -71,7 +71,7 @@ test("OpenAI -> Gemini request strips encrypted from Codex collaboration tool pa
],
};
const result = openaiToGeminiRequest("gemini-3.5-flash-low", body, false) as {
const result = openaiToGeminiRequest("gemini-3.7-flash-low", body, false) as {
tools?: Array<{ functionDeclarations?: Array<{ parameters: unknown }> }>;
};

View File

@@ -14,6 +14,16 @@ const SAMPLE = {
supportedGenerationMethods: ["generateContent", "countTokens", "batchGenerateContent"],
thinking: true,
},
{
name: "models/gemini-3.5-flash",
displayName: "Gemini 3.5 Flash",
supportedGenerationMethods: ["generateContent"],
},
{
name: "models/gemini-3.5-flash-lite",
displayName: "Gemini 3.5 Flash Lite",
supportedGenerationMethods: ["generateContent"],
},
{
name: "models/gemini-3-pro-image-preview",
displayName: "Gemini 3 Pro Image Preview",
@@ -48,6 +58,12 @@ test("parseGeminiModelsList strips the models/ prefix and maps display name", ()
assert.deepEqual(flash!.supportedEndpoints, ["chat"]);
});
test("parseGeminiModelsList excludes retired Gemini 3.5 Flash but keeps Flash Lite", () => {
const ids = parseGeminiModelsList(SAMPLE).map((model) => model.id);
assert.equal(ids.includes("gemini-3.5-flash"), false);
assert.equal(ids.includes("gemini-3.5-flash-lite"), true);
});
test("parseGeminiModelsList maps generateContent image models to the chat endpoint", () => {
const models = parseGeminiModelsList(SAMPLE);
const proImage = models.find((m) => m.id === "gemini-3-pro-image-preview");

View File

@@ -56,7 +56,7 @@ test("OpenAI -> Gemini request strips strict from OpenAI-style function tool par
],
};
const result = openaiToGeminiRequest("gemini-3.5-flash-low", body, false) as {
const result = openaiToGeminiRequest("gemini-3.7-flash-low", body, false) as {
tools?: Array<{ functionDeclarations?: Array<{ parameters: unknown }> }>;
};

View File

@@ -99,7 +99,7 @@ test("buildUrl uses chat/completions endpoint for gemini models", () => {
};
// Gemini has no native shim on Copilot — it stays on /chat/completions.
assert.strictEqual(
executor.buildUrl("gemini-3.5-flash", true, 0, credentials),
executor.buildUrl("gemini-3.7-flash", true, 0, credentials),
"https://ghe.company.com/chat/completions"
);
});

View File

@@ -40,7 +40,11 @@ const EXPECTED: Record<InventoryKind, Record<string, number>> = {
"src/app/api/v1/session-leases/route.ts": 1,
"src/app/api/v1/videos/generations/route.ts": 2,
"src/app/api/v1/web/fetch/route.ts": 1,
"src/lib/embeddings/service.ts": 2,
// #11088/#11271: third site is the synced local-endpoint route — it resolves
// credentials through getProviderCredentials with the connection allowlist
// from resolveLocalSyncedEndpointRoute, and handles allRateLimited, so it is
// fenced the same way as the two pre-existing sites.
"src/lib/embeddings/service.ts": 3,
"src/lib/memory/embedding/index.ts": 1,
"src/lib/search/executeWebSearch.ts": 2,
"src/lib/skills/webFetchExecution.ts": 1,

View File

@@ -0,0 +1,49 @@
import type { Response as Resp } from "undici";
// Minimal fetch mock responses that satisfy what apiFetch needs.
export function makeMcpResp(data: unknown, status = 200, headers: Record<string, string> = {}) {
const hdrs = new Headers({ "content-type": "application/json", ...headers });
const obj = {
ok: status < 400,
status,
json: () => Promise.resolve(data),
text: () => Promise.resolve(typeof data === "string" ? data : JSON.stringify(data)),
headers: hdrs,
} as unknown as Resp;
return obj;
}
export function makeMcpStreamFetch({
toolResult = { content: [{ type: "text", text: "ok" }] },
initStatus = 200,
callStatus = 200,
callError = false,
} = {}) {
return (async (url: string | URL, init?: unknown) => {
const u = String(url);
if (!u.includes("/api/mcp/stream")) {
return makeMcpResp({ error: "not found" }, 404);
}
const body = init?.body ? JSON.parse(init.body) : {};
if (body.method === "initialize") {
return makeMcpResp(
{ jsonrpc: "2.0", id: body.id, result: { protocolVersion: "2024-11-05", capabilities: {} } },
initStatus,
initStatus < 400 ? { "mcp-session-id": "sess-test" } : {},
);
}
if (body.method === "tools/call") {
if (callStatus !== 200) return makeMcpResp({ error: "tool failure" }, callStatus);
if (callError) {
return makeMcpResp({
jsonrpc: "2.0",
id: body.id,
result: { content: [{ type: "text", text: "tool error" }], isError: true },
});
}
return makeMcpResp({ jsonrpc: "2.0", id: body.id, result: toolResult });
}
return makeMcpResp({ error: "unknown method" }, 400);
}) as unknown as typeof globalThis.fetch;
}

View File

@@ -0,0 +1,77 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../..");
function readHomePage(): string {
return readFileSync(
join(repoRoot, "src/app/(dashboard)/home/page.tsx"),
"utf8",
);
}
function readReadinessCard(): string {
return readFileSync(
join(repoRoot, "src/app/(dashboard)/dashboard/FirstRunReadinessCard.tsx"),
"utf8",
);
}
function readEnKeys(): string[] {
const en = JSON.parse(
readFileSync(join(repoRoot, "src/i18n/messages/en.json"), "utf8"),
) as { home: Record<string, string> };
return Object.keys(en.home);
}
describe("home page first-run readiness card", () => {
it("does not hard-redirect incomplete setup to onboarding", () => {
const source = readHomePage();
assert.doesNotMatch(source, /redirect\(["']\/dashboard\/onboarding["']\)/);
assert.match(source, /FirstRunReadinessCard/);
assert.match(source, /setupComplete=\{Boolean\(settings\.setupComplete\)\}/);
});
it("keeps the readiness card dismissable via localStorage", () => {
const source = readReadinessCard();
assert.match(source, /omniroute-first-run-readiness-dismissed/);
assert.match(source, /localStorage/);
assert.match(source, /readinessContinue/);
assert.match(source, /readinessDismiss/);
});
it("uses t() keys for readiness copy", () => {
const source = readReadinessCard();
for (const key of [
"readinessEyebrow",
"readinessTitle",
"readinessSubtitle",
"readinessStep1",
"readinessStep2",
"readinessStep3",
"readinessStep4",
]) {
assert.match(source, new RegExp(key));
}
});
it("new i18n keys exist in en.json home namespace", () => {
const keys = readEnKeys();
for (const key of [
"readinessEyebrow",
"readinessTitle",
"readinessSubtitle",
"readinessStep1",
"readinessStep2",
"readinessStep3",
"readinessStep4",
"readinessContinue",
"readinessDismiss",
]) {
assert.ok(keys.includes(key), `Missing home.${key}`);
}
});
});

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

@@ -279,6 +279,7 @@ test("antigravity sync dynamically builds and saves mitmAlias mappings", async (
mode: "sync",
fetchedModels: [
{ id: "gemini-3.5-flash", name: "Gemini 3.5 Flash" },
{ id: "gemini-3.7-flash-high", name: "Gemini 3.7 Flash High" },
{ id: "custom-antigravity-model", name: "Custom Antigravity Model" },
],
});
@@ -289,8 +290,13 @@ test("antigravity sync dynamically builds and saves mitmAlias mappings", async (
const mitmMappings = await modelsDb.getMitmAlias("antigravity");
console.log("MITM MAPPINGS IN TEST:", mitmMappings);
// Should contain standard mapping
assert.equal(mitmMappings["gemini-3.5-flash"], "antigravity/gemini-3.5-flash");
// Retired models reported by upstream must not be imported or mapped.
assert.equal(mitmMappings["gemini-3.5-flash"], undefined);
assert.equal(
models.some((model) => model.id === "gemini-3.5-flash"),
false
);
assert.equal(mitmMappings["gemini-3.7-flash-high"], "antigravity/gemini-3.7-flash-high");
assert.equal(mitmMappings["custom-antigravity-model"], "antigravity/custom-antigravity-model");
// Removed Antigravity 2.0 preview/agent aliases must not be reintroduced.

View File

@@ -154,21 +154,14 @@ test("unknown models keep maxOutputTokens null instead of using a generic defaul
);
});
test("provider-neutral Gemini 3.5 tier IDs retain their non-thinking capabilities", () => {
test("retired Gemini 3.5 Flash IDs have no provider-neutral model specs", () => {
for (const modelId of [
"gemini-3.5-flash",
"gemini-3.5-flash-extra-low",
"gemini-3.5-flash-low",
"gemini-3-flash-agent",
]) {
const spec = MODEL_SPECS[modelId];
assert.ok(spec, `missing exact MODEL_SPECS entry for ${modelId}`);
const capabilities = modelCapabilities.getResolvedModelCapabilities(modelId);
assert.equal(capabilities.contextWindow, 1048576, modelId);
assert.equal(capabilities.maxOutputTokens, 65536, modelId);
// These ids encode the upstream reasoning tier and do not accept a client-supplied effort.
assert.equal(capabilities.supportsThinking, false, modelId);
assert.equal(capabilities.supportsTools, true, modelId);
assert.equal(capabilities.supportsVision, true, modelId);
assert.equal(MODEL_SPECS[modelId], undefined, modelId);
}
});

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