mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 01:32:22 +03:00
Compare commits
9 Commits
fix/9981-i
...
fix/releas
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6dffb3913 | ||
|
|
d6b1405a5c | ||
|
|
940f38545d | ||
|
|
65940dde76 | ||
|
|
f672ddcc2c | ||
|
|
8fc4023f94 | ||
|
|
aafdc4d4c6 | ||
|
|
fed0858f89 | ||
|
|
2b6977229b |
12
.env.example
12
.env.example
@@ -1173,6 +1173,12 @@ CURSOR_USER_AGENT="Cursor/3.4"
|
||||
# Or enable for all providers at once:
|
||||
# CLI_COMPAT_ALL=1
|
||||
|
||||
# Allow the Antigravity request translator to skip its strict CLI request-signature
|
||||
# validation when the upstream refuses real signatures (debug/antiquated-CLI mode).
|
||||
# Default: real signatures enforced (unset) — signature bypass disabled.
|
||||
# Used by: open-sse/translator/request/openai-to-gemini.ts
|
||||
# ANTIGRAVITY_ALLOW_SIGNATURE_BYPASS=0
|
||||
|
||||
# ── Kimi Coding CLI identity overrides ──
|
||||
# Used by: src/lib/oauth/providers/kimi-coding.ts — sent in OAuth + API headers.
|
||||
# Leave unset to use the captured defaults baked into the OmniRoute build.
|
||||
@@ -2003,6 +2009,12 @@ APP_LOG_TO_FILE=true
|
||||
# Default: 0.33.2
|
||||
# COMMAND_CODE_VERSION=0.33.2
|
||||
|
||||
# Base URL for the Command Code usage/quota upstream, used by smartphone
|
||||
# quota-fetcher telemetry.
|
||||
# Used by: open-sse/services/usage/command-code.ts
|
||||
# Default: https://api.commandcode.ai
|
||||
# COMMANDCODE_API_URL=https://api.commandcode.ai
|
||||
|
||||
# ── MITM debug proxy (development only) ──
|
||||
# Used by: src/mitm/server.cjs — captures upstream traffic for inspection.
|
||||
# MITM_LOCAL_PORT=443
|
||||
|
||||
1
changelog.d/fixes/9914-search-exa-contents.md
Normal file
1
changelog.d/fixes/9914-search-exa-contents.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(search): nest Exa contents options (text/highlights) for /search API (#9914)
|
||||
1
changelog.d/fixes/9927-encryption-log-identity.md
Normal file
1
changelog.d/fixes/9927-encryption-log-identity.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(encryption): name failing credential + recovery path in decrypt errors, dedupe per connection (#9927)
|
||||
1
changelog.d/fixes/9934-migration-fresh-setup.md
Normal file
1
changelog.d/fixes/9934-migration-fresh-setup.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(migrations): don't abort on fresh install with only the 001 seed (#9934)
|
||||
1
changelog.d/fixes/9981-image-error-normalization.md
Normal file
1
changelog.d/fixes/9981-image-error-normalization.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(images): normalize terminal upstream errors via OpenAI-standard type/code (#9981)
|
||||
1
changelog.d/fixes/9985-basereds-docs-size.md
Normal file
1
changelog.d/fixes/9985-basereds-docs-size.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(quality): green release/v3.8.50 base-reds — sync 4 env vars into .env.example/ENVIRONMENT.md and freeze the new proxied-TLS proxyFetch helper in the file-size baseline (#9985)
|
||||
@@ -98,6 +98,7 @@
|
||||
"omniglyph",
|
||||
"open",
|
||||
"opencode-ai",
|
||||
"onnxruntime-node",
|
||||
"ora",
|
||||
"parse5",
|
||||
"pino",
|
||||
|
||||
@@ -420,8 +420,10 @@
|
||||
"open-sse/executors/default.ts": 1355,
|
||||
"open-sse/executors/kiro.ts": 1390,
|
||||
"open-sse/translator/request/openai-to-kiro.ts": 1374,
|
||||
"open-sse/utils/sseHeartbeat.ts": 194
|
||||
"open-sse/utils/sseHeartbeat.ts": 194,
|
||||
"open-sse/utils/proxyFetch.ts": 1207
|
||||
},
|
||||
"_rebaseline_base_2026_08_10_proxyfetch": "Base-red fix (green-prs sweep, issue #9985): open-sse/utils/proxyFetch.ts 1207 > cap 1000 — new proxied-TLS fetch helper introduced by the Fal reference-image work. Owner-authorized quick rebaseline to green; structural slim tracked for v3.9.0.",
|
||||
"_rebaseline_2026_07_27_v3849_train2": "Merge-train 2 (7 PRs) \u2014 owner-approved 2026-07-27. Single entry: chatCore.ts 4955->5006 (#8595, Responses multi-turn image compaction before the context hard-reject). Genuine irreducible growth at the existing compaction chokepoint in handleChatCore \u2014 the PR adds a last-resort retry against the concrete budget plus the estimateFinalInputTokens helper, both wired at the pre-existing call site rather than a new branch. Covered by tests/unit/8560-responses-image-compaction.test.ts (4 tests).",
|
||||
"_rebaseline_2026_07_27_v3849_train3": "Merge-train 3 (13 PRs) \u2014 owner-approved 2026-07-27. Both entries are genuine irreducible growth at existing chokepoints, not new branches: src/lib/db/apiKeys.ts 1518->1529 (#8805 cx/* \u2261 codex/* API-key model permissions); open-sse/handlers/chatCore.ts 5006->5020 (#8806 real response payload into plugin onResponse hooks). Covered by tests/unit/db-apiKeys-crud.test.ts (4 new cases) and the two plugin-hook test files updated in #8806 respectively.",
|
||||
"_rebaseline_2026_07_28_8842_antigravity_projectid_refresh": "PR #8842 (fix/antigravity-projectid-refresh) own growth: open-sse/executors/antigravity.ts 1493->1528 (+35 = projectId discovery in refreshCredentials: import ensureAntigravityProjectAssigned + trim projectId + call ensureAntigravityProjectAssigned with 8s timeout + persistDiscoveredAntigravityProjectId + log success/failure). Irreducible wiring at the existing credential-refresh chokepoint. Covered by tests/unit/executor-antigravity.test.ts (4 new test cases).",
|
||||
|
||||
@@ -149,10 +149,11 @@
|
||||
"dedicatedGate": true
|
||||
},
|
||||
"codeqlAlerts": {
|
||||
"value": 1,
|
||||
"value": 2,
|
||||
"direction": "down",
|
||||
"dedicatedGate": true,
|
||||
"_rebaseline_2026_08_06_base_grew": "Base branch file-size drift: translator-openai-to-gemini.test.ts grew 1619->1622 (test assertions for Gemini translator compatibility). CodeQL alert (js/insufficient-password-hash in raycast.ts) is pre-existing base-red; incremented baseline to match."
|
||||
"_rebaseline_2026_08_06_base_grew": "Base branch file-size drift: translator-openai-to-gemini.test.ts grew 1619->1622 (test assertions for Gemini translator compatibility). CodeQL alert (js/insufficient-password-hash in raycast.ts) is pre-existing base-red; incremented baseline to match.",
|
||||
"_rebaseline_2026_08_10_9940_fingerprint": "CodeQL base-red (green-prs sweep, issue #9985): 2nd js/insufficient-password-hash alert at src/shared/middleware/chatBodyAdmission.ts:265,269 introduced by #9940 (per-connection virtual admission lanes). Both are API-key/bearer FINGERPRINTS (createHash('sha256') truncated to 16-hex admission-lane key), not password VERIFICATION — false-positive class for this rule. Reproduces on release/v3.8.50 tip. Owner-authorized rebaseline 1->2; revisit at v3.9.0."
|
||||
},
|
||||
"secretFindings": {
|
||||
"_note": "Zeroed 2026-07-13 (WS6/D3): the 3 frozen generic-api-key FPs are allowlisted with justification in .gitleaks.toml — any NEW finding regresses the ratchet.",
|
||||
|
||||
@@ -340,6 +340,7 @@ Route upstream LLM provider calls through an HTTP or SOCKS5 proxy for egress con
|
||||
| `SOCKS_HANDSHAKE_TIMEOUT_MS` | `10000` | `open-sse/utils/socksConnectorWithFamily.ts` | SOCKS5 handshake (connect) timeout in ms. Raise it when a single residential gateway host is hit by high concurrency (e.g. 100 simultaneous requests) — the real handshake can exceed 10s under a saturated pool even though the proxy is reachable, which otherwise surfaces as a false `[Proxy Fast-Fail] Proxy unreachable`. Capped at `120000`. |
|
||||
| `PROXY_FAIL_OPEN` | `false` | `src/sse/handlers/chatHelpers.ts` | When `false` (default), a request whose assigned proxy fails to resolve is **refused (fail-closed)** rather than falling back to a direct connection — prevents real-IP leaks. Set `true` to restore the legacy DIRECT fallback. |
|
||||
| `ENABLE_TLS_FINGERPRINT` | `false` | `open-sse/executors` | Spoof TLS fingerprint using wreq-js (mimics Chrome 124). Counters JA3/JA4 blocking. |
|
||||
| `TLS_FINGERPRINT_PROVIDERS` | _(unset)_ | `open-sse/utils/proxyFetch.ts` | Comma-separated provider allowlist for the new proxied TLS routing (`open-sse/utils/proxyFetch.ts`). Direct TLS keeps its legacy behavior when unset; only these providers route through the Chrome-124 fingerprint bridge. |
|
||||
| `OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS` | `false` | `open-sse/services/claudeTurnstileSolver.ts` | Allow the Claude Turnstile Playwright browser context to ignore HTTPS certificate errors. |
|
||||
|
||||
### Scenarios
|
||||
@@ -468,6 +469,7 @@ detection above).
|
||||
| `OMNIROUTE_BOOTSTRAPPED` | `false` | `src/app/(dashboard)/dashboard/page.tsx` | Set `true` by bootstrap script after initial setup. Controls setup wizard visibility. |
|
||||
| `OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE` | `0` | `open-sse/executors/antigravity.ts` | Escape hatch: allow request body to override the Antigravity project field. |
|
||||
| `ANTIGRAVITY_CREDITS` | `off` | `open-sse/services/antigravityCredits.ts` | Google One AI credits policy: `off` never injects credits, `retry` injects once after an eligible quota 429, and `always` injects on the first request. |
|
||||
| `ANTIGRAVITY_ALLOW_SIGNATURE_BYPASS` | `0` | `open-sse/translator/request/openai-to-gemini.ts` | Allow the Antigravity request translator to skip its strict CLI request-signature validation when the upstream refuses real signatures (debug/antiquated-CLI mode). Non-zero enables the bypass. |
|
||||
| `AGY_TOKEN_FILE` | `~/.gemini/antigravity-cli/antigravity-oauth-token` | `src/app/api/providers/agy-auth/apply-local/route.ts` | Override the Antigravity CLI (agy) token-file path for the auto-detect local login import. |
|
||||
|
||||
### OAuth CLI Bridge (Internal)
|
||||
@@ -791,6 +793,7 @@ Embedding layer, vector store and reranking knobs for the persistent memory subs
|
||||
| `MEMORY_TYPED_DECAY_EPISODIC_DAYS` | `30` | TTL (days) after which an unused `episodic` memory decays. `0` makes episodic immune too. Durable types (`factual`/`procedural`/`semantic`) are always immune. The decay clock re-bases on `last_accessed_at`. |
|
||||
| `MEMORY_TYPED_DECAY_ACCESS_IMMUNITY` | `3` | A memory injected `>=` this many times becomes immune to decay regardless of type. `0` disables access immunity. |
|
||||
| `MEMORY_TYPED_DECAY_SWEEP_INTERVAL` | `0` (disabled) | Interval (seconds) for the optional periodic decay sweep in `src/lib/memory/typedDecay.ts`. `0`/unset = no periodic sweep. Doubly opt-in: also requires `MEMORY_TYPED_DECAY_ENABLED=true`. |
|
||||
| `OMNIROUTE_STRICT_SYSTEM_PROVIDERS` | _(unset)_ | Comma-separated provider ids (case-insensitive) that accept a `system` message **only at index 0** (`src/lib/memory/injection.ts`). For these, the cache-safe mid-array memory splice is unsafe in multi-turn conversations, so memory is merged/prepended as the leading system message instead. Defaults to only `xiaomi-mimo`/`mimo`; extend for self-hosted OpenAI-compatible endpoints (e.g. Qwen3.5+/3.6) whose chat template enforces the same single-leading-system-message constraint. |
|
||||
|
||||
### Low-RAM Docker Example
|
||||
|
||||
@@ -1092,6 +1095,7 @@ Provider quota endpoints, network tunnels (Tailscale, Ngrok, MITM debug proxy),
|
||||
| `OMNIROUTE_EMERGENCY_FALLBACK` | enabled | `open-sse/services/emergencyFallback.ts` | Set `false` (or `0`) to disable the emergency budget-exhaustion fallback that reroutes failed requests to the free `nvidia`/`openai/gpt-oss-120b` model. Effective precedence is Feature Flags DB override > env var > default; if unavailable, the service falls back to the raw env value. |
|
||||
| `COMMAND_CODE_CALLBACK_PORT` | _(unset)_ | `src/app/api/providers/command-code/auth/shared.ts` | Local port used for OAuth-style callbacks from the Command Code CLI helper. |
|
||||
| `COMMAND_CODE_VERSION` | `0.33.2` | `open-sse/executors/commandCode.ts` | Value sent as the `x-command-code-version` header to the Command Code upstream. Override to bump the CLI version. |
|
||||
| `COMMANDCODE_API_URL` | `https://api.commandcode.ai` | `open-sse/services/usage/command-code.ts` | Base URL for the Command Code usage/quota upstream used by the smartphone quota-fetcher telemetry. Override for a self-hosted/alternative Command Code API. |
|
||||
| `MITM_LOCAL_PORT` | `443` | `src/mitm/server.cjs` | Local bind port for the MITM debug proxy. |
|
||||
| `MITM_DISABLE_TLS_VERIFY` | `0` | `src/mitm/server.cjs` | Set `1` to disable upstream TLS verification (development only). |
|
||||
| `MITM_IDLE_TIMEOUT_MS` | `60000` | `src/mitm/socketTimeouts.ts`, `src/mitm/server.cjs` | Idle socket timeout (ms) for proxied connections; idle sockets past this are torn down to avoid leaking half-open tunnels. |
|
||||
|
||||
@@ -89,7 +89,7 @@ export async function handleFalAIImageEdit({
|
||||
const payload = await response.json();
|
||||
const normalizedBody =
|
||||
body.response_format === undefined ? { ...body, response_format: "b64_json" } : body;
|
||||
const imagesOut = await normalizeProviderImagePayload(payload, normalizedBody, log);
|
||||
const imagesOut = await normalizeProviderImagePayload(payload, normalizedBody, log, "b64_json");
|
||||
return saveImageSuccessResult({
|
||||
provider,
|
||||
model: editModel,
|
||||
|
||||
@@ -576,15 +576,21 @@ function sanitizeResponsesUsage(usage: unknown): unknown {
|
||||
|
||||
/**
|
||||
* Normalize response ID to use chatcmpl- prefix.
|
||||
* Preserves numeric/short custom ids as their string form rather than
|
||||
* regenerating them — a passthrough numeric id (e.g. `123`) must stay `"123"`
|
||||
* so streaming clients can correlate chunks (#3427/#5776). Only a genuinely
|
||||
* missing/empty id gets a fresh `chatcmpl-` token.
|
||||
*/
|
||||
function normalizeResponseId(id: unknown): string {
|
||||
if (!id || typeof id !== "string") {
|
||||
if (!id || (typeof id !== "string" && typeof id !== "number")) {
|
||||
return `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 29)}`;
|
||||
}
|
||||
// Already correct format
|
||||
if (id.startsWith("chatcmpl-")) return id;
|
||||
// Keep custom IDs but don't break them
|
||||
return id;
|
||||
const str = String(id);
|
||||
if (str === "") {
|
||||
return `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 29)}`;
|
||||
}
|
||||
// Already correct format, or a custom/numeric id — keep it.
|
||||
return str;
|
||||
}
|
||||
|
||||
function normalizeResponsesId(id: unknown): string {
|
||||
|
||||
@@ -336,8 +336,10 @@ function buildExaRequest(
|
||||
query: params.query,
|
||||
numResults: params.maxResults,
|
||||
type: "auto",
|
||||
text: true,
|
||||
highlights: true,
|
||||
contents: {
|
||||
text: true,
|
||||
highlights: true,
|
||||
},
|
||||
};
|
||||
if (includes.length) body.includeDomains = includes;
|
||||
if (excludes.length) body.excludeDomains = excludes;
|
||||
|
||||
@@ -90,7 +90,10 @@ function absoluteFalUrl(value: unknown, baseUrl: string): string | undefined {
|
||||
|
||||
function normalizeFalVideoResponse(payload: unknown) {
|
||||
const record = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
|
||||
const video = record.video && typeof record.video === "object" ? record.video : null;
|
||||
const video =
|
||||
record.video && typeof record.video === "object"
|
||||
? (record.video as Record<string, unknown>)
|
||||
: null;
|
||||
const url = video && typeof video.url === "string" ? video.url.trim() : "";
|
||||
|
||||
if (!url) {
|
||||
|
||||
@@ -182,6 +182,16 @@ export async function createBuiltinAutoCombo(modelStr: string, suffix: string) {
|
||||
return virtualCombo;
|
||||
}
|
||||
|
||||
// Advertised `auto/*` ids whose template maps to no variant (auto/chat,
|
||||
// auto/best-chat, auto/pro-chat) still materialize via the default
|
||||
// (unconstrained) virtual combo rather than throwing "Unknown built-in".
|
||||
if (Object.prototype.hasOwnProperty.call(AUTO_TEMPLATE_VARIANTS, modelStr)) {
|
||||
const virtualCombo = await createVirtualAutoCombo(undefined);
|
||||
virtualCombo.name = modelStr;
|
||||
virtualCombo.id = modelStr;
|
||||
return virtualCombo;
|
||||
}
|
||||
|
||||
// #4235 Phase B: `auto/<category>[:<tier>]` (e.g. auto/coding:fast, auto/vision).
|
||||
const parsed = parseAutoSuffix(suffix);
|
||||
if (parsed.valid) {
|
||||
|
||||
@@ -1096,9 +1096,12 @@ async function patchedFetch(
|
||||
continue;
|
||||
}
|
||||
tagProxyUnreachable(error);
|
||||
const originalMsg = error instanceof Error ? error.message : String(error);
|
||||
const sanitized = sanitizeTransportError(
|
||||
error,
|
||||
"Proxy request failed",
|
||||
originalMsg
|
||||
? `Proxy request failed: ${originalMsg}`
|
||||
: "Proxy request failed",
|
||||
"PROXY_REQUEST_FAILED"
|
||||
);
|
||||
console.error(
|
||||
|
||||
@@ -1640,7 +1640,8 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
(parsed.choices.length === 1 &&
|
||||
parsed.choices[0]?.delta &&
|
||||
typeof parsed.choices[0].delta === "object" &&
|
||||
Object.keys(parsed.choices[0].delta).length === 0))
|
||||
Object.keys(parsed.choices[0].delta).length === 0 &&
|
||||
!parsed.choices[0]?.finish_reason))
|
||||
) {
|
||||
const emptyChoicesUsage = extractUsage(parsed) ?? parsed.usage;
|
||||
if (hasValidUsage(emptyChoicesUsage)) {
|
||||
@@ -1850,6 +1851,7 @@ export function createSSEStream(options: StreamOptions = {}) {
|
||||
passthroughSawFinishReason = true;
|
||||
}
|
||||
|
||||
|
||||
if (isFinishChunk && passthroughHasToolCalls) {
|
||||
toolFinishTime = now;
|
||||
try {
|
||||
|
||||
@@ -14,17 +14,35 @@ import { buildErrorBody } from "@omniroute/open-sse/utils/error";
|
||||
* Returns a 424 (Failed Dependency) response with a clear, sanitized message
|
||||
* when the connection carries that flag; otherwise null (proceed normally).
|
||||
*/
|
||||
const STALE_ENCRYPTION_MESSAGE =
|
||||
"Stored API key cannot be decrypted (STORAGE_ENCRYPTION_KEY changed or unset). Re-enter the API key.";
|
||||
|
||||
export function buildStaleEncryptionKeyResponse(
|
||||
connection: { credentialDecryptFailed?: unknown } | null | undefined
|
||||
connection:
|
||||
| {
|
||||
credentialDecryptFailed?: unknown;
|
||||
id?: unknown;
|
||||
provider?: unknown;
|
||||
}
|
||||
| null
|
||||
| undefined
|
||||
): NextResponse | null {
|
||||
if (!connection || connection.credentialDecryptFailed !== true) return null;
|
||||
|
||||
// #9927 — surface WHICH credential failed plus the recovery path so the
|
||||
// dashboard points the operator at the account to re-authenticate instead of
|
||||
// a generic "API key cannot be decrypted".
|
||||
const provider = typeof connection.provider === "string" ? connection.provider : "";
|
||||
const id = typeof connection.id === "string" ? connection.id : "";
|
||||
const identity = [provider && `provider "${provider}"`, id && `connection ${id}`]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
|
||||
const message =
|
||||
`Stored credential${identity ? ` for ${identity}` : ""} cannot be decrypted ` +
|
||||
`(STORAGE_ENCRYPTION_KEY changed or unset). Re-authenticate this account, or verify ` +
|
||||
`STORAGE_ENCRYPTION_KEY matches the key used to store it.`;
|
||||
|
||||
// buildErrorBody sanitizes the message (Rule #12); override the type so the
|
||||
// client can key off the specific stale-encryption cause.
|
||||
const body = buildErrorBody(424, STALE_ENCRYPTION_MESSAGE);
|
||||
const body = buildErrorBody(424, message);
|
||||
body.error.type = "storage_encryption_stale";
|
||||
return NextResponse.json(body, { status: 424 });
|
||||
}
|
||||
|
||||
@@ -307,11 +307,14 @@ async function postHandler(request, context) {
|
||||
});
|
||||
}
|
||||
|
||||
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error");
|
||||
return new Response(JSON.stringify(errorPayload), {
|
||||
status: (result as any).status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error") as {
|
||||
error?: { message?: string };
|
||||
};
|
||||
const message =
|
||||
typeof errorPayload?.error?.message === "string"
|
||||
? errorPayload.error.message
|
||||
: "Image generation provider error";
|
||||
return errorResponse((result as any).status, message);
|
||||
}
|
||||
|
||||
export const POST = withInjectionGuard(postHandler);
|
||||
|
||||
@@ -119,8 +119,9 @@ export async function POST(request, { params }) {
|
||||
}
|
||||
|
||||
const errorPayload = toJsonErrorPayload((result as any).error, "Image generation provider error");
|
||||
return new Response(JSON.stringify(errorPayload), {
|
||||
status: (result as any).status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
const message =
|
||||
typeof errorPayload?.error?.message === "string"
|
||||
? errorPayload.error.message
|
||||
: "Image generation provider error";
|
||||
return errorResponse((result as any).status, message);
|
||||
}
|
||||
|
||||
@@ -4948,6 +4948,7 @@
|
||||
"baseUrlHint": "Required. Provider API base URL.",
|
||||
"iconUrlLabel": "Icon URL",
|
||||
"iconUrlHint": "Optional. Image URL shown as this provider's icon.",
|
||||
"iconUrlInvalid": "Invalid icon URL. Use an http(s):// or data:image/*;base64 URL.",
|
||||
"anthropicPrefixPlaceholder": "ac-prod",
|
||||
"openaiPrefixPlaceholder": "oc-prod",
|
||||
"anthropicBaseUrlPlaceholder": "https://api.anthropic.com/v1",
|
||||
|
||||
@@ -1045,13 +1045,35 @@ export function getDbInstance(): SqliteDatabase {
|
||||
// This is needed so the migration runner skips the mass-migration safety abort
|
||||
// that would otherwise trigger because heuristic seeding marks some migrations
|
||||
// as applied, making the fresh DB look like a wiped existing DB (#1328).
|
||||
const isNewDb = !fs.existsSync(sqliteFile);
|
||||
// #9934: also classify as fresh a file that `omniroute setup` created with
|
||||
// only the clipped skeleton schema (see the probe below) — even though the
|
||||
// file exists, it has never had migrations run.
|
||||
let isNewDb = !fs.existsSync(sqliteFile);
|
||||
|
||||
// Detect and handle old schema format — preserve data when possible (#146)
|
||||
// Uses a single probe connection that becomes the real connection when possible.
|
||||
if (fs.existsSync(sqliteFile)) {
|
||||
try {
|
||||
const probe = openSqliteDatabase(sqliteFile, { readonly: true });
|
||||
// #9934: init asymmetry — bin/cli/sqlite.mjs::openOmniRouteDb (used by
|
||||
// `omniroute setup`) creates storage.sqlite with only the partial inline
|
||||
// schema (key_value + provider_connections) and never runs migrations.
|
||||
// Purely file-existence-based freshness made that file look like an
|
||||
// existing DB, so the first `serve` auto-seeded only the 001 marker and
|
||||
// tripped the mass-migration safety abort on a brand-new install. A
|
||||
// skeleton file has provider_connections but none of the tables the 001
|
||||
// migration creates (combos) — treat it as fresh, not as a wiped DB.
|
||||
const probeHasProviderConnections = !!probe
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='provider_connections'"
|
||||
)
|
||||
.get();
|
||||
const probeHasCombos = !!probe
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='combos'")
|
||||
.get();
|
||||
if (probeHasProviderConnections && !probeHasCombos) {
|
||||
isNewDb = true;
|
||||
}
|
||||
const hasOldSchema = probe
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='schema_migrations'")
|
||||
.get();
|
||||
|
||||
@@ -51,6 +51,31 @@ export interface ConnectionFields {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* #9927 — dedupe tracker for credential-decrypt-failure messages. The health
|
||||
* sweep / refresh / request routing re-decrypt the same corrupt row every
|
||||
* cycle; we log the enriched, actionable message ONCE per
|
||||
* (provider + connection + failing-ciphertext) state so it does not spam
|
||||
* every sweep, while still re-logging if the row state actually changes
|
||||
* (e.g. a different field starts failing) instead of permanently suppressing.
|
||||
*/
|
||||
const loggedDecryptFailures = new Set<string>();
|
||||
|
||||
function decryptFailureSignature(
|
||||
connectionId: string,
|
||||
provider: string,
|
||||
failed: Array<{ field: string; value: unknown }>
|
||||
): string {
|
||||
const parts = failed
|
||||
.map((f) => `${f.field}:${typeof f.value === "string" ? f.value : ""}`)
|
||||
.sort()
|
||||
.join("|");
|
||||
return `${provider}::${connectionId}::${parts}`;
|
||||
}
|
||||
|
||||
const RECOVERY_HINT =
|
||||
"Re-authenticate this account, or verify STORAGE_ENCRYPTION_KEY matches the key used to store it.";
|
||||
|
||||
/**
|
||||
* Derive the PRIMARY encryption key using the static salt.
|
||||
* This is the canonical key derivation that all new encryptions use.
|
||||
@@ -157,7 +182,10 @@ export function encrypt(plaintext: string | null | undefined): string | null | u
|
||||
* auto-migration: the next encrypt() call will re-encrypt it with the
|
||||
* static-salt key, gradually migrating the database.
|
||||
*/
|
||||
export function decrypt(ciphertext: string | null | undefined): string | null | undefined {
|
||||
export function decrypt(
|
||||
ciphertext: string | null | undefined,
|
||||
opts?: { quiet?: boolean }
|
||||
): string | null | undefined {
|
||||
if (!ciphertext || typeof ciphertext !== "string") return ciphertext;
|
||||
|
||||
// Not encrypted — return as-is (legacy plaintext or passthrough mode)
|
||||
@@ -204,14 +232,21 @@ export function decrypt(ciphertext: string | null | undefined): string | null |
|
||||
return decrypted;
|
||||
}
|
||||
|
||||
console.error(
|
||||
`[Encryption] Decryption failed. Ciphertext prefix: ${ciphertext.slice(0, 30)}... ` +
|
||||
`Auth tag validation likely failed.`
|
||||
);
|
||||
// #9927 — the low-level generic log is suppressed when called through the
|
||||
// connection-decryption path (quiet:true); decryptConnectionFields emits a
|
||||
// single enriched message naming the credential + recovery path instead.
|
||||
if (!opts?.quiet) {
|
||||
console.error(
|
||||
`[Encryption] Decryption failed. Ciphertext prefix: ${ciphertext.slice(0, 30)}... ` +
|
||||
`Auth tag validation likely failed.`
|
||||
);
|
||||
}
|
||||
return null;
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error("[Encryption] Decryption failed:", message);
|
||||
if (!opts?.quiet) {
|
||||
console.error("[Encryption] Decryption failed:", message);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -242,10 +277,13 @@ export function decryptConnectionFields<T extends ConnectionFields | null | unde
|
||||
if (!row) return row;
|
||||
if (!isEncryptionEnabled()) return row;
|
||||
|
||||
const apiKey = decrypt(row.apiKey);
|
||||
const accessToken = decrypt(row.accessToken);
|
||||
const refreshToken = decrypt(row.refreshToken);
|
||||
const idToken = decrypt(row.idToken);
|
||||
// quiet:true — the low-level generic decrypt() log is suppressed here so a
|
||||
// single failure emits ONE enriched message (below) naming the credential
|
||||
// and recovery path (#9927) instead of one generic line per field per cycle.
|
||||
const apiKey = decrypt(row.apiKey, { quiet: true });
|
||||
const accessToken = decrypt(row.accessToken, { quiet: true });
|
||||
const refreshToken = decrypt(row.refreshToken, { quiet: true });
|
||||
const idToken = decrypt(row.idToken, { quiet: true });
|
||||
|
||||
// #6148 — a stored credential that is still encrypted (`enc:v1:…`) but
|
||||
// decrypts to null means the STORAGE_ENCRYPTION_KEY changed or was unset.
|
||||
@@ -257,6 +295,31 @@ export function decryptConnectionFields<T extends ConnectionFields | null | unde
|
||||
(looksEncrypted(row.refreshToken) && refreshToken === null) ||
|
||||
(looksEncrypted(row.idToken) && idToken === null);
|
||||
|
||||
if (credentialDecryptFailed) {
|
||||
const failed: Array<{ field: string; value: unknown }> = [];
|
||||
if (looksEncrypted(row.apiKey) && apiKey === null) failed.push({ field: "apiKey", value: row.apiKey });
|
||||
if (looksEncrypted(row.accessToken) && accessToken === null)
|
||||
failed.push({ field: "accessToken", value: row.accessToken });
|
||||
if (looksEncrypted(row.refreshToken) && refreshToken === null)
|
||||
failed.push({ field: "refreshToken", value: row.refreshToken });
|
||||
if (looksEncrypted(row.idToken) && idToken === null) failed.push({ field: "idToken", value: row.idToken });
|
||||
|
||||
const connectionId = typeof row.id === "string" ? row.id : "";
|
||||
const provider = typeof row.provider === "string" ? row.provider : "unknown";
|
||||
const fields = failed.map((f) => f.field).join(", ");
|
||||
|
||||
// Dedupe per credential/row state: the sweep re-decrypts the same corrupt
|
||||
// row every cycle — log ONCE unless the failing state actually changes.
|
||||
const signature = decryptFailureSignature(connectionId, provider, failed);
|
||||
if (!loggedDecryptFailures.has(signature)) {
|
||||
loggedDecryptFailures.add(signature);
|
||||
console.error(
|
||||
`[Encryption] Failed to decrypt credential(s) [${fields}] for provider ` +
|
||||
`"${provider}" (connection ${connectionId || "unknown"}). ${RECOVERY_HINT}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...row,
|
||||
apiKey,
|
||||
|
||||
@@ -922,9 +922,26 @@ export function runMigrations(db: SqliteAdapter, options?: { isNewDb?: boolean }
|
||||
// interpolates this resolved value, so it auto-reflects any override.
|
||||
const maxPendingMigrations = resolveMaxPendingMigrations();
|
||||
|
||||
// #9934: `omniroute setup`'s openOmniRouteDb writes a partial skeleton file
|
||||
// (provider_connections + key_value) that has never had migrations run. When
|
||||
// the first `serve` opens it and auto-seeds only the 001 marker, the applied
|
||||
// set is exactly {001} — which would otherwise look like a wiped existing DB
|
||||
// and trip this abort on a brand-new install. This is distinct from a real
|
||||
// wiped/backup-restored database: that case has a non-trivial physical schema
|
||||
// (baseline inference is non-null) and full data tables, so it still aborts.
|
||||
// The 001-marker-only state on a provider_connections skeleton is the fresh
|
||||
// auto-seed — let it through. A genuinely empty table is already exempt via
|
||||
// `applied.size > 0`, and an upgraded DB has a non-trivial applied set.
|
||||
const isFreshSeedOnly =
|
||||
applied.size === 1 &&
|
||||
applied.has("001") &&
|
||||
inferPhysicalSchemaBaseline(db) === null &&
|
||||
hasTable(db, "provider_connections");
|
||||
|
||||
if (
|
||||
!isTestEnvironment &&
|
||||
!isNewDb &&
|
||||
!isFreshSeedOnly &&
|
||||
process.env.DISABLE_SQLITE_AUTO_BACKUP !== "true" &&
|
||||
maxPendingMigrations > 0 &&
|
||||
applied.size > 0 &&
|
||||
|
||||
@@ -222,7 +222,7 @@ const VISION_BRIDGE_UA_FETCH: typeof fetch = ((input: RequestInfo | URL, init?:
|
||||
"user-agent": "omniroute-vision-bridge",
|
||||
...((init?.headers as Record<string, string> | undefined) ?? {}),
|
||||
},
|
||||
})) as typeof fetch;
|
||||
})) as unknown as typeof fetch;
|
||||
|
||||
/**
|
||||
* Resolve every image part in the body to a base64 data URI when the target
|
||||
|
||||
@@ -19,7 +19,7 @@ export type ProviderListingModel = {
|
||||
|
||||
export type MergeProviderModelListingInput = {
|
||||
providerId: string;
|
||||
registryModels: Array<{ id: string; name?: string; [key: string]: unknown }>;
|
||||
registryModels: Array<{ id: string; name?: string }>;
|
||||
syncedModels: Array<{ id: string; name?: string; [key: string]: unknown }>;
|
||||
customModels: Array<{ id: string; name?: string; source?: string; [key: string]: unknown }>;
|
||||
usesCuratedModelsOnly?: boolean;
|
||||
|
||||
@@ -46,6 +46,7 @@ export class ServiceSupervisor extends EventEmitter {
|
||||
private lastError: string | null = null;
|
||||
private childProcess: ChildProcess | null = null;
|
||||
private adopted: boolean = false;
|
||||
private spawnFailed: boolean = false;
|
||||
private readonly buffer: RingBuffer;
|
||||
private readonly checker: HealthChecker;
|
||||
private operationLock: Promise<void> = Promise.resolve();
|
||||
@@ -158,6 +159,7 @@ export class ServiceSupervisor extends EventEmitter {
|
||||
child = spawn(command, args, buildServiceSpawnOptions(env, cwd));
|
||||
} catch (err) {
|
||||
this.checker.stop();
|
||||
this.spawnFailed = true;
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
this.lastError = msg;
|
||||
this.setState("error");
|
||||
@@ -195,6 +197,7 @@ export class ServiceSupervisor extends EventEmitter {
|
||||
// the health poller hammers the dead port every healthIntervalMs.
|
||||
child.once("error", (err) => {
|
||||
this.checker.stop();
|
||||
this.spawnFailed = true;
|
||||
const msg = sanitizeErrorMessage(err instanceof Error ? err.message : String(err));
|
||||
this.lastError = msg;
|
||||
this.setState("error");
|
||||
@@ -206,6 +209,12 @@ export class ServiceSupervisor extends EventEmitter {
|
||||
|
||||
await this.waitForHealthy();
|
||||
|
||||
// A spawn failure flips state to "error" — surface the explicit error
|
||||
// status instead of overriding it with "running".
|
||||
if (this.state === "error") {
|
||||
return this.getStatus();
|
||||
}
|
||||
|
||||
this.setState("running");
|
||||
await setToolStatus(this.config.tool, "running", this.pid ?? undefined);
|
||||
|
||||
@@ -260,13 +269,21 @@ export class ServiceSupervisor extends EventEmitter {
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
if (this.checker.getHealth() === "healthy") return;
|
||||
if (this.state === "error") throw new Error(this.lastError ?? "Service failed to start");
|
||||
// A spawn failure (child 'error' event or sync throw) flips state to
|
||||
// "error" — stop polling and let start() surface the explicit error
|
||||
// status as a resolve. A health-probe failure is a different, harder
|
||||
// condition and must reject (handled below).
|
||||
if (this.state === "error") {
|
||||
if (this.spawnFailed) return;
|
||||
throw new Error(this.lastError ?? "Service failed to start");
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 1_000));
|
||||
}
|
||||
// Timeout reached without a healthy probe. The health poller may have
|
||||
// flipped the state to "error" while we were waiting (FAILURE_THRESHOLD
|
||||
// consecutive failures) — surface that instead of a degraded marker.
|
||||
if (this.state === "error") {
|
||||
if (this.spawnFailed) return;
|
||||
throw new Error(this.lastError ?? "Service failed to start");
|
||||
}
|
||||
this.lastError = sanitizeErrorMessage(
|
||||
|
||||
@@ -9,7 +9,14 @@ function readJson<T = Record<string, unknown>>(relPath: string): T {
|
||||
return JSON.parse(readFileSync(join(repoRoot, relPath), "utf8")) as T;
|
||||
}
|
||||
|
||||
test("@huggingface/transformers is optional so onnxruntime CUDA install failures cannot abort OmniRoute install", () => {
|
||||
test("@huggingface/transformers is a regular dependency so npm ci never skips it", () => {
|
||||
// #9962 deliberately moved @huggingface/transformers out of optionalDependencies:
|
||||
// as an optional dep, npm silently skipped the whole subtree on Node 24/26 (old
|
||||
// pin dragged onnxruntime-node@1.21.0 whose NAN build no longer compiles), which
|
||||
// broke `npm ci`/`next build` with "Can't resolve @huggingface/transformers"
|
||||
// (lazy import in src/lib/memory/embedding/transformersLocal.ts). As a regular
|
||||
// dep with onnxruntime-node@~1.24.3 (napi prebuilds, no node-gyp) it stays
|
||||
// installable and the memory embedding path requires() cleanly.
|
||||
const pkg = readJson<{
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
@@ -17,29 +24,37 @@ test("@huggingface/transformers is optional so onnxruntime CUDA install failures
|
||||
|
||||
assert.equal(
|
||||
pkg.dependencies?.["@huggingface/transformers"],
|
||||
undefined,
|
||||
"transformers must not be a regular dependency because it pulls onnxruntime-node install scripts"
|
||||
"^4.2.0",
|
||||
"transformers must be a regular dependency (never optional) so npm ci cannot skip it"
|
||||
);
|
||||
assert.equal(pkg.optionalDependencies?.["@huggingface/transformers"], "3.5.2");
|
||||
assert.equal(pkg.optionalDependencies?.["@huggingface/transformers"], undefined);
|
||||
});
|
||||
|
||||
test("package-lock marks transformers and its onnxruntime runtime as optional", () => {
|
||||
test("transformers + onnxruntime-node are regular dependencies (not optional)", () => {
|
||||
const pkg = readJson<{
|
||||
dependencies?: Record<string, string>;
|
||||
optionalDependencies?: Record<string, string>;
|
||||
}>("package.json");
|
||||
|
||||
assert.equal(
|
||||
pkg.dependencies?.["onnxruntime-node"],
|
||||
"~1.24.3",
|
||||
"onnxruntime-node is a regular dep (napi prebuilds, installable on Node 24/26)"
|
||||
);
|
||||
assert.equal(pkg.optionalDependencies?.["onnxruntime-node"], undefined);
|
||||
|
||||
const lock = readJson<{
|
||||
packages: Record<string, { optional?: boolean; dependencies?: Record<string, string>; optionalDependencies?: Record<string, string> }>;
|
||||
}>("package-lock.json");
|
||||
|
||||
assert.equal(
|
||||
lock.packages[""]?.dependencies?.["@huggingface/transformers"],
|
||||
undefined,
|
||||
"root lock dependencies must not keep transformers as mandatory"
|
||||
"^4.2.0",
|
||||
"root lock dependencies must hold transformers as a regular (non-optional) dep"
|
||||
);
|
||||
assert.equal(lock.packages[""]?.optionalDependencies?.["@huggingface/transformers"], "3.5.2");
|
||||
|
||||
for (const packagePath of [
|
||||
"node_modules/@huggingface/transformers",
|
||||
"node_modules/onnxruntime-node",
|
||||
"node_modules/onnxruntime-common",
|
||||
]) {
|
||||
assert.equal(lock.packages[packagePath]?.optional, true, `${packagePath} should be optional`);
|
||||
}
|
||||
// Optional flag is only written `true` for genuinely optional packages;
|
||||
// regular deps leave it absent/null. Assert each is NOT optional.
|
||||
assert.ok(!lock.packages["node_modules/@huggingface/transformers"]?.optional, "transformers must not be marked optional in the lockfile");
|
||||
assert.ok(!lock.packages["node_modules/onnxruntime-node"]?.optional, "onnxruntime-node must not be marked optional in the lockfile");
|
||||
assert.ok(!lock.packages["node_modules/onnxruntime-common"]?.optional, "onnxruntime-common must not be marked optional in the lockfile");
|
||||
});
|
||||
|
||||
138
tests/unit/db-fresh-setup-9934.test.ts
Normal file
138
tests/unit/db-fresh-setup-9934.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import Database from "better-sqlite3";
|
||||
import { resetDbInstance } from "../../src/lib/db/core.ts";
|
||||
|
||||
// Regression guard for #9934 — init asymmetry breaks a fresh install.
|
||||
//
|
||||
// `omniroute setup` (bin/cli/sqlite.mjs::openOmniRouteDb) creates
|
||||
// storage.sqlite with the *partial* inline schema (key_value +
|
||||
// provider_connections) but NEVER creates _omniroute_migrations and never runs
|
||||
// migrations. That file flips the server's new-DB heuristic
|
||||
// (src/lib/db/core.ts uses `!fs.existsSync(sqliteFile)`), so the first
|
||||
// `omniroute serve` believes it is an existing DB, auto-seeds only the 001
|
||||
// marker, and then trips the mass-migration safety abort because 139 pending
|
||||
// migrations exceed the default threshold of 50 (#6260 gate).
|
||||
//
|
||||
// A DB whose ONLY applied migration is the 001 initial-schema auto-seed is a
|
||||
// fresh install, not a wiped/backup-restored database — it must NOT abort.
|
||||
|
||||
const serial = { concurrency: false };
|
||||
|
||||
// Re-import a module so module-level env-derived constants (DATA_DIR,
|
||||
// SQLITE_FILE) re-resolve after we set DATA_DIR. Static import cannot work
|
||||
// here: the whole point is exercising the module-loading boundary.
|
||||
async function importFresh(modulePath: string) {
|
||||
const url = pathToFileURL(path.resolve(modulePath)).href;
|
||||
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
}
|
||||
|
||||
// Simulate a production (non-test) process so the #6260 mass-migration safety
|
||||
// gate is actually LIVE: under `node --test` the runner would be detected and
|
||||
// the gate skipped, making the bug invisible.
|
||||
function withNonTestEnvironment<R>(fn: () => R): R {
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
const originalVitest = process.env.VITEST;
|
||||
const originalDisableAutoBackup = process.env.DISABLE_SQLITE_AUTO_BACKUP;
|
||||
const originalArgv = [...process.argv];
|
||||
const originalExecArgv = [...process.execArgv];
|
||||
|
||||
delete process.env.NODE_ENV;
|
||||
delete process.env.VITEST;
|
||||
delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
|
||||
process.argv = process.argv.filter((arg) => !arg.includes("test"));
|
||||
process.execArgv = process.execArgv.filter((arg) => !arg.includes("test"));
|
||||
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
process.argv = originalArgv;
|
||||
process.execArgv = originalExecArgv;
|
||||
if (originalNodeEnv === undefined) delete process.env.NODE_ENV;
|
||||
else process.env.NODE_ENV = originalNodeEnv;
|
||||
if (originalVitest === undefined) delete process.env.VITEST;
|
||||
else process.env.VITEST = originalVitest;
|
||||
if (originalDisableAutoBackup === undefined) delete process.env.DISABLE_SQLITE_AUTO_BACKUP;
|
||||
else process.env.DISABLE_SQLITE_AUTO_BACKUP = originalDisableAutoBackup;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupGlobalDb() {
|
||||
try {
|
||||
const g = globalThis as Record<string, { open?: boolean; close?: () => void }>;
|
||||
if (g.__omnirouteDb?.open) g.__omnirouteDb.close?.();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
delete (globalThis as Record<string, unknown>).__omnirouteDb;
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
cleanupGlobalDb();
|
||||
resetDbInstance();
|
||||
});
|
||||
|
||||
test(
|
||||
"fresh `omniroute setup` DB (only the 001 seed) survives first serve without mass-migration abort (#9934)",
|
||||
serial,
|
||||
async () => {
|
||||
const dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-9934-"));
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = dataDir;
|
||||
|
||||
try {
|
||||
// Step 1 — mimic `omniroute setup`: the CLI opens the DB, writes the
|
||||
// partial inline schema (key_value + provider_connections) and closes it,
|
||||
// WITHOUT running migrations or creating _omniroute_migrations.
|
||||
const cli = await importFresh("bin/cli/sqlite.mjs");
|
||||
const setup = await cli.openOmniRouteDb();
|
||||
assert.ok(fs.existsSync(setup.dbPath), "setup created storage.sqlite");
|
||||
setup.db.close();
|
||||
|
||||
const onDisk = new Database(setup.dbPath, { readonly: true });
|
||||
try {
|
||||
const hasMigrationTable = !!onDisk
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?")
|
||||
.get("_omniroute_migrations");
|
||||
assert.equal(
|
||||
hasMigrationTable,
|
||||
false,
|
||||
"setup must NOT pre-create the migrations tracking table (bug premise)"
|
||||
);
|
||||
} finally {
|
||||
onDisk.close();
|
||||
}
|
||||
|
||||
// Step 2 — mimic the first `omniroute serve`: the real server opens the
|
||||
// same DB, auto-seeds only the 001 marker and runs migrations. Under a
|
||||
// live (non-test) safety gate this must NOT throw.
|
||||
const core = await importFresh("src/lib/db/core.ts");
|
||||
cleanupGlobalDb();
|
||||
resetDbInstance();
|
||||
|
||||
let db: { prepare?: (sql: string) => { get: () => { maxV: number } | undefined } };
|
||||
assert.doesNotThrow(() => {
|
||||
withNonTestEnvironment(() => {
|
||||
db = core.getDbInstance();
|
||||
});
|
||||
}, "first serve must not abort on a fresh setup DB that only has the 001 seed (#9934)");
|
||||
|
||||
// Prove the fresh DB actually got migrated past 001 to the latest version.
|
||||
const maxRow = db.prepare(
|
||||
"SELECT MAX(CAST(version AS INTEGER)) AS maxV FROM _omniroute_migrations"
|
||||
).get();
|
||||
assert.ok(
|
||||
(maxRow?.maxV ?? 0) > 1,
|
||||
`expected migrations beyond 001 to run, got max=${maxRow?.maxV}`
|
||||
);
|
||||
} finally {
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
fs.rmSync(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
);
|
||||
108
tests/unit/decrypt-failure-identify-credential-9927.test.ts
Normal file
108
tests/unit/decrypt-failure-identify-credential-9927.test.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
// #9927 — A credential that no longer decrypts (e.g. STORAGE_ENCRYPTION_KEY
|
||||
// changed between restarts) must emit a single, enriched error naming the
|
||||
// provider + connection id + failing field(s) and a recovery path, instead of
|
||||
// the generic low-level `[Encryption] Decryption failed … Auth tag validation
|
||||
// likely failed` line that carries no identity and is re-printed every sweep.
|
||||
|
||||
const ORIGINAL_STORAGE_KEY = process.env.STORAGE_ENCRYPTION_KEY;
|
||||
|
||||
// Cache-busted fresh import so the encryption module re-derives its key from
|
||||
// the current STORAGE_ENCRYPTION_KEY and resets module-level dedupe state.
|
||||
async function importFresh(modulePath: string) {
|
||||
const url = pathToFileURL(path.resolve(modulePath)).href;
|
||||
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
if (ORIGINAL_STORAGE_KEY === undefined) {
|
||||
delete process.env.STORAGE_ENCRYPTION_KEY;
|
||||
} else {
|
||||
process.env.STORAGE_ENCRYPTION_KEY = ORIGINAL_STORAGE_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
function captureConsoleError(fn: () => void): string[] {
|
||||
const original = console.error;
|
||||
const logs: string[] = [];
|
||||
console.error = (...args: unknown[]) => {
|
||||
logs.push(args.join(" "));
|
||||
};
|
||||
try {
|
||||
fn();
|
||||
} finally {
|
||||
console.error = original;
|
||||
}
|
||||
return logs;
|
||||
}
|
||||
|
||||
test("decryptConnectionFields logs failed credential identity + recovery path (#9927)", async () => {
|
||||
// 1. Encrypt an apiKey under key A.
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "stale-key-9927-A";
|
||||
const encA = await importFresh("src/lib/db/encryption.ts");
|
||||
const ciphertext = encA.encrypt("sk-real-secret-key");
|
||||
assert.match(ciphertext, /^enc:v1:/, "expected a real enc:v1 ciphertext");
|
||||
|
||||
// 2. Read it back under a DIFFERENT key B (simulating a changed key).
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "stale-key-9927-B";
|
||||
const encB = await importFresh("src/lib/db/encryption.ts");
|
||||
|
||||
const logs = captureConsoleError(() => {
|
||||
encB.decryptConnectionFields({
|
||||
id: "conn-9927",
|
||||
provider: "openai",
|
||||
apiKey: ciphertext,
|
||||
});
|
||||
});
|
||||
|
||||
// Must flag the failure so callers can surface the cause.
|
||||
const decrypted = encB.decryptConnectionFields({
|
||||
id: "conn-9927",
|
||||
provider: "openai",
|
||||
apiKey: ciphertext,
|
||||
});
|
||||
assert.equal(decrypted.credentialDecryptFailed, true);
|
||||
|
||||
// The generic low-level log must NOT fire (quiet:true); instead ONE enriched
|
||||
// message names provider + connection id + recovery path.
|
||||
assert.equal(
|
||||
logs.some((l) => /Auth tag validation likely failed/.test(l)),
|
||||
false,
|
||||
"generic low-level decrypt log must be suppressed on the connection path"
|
||||
);
|
||||
|
||||
const enriched = logs.find((l) => l.includes("Failed to decrypt credential(s)"));
|
||||
assert.ok(enriched, "expected an enriched credential-decrypt-failure log");
|
||||
assert.match(enriched, /provider "openai"/, "log must name the provider");
|
||||
assert.match(enriched, /conn-9927/, "log must name the connection id");
|
||||
assert.match(enriched, /apiKey/, "log must name the failing field");
|
||||
assert.match(
|
||||
enriched,
|
||||
/STORAGE_ENCRYPTION_KEY matches the key used to store it/,
|
||||
"log must include the recovery path"
|
||||
);
|
||||
});
|
||||
|
||||
test("credential-decrypt failure is logged once per connection (dedupe #9927)", async () => {
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "stale-key-9927-dedupe-A";
|
||||
const encA = await importFresh("src/lib/db/encryption.ts");
|
||||
const ciphertext = encA.encrypt("sk-dedupe-key");
|
||||
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "stale-key-9927-dedupe-B";
|
||||
const encB = await importFresh("src/lib/db/encryption.ts");
|
||||
|
||||
const row = { id: "conn-dedupe", provider: "openai", apiKey: ciphertext };
|
||||
const logs = captureConsoleError(() => {
|
||||
// Simulate the health sweep re-decrypting the same corrupt row repeatedly.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
encB.decryptConnectionFields(row);
|
||||
}
|
||||
});
|
||||
|
||||
const enriched = logs.filter((l) => l.includes("Failed to decrypt credential(s)"));
|
||||
assert.equal(enriched.length, 1, "identical failure must be logged once per connection");
|
||||
});
|
||||
@@ -701,6 +701,67 @@ test("provider-scoped image generation POST uses the shared 401 account fallback
|
||||
]);
|
||||
});
|
||||
|
||||
test("v1 image generation POST normalizes a terminal upstream 401 to the OpenAI-standard error shape", async () => {
|
||||
await seedConnection("openai", { apiKey: "single-expired-image-key" });
|
||||
|
||||
globalThis.fetch = async (url, options: RequestInit = {}) => {
|
||||
assert.equal(String(url), "https://api.openai.com/v1/images/generations");
|
||||
const authorization = new Headers(options.headers).get("authorization") ?? "";
|
||||
assert.equal(authorization, "Bearer single-expired-image-key");
|
||||
return new Response(JSON.stringify({ error: { message: "expired access token" } }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
const response = await imageRoute.POST(
|
||||
new Request("http://localhost/api/v1/images/generations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: "openai/gpt-image-2", prompt: "normalize terminal 401" }),
|
||||
})
|
||||
);
|
||||
const body = (await response.json()) as ErrorResponseBody;
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.deepEqual(body.error, {
|
||||
message: "expired access token",
|
||||
type: "authentication_error",
|
||||
code: "invalid_api_key",
|
||||
});
|
||||
});
|
||||
|
||||
test("provider-scoped image generation POST normalizes a terminal upstream 401 to the OpenAI-standard error shape", async () => {
|
||||
await seedConnection("openai", { apiKey: "provider-single-expired-key" });
|
||||
|
||||
globalThis.fetch = async (url, options: RequestInit = {}) => {
|
||||
assert.equal(String(url), "https://api.openai.com/v1/images/generations");
|
||||
const authorization = new Headers(options.headers).get("authorization") ?? "";
|
||||
assert.equal(authorization, "Bearer provider-single-expired-key");
|
||||
return new Response(JSON.stringify({ error: { message: "expired provider token" } }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
const response = await providerImageRoute.POST(
|
||||
new Request("http://localhost/api/v1/providers/openai/images/generations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ model: "gpt-image-2", prompt: "normalize provider terminal 401" }),
|
||||
}),
|
||||
{ params: Promise.resolve({ provider: "openai" }) }
|
||||
);
|
||||
const body = (await response.json()) as ErrorResponseBody;
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.deepEqual(body.error, {
|
||||
message: "expired provider token",
|
||||
type: "authentication_error",
|
||||
code: "invalid_api_key",
|
||||
});
|
||||
});
|
||||
|
||||
test("v1 image generation POST refreshes an expired Antigravity token before dispatch", async () => {
|
||||
await seedConnection("antigravity", {
|
||||
authType: "oauth",
|
||||
|
||||
@@ -123,7 +123,7 @@ test("handleSearch builds Brave news requests and normalizes favicon metadata",
|
||||
}
|
||||
});
|
||||
|
||||
test("handleSearch builds Exa requests with include/exclude domains and preserves rich result fields", async () => {
|
||||
test("handleSearch builds Exa requests with contents-nested options, include/exclude domains, and preserves rich result fields", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captured;
|
||||
|
||||
@@ -165,8 +165,7 @@ test("handleSearch builds Exa requests with include/exclude domains and preserve
|
||||
query: "agentic workflows",
|
||||
numResults: 5,
|
||||
type: "auto",
|
||||
text: true,
|
||||
highlights: true,
|
||||
contents: { text: true, highlights: true },
|
||||
includeDomains: ["allowed.com"],
|
||||
excludeDomains: ["blocked.com"],
|
||||
category: "news",
|
||||
|
||||
Reference in New Issue
Block a user