Files
OmniRoute/scripts/packs/optionalPackManifest.mjs
Diego Rodrigues de Sa e Souza 7d57d9f4a1 fix(providers): retire common ChatGPT Web provider (#11754)
Rebased onto the current release/v3.8.51 tip as part of a combined provider-retirement/provenance merge batch (Designer Web, Felo Web, Runtime, GPL-derived removal, Qwen Web already landed). Large conflict set (this is the biggest PR in the batch — the common ChatGPT Web provider touches chat, images, count-tokens, session leases, and combos). Conflicts resolved:

- `open-sse/config/providers/registry/chatgpt-web/*`, `open-sse/executors/chatgpt-web*`, `open-sse/handlers/imageGeneration/providers/chatgptWeb.ts`, and their tests: kept deleted, matching the PR's stated scope.
- `open-sse/config/providers/registry/minimax/web/index.ts`, `open-sse/handlers/imageGeneration/providers/geminiWeb.ts`, `open-sse/executors/gemini-web.ts`'s stale image-mode branch: base-drift collisions against already-merged sibling retirements (#11691, #11708) — kept deleted / dropped the dead code, since this PR's own branch forked before those merged.
- `src/shared/constants/reservedProviderPrefixes.ts`, `open-sse/executors/index.ts`, `executorProxy.ts`, `virtualFactory.ts`, `autoStrategy.ts`, `src/lib/db/providers.ts`, `src/sse/handlers/chat.ts`: combined the Designer + Runtime (Felo/Qwen) + common-ChatGPT-Web retirement guard calls at each shared chokepoint — compute-once-then-OR pattern, consistent with prior combinations in this batch.
- `src/sse/services/model.ts` / `src/sse/handlers/chatHelpers.ts`: adopted this PR's new `getModelInfoOrRetirementResponse()` central wrapper (a real improvement over ad-hoc try/catch), and extended it to also catch the Designer + Runtime retirement errors it didn't originally cover, so the consolidation doesn't regress the other two mechanisms.
- `src/app/api/v1/images/edits/route.ts`: this PR moved the retirement check earlier (before `enforceApiKeyPolicy`) but left the old later call+catch block in place from base drift — removed the now-redundant duplicate `resolveImageRouteModel()` call and merged the Designer catch into the earlier one.
- `open-sse/config/imageRegistry.ts`, `tests/snapshots/executors/executor-map.json` (`keyCount` recomputed to 133), `tests/snapshots/provider/translate-path.json`: same "both sides inserted a different retired provider at the same slot" pattern — resolved by dropping both.
- `tests/unit/chatcore-executor-proxy.test.ts`, `provider-node-reserved-prefix.test.ts`, `combo-auto-candidate-expansion.test.ts`, `messages-count-tokens-route.test.ts`, `virtual-auto-combo.test.ts`: split into independent per-mechanism test blocks (established pattern); `virtual-auto-combo.test.ts`'s old "includes cookie web-session providers" positive-inclusion test (which used chatgpt-web as its example) was retired along with the provider and replaced by this PR's negative-exclusion test for the same slot.
- `docs/architecture/ARCHITECTURE.md`, `CODEBASE_DOCUMENTATION.md` (+ 4 i18n mirrors), `README.md`, `FREE-TIERS-GUIDE.md`, `docs/diagrams/free-tier-budget.svg`, `docs/screenshots/free-tier-budget-card.svg`, `docs/reference/PROVIDER_REFERENCE.md`: recomputed every stale count from the real merged state — 104 executors (`countFiles` gate logic), 351 providers (regenerated via `gen:provider-reference`), 152/351 `hasFree` entries, 445/438/7 free-tier catalog rows, 13 ToS-avoid providers, budget-card regenerated via its real generator script. One doc conflict (`oauth/` module list) needed picking HEAD's side specifically — theirs still listed the already-removed `raycast` module instead of the real `openference`.
- `config/quality/test-masking-allowlist.json`: additive merge of the PR's 17 `_deletedWithReplacement` entries alongside the batch's existing ones (one real duplicate-key mistake in my first pass, caught and fixed via a `object_pairs_hook` duplicate-key check before finalizing).

Also fixed two real, unrelated-to-my-merge issues surfaced by the focused suite:
- `tests/unit/resolve-web-provider-host.test.ts`: the PR's own test had a typo — it asserted `perplexity-web`'s resolved host as `"perplexity.ai"`, but the provider's registered `website` is `"https://www.perplexity.ai"` and the resolver returns the URL's `host` verbatim (no www-stripping), so the correct value is `"www.perplexity.ai"` (consistent with the same test's own `url` assertion).
- `tests/unit/hard-session-lease-bypass-inventory.test.ts`: this golden call-site inventory was already stale on the pristine post-#11713 tip (confirmed via a throwaway probe worktree) — `src/lib/db/providers.ts`'s 3 connection-fallback sites and a third `src/app/api/providers/route.ts` site were never added to the golden list by the earlier-merged #11698/#11720 PRs. Updated it to the real current inventory (dated inline comments explain each delta and which PR introduced it), plus this PR's own legitimate deltas (image-edits duplicate-call removal, `ChatGptWebExecutor.execute()` site removed).

Focused suite green (433/433 across executor-proxy, reserved-prefix, hard-session-lease-bypass-inventory, resolve-web-provider-host, retirement/runtime-block/source-retirement/management-retirement/image-handler-retirement, migration-168, combo-auto-candidate-expansion, virtual-auto-combo, executor-map-golden and siblings), plus `typecheck:core`, `check-file-size`, and `check-changelog-integrity` clean. Thanks for the thorough provenance-hold retirement work — appreciated.
2026-08-28 06:52:46 -03:00

229 lines
8.5 KiB
JavaScript

#!/usr/bin/env node
/**
* OmniRoute — Optional runtime pack manifest + integrity core.
*
* Stage 7 of the Electron efficiency roadmap (issue #10321): the heavy optional
* ML / browser automation dependency closure is excluded from the packaged
* desktop app and shipped as versioned, checksummed packs that install on first
* use into `DATA_DIR/packs/<name>/node_modules`.
*
* This module owns the *contract* shared by three consumers:
* - `scripts/build/optionalPackStaging.mjs` (build): checksums the staged
* closure, emits `optional-packs.index.json`, removes pack members from the
* Electron staging tree, optionally tars the packs for release assets.
* - `scripts/packs/optionalPackInstaller.mjs` (first use): installs/verifies/
* removes packs in DATA_DIR against the shipped index.
* - `bin/cli/commands/packs.mjs` (UX): `omniroute packs …`.
*
* The runtime *resolution* side (making an installed pack light up the SLM /
* embeddings / browser features) lives in `open-sse/utils/optionalPacks.ts` and
* intentionally does NOT import this file — it embeds only the pack names.
*
* Fail-open philosophy: every consumer of a pack degrades gracefully when the
* pack is absent; nothing here may throw into a code path that works today.
*/
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import fs from "node:fs";
import path from "node:path";
/**
* The optional runtime packs. Membership changes require bumping `packVersion`.
*
* `os`/`cpu` use Node `process.platform`/`process.arch` values and exist so the
* installer can refuse (with a clear error) a pack whose native payloads do not
* match the machine — e.g. a future pack that only ships darwin/win prebuilds.
*/
export const OPTIONAL_PACKS = [
{
name: "ml-runtime",
packVersion: 1,
description:
"Local ML inference closure: LLMLingua-2 SLM prompt compression and transformers.js memory embeddings",
packages: [
// NOTE: exact versions are resolved at packaging time from the staged
// tree and recorded in optional-packs.index.json — the manifest defines
// MEMBERSHIP only, so member bumps don't need a manifest edit unless the
// set of packages changes.
{ name: "@huggingface/transformers" },
{ name: "onnxruntime-node" },
{ name: "@atjsh/llmlingua-2" },
{ name: "js-tiktoken" },
],
},
{
name: "browser-runtime",
packVersion: 1,
description:
"Browser automation closure: Claude Turnstile solver and ChatGPT Web (Codex)/Gemini web executors",
packages: [{ name: "playwright" }, { name: "playwright-core" }],
},
];
/** Index file emitted at the standalone bundle root (same walk-up anchor style as llmlingua's GATE_DEP_REL). */
export const PACK_INDEX_FILENAME = "optional-packs.index.json";
/** Look up a pack definition by name. */
export function findPack(name) {
return OPTIONAL_PACKS.find((pack) => pack.name === name) ?? null;
}
/** Flatten every package name across all packs (sorted, deduped). */
export function allPackPackageNames() {
return [...new Set(OPTIONAL_PACKS.flatMap((pack) => pack.packages.map((p) => p.name)))].sort();
}
/** Whether `platform`/`arch` satisfy a package's optional os/cpu filters. */
export function packageMatchesPlatform(pkg, platform = process.platform, arch = process.arch) {
if (Array.isArray(pkg.os) && !pkg.os.includes(platform)) return false;
if (Array.isArray(pkg.cpu) && !pkg.cpu.includes(arch)) return false;
return true;
}
/** Whether every package of `pack` matches the platform (compat gate for installs). */
export function packMatchesPlatform(pack, platform = process.platform, arch = process.arch) {
return pack.packages.every((pkg) => packageMatchesPlatform(pkg, platform, arch));
}
// ─── deterministic directory checksum ───────────────────────────────────────────
/**
* Recursively collect sorted relative POSIX paths of regular files under `dir`.
* Symlinks are included as their own entries (link target hashed) — npm trees can
* contain them and silently skipping them would weaken tamper detection.
*
* @param {string} dir
* @returns {{rel: string, absolute: string, symlink: boolean}[]}
*/
export function listDirFiles(dir) {
const out = [];
const walk = (current, prefix) => {
let entries;
try {
entries = fs.readdirSync(current, { withFileTypes: true });
} catch {
return;
}
// Sort for determinism across platforms/FS orderings.
const sorted = [...entries].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
for (const entry of sorted) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
const absolute = path.join(current, entry.name);
if (entry.isDirectory()) {
walk(absolute, rel);
} else {
out.push({ rel, absolute, symlink: entry.isSymbolicLink() });
}
}
};
walk(dir, "");
return out;
}
/**
* Deterministic sha256 over a directory tree: sorted relative path + per-file
* content (or link target). Byte-stable across platforms (POSIX separators).
*
* @param {string} dir
* @returns {Promise<{sha256: string, files: number, bytes: number}>}
*/
export async function dirChecksum(dir) {
const hash = createHash("sha256");
let files = 0;
let bytes = 0;
for (const { rel, absolute, symlink } of listDirFiles(dir)) {
hash.update(rel);
hash.update("\0");
if (symlink) {
let target = "";
try {
target = fs.readlinkSync(absolute);
} catch {
/* unreadable link — hash as empty target */
}
hash.update(`link:${target}`);
} else {
let size = 0;
try {
size = fs.statSync(absolute).size;
} catch {
/* stat race — hash content stream anyway */
}
bytes += size;
hash.update(String(size));
hash.update("\0");
try {
// Stream to keep memory bounded on multi-hundred-MB packages (onnxruntime-node).
for await (const chunk of createReadStream(absolute)) hash.update(chunk);
} catch {
hash.update("<unreadable>");
}
}
hash.update("\0");
files++;
}
return { sha256: hash.digest("hex"), files, bytes };
}
// ─── index build / verify ────────────────────────────────────────────────────────
/**
* Build the pack index entry for one pack from a populated `node_modules` dir.
* Records resolved versions + deterministic checksums so installs and `verify`
* can prove integrity without network access.
*
* @param {{name: string, packVersion: number, description?: string, packages: {name: string}[]}} pack
* @param {string} nodeModulesDir tree containing the pack members
* @returns {Promise<{name: string, packVersion: number, description: string, tarball: string, packages: object[]}>}
*/
export async function buildPackIndexEntry(pack, nodeModulesDir) {
const packages = [];
for (const pkg of pack.packages) {
const pkgDir = path.join(nodeModulesDir, ...pkg.name.split("/"));
if (!fs.existsSync(path.join(pkgDir, "package.json"))) {
throw new Error(`pack member missing from staging tree: ${pkg.name}`);
}
const manifest = JSON.parse(fs.readFileSync(path.join(pkgDir, "package.json"), "utf8"));
const checksum = await dirChecksum(pkgDir);
packages.push({
name: pkg.name,
version: manifest.version ?? null,
sha256: checksum.sha256,
files: checksum.files,
bytes: checksum.bytes,
});
}
return {
name: pack.name,
packVersion: pack.packVersion,
description: pack.description,
tarball: `optional-pack-${pack.name}.tar.gz`,
packages,
};
}
/**
* Verify a directory tree against an index entry (every member checksum).
*
* @returns {Promise<{ok: true} | {ok: false, errors: string[]}>}
*/
export async function verifyAgainstIndexEntry(entry, nodeModulesDir) {
const errors = [];
for (const pkg of entry.packages) {
const pkgDir = path.join(nodeModulesDir, ...pkg.name.split("/"));
if (!fs.existsSync(pkgDir)) {
errors.push(`${pkg.name}: missing`);
continue;
}
const checksum = await dirChecksum(pkgDir);
if (checksum.sha256 !== pkg.sha256) {
errors.push(
`${pkg.name}: checksum mismatch (expected ${pkg.sha256.slice(0, 12)}, got ${checksum.sha256.slice(0, 12)})`
);
}
}
return errors.length === 0 ? { ok: true } : { ok: false, errors };
}