Compare commits

..

24 Commits

Author SHA1 Message Date
Diego Rodrigues de Sa e Souza
3192eb88d5 fix(providers): unify learned and declared reasoning-effort clamps on nearest-tier semantics (#11295) (#11305)
Merging --admin with red discrimination (merge-gates §4). 5/6 failing tests are the compression/i18n-vi/kiro/memory mini-cluster fixed by #11306 (this branch predates it). The 6th (token-health-check-kimi, 'jittered expiration window') is a timing-sensitive test — reproduces GREEN locally, name itself indicates randomized jitter, not a deterministic regression from this PR's reasoning-effort clamp change (zero file overlap). ESLint warnings + dast-smoke (advisory, isRequired:null) also inherited.
2026-08-23 22:17:12 -03:00
Diego Rodrigues de Sa e Souza
8969526437 fix(tests): drain compression/kiro/memory/i18n base-red mini-cluster (2026-08-24) (#11306)
Merging --admin: this is itself the base-red drain PR (compression/i18n-vi/i18n-pt-br/kiro/memory mini-cluster). Remaining fails (ESLint warnings ratchet drift, dast-smoke advisory) are not blocking; isRequired:null on dast-smoke.
2026-08-23 22:17:09 -03:00
Diego Rodrigues de Sa e Souza
5518916725 fix(chat): detect severity-classifier format in claudeClassifierCompat short-circuit (#11289) (#11304)
Validated on a 4-PR combined board: claude-classifier-compat 15/15 + 8189-classifier-compat-auto-narrow 5/5 sibling sweep, typecheck:core clean, gates within baseline. Detects the newer Claude Code severity-classifier request shape (stop_sequences carrying </severity>) and replies with <severity>0</severity> instead of the legacy <block>no</block> — the mismatch was failing both classifier stages closed and blocking gated tool calls (Bash, WebSearch). Closes #11289.
2026-08-23 21:34:54 -03:00
Diego Rodrigues de Sa e Souza
3daa455e1f fix(memory): avoid rejected mid-conversation system injection on Claude when preceding turn isn't a tool result (#11290) (#11303)
Validated on a 4-PR combined board: memory-cache-safe-injection + memory-glm-injection + chatcore-memory-skills-injection 39/39 (node:test), injection.test.ts 23/23 (vitest), typecheck:core clean, check:cycles clean, gates within baseline. Narrows the #3890 mid-array system-splice skip to the exact shape that 400s on Claude Opus 5 (splice point not preceded by a server tool result) instead of disabling the cache-safe optimization for every Claude request — preserves the fast path everywhere it's actually safe. Closes #11290.
2026-08-23 21:34:50 -03:00
Diego Rodrigues de Sa e Souza
d282ec7ad6 fix(providers): don't silently enable rate-limit protection on PATCH unless persisted (#11278) (#11302)
Validated on a 4-PR combined board: provider-patch-ratelimit-protection-11278 2/2 + 56/56 sibling suites, typecheck:core clean, gates within baseline. PUT /api/providers/[id] can no longer silently enable rate-limit protection just because EditConnectionModal sends rateLimitOverrides on every save — the runtime toggle now strictly follows the persisted DB row. Closes #11278.
2026-08-23 21:34:46 -03:00
Diego Rodrigues de Sa e Souza
1e81e521c0 fix(opencode-plugin): track active release branch in CI + align combo-id fixture (#11301)
Validated on a 4-PR combined board: @omniroute/opencode-plugin provider.test.ts 13/13, full plugin suite 343/343, YAML validated, typecheck:core clean, gates within baseline. CI branch trigger was pinned to release/v3.8.2 since creation (never fired mid-cycle) — now tracks [main, release/**] matching every other workflow's pattern; combo-id fixture aligned to the real #10821 unprefixed-lookup contract (RED 11/13 → GREEN 13/13).
2026-08-23 21:34:42 -03:00
Óscar Fonseca
a3c3117254 fix(translator): preserve omitted subagent session IDs (#11297)
Validated on a 3-PR combined board: openai-responses-opencode-subagent-sessionid within the board's 20/20, typecheck:core + check:open-sse-typecheck clean, gates within baseline. Uses null as the omission sentinel for OpenCode's optional default-less subagent.sessionID under Responses/Codex strict mode (which required every declared property) — models can now omit the field to spawn a new child instead of inventing filler IDs; the null is stripped before OpenCode sees the call, real child IDs pass through unchanged. Thank you @ofonseca-pyming!
2026-08-23 21:03:16 -03:00
Ravi Tharuma
d9a883ec53 docs(openapi): declare spec endpoint management auth (#11299)
Validated on a 3-PR combined board: openapi-security-tiers 1/1 within the board's 20/20, typecheck:core clean, gates within baseline. Declares GET /api/openapi/spec's real ManagementSessionAuth contract (conditional on requireLogin) — no runtime behavior change, just the doc catching up. Closes #10851. Thank you @RaviTharuma!
2026-08-23 21:03:11 -03:00
Ravi Tharuma
7913447bf0 docs(db): align cache guide with runtime settings (#11018) (#11294)
Validated on a 3-PR combined board: 11018-database-cache-docs 1/1 within the board's 20/20 focused suite, typecheck:core clean, gates within baseline. DATABASE_GUIDE.md now cites the real 64 MiB runtime default and the 1..1,000,000 KiB range, locked with a regression test. Thank you @RaviTharuma!
2026-08-23 21:03:07 -03:00
Diego Rodrigues de Sa e Souza
67fba53190 fix(tests): drain base-red cluster from 2026-08-23 merges (#9985) (#11280)
Validated on a 2-PR combined board: 158/158 across the drain's 17 focused test files (incl. integration qdrant-routes), typecheck:core + dashboard-typecheck clean, env-doc-sync, mutation-test-coverage, cli-i18n, file-size, changelog-integrity all green. Every red discriminated per-item as stale-test (contract intentionally moved, citing the origin PR) or real bug (fixed, never masked) across 3 base-advance waves (#9985): stryker registration, i18n gaps (health.* namespace mismatch + pt-BR parity), combo-token-window pins (872K), CLI catalog counts (prime-agent), the isComboStep single-target reasoning-transport-fallback regression, container-guard hermeticity, and 26 unsuppressed ESLint errors from the wave (typed qdrant adapter, import trim, justified disable with precedent). Drains the accumulated base-red cluster.
2026-08-23 20:32:46 -03:00
Diego Rodrigues de Sa e Souza
9cb91dee74 fix(security): close round-3 advisories (v7g9, x7vm, j7j4, jcm5) + exposure warning (wmgv) (#11261)
Validated on a 2-PR combined board: routeGuard 36/36 (within the 68/68 focused-file total), a2a-task-owner-idor 7/7, a2a-tasks-auth, search-baseurl-ssrf-guard, cli-serve-hostname, spawn-capable-prefixes-client-safe all green, typecheck:core + dashboard-typecheck clean, gates within baseline. Five real High-severity advisories fixed with TDD (each failing-then-passing): settings export/import-json ALWAYS_PROTECTED completion, MITM route LOCAL_ONLY+SPAWN_CAPABLE gating, search baseUrl SSRF/IMDS guard, A2A REST task auth+ownership (previously none at all), and the loud boot exposure warning. GHSA-cjv9 confirmed already closed on this base (verified). Round 3 of the advisory sweep.
2026-08-23 20:32:42 -03:00
ignamiranda
92f58603f9 Beginner UX: purpose-first Traffic Inspector header (#11283)
Validated on a 3-PR combined board: traffic-inspector-beginner-header suite green within the board's 30/30, typecheck:core + dashboard-typecheck clean, gates within baseline. Purpose-first orientation header for Traffic Inspector, existing inspection UI untouched. Thank you @ignamiranda!
2026-08-23 19:08:27 -03:00
ignamiranda
7cec8e32fd Beginner UX: Essentials sidebar preset (#11286)
Validated on a 3-PR combined board: sidebar-customization + sidebar-essentials-static 30/30 combined (across the board's 4 focused files), typecheck:core + dashboard-typecheck clean, gates within baseline. New Essentials sidebar preset gives first-time users a short beginner path while advanced tools stay reachable via Command Palette search. Thank you @ignamiranda!
2026-08-23 19:08:23 -03:00
engenhariaandrereis01-ai
d137368fb5 fix(oauth): stop overwriting Kiro connections that share a profile ARN (#10815) (#11287)
Validated on a 3-PR combined board: kiro-connection-identity 8/8 (written failing-first — 3 new cases red on the pristine release/v3.8.50 tip, green with this change), typecheck:core + dashboard-typecheck clean, all static gates within baseline. Root cause is exactly right: a CodeWhisperer profile ARN identifies the profile, not the account, and distinct Builder ID accounts via social login can share one — the ARN is now trusted only alongside a non-contradicting account-level identifier (email/clientId). Closes #10815. Thank you @engenhariaandrereis01-ai!
2026-08-23 19:08:18 -03:00
Diego Rodrigues de Sa e Souza
2904cf849d fix(security): clear new CodeQL code-scanning alerts (round 4) (#11293)
- open-sse/executors/github.ts: replace the Math.random() fallback in
  the Copilot correlation-id generators (x-request-id,
  x-interaction-id, x-client-session-id, x-agent-task-id) with a
  CSPRNG-backed randomIdFallback() (node:crypto randomBytes) — closes
  js/insecure-randomness with no behavior change (crypto.randomUUID
  stays the primary path).
- tests/unit/cli/_helpers/shellArgs.mjs: collapse the two sequential
  global .replace() unescape passes into a single left-to-right regex
  replace with alternation — closes js/double-escaping. The prior
  two-pass form let the first pass's output feed the second, which is
  exactly the double-(un)escaping bug pattern the query flags (e.g. an
  escaped-backslash-then-quote sequence could be misread depending on
  pass order).

Co-authored-by: Markus Hartung <mail@hartmark.se>
2026-08-23 19:06:53 -03:00
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
148 changed files with 4460 additions and 1067 deletions

View File

@@ -2,11 +2,11 @@ name: opencode-plugin CI
on:
push:
branches: [main, release/v3.8.2]
branches: [main, "release/**"]
paths:
- "@omniroute/opencode-plugin/**"
pull_request:
branches: [main, release/v3.8.2]
branches: [main, "release/**"]
paths:
- "@omniroute/opencode-plugin/**"
types: [opened, synchronize, reopened, ready_for_review]

View File

@@ -104,7 +104,10 @@ test("models: extracts apiKey from ctx.auth (type=api) and calls fetcher with it
// #6859: dynamic-hook catalog keys use the unprefixed omnirouteProviderId
// ("omniroute"), not the OC-gate-prefixed hook.id ("opencode-omniroute") —
// that prefix must never leak into anything OmniRoute's server parses.
assert.ok(out["omniroute/claude-primary"]);
// #10345/#10821: bare combo ids (owned_by: "combo") stay unprefixed —
// OpenCode looks up `-m <plugin>/<combo>` as model id `<combo>` under the
// plugin provider, so `claude-primary` here carries no provider prefix.
assert.ok(out["claude-primary"]);
});
test("models: returns {} when ctx.auth is null/undefined/wrong-type/empty-key", async () => {
@@ -159,11 +162,15 @@ test("models: maps a sample /v1/models entry to ModelV2 (sanity)", async () => {
// omnirouteProviderId ("omniroute") — the OC-gate prefix ("opencode-")
// must stay OC-internal (hook.id / AuthHook.provider) and never leak into
// anything OmniRoute's own server parses for credential lookup.
const claude = out["omniroute/claude-primary"];
// #10345/#10821: bare **combo** ids (owned_by: "combo", e.g.
// "claude-primary") must also stay unprefixed — OpenCode looks up
// `-m <plugin>/<combo>` as model id `<combo>` under the plugin provider.
const claude = out["claude-primary"];
assert.ok(claude, "claude-primary present");
// `mapRawModelToModelV2` stamps the provider prefix on the id so OC's
// static-catalog reader resolves `(providerID, modelID)` from the key.
assert.equal(claude.id, "omniroute/claude-primary");
// `mapRawModelToModelV2` leaves bare combo ids unprefixed (see
// src/index.ts mapRawModelToModelV2) so OC's `-m <plugin>/<combo>` lookup
// resolves the combo id directly.
assert.equal(claude.id, "claude-primary");
assert.equal(claude.name, "claude-primary");
assert.equal(claude.providerID, "omniroute");
assert.equal(claude.api.id, "openai-compatible");

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 @@
- Document the conditional management authentication and 401/403 responses for `GET /api/openapi/spec`.

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

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

View File

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

View File

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

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

View File

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

View File

@@ -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";
@@ -379,6 +380,7 @@ import { isCompactResponsesEndpoint } from "../executors/codex.ts";
import { persistCodexChildQuotaResponse } from "../services/codexAccount/index.ts";
import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts";
import { translateNonStreamingResponse } from "./responseTranslator.ts";
import { extractToolSchemaMap } from "../translator/response/openai-responses/toolSchemas.ts";
import { unwrapClineNonStreamingEnvelope } from "./chatCore/clineResponseEnvelope.ts";
import { extractUsageFromResponse } from "./usageExtractor.ts";
import {
@@ -778,11 +780,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);
}
}
@@ -4910,12 +4913,14 @@ export async function handleChatCore({
// Translate response to client's expected format (usually OpenAI)
// Pass toolNameMap so Claude OAuth proxy_ prefix is stripped in tool_use blocks (#605)
const responseToolSchemas = extractToolSchemaMap(finalBody || translatedBody || body);
let translatedResponse = needsTranslation(responsePayloadFormat, clientResponseFormat)
? translateNonStreamingResponse(
responseBody,
responsePayloadFormat,
clientResponseFormat,
responseToolNameMap
responseToolNameMap,
responseToolSchemas
)
: responseBody;
const memoryExtractionResponse = translatedResponse;
@@ -4942,7 +4947,8 @@ export async function handleChatCore({
responseBody,
responsePayloadFormat,
FORMATS.OPENAI,
responseToolNameMap
responseToolNameMap,
responseToolSchemas
)
: responseBody;
const firstChoice = cacheResponse?.choices?.[0];
@@ -5465,7 +5471,8 @@ export async function handleChatCore({
streamBody,
clientResponseFormat,
FORMATS.OPENAI,
responseToolNameMap
responseToolNameMap,
extractToolSchemaMap(finalBody || translatedBody || body)
) as Record<string, unknown>)
: streamBody;
const choices = cacheStreamBody.choices as

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

@@ -439,13 +439,22 @@ type ProviderConnectionLike = {
* whose stored `providerSpecificData.profileArn` matches the given ARN.
* Returns null when profileArn is undefined/null or no match is found.
*
* #10815 hardened `findKiroConnectionByIdentity` to require an account-level
* identifier (email or clientId) alongside a matching profileArn before
* trusting the match — distinct Builder ID accounts (Google/GitHub social
* login) can share the same CodeWhisperer profile ARN, and matching on ARN
* alone let a second social login silently overwrite the first connection.
* `email`/`clientId` here let a caller supply that account identifier; the
* real `saveAndRespond()` call sites already do (see below).
*
* Exported for unit tests (#3615).
*/
export function findKiroConnectionByProfileArn(
connections: ProviderConnectionLike[],
profileArn: string | undefined
profileArn: string | undefined,
accountIdentity?: { email?: string | null; clientId?: string | null }
): ProviderConnectionLike | null {
return findKiroConnectionByIdentity(connections, { profileArn });
return findKiroConnectionByIdentity(connections, { profileArn, ...accountIdentity });
}
// ── Save to OmniRoute DB ──────────────────────────────────────────────────────

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

@@ -29,6 +29,7 @@ import { canUpdateProviderApiKey } from "@/shared/providers/webSessionCredential
import {
refreshConnectionRateLimits,
enableRateLimitProtection,
disableRateLimitProtection,
} from "@/../open-sse/services/rateLimitManager";
import {
finalizeValidatedChatGptWebCodexSecrets,
@@ -342,10 +343,18 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id:
// If rateLimitOverrides was included in the request, refresh the in-memory
// rate limiter state so the change takes effect without a server restart.
// Also ensure rate limit protection is active so the limiter is enforced.
// Only (re)enable enforcement when rate limit protection is actually
// persisted for this connection — this route never lets a caller flip
// `rateLimitProtection` itself, so any drift here would silently start
// queuing requests through Bottleneck for a connection whose DB row (and
// the dashboard toggle reading it) both still say "off" (#11278).
if (rateLimitOverrides !== undefined) {
refreshConnectionRateLimits(id, updated?.rateLimitOverrides ?? null);
enableRateLimitProtection(id);
if (updated?.rateLimitProtection === true) {
enableRateLimitProtection(id);
} else {
disableRateLimitProtection(id);
}
}
// Hide sensitive fields

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

@@ -1267,6 +1267,7 @@
"agentBridgeSubtitle": "Interceptar tráfego de agentes IDE",
"trafficInspector": "Inspector de Tráfego",
"trafficInspectorSubtitle": "Monitorar chamadas LLM + debugar tráfego HTTPS",
"trafficInspectorPurpose": "Veja exatamente o que sua aplicação envia e recebe dos provedores de IA. Funciona com qualquer cliente compatível com OpenAI.",
"cliCode": "CLI Code's",
"cliCodeSubtitle": "Ferramentas de código que apontam para o OmniRoute",
"cliAgents": "CLI Agents",
@@ -1868,7 +1869,16 @@
"directDownloadHint": "Ou baixe o formato do instalador respectivo diretamente:",
"releaseNotes": "Notas de Lançamento",
"readMore": "Leia Mais",
"noAuthLabel": "Sem Autenticação"
"noAuthLabel": "Sem Autenticação",
"readinessEyebrow": "Prepare-se para rotear",
"readinessTitle": "Envie sua primeira requisição",
"readinessSubtitle": "Quatro pequenos passos. O OmniRoute verifica a prontidão conforme você avança.",
"readinessStep1": "Conecte um provedor",
"readinessStep2": "Configure a autenticação do endpoint",
"readinessStep3": "Copie seu endpoint",
"readinessStep4": "Envie uma requisição de teste",
"readinessContinue": "Continuar configuração",
"readinessDismiss": "Dispensar por agora"
},
"analytics": {
"title": "Análises",
@@ -6700,6 +6710,18 @@
"sidebarVisibility": "Hide sidebar items",
"sidebarVisibilityDesc": "Hide any sidebar navigation entry to reduce visual clutter.",
"sidebarVisibilityHint": "Any sidebar section is hidden automatically when a...",
"presetAll": "Tudo",
"presetAllDesc": "Mostrar tudo",
"presetEssentials": "Essenciais",
"presetEssentialsDesc": "Caminho para iniciantes - Ferramentas avançadas continuam pesquisáveis",
"presetMinimal": "Mínimo",
"presetMinimalDesc": "Apenas páginas principais",
"presetDeveloper": "Desenvolvedor",
"presetDeveloperDesc": "Ferramentas de dev & proxy",
"presetAdmin": "Admin",
"presetAdminDesc": "Monitoramento & auditoria",
"settingsSidebarTitle": "Personalização da Barra Lateral",
"settingsSidebarDesc": "Escolha quais itens da barra lateral exibir. Essenciais mantém as ferramentas avançadas pesquisáveis.",
"hideHealthLogs": "Ocultar Logs de Health Check",
"hideHealthLogsDesc": "Quando ATIVADO, suprime mensagens [HealthCheck] no console do servidor",
"themeAccent": "Cor do tema",

View File

@@ -1267,6 +1267,7 @@
"agentBridgeSubtitle": "Chặn lưu lượng agent IDE",
"trafficInspector": "Traffic Inspector",
"trafficInspectorSubtitle": "Giám sát lệnh gọi LLM + gỡ lỗi mọi lưu lượng HTTPS",
"trafficInspectorPurpose": "Xem chính xác những gì ứng dụng của bạn gửi đến và nhận từ các nhà cung cấp AI. Hoạt động với bất kỳ ứng dụng khách nào tương thích với OpenAI.",
"cliCode": "CLI Code",
"cliCodeSubtitle": "Các công cụ lập trình trỏ đến OmniRoute",
"cliAgents": "CLI Agents",
@@ -1868,7 +1869,16 @@
"directDownloadHint": "Hoặc tải trực tiếp định dạng trình cài đặt phù hợp:",
"releaseNotes": "Ghi chú phát hành",
"readMore": "Đọc thêm",
"noAuthLabel": "Không xác thực"
"noAuthLabel": "Không xác thực",
"readinessEyebrow": "Chuẩn bị định tuyến",
"readinessTitle": "Gửi yêu cầu đầu tiên của bạn",
"readinessSubtitle": "Bốn bước nhỏ. OmniRoute kiểm tra mức độ sẵn sàng khi bạn thực hiện.",
"readinessStep1": "Kết nối một nhà cung cấp",
"readinessStep2": "Định cấu hình xác thực endpoint",
"readinessStep3": "Sao chép endpoint của bạn",
"readinessStep4": "Gửi một yêu cầu thử nghiệm",
"readinessContinue": "Tiếp tục thiết lập",
"readinessDismiss": "Bỏ qua lúc này"
},
"analytics": {
"title": "Phân tích",
@@ -6700,6 +6710,18 @@
"sidebarVisibility": "Ẩn các mục trên thanh bên",
"sidebarVisibilityDesc": "Ẩn bất kỳ mục điều hướng nào trên thanh bên để giảm bớt sự lộn xộn về mặt trực quan mà không vô hiệu hóa bất kỳ tính năng nào",
"sidebarVisibilityHint": "Bất kỳ phần nào trên thanh bên sẽ tự động bị ẩn khi tất cả các mục bên trong nó đều bị ẩn",
"presetAll": "Tất cả",
"presetAllDesc": "Hiển thị mọi thứ",
"presetEssentials": "Thiết yếu",
"presetEssentialsDesc": "Lộ trình cho người mới bắt đầu - Công cụ nâng cao vẫn có thể tìm kiếm",
"presetMinimal": "Tối giản",
"presetMinimalDesc": "Chỉ các trang cốt lõi",
"presetDeveloper": "Nhà phát triển",
"presetDeveloperDesc": "Công cụ dev & proxy",
"presetAdmin": "Quản trị",
"presetAdminDesc": "Giám sát & kiểm toán",
"settingsSidebarTitle": "Tùy chỉnh thanh bên",
"settingsSidebarDesc": "Chọn các mục trên thanh bên sẽ hiển thị. Thiết yếu giữ cho các công cụ nâng cao vẫn có thể tìm kiếm.",
"hideHealthLogs": "Ẩn nhật ký kiểm tra sức khỏe",
"hideHealthLogsDesc": "Khi BẬT, sẽ chặn các thông báo [HealthCheck] trong bảng điều khiển máy chủ",
"themeAccent": "Màu chủ đề",

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

@@ -188,6 +188,32 @@ describe("injectMemory — edge cases", () => {
});
});
describe("injectMemory — Claude-family cache-safe splice gate (#11290)", () => {
test("does not splice mid-array on anthropic when the last turn before the splice point is plain assistant text", () => {
const request = makeRequest({
messages: [
{ role: "system", content: "SYSTEM PROMPT" },
{ role: "user", content: "turn 1 question" },
{ role: "assistant", content: "turn 1 answer" },
{ role: "user", content: "turn 2 question" },
],
});
const memories = [makeMemory("dark mode")];
const result = injectMemory(request, memories, "anthropic", { cacheSafe: true });
// The plain-text assistant turn must stay immediately followed by the final user
// turn — no system message spliced between them (that shape is what Opus 5 rejects
// with HTTP 400, #11290). Memory is merged into the leading system message instead.
expect(result.messages).toHaveLength(4);
expect(result.messages[0].role).toBe("system");
expect(result.messages[0].content).toContain("Memory context: dark mode");
expect(result.messages[0].content).toContain("SYSTEM PROMPT");
expect(result.messages[2]).toEqual({ role: "assistant", content: "turn 1 answer" });
expect(result.messages[3]).toEqual({ role: "user", content: "turn 2 question" });
});
});
describe("shouldInjectMemory", () => {
test("returns true when messages are present and enabled not set", () => {
const request = makeRequest();

View File

@@ -12,6 +12,10 @@
import { Memory } from "./types";
import { logger } from "../../../open-sse/utils/logger.ts";
import {
isAnthropicCompatibleProvider,
isClaudeCodeCompatibleProvider,
} from "../../shared/constants/providers";
const log = logger("MEMORY_INJECTION");
@@ -170,6 +174,43 @@ function injectSystemFirst(
return { ...request, messages: [memorySystemMessage, ...messages] };
}
/**
* #11290: providers in the Claude family (direct Anthropic, and any
* anthropic-compatible / Claude-Code-compatible passthrough connection) — the
* ones affected by the stricter Opus 5 message-ordering validation described
* below. Deliberately narrower than `systemMessageMustBeFirst()`'s strict-set:
* this only gates the cache-safe mid-array splice, not the leading-system-message
* requirement, so non-Claude providers keep the #3890 cache-hit optimization
* unconditionally.
*/
function isClaudeFamilyProvider(provider: string | null | undefined): boolean {
if (!provider) return false;
const normalized = provider.toLowerCase().trim();
return (
normalized === "claude" ||
normalized === "anthropic" ||
isClaudeCodeCompatibleProvider(provider) ||
isAnthropicCompatibleProvider(provider)
);
}
/**
* True when an assistant message's content ends in a server-side tool result
* block (e.g. `web_search_tool_result`, `code_execution_tool_result`,
* `mcp_tool_result` — any Anthropic content block whose type ends in
* `_tool_result`, produced by a server-executed tool rather than a
* client-executed one). `content` is typed as `string` on `ChatMessage` for
* the common case, but the Claude-native wire shape carries an array of
* content blocks — this only recognizes that richer shape.
*/
function endsWithServerToolResult(message: ChatMessage | undefined): boolean {
if (!message || message.role !== "assistant") return false;
const content = message.content as unknown;
if (!Array.isArray(content) || content.length === 0) return false;
const lastBlock = content[content.length - 1] as { type?: unknown } | null | undefined;
return typeof lastBlock?.type === "string" && lastBlock.type.endsWith("_tool_result");
}
/**
* Place a memory message at the #3890 cache-safe anchor (just before the last
* user turn) when one exists, else prepend it. Shared by the system and user
@@ -222,6 +263,24 @@ export function injectMemory(
return injectSystemFirst(request, messages, memoryText, memories.length);
}
// #11290: Claude Opus 5 tightened server-side validation of the cache-safe
// mid-array splice — a system message spliced right after a plain-text assistant
// turn is rejected with HTTP 400 (the immediately preceding message must end in a
// server-side tool result for a following system message to be accepted). Rather
// than adding "claude"/"anthropic" outright to `systemMessageMustBeFirst()` (which
// would revert the #3890 cache-hit optimization for every Claude request, including
// the ones that work fine today), only fall back to the leading-system-message
// placement for the specific requests where the turn right before the splice point
// isn't a server tool result.
if (
supportsSystem &&
cacheSafeIndex >= 0 &&
isClaudeFamilyProvider(provider) &&
!endsWithServerToolResult(messages[cacheSafeIndex - 1])
) {
return injectSystemFirst(request, messages, memoryText, memories.length);
}
// Strategy 1 (system): prepend before existing system messages, preserving the
// caller's own instructions. Strategy 2 (user, e.g. o1-mini): inject as a user
// message. Both honor the #3890 cache-safe anchor via placeMessage.

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

View File

@@ -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);
});

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