Compare commits

..

1 Commits

Author SHA1 Message Date
Markus Hartung
b32817c80d fix(db): dedupe lazy-decrypt-view failure logging across sync cycles (#11500) 2026-08-26 13:32:08 -03:00
9 changed files with 197 additions and 134 deletions

View File

@@ -1 +0,0 @@
- **fix(combos):** the combo builder's precision-select, global-model-search, and manual-entry flows now serialize a model step's `model` string using the provider's already-computed routing-alias prefix (e.g. `oc/`) instead of rebuilding it from the raw canonical `providerId`, fixing the no-auth "OpenCode Free" provider (`opencode`) being routed to the unrelated paid "OpenCode Zen" provider (`opencode-zen`) because `opencode` doubles as a manual routing-prefix override ([#11433](https://github.com/diegosouzapw/OmniRoute/issues/11433)).

View File

@@ -0,0 +1 @@
- **fix(db):** dedupe the raw `[Encryption] Decryption failed...` log line emitted by the lazy-decrypt views (`createLazyRowProxy`/`createLazyConnectionView`), which power `getProviderConnections()` and were re-triggering that line on every CredentialHealth/model-sync cycle for the same corrupt or stale-key credential — a fresh Proxy over a fresh row on every cycle meant the per-proxy memoization never suppressed it, unlike the dedup `decryptConnectionFields()` already had since [#9927](https://github.com/diegosouzapw/OmniRoute/issues/9927). Now shares that dedupe tracking so the line logs at most once per credential ([#11500](https://github.com/diegosouzapw/OmniRoute/issues/11500)).

View File

@@ -2177,9 +2177,6 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
builderConnectionId !== COMBO_BUILDER_AUTO_CONNECTION ? builderConnectionId : null,
connectionLabel: selectedBuilderConnection?.label || null,
allowedConnectionIds: builderEffectiveAllowedConnectionIds,
// #11433: use the already-corrected routing prefix (e.g. "oc" for
// OpenCode Free) instead of letting it default to the raw providerId.
modelPrefix: parseQualifiedModel(selectedBuilderModel.qualifiedModel)?.providerId,
})
: null;
const builderHasDuplicate =
@@ -2504,9 +2501,6 @@ function ComboFormModal({ isOpen, combo, onClose, onSave, activeProviders, combo
builderConnectionId !== COMBO_BUILDER_AUTO_CONNECTION ? builderConnectionId : null,
connectionLabel: selectedBuilderConnection?.label || null,
allowedConnectionIds: builderEffectiveAllowedConnectionIds,
// #11433: use the already-corrected routing prefix (e.g. "oc" for
// OpenCode Free) instead of letting it default to the raw providerId.
modelPrefix: parseQualifiedModel(selectedBuilderModel.qualifiedModel)?.providerId,
});
if (hasExactModelStepDuplicate(models, nextStep)) {

View File

@@ -83,7 +83,6 @@ export function buildPrecisionComboModelStep({
connectionLabel,
allowedConnectionIds = null,
weight = 0,
modelPrefix,
}: {
providerId: string;
modelId: string;
@@ -92,22 +91,9 @@ export function buildPrecisionComboModelStep({
/** #3266: account allowlist scoping round-robin to a subset of connections. */
allowedConnectionIds?: string[] | null;
weight?: number;
/**
* #11433: the routing-prefix segment to serialize into `model` (e.g. "oc"
* for the no-auth OpenCode Free provider), when it differs from the
* canonical `providerId`. Some canonical provider ids collide with an
* unrelated manual `ALIAS_TO_PROVIDER_ID` routing override (`opencode` →
* `opencode-zen`), so reconstructing `model` from the raw `providerId`
* alone can round-trip to the wrong provider on request routing. Falls
* back to `providerId` when omitted/blank. `step.providerId` always stays
* the canonical id regardless, so routing/duplicate-detection identity is
* unaffected.
*/
modelPrefix?: string | null;
}): ComboModelStep {
const normalizedProviderId = toTrimmedString(providerId) || "provider";
const normalizedModelId = toTrimmedString(modelId) || "model";
const normalizedModelPrefix = toTrimmedString(modelPrefix) || normalizedProviderId;
const normalizedConnectionId = toTrimmedString(connectionId);
const normalizedConnectionLabel = toTrimmedString(connectionLabel);
// A pinned single connection wins over an allowlist, so only carry the allowlist
@@ -124,7 +110,7 @@ export function buildPrecisionComboModelStep({
return {
kind: "model",
providerId: normalizedProviderId,
model: `${normalizedModelPrefix}/${normalizedModelId}`,
model: `${normalizedProviderId}/${normalizedModelId}`,
...(normalizedConnectionId ? { connectionId: normalizedConnectionId } : {}),
...(normalizedConnectionLabel ? { label: normalizedConnectionLabel } : {}),
...(normalizedAllowed.length > 0 ? { allowedConnectionIds: normalizedAllowed } : {}),
@@ -174,15 +160,10 @@ export function buildManualComboModelStep({
const providerId = resolveComboBuilderProviderId(parsed.providerId, providers);
if (!providerId) return null;
// #11433: preserve the user-typed prefix (e.g. "oc") as the routing prefix
// instead of letting buildPrecisionComboModelStep rebuild `model` from the
// resolved canonical providerId, which can collide with an unrelated
// manual alias override (e.g. "opencode" -> "opencode-zen").
return buildPrecisionComboModelStep({
providerId,
modelId: parsed.modelId,
weight,
modelPrefix: parsed.providerId,
});
}
@@ -244,7 +225,7 @@ type ComboBuilderGlobalProvider = {
displayName?: unknown;
connectionCount?: unknown;
connections?: unknown[];
models?: Array<{ id?: unknown; name?: unknown; qualifiedModel?: unknown }>;
models?: Array<{ id?: unknown; name?: unknown }>;
};
/**
@@ -271,18 +252,12 @@ export function buildGlobalModelList(
const modelId = toTrimmedString(model?.id);
if (!modelId) return;
const modelName = toTrimmedString(model?.name) || modelId;
// #11433: derive the routing prefix from the model's already-corrected
// `qualifiedModel` (e.g. "oc/<model>" for the OpenCode Free provider)
// instead of defaulting to the raw providerId, which can collide with
// an unrelated manual alias override.
const modelPrefix = parseQualifiedModel(model?.qualifiedModel)?.providerId || providerId;
const step = buildPrecisionComboModelStep({
providerId,
modelId,
connectionId: null,
connectionLabel: null,
allowedConnectionIds: [],
modelPrefix,
});
list.push({
providerId,

View File

@@ -288,6 +288,36 @@ export function decrypt(
}
}
/**
* #11500 — decrypt() wrapper for callers outside decryptConnectionFields()
* (the lazy-decrypt views in providers/lazyConnectionView.ts, which call
* decrypt() directly on every fresh getProviderConnections() cycle). A
* fresh Proxy wraps a fresh row object each cycle, so per-proxy memoization
* never survives across cycles — without this wrapper the raw
* "[Encryption] Decryption failed..." line re-fires every single cycle for
* the same corrupt/stale-key credential. Shares the loggedDecryptFailures
* Set with decryptConnectionFields() so a credential already flagged via one
* path does not re-log via the other, and logs the SAME raw message
* decrypt() would emit (unlike decryptConnectionFields()'s enriched
* message) — just deduped to once per (provider + connection + field +
* ciphertext) instead of once per cycle.
*/
export function decryptQuiet(
ciphertext: string | null | undefined,
meta: { connectionId: string; provider: string; field: string }
): string | null | undefined {
if (!looksEncrypted(ciphertext)) {
return decrypt(ciphertext);
}
const signature = `${meta.provider}::${meta.connectionId}::${meta.field}:${ciphertext}`;
const alreadyLogged = loggedDecryptFailures.has(signature);
const result = decrypt(ciphertext, { quiet: alreadyLogged });
if (result === null && !alreadyLogged) {
loggedDecryptFailures.add(signature);
}
return result;
}
/**
* Encrypt sensitive fields in a connection object (mutates in-place).
* After decryption that required legacy key, re-encrypt with static key

View File

@@ -9,7 +9,7 @@
* admin, and catalog callers during Phase2/3 of the lazy-decrypt rollout.
*/
import { decrypt } from "../encryption";
import { decryptQuiet } from "../encryption";
type JsonRecord = Record<string, unknown>;
@@ -119,10 +119,18 @@ export function createLazyConnectionView(row: Record<string, unknown>): Provider
const ensureDecrypted = () => {
if (!decrypted) {
const connectionId = base.id;
const provider = base.provider;
decrypted = {
apiKey: toStringOrNull(decrypt(base.apiKey)),
accessToken: toStringOrNull(decrypt(base.accessToken)),
refreshToken: toStringOrNull(decrypt(base.refreshToken)),
apiKey: toStringOrNull(
decryptQuiet(base.apiKey, { connectionId, provider, field: "apiKey" })
),
accessToken: toStringOrNull(
decryptQuiet(base.accessToken, { connectionId, provider, field: "accessToken" })
),
refreshToken: toStringOrNull(
decryptQuiet(base.refreshToken, { connectionId, provider, field: "refreshToken" })
),
};
}
return decrypted;
@@ -154,11 +162,13 @@ export function createLazyRowProxy(row: Record<string, unknown>): Record<string,
const ensureDecrypted = () => {
if (!decrypted) {
const connectionId = typeof row.id === "string" ? row.id : "";
const provider = typeof row.provider === "string" ? row.provider : "unknown";
decrypted = {
apiKey: lazyDecrypt(row.apiKey),
accessToken: lazyDecrypt(row.accessToken),
refreshToken: lazyDecrypt(row.refreshToken),
idToken: lazyDecrypt(row.idToken),
apiKey: lazyDecrypt(row.apiKey, { connectionId, provider, field: "apiKey" }),
accessToken: lazyDecrypt(row.accessToken, { connectionId, provider, field: "accessToken" }),
refreshToken: lazyDecrypt(row.refreshToken, { connectionId, provider, field: "refreshToken" }),
idToken: lazyDecrypt(row.idToken, { connectionId, provider, field: "idToken" }),
};
}
return decrypted;
@@ -189,7 +199,10 @@ export function createLazyRowProxy(row: Record<string, unknown>): Record<string,
});
}
function lazyDecrypt(value: unknown): string | null | undefined {
function lazyDecrypt(
value: unknown,
meta: { connectionId: string; provider: string; field: string }
): string | null | undefined {
if (typeof value !== "string") return undefined;
return decrypt(value);
return decryptQuiet(value, meta);
}

View File

@@ -42,12 +42,6 @@ test("buildPrecisionComboModelStep preserves provider/model/account triple", ()
});
test("buildManualComboModelStep resolves provider aliases and uses dynamic account", () => {
// #11433: `providerId` resolves to the canonical id ("codex") for
// duplicate-detection/routing identity, but the serialized `model` string
// now preserves the user-typed prefix ("cx/") verbatim instead of
// collapsing back to the canonical id — some canonical ids (e.g.
// "opencode") collide with an unrelated manual routing-alias override, so
// rebuilding `model` from the canonical id alone can silently misroute.
assert.deepEqual(
builderDraft.buildManualComboModelStep({
value: "cx/gpt-5.5",
@@ -56,7 +50,7 @@ test("buildManualComboModelStep resolves provider aliases and uses dynamic accou
{
kind: "model",
providerId: "codex",
model: "cx/gpt-5.5",
model: "codex/gpt-5.5",
weight: 0,
}
);

View File

@@ -1,83 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
buildPrecisionComboModelStep,
buildGlobalModelList,
buildManualComboModelStep,
} from "../../src/lib/combos/builderDraft.ts";
import { resolveProviderAlias, parseModel } from "../../open-sse/services/model.ts";
// Issue #11433: the combo builder's precision-select path builds a step's
// `model` string as `${providerId}/${modelId}` using the CANONICAL provider id.
// For the no-auth "opencode" (OpenCode Free) provider this produces
// `model: "opencode/<modelId>"`, but `opencode` is ALSO a manual routing-prefix
// override (`ALIAS_TO_PROVIDER_ID["opencode"] = "opencode-zen"`) intended only
// for user-typed `opencode/` prefixes referring to the OpenCode Zen (api-key)
// tier. Parsing the step's own `model` string therefore resolves to a
// DIFFERENT provider than the one recorded in `step.providerId`.
test('sanity: resolveProviderAlias("opencode") is the manual override causing the collision', () => {
// Documents the root cause directly: the manual alias override in
// open-sse/services/model.ts unconditionally rewrites "opencode" to
// "opencode-zen", even though "opencode" is also a registered canonical
// provider id (src/shared/constants/providers/noauth.ts).
assert.equal(resolveProviderAlias("opencode"), "opencode-zen");
});
test("issue #11433 fix: buildPrecisionComboModelStep honors an explicit modelPrefix override", () => {
// The combo builder call sites now thread through the already-computed
// routing-alias prefix (e.g. "oc") instead of letting the step default to
// the raw providerId, so the serialized `model` field round-trips to the
// correct provider.
const step = buildPrecisionComboModelStep({
providerId: "opencode",
modelId: "big-pickle",
modelPrefix: "oc",
});
assert.equal(step.providerId, "opencode");
assert.equal(step.model, "oc/big-pickle");
const parsed = parseModel(step.model);
assert.equal(parsed.provider, step.providerId);
});
test("issue #11433 fix: buildGlobalModelList derives modelPrefix from qualifiedModel for the no-auth OpenCode Free provider", () => {
// Mirrors what src/lib/combos/builderOptions.ts::rewriteQualifiedModelPrefix
// produces for the no-auth "opencode" provider entry: `qualifiedModel` is
// already rewritten to the "oc/" alias prefix, but (pre-fix)
// buildGlobalModelList ignored it and rebuilt `model` from the raw
// providerId, producing "opencode/big-pickle" which parses back to the
// wrong provider ("opencode-zen").
const [entry] = buildGlobalModelList([
{
providerId: "opencode",
displayName: "OpenCode Free",
connectionCount: 0,
connections: [],
models: [{ id: "big-pickle", name: "Big Pickle", qualifiedModel: "oc/big-pickle" }],
},
]);
assert.equal(entry.step.providerId, "opencode");
assert.equal(entry.step.model, "oc/big-pickle");
assert.equal(parseModel(entry.step.model).provider, entry.step.providerId);
});
test("issue #11433 fix: buildManualComboModelStep preserves a user-typed oc/<model> prefix", () => {
// buildManualComboModelStep resolves the typed alias ("oc") back to the
// canonical providerId ("opencode") before building the step. Pre-fix, it
// then handed that canonical id straight to buildPrecisionComboModelStep,
// which rebuilt `model` from it and collapsed "oc/<model>" back down to
// "opencode/<model>" — reproducing the same collision for manual entry.
const step = buildManualComboModelStep({
value: "oc/big-pickle",
providers: [{ providerId: "opencode", alias: "oc" }],
});
assert.ok(step);
assert.equal(step?.providerId, "opencode");
assert.equal(step?.model, "oc/big-pickle");
assert.equal(parseModel(step!.model).provider, step!.providerId);
});

View File

@@ -0,0 +1,140 @@
import test from "node:test";
import assert from "node:assert/strict";
import { createCipheriv, randomBytes, scryptSync } from "node:crypto";
import path from "node:path";
import { pathToFileURL } from "node:url";
// #11500 — the #9927 fix deduped raw decrypt-failure logging only inside
// decryptConnectionFields(). The lazy-decrypt rollout (createLazyRowProxy /
// createLazyConnectionView in src/lib/db/providers/lazyConnectionView.ts,
// used by getProviderConnections() on every CredentialHealth/model-sync
// cycle) called decrypt() directly with no quiet option and no dedup
// tracking, so the raw "[Encryption] Decryption failed..." line re-fired on
// every cycle for the same corrupt/stale-key credential.
const ORIGINAL_STORAGE_KEY = process.env.STORAGE_ENCRYPTION_KEY;
async function importFresh(modulePath: string) {
const url = pathToFileURL(path.resolve(modulePath)).href;
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
}
function encryptWithStaticSalt(secret: string, salt: string, plaintext: string): string {
const key = scryptSync(secret, salt, 32);
const iv = randomBytes(16);
const cipher = createCipheriv("aes-256-gcm", key, iv);
let encrypted = cipher.update(plaintext, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag().toString("hex");
return `enc:v1:${iv.toString("hex")}:${encrypted}:${authTag}`;
}
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.map(String).join(" "));
};
try {
fn();
} finally {
console.error = original;
}
return logs;
}
test("#11500 — createLazyRowProxy dedupes decrypt-failure logging across sync cycles", async () => {
process.env.STORAGE_ENCRYPTION_KEY = "probe-11500-current-key";
const { createLazyRowProxy } = await importFresh("src/lib/db/providers/lazyConnectionView.ts");
// Credential encrypted with a DIFFERENT key than the one currently
// configured (stale STORAGE_ENCRYPTION_KEY / corrupted row) — produces
// exactly "Auth tag validation likely failed."
const staleCiphertext = encryptWithStaticSalt(
"some-other-key-that-was-rotated-away",
"omniroute-field-encryption-v1",
"sk-super-secret-api-key"
);
const rawRow = {
id: "conn-zai-1",
provider: "zai",
apiKey: staleCiphertext,
accessToken: null,
refreshToken: null,
idToken: null,
};
const capturedLines = captureConsoleError(() => {
// Simulate 3 separate CredentialHealth / model-sync cycles, each of
// which calls getProviderConnections() fresh and gets a brand-new
// createLazyRowProxy() over a brand-new row object for the SAME
// underlying corrupt DB row.
for (let cycle = 0; cycle < 3; cycle++) {
const view = createLazyRowProxy({ ...rawRow });
void view.apiKey;
}
});
const rawDecryptFailureLines = capturedLines.filter((line) =>
line.includes("[Encryption] Decryption failed. Ciphertext prefix:")
);
assert.equal(
rawDecryptFailureLines.length,
1,
`expected the raw decrypt-failure line to be logged at most once across 3 sync cycles for the ` +
`same corrupt credential, but it was logged ${rawDecryptFailureLines.length} times: ` +
JSON.stringify(rawDecryptFailureLines, null, 2)
);
});
test("#11500 — createLazyConnectionView dedupes decrypt-failure logging across sync cycles", async () => {
process.env.STORAGE_ENCRYPTION_KEY = "probe-11500-current-key-view";
const { createLazyConnectionView } = await importFresh(
"src/lib/db/providers/lazyConnectionView.ts"
);
const staleCiphertext = encryptWithStaticSalt(
"some-other-key-that-was-rotated-away-view",
"omniroute-field-encryption-v1",
"sk-super-secret-api-key-view"
);
const rawRow = {
id: "conn-glm-1",
provider: "glm",
apiKey: staleCiphertext,
accessToken: null,
refreshToken: null,
};
const capturedLines = captureConsoleError(() => {
for (let cycle = 0; cycle < 3; cycle++) {
const view = createLazyConnectionView({ ...rawRow });
void view.apiKey;
}
});
const rawDecryptFailureLines = capturedLines.filter((line) =>
line.includes("[Encryption] Decryption failed. Ciphertext prefix:")
);
assert.equal(
rawDecryptFailureLines.length,
1,
`expected the raw decrypt-failure line to be logged at most once across 3 sync cycles for the ` +
`same corrupt credential, but it was logged ${rawDecryptFailureLines.length} times: ` +
JSON.stringify(rawDecryptFailureLines, null, 2)
);
});