mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-24 16:12:23 +03:00
Compare commits
1 Commits
fix/11290-
...
fix/codeql
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f9d9b4a2c |
@@ -65,12 +65,6 @@ INITIAL_PASSWORD=CHANGEME
|
||||
# OMNIROUTE_RELEASE_REF=origin/main
|
||||
# OMNIROUTE_ALLOW_CANARY_BUILD=1
|
||||
|
||||
# Build-phase signal (#10060). Set to 1 by scripts/build/build-next-isolated.mjs and
|
||||
# inherited by every spawned build worker so the DB layer returns a no-op stub instead
|
||||
# of loading the native better-sqlite3 addon (which aborts the worker on exit).
|
||||
# Never set this for the running server. Used by: src/lib/buildPhase.ts, src/lib/db/core.ts
|
||||
# OMNIROUTE_BUILDING=1
|
||||
|
||||
# Encryption key for SQLite database encryption at rest.
|
||||
# Used by: src/lib/db/encryption.ts — encrypts the entire SQLite database.
|
||||
# Generate: openssl rand -hex 32 | Leave empty to disable DB encryption.
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
isFatalInstrumentationHookFailure,
|
||||
formatAndroidInstrumentationFailureHint,
|
||||
} from "../utils/ensureAndroidCacheDir.mjs";
|
||||
import { resolveServerHost, resolveExposureWarning } from "../utils/serverHost.mjs";
|
||||
import { resolveServerHost } from "../utils/serverHost.mjs";
|
||||
import {
|
||||
resolveMaxOldSpaceMb,
|
||||
calibrateHeapFallbackMb,
|
||||
@@ -162,15 +162,6 @@ 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");
|
||||
|
||||
|
||||
@@ -24,34 +24,3 @@ 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.`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
- **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))
|
||||
@@ -1 +0,0 @@
|
||||
- **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))
|
||||
@@ -1 +0,0 @@
|
||||
- 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)
|
||||
@@ -1,9 +1,5 @@
|
||||
{
|
||||
"_comment": "Allowlist anti-slopsquatting (check-deps.mjs). Toda dep nova exige adicao EXPLICITA aqui apos verificar que e legitima.",
|
||||
"_justifications": {
|
||||
"@testing-library/dom": "Peer dep obrigatoria de @testing-library/react v16 (adicionada no PR #11224); Refs #9985.",
|
||||
"@testing-library/user-event": "Utilitario oficial do ecossistema testing-library para testes de UI (adicionada no PR #11224); Refs #9985."
|
||||
},
|
||||
"allowed": [
|
||||
"@atjsh/llmlingua-2",
|
||||
"@aws-sdk/client-bedrock-runtime",
|
||||
@@ -24,10 +20,8 @@
|
||||
"@stryker-mutator/tap-runner",
|
||||
"@swc/helpers",
|
||||
"@tailwindcss/postcss",
|
||||
"@testing-library/dom",
|
||||
"@testing-library/jest-dom",
|
||||
"@testing-library/react",
|
||||
"@testing-library/user-event",
|
||||
"@toon-format/toon",
|
||||
"@types/better-sqlite3",
|
||||
"@types/bun",
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
---
|
||||
title: "CLI Tools — OmniRoute"
|
||||
version: 3.8.50
|
||||
lastUpdated: 2026-08-23
|
||||
lastUpdated: 2026-08-18
|
||||
---
|
||||
|
||||
# CLI Tools — OmniRoute
|
||||
|
||||
Last updated: 2026-08-23
|
||||
Last updated: 2026-08-18
|
||||
|
||||
OmniRoute integrates with three categories of CLI tools spread across three dedicated dashboard pages:
|
||||
|
||||
| Page | Route | Concept | Count |
|
||||
| -------------- | ----------------------- | ------------------------------------------------------------------------- | ------------ |
|
||||
| **CLI Code's** | `/dashboard/cli-code` | Coding tools you point at OmniRoute (Client → CLI → OmniRoute → Provider) | 26 |
|
||||
| **CLI Agents** | `/dashboard/cli-agents` | Autonomous agents you point at OmniRoute (same flow, broader scope) | 9 |
|
||||
| **CLI Agents** | `/dashboard/cli-agents` | Autonomous agents you point at OmniRoute (same flow, broader scope) | 8 |
|
||||
| **ACP Agents** | `/dashboard/acp-agents` | CLIs that OmniRoute spawns as backend via stdio/ACP (reverse flow) | see registry |
|
||||
|
||||
Legacy routes redirect via 308: `/dashboard/cli-tools` → `/dashboard/cli-code`, `/dashboard/agents` → `/dashboard/acp-agents`.
|
||||
|
||||
@@ -88,7 +88,6 @@ OmniRoute uses **SQLite** (via `better-sqlite3`) for all persistence. These vari
|
||||
| `OMNIROUTE_RELEASE_REF` | `origin/main` | `scripts/build/buildProvenance.ts` | Ref the pack-artifact provenance gate checks the build SHA against (#10427). |
|
||||
| `OMNIROUTE_ALLOW_CANARY_BUILD` | _(unset)_ | `scripts/build/buildProvenance.ts` | Set to `1` to allow packing a build whose SHA is not on the release line, recording it as a deliberate canary instead of failing the gate (#10427). |
|
||||
| `OMNIROUTE_SMOKE_API_KEY` | _(unset)_ | `scripts/ops/deploy-canary.mjs` | API key for the canary-deploy smoke probe, sent as `Authorization: Bearer` on `/v1/chat/completions`. Only used by the deploy script (#10429), never by the server. Not related to the `OMNIROUTE_SMOKE_*` variables of the opt-in CLI smoke harness (`RUN_CLI_SMOKE=1`, `OMNIROUTE_SMOKE_BASE_URL/MODEL/API_KEY_ENV/TARGETS/TIMEOUT_MS` in `tests/integration/upstream-cli-smoke.int.test.ts`) — see [CLI Integrations → Real smoke sweep](../guides/CLI-INTEGRATIONS.md). |
|
||||
| `OMNIROUTE_BUILDING` | _(unset)_ | `src/lib/buildPhase.ts` | Build-phase signal (#10060): set to `1` by `scripts/build/build-next-isolated.mjs` and inherited by every spawned build worker so the DB layer returns a no-op stub instead of loading the native better-sqlite3 addon (which aborts the worker on exit). Never set for the running server. |
|
||||
| `OMNIROUTE_DATA_DIR` | _(unset)_ | `open-sse/executors/promptql/threadSticky.ts` | **Fallback alias** for `DATA_DIR`, checked only when `DATA_DIR` is unset. Used to locate the PromptQL executor's on-disk thread-sticky session cache (`<dir>/promptql-thread-sessions.json`); if neither var is set, the cache stays in-memory only (not persisted across restarts). |
|
||||
| `STORAGE_ENCRYPTION_KEY` | _(empty = disabled)_ | `src/lib/db/encryption.ts` | AES key for full SQLite database encryption at rest. Generate with `openssl rand -hex 32`. |
|
||||
| `STORAGE_ENCRYPTION_KEY_VERSION` | `v1` | `scripts/build/bootstrap-env.mjs`, `electron/main.js` | Version label for the encryption key. Increment when performing key rotation to support decryption of old backups. |
|
||||
|
||||
@@ -1218,12 +1218,7 @@ export async function handleChatCore({
|
||||
credentials?.providerSpecificData?.preserveEncryptedReasoning === true,
|
||||
onIncompatibleReasoning: resolveIncompatibleReasoningAction({
|
||||
reasoningTransportFallback,
|
||||
// #11178 regressed combo steps whose combo record carries no explicit
|
||||
// stepId/executionKey (plain model-list combos): their explicit
|
||||
// `reasoningTransportFallback: "skip"` config was silently degraded to
|
||||
// "drop". `isCombo` is the combo marker; step ids are optional
|
||||
// finer-grained metadata that plain combos never set.
|
||||
isComboStep: Boolean(isCombo) || Boolean(comboStepId || comboExecutionKey),
|
||||
isComboStep: Boolean(comboStepId || comboExecutionKey),
|
||||
headers: clientRawRequest?.headers ?? null,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -31,7 +31,6 @@ 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";
|
||||
@@ -314,23 +313,9 @@ function getProviderSettingString(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveSearchBaseUrl(
|
||||
config: SearchProviderConfig,
|
||||
params: SearchRequestParams
|
||||
): string {
|
||||
function resolveSearchBaseUrl(config: SearchProviderConfig, params: SearchRequestParams): string {
|
||||
const override = getProviderSettingString(params, "baseUrl");
|
||||
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(/\/+$/, "");
|
||||
return (override || config.baseUrl).replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function toSearchPageNumber(offset: number | undefined, maxResults: number): number | undefined {
|
||||
|
||||
@@ -290,11 +290,6 @@ export default function EditConnectionModal({
|
||||
connection.providerSpecificData?.quotaPerUnit != null
|
||||
? String(connection.providerSpecificData.quotaPerUnit)
|
||||
: "";
|
||||
// Modal-open form initialization from the loaded connection (sync with an
|
||||
// external system on `isOpen`); remounting the 30+ field form per
|
||||
// connection id is a behavior-risking restructure out of scope here
|
||||
// (#11251 follow-up, #9985).
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setFormData({
|
||||
name: connection.name || "",
|
||||
priority: connection.priority || 1,
|
||||
|
||||
@@ -521,7 +521,6 @@ 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"),
|
||||
@@ -529,10 +528,6 @@ 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"),
|
||||
|
||||
@@ -15,15 +15,7 @@ import { HistoricSessionBanner } from "./components/session/HistoricSessionBanne
|
||||
|
||||
const BUFFER_MAX = 1000;
|
||||
|
||||
export function TrafficInspectorPageClient({
|
||||
title,
|
||||
subtitle,
|
||||
purpose,
|
||||
}: {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
purpose?: string;
|
||||
} = {}) {
|
||||
export function TrafficInspectorPageClient() {
|
||||
const [containerHeight, setContainerHeight] = useState(600);
|
||||
const listContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [selectedRequest, setSelectedRequest] = useState<InterceptedRequest | null>(null);
|
||||
@@ -99,18 +91,6 @@ 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} />
|
||||
|
||||
@@ -9,7 +9,6 @@ export async function generateMetadata() {
|
||||
};
|
||||
}
|
||||
|
||||
export default async function TrafficInspectorPage() {
|
||||
const t = await getTranslations("sidebar");
|
||||
return <TrafficInspectorPageClient title={t("trafficInspector")} subtitle={t("trafficInspectorSubtitle")} purpose={t("trafficInspectorPurpose")} />;
|
||||
export default function TrafficInspectorPage() {
|
||||
return <TrafficInspectorPageClient />;
|
||||
}
|
||||
|
||||
@@ -10,13 +10,15 @@
|
||||
* 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 { authenticateA2ARequest, resolveA2AOwner } from "@/lib/a2a/authenticate";
|
||||
import { isRequireApiKeyEnabled } from "@/shared/utils/featureFlags";
|
||||
import { extractApiKey, isValidApiKey } from "@/sse/services/auth";
|
||||
|
||||
// ============ A2A v1.0 ↔ v0.3 compatibility layer ============
|
||||
// A2A 1.0 renamed the JSON-RPC methods (message/send → SendMessage,
|
||||
@@ -53,7 +55,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)
|
||||
@@ -122,13 +124,39 @@ 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). The shared helper applies the same posture on both
|
||||
// the JSON-RPC and the REST task surfaces (GHSA-jcm5-6wpp-wjj8).
|
||||
return authenticateA2ARequest(req);
|
||||
// (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;
|
||||
}
|
||||
|
||||
// ============ JSON-RPC Helpers ============
|
||||
@@ -185,9 +213,6 @@ 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;
|
||||
@@ -211,7 +236,7 @@ export async function POST(req: NextRequest) {
|
||||
return jsonRpcError(id, -32601, `Unknown skill: ${skill}`);
|
||||
}
|
||||
|
||||
const task = tm.createTask({ skill, messages, metadata: params?.metadata }, callerOwner);
|
||||
const task = tm.createTask({ skill, messages, metadata: params?.metadata });
|
||||
try {
|
||||
tm.updateTask(task.id, "working");
|
||||
const result = await handler(task);
|
||||
@@ -277,7 +302,7 @@ export async function POST(req: NextRequest) {
|
||||
return jsonRpcError(id, -32601, `Unknown skill: ${skill}`);
|
||||
}
|
||||
|
||||
const task = tm.createTask({ skill, messages, metadata: params?.metadata }, callerOwner);
|
||||
const task = tm.createTask({ skill, messages, metadata: params?.metadata });
|
||||
tm.updateTask(task.id, "working");
|
||||
|
||||
const stream = createA2AStream(
|
||||
@@ -298,7 +323,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, callerOwner);
|
||||
const task = tm.getTask(taskId);
|
||||
if (!task) return jsonRpcError(id, -32601, `Task not found: ${taskId}`);
|
||||
|
||||
return jsonRpcResult(id, { task });
|
||||
@@ -310,7 +335,7 @@ export async function POST(req: NextRequest) {
|
||||
if (!taskId) return jsonRpcError(id, -32602, "Invalid params: taskId required");
|
||||
|
||||
try {
|
||||
const task = tm.cancelTask(taskId, callerOwner);
|
||||
const task = tm.cancelTask(taskId);
|
||||
return jsonRpcResult(id, { task: { id: task.id, state: task.state } });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -1,23 +1,14 @@
|
||||
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 }> }) {
|
||||
// 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;
|
||||
export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const tm = getTaskManager();
|
||||
const task = tm.cancelTask(id, auth.owner);
|
||||
const task = tm.cancelTask(id);
|
||||
return NextResponse.json({ task: { id: task.id, state: task.state } });
|
||||
} catch (error) {
|
||||
const message = sanitizeErrorMessage(
|
||||
error instanceof Error ? error.message : "Failed to cancel A2A task"
|
||||
);
|
||||
const message = error instanceof Error ? error.message : "Failed to cancel A2A task";
|
||||
const status = message.includes("not found") ? 404 : 400;
|
||||
return NextResponse.json({ error: message }, { status });
|
||||
}
|
||||
|
||||
@@ -1,30 +1,17 @@
|
||||
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 }> }) {
|
||||
// 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;
|
||||
export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const tm = getTaskManager();
|
||||
const task = tm.getTask(id, auth.owner);
|
||||
const task = tm.getTask(id);
|
||||
if (!task) {
|
||||
return NextResponse.json({ error: `Task not found: ${id}` }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ task });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: sanitizeErrorMessage(
|
||||
error instanceof Error ? error.message : "Failed to load A2A task"
|
||||
),
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
const message = error instanceof Error ? error.message : "Failed to load A2A task";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ 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";
|
||||
|
||||
@@ -23,11 +22,6 @@ 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");
|
||||
@@ -42,7 +36,7 @@ export async function GET(request: Request) {
|
||||
|
||||
const tm = getTaskManager();
|
||||
const total = tm.countTasks({ state, skill });
|
||||
const tasks = tm.listTasks({ state, skill, limit, offset }, auth.owner);
|
||||
const tasks = tm.listTasks({ state, skill, limit, offset });
|
||||
|
||||
return NextResponse.json({
|
||||
tasks,
|
||||
@@ -110,10 +104,7 @@ 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) {
|
||||
@@ -131,18 +122,12 @@ 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 }
|
||||
);
|
||||
}
|
||||
@@ -153,9 +138,7 @@ 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,
|
||||
|
||||
@@ -1221,6 +1221,12 @@
|
||||
"consoleLogsSubtitle": "Console output",
|
||||
"logsActivitySubtitle": "User activity log",
|
||||
"healthSubtitle": "System health check",
|
||||
"healthVerdictReady": "OmniRoute is ready",
|
||||
"healthVerdictActionRequired": "Action required to restore full operation",
|
||||
"healthVerdictCoolingDown": "Cooling down after recent changes",
|
||||
"advancedDiagnosticsTitle": "Advanced diagnostics",
|
||||
"hide": "Hide",
|
||||
"show": "Show",
|
||||
"costsPricingSubtitle": "Per-model pricing rules",
|
||||
"costsBudgetSubtitle": "Budget limits",
|
||||
"costsQuotaShareSubtitle": "Share provider quotas across keys",
|
||||
@@ -1266,8 +1272,7 @@
|
||||
"agentBridge": "Agent Bridge",
|
||||
"agentBridgeSubtitle": "Intercept IDE agent traffic",
|
||||
"trafficInspector": "Traffic Inspector",
|
||||
"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.",
|
||||
"trafficInspectorSubtitle": "Monitor LLM calls + debug any HTTPS traffic",
|
||||
"cliCode": "CLI Code",
|
||||
"cliCodeSubtitle": "Code tools pointing to OmniRoute",
|
||||
"cliAgents": "CLI Agents",
|
||||
@@ -2922,7 +2927,6 @@
|
||||
"interpreter": "Open Interpreter autonomous coding agent CLI",
|
||||
"omp": "Oh My Pi terminal coding agent",
|
||||
"letta": "Letta CLI agent with persistent memory and tool use",
|
||||
"prime-agent": "Prime Agent — self-improving RLM coding harness with OpenAI-compatible provider support",
|
||||
"warp": "Warp AI terminal with custom provider support",
|
||||
"agent-deck": "Agent Deck multi-agent orchestrator"
|
||||
},
|
||||
@@ -4632,13 +4636,6 @@
|
||||
"retry": "Retry",
|
||||
"allOperational": "All systems operational",
|
||||
"issuesDetected": "System issues detected",
|
||||
"healthVerdictReady": "OmniRoute is ready",
|
||||
"healthVerdictActionRequired": "Action required to restore full operation",
|
||||
"healthVerdictCoolingDown": "Cooling down after recent changes",
|
||||
"healthSubtitle": "System health check",
|
||||
"advancedDiagnosticsTitle": "Advanced diagnostics",
|
||||
"hide": "Hide",
|
||||
"show": "Show",
|
||||
"updatedAt": "Updated {time}",
|
||||
"latency": "Latency",
|
||||
"latencyP50": "p50",
|
||||
@@ -6710,18 +6707,6 @@
|
||||
"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",
|
||||
|
||||
@@ -970,13 +970,6 @@
|
||||
"batchTimelineCancelled": "Cancelado",
|
||||
"batchTokenUsage": "Uso de Token",
|
||||
"batchMetadata": "Metadados",
|
||||
"batchHeaderSubtitle": "Execute muitas requisições como um único job",
|
||||
"batchStep1": "1 · Enviar JSONL",
|
||||
"batchStep1Desc": "Adicionar requisições",
|
||||
"batchStep2": "2 · Criar lote",
|
||||
"batchStep2Desc": "Executar job",
|
||||
"batchStep3": "3 · Obter resultados",
|
||||
"batchStep3Desc": "Baixar saída",
|
||||
"batchFileContents": "Conteúdo do Arquivo",
|
||||
"batchFileUsedByCount": "Usado por {count, plural, one {# lote} other {# lotes}}",
|
||||
"batchFilePreview": "Prévia",
|
||||
@@ -2912,7 +2905,6 @@
|
||||
"interpreter": "CLI do agente de codificação autônomo Open Interpreter",
|
||||
"omp": "Agente de codificação de terminal Oh My Pi",
|
||||
"letta": "Agente CLI Letta com memória persistente e uso de ferramentas",
|
||||
"prime-agent": "Prime Agent — harness de codificação RLM autoevolutivo com suporte a API compatível com OpenAI",
|
||||
"warp": "Terminal de IA Warp com suporte a provedor personalizado",
|
||||
"agent-deck": "Orquestrador multi-agente Agent Deck"
|
||||
},
|
||||
@@ -3839,9 +3831,6 @@
|
||||
},
|
||||
"endpoint": {
|
||||
"title": "Endpoint da API",
|
||||
"subtitle": "Use o endpoint compatível com OpenAI na maioria dos SDKs e ferramentas.",
|
||||
"testEndpoint": "Testar endpoint →",
|
||||
"advancedProtocols": "Protocolos avançados",
|
||||
"available": "Endpoints Disponíveis",
|
||||
"cloudProxy": "Proxy na Nuvem",
|
||||
"disableConfirm": "Tem certeza que deseja desativar o proxy na nuvem?",
|
||||
@@ -4622,13 +4611,6 @@
|
||||
"retry": "Tentar Novamente",
|
||||
"allOperational": "Todos os sistemas operacionais",
|
||||
"issuesDetected": "Problemas detectados no sistema",
|
||||
"healthVerdictReady": "O OmniRoute está pronto",
|
||||
"healthVerdictActionRequired": "Ação necessária para restaurar a operação plena",
|
||||
"healthVerdictCoolingDown": "Em resfriamento após mudanças recentes",
|
||||
"healthSubtitle": "Verificação de saúde do sistema",
|
||||
"advancedDiagnosticsTitle": "Diagnósticos avançados",
|
||||
"hide": "Ocultar",
|
||||
"show": "Mostrar",
|
||||
"updatedAt": "Atualizado {time}",
|
||||
"latency": "Latência",
|
||||
"latencyP50": "p50",
|
||||
@@ -12053,7 +12035,6 @@
|
||||
"acp": {
|
||||
"title": "ACP Agents",
|
||||
"phrase": "CLIs que o OmniRoute spawna como backend de execução (fluxo reverso)",
|
||||
"warning": "A maioria dos usuários pode ignorar isto — use apenas quando uma integração exigir.",
|
||||
"flow": "Cliente → OmniRoute → spawn CLI (stdio/ACP) → resposta",
|
||||
"seeOther": "Ver →"
|
||||
}
|
||||
@@ -13361,13 +13342,6 @@
|
||||
},
|
||||
"resilienceConnections": {
|
||||
"title": "Resiliência de Conexão",
|
||||
"reassuranceTitle": "Suas conexões se recuperam automaticamente",
|
||||
"reassuranceDetail": "Normalmente nenhuma ação é necessária. O OmniRoute dá uma pausa temporária em uma conexão após falhas e depois a tenta novamente com segurança.",
|
||||
"plainStates": {
|
||||
"healthy": "Requisições podem ser enviadas",
|
||||
"coolingDown": "Tentando novamente em breve",
|
||||
"lockedOut": "Precisa da sua atenção"
|
||||
},
|
||||
"table": {
|
||||
"status": "Status",
|
||||
"provider": "Provedor",
|
||||
|
||||
@@ -1300,7 +1300,13 @@
|
||||
"open": "mở",
|
||||
"close": "đóng"
|
||||
},
|
||||
"noResults": "Không có kết quả"
|
||||
"noResults": "Không có kết quả",
|
||||
"healthVerdictReady": "OmniRoute đã sẵn sàng",
|
||||
"healthVerdictActionRequired": "Cần hành động để khôi phục hoạt động đầy đủ",
|
||||
"healthVerdictCoolingDown": "Đang nguội sau các thay đổi gần đây",
|
||||
"advancedDiagnosticsTitle": "Chẩn đoán nâng cao",
|
||||
"hide": "Ẩn",
|
||||
"show": "Hiện"
|
||||
},
|
||||
"webhooks": {
|
||||
"title": "Webhook",
|
||||
@@ -2912,7 +2918,6 @@
|
||||
"interpreter": "Tác nhân lập trình tự trị Open Interpreter CLI",
|
||||
"omp": "Tác nhân lập trình Oh My Pi trên terminal",
|
||||
"letta": "Tác nhân Letta CLI có bộ nhớ lâu dài và khả năng dùng công cụ",
|
||||
"prime-agent": "Prime Agent — bộ khung lập trình RLM tự cải tiến hỗ trợ API tương thích OpenAI",
|
||||
"warp": "Terminal Warp AI hỗ trợ nhà cung cấp tùy chỉnh",
|
||||
"agent-deck": "Trình điều phối đa tác nhân Agent Deck"
|
||||
},
|
||||
@@ -4622,13 +4627,6 @@
|
||||
"retry": "Thử lại",
|
||||
"allOperational": "Tất cả hệ thống đang hoạt động bình thường",
|
||||
"issuesDetected": "Phát hiện sự cố hệ thống",
|
||||
"healthVerdictReady": "OmniRoute đã sẵn sàng",
|
||||
"healthVerdictActionRequired": "Cần hành động để khôi phục hoạt động đầy đủ",
|
||||
"healthVerdictCoolingDown": "Đang nguội sau các thay đổi gần đây",
|
||||
"healthSubtitle": "Kiểm tra tình trạng hệ thống",
|
||||
"advancedDiagnosticsTitle": "Chẩn đoán nâng cao",
|
||||
"hide": "Ẩn",
|
||||
"show": "Hiện",
|
||||
"updatedAt": "Đã cập nhật {time}",
|
||||
"latency": "Độ trễ",
|
||||
"latencyP50": "p50",
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -45,13 +45,6 @@ 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 {
|
||||
@@ -98,7 +91,7 @@ export class A2ATaskManager {
|
||||
}
|
||||
}
|
||||
|
||||
createTask(input: TaskInput, owner?: string): A2ATask {
|
||||
createTask(input: TaskInput): A2ATask {
|
||||
const now = new Date();
|
||||
const task: A2ATask = {
|
||||
id: randomUUID(),
|
||||
@@ -111,31 +104,19 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
getTask(taskId: 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");
|
||||
}
|
||||
}
|
||||
const current = this.tasks.get(taskId);
|
||||
if (!current || !this.isVisibleTo(current, owner)) return undefined;
|
||||
return current;
|
||||
return this.tasks.get(taskId);
|
||||
}
|
||||
|
||||
updateTask(
|
||||
@@ -161,15 +142,7 @@ export class A2ATaskManager {
|
||||
return task;
|
||||
}
|
||||
|
||||
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`);
|
||||
}
|
||||
cancelTask(taskId: string): A2ATask {
|
||||
return this.updateTask(taskId, "cancelled", undefined, "Cancelled by client");
|
||||
}
|
||||
|
||||
@@ -180,11 +153,8 @@ export class A2ATaskManager {
|
||||
return tasks.length;
|
||||
}
|
||||
|
||||
listTasks(filter?: TaskListFilter, owner?: string): A2ATask[] {
|
||||
listTasks(filter?: TaskListFilter): 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());
|
||||
|
||||
@@ -188,32 +188,6 @@ 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();
|
||||
|
||||
@@ -12,10 +12,6 @@
|
||||
|
||||
import { Memory } from "./types";
|
||||
import { logger } from "../../../open-sse/utils/logger.ts";
|
||||
import {
|
||||
isAnthropicCompatibleProvider,
|
||||
isClaudeCodeCompatibleProvider,
|
||||
} from "../../shared/constants/providers";
|
||||
|
||||
const log = logger("MEMORY_INJECTION");
|
||||
|
||||
@@ -174,43 +170,6 @@ 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
|
||||
@@ -263,24 +222,6 @@ 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.
|
||||
|
||||
@@ -30,27 +30,6 @@ 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[],
|
||||
@@ -66,14 +45,7 @@ export function findKiroConnectionByIdentity(
|
||||
const match = candidates.find(
|
||||
(connection) => trimmed(providerData(connection).profileArn) === profileArn
|
||||
);
|
||||
// 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;
|
||||
}
|
||||
if (match) return match;
|
||||
}
|
||||
|
||||
const clientId = trimmed(identity.clientId);
|
||||
|
||||
@@ -43,8 +43,6 @@ 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)
|
||||
@@ -128,12 +126,6 @@ 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 {
|
||||
|
||||
@@ -6,11 +6,8 @@ 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";
|
||||
@@ -64,7 +61,6 @@ 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(() => {
|
||||
@@ -75,9 +71,6 @@ 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(() => {
|
||||
@@ -111,13 +104,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
if (isSidebarGroup(child)) {
|
||||
const subgroupLabel = safeTranslate(child.titleKey, child.titleFallback);
|
||||
return child.items
|
||||
.filter((item) => {
|
||||
if (!hiddenItems.has(item.id)) return true;
|
||||
return (
|
||||
activePreset === "essentials" &&
|
||||
ESSENTIALS_ADVANCED_TOOL_IDS.has(item.id as HideableSidebarItemId)
|
||||
);
|
||||
})
|
||||
.filter((item) => !hiddenItems.has(item.id))
|
||||
.map<PaletteItem>((item) => ({
|
||||
id: item.id,
|
||||
href: item.href,
|
||||
@@ -134,12 +121,7 @@ function CommandPaletteDialog({ onClose }: { onClose: () => void }) {
|
||||
}));
|
||||
}
|
||||
const item = child as SidebarItemDefinition;
|
||||
if (hiddenItems.has(item.id)) {
|
||||
const keepForEssentials =
|
||||
activePreset === "essentials" &&
|
||||
ESSENTIALS_ADVANCED_TOOL_IDS.has(item.id as HideableSidebarItemId);
|
||||
if (!keepForEssentials) return [];
|
||||
}
|
||||
if (hiddenItems.has(item.id)) return [];
|
||||
return [
|
||||
{
|
||||
id: item.id,
|
||||
|
||||
@@ -202,36 +202,6 @@ 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",
|
||||
@@ -327,7 +297,6 @@ 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) },
|
||||
|
||||
@@ -174,7 +174,7 @@ export interface SidebarSectionDefinition {
|
||||
defaultPinned?: boolean;
|
||||
}
|
||||
|
||||
export type SidebarPresetId = "all" | "essentials" | "minimal" | "developer" | "admin";
|
||||
export type SidebarPresetId = "all" | "minimal" | "developer" | "admin";
|
||||
|
||||
export interface SidebarPresetDefinition {
|
||||
id: SidebarPresetId;
|
||||
|
||||
@@ -28,8 +28,6 @@ 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)
|
||||
|
||||
@@ -67,5 +67,4 @@ export const EXPECTED_CODE_COUNT = 21;
|
||||
// +2 (#6318): "omp" (Oh My Pi) and "letta" (Letta CLI) added as agent entries.
|
||||
// Note: #6318 originally also shipped duplicate "pi"/"jcode"/"codewhale" entries —
|
||||
// those tools were already delivered by a separate PR, so only omp+letta landed here.
|
||||
// +1 (#11166): "prime-agent" (PrimeIntellect-ai/prime-agent) added as an agent entry.
|
||||
export const EXPECTED_AGENT_COUNT = 9;
|
||||
export const EXPECTED_AGENT_COUNT = 8;
|
||||
|
||||
@@ -199,10 +199,7 @@ 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", "essentials", "minimal", "developer", "admin"])
|
||||
.nullable()
|
||||
.optional(),
|
||||
sidebarActivePreset: z.enum(["all", "minimal", "developer", "admin"]).nullable().optional(),
|
||||
comboConfigMode: z.enum(COMBO_CONFIG_MODES).optional(),
|
||||
codexServiceTier: z
|
||||
.object({
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
"tests/unit/auth-terminal-status.test.ts",
|
||||
"tests/unit/authz/discovery-routes-local-only.test.ts",
|
||||
"tests/unit/authz/oauth-autoimport-local-only.test.ts",
|
||||
"tests/unit/quota-exhaustion-cutoff-opencode.test.ts",
|
||||
"tests/unit/authz/route-guard-local-prefix.test.ts",
|
||||
"tests/unit/authz/route-guard-skills-collect.test.ts",
|
||||
"tests/unit/authz/route-guard-version-get-exemption.test.ts",
|
||||
@@ -307,7 +308,6 @@
|
||||
"tests/unit/public-client-ids-3493.test.ts",
|
||||
"tests/unit/publicCreds.test.ts",
|
||||
"tests/unit/qoder-oauth-config.test.ts",
|
||||
"tests/unit/quota-exhaustion-cutoff-opencode.test.ts",
|
||||
"tests/unit/quota-groups-route.test.ts",
|
||||
"tests/unit/quota-key-models-route.test.ts",
|
||||
"tests/unit/quota-policy-generalization.test.ts",
|
||||
|
||||
@@ -39,10 +39,6 @@ const qdrantEmbeddingModelsRoute =
|
||||
|
||||
// ── Helpers ──
|
||||
|
||||
// Route handlers are typed against NextRequest; the management-session helper
|
||||
// returns the Fetch API Request, which is structurally sufficient at runtime.
|
||||
const asNextRequest = (req: Request) => req as unknown as import("next/server").NextRequest;
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
@@ -95,7 +91,7 @@ test.after(async () => {
|
||||
|
||||
test("GET /api/settings/qdrant — returns settings with masked API key shape", async () => {
|
||||
const req = await makeAuthRequest("GET", "http://localhost/api/settings/qdrant");
|
||||
const res = await qdrantSettingsRoute.GET(asNextRequest(req));
|
||||
const res = await qdrantSettingsRoute.GET(req as any);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
@@ -114,7 +110,7 @@ test("GET /api/settings/qdrant — returns settings with masked API key shape",
|
||||
test("GET /api/settings/qdrant — 401 without auth", async () => {
|
||||
await setRequireLogin(true);
|
||||
const req = makeUnauthRequest("GET", "http://localhost/api/settings/qdrant");
|
||||
const res = await qdrantSettingsRoute.GET(asNextRequest(req));
|
||||
const res = await qdrantSettingsRoute.GET(req as any);
|
||||
assert.strictEqual(res.status, 401);
|
||||
await setRequireLogin(false);
|
||||
});
|
||||
@@ -130,7 +126,7 @@ test("PUT /api/settings/qdrant — updates settings and returns new masked shape
|
||||
embeddingModel: "openai/text-embedding-3-small",
|
||||
});
|
||||
|
||||
const res = await qdrantSettingsRoute.PUT(asNextRequest(req));
|
||||
const res = await qdrantSettingsRoute.PUT(req as any);
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
const body = await res.json();
|
||||
@@ -152,7 +148,7 @@ test("PUT enabled=true also activates Qdrant as the engine (memoryVectorStore=qd
|
||||
host: "qdrant-server",
|
||||
collection: "c",
|
||||
});
|
||||
const res = await qdrantSettingsRoute.PUT(asNextRequest(req));
|
||||
const res = await qdrantSettingsRoute.PUT(req as any);
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
const s = (await localDb.getSettings()) as Record<string, unknown>;
|
||||
@@ -165,20 +161,16 @@ test("PUT enabled=true also activates Qdrant as the engine (memoryVectorStore=qd
|
||||
|
||||
test("PUT enabled=false resets the engine back to auto (sqlite-vec)", async () => {
|
||||
await qdrantSettingsRoute.PUT(
|
||||
asNextRequest(
|
||||
await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
|
||||
enabled: true,
|
||||
host: "qdrant-server",
|
||||
collection: "c",
|
||||
})
|
||||
)
|
||||
(await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
|
||||
enabled: true,
|
||||
host: "qdrant-server",
|
||||
collection: "c",
|
||||
})) as any
|
||||
);
|
||||
await qdrantSettingsRoute.PUT(
|
||||
asNextRequest(
|
||||
await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
|
||||
enabled: false,
|
||||
})
|
||||
)
|
||||
(await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
|
||||
enabled: false,
|
||||
})) as any
|
||||
);
|
||||
|
||||
const s = (await localDb.getSettings()) as Record<string, unknown>;
|
||||
@@ -193,11 +185,9 @@ test("PUT without the enabled field must not change memoryVectorStore", async ()
|
||||
// User already on qdrant; editing only the collection must not reset the engine.
|
||||
await localDb.updateSettings({ memoryVectorStore: "qdrant", qdrantEnabled: true });
|
||||
await qdrantSettingsRoute.PUT(
|
||||
asNextRequest(
|
||||
await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
|
||||
collection: "renamed",
|
||||
})
|
||||
)
|
||||
(await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
|
||||
collection: "renamed",
|
||||
})) as any
|
||||
);
|
||||
|
||||
const s = (await localDb.getSettings()) as Record<string, unknown>;
|
||||
@@ -221,13 +211,11 @@ test("PUT enabled=true invalidates the memory-settings cache (retrieval sees qdr
|
||||
);
|
||||
|
||||
const res = await qdrantSettingsRoute.PUT(
|
||||
asNextRequest(
|
||||
await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
|
||||
enabled: true,
|
||||
host: "qdrant-server",
|
||||
collection: "c",
|
||||
})
|
||||
)
|
||||
(await makeAuthRequest("PUT", "http://localhost/api/settings/qdrant", {
|
||||
enabled: true,
|
||||
host: "qdrant-server",
|
||||
collection: "c",
|
||||
})) as any
|
||||
);
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
@@ -246,7 +234,7 @@ test("PUT /api/settings/qdrant — 400 invalid settings (invalid port type in st
|
||||
port: "not-a-number",
|
||||
});
|
||||
|
||||
const res = await qdrantSettingsRoute.PUT(asNextRequest(req));
|
||||
const res = await qdrantSettingsRoute.PUT(req as any);
|
||||
assert.strictEqual(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.ok(body.message || body.error, "should return error");
|
||||
@@ -255,7 +243,7 @@ test("PUT /api/settings/qdrant — 400 invalid settings (invalid port type in st
|
||||
test("PUT /api/settings/qdrant — 401 without auth", async () => {
|
||||
await setRequireLogin(true);
|
||||
const req = makeUnauthRequest("PUT", "http://localhost/api/settings/qdrant", { enabled: true });
|
||||
const res = await qdrantSettingsRoute.PUT(asNextRequest(req));
|
||||
const res = await qdrantSettingsRoute.PUT(req as any);
|
||||
assert.strictEqual(res.status, 401);
|
||||
await setRequireLogin(false);
|
||||
});
|
||||
@@ -269,7 +257,7 @@ test("GET /api/settings/qdrant/health — returns health result shape (qdrant di
|
||||
headers: Object.fromEntries(headers.entries()),
|
||||
});
|
||||
|
||||
const res = await qdrantHealthRoute.GET(asNextRequest(req));
|
||||
const res = await qdrantHealthRoute.GET(req as any);
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
const body = await res.json();
|
||||
@@ -304,7 +292,7 @@ test("GET /api/settings/qdrant/health — reports named collection vector metada
|
||||
|
||||
try {
|
||||
const req = await makeAuthRequest("GET", "http://localhost/api/settings/qdrant/health");
|
||||
const res = await qdrantHealthRoute.GET(asNextRequest(req));
|
||||
const res = await qdrantHealthRoute.GET(req as any);
|
||||
const body = await res.json();
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
@@ -321,7 +309,7 @@ test("GET /api/settings/qdrant/health — reports named collection vector metada
|
||||
test("GET /api/settings/qdrant/health — 401 without auth", async () => {
|
||||
await setRequireLogin(true);
|
||||
const req = makeUnauthRequest("GET", "http://localhost/api/settings/qdrant/health");
|
||||
const res = await qdrantHealthRoute.GET(asNextRequest(req));
|
||||
const res = await qdrantHealthRoute.GET(req as any);
|
||||
assert.strictEqual(res.status, 401);
|
||||
await setRequireLogin(false);
|
||||
});
|
||||
@@ -334,7 +322,7 @@ test("POST /api/settings/qdrant/search — returns ok + results array", async ()
|
||||
topK: 5,
|
||||
});
|
||||
|
||||
const res = await qdrantSearchRoute.POST(asNextRequest(req));
|
||||
const res = await qdrantSearchRoute.POST(req as any);
|
||||
assert.strictEqual(res.status, 200);
|
||||
|
||||
const body = await res.json();
|
||||
@@ -348,7 +336,7 @@ test("POST /api/settings/qdrant/search — 400 invalid body (empty query)", asyn
|
||||
topK: 5,
|
||||
});
|
||||
|
||||
const res = await qdrantSearchRoute.POST(asNextRequest(req));
|
||||
const res = await qdrantSearchRoute.POST(req as any);
|
||||
assert.strictEqual(res.status, 400);
|
||||
const body = await res.json();
|
||||
assert.ok(body.message || body.error, "should return error");
|
||||
@@ -358,7 +346,7 @@ test("POST /api/settings/qdrant/search — 400 invalid body (empty query)", asyn
|
||||
|
||||
test("POST /api/settings/qdrant/cleanup — returns ok + deletedCount + retentionDays", async () => {
|
||||
const req = await makeAuthRequest("POST", "http://localhost/api/settings/qdrant/cleanup");
|
||||
const res = await qdrantCleanupRoute.POST(asNextRequest(req));
|
||||
const res = await qdrantCleanupRoute.POST(req as any);
|
||||
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
@@ -377,7 +365,7 @@ test("GET /api/settings/qdrant/embedding-models — returns models array", async
|
||||
headers: Object.fromEntries(headers.entries()),
|
||||
});
|
||||
|
||||
const res = await qdrantEmbeddingModelsRoute.GET(asNextRequest(req));
|
||||
const res = await qdrantEmbeddingModelsRoute.GET(req as any);
|
||||
// 200 expected; verify shape
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
@@ -399,7 +387,7 @@ test("GET /api/settings/qdrant/embedding-models — lists only configured provid
|
||||
headers: Object.fromEntries(headers.entries()),
|
||||
});
|
||||
|
||||
const res = await qdrantEmbeddingModelsRoute.GET(asNextRequest(req));
|
||||
const res = await qdrantEmbeddingModelsRoute.GET(req as any);
|
||||
assert.strictEqual(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.ok(body.models.length > 0, "should list models for configured provider");
|
||||
@@ -412,7 +400,7 @@ test("GET /api/settings/qdrant/embedding-models — lists only configured provid
|
||||
test("GET /api/settings/qdrant/embedding-models — 401 without auth", async () => {
|
||||
await setRequireLogin(true);
|
||||
const req = makeUnauthRequest("GET", "http://localhost/api/settings/qdrant/embedding-models");
|
||||
const res = await qdrantEmbeddingModelsRoute.GET(asNextRequest(req));
|
||||
const res = await qdrantEmbeddingModelsRoute.GET(req as any);
|
||||
assert.strictEqual(res.status, 401);
|
||||
await setRequireLogin(false);
|
||||
});
|
||||
@@ -428,7 +416,7 @@ test("Qdrant routes — error response has no stack trace in body", async () =>
|
||||
body: "not-valid-json{{{",
|
||||
});
|
||||
|
||||
const res = await qdrantSettingsRoute.PUT(asNextRequest(req));
|
||||
const res = await qdrantSettingsRoute.PUT(req as any);
|
||||
assert.ok(res.status >= 400, "should return error status");
|
||||
|
||||
const body = await res.json();
|
||||
|
||||
@@ -6,8 +6,8 @@ import { getRegistryEntry } from "../../open-sse/config/providerRegistry.ts";
|
||||
const { getNextFamilyFallback } = await import("../../open-sse/services/modelFamilyFallback.ts");
|
||||
|
||||
// Regression for #8134 — GitHub Copilot ("github", alias "gh") T5 family fallback
|
||||
// returned "claude-opus-4-6" verbatim even though the github registry catalog at
|
||||
// the time (Opus 4.8 / 4.8-fast / 4.7 / 4.5) had NO 4.6 tier under any dot/hyphen
|
||||
// returned "claude-opus-4-6" verbatim even though the github registry catalog
|
||||
// (Opus 4.8 / 4.8-fast / 4.7 / 4.5) has NO 4.6 tier under any dot/hyphen
|
||||
// notation. getNextFamilyFallback() resolved `supportedIds` from the provider's
|
||||
// registry but only used it to try notation variants of a candidate, never to
|
||||
// filter out a candidate that is provably absent from the catalog — so the
|
||||
@@ -18,46 +18,35 @@ const { getNextFamilyFallback } = await import("../../open-sse/services/modelFam
|
||||
// skips (continue) any family candidate that has no match in supportedIds
|
||||
// under ANY notation (hyphen, dot, or a dated-snapshot id with the date
|
||||
// suffix stripped) instead of returning it unfiltered.
|
||||
//
|
||||
// Fixture note: #10952 later added claude-opus-4.6 to the github registry, so
|
||||
// the provably-absent tier used by the fixture moved to claude-opus-4-6-thinking
|
||||
// (the ladder's first candidate after 4.6 — still absent from the catalog).
|
||||
|
||||
test("#8134: github claude-opus fallback chain never returns an unsupported tier (claude-opus-4-6-thinking)", () => {
|
||||
test("#8134: github claude-opus-4.8 fallback chain never returns an unsupported tier (claude-opus-4-6)", () => {
|
||||
const github = getRegistryEntry("github");
|
||||
assert.ok(github, "expected the github registry entry to resolve");
|
||||
const githubIds = new Set(github.models.map((m) => m.id));
|
||||
// Fixture assumption: #10952 added claude-opus-4.6 to the github registry, so
|
||||
// the original absent-tier role moved to the 4.6-thinking variant, which the
|
||||
// catalog still does NOT carry under any notation.
|
||||
assert.ok(
|
||||
!githubIds.has("claude-opus-4-6-thinking") && !githubIds.has("claude-opus-4.6-thinking"),
|
||||
"fixture assumption broken: github registry now has a 4.6-thinking tier"
|
||||
!githubIds.has("claude-opus-4-6") && !githubIds.has("claude-opus-4.6"),
|
||||
"fixture assumption broken: github registry now has a 4.6 tier"
|
||||
);
|
||||
|
||||
// Ladder reality: 4.8 -> 4.7 -> 4.6 -> [4-6-thinking (absent), 4-5-20251101,
|
||||
// sonnet-5]. The absent 4-6-thinking must be SKIPPED — the third hop resolves
|
||||
// to the dated 4.5 snapshot's undated catalog entry, never to 4-6-thinking.
|
||||
const tried = new Set(["github/claude-opus-4.8"]);
|
||||
const hops: string[] = [];
|
||||
let current = "github/claude-opus-4.8";
|
||||
for (let hop = 0; hop < 3; hop++) {
|
||||
const next = getNextFamilyFallback(current, tried);
|
||||
assert.ok(next, `hop ${hop + 1}: family must not be silently exhausted`);
|
||||
const bareId = next!.replace(/^github\//, "");
|
||||
assert.ok(
|
||||
githubIds.has(bareId),
|
||||
`hop ${hop + 1}: "${next}" is not in github's registered model catalog: ${[...githubIds].join(", ")}`
|
||||
);
|
||||
assert.notEqual(bareId, "claude-opus-4-6-thinking");
|
||||
assert.notEqual(bareId, "claude-opus-4.6-thinking");
|
||||
tried.add(next!);
|
||||
hops.push(next!);
|
||||
current = next!;
|
||||
}
|
||||
// The skip specifically fired: the 4.6 -> next hop jumped past the absent
|
||||
// 4-6-thinking tier straight to a catalogued model.
|
||||
assert.equal(hops[2].replace(/^github\//, ""), "claude-opus-4.5");
|
||||
const first = getNextFamilyFallback("github/claude-opus-4.8", tried);
|
||||
assert.ok(first, "expected a first fallback candidate");
|
||||
const firstBareId = first.replace(/^github\//, "");
|
||||
assert.ok(
|
||||
githubIds.has(firstBareId),
|
||||
`first fallback "${first}" is not in github's registered model catalog: ${[...githubIds].join(", ")}`
|
||||
);
|
||||
|
||||
tried.add(first);
|
||||
const second = getNextFamilyFallback(first, tried);
|
||||
assert.ok(second, "expected a second fallback candidate (family must not be silently exhausted)");
|
||||
const secondBareId = second.replace(/^github\//, "");
|
||||
assert.ok(
|
||||
githubIds.has(secondBareId),
|
||||
`second fallback "${second}" is not in github's registered model catalog: ${[...githubIds].join(", ")}`
|
||||
);
|
||||
assert.notEqual(secondBareId, "claude-opus-4-6");
|
||||
assert.notEqual(secondBareId, "claude-opus-4.6");
|
||||
});
|
||||
|
||||
test("#8134: getNextFamilyFallback never returns a candidate absent from the resolved provider's catalog", () => {
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
/**
|
||||
* 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");
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,7 @@ const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const TASKS_ROUTE = path.resolve(__dirname, "../../src/app/api/a2a/tasks/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 A2A_ROUTE = path.resolve(__dirname, "../../src/app/a2a/route.ts");
|
||||
|
||||
const source = fs.readFileSync(TASKS_ROUTE, "utf-8");
|
||||
|
||||
@@ -23,11 +21,11 @@ function hasImport(src: string, name: string, from: string): boolean {
|
||||
return pattern.test(src);
|
||||
}
|
||||
|
||||
test("tasks route uses the same constant-time contract as the shared A2A auth helper", () => {
|
||||
const a2aSource = fs.readFileSync(A2A_AUTH_HELPER, "utf-8");
|
||||
test("tasks route uses the same constant-time contract as src/app/a2a/route.ts", () => {
|
||||
const a2aSource = fs.readFileSync(A2A_ROUTE, "utf-8");
|
||||
assert.ok(
|
||||
hasImport(a2aSource, "timingSafeEqual", "crypto"),
|
||||
"shared auth helper imports timingSafeEqual"
|
||||
hasImport(a2aSource, "timingSafeEqual", "node:crypto"),
|
||||
"reference route imports timingSafeEqual"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
|
||||
@@ -22,22 +22,6 @@ 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);
|
||||
@@ -105,19 +89,6 @@ 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);
|
||||
|
||||
@@ -82,13 +82,11 @@ 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, 14);
|
||||
assert.equal(SPAWN_CAPABLE_PREFIXES.length, 12);
|
||||
});
|
||||
|
||||
@@ -61,11 +61,6 @@ function hasImporter(mod: string, roots: string[]): boolean {
|
||||
new RegExp(`(?:import|require)\\s*\\(\\s*['""][^'"]+/db/${escaped}['"]`),
|
||||
// dynamic template: import(`…/db/<mod>.ts`) — bin/cli/runtime.mjs uses template literals
|
||||
new RegExp(`import\\s*\\(\`[^'"\`]+/db/${escaped}\\.ts\`\\)`),
|
||||
// dynamic via file:// URL helper: import(projectFileUrl("…/db/<mod>.ts")) —
|
||||
// bin/cli/runtime.mjs since #11238 (Windows-safe file:// dynamic imports).
|
||||
new RegExp(
|
||||
`import\\s*\\(\\s*projectFileUrl\\(\\s*['""][^'"]+/db/${escaped}\\.ts['"]\\s*\\)\\s*\\)`
|
||||
),
|
||||
// relative import within db/: from "./<mod>" or from "./<mod>"
|
||||
new RegExp(`from\\s+['"]\\.\\.?/${escaped}['"]`),
|
||||
];
|
||||
|
||||
@@ -41,8 +41,8 @@ test("CLI_TOOLS total code entries (including none) equals 26 (21 visible + 5 no
|
||||
assert.equal(codeAll.length, 26, `Expected 26 total code entries, got ${codeAll.length}`);
|
||||
});
|
||||
|
||||
test("CLI_TOOLS total (code + agent) = 35", () => {
|
||||
assert.equal(all.length, 35, `Expected 35 total entries, got ${all.length}`);
|
||||
test("CLI_TOOLS total (code + agent) = 34", () => {
|
||||
assert.equal(all.length, 34, `Expected 34 total entries, got ${all.length}`);
|
||||
});
|
||||
|
||||
test("All code-none entries have configType mitm OR are legacy excluded entries", () => {
|
||||
@@ -99,7 +99,7 @@ test("The 21 visible code entries include Qwen Code's rebuilt integration", () =
|
||||
}
|
||||
});
|
||||
|
||||
test("The 9 agent entries match D15 list exactly (+ omp + letta #6318, + prime-agent #11166)", () => {
|
||||
test("The 8 agent entries match D15 list exactly (+ omp + letta, #6318)", () => {
|
||||
const d15Agents = new Set([
|
||||
"hermes-agent",
|
||||
"openclaw",
|
||||
@@ -109,7 +109,6 @@ test("The 9 agent entries match D15 list exactly (+ omp + letta #6318, + prime-a
|
||||
"agent-deck",
|
||||
"omp",
|
||||
"letta",
|
||||
"prime-agent",
|
||||
]);
|
||||
const agentIds = new Set(agentAll.map((t) => t.id));
|
||||
for (const id of d15Agents) {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveServerHost, resolveExposureWarning } from "../../bin/cli/utils/serverHost.mjs";
|
||||
import { resolveServerHost } from "../../bin/cli/utils/serverHost.mjs";
|
||||
|
||||
test("serve hostname: Linux honors OMNIROUTE_SERVER_HOST when HOSTNAME is set", () => {
|
||||
assert.equal(
|
||||
@@ -55,26 +55,3 @@ test("serve hostname: Windows preserves an explicit legacy HOSTNAME", () => {
|
||||
test("serve hostname: Windows ignores an auto-set HOSTNAME matching the machine", () => {
|
||||
assert.equal(resolveServerHost({ HOSTNAME: "windows-pc" }, "win32", "windows-pc"), "0.0.0.0");
|
||||
});
|
||||
|
||||
test("exposure warning: fires when bound to all interfaces with no API-key requirement (GHSA-wmgv-ph3p-rv57)", () => {
|
||||
const warning = resolveExposureWarning({}, "0.0.0.0");
|
||||
assert.ok(warning, "a warning must be returned for the shipped default posture");
|
||||
assert.match(warning, /REQUIRE_API_KEY/);
|
||||
assert.match(warning, /OMNIROUTE_SERVER_HOST/);
|
||||
});
|
||||
|
||||
test("exposure warning: silent when REQUIRE_API_KEY is enabled", () => {
|
||||
assert.equal(resolveExposureWarning({ REQUIRE_API_KEY: "true" }, "0.0.0.0"), null);
|
||||
assert.equal(resolveExposureWarning({ REQUIRE_API_KEY: "1" }, "0.0.0.0"), null);
|
||||
});
|
||||
|
||||
test("exposure warning: silent on loopback binds", () => {
|
||||
assert.equal(resolveExposureWarning({}, "127.0.0.1"), null);
|
||||
assert.equal(resolveExposureWarning({}, "localhost"), null);
|
||||
assert.equal(resolveExposureWarning({}, "::1"), null);
|
||||
});
|
||||
|
||||
test("exposure warning: fires for a LAN bind too (any non-loopback interface)", () => {
|
||||
assert.ok(resolveExposureWarning({}, "192.168.0.17"));
|
||||
assert.ok(resolveExposureWarning({}, "::"));
|
||||
});
|
||||
|
||||
@@ -74,9 +74,6 @@ describe("omniroute setup opencode", () => {
|
||||
// Commander turns `--base-url` into `baseUrl` — the runner must accept it.
|
||||
baseUrl: "http://10.0.0.5:20128",
|
||||
nonInteractive: true,
|
||||
// These tests exercise the plugin install/merge path, not the container
|
||||
// guard (#10057) — keep them hermetic on container devboxes/CI.
|
||||
allowContainerWrite: true,
|
||||
});
|
||||
assert.equal(r.exitCode, 0);
|
||||
|
||||
@@ -102,7 +99,6 @@ describe("omniroute setup opencode", () => {
|
||||
configDir: CONFIG_DIR,
|
||||
baseUrl: "http://10.0.0.9:20128",
|
||||
nonInteractive: true,
|
||||
allowContainerWrite: true,
|
||||
});
|
||||
assert.equal(r.exitCode, 0);
|
||||
|
||||
@@ -131,11 +127,7 @@ describe("omniroute setup opencode", () => {
|
||||
})
|
||||
);
|
||||
|
||||
const r = await runSetupOpenCodeCommand({
|
||||
configDir: CONFIG_DIR,
|
||||
nonInteractive: true,
|
||||
allowContainerWrite: true,
|
||||
});
|
||||
const r = await runSetupOpenCodeCommand({ configDir: CONFIG_DIR, nonInteractive: true });
|
||||
assert.equal(r.exitCode, 0);
|
||||
|
||||
const cfg = readConfig();
|
||||
@@ -148,11 +140,7 @@ describe("omniroute setup opencode", () => {
|
||||
it("fails with a clear error (exit 1) when the bundled plugin dist is missing", async () => {
|
||||
fs.rmSync(path.join(FAKE_PLUGIN_DIR, "dist"), { recursive: true, force: true });
|
||||
try {
|
||||
const r = await runSetupOpenCodeCommand({
|
||||
configDir: CONFIG_DIR,
|
||||
nonInteractive: true,
|
||||
allowContainerWrite: true,
|
||||
});
|
||||
const r = await runSetupOpenCodeCommand({ configDir: CONFIG_DIR, nonInteractive: true });
|
||||
assert.equal(r.exitCode, 1);
|
||||
} finally {
|
||||
makeFakePluginDist();
|
||||
|
||||
@@ -15,10 +15,6 @@ const originalFetch = globalThis.fetch;
|
||||
const originalJwtSecret = process.env.JWT_SECRET;
|
||||
const originalApiKeySecret = process.env.API_KEY_SECRET;
|
||||
const originalXdg = process.env.XDG_CONFIG_HOME;
|
||||
const originalAllowContainerWrite = process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE;
|
||||
// This test exercises the apply/merge path, not the container guard (#10057) —
|
||||
// keep it hermetic on container devboxes/CI.
|
||||
process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE = "1";
|
||||
const testRoots = new Set<string>();
|
||||
|
||||
async function createAuthCookie(): Promise<string> {
|
||||
@@ -76,9 +72,6 @@ test.afterEach(async () => {
|
||||
else process.env.API_KEY_SECRET = originalApiKeySecret;
|
||||
if (originalXdg === undefined) delete process.env.XDG_CONFIG_HOME;
|
||||
else process.env.XDG_CONFIG_HOME = originalXdg;
|
||||
if (originalAllowContainerWrite === undefined)
|
||||
delete process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE;
|
||||
else process.env.OMNIROUTE_ALLOW_CONTAINER_CONFIG_WRITE = originalAllowContainerWrite;
|
||||
for (const root of testRoots) await fs.rm(root, { recursive: true, force: true });
|
||||
testRoots.clear();
|
||||
});
|
||||
|
||||
@@ -11,7 +11,6 @@ test("CLI_TOOLS registry contains all expected tools including rebuilt Qwen Code
|
||||
// (CodeWhale is the actively-maintained successor to DeepSeek TUI).
|
||||
// omp + letta added by #6318 (agent-category CLI integrations).
|
||||
// grok-build added — xAI Grok Build TUI coding agent (ported from upstream decolua/9router#2571).
|
||||
// prime-agent added by #11166 (PrimeIntellect-ai/prime-agent, agent category).
|
||||
const expected = [
|
||||
"claude",
|
||||
"codex",
|
||||
@@ -47,7 +46,6 @@ test("CLI_TOOLS registry contains all expected tools including rebuilt Qwen Code
|
||||
"grok-build",
|
||||
"qwen",
|
||||
"zcode",
|
||||
"prime-agent",
|
||||
];
|
||||
for (const id of expected) {
|
||||
assert.ok(id in CLI_TOOLS, `Missing tool: ${id}`);
|
||||
|
||||
@@ -106,9 +106,7 @@ test("CLI fingerprint preserves Codex executor User-Agent and maps legacy Copilo
|
||||
{ model: "gpt-4o", messages: [] }
|
||||
);
|
||||
|
||||
// #10952 bumped GITHUB_COPILOT_CLI_VERSION 0.54.0 -> 1.0.81-6; the fingerprint
|
||||
// pin tracks the advertised upstream CLI version.
|
||||
assert.equal(copilot.headers["User-Agent"], "GitHubCopilotChat/1.0.81-6");
|
||||
assert.equal(copilot.headers["User-Agent"], "GitHubCopilotChat/0.54.0");
|
||||
});
|
||||
|
||||
test("CLI fingerprint keeps legacy Copilot settings functional without exposing duplicate UI toggles", () => {
|
||||
|
||||
@@ -42,9 +42,6 @@ test("setup-qwen writes current V4 settings and only its dedicated env key", asy
|
||||
configPath: settingsPath,
|
||||
envPath,
|
||||
yes: true,
|
||||
// These tests exercise the merge/write logic, not the container guard
|
||||
// (#10057) — keep them hermetic on container devboxes/CI.
|
||||
allowContainerWrite: true,
|
||||
});
|
||||
assert.equal(code, 0);
|
||||
|
||||
@@ -79,8 +76,6 @@ test("setup-qwen does not overwrite an invalid settings file", async () => {
|
||||
model: "model-id",
|
||||
configPath: settingsPath,
|
||||
yes: true,
|
||||
// See above — hermetic regardless of container detection (#10057).
|
||||
allowContainerWrite: true,
|
||||
});
|
||||
assert.equal(code, 1);
|
||||
assert.equal(await fs.readFile(settingsPath, "utf8"), "{ invalid JSON");
|
||||
|
||||
@@ -497,13 +497,7 @@ test(
|
||||
}
|
||||
);
|
||||
|
||||
test("build phase returns the no-op stub without creating sqlite files", serial, async () => {
|
||||
// Contract changed by #10060 (via #10952): the build phase no longer opens a
|
||||
// real in-memory SQLite with migrations — loading the native better-sqlite3
|
||||
// addon aborts the Next.js build worker on exit (node::
|
||||
// RemoveEnvironmentCleanupHook). getDbInstance() now returns a no-op stub
|
||||
// (pinned by tests/unit/build/10060-build-sqlite-stub.test.ts); queries are
|
||||
// harmless no-ops and no file is touched.
|
||||
test("build phase uses an in-memory database without creating sqlite files", serial, async () => {
|
||||
const dataDir = makeTempDir("omniroute-db-build-");
|
||||
|
||||
try {
|
||||
@@ -516,15 +510,13 @@ test("build phase returns the no-op stub without creating sqlite files", serial,
|
||||
const core = await importFresh("src/lib/db/core.ts");
|
||||
const db = core.getDbInstance();
|
||||
|
||||
assert.notEqual(db.driver, "better-sqlite3");
|
||||
assert.equal(
|
||||
assert.ok(
|
||||
db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("provider_connections"),
|
||||
undefined,
|
||||
"the build stub must answer queries with no-ops, never a real table scan"
|
||||
.get("provider_connections")
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(dataDir, "storage.sqlite")), false);
|
||||
assert.equal(db.pragma("journal_mode", { simple: true }), "memory");
|
||||
|
||||
core.resetDbInstance();
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* response (/api/v1/models), including the learned-only variant entry.
|
||||
* Never "fix" this test by injecting the same string on both sides.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import { test, after, beforeEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
|
||||
@@ -79,49 +79,3 @@ test("findKiroConnectionByIdentity never overwrites a different authentication t
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
// #10815 — a profile ARN identifies the CodeWhisperer profile, not the account: two
|
||||
// distinct social (Google/GitHub) Builder ID accounts share the same ARN, so matching
|
||||
// on it alone made the second login overwrite the first connection.
|
||||
const SHARED_PROFILE_ARN = "arn:aws:codewhisperer:us-east-1:1:profile/SHARED";
|
||||
|
||||
const firstSocialAccount = {
|
||||
id: "social-account-1",
|
||||
authType: "oauth",
|
||||
name: null,
|
||||
email: null,
|
||||
providerSpecificData: {
|
||||
profileArn: SHARED_PROFILE_ARN,
|
||||
authMethod: "imported",
|
||||
provider: "Github",
|
||||
},
|
||||
};
|
||||
|
||||
test("findKiroConnectionByIdentity does not match a shared profile ARN without an account identifier", () => {
|
||||
const match = findKiroConnectionByIdentity([firstSocialAccount], {
|
||||
authType: "oauth",
|
||||
profileArn: SHARED_PROFILE_ARN,
|
||||
email: null,
|
||||
});
|
||||
assert.equal(match, null);
|
||||
});
|
||||
|
||||
test("findKiroConnectionByIdentity treats diverging emails on a shared profile ARN as distinct accounts", () => {
|
||||
const stored = { ...firstSocialAccount, id: "social-a", email: "a@example.com" };
|
||||
const match = findKiroConnectionByIdentity([stored], {
|
||||
authType: "oauth",
|
||||
profileArn: SHARED_PROFILE_ARN,
|
||||
email: "b@example.com",
|
||||
});
|
||||
assert.equal(match, null);
|
||||
});
|
||||
|
||||
test("findKiroConnectionByIdentity still matches the same account on a shared profile ARN", () => {
|
||||
const stored = { ...firstSocialAccount, id: "social-a", email: "a@example.com" };
|
||||
const match = findKiroConnectionByIdentity([stored], {
|
||||
authType: "oauth",
|
||||
profileArn: SHARED_PROFILE_ARN,
|
||||
email: "a@example.com",
|
||||
});
|
||||
assert.equal(match?.id, "social-a");
|
||||
});
|
||||
|
||||
@@ -39,20 +39,16 @@ function multiTurn(): ChatRequest {
|
||||
|
||||
describe("injectMemory cache-safe positioning (#3890)", () => {
|
||||
it("default (cacheSafe off) prepends memory at index 0 — unchanged legacy behavior", () => {
|
||||
const out = injectMemory(multiTurn(), [mem("dark mode")], "openai");
|
||||
const out = injectMemory(multiTurn(), [mem("dark mode")], "anthropic");
|
||||
assert.equal(out.messages[0].role, "system");
|
||||
assert.ok(out.messages[0].content.includes("Memory context"));
|
||||
assert.equal(out.messages[1].content, "SYSTEM PROMPT");
|
||||
});
|
||||
|
||||
// Note: "openai" here stands in for any non-Claude-family provider that honors the
|
||||
// cache-safe mid-array splice (e.g. DashScope/Xiaomi MiMo via OpenAI-format
|
||||
// cache_control). Claude-family providers (anthropic/claude/CC-compatible) have their
|
||||
// own, narrower gate covered in the "#11290" describe block below.
|
||||
it("cacheSafe inserts memory just before the last user message, preserving the prefix", () => {
|
||||
const req = multiTurn();
|
||||
const prefixBefore = JSON.stringify(req.messages.slice(0, 3)); // sys, u1, a1
|
||||
const out = injectMemory(req, [mem("dark mode")], "openai", { cacheSafe: true });
|
||||
const out = injectMemory(req, [mem("dark mode")], "anthropic", { cacheSafe: true });
|
||||
|
||||
// The cacheable prefix (system + prior turns up to the last assistant) is byte-identical.
|
||||
assert.equal(JSON.stringify(out.messages.slice(0, 3)), prefixBefore);
|
||||
@@ -79,8 +75,8 @@ describe("injectMemory cache-safe positioning (#3890)", () => {
|
||||
};
|
||||
const turn2 = multiTurn();
|
||||
|
||||
const out1 = injectMemory(turn1, [mem("A")], "openai", { cacheSafe: true });
|
||||
const out2 = injectMemory(turn2, [mem("B")], "openai", { cacheSafe: true });
|
||||
const out1 = injectMemory(turn1, [mem("A")], "anthropic", { cacheSafe: true });
|
||||
const out2 = injectMemory(turn2, [mem("B")], "anthropic", { cacheSafe: true });
|
||||
|
||||
// The cache-breakpoint-bearing system message stays at the head, byte-identical, in
|
||||
// both turns (and is NOT displaced by the per-query memory) — so the prompt cache
|
||||
@@ -107,76 +103,3 @@ describe("injectMemory cache-safe positioning (#3890)", () => {
|
||||
assert.equal(out.messages[1].content, "SYS");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* #11290: Claude Opus 5 tightened server-side validation and started rejecting the
|
||||
* #3890 cache-safe mid-array splice with HTTP 400 whenever the assistant turn
|
||||
* immediately before the splice point is a plain-text turn (not a server-side tool
|
||||
* result). These tests pin the narrower, Claude-family-only gate added to
|
||||
* `injectMemory()`: fall back to leading-system-message placement in that specific
|
||||
* case, while still honoring the mid-array splice everywhere it is safe (non-Claude
|
||||
* providers unconditionally, and Claude providers whose preceding turn IS a server
|
||||
* tool result).
|
||||
*/
|
||||
describe("injectMemory cache-safe positioning — Claude-family server-tool-result gate (#11290)", () => {
|
||||
it("falls back to leading system-message placement for anthropic when the preceding assistant turn is plain text", () => {
|
||||
const out = injectMemory(multiTurn(), [mem("dark mode")], "anthropic", { cacheSafe: true });
|
||||
|
||||
// No splice: the memory is merged into the leading system message instead of being
|
||||
// inserted right after the plain-text "turn 1 answer" assistant turn.
|
||||
assert.equal(out.messages.length, 4);
|
||||
assert.equal(out.messages[0].role, "system");
|
||||
assert.ok(out.messages[0].content.includes("Memory context: dark mode"));
|
||||
assert.ok(out.messages[0].content.includes("SYSTEM PROMPT"));
|
||||
assert.equal(out.messages[1].content, "turn 1 question");
|
||||
assert.equal(out.messages[2].content, "turn 1 answer");
|
||||
assert.equal(out.messages[3].content, "turn 2 question");
|
||||
});
|
||||
|
||||
it("still splices mid-array for anthropic when the preceding assistant turn ends in a server tool result", () => {
|
||||
const req: ChatRequest = {
|
||||
model: "anthropic/claude-opus-5",
|
||||
messages: [
|
||||
{ role: "system", content: "SYSTEM PROMPT" },
|
||||
{ role: "user", content: "turn 1 question" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "server_tool_use", id: "srvtoolu_1", name: "web_search", input: {} },
|
||||
{ type: "web_search_tool_result", tool_use_id: "srvtoolu_1", content: [] },
|
||||
],
|
||||
} as unknown as ChatRequest["messages"][number],
|
||||
{ role: "user", content: "turn 2 question" },
|
||||
],
|
||||
};
|
||||
|
||||
const out = injectMemory(req, [mem("dark mode")], "anthropic", { cacheSafe: true });
|
||||
|
||||
assert.equal(out.messages.length, 5);
|
||||
assert.equal(out.messages[0].content, "SYSTEM PROMPT");
|
||||
assert.equal(out.messages[3].role, "system");
|
||||
assert.ok(out.messages[3].content.includes("Memory context"));
|
||||
assert.equal(out.messages[4].content, "turn 2 question");
|
||||
});
|
||||
|
||||
it("applies the same fallback to a Claude-Code-compatible passthrough provider id", () => {
|
||||
const out = injectMemory(multiTurn(), [mem("dark mode")], "anthropic-compatible-cc-github-copilot", {
|
||||
cacheSafe: true,
|
||||
});
|
||||
|
||||
assert.equal(out.messages.length, 4);
|
||||
assert.equal(out.messages[0].role, "system");
|
||||
assert.ok(out.messages[0].content.includes("Memory context: dark mode"));
|
||||
assert.ok(out.messages[0].content.includes("SYSTEM PROMPT"));
|
||||
});
|
||||
|
||||
it("does not gate non-Claude providers even without a server tool result", () => {
|
||||
const out = injectMemory(multiTurn(), [mem("dark mode")], "openai", { cacheSafe: true });
|
||||
|
||||
// Unaffected by #11290: the mid-array splice is preserved for non-Claude providers.
|
||||
assert.equal(out.messages.length, 5);
|
||||
assert.equal(out.messages[3].role, "system");
|
||||
assert.ok(out.messages[3].content.includes("Memory context"));
|
||||
assert.equal(out.messages[4].content, "turn 2 question");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -98,11 +98,7 @@ test("single-target Codex combo advertises a larger model context override", asy
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(direct?.context_length, contextWindow);
|
||||
assert.equal(combo?.context_length, contextWindow);
|
||||
// #11179 raised the static codex catalog cap to maxInputTokens=872000 (the real
|
||||
// usable window; the old 272000 was just the first pricing tier). The input cap
|
||||
// can never exceed the total window, so with the 500K override it clamps to it:
|
||||
// min(872000, 500000) = 500000.
|
||||
assert.equal(combo?.max_input_tokens, 500000);
|
||||
assert.equal(combo?.max_input_tokens, 272000);
|
||||
} finally {
|
||||
contextOverrides.removeModelContextOverride("codex", modelId);
|
||||
}
|
||||
|
||||
@@ -181,11 +181,10 @@ test("provider models route merges live Codex models with the local catalog then
|
||||
// merge conservatively — the smaller of live vs. pinned wins, never the
|
||||
// larger, so a stale/inflated live number can never make OmniRoute promise
|
||||
// more context than the account can actually serve (#7012). Here the pinned
|
||||
// GPT-5.6 Codex contract (872000/128000, see GPT_5_6_CODEX_CAPABILITIES — raised
|
||||
// from the old 272K pricing tier to the real usable window by #11179)
|
||||
// GPT-5.6 Codex contract (272000/128000, see GPT_5_6_CODEX_CAPABILITIES)
|
||||
// is smaller than the live payload's 999999/999999, so the pinned value wins.
|
||||
assert.equal(liveModel?.name, "GPT 5.6 Sol Live");
|
||||
assert.equal(liveModel?.inputTokenLimit, 872000);
|
||||
assert.equal(liveModel?.inputTokenLimit, 272000);
|
||||
assert.equal(liveModel?.outputTokenLimit, 128000);
|
||||
assert.equal(liveModel?.apiFormat, "responses");
|
||||
assert.deepEqual(liveModel?.supportedEndpoints, ["responses"]);
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* SSRF guard coverage for /v1/search's shared base-url resolution (GHSA-j7j4-g9qc-q69c).
|
||||
*
|
||||
* `provider_options.baseUrl` (and legacy `providerSpecificData.baseUrl`) is
|
||||
* client-controlled and flowed verbatim through `resolveSearchBaseUrl()` into
|
||||
* every search builder's server-side fetch target (searxng, ollama, …), with
|
||||
* no SSRF validation — while the sink (`searchProxy.ts`) is a plain `fetch()`.
|
||||
* The Firecrawl sibling was fixed in #10738; this shared resolver was missed.
|
||||
*
|
||||
* Guard mode is `block-metadata` (NOT public-only): the catalog's primary
|
||||
* searxng use case is a self-hosted instance on loopback/LAN, so private
|
||||
* hosts must keep working, while cloud-metadata endpoints (IMDS credential
|
||||
* theft — the worst pivot) are rejected.
|
||||
*
|
||||
* Run with:
|
||||
* node --import tsx/esm --test tests/unit/search-baseurl-ssrf-guard.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveSearchBaseUrl } from "../../open-sse/handlers/search.ts";
|
||||
import type { SearchProviderConfig } from "../../open-sse/config/searchRegistry.ts";
|
||||
|
||||
const config: SearchProviderConfig = {
|
||||
id: "searxng-search",
|
||||
name: "SearXNG",
|
||||
baseUrl: "http://127.0.0.1:8888",
|
||||
method: "GET",
|
||||
authType: "none",
|
||||
costPerQuery: 0,
|
||||
} as SearchProviderConfig;
|
||||
|
||||
const base = {
|
||||
query: "test",
|
||||
searchType: "web",
|
||||
maxResults: 5,
|
||||
};
|
||||
|
||||
const METADATA_URLS = [
|
||||
"http://169.254.169.254/latest/meta-data/iam/security-credentials/",
|
||||
"http://169.254.169.254/latest/meta-data/?x=/search", // reporter's suffix-bypass shape
|
||||
"http://metadata.google.internal/computeMetadata/v1/",
|
||||
];
|
||||
|
||||
describe("resolveSearchBaseUrl — SSRF guard on client-controlled baseUrl (GHSA-j7j4)", () => {
|
||||
for (const malicious of METADATA_URLS) {
|
||||
it(`rejects providerOptions.baseUrl pointing at cloud metadata (${malicious})`, () => {
|
||||
assert.throws(() => {
|
||||
resolveSearchBaseUrl(config, { ...base, providerOptions: { baseUrl: malicious } });
|
||||
});
|
||||
});
|
||||
|
||||
it(`rejects providerSpecificData.baseUrl pointing at cloud metadata (${malicious})`, () => {
|
||||
assert.throws(() => {
|
||||
resolveSearchBaseUrl(config, { ...base, providerSpecificData: { baseUrl: malicious } });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it("still allows a self-hosted loopback/LAN override (block-metadata, not public-only)", () => {
|
||||
assert.equal(
|
||||
resolveSearchBaseUrl(config, {
|
||||
...base,
|
||||
providerOptions: { baseUrl: "http://127.0.0.1:9999" },
|
||||
}),
|
||||
"http://127.0.0.1:9999"
|
||||
);
|
||||
assert.equal(
|
||||
resolveSearchBaseUrl(config, {
|
||||
...base,
|
||||
providerOptions: { baseUrl: "http://10.0.0.5:8080" },
|
||||
}),
|
||||
"http://10.0.0.5:8080"
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves the catalog baseUrl untouched when no override is supplied", () => {
|
||||
assert.equal(resolveSearchBaseUrl(config, base), "http://127.0.0.1:8888");
|
||||
});
|
||||
});
|
||||
@@ -420,24 +420,25 @@ test("v1 search POST preserves stored SearXNG baseUrl for authless providers", a
|
||||
}
|
||||
});
|
||||
|
||||
test("v1 search POST falls back to duckduckgo-free when no provider is configured (#11097)", async () => {
|
||||
// Contract changed by PR #11097 ("fix(search): fall back to duckduckgo-free when
|
||||
// no search provider is configured"): zero-credential /v1/search no longer returns
|
||||
// 400 — it promotes the fallback-only duckduckgo-free provider so out-of-the-box
|
||||
// search works. This test pins the NEW contract.
|
||||
test("v1 search POST returns 400 when auto-select finds no configured provider (searxng-search is now fallbackOnly)", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedUrl = "";
|
||||
|
||||
// DuckDuckGo lite HTML shape: result link + snippet cell (see
|
||||
// open-sse/services/freeWebSearch.ts parseDuckDuckGoLite).
|
||||
const liteHtml = `<html><body>
|
||||
<a href="https://example.com/auto-result" class='result-link'>Auto-selected DuckDuckGo result</a>
|
||||
<td class='result-snippet'>Fallback free search snippet</td>
|
||||
</body></html>`;
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
capturedUrl = String(url);
|
||||
return new Response(liteHtml, { status: 200, headers: { "content-type": "text/html" } });
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
results: [
|
||||
{
|
||||
title: "Auto-selected SearXNG result",
|
||||
url: "https://searx.example/auto",
|
||||
content: "Auto-selected self-hosted response",
|
||||
engines: ["duckduckgo"],
|
||||
},
|
||||
],
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -453,15 +454,14 @@ test("v1 search POST falls back to duckduckgo-free when no provider is configure
|
||||
);
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(
|
||||
capturedUrl,
|
||||
"https://lite.duckduckgo.com/lite/",
|
||||
"the fallback must call the DuckDuckGo lite endpoint"
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(capturedUrl, "", "fallback-only SearXNG must not receive an upstream request");
|
||||
assert.ok(body.error?.message || body.error);
|
||||
assert.match(
|
||||
String(body.error?.message ?? body.error),
|
||||
/provider|configured/i,
|
||||
"the response must explain that no provider was selected"
|
||||
);
|
||||
assert.equal(body.provider, "duckduckgo-free");
|
||||
assert.equal(body.results[0].title, "Auto-selected DuckDuckGo result");
|
||||
assert.equal(body.results[0].url, "https://example.com/auto-result");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
@@ -89,10 +89,9 @@ test("applyItemOrder ignores unknown IDs in order list", () => {
|
||||
|
||||
// ─── SIDEBAR_PRESETS ──────────────────────────────────────────────────────────
|
||||
|
||||
test("SIDEBAR_PRESETS contains all five preset IDs", () => {
|
||||
test("SIDEBAR_PRESETS contains all four preset IDs", () => {
|
||||
const ids = SIDEBAR_PRESETS.map((p) => p.id);
|
||||
assert.ok(ids.includes("all"), "expected 'all' preset");
|
||||
assert.ok(ids.includes("essentials"), "expected 'essentials' preset");
|
||||
assert.ok(ids.includes("minimal"), "expected 'minimal' preset");
|
||||
assert.ok(ids.includes("developer"), "expected 'developer' preset");
|
||||
assert.ok(ids.includes("admin"), "expected 'admin' preset");
|
||||
@@ -113,30 +112,6 @@ test("SIDEBAR_PRESETS 'all' preset has no hidden items", () => {
|
||||
assert.deepEqual(allPreset.hiddenItems, []);
|
||||
});
|
||||
|
||||
test("SIDEBAR_PRESETS includes essentials as the beginner path", () => {
|
||||
assert.equal(SIDEBAR_PRESETS.length, 5);
|
||||
assert.deepEqual(
|
||||
SIDEBAR_PRESETS.map((p) => p.id),
|
||||
["all", "essentials", "minimal", "developer", "admin"]
|
||||
);
|
||||
const essentials = SIDEBAR_PRESETS.find((p) => p.id === "essentials");
|
||||
assert.ok(essentials, "expected 'essentials' preset to exist");
|
||||
const hidden = new Set(essentials.hiddenItems);
|
||||
for (const id of [
|
||||
"home",
|
||||
"endpoints",
|
||||
"api-manager",
|
||||
"providers",
|
||||
"health",
|
||||
"settings-general",
|
||||
"settings-sidebar",
|
||||
]) {
|
||||
assert.equal(hidden.has(id as never), false, `${id} should stay visible in essentials`);
|
||||
}
|
||||
assert.equal(hidden.has("playground"), true);
|
||||
assert.equal(hidden.has("logs"), true);
|
||||
});
|
||||
|
||||
test("SIDEBAR_PRESETS non-all presets have at least one hidden item", () => {
|
||||
for (const preset of SIDEBAR_PRESETS.filter((p) => p.id !== "all")) {
|
||||
assert.ok(preset.hiddenItems.length > 0, `Preset '${preset.id}' should hide at least one item`);
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
test("essentials preset is registered in sidebar visibility types and presets", () => {
|
||||
const types = fs.readFileSync(
|
||||
path.join(repoRoot, "src/shared/constants/sidebarVisibility/types.ts"),
|
||||
"utf8"
|
||||
);
|
||||
const visibility = fs.readFileSync(
|
||||
path.join(repoRoot, "src/shared/constants/sidebarVisibility.ts"),
|
||||
"utf8"
|
||||
);
|
||||
const schema = fs.readFileSync(
|
||||
path.join(repoRoot, "src/shared/validation/settingsSchemas.ts"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert.match(types, /"essentials"/);
|
||||
assert.match(visibility, /id:\s*"essentials"/);
|
||||
assert.match(visibility, /ESSENTIALS_ADVANCED_TOOL_IDS/);
|
||||
assert.match(schema, /"essentials"/);
|
||||
});
|
||||
|
||||
test("command palette keeps essentials advanced tools searchable", () => {
|
||||
const source = fs.readFileSync(
|
||||
path.join(repoRoot, "src/shared/components/CommandPalette.tsx"),
|
||||
"utf8"
|
||||
);
|
||||
assert.match(source, /ESSENTIALS_ADVANCED_TOOL_IDS/);
|
||||
assert.match(source, /activePreset === "essentials"/);
|
||||
});
|
||||
|
||||
test("essentials i18n keys exist in en.json", () => {
|
||||
const en = JSON.parse(
|
||||
fs.readFileSync(path.join(repoRoot, "src/i18n/messages/en.json"), "utf8")
|
||||
) as { settings: Record<string, string> };
|
||||
assert.equal(en.settings.presetEssentials, "Essentials");
|
||||
assert.match(en.settings.presetEssentialsDesc, /Beginner path/i);
|
||||
assert.match(en.settings.presetEssentialsDesc, /searchable/i);
|
||||
});
|
||||
@@ -1,44 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const pagePath = path.join(
|
||||
repoRoot,
|
||||
"src/app/(dashboard)/dashboard/tools/traffic-inspector/page.tsx"
|
||||
);
|
||||
const clientPath = path.join(
|
||||
repoRoot,
|
||||
"src/app/(dashboard)/dashboard/tools/traffic-inspector/TrafficInspectorPageClient.tsx"
|
||||
);
|
||||
const enPath = path.join(repoRoot, "src/i18n/messages/en.json");
|
||||
|
||||
test("Traffic Inspector page passes translated title, subtitle, and purpose", () => {
|
||||
const pageSource = fs.readFileSync(pagePath, "utf8");
|
||||
assert.match(pageSource, /title=\{t\("trafficInspector"\)\}/);
|
||||
assert.match(pageSource, /subtitle=\{t\("trafficInspectorSubtitle"\)\}/);
|
||||
assert.match(pageSource, /purpose=\{t\("trafficInspectorPurpose"\)\}/);
|
||||
});
|
||||
|
||||
test("Traffic Inspector client renders purpose-first header when props are provided", () => {
|
||||
const clientSource = fs.readFileSync(clientPath, "utf8");
|
||||
assert.match(clientSource, /title\s*&&/);
|
||||
assert.match(clientSource, /subtitle\s*&&/);
|
||||
assert.match(clientSource, /purpose\s*&&/);
|
||||
});
|
||||
|
||||
test("Traffic Inspector beginner i18n keys exist in en.json", () => {
|
||||
const en = JSON.parse(fs.readFileSync(enPath, "utf8"));
|
||||
assert.equal(en.sidebar.trafficInspector, "Traffic Inspector");
|
||||
assert.equal(
|
||||
en.sidebar.trafficInspectorSubtitle,
|
||||
"Inspect request and response traffic from your apps"
|
||||
);
|
||||
assert.equal(
|
||||
typeof en.sidebar.trafficInspectorPurpose,
|
||||
"string"
|
||||
);
|
||||
assert.ok(en.sidebar.trafficInspectorPurpose.length > 20);
|
||||
});
|
||||
@@ -72,12 +72,10 @@ test("usage service covers GitHub free-plan parsing, auth denial and unsupported
|
||||
assert.equal(freeUsage.quotas.completions.used, 0);
|
||||
assert.equal(freeUsage.quotas.completions.remainingPercentage, 100);
|
||||
assert.equal(calls[0].headers.Authorization, "token gho-free");
|
||||
// #10952 re-based the Copilot wire identity on the live-captured CLI 1.0.81-6
|
||||
// (copilot-developer-cli integration id; API version 2026-08-01).
|
||||
assert.equal(calls[0].headers["User-Agent"], "GitHubCopilotChat/1.0.81-6");
|
||||
assert.equal(calls[0].headers["Editor-Version"], "copilot/1.0.81-6");
|
||||
assert.equal(calls[0].headers["Editor-Plugin-Version"], "copilot-chat/1.0.81-6");
|
||||
assert.equal(calls[0].headers["X-GitHub-Api-Version"], "2026-08-01");
|
||||
assert.equal(calls[0].headers["User-Agent"], "GitHubCopilotChat/0.54.0");
|
||||
assert.equal(calls[0].headers["Editor-Version"], "vscode/1.126.0");
|
||||
assert.equal(calls[0].headers["Editor-Plugin-Version"], "copilot-chat/0.54.0");
|
||||
assert.equal(calls[0].headers["X-GitHub-Api-Version"], "2026-06-01");
|
||||
|
||||
globalThis.fetch = async () => new Response("forbidden", { status: 403 });
|
||||
const forbidden: any = await usageService.getUsageForProvider({
|
||||
|
||||
@@ -128,11 +128,9 @@ test("vscode raw models route exposes native GPT-5.6 IDs and effort tiers", asyn
|
||||
assert.equal(typeof defaultModel.created, "number");
|
||||
assert.equal(defaultModel.owned_by, "codex");
|
||||
assert.equal(defaultModel.name, "Codex GPT 5.6 Sol");
|
||||
// #11179: codex static catalog advertises the usable 872K window (max_context_window),
|
||||
// not the old 272K pricing tier.
|
||||
assert.equal(defaultModel.context_length, 872000);
|
||||
assert.equal(defaultModel.context_length, 272000);
|
||||
assert.equal(defaultModel.max_output_tokens, 128000);
|
||||
assert.equal(defaultModel.max_input_tokens, 872000);
|
||||
assert.equal(defaultModel.max_input_tokens, 272000);
|
||||
assert.deepEqual(defaultModel.capabilities, {
|
||||
vision: true,
|
||||
tool_calling: true,
|
||||
|
||||
@@ -255,9 +255,7 @@ test("vscode combos route resolves combo names through Ollama api/show", async (
|
||||
assert.equal(body.model, "show-combo");
|
||||
assert.equal(body.modelfile, "FROM show-combo");
|
||||
assert.equal(body.details.family, "show-combo");
|
||||
// #11179: codex static catalog advertises the usable 872K window (max_context_window),
|
||||
// not the old 272K pricing tier.
|
||||
assert.equal(body.model_info.context_length, 872000);
|
||||
assert.equal(body.model_info.context_length, 272000);
|
||||
assert.deepEqual(body.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]);
|
||||
assert.equal(body.model_info.capabilities.reasoning, true);
|
||||
});
|
||||
@@ -292,8 +290,7 @@ test("vscode tokenized combos root route exposes importable combo metadata", asy
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(combo, "expected balanced-load in combo root response");
|
||||
assert.equal(combo.url.includes("/responses#models.ai.azure.com"), true);
|
||||
// #11179: codex static catalog maxInputTokens is now the usable 872K window.
|
||||
assert.equal(combo.maxInputTokens, 872000);
|
||||
assert.equal(combo.maxInputTokens, 272000);
|
||||
assert.equal(combo.toolCalling, true);
|
||||
assert.deepEqual(combo.supportsReasoningEffort, ["none", "low", "medium", "high", "xhigh"]);
|
||||
});
|
||||
@@ -1076,9 +1073,7 @@ test("vscode tokenized api/show route exposes explicit reasoning effort metadata
|
||||
assert.equal(body.configurationSchema?.properties?.reasoningEffort?.default, "low");
|
||||
assert.equal(body.model_info["general.basename"], "Codex GPT 5.6 Sol (Default)");
|
||||
assert.equal(body.model_info["general.architecture"], "codex");
|
||||
// #11179: codex static catalog advertises the usable 872K window (max_context_window),
|
||||
// not the old 272K pricing tier.
|
||||
assert.equal(body.model_info["codex.context_length"], 872000);
|
||||
assert.equal(body.model_info["codex.context_length"], 272000);
|
||||
assert.deepEqual(body.model_info.supports_reasoning_effort, [
|
||||
"low",
|
||||
"medium",
|
||||
|
||||
Reference in New Issue
Block a user