mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-11 09:42:15 +03:00
* fix(build): spawn npx.cmd through a shell in the electron better-sqlite3 rebuild Node's CVE-2024-27980 hardening makes spawnSync of .cmd/.bat shims fail with status null unless shell:true — the v3.8.47 tag build died on the Windows job with 'better-sqlite3 rebuild against electron 43.1.0 failed (exit null)'. Extracted the spawn plan into electronRebuildPlan.mjs (pure, import-safe) with a regression test; args are fixed literals, no untrusted input reaches the shell. * fix(build): ship head-response-guard.cjs in the npm tarball (#7065) The prepublish prune allowlist (pack-artifact-policy.ts) lacked head-response-guard.cjs, so assembleStandalone copied it and the prune deleted it — every boot of the published 3.8.47 crashed with ERR_MODULE_NOT_FOUND (3rd occurrence of this class; tls-options/3.8.41). Also added to PACK_ARTIFACT_REQUIRED_PATHS so check:pack-artifact fails loudly, plus a closure test that derives every server-ws.mjs sibling import and asserts both lists cover it. * fix(ci): zero the Sonar quality-gate findings on new code - ci.yml sonarqube job: download the coverage artifact into coverage/ so lcov.info lands where sonar.javascript.lcov.reportPaths points — the upload strips the common coverage/ prefix, so 'path: .' left new-code coverage at 0% on every scan - kiro auto-import: await the async isCloudEnabled() gate (S6544 — a bare truthiness check on the Promise made syncToCloud run even with cloud sync disabled) - stream.ts: real JSON fallback in the reasoning-split clone (S3923 — both ternary branches called structuredClone, the promised fallback was dead) - codex executor: handle the async reader.cancel() rejection (S4822) - deterministic localeCompare sorts (S2871 x2) - classify-pr-changes.mjs: confine the argv list file to the workspace (jssecurity:S8707 path-traversal guard) + behavioral tests - Dockerfile: rebuild better-sqlite3 with npm's bundled node-gyp instead of npx --yes (docker:S6505 — no on-demand registry install) * chore(ci): make the Sonar quality gate informational (wait=false) The org's SonarCloud FREE plan cannot associate a custom quality gate — only the built-in Sonar way (80% new coverage) applies, which no full-cycle release PR can realistically meet. The scan still uploads (dashboard, PR decoration); the CI job just no longer fails on the gate. A tuned 'OmniRoute way' gate (coverage >=60, duplication <=5) is already configured in the org for the day the plan is upgraded — re-enable wait=true then. * chore(release): v3.8.48 hotfix bump + changelog (npm 3.8.47 unbootable, #7065) * test: align pack-artifact + dockerfile guards to the corrected contracts pack-artifact-policy.test.ts encoded the pre-#7065 REQUIRED list (without head-response-guard.cjs) and dockerfile-better-sqlite3-node-gyp-6700.test.ts asserted the npx invocation the Sonar fix replaced with npm's bundled node-gyp — both now assert the corrected behavior.
396 lines
13 KiB
TypeScript
396 lines
13 KiB
TypeScript
/**
|
|
* Codex catalog revalidation (Codex provider only)
|
|
*
|
|
* Runs scrub + live re-sync only in three cases:
|
|
* 1) first-start — no version marker yet
|
|
* 2) upgrade — app version marker changed after update/reboot
|
|
* 3) init — setup/onboarding just completed (explicit trigger)
|
|
*
|
|
* Success log (single line): kill deprecated models complete.
|
|
*/
|
|
|
|
import { readFileSync } from "node:fs";
|
|
import path from "node:path";
|
|
import { isCodexDiscoveryModelExcluded } from "@/shared/services/codexDiscoveryPolicy";
|
|
import {
|
|
getSyncedAvailableModelsForConnection,
|
|
replaceSyncedAvailableModelsForConnection,
|
|
type SyncedAvailableModel,
|
|
} from "@/lib/db/models";
|
|
import { getProviderConnections } from "@/lib/db/providers";
|
|
import { getSettings, updateSettings } from "@/lib/db/settings";
|
|
|
|
export const CODEX_CATALOG_REVALIDATED_VERSION_KEY = "codex_catalog_revalidated_version";
|
|
|
|
export type CodexCatalogRevalidationReason = "first-start" | "upgrade" | "init";
|
|
|
|
type AppVersionOptions = {
|
|
runtimeRoot?: string;
|
|
packageVersion?: string | null;
|
|
};
|
|
|
|
function readNonEmptyTextFile(filePath: string): string | null {
|
|
try {
|
|
const value = readFileSync(filePath, "utf8").trim();
|
|
return value || null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function readInstalledPackageVersion(runtimeRoot: string): string | null {
|
|
try {
|
|
const pkg = JSON.parse(readFileSync(path.join(runtimeRoot, "package.json"), "utf8")) as {
|
|
version?: unknown;
|
|
};
|
|
return typeof pkg.version === "string" && pkg.version.trim() ? pkg.version.trim() : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Resolve a stable, source-qualified app identity for upgrade detection. */
|
|
export function resolveCodexCatalogAppVersion(
|
|
env: NodeJS.ProcessEnv = process.env,
|
|
options: AppVersionOptions = {}
|
|
): string | null {
|
|
for (const value of [env.OMNIROUTE_BUILD_SHA, env.BUILD_SHA]) {
|
|
if (typeof value === "string" && value.trim()) return `build:${value.trim()}`;
|
|
}
|
|
|
|
const runtimeRoot = options.runtimeRoot || process.cwd();
|
|
const buildSha = readNonEmptyTextFile(path.join(runtimeRoot, "BUILD_SHA"));
|
|
if (buildSha) return `build:${buildSha}`;
|
|
|
|
for (const buildIdPath of [
|
|
path.join(runtimeRoot, ".build", "next", "BUILD_ID"),
|
|
path.join(runtimeRoot, ".next", "BUILD_ID"),
|
|
]) {
|
|
const buildId = readNonEmptyTextFile(buildIdPath);
|
|
if (buildId) return `next:${buildId}`;
|
|
}
|
|
|
|
const envPackageVersion = env.npm_package_version || env.OMNIROUTE_VERSION;
|
|
if (typeof envPackageVersion === "string" && envPackageVersion.trim()) {
|
|
return `pkg:${envPackageVersion.trim()}`;
|
|
}
|
|
|
|
const hasPackageVersionOverride = Object.prototype.hasOwnProperty.call(options, "packageVersion");
|
|
const packageVersion = hasPackageVersionOverride
|
|
? options.packageVersion
|
|
: readInstalledPackageVersion(runtimeRoot);
|
|
return typeof packageVersion === "string" && packageVersion.trim()
|
|
? `pkg:${packageVersion.trim()}`
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* Pure: map stored marker + current version → boot trigger, or null to skip.
|
|
* - no marker → first-start
|
|
* - marker !== version → upgrade
|
|
* - else → null (do nothing on this boot)
|
|
*/
|
|
export function resolveBootRevalidationReason(
|
|
previousVersion: string | null | undefined,
|
|
appVersion: string
|
|
): CodexCatalogRevalidationReason | null {
|
|
if (!previousVersion || !String(previousVersion).trim()) return "first-start";
|
|
if (String(previousVersion).trim() !== appVersion) return "upgrade";
|
|
return null;
|
|
}
|
|
|
|
/** Pure helper: drop denylisted ids from a synced model list. */
|
|
export function scrubSyncedModelsWithCodexDenylist(models: SyncedAvailableModel[]): {
|
|
kept: SyncedAvailableModel[];
|
|
removedIds: string[];
|
|
} {
|
|
const removedIds: string[] = [];
|
|
const kept: SyncedAvailableModel[] = [];
|
|
for (const model of models) {
|
|
if (!model?.id) continue;
|
|
if (isCodexDiscoveryModelExcluded({ id: model.id })) {
|
|
removedIds.push(model.id);
|
|
continue;
|
|
}
|
|
kept.push(model);
|
|
}
|
|
return { kept, removedIds };
|
|
}
|
|
|
|
export type CodexCatalogScrubResult = {
|
|
connections: number;
|
|
connectionsChanged: number;
|
|
modelsRemoved: number;
|
|
removedIds: string[];
|
|
};
|
|
|
|
/** Offline pass: rewrite persisted Codex synced catalogs through the denylist. */
|
|
export async function scrubCodexPersistedCatalogs(): Promise<CodexCatalogScrubResult> {
|
|
const connections = await getProviderConnections({ provider: "codex" });
|
|
const removedIds = new Set<string>();
|
|
let connectionsChanged = 0;
|
|
|
|
for (const connection of connections) {
|
|
const connectionId = String(connection.id || "");
|
|
if (!connectionId) continue;
|
|
const existing = await getSyncedAvailableModelsForConnection("codex", connectionId);
|
|
if (existing.length === 0) continue;
|
|
const { kept, removedIds: removed } = scrubSyncedModelsWithCodexDenylist(existing);
|
|
if (removed.length === 0) continue;
|
|
await replaceSyncedAvailableModelsForConnection("codex", connectionId, kept);
|
|
connectionsChanged += 1;
|
|
for (const id of removed) removedIds.add(id);
|
|
}
|
|
|
|
return {
|
|
connections: connections.length,
|
|
connectionsChanged,
|
|
modelsRemoved: removedIds.size,
|
|
removedIds: Array.from(removedIds).sort((a, b) => a.localeCompare(b)),
|
|
};
|
|
}
|
|
|
|
async function listActiveCodexConnectionIds(): Promise<Array<{ id: string; name?: string }>> {
|
|
const connections = await getProviderConnections({ provider: "codex" });
|
|
return connections
|
|
.filter((conn) => conn.isActive !== false)
|
|
.map((conn) => ({
|
|
id: String(conn.id),
|
|
name: typeof conn.name === "string" ? conn.name : undefined,
|
|
}))
|
|
.filter((conn) => conn.id.length > 0);
|
|
}
|
|
|
|
export async function waitForLoopbackHttpReady(options?: {
|
|
apiBaseUrl?: string;
|
|
maxWaitMs?: number;
|
|
pollMs?: number;
|
|
}): Promise<void> {
|
|
const maxWaitMs = options?.maxWaitMs ?? 15_000;
|
|
const pollMs = options?.pollMs ?? 50;
|
|
const { fetchModelSyncInternal, resolveModelSyncInternalBaseUrl } =
|
|
await import("./modelSyncScheduler");
|
|
const baseUrl = resolveModelSyncInternalBaseUrl(options?.apiBaseUrl);
|
|
const deadline = Date.now() + maxWaitMs;
|
|
let lastErr: unknown;
|
|
|
|
while (Date.now() < deadline) {
|
|
try {
|
|
const res = await fetchModelSyncInternal(
|
|
`${baseUrl}/api/providers/__readiness_probe__/models`,
|
|
{
|
|
redirect: "error",
|
|
signal: AbortSignal.timeout(1_500),
|
|
}
|
|
);
|
|
if (res.status >= 200 && res.status < 600) return;
|
|
} catch (err) {
|
|
lastErr = err;
|
|
}
|
|
await new Promise((r) => setTimeout(r, pollMs));
|
|
}
|
|
|
|
throw new Error(
|
|
`loopback not ready within ${maxWaitMs}ms: ${
|
|
lastErr instanceof Error ? lastErr.message : String(lastErr)
|
|
}`
|
|
);
|
|
}
|
|
|
|
export async function liveResyncCodexConnections(
|
|
apiBaseUrl?: string
|
|
): Promise<{ attempted: number; succeeded: number }> {
|
|
const connections = await listActiveCodexConnectionIds();
|
|
if (connections.length === 0) {
|
|
return { attempted: 0, succeeded: 0 };
|
|
}
|
|
|
|
const { buildModelSyncInternalHeaders, fetchModelSyncInternal, resolveModelSyncInternalBaseUrl } =
|
|
await import("./modelSyncScheduler");
|
|
const base = resolveModelSyncInternalBaseUrl(apiBaseUrl);
|
|
const results = await Promise.allSettled(
|
|
connections.map(async (conn) => {
|
|
const res = await fetchModelSyncInternal(
|
|
`${base}/api/providers/${conn.id}/sync-models?quiet=1`,
|
|
{
|
|
method: "POST",
|
|
redirect: "error",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
...buildModelSyncInternalHeaders(),
|
|
},
|
|
}
|
|
);
|
|
if (!res.ok) {
|
|
throw new Error(`HTTP ${res.status}`);
|
|
}
|
|
return true;
|
|
})
|
|
);
|
|
|
|
const succeeded = results.filter((r) => r.status === "fulfilled").length;
|
|
return { attempted: connections.length, succeeded };
|
|
}
|
|
|
|
async function readPreviousVersionMarker(): Promise<string | null> {
|
|
try {
|
|
const settings = await getSettings();
|
|
const raw = settings?.[CODEX_CATALOG_REVALIDATED_VERSION_KEY];
|
|
return typeof raw === "string" && raw.trim() ? raw.trim() : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function writeVersionMarker(appVersion: string): Promise<boolean> {
|
|
try {
|
|
await updateSettings({ [CODEX_CATALOG_REVALIDATED_VERSION_KEY]: appVersion });
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
export type CodexCatalogRevalidationOutcome = {
|
|
complete: boolean;
|
|
attempted: number;
|
|
succeeded: number;
|
|
};
|
|
|
|
export async function executeCodexCatalogRevalidation(options: {
|
|
appVersion: string | null;
|
|
scrub: () => Promise<unknown>;
|
|
waitForReady: () => Promise<void>;
|
|
liveResync: () => Promise<{ attempted: number; succeeded: number }>;
|
|
writeMarker: (appVersion: string) => Promise<boolean>;
|
|
logSuccess: () => void;
|
|
}): Promise<CodexCatalogRevalidationOutcome> {
|
|
await options.scrub();
|
|
|
|
try {
|
|
await options.waitForReady();
|
|
} catch {
|
|
return { complete: false, attempted: 0, succeeded: 0 };
|
|
}
|
|
|
|
const syncResult = await options.liveResync();
|
|
if (syncResult.succeeded !== syncResult.attempted) {
|
|
return { complete: false, ...syncResult };
|
|
}
|
|
|
|
if (!options.appVersion) return { complete: false, ...syncResult };
|
|
const markerWritten = await options.writeMarker(options.appVersion);
|
|
if (!markerWritten) return { complete: false, ...syncResult };
|
|
|
|
options.logSuccess();
|
|
return { complete: true, ...syncResult };
|
|
}
|
|
|
|
type CodexCatalogRevalidationRequest = {
|
|
apiBaseUrl?: string;
|
|
reason: CodexCatalogRevalidationReason;
|
|
};
|
|
|
|
export function createCodexCatalogRevalidationCoordinator(
|
|
run: (options: CodexCatalogRevalidationRequest) => Promise<void>
|
|
): (options: CodexCatalogRevalidationRequest) => Promise<void> {
|
|
let activeRun: Promise<void> | null = null;
|
|
let activeReason: CodexCatalogRevalidationReason | null = null;
|
|
let queuedInit: CodexCatalogRevalidationRequest | null = null;
|
|
|
|
return (options) => {
|
|
if (activeRun !== null) {
|
|
if (options.reason === "init" && activeReason !== "init") {
|
|
queuedInit = options;
|
|
}
|
|
return activeRun;
|
|
}
|
|
|
|
activeRun = (async () => {
|
|
let current: CodexCatalogRevalidationRequest | null = options;
|
|
let firstError: unknown;
|
|
|
|
try {
|
|
while (current) {
|
|
activeReason = current.reason;
|
|
try {
|
|
await run(current);
|
|
} catch (error) {
|
|
firstError ??= error;
|
|
}
|
|
current = queuedInit;
|
|
queuedInit = null;
|
|
}
|
|
} finally {
|
|
activeRun = null;
|
|
activeReason = null;
|
|
}
|
|
|
|
if (firstError) throw firstError;
|
|
})();
|
|
|
|
return activeRun;
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Run scrub + live re-sync for an explicit reason, then mark version.
|
|
* Operator-facing success log is a single line.
|
|
*/
|
|
async function performCodexCatalogRevalidation(
|
|
options: CodexCatalogRevalidationRequest
|
|
): Promise<void> {
|
|
const appVersion = resolveCodexCatalogAppVersion();
|
|
const { resolveModelSyncInternalBaseUrl } = await import("./modelSyncScheduler");
|
|
const apiBaseUrl = resolveModelSyncInternalBaseUrl(options.apiBaseUrl);
|
|
|
|
await executeCodexCatalogRevalidation({
|
|
appVersion,
|
|
scrub: scrubCodexPersistedCatalogs,
|
|
waitForReady: () => waitForLoopbackHttpReady({ apiBaseUrl }),
|
|
liveResync: () => liveResyncCodexConnections(apiBaseUrl),
|
|
writeMarker: writeVersionMarker,
|
|
logSuccess: () => console.log("kill deprecated models complete."),
|
|
});
|
|
}
|
|
|
|
const requestCodexCatalogRevalidation = createCodexCatalogRevalidationCoordinator(
|
|
performCodexCatalogRevalidation
|
|
);
|
|
|
|
export function revalidateCodexCatalogs(options: CodexCatalogRevalidationRequest): Promise<void> {
|
|
return requestCodexCatalogRevalidation(options);
|
|
}
|
|
|
|
/** Boot path: only first-start or upgrade. */
|
|
export async function revalidateCodexCatalogsOnStartup(options?: {
|
|
apiBaseUrl?: string;
|
|
}): Promise<void> {
|
|
const appVersion = resolveCodexCatalogAppVersion();
|
|
const previousVersion = await readPreviousVersionMarker();
|
|
const reason = appVersion
|
|
? resolveBootRevalidationReason(previousVersion, appVersion)
|
|
: "first-start";
|
|
if (!reason) return;
|
|
await revalidateCodexCatalogs({ apiBaseUrl: options?.apiBaseUrl, reason });
|
|
}
|
|
|
|
function scheduleRun(run: () => Promise<void>): void {
|
|
const timer = setTimeout(() => {
|
|
void run().catch(() => {
|
|
// silent — success line only on full success
|
|
});
|
|
}, 0);
|
|
timer.unref?.();
|
|
}
|
|
|
|
/** Fire-and-forget boot schedule (first-start / upgrade only). */
|
|
export function scheduleCodexCatalogRevalidation(options?: { apiBaseUrl?: string }): void {
|
|
scheduleRun(() => revalidateCodexCatalogsOnStartup({ apiBaseUrl: options?.apiBaseUrl }));
|
|
}
|
|
|
|
/** Fire-and-forget after setup/onboarding completes. */
|
|
export function scheduleCodexCatalogRevalidationAfterInit(options?: { apiBaseUrl?: string }): void {
|
|
scheduleRun(() => revalidateCodexCatalogs({ apiBaseUrl: options?.apiBaseUrl, reason: "init" }));
|
|
}
|